Merge branch 'origin/master' into sync/upstream

Conflicts:
	app/javascript/mastodon/components/status_list.js
	app/javascript/mastodon/features/notifications/index.js
	app/javascript/mastodon/features/ui/components/modal_root.js
	app/javascript/mastodon/features/ui/components/onboarding_modal.js
	app/javascript/mastodon/features/ui/index.js
	app/javascript/styles/about.scss
	app/javascript/styles/accounts.scss
	app/javascript/styles/components.scss
	app/presenters/instance_presenter.rb
	app/services/post_status_service.rb
	app/services/reblog_service.rb
	app/views/about/more.html.haml
	app/views/about/show.html.haml
	app/views/accounts/_header.html.haml
	config/webpack/loaders/babel.js
	spec/controllers/api/v1/accounts/credentials_controller_spec.rb
This commit is contained in:
David Yip
2017-09-09 14:27:47 -05:00
352 changed files with 8629 additions and 2380 deletions

View File

@@ -77,6 +77,10 @@ class Account < ApplicationRecord
has_many :mentions, inverse_of: :account, dependent: :destroy
has_many :notifications, inverse_of: :account, dependent: :destroy
# Pinned statuses
has_many :status_pins, inverse_of: :account, dependent: :destroy
has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
# Media
has_many :media_attachments, dependent: :destroy
@@ -91,7 +95,7 @@ class Account < ApplicationRecord
scope :local, -> { where(domain: nil) }
scope :without_followers, -> { where(followers_count: 0) }
scope :with_followers, -> { where('followers_count > 0') }
scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
scope :partitioned, -> { order('row_number() over (partition by domain)') }
scope :silenced, -> { where(silenced: true) }
scope :suspended, -> { where(suspended: true) }
@@ -105,6 +109,7 @@ class Account < ApplicationRecord
:current_sign_in_ip,
:current_sign_in_at,
:confirmed?,
:admin?,
:locale,
to: :user,
prefix: true,
@@ -133,11 +138,11 @@ class Account < ApplicationRecord
end
def keypair
OpenSSL::PKey::RSA.new(private_key || public_key)
@keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
end
def subscription(webhook_url)
OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url)
@subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
end
def save_with_optional_media!
@@ -171,6 +176,10 @@ class Account < ApplicationRecord
reorder(nil).pluck('distinct accounts.domain')
end
def inboxes
reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
end
def triadic_closures(account, limit: 5, offset: 0)
sql = <<-SQL.squish
WITH first_degree AS (
@@ -263,7 +272,7 @@ class Account < ApplicationRecord
def generate_keys
return unless local?
keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048)
keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048)
self.private_key = keypair.to_pem
self.public_key = keypair.public_key.to_pem
end

View File

@@ -8,7 +8,7 @@ module AccountAvatar
class_methods do
def avatar_styles(file)
styles = { original: '120x120#' }
styles[:static] = { animated: false } if file.content_type == 'image/gif'
styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
styles
end

View File

@@ -8,7 +8,7 @@ module AccountHeader
class_methods do
def header_styles(file)
styles = { original: '700x335#' }
styles[:static] = { animated: false } if file.content_type == 'image/gif'
styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
styles
end

View File

@@ -138,4 +138,8 @@ module AccountInteractions
def reblogged?(status)
status.proper.reblogs.where(account: self).exists?
end
def pinned?(status)
status_pins.where(status: status).exists?
end
end

View File

@@ -10,6 +10,8 @@ module Remotable
alt_method_name = "reset_#{attachment_name}!".to_sym
define_method method_name do |url|
return if url.blank?
begin
parsed_url = Addressable::URI.parse(url).normalize
rescue Addressable::URI::InvalidURIError

View File

@@ -28,4 +28,5 @@ class Import < ApplicationRecord
has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
validates_attachment_content_type :data, content_type: FILE_TYPES
validates_attachment_presence :data
end

View File

@@ -142,9 +142,11 @@ class MediaAttachment < ApplicationRecord
def populate_meta
meta = {}
file.queued_for_write.each do |style, file|
begin
geo = Paperclip::Geometry.from_file file
meta[style] = {
width: geo.width.to_i,
height: geo.height.to_i,
@@ -155,6 +157,7 @@ class MediaAttachment < ApplicationRecord
meta[style] = {}
end
end
meta
end

View File

@@ -4,16 +4,13 @@
# Table name: preview_cards
#
# id :integer not null, primary key
# status_id :integer
# url :string default(""), not null
# title :string
# description :string
# title :string default(""), not null
# description :string default(""), not null
# image_file_name :string
# image_content_type :string
# image_file_size :integer
# image_updated_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# type :integer default("link"), not null
# html :text default(""), not null
# author_name :string default(""), not null
@@ -22,6 +19,8 @@
# provider_url :string default(""), not null
# width :integer default(0), not null
# height :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class PreviewCard < ApplicationRecord
@@ -31,21 +30,37 @@ class PreviewCard < ApplicationRecord
enum type: [:link, :photo, :video, :rich]
belongs_to :status
has_and_belongs_to_many :statuses
has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
has_attached_file :image, styles: { original: '280x120>' }, convert_options: { all: '-quality 80 -strip' }
include Attachmentable
include Remotable
validates :url, presence: true
validates :url, presence: true, uniqueness: true
validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
validates_attachment_size :image, less_than: 1.megabytes
before_save :extract_dimensions, if: :link?
def save_with_optional_image!
save!
rescue ActiveRecord::RecordInvalid
self.image = nil
save!
end
private
def extract_dimensions
file = image.queued_for_write[:original]
return if file.nil?
geo = Paperclip::Geometry.from_file(file)
self.width = geo.width.to_i
self.height = geo.height.to_i
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
nil
end
end

View File

@@ -42,7 +42,7 @@ class RemoteFollow
def acct_resource
@_acct_resource ||= Goldfinger.finger("acct:#{acct}")
rescue Goldfinger::Error
rescue Goldfinger::Error, HTTP::ConnectionError
nil
end

View File

@@ -25,6 +25,7 @@
#
class SessionActivation < ApplicationRecord
belongs_to :user, inverse_of: :session_activations, required: true
belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy

View File

@@ -47,10 +47,12 @@ class Status < ApplicationRecord
has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
has_many :mentions, dependent: :destroy
has_many :media_attachments, dependent: :destroy
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
has_one :notification, as: :activity, dependent: :destroy
has_one :preview_card, dependent: :destroy
has_one :stream_entry, as: :activity, inverse_of: :status
validates :uri, uniqueness: true, unless: :local?
validates :text, presence: true, unless: :reblog?
@@ -90,7 +92,11 @@ class Status < ApplicationRecord
end
def verb
reblog? ? :share : :post
if destroyed?
:delete
else
reblog? ? :share : :post
end
end
def object_type
@@ -110,7 +116,11 @@ class Status < ApplicationRecord
end
def title
reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
if destroyed?
"#{account.acct} deleted status"
else
reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
end
end
def hidden?
@@ -164,6 +174,10 @@ class Status < ApplicationRecord
ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h
end
def pins_map(status_ids, account_id)
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h
end
def reload_stale_associations!(cached_items)
account_ids = []

18
app/models/status_pin.rb Normal file
View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: status_pins
#
# id :integer not null, primary key
# account_id :integer not null
# status_id :integer not null
# created_at :datetime not null
# updated_at :datetime not null
#
class StatusPin < ApplicationRecord
belongs_to :account, required: true
belongs_to :status, required: true
validates_with StatusPinValidator
end

View File

@@ -26,6 +26,7 @@ class Subscription < ApplicationRecord
scope :confirmed, -> { where(confirmed: true) }
scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) }
scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) }
scope :active, -> { confirmed.future_expiration }
def lease_seconds=(value)

View File

@@ -46,6 +46,8 @@ class User < ApplicationRecord
belongs_to :account, inverse_of: :user, required: true
accepts_nested_attributes_for :account
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
validates_with BlacklistedEmailValidator, if: :email_changed?
@@ -108,10 +110,21 @@ class User < ApplicationRecord
settings.noindex
end
def token_for_app(a)
return nil if a.nil? || a.owner != self
Doorkeeper::AccessToken
.find_or_create_by(application_id: a.id, resource_owner_id: id) do |t|
t.scopes = a.scopes
t.expires_in = Doorkeeper.configuration.access_token_expires_in
t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled?
end
end
def activate_session(request)
session_activations.activate(session_id: SecureRandom.hex,
user_agent: request.user_agent,
ip: request.ip).session_id
ip: request.remote_ip).session_id
end
def exclusive_session(id)

View File

@@ -13,59 +13,14 @@
#
require 'webpush'
require_relative '../../models/setting'
class Web::PushSubscription < ApplicationRecord
include RoutingHelper
include StreamEntriesHelper
include ActionView::Helpers::TranslationHelper
include ActionView::Helpers::SanitizeHelper
has_one :session_activation
before_create :send_welcome_notification
def push(notification)
name = display_name notification.from_account
title = title_str(name, notification)
body = body_str notification
dir = dir_str body
url = url_str notification
image = image_str notification
actions = actions_arr notification
access_token = actions.empty? ? nil : find_or_create_access_token(notification).token
nsfw = notification.target_status.nil? || notification.target_status.spoiler_text.empty? ? nil : notification.target_status.spoiler_text
# TODO: Make sure that the payload does not exceed 4KB - Webpush::PayloadTooLarge
Webpush.payload_send(
message: JSON.generate(
title: title,
dir: dir,
image: image,
badge: full_asset_url('badge.png', skip_pipeline: true),
tag: notification.id,
timestamp: notification.created_at,
icon: notification.from_account.avatar_static_url,
data: {
content: decoder.decode(strip_tags(body)),
nsfw: nsfw.nil? ? nil : decoder.decode(strip_tags(nsfw)),
url: url,
actions: actions,
access_token: access_token,
message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
}
),
endpoint: endpoint,
p256dh: key_p256dh,
auth: key_auth,
vapid: {
subject: "mailto:#{Setting.site_contact_email}",
private_key: Rails.configuration.x.vapid_private_key,
public_key: Rails.configuration.x.vapid_public_key,
},
ttl: 40 * 60 * 60 # 48 hours
)
I18n.with_locale(session_activation.user.locale || I18n.default_locale) do
push_payload(message_from(notification), 48.hours.seconds)
end
end
def pushable?(notification)
@@ -73,120 +28,47 @@ class Web::PushSubscription < ApplicationRecord
end
def as_payload
payload = {
id: id,
endpoint: endpoint,
}
payload = { id: id, endpoint: endpoint }
payload[:alerts] = data['alerts'] if data && data.key?('alerts')
payload
end
def access_token
find_or_create_access_token.token
end
private
def title_str(name, notification)
case notification.type
when :mention then translate('push_notifications.mention.title', name: name)
when :follow then translate('push_notifications.follow.title', name: name)
when :favourite then translate('push_notifications.favourite.title', name: name)
when :reblog then translate('push_notifications.reblog.title', name: name)
end
end
def push_payload(message, ttl = 5.minutes.seconds)
# TODO: Make sure that the payload does not
# exceed 4KB - Webpush::PayloadTooLarge
def body_str(notification)
case notification.type
when :mention then notification.target_status.text
when :follow then notification.from_account.note
when :favourite then notification.target_status.text
when :reblog then notification.target_status.text
end
end
def url_str(notification)
case notification.type
when :mention then web_url("statuses/#{notification.target_status.id}")
when :follow then web_url("accounts/#{notification.from_account.id}")
when :favourite then web_url("statuses/#{notification.target_status.id}")
when :reblog then web_url("statuses/#{notification.target_status.id}")
end
end
def actions_arr(notification)
actions =
case notification.type
when :mention then [
{
title: translate('push_notifications.mention.action_favourite'),
icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true),
todo: 'request',
method: 'POST',
action: "/api/v1/statuses/#{notification.target_status.id}/favourite",
},
]
else []
end
should_hide = notification.type.equal?(:mention) && !notification.target_status.nil? && (notification.target_status.sensitive || !notification.target_status.spoiler_text.empty?)
can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden?
if should_hide
actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true), todo: 'expand', action: 'expand')
end
if can_boost
actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" }
end
actions
end
def image_str(notification)
return nil if notification.target_status.nil? || notification.target_status.media_attachments.empty?
full_asset_url(notification.target_status.media_attachments.first.file.url(:small))
end
def dir_str(body)
rtl?(body) ? 'rtl' : 'ltr'
end
def send_welcome_notification
Webpush.payload_send(
message: JSON.generate(
title: translate('push_notifications.subscribed.title'),
icon: full_asset_url('android-chrome-192x192.png', skip_pipeline: true),
badge: full_asset_url('badge.png', skip_pipeline: true),
data: {
content: translate('push_notifications.subscribed.body'),
actions: [],
url: web_url('notifications'),
message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
}
),
message: Oj.dump(message),
endpoint: endpoint,
p256dh: key_p256dh,
auth: key_auth,
ttl: ttl,
vapid: {
subject: "mailto:#{Setting.site_contact_email}",
subject: "mailto:#{::Setting.site_contact_email}",
private_key: Rails.configuration.x.vapid_private_key,
public_key: Rails.configuration.x.vapid_public_key,
},
ttl: 5 * 60 # 5 minutes
}
)
end
def find_or_create_access_token(notification)
def message_from(notification)
serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription)
serializable_resource.as_json
end
def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(
Doorkeeper::Application.find_by(superapp: true),
notification.account.user.id,
session_activation.user_id,
Doorkeeper::OAuth::Scopes.from_string('read write follow'),
Doorkeeper.configuration.access_token_expires_in,
Doorkeeper.configuration.refresh_token_enabled?
)
end
def decoder
@decoder ||= HTMLEntities.new
end
end