Commit a6f2907a authored by xxxkurosukexxx's avatar xxxkurosukexxx

Merge branch 'follow_master' into mstdn.kurosuke.org

parents eee74568 8e90e7ab
......@@ -5,7 +5,7 @@ ruby '>= 2.4.0', '< 2.7.0'
gem 'pkg-config', '~> 1.3'
gem 'puma', '~> 4.0'
gem 'puma', '~> 4.1'
gem 'rails', '~> 5.2.3'
gem 'thor', '~> 0.20'
......@@ -32,7 +32,7 @@ gem 'iso-639'
gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.4'
gem 'devise', '~> 4.6'
gem 'devise-two-factor', '~> 3.0'
gem 'devise-two-factor', '~> 3.1'
group :pam_authentication, optional: true do
gem 'devise_pam_authenticatable2', '~> 9.2'
......
......@@ -194,11 +194,11 @@ GEM
railties (>= 4.1.0, < 6.0)
responders
warden (~> 1.2.3)
devise-two-factor (3.0.3)
activesupport (< 5.3)
devise-two-factor (3.1.0)
activesupport (< 6.1)
attr_encrypted (>= 1.3, < 4, != 2)
devise (~> 4.0)
railties (< 5.3)
railties (< 6.1)
rotp (~> 2.0)
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
......@@ -229,8 +229,8 @@ GEM
tzinfo
excon (0.62.0)
fabrication (2.20.2)
faker (2.1.0)
i18n (>= 0.7)
faker (2.1.2)
i18n (>= 0.8)
faraday (0.15.0)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0)
......@@ -375,8 +375,8 @@ GEM
net-scp (1.2.1)
net-ssh (>= 2.6.5)
net-ssh (5.0.2)
nio4r (2.3.1)
nokogiri (1.10.3)
nio4r (2.4.0)
nokogiri (1.10.4)
mini_portile2 (~> 2.4.0)
nokogumbo (2.0.0)
nokogiri (~> 1.8, >= 1.8.4)
......@@ -412,7 +412,7 @@ GEM
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.17.0)
parallel_tests (2.29.1)
parallel_tests (2.29.2)
parallel
parser (2.6.3.0)
ast (~> 2.4.0)
......@@ -441,7 +441,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (3.1.1)
puma (4.0.1)
puma (4.1.0)
nio4r (~> 2.0)
pundit (2.0.1)
activesupport (>= 3.0.0)
......@@ -476,7 +476,7 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.1.0)
rails-html-sanitizer (1.2.0)
loofah (~> 2.2, >= 2.2.2)
rails-i18n (5.1.3)
i18n (>= 0.7, < 2)
......@@ -518,9 +518,9 @@ GEM
regexp_parser (1.6.0)
request_store (1.4.1)
rack (>= 1.4)
responders (2.4.1)
actionpack (>= 4.2.0, < 6.0)
railties (>= 4.2.0, < 6.0)
responders (3.0.0)
actionpack (>= 5.0)
railties (>= 5.0)
rotp (2.1.2)
rpam2 (4.0.2)
rqrcode (0.10.1)
......@@ -690,7 +690,7 @@ DEPENDENCIES
connection_pool
derailed_benchmarks
devise (~> 4.6)
devise-two-factor (~> 3.0)
devise-two-factor (~> 3.1)
devise_pam_authenticatable2 (~> 9.2)
doorkeeper (~> 5.1)
dotenv-rails (~> 2.7)
......@@ -746,7 +746,7 @@ DEPENDENCIES
private_address_check (~> 0.5)
pry-byebug (~> 3.7)
pry-rails (~> 0.3)
puma (~> 4.0)
puma (~> 4.1)
pundit (~> 2.0)
rack-attack (~> 6.1)
rack-cors (~> 1.0)
......
......@@ -9,6 +9,8 @@ class AccountsController < ApplicationController
before_action :set_cache_headers
before_action :set_body_classes
skip_around_action :set_locale, if: -> { request.format == :json }
def show
respond_to do |format|
format.html do
......
......@@ -5,6 +5,7 @@ module Admin
class DashboardController < BaseController
def index
@users_count = User.count
@pending_users_count = User.pending.count
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0
@logins_week = Redis.current.pfcount("activity:logins:#{current_week}")
@interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0
......@@ -19,7 +20,7 @@ module Admin
@redis_version = redis_info['redis_version']
@reports_count = Report.unresolved.count
@queue_backlog = Sidekiq::Stats.new.enqueued
@recent_users = User.confirmed.recent.includes(:account).limit(4)
@recent_users = User.confirmed.recent.includes(:account).limit(8)
@database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
@redis_size = redis_info['used_memory']
@ldap_enabled = ENV['LDAP_ENABLED'] == 'true'
......@@ -28,6 +29,7 @@ module Admin
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
@trending_hashtags = TrendingTags.get(10, filtered: false)
@pending_tags_count = Tag.pending_review.count
@authorized_fetch = authorized_fetch_mode?
@whitelist_enabled = whitelist_mode?
@profile_directory = Setting.profile_directory
......
......@@ -2,13 +2,17 @@
module Admin
class DomainBlocksController < BaseController
before_action :set_domain_block, only: [:show, :destroy]
before_action :set_domain_block, only: [:show, :destroy, :edit, :update]
def new
authorize :domain_block, :create?
@domain_block = DomainBlock.new(domain: params[:_domain])
end
def edit
authorize :domain_block, :create?
end
def create
authorize :domain_block, :create?
......@@ -35,6 +39,22 @@ module Admin
end
end
def update
authorize :domain_block, :create?
@domain_block.update(update_params)
severity_changed = @domain_block.severity_changed?
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
render :edit
end
end
def show
authorize @domain_block, :show?
end
......@@ -52,8 +72,12 @@ module Admin
@domain_block = DomainBlock.find(params[:id])
end
def update_params
params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment)
end
def resource_params
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports)
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment)
end
end
end
......@@ -21,6 +21,8 @@ module Admin
@blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count
@available = DeliveryFailureTracker.available?(Account.select(:shared_inbox_url).where(domain: params[:id]).first&.shared_inbox_url)
@media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size)
@private_comment = @domain_block&.private_comment
@public_comment = @domain_block&.public_comment
end
private
......
......@@ -4,6 +4,8 @@ module Admin
class TagsController < BaseController
before_action :set_tags, only: :index
before_action :set_tag, except: :index
before_action :set_usage_by_domain, except: :index
before_action :set_counters, except: :index
def index
authorize :tag, :index?
......@@ -17,7 +19,7 @@ module Admin
authorize @tag, :update?
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
redirect_to admin_tag_path(@tag.id)
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
else
render :show
end
......@@ -33,12 +35,28 @@ module Admin
@tag = Tag.find(params[:id])
end
def set_usage_by_domain
@usage_by_domain = @tag.statuses
.where(visibility: :public)
.where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
.joins(:account)
.group('accounts.domain')
.reorder('statuses_count desc')
.pluck('accounts.domain, count(*) AS statuses_count')
end
def set_counters
@accounts_today = @tag.history.first[:accounts]
@accounts_week = Redis.current.pfcount(*current_week_days.map { |day| "activity:tags:#{@tag.id}:#{day}:accounts" })
end
def filtered_tags
scope = Tag
scope = scope.discoverable if filter_params[:context] == 'directory'
scope = scope.reviewed if filter_params[:review] == 'reviewed'
scope = scope.pending_review if filter_params[:review] == 'pending_review'
scope.reorder(score: :desc)
scope = scope.unreviewed if filter_params[:review] == 'unreviewed'
scope = scope.reviewed.order(reviewed_at: :desc) if filter_params[:review] == 'reviewed'
scope = scope.pending_review.order(requested_review_at: :desc) if filter_params[:review] == 'pending_review'
scope.order(score: :desc)
end
def filter_params
......@@ -48,5 +66,13 @@ module Admin
def tag_params
params.require(:tag).permit(:name, :trendable, :usable, :listable)
end
def current_week_days
now = Time.now.utc.beginning_of_day.to_date
(Date.commercial(now.cwyear, now.cweek)..now).map do |date|
date.to_time(:utc).beginning_of_day.to_i
end
end
end
end
......@@ -14,6 +14,8 @@ class Api::BaseController < ApplicationController
protect_from_forgery with: :null_session
skip_around_action :set_locale
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: e.to_s }, status: 422
end
......
......@@ -3,7 +3,8 @@
class Api::V1::Accounts::StatusesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_account
after_action :insert_pagination_headers
after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
respond_to :json
......
......@@ -25,7 +25,7 @@ class DirectoriesController < ApplicationController
end
def set_tag
@tag = Tag.discoverable.find_by!(name: params[:id].downcase)
@tag = Tag.discoverable.find_normalized!(params[:id])
end
def set_tags
......
......@@ -7,6 +7,8 @@ class FollowerAccountsController < ApplicationController
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers
skip_around_action :set_locale, if: -> { request.format == :json }
def index
respond_to do |format|
format.html do
......
......@@ -7,6 +7,8 @@ class FollowingAccountsController < ApplicationController
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers
skip_around_action :set_locale, if: -> { request.format == :json }
def index
respond_to do |format|
format.html do
......
......@@ -39,7 +39,6 @@ class RemoteInteractionController < ApplicationController
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
# Reraise in order to get a 404
raise ActiveRecord::RecordNotFound
end
......
......@@ -18,6 +18,8 @@ class StatusesController < ApplicationController
before_action :set_body_classes
before_action :set_autoplay, only: :embed
skip_around_action :set_locale, if: -> { request.format == :json }
content_security_policy only: :embed do |p|
p.frame_ancestors(false)
end
......
......@@ -47,7 +47,7 @@ class TagsController < ApplicationController
private
def set_tag
@tag = Tag.find_normalized!(params[:id])
@tag = Tag.usable.find_normalized!(params[:id])
end
def set_body_classes
......
......@@ -12,6 +12,7 @@ export default class Button extends React.PureComponent {
secondary: PropTypes.bool,
size: PropTypes.number,
className: PropTypes.string,
title: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node,
};
......@@ -54,6 +55,7 @@ export default class Button extends React.PureComponent {
onClick={this.handleClick}
ref={this.setRef}
style={style}
title={this.props.title}
>
{this.props.text || this.props.children}
</button>
......
......@@ -45,7 +45,6 @@ class DropdownMenu extends React.PureComponent {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
this.activeElement = document.activeElement;
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus();
}
......@@ -56,9 +55,6 @@ class DropdownMenu extends React.PureComponent {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('keydown', this.handleKeyDown, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.activeElement) {
this.activeElement.focus();
}
}
setRef = c => {
......@@ -117,7 +113,7 @@ class DropdownMenu extends React.PureComponent {
}
}
handleItemKeyUp = e => {
handleItemKeyPress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
......@@ -147,7 +143,7 @@ class DropdownMenu extends React.PureComponent {
return (
<li className='dropdown-menu__item' key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyUp={this.handleItemKeyUp} data-index={i}>
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
{text}
</a>
</li>
......@@ -214,15 +210,44 @@ export default class Dropdown extends React.PureComponent {
} else {
const { top } = target.getBoundingClientRect();
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
}
}
handleClose = () => {
if (this.activeElement) {
this.activeElement.focus();
this.activeElement = null;
}
this.props.onClose(this.state.id);
}
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
}
handleKeyPress = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
}
handleItemClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
......@@ -266,6 +291,9 @@ export default class Dropdown extends React.PureComponent {
size={size}
ref={this.setTargetRef}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
/>
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
......
......@@ -14,6 +14,7 @@ export default class IconButton extends React.PureComponent {
onClick: PropTypes.func,
onMouseDown: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
......@@ -44,6 +45,12 @@ export default class IconButton extends React.PureComponent {
}
}
handleKeyPress = (e) => {
if (this.props.onKeyPress && !this.props.disabled) {
this.props.onKeyPress(e);
}
}
handleMouseDown = (e) => {
if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e);
......@@ -100,6 +107,7 @@ export default class IconButton extends React.PureComponent {
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
style={style}
tabIndex={tabIndex}
disabled={disabled}
......@@ -121,6 +129,7 @@ export default class IconButton extends React.PureComponent {
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
style={style}
tabIndex={tabIndex}
disabled={disabled}
......
......@@ -112,7 +112,7 @@ export default class StatusContent extends React.PureComponent {
}
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
hashtag = hashtag.replace(/^#/, '');
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
......@@ -166,11 +166,6 @@ export default class StatusContent extends React.PureComponent {
}
}
handleCollapsedClick = (e) => {
e.preventDefault();
this.setState({ collapsed: !this.state.collapsed });
}
setRef = (c) => {
this.node = c;
}
......@@ -234,15 +229,19 @@ export default class StatusContent extends React.PureComponent {
</div>
);
} else if (this.props.onClick) {
return (
const output = [
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
{!!this.state.collapsed && readMoreButton}
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
</div>
);
</div>,
];
if (this.state.collapsed) {
output.push(readMoreButton);
}
return output;
} else {
return (
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
......
......@@ -15,6 +15,7 @@ import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
......@@ -148,7 +149,7 @@ class Header extends ImmutablePureComponent {
if (!account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
} else if (account.getIn(['relationship', 'blocking'])) {
......
......@@ -324,7 +324,8 @@
&.active h4 {
&,
.fa,
small {
small,
.trends__item__current {
color: $primary-text-color;
}
}
......@@ -337,6 +338,10 @@
&.active .avatar-stack .account__avatar {
border-color: $ui-highlight-color;
}
.trends__item__current {
padding-right: 0;
}
}
}
......
......@@ -294,7 +294,7 @@ class Formatter
end
def hashtag_html(tag)
"<a href=\"#{encode(tag_url(tag.downcase))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>"
"<a href=\"#{encode(tag_url(tag))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>"
end
def mention_html(account)
......
......@@ -227,17 +227,7 @@ class Account < ApplicationRecord
end
def tags_as_strings=(tag_names)
tag_names.map! { |name| name.mb_chars.downcase.to_s }
tag_names.uniq!
# Existing hashtags
hashtags_map = Tag.where(name: tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag }
# Initialize not yet existing hashtags
tag_names.each do |name|
next if hashtags_map.key?(name)
hashtags_map[name] = Tag.new(name: name)
end
hashtags_map = Tag.find_or_create_by_names(tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag }
# Remove hashtags that are to be deleted
tags.each do |tag|
......@@ -516,7 +506,7 @@ class Account < ApplicationRecord
end
def emojifiable_text
[note, display_name, fields.map(&:value)].join(' ')
[note, display_name, fields.map(&:name), fields.map(&:value)].join(' ')
end
def clean_feed_manager
......
......@@ -15,7 +15,7 @@ class AccountDomainBlock < ApplicationRecord
include DomainNormalizable
belongs_to :account
validates :domain, presence: true, uniqueness: { scope: :account_id }
validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
after_commit :remove_blocking_cache
after_commit :remove_relationship_cache
......
......@@ -4,7 +4,7 @@ module DomainNormalizable
extend ActiveSupport::Concern
included do
before_validation :normalize_domain
before_save :normalize_domain
end
private
......
......@@ -28,6 +28,8 @@ class CustomEmoji < ApplicationRecord
:(#{SHORTCODE_RE_FRAGMENT}):
(?=[^[:alnum:]:]|$)/x
IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
......@@ -35,7 +37,7 @@ class CustomEmoji < ApplicationRecord
before_validation :downcase_domain
validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { less_than: LIMIT }
validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT }
validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
scope :local, -> { where(domain: nil) }
......
......@@ -13,7 +13,7 @@
class DomainAllow < ApplicationRecord
include DomainNormalizable
validates :domain, presence: true, uniqueness: true
validates :domain, presence: true, uniqueness: true, domain: true
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
......
......@@ -3,13 +3,15 @@
#
# Table name: domain_blocks
#
# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# severity :integer default("silence")
# reject_media :boolean default(FALSE), not null
# reject_reports :boolean default(FALSE), not null
# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# severity :integer default("silence")
# reject_media :boolean default(FALSE), not null
# reject_reports :boolean default(FALSE), not null
# private_comment :text
# public_comment :text
#
class DomainBlock < ApplicationRecord
......@@ -17,7 +19,7 @@ class DomainBlock < ApplicationRecord
enum severity: [:silence, :suspend, :noop]
validates :domain, presence: true, uniqueness: true
validates :domain, presence: true, uniqueness: true, domain: true
has_many :accounts, foreign_key: :domain, primary_key: :domain
delegate :count, to: :accounts, prefix: true
......