Merge branch 'main' into glitch-soc/merge-upstream

Conflicts:
- `app/controllers/settings/preferences_controller.rb`:
  Upstream dropping `digest` from notifications emails while we have more
  notification emails settings.
  Removed `digest` from our list while keeping our extra settings.
- `app/javascript/packs/admin.js`:
  Conflicts caused by glitch-soc's theming system.
  Applied the changes to `app/javascript/core/admin.js`.
- `app/views/settings/preferences/other/show.html.haml`:
  Upstream removed a setting close to a glitch-soc-only setting.
  Applied upstream's change.
main
Claire 2 years ago
commit 077183a121

@ -75,8 +75,8 @@ GEM
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
zeitwerk (~> 2.3) zeitwerk (~> 2.3)
addressable (2.8.0) addressable (2.8.1)
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 6.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
airbrussh (1.4.1) airbrussh (1.4.1)
sshkit (>= 1.6.1, != 1.7.0) sshkit (>= 1.6.1, != 1.7.0)
@ -424,7 +424,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5) sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0) statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.13.20) oj (3.13.21)
omniauth (1.9.2) omniauth (1.9.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 1.6.2, < 3) rack (>= 1.6.2, < 3)
@ -480,8 +480,8 @@ GEM
pry (>= 0.13, < 0.15) pry (>= 0.13, < 0.15)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.7) public_suffix (5.0.0)
puma (5.6.4) puma (5.6.5)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)

@ -16,7 +16,11 @@ module Admin
def batch def batch
authorize :account, :index? authorize :account, :index?
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button)) @form = Form::AccountBatch.new(form_account_batch_params)
@form.current_account = current_account
@form.action = action_from_button
@form.select_all_matching = params[:select_all_matching]
@form.query = filtered_accounts
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.accounts.no_account_selected')

@ -23,6 +23,7 @@ module Admin
@role.current_account = current_account @role.current_account = current_account
if @role.save if @role.save
log_action :create, @role
redirect_to admin_roles_path redirect_to admin_roles_path
else else
render :new render :new
@ -39,6 +40,7 @@ module Admin
@role.current_account = current_account @role.current_account = current_account
if @role.update(resource_params) if @role.update(resource_params)
log_action :update, @role
redirect_to admin_roles_path redirect_to admin_roles_path
else else
render :edit render :edit
@ -48,6 +50,7 @@ module Admin
def destroy def destroy
authorize @role, :destroy? authorize @role, :destroy?
@role.destroy! @role.destroy!
log_action :destroy, @role
redirect_to admin_roles_path redirect_to admin_roles_path
end end

@ -14,6 +14,7 @@ module Admin
@user.current_account = current_account @user.current_account = current_account
if @user.update(resource_params) if @user.update(resource_params)
log_action :change_role, @user
redirect_to admin_account_path(@user.account_id), notice: I18n.t('admin.accounts.change_role.changed_msg') redirect_to admin_account_path(@user.account_id), notice: I18n.t('admin.accounts.change_role.changed_msg')
else else
render :show render :show

@ -0,0 +1,99 @@
# frozen_string_literal: true
class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController
include Authorization
include AccountableConcern
LIMIT = 100
before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:canonical_email_blocks' }, only: [:index, :show, :test]
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:canonical_email_blocks' }, except: [:index, :show, :test]
before_action :set_canonical_email_blocks, only: :index
before_action :set_canonical_email_blocks_from_test, only: [:test]
before_action :set_canonical_email_block, only: [:show, :destroy]
after_action :verify_authorized
after_action :insert_pagination_headers, only: :index
PAGINATION_PARAMS = %i(limit).freeze
def index
authorize :canonical_email_block, :index?
render json: @canonical_email_blocks, each_serializer: REST::Admin::CanonicalEmailBlockSerializer
end
def show
authorize @canonical_email_block, :show?
render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer
end
def test
authorize :canonical_email_block, :test?
render json: @canonical_email_blocks, each_serializer: REST::Admin::CanonicalEmailBlockSerializer
end
def create
authorize :canonical_email_block, :create?
@canonical_email_block = CanonicalEmailBlock.create!(resource_params)
log_action :create, @canonical_email_block
render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer
end
def destroy
authorize @canonical_email_block, :destroy?
@canonical_email_block.destroy!
log_action :destroy, @canonical_email_block
render json: @canonical_email_block, serializer: REST::Admin::CanonicalEmailBlockSerializer
end
private
def resource_params
params.permit(:canonical_email_hash, :email)
end
def set_canonical_email_blocks
@canonical_email_blocks = CanonicalEmailBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def set_canonical_email_blocks_from_test
@canonical_email_blocks = CanonicalEmailBlock.matching_email(params[:email])
end
def set_canonical_email_block
@canonical_email_block = CanonicalEmailBlock.find(params[:id])
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_admin_canonical_email_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty?
end
def pagination_max_id
@canonical_email_blocks.last.id
end
def pagination_since_id
@canonical_email_blocks.first.id
end
def records_continue?
@canonical_email_blocks.size == limit_param(LIMIT)
end
def pagination_params(core_params)
params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params)
end
end

@ -0,0 +1,90 @@
# frozen_string_literal: true
class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController
include Authorization
include AccountableConcern
LIMIT = 100
before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:email_domain_blocks' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:email_domain_blocks' }, except: [:index, :show]
before_action :set_email_domain_blocks, only: :index
before_action :set_email_domain_block, only: [:show, :destroy]
after_action :verify_authorized
after_action :insert_pagination_headers, only: :index
PAGINATION_PARAMS = %i(
limit
).freeze
def create
authorize :email_domain_block, :create?
@email_domain_block = EmailDomainBlock.create!(resource_params)
log_action :create, @email_domain_block
render json: @email_domain_block, serializer: REST::Admin::EmailDomainBlockSerializer
end
def index
authorize :email_domain_block, :index?
render json: @email_domain_blocks, each_serializer: REST::Admin::EmailDomainBlockSerializer
end
def show
authorize @email_domain_block, :show?
render json: @email_domain_block, serializer: REST::Admin::EmailDomainBlockSerializer
end
def destroy
authorize @email_domain_block, :destroy?
@email_domain_block.destroy!
log_action :destroy, @email_domain_block
render json: @email_domain_block, serializer: REST::Admin::EmailDomainBlockSerializer
end
private
def set_email_domain_blocks
@email_domain_blocks = EmailDomainBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def set_email_domain_block
@email_domain_block = EmailDomainBlock.find(params[:id])
end
def resource_params
params.permit(:domain)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_admin_email_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty?
end
def pagination_max_id
@email_domain_blocks.last.id
end
def pagination_since_id
@email_domain_blocks.first.id
end
def records_continue?
@email_domain_blocks.size == limit_param(LIMIT)
end
def pagination_params(core_params)
params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params)
end
end

@ -0,0 +1,99 @@
# frozen_string_literal: true
class Api::V1::Admin::IpBlocksController < Api::BaseController
include Authorization
include AccountableConcern
LIMIT = 100
before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:ip_blocks' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:ip_blocks' }, except: [:index, :show]
before_action :set_ip_blocks, only: :index
before_action :set_ip_block, only: [:show, :update, :destroy]
after_action :verify_authorized
after_action :insert_pagination_headers, only: :index
PAGINATION_PARAMS = %i(
limit
).freeze
def create
authorize :ip_block, :create?
@ip_block = IpBlock.create!(resource_params)
log_action :create, @ip_block
render json: @ip_block, serializer: REST::Admin::IpBlockSerializer
end
def index
authorize :ip_block, :index?
render json: @ip_blocks, each_serializer: REST::Admin::IpBlockSerializer
end
def show
authorize @ip_block, :show?
render json: @ip_block, serializer: REST::Admin::IpBlockSerializer
end
def update
authorize @ip_block, :update?
@ip_block.update(resource_params)
log_action :update, @ip_block
render json: @ip_block, serializer: REST::Admin::IpBlockSerializer
end
def destroy
authorize @ip_block, :destroy?
@ip_block.destroy!
log_action :destroy, @ip_block
render json: @ip_block, serializer: REST::Admin::IpBlockSerializer
end
private
def set_ip_blocks
@ip_blocks = IpBlock.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def set_ip_block
@ip_block = IpBlock.find(params[:id])
end
def resource_params
params.permit(:ip, :severity, :comment, :expires_in)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_admin_ip_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty?
end
def pagination_max_id
@ip_blocks.last.id
end
def pagination_since_id
@ip_blocks.first.id
end
def records_continue?
@ip_blocks.size == limit_param(LIMIT)
end
def pagination_params(core_params)
params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params)
end
end

@ -3,7 +3,11 @@
module AccountableConcern module AccountableConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
def log_action(action, target, options = {}) def log_action(action, target)
Admin::ActionLog.create(account: current_account, action: action, target: target, recorded_changes: options.stringify_keys) Admin::ActionLog.create(
account: current_account,
action: action,
target: target
)
end end
end end

@ -58,7 +58,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_trends, :setting_trends,
:setting_crop_images, :setting_crop_images,
:setting_always_send_emails, :setting_always_send_emails,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag trending_link trending_status appeal), notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag trending_link trending_status appeal),
interactions: %i(must_be_follower must_be_following must_be_following_dm) interactions: %i(must_be_follower must_be_following must_be_following_dm)
) )
end end

@ -2,64 +2,29 @@
module Admin::ActionLogsHelper module Admin::ActionLogsHelper
def log_target(log) def log_target(log)
if log.target case log.target_type
linkable_log_target(log.target)
else
log_target_from_history(log.target_type, log.recorded_changes)
end
end
private
def linkable_log_target(record)
case record.class.name
when 'Account' when 'Account'
link_to record.acct, admin_account_path(record.id) link_to log.human_identifier, admin_account_path(log.target_id)
when 'User' when 'User'
link_to record.account.acct, admin_account_path(record.account_id) link_to log.human_identifier, admin_account_path(log.route_param)
when 'CustomEmoji' when 'UserRole'
record.shortcode link_to log.human_identifier, admin_roles_path(log.target_id)
when 'Report' when 'Report'
link_to "##{record.id}", admin_report_path(record) link_to "##{log.human_identifier}", admin_report_path(log.target_id)
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain' when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'
link_to record.domain, "https://#{record.domain}" link_to log.human_identifier, "https://#{log.human_identifier}"
when 'Status' when 'Status'
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record) link_to log.human_identifier, log.permalink
when 'AccountWarning' when 'AccountWarning'
link_to record.target_account.acct, admin_account_path(record.target_account_id) link_to log.human_identifier, admin_account_path(log.target_id)
when 'Announcement' when 'Announcement'
link_to truncate(record.text), edit_admin_announcement_path(record.id) link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id)
when 'IpBlock' when 'IpBlock', 'Instance', 'CustomEmoji'
"#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})" log.human_identifier
when 'Instance' when 'CanonicalEmailBlock'
record.domain content_tag(:samp, log.human_identifier[0...7], title: log.human_identifier)
when 'Appeal' when 'Appeal'
link_to record.account.acct, disputes_strike_path(record.strike) link_to log.human_identifier, disputes_strike_path(log.route_param)
end
end
def log_target_from_history(type, attributes)
case type
when 'User'
attributes['username']
when 'CustomEmoji'
attributes['shortcode']
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'
link_to attributes['domain'], "https://#{attributes['domain']}"
when 'Status'
tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))
if tmp_status.account
link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id)
else
I18n.t('admin.action_logs.deleted_status')
end
when 'Announcement'
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
when 'IpBlock'
"#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
when 'Instance'
attributes['domain']
end end
end end
end end

@ -6,18 +6,71 @@ import ready from '../mastodon/ready';
const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
const showSelectAll = () => {
const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
selectAllMatchingElement.classList.add('active');
};
const hideSelectAll = () => {
const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
const hiddenField = document.querySelector('#select_all_matching');
const selectedMsg = document.querySelector('.batch-table__select-all .selected');
const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
selectAllMatchingElement.classList.remove('active');
selectedMsg.classList.remove('active');
notSelectedMsg.classList.add('active');
hiddenField.value = '0';
};
delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
[].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => { [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => {
content.checked = target.checked; content.checked = target.checked;
}); });
if (selectAllMatchingElement) {
if (target.checked) {
showSelectAll();
} else {
hideSelectAll();
}
}
});
delegate(document, '.batch-table__select-all button', 'click', () => {
const hiddenField = document.querySelector('#select_all_matching');
const active = hiddenField.value === '1';
const selectedMsg = document.querySelector('.batch-table__select-all .selected');
const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
if (active) {
hiddenField.value = '0';
selectedMsg.classList.remove('active');
notSelectedMsg.classList.add('active');
} else {
hiddenField.value = '1';
notSelectedMsg.classList.remove('active');
selectedMsg.classList.add('active');
}
}); });
delegate(document, batchCheckboxClassName, 'change', () => { delegate(document, batchCheckboxClassName, 'change', () => {
const checkAllElement = document.querySelector('#batch_checkbox_all'); const checkAllElement = document.querySelector('#batch_checkbox_all');
const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
if (checkAllElement) { if (checkAllElement) {
checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
if (selectAllMatchingElement) {
if (checkAllElement.checked) {
showSelectAll();
} else {
hideSelectAll();
}
}
} }
}); });

@ -6,7 +6,7 @@ const TimelineHint = ({ resource, url }) => (
<div className='timeline-hint'> <div className='timeline-hint'>
<strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong> <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong>
<br /> <br />
<a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a> <a href={url} target='_blank' rel='noopener'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a>
</div> </div>
); );

@ -1,9 +1,9 @@
import * as registerPushNotifications from './actions/push_notifications';
import { setupBrowserNotifications } from './actions/notifications';
import { default as Mastodon, store } from './containers/mastodon';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import ready from './ready'; import * as registerPushNotifications from 'mastodon/actions/push_notifications';
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
import Mastodon, { store } from 'mastodon/containers/mastodon';
import ready from 'mastodon/ready';
const perf = require('./performance'); const perf = require('./performance');
@ -24,10 +24,20 @@ function main() {
ReactDOM.render(<Mastodon {...props} />, mountNode); ReactDOM.render(<Mastodon {...props} />, mountNode);
store.dispatch(setupBrowserNotifications()); store.dispatch(setupBrowserNotifications());
if (process.env.NODE_ENV === 'production') {
// avoid offline in dev mode because it's harder to debug if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
require('offline-plugin/runtime').install(); import('workbox-window')
store.dispatch(registerPushNotifications.register()); .then(({ Workbox }) => {
const wb = new Workbox('/sw.js');
return wb.register();
})
.then(() => {
store.dispatch(registerPushNotifications.register());
})
.catch(err => {
console.error(err);
});
} }
perf.stop('main()'); perf.stop('main()');
}); });

@ -1,20 +1,59 @@
// import { freeStorage, storageFreeable } from '../storage/modifier'; import { ExpirationPlugin } from 'workbox-expiration';
import './web_push_notifications'; import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { handleNotificationClick, handlePush } from './web_push_notifications';
// function openSystemCache() { const CACHE_NAME_PREFIX = 'mastodon-';
// return caches.open('mastodon-system');
// }
function openWebCache() { function openWebCache() {
return caches.open('mastodon-web'); return caches.open(`${CACHE_NAME_PREFIX}web`);
} }
function fetchRoot() { function fetchRoot() {
return fetch('/', { credentials: 'include', redirect: 'manual' }); return fetch('/', { credentials: 'include', redirect: 'manual' });
} }
// const firefox = navigator.userAgent.match(/Firefox\/(\d+)/); precacheAndRoute(self.__WB_MANIFEST);
// const invalidOnlyIfCached = firefox && firefox[1] < 60;
registerRoute(
/locale_.*\.js$/,
new CacheFirst({
cacheName: `${CACHE_NAME_PREFIX}locales`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 30 * 24 * 60 * 60, // 1 month
maxEntries: 5,
}),
],
}),
);
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: `${CACHE_NAME_PREFIX}fonts`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 30 * 24 * 60 * 60, // 1 month
maxEntries: 5,
}),
],
}),
);
registerRoute(
({ request }) => ['audio', 'image', 'track', 'video'].includes(request.destination),
new CacheFirst({
cacheName: `m${CACHE_NAME_PREFIX}media`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
maxEntries: 256,
}),
],
}),
);
// Cause a new version of a registered Service Worker to replace an existing one // Cause a new version of a registered Service Worker to replace an existing one
// that is already installed, and replace the currently active worker on open pages. // that is already installed, and replace the currently active worker on open pages.
@ -52,26 +91,8 @@ self.addEventListener('fetch', function(event) {
return response; return response;
})); }));
} /* else if (storageFreeable && (ATTACHMENT_HOST ? url.host === ATTACHMENT_HOST : url.pathname.startsWith('/system/'))) { }
event.respondWith(openSystemCache().then(cache => {
return cache.match(event.request.url).then(cached => {
if (cached === undefined) {
const asyncResponse = invalidOnlyIfCached && event.request.cache === 'only-if-cached' ?
fetch(event.request, { cache: 'no-cache' }) : fetch(event.request);
return asyncResponse.then(response => {
if (response.ok) {
cache
.put(event.request.url, response.clone())
.catch(()=>{}).then(freeStorage()).catch();
}
return response;
});
}
return cached;
});
}));
} */
}); });
self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick);

@ -75,7 +75,7 @@ const formatMessage = (messageId, locale, values = {}) =>
const htmlToPlainText = html => const htmlToPlainText = html =>
unescape(html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, '')); unescape(html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, ''));
const handlePush = (event) => { export const handlePush = (event) => {
const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json(); const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json();
// Placeholder until more information can be loaded // Placeholder until more information can be loaded
@ -189,7 +189,7 @@ const openUrl = url =>
return self.clients.openWindow(url); return self.clients.openWindow(url);
}); });
const handleNotificationClick = (event) => { export const handleNotificationClick = (event) => {
const reactToNotificationClick = new Promise((resolve, reject) => { const reactToNotificationClick = new Promise((resolve, reject) => {
if (event.action) { if (event.action) {
if (event.action === 'expand') { if (event.action === 'expand') {
@ -211,6 +211,3 @@ const handleNotificationClick = (event) => {
event.waitUntil(reactToNotificationClick); event.waitUntil(reactToNotificationClick);
}; };
self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick);

@ -1,27 +0,0 @@
export default () => new Promise((resolve, reject) => {
// ServiceWorker is required to synchronize the login state.
// Microsoft Edge 17 does not support getAll according to:
// Catalog of standard and vendor APIs across browsers - Microsoft Edge Development
// https://developer.microsoft.com/en-us/microsoft-edge/platform/catalog/?q=specName%3Aindexeddb
if (!('caches' in self && 'getAll' in IDBObjectStore.prototype)) {
reject();
return;
}
const request = indexedDB.open('mastodon');
request.onerror = reject;
request.onsuccess = ({ target }) => resolve(target.result);
request.onupgradeneeded = ({ target }) => {
const accounts = target.result.createObjectStore('accounts', { autoIncrement: true });
const statuses = target.result.createObjectStore('statuses', { autoIncrement: true });
accounts.createIndex('id', 'id', { unique: true });
accounts.createIndex('moved', 'moved');
statuses.createIndex('id', 'id', { unique: true });
statuses.createIndex('account', 'account');
statuses.createIndex('reblog', 'reblog');
};
});

@ -1,211 +0,0 @@
import openDB from './db';
const accountAssetKeys = ['avatar', 'avatar_static', 'header', 'header_static'];
const storageMargin = 8388608;
const storeLimit = 1024;
// navigator.storage is not present on:
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.100 Safari/537.36 Edge/16.16299
// estimate method is not present on Chrome 57.0.2987.98 on Linux.
export const storageFreeable = 'storage' in navigator && 'estimate' in navigator.storage;
function openCache() {
// ServiceWorker and Cache API is not available on iOS 11
// https://webkit.org/status/#specification-service-workers
return self.caches ? caches.open('mastodon-system') : Promise.reject();
}
function printErrorIfAvailable(error) {
if (error) {
console.warn(error);
}
}
function put(name, objects, onupdate, oncreate) {
return openDB().then(db => (new Promise((resolve, reject) => {
const putTransaction = db.transaction(name, 'readwrite');
const putStore = putTransaction.objectStore(name);
const putIndex = putStore.index('id');
objects.forEach(object => {
putIndex.getKey(object.id).onsuccess = retrieval => {
function addObject() {
putStore.add(object);
}
function deleteObject() {
putStore.delete(retrieval.target.result).onsuccess = addObject;
}
if (retrieval.target.result) {
if (onupdate) {
onupdate(object, retrieval.target.result, putStore, deleteObject);
} else {
deleteObject();
}
} else {
if (oncreate) {
oncreate(object, addObject);
} else {
addObject();
}
}
};
});
putTransaction.oncomplete = () => {
const readTransaction = db.transaction(name, 'readonly');
const readStore = readTransaction.objectStore(name);
const count = readStore.count();
count.onsuccess = () => {
const excess = count.result - storeLimit;
if (excess > 0) {
const retrieval = readStore.getAll(null, excess);
retrieval.onsuccess = () => resolve(retrieval.result);
retrieval.onerror = reject;
} else {
resolve([]);
}
};
count.onerror = reject;
};
putTransaction.onerror = reject;
})).then(resolved => {
db.close();
return resolved;
}, error => {
db.close();
throw error;
}));
}
function evictAccountsByRecords(records) {
return openDB().then(db => {
const transaction = db.transaction(['accounts', 'statuses'], 'readwrite');
const accounts = transaction.objectStore('accounts');
const accountsIdIndex = accounts.index('id');
const accountsMovedIndex = accounts.index('moved');
const statuses = transaction.objectStore('statuses');
const statusesIndex = statuses.index('account');
function evict(toEvict) {
toEvict.forEach(record => {
openCache()
.then(cache => accountAssetKeys.forEach(key => cache.delete(records[key])))
.catch(printErrorIfAvailable);
accountsMovedIndex.getAll(record.id).onsuccess = ({ target }) => evict(target.result);
statusesIndex.getAll(record.id).onsuccess =
({ target }) => evictStatusesByRecords(target.result);
accountsIdIndex.getKey(record.id).onsuccess =
({ target }) => target.result && accounts.delete(target.result);
});
}
evict(records);
db.close();
}).catch(printErrorIfAvailable);
}
export function evictStatus(id) {
evictStatuses([id]);
}
export function evictStatuses(ids) {
return openDB().then(db => {
const transaction = db.transaction('statuses', 'readwrite');
const store = transaction.objectStore('statuses');
const idIndex = store.index('id');
const reblogIndex = store.index('reblog');
ids.forEach(id => {
reblogIndex.getAllKeys(id).onsuccess =
({ target }) => target.result.forEach(reblogKey => store.delete(reblogKey));
idIndex.getKey(id).onsuccess =
({ target }) => target.result && store.delete(target.result);
});
db.close();
}).catch(printErrorIfAvailable);
}
function evictStatusesByRecords(records) {
return evictStatuses(records.map(({ id }) => id));
}
export function putAccounts(records, avatarStatic) {
const avatarKey = avatarStatic ? 'avatar_static' : 'avatar';
const newURLs = [];
put('accounts', records, (newRecord, oldKey, store, oncomplete) => {
store.get(oldKey).onsuccess = ({ target }) => {
accountAssetKeys.forEach(key => {
const newURL = newRecord[key];
const oldURL = target.result[key];
if (newURL !== oldURL) {
openCache()
.then(cache => cache.delete(oldURL))
.catch(printErrorIfAvailable);
}
});
const newURL = newRecord[avatarKey];
const oldURL = target.result[avatarKey];
if (newURL !== oldURL) {
newURLs.push(newURL);
}
oncomplete();
};
}, (newRecord, oncomplete) => {
newURLs.push(newRecord[avatarKey]);
oncomplete();
}).then(records => Promise.all([
evictAccountsByRecords(records),
openCache().then(cache => cache.addAll(newURLs)),
])).then(freeStorage, error => {
freeStorage();
throw error;
}).catch(printErrorIfAvailable);
}
export function putStatuses(records) {
put('statuses', records)
.then(evictStatusesByRecords)
.catch(printErrorIfAvailable);
}
export function freeStorage() {
return storageFreeable && navigator.storage.estimate().then(({ quota, usage }) => {
if (usage + storageMargin < quota) {
return null;
}
return openDB().then(db => new Promise((resolve, reject) => {
const retrieval = db.transaction('accounts', 'readonly').objectStore('accounts').getAll(null, 1);
retrieval.onsuccess = () => {
if (retrieval.result.length > 0) {
resolve(evictAccountsByRecords(retrieval.result).then(freeStorage));
} else {
resolve(caches.delete('mastodon-system'));
}
};
retrieval.onerror = reject;
db.close();
}));
});
}

@ -190,6 +190,55 @@ a.table-action-link {
} }
} }
&__select-all {
background: $ui-base-color;
height: 47px;
align-items: center;
justify-content: center;
border: 1px solid darken($ui-base-color, 8%);
border-top: 0;
color: $secondary-text-color;
display: none;
&.active {
display: flex;
}
.selected,
.not-selected {
display: none;
&.active {
display: block;
}
}
strong {
font-weight: 700;
}
span {
padding: 8px;
display: inline-block;
}
button {
background: transparent;
border: 0;
font: inherit;
color: $highlight-text-color;
border-radius: 4px;
font-weight: 700;
padding: 8px;
&:hover,
&:focus,
&:active {
background: lighten($ui-base-color, 8%);
}
}
}
&__form { &__form {
padding: 16px; padding: 16px;
border: 1px solid darken($ui-base-color, 8%); border: 1px solid darken($ui-base-color, 8%);

@ -60,7 +60,7 @@ class FeedManager
# @param [Boolean] update # @param [Boolean] update
# @return [Boolean] # @return [Boolean]
def push_to_home(account, status, update: false) def push_to_home(account, status, update: false)
return false unless add_to_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?) return false unless add_to_feed(:home, account.id, status, aggregate_reblogs: true)
trim(:home, account.id) trim(:home, account.id)
PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}", { 'update' => update }) if push_update_required?("timeline:#{account.id}") PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}", { 'update' => update }) if push_update_required?("timeline:#{account.id}")
@ -73,7 +73,7 @@ class FeedManager
# @param [Boolean] update # @param [Boolean] update
# @return [Boolean] # @return [Boolean]
def unpush_from_home(account, status, update: false) def unpush_from_home(account, status, update: false)
return false unless remove_from_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?) return false unless remove_from_feed(:home, account.id, status, aggregate_reblogs: true)
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update
true true
@ -85,7 +85,7 @@ class FeedManager
# @param [Boolean] update # @param [Boolean] update
# @return [Boolean] # @return [Boolean]
def push_to_list(list, status, update: false) def push_to_list(list, status, update: false)
return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, aggregate_reblogs: true)
trim(:list, list.id) trim(:list, list.id)
PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}", { 'update' => update }) if push_update_required?("timeline:list:#{list.id}") PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}", { 'update' => update }) if push_update_required?("timeline:list:#{list.id}")
@ -98,7 +98,7 @@ class FeedManager
# @param [Boolean] update # @param [Boolean] update
# @return [Boolean] # @return [Boolean]
def unpush_from_list(list, status, update: false) def unpush_from_list(list, status, update: false)
return false unless remove_from_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) return false unless remove_from_feed(:list, list.id, status, aggregate_reblogs: true)
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update
true true
@ -133,7 +133,7 @@ class FeedManager
# @return [void] # @return [void]
def merge_into_home(from_account, into_account) def merge_into_home(from_account, into_account)
timeline_key = key(:home, into_account.id) timeline_key = key(:home, into_account.id)
aggregate = into_account.user&.aggregates_reblogs? aggregate = true
query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, :media_attachments, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, :media_attachments, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
@ -159,7 +159,7 @@ class FeedManager
# @return [void] # @return [void]
def merge_into_list(from_account, list) def merge_into_list(from_account, list)
timeline_key = key(:list, list.id) timeline_key = key(:list, list.id)
aggregate = list.account.user&.aggregates_reblogs? aggregate = true
query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, :media_attachments, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, :media_attachments, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
@ -188,7 +188,7 @@ class FeedManager
timeline_status_ids = redis.zrange(timeline_key, 0, -1) timeline_status_ids = redis.zrange(timeline_key, 0, -1)
from_account.statuses.select('id, reblog_of_id').where(id: timeline_status_ids).reorder(nil).find_each do |status| from_account.statuses.select('id, reblog_of_id').where(id: timeline_status_ids).reorder(nil).find_each do |status|
remove_from_feed(:home, into_account.id, status, aggregate_reblogs: into_account.user&.aggregates_reblogs?) remove_from_feed(:home, into_account.id, status, aggregate_reblogs: true)
end end
end end
@ -201,7 +201,7 @@ class FeedManager
timeline_status_ids = redis.zrange(timeline_key, 0, -1) timeline_status_ids = redis.zrange(timeline_key, 0, -1)
from_account.statuses.select('id, reblog_of_id').where(id: timeline_status_ids).reorder(nil).find_each do |status| from_account.statuses.select('id, reblog_of_id').where(id: timeline_status_ids).reorder(nil).find_each do |status|
remove_from_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) remove_from_feed(:list, list.id, status, aggregate_reblogs: true)
end end
end end
@ -260,7 +260,7 @@ class FeedManager
# @return [void] # @return [void]
def populate_home(account) def populate_home(account)
limit = FeedManager::MAX_ITEMS / 2 limit = FeedManager::MAX_ITEMS / 2
aggregate = account.user&.aggregates_reblogs? aggregate = true
timeline_key = key(:home, account.id) timeline_key = key(:home, account.id)
account.statuses.limit(limit).each do |status| account.statuses.limit(limit).each do |status|

@ -66,24 +66,6 @@ class NotificationMailer < ApplicationMailer
end end
end end
def digest(recipient, **opts)
return unless recipient.user.functional?
@me = recipient
@since = opts[:since] || [@me.user.last_emailed_at, (@me.user.current_sign_in_at + 1.day)].compact.max
@notifications_count = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since).count
return if @notifications_count.zero?
@notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since).limit(40)
@follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count
locale_for_account(@me) do
mail to: @me.user.email,
subject: I18n.t(:subject, scope: [:notification_mailer, :digest], count: @notifications_count)
end
end
private private
def thread_by_conversation(conversation) def thread_by_conversation(conversation)

@ -364,6 +364,10 @@ class Account < ApplicationRecord
username username
end end
def to_log_human_identifier
acct
end
def excluded_from_timeline_account_ids def excluded_from_timeline_account_ids
Rails.cache.fetch("exclude_account_ids_for:#{id}") { block_relationships.pluck(:target_account_id) + blocked_by_relationships.pluck(:account_id) + mute_relationships.pluck(:target_account_id) } Rails.cache.fetch("exclude_account_ids_for:#{id}") { block_relationships.pluck(:target_account_id) + blocked_by_relationships.pluck(:account_id) + mute_relationships.pluck(:target_account_id) }
end end

@ -43,4 +43,8 @@ class AccountWarning < ApplicationRecord
def overruled? def overruled?
overruled_at.present? overruled_at.present?
end end
def to_log_human_identifier
target_account.acct
end
end end

@ -9,38 +9,42 @@
# action :string default(""), not null # action :string default(""), not null
# target_type :string # target_type :string
# target_id :bigint(8) # target_id :bigint(8)
# recorded_changes :text default(""), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# human_identifier :string
# route_param :string
# permalink :string
# #
class Admin::ActionLog < ApplicationRecord class Admin::ActionLog < ApplicationRecord
serialize :recorded_changes self.ignored_columns = %w(
recorded_changes
)
belongs_to :account belongs_to :account
belongs_to :target, polymorphic: true, optional: true belongs_to :target, polymorphic: true, optional: true
default_scope -> { order('id desc') } default_scope -> { order('id desc') }
before_validation :set_human_identifier
before_validation :set_route_param
before_validation :set_permalink
def action def action
super.to_sym super.to_sym
end end
before_validation :set_changes
private private
def set_changes def set_human_identifier
case action self.human_identifier = target.to_log_human_identifier if target.respond_to?(:to_log_human_identifier)
when :destroy, :create end
self.recorded_changes = target.attributes
when :update, :promote, :demote def set_route_param
self.recorded_changes = target.previous_changes self.route_param = target.to_log_route_param if target.respond_to?(:to_log_route_param)
when :change_email end
self.recorded_changes = ActiveSupport::HashWithIndifferentAccess.new(
email: [target.email, nil], def set_permalink
unconfirmed_email: [nil, target.unconfirmed_email] self.permalink = target.to_log_permalink if target.respond_to?(:to_log_permalink)
)
end
end end
end end

@ -12,6 +12,7 @@ class Admin::ActionLogFilter
reject_appeal: { target_type: 'Appeal', action: 'reject' }.freeze, reject_appeal: { target_type: 'Appeal', action: 'reject' }.freeze,
assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze, assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze,
change_email_user: { target_type: 'User', action: 'change_email' }.freeze, change_email_user: { target_type: 'User', action: 'change_email' }.freeze,
change_role_user: { target_type: 'User', action: 'change_role' }.freeze,
confirm_user: { target_type: 'User', action: 'confirm' }.freeze, confirm_user: { target_type: 'User', action: 'confirm' }.freeze,
approve_user: { target_type: 'User', action: 'approve' }.freeze, approve_user: { target_type: 'User', action: 'approve' }.freeze,
reject_user: { target_type: 'User', action: 'reject' }.freeze, reject_user: { target_type: 'User', action: 'reject' }.freeze,
@ -21,16 +22,22 @@ class Admin::ActionLogFilter
create_domain_allow: { target_type: 'DomainAllow', action: 'create' }.freeze, create_domain_allow: { target_type: 'DomainAllow', action: 'create' }.freeze,
create_domain_block: { target_type: 'DomainBlock', action: 'create' }.freeze, create_domain_block: { target_type: 'DomainBlock', action: 'create' }.freeze,
create_email_domain_block: { target_type: 'EmailDomainBlock', action: 'create' }.freeze, create_email_domain_block: { target_type: 'EmailDomainBlock', action: 'create' }.freeze,
create_ip_block: { target_type: 'IpBlock', action: 'create' }.freeze,
create_unavailable_domain: { target_type: 'UnavailableDomain', action: 'create' }.freeze, create_unavailable_domain: { target_type: 'UnavailableDomain', action: 'create' }.freeze,
create_user_role: { target_type: 'UserRole', action: 'create' }.freeze,
create_canonical_email_block: { target_type: 'CanonicalEmailBlock', action: 'create' }.freeze,
demote_user: { target_type: 'User', action: 'demote' }.freeze, demote_user: { target_type: 'User', action: 'demote' }.freeze,
destroy_announcement: { target_type: 'Announcement', action: 'destroy' }.freeze, destroy_announcement: { target_type: 'Announcement', action: 'destroy' }.freeze,
destroy_custom_emoji: { target_type: 'CustomEmoji', action: 'destroy' }.freeze, destroy_custom_emoji: { target_type: 'CustomEmoji', action: 'destroy' }.freeze,
destroy_domain_allow: { target_type: 'DomainAllow', action: 'destroy' }.freeze, destroy_domain_allow: { target_type: 'DomainAllow', action: 'destroy' }.freeze,
destroy_domain_block: { target_type: 'DomainBlock', action: 'destroy' }.freeze, destroy_domain_block: { target_type: 'DomainBlock', action: 'destroy' }.freeze,
destroy_ip_block: { target_type: 'IpBlock', action: 'destroy' }.freeze,
destroy_email_domain_block: { target_type: 'EmailDomainBlock', action: 'destroy' }.freeze, destroy_email_domain_block: { target_type: 'EmailDomainBlock', action: 'destroy' }.freeze,
destroy_instance: { target_type: 'Instance', action: 'destroy' }.freeze, destroy_instance: { target_type: 'Instance', action: 'destroy' }.freeze,
destroy_unavailable_domain: { target_type: 'UnavailableDomain', action: 'destroy' }.freeze, destroy_unavailable_domain: { target_type: 'UnavailableDomain', action: 'destroy' }.freeze,
destroy_status: { target_type: 'Status', action: 'destroy' }.freeze, destroy_status: { target_type: 'Status', action: 'destroy' }.freeze,
destroy_user_role: { target_type: 'UserRole', action: 'destroy' }.freeze,
destroy_canonical_email_block: { target_type: 'CanonicalEmailBlock', action: 'destroy' }.freeze,
disable_2fa_user: { target_type: 'User', action: 'disable' }.freeze, disable_2fa_user: { target_type: 'User', action: 'disable' }.freeze,
disable_custom_emoji: { target_type: 'CustomEmoji', action: 'disable' }.freeze, disable_custom_emoji: { target_type: 'CustomEmoji', action: 'disable' }.freeze,
disable_user: { target_type: 'User', action: 'disable' }.freeze, disable_user: { target_type: 'User', action: 'disable' }.freeze,
@ -52,6 +59,8 @@ class Admin::ActionLogFilter
update_announcement: { target_type: 'Announcement', action: 'update' }.freeze, update_announcement: { target_type: 'Announcement', action: 'update' }.freeze,
update_custom_emoji: { target_type: 'CustomEmoji', action: 'update' }.freeze, update_custom_emoji: { target_type: 'CustomEmoji', action: 'update' }.freeze,
update_status: { target_type: 'Status', action: 'update' }.freeze, update_status: { target_type: 'Status', action: 'update' }.freeze,
update_user_role: { target_type: 'UserRole', action: 'update' }.freeze,
update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze,
unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze, unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze,
}.freeze }.freeze

@ -34,6 +34,10 @@ class Announcement < ApplicationRecord
before_validation :set_all_day before_validation :set_all_day
before_validation :set_published, on: :create before_validation :set_published, on: :create
def to_log_human_identifier
text
end
def publish! def publish!
update!(published: true, published_at: Time.now.utc, scheduled_at: nil) update!(published: true, published_at: Time.now.utc, scheduled_at: nil)
end end

@ -52,6 +52,14 @@ class Appeal < ApplicationRecord
update!(rejected_at: Time.now.utc, rejected_by_account: current_account) update!(rejected_at: Time.now.utc, rejected_by_account: current_account)
end end
def to_log_human_identifier
account.acct
end
def to_log_route_param
account_warning_id
end
private private
def validate_time_frame def validate_time_frame

@ -5,27 +5,30 @@
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# canonical_email_hash :string default(""), not null # canonical_email_hash :string default(""), not null
# reference_account_id :bigint(8) not null # reference_account_id :bigint(8)
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# #
class CanonicalEmailBlock < ApplicationRecord class CanonicalEmailBlock < ApplicationRecord
include EmailHelper include EmailHelper
include Paginable
belongs_to :reference_account, class_name: 'Account' belongs_to :reference_account, class_name: 'Account', optional: true
validates :canonical_email_hash, presence: true, uniqueness: true validates :canonical_email_hash, presence: true, uniqueness: true
scope :matching_email, ->(email) { where(canonical_email_hash: email_to_canonical_email_hash(email)) }
def to_log_human_identifier
canonical_email_hash
end
def email=(email) def email=(email)
self.canonical_email_hash = email_to_canonical_email_hash(email) self.canonical_email_hash = email_to_canonical_email_hash(email)
end end
def self.block?(email) def self.block?(email)
where(canonical_email_hash: email_to_canonical_email_hash(email)).exists? matching_email(email).exists?
end
def self.find_blocks(email)
where(canonical_email_hash: email_to_canonical_email_hash(email))
end end
end end

@ -49,7 +49,7 @@ class CustomEmoji < ApplicationRecord
scope :local, -> { where(domain: nil) } scope :local, -> { where(domain: nil) }
scope :remote, -> { where.not(domain: nil) } scope :remote, -> { where.not(domain: nil) }
scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) } scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) }
scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches("%.#{domain}"))) }
scope :listed, -> { local.where(disabled: false).where(visible_in_picker: true) } scope :listed, -> { local.where(disabled: false).where(visible_in_picker: true) }
remotable_attachment :image, LIMIT remotable_attachment :image, LIMIT
@ -70,6 +70,10 @@ class CustomEmoji < ApplicationRecord
copy.tap(&:save!) copy.tap(&:save!)
end end
def to_log_human_identifier
shortcode
end
class << self class << self
def from_text(text, domain = nil) def from_text(text, domain = nil)
return [] if text.blank? return [] if text.blank?

@ -5,7 +5,7 @@
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# custom_filter_id :bigint(8) not null # custom_filter_id :bigint(8) not null
# status_id :bigint(8) default(""), not null # status_id :bigint(8) not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# #

@ -19,6 +19,10 @@ class DomainAllow < ApplicationRecord
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
def to_log_human_identifier
domain
end
class << self class << self
def allowed?(domain) def allowed?(domain)
!rule_for(domain).nil? !rule_for(domain).nil?

@ -31,6 +31,10 @@ class DomainBlock < ApplicationRecord
scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)) } scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)) }
scope :by_severity, -> { order(Arel.sql('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), reject_media, domain')) } scope :by_severity, -> { order(Arel.sql('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), reject_media, domain')) }
def to_log_human_identifier
domain
end
def policies def policies
if suspend? if suspend?
[:suspend] [:suspend]

@ -17,6 +17,7 @@ class EmailDomainBlock < ApplicationRecord
) )
include DomainNormalizable include DomainNormalizable
include Paginable
belongs_to :parent, class_name: 'EmailDomainBlock', optional: true belongs_to :parent, class_name: 'EmailDomainBlock', optional: true
has_many :children, class_name: 'EmailDomainBlock', foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy has_many :children, class_name: 'EmailDomainBlock', foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy
@ -26,6 +27,10 @@ class EmailDomainBlock < ApplicationRecord
# Used for adding multiple blocks at once # Used for adding multiple blocks at once
attr_accessor :other_domains attr_accessor :other_domains
def to_log_human_identifier
domain
end
def history def history
@history ||= Trends::History.new('email_domain_blocks', id) @history ||= Trends::History.new('email_domain_blocks', id)
end end

@ -6,7 +6,8 @@ class Form::AccountBatch
include AccountableConcern include AccountableConcern
include Payloadable include Payloadable
attr_accessor :account_ids, :action, :current_account attr_accessor :account_ids, :action, :current_account,
:select_all_matching, :query
def save def save
case action case action
@ -60,7 +61,11 @@ class Form::AccountBatch
end end
def accounts def accounts
Account.where(id: account_ids) if select_all_matching?
query
else
Account.where(id: account_ids)
end
end end
def approve! def approve!
@ -101,7 +106,7 @@ class Form::AccountBatch
def reject_account(account) def reject_account(account)
authorize(account.user, :reject?) authorize(account.user, :reject?)
log_action(:reject, account.user, username: account.username) log_action(:reject, account.user)
account.suspend!(origin: :local) account.suspend!(origin: :local)
AccountDeletionWorker.perform_async(account.id, { 'reserve_username' => false }) AccountDeletionWorker.perform_async(account.id, { 'reserve_username' => false })
end end
@ -118,4 +123,8 @@ class Form::AccountBatch
log_action(:approve, account.user) log_action(:approve, account.user)
account.user.approve! account.user.approve!
end end
def select_all_matching?
select_all_matching == '1'
end
end end

@ -48,6 +48,8 @@ class Instance < ApplicationRecord
domain domain
end end
alias to_log_human_identifier to_param
delegate :exhausted_deliveries_days, to: :delivery_failure_tracker delegate :exhausted_deliveries_days, to: :delivery_failure_tracker
def availability_over_days(num_days, end_date = Time.now.utc.to_date) def availability_over_days(num_days, end_date = Time.now.utc.to_date)

@ -16,6 +16,7 @@ class IpBlock < ApplicationRecord
CACHE_KEY = 'blocked_ips' CACHE_KEY = 'blocked_ips'
include Expireable include Expireable
include Paginable
enum severity: { enum severity: {
sign_up_requires_approval: 5000, sign_up_requires_approval: 5000,
@ -27,6 +28,10 @@ class IpBlock < ApplicationRecord
after_commit :reset_cache after_commit :reset_cache
def to_log_human_identifier
"#{ip}/#{ip.prefix}"
end
class << self class << self
def blocked?(remote_ip) def blocked?(remote_ip)
blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) } blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) }

@ -115,6 +115,10 @@ class Report < ApplicationRecord
Report.where.not(id: id).where(target_account_id: target_account_id).unresolved.exists? Report.where.not(id: id).where(target_account_id: target_account_id).unresolved.exists?
end end
def to_log_human_identifier
id
end
def history def history
subquery = [ subquery = [
Admin::ActionLog.where( Admin::ActionLog.where(
@ -136,6 +140,8 @@ class Report < ApplicationRecord
Admin::ActionLog.from(Arel::Nodes::As.new(subquery, Admin::ActionLog.arel_table)) Admin::ActionLog.from(Arel::Nodes::As.new(subquery, Admin::ActionLog.arel_table))
end end
private
def set_uri def set_uri
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil? && account.local? self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil? && account.local?
end end

@ -171,6 +171,14 @@ class Status < ApplicationRecord
].compact.join("\n\n") ].compact.join("\n\n")
end end
def to_log_human_identifier
account.acct
end
def to_log_permalink
ActivityPub::TagManager.instance.uri_for(self)
end
def reply? def reply?
!in_reply_to_id.nil? || attributes['reply'] !in_reply_to_id.nil? || attributes['reply']
end end

@ -16,6 +16,10 @@ class UnavailableDomain < ApplicationRecord
after_commit :reset_cache! after_commit :reset_cache!
def to_log_human_identifier
domain
end
private private
def reset_cache! def reset_cache!

@ -181,6 +181,14 @@ class User < ApplicationRecord
update!(disabled: false) update!(disabled: false)
end end
def to_log_human_identifier
account.acct
end
def to_log_route_param
account_id
end
def confirm def confirm
new_user = !confirmed? new_user = !confirmed?
self.approved = true if open_registrations? && !sign_up_from_ip_requires_approval? self.approved = true if open_registrations? && !sign_up_from_ip_requires_approval?
@ -281,10 +289,6 @@ class User < ApplicationRecord
settings.default_privacy || (account.locked? ? 'private' : 'public') settings.default_privacy || (account.locked? ? 'private' : 'public')
end end
def allows_digest_emails?
settings.notification_emails['digest']
end
def allows_report_emails? def allows_report_emails?
settings.notification_emails['report'] settings.notification_emails['report']
end end

@ -155,6 +155,10 @@ class UserRole < ApplicationRecord
end end
end end
def to_log_human_identifier
name
end
private private
def in_permissions?(privilege) def in_permissions?(privilege)

@ -0,0 +1,23 @@
# frozen_string_literal: true
class CanonicalEmailBlockPolicy < ApplicationPolicy
def index?
role.can?(:manage_blocks)
end
def show?
role.can?(:manage_blocks)
end
def test?
role.can?(:manage_blocks)
end
def create?
role.can?(:manage_blocks)
end
def destroy?
role.can?(:manage_blocks)
end
end

@ -9,6 +9,10 @@ class IpBlockPolicy < ApplicationPolicy
role.can?(:manage_blocks) role.can?(:manage_blocks)
end end
def update?
role.can?(:manage_blocks)
end
def destroy? def destroy?
role.can?(:manage_blocks) role.can?(:manage_blocks)
end end

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::Admin::CanonicalEmailBlockSerializer < ActiveModel::Serializer
attributes :id, :canonical_email_hash
def id
object.id.to_s
end
end

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::Admin::EmailDomainBlockSerializer < ActiveModel::Serializer
attributes :id, :domain, :created_at, :history
def id
object.id.to_s
end
end

@ -0,0 +1,14 @@
# frozen_string_literal: true
class REST::Admin::IpBlockSerializer < ActiveModel::Serializer
attributes :id, :ip, :severity, :comment,
:created_at, :expires_at
def id
object.id.to_s
end
def ip
"#{object.ip}/#{object.ip.prefix}"
end
end

@ -10,24 +10,18 @@ class ClearDomainMediaService < BaseService
private private
def invalidate_association_caches! def invalidate_association_caches!(status_ids)
# Normally, associated models of a status are immutable (except for accounts) # Normally, associated models of a status are immutable (except for accounts)
# so they are aggressively cached. After updating the media attachments to no # so they are aggressively cached. After updating the media attachments to no
# longer point to a local file, we need to clear the cache to make those # longer point to a local file, we need to clear the cache to make those
# changes appear in the API and UI # changes appear in the API and UI
@affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") } Rails.cache.delete_multi(status_ids.map { |id| "statuses/#{id}" })
end end
def clear_media! def clear_media!
@affected_status_ids = [] clear_account_images!
clear_account_attachments!
begin clear_emojos!
clear_account_images!
clear_account_attachments!
clear_emojos!
ensure
invalidate_association_caches!
end
end end
def clear_account_images! def clear_account_images!
@ -39,12 +33,18 @@ class ClearDomainMediaService < BaseService
end end
def clear_account_attachments! def clear_account_attachments!
media_from_blocked_domain.reorder(nil).find_each do |attachment| media_from_blocked_domain.reorder(nil).find_in_batches do |attachments|
@affected_status_ids << attachment.status_id if attachment.status_id.present? affected_status_ids = []
attachments.each do |attachment|
affected_status_ids << attachment.status_id if attachment.status_id.present?
attachment.file.destroy if attachment.file&.exists?
attachment.type = :unknown
attachment.save
end
attachment.file.destroy if attachment.file&.exists? invalidate_association_caches!(affected_status_ids) unless affected_status_ids.empty?
attachment.type = :unknown
attachment.save
end end
end end

@ -34,6 +34,7 @@
= form_for(@form, url: batch_admin_accounts_path) do |f| = form_for(@form, url: batch_admin_accounts_path) do |f|
= hidden_field_tag :page, params[:page] || 1 = hidden_field_tag :page, params[:page] || 1
= hidden_field_tag :select_all_matching, '0'
- AccountFilter::KEYS.each do |key| - AccountFilter::KEYS.each do |key|
= hidden_field_tag key, params[key] if params[key].present? = hidden_field_tag key, params[key] if params[key].present?
@ -49,6 +50,14 @@
= f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } = f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
= f.button safe_join([fa_icon('lock'), t('admin.accounts.perform_full_suspension')]), name: :suspend, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } = f.button safe_join([fa_icon('lock'), t('admin.accounts.perform_full_suspension')]), name: :suspend, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
- if @accounts.total_count > @accounts.size
.batch-table__select-all
.not-selected.active
%span= t('generic.all_items_on_page_selected_html', count: @accounts.size)
%button{ type: 'button' }= t('generic.select_all_matching_items', count: @accounts.total_count)
.selected
%span= t('generic.all_matching_items_selected_html', count: @accounts.total_count)
%button{ type: 'button' }= t('generic.deselect')
.batch-table__body .batch-table__body
- if @accounts.empty? - if @accounts.empty?
= nothing_here 'nothing-here--under-tabs' = nothing_here 'nothing-here--under-tabs'

@ -23,6 +23,7 @@
%link{ rel: 'manifest', href: manifest_path(format: :json) }/ %link{ rel: 'manifest', href: manifest_path(format: :json) }/
%meta{ name: 'theme-color', content: '#6364FF' }/ %meta{ name: 'theme-color', content: '#6364FF' }/
%meta{ name: 'apple-mobile-web-app-capable', content: 'yes' }/ %meta{ name: 'apple-mobile-web-app-capable', content: 'yes' }/
%meta{ name: 'apple-itunes-app', content: 'app-id=1571998974' }/
%title= content_for?(:page_title) ? safe_join([yield(:page_title).chomp.html_safe, title], ' - ') : title %title= content_for?(:page_title) ? safe_join([yield(:page_title).chomp.html_safe, title], ' - ') : title

@ -1,44 +0,0 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.darker.hero-with-button
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%h1= t 'notification_mailer.digest.title'
%p.lead= t('notification_mailer.digest.body', since: l((@me.user_current_sign_in_at || @since).to_date, format: :short), instance: site_hostname)
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to web_url do
%span= t 'notification_mailer.digest.action'
- @notifications.each_with_index do |n, i|
= render 'status', status: n.target_status, i: i
- unless @follows_since.zero?
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start.border-top
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center
%p= t('notification_mailer.digest.new_followers_summary', count: @follows_since)

@ -1,15 +0,0 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('notification_mailer.digest.body', since: l(@me.user_current_sign_in_at || @since), instance: root_url) %>
<% @notifications.each do |notification| %>
* <%= raw t('notification_mailer.digest.mention', name: notification.from_account.pretty_acct) %>
<%= raw extract_status_plain_text(notification.target_status) %>
<%= raw t('application_mailer.view')%> <%= web_url("statuses/#{notification.target_status.id}") %>
<% end %>
<% if @follows_since > 0 %>
<%= raw t('notification_mailer.digest.new_followers_summary', count: @follows_since) %>
<% end %>

@ -28,10 +28,6 @@
.fields-group .fields-group
= f.input :setting_always_send_emails, as: :boolean, wrapper: :with_label = f.input :setting_always_send_emails, as: :boolean, wrapper: :with_label
.fields-group
= f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
= ff.input :digest, as: :boolean, wrapper: :with_label
%h4= t 'notifications.other_settings' %h4= t 'notifications.other_settings'
.fields-group .fields-group

@ -10,9 +10,6 @@
.fields-group .fields-group
= f.input :setting_noindex, as: :boolean, wrapper: :with_label = f.input :setting_noindex, as: :boolean, wrapper: :with_label
.fields-group
= f.input :setting_aggregate_reblogs, as: :boolean, wrapper: :with_label, recommended: true
- unless Setting.hide_followers_count - unless Setting.hide_followers_count
.fields-group .fields-group
= f.input :setting_hide_followers_count, as: :boolean, wrapper: :with_label = f.input :setting_hide_followers_count, as: :boolean, wrapper: :with_label

@ -1,21 +0,0 @@
# frozen_string_literal: true
class DigestMailerWorker
include Sidekiq::Worker
sidekiq_options queue: 'mailers'
attr_reader :user
def perform(user_id)
@user = User.find(user_id)
deliver_digest if @user.allows_digest_emails?
end
private
def deliver_digest
NotificationMailer.digest(user.account).deliver_now!
user.touch(:last_emailed_at)
end
end

@ -1,25 +0,0 @@
# frozen_string_literal: true
class Scheduler::EmailScheduler
include Sidekiq::Worker
sidekiq_options retry: 0
FREQUENCY = 7.days.freeze
SIGN_IN_OFFSET = 1.day.freeze
def perform
eligible_users.reorder(nil).find_each do |user|
next unless user.allows_digest_emails?
DigestMailerWorker.perform_async(user.id)
end
end
private
def eligible_users
User.emailable
.where('current_sign_in_at < ?', (FREQUENCY + SIGN_IN_OFFSET).ago)
.where('last_emailed_at IS NULL OR last_emailed_at < ?', FREQUENCY.ago)
end
end

@ -235,17 +235,21 @@ en:
approve_user: Approve User approve_user: Approve User
assigned_to_self_report: Assign Report assigned_to_self_report: Assign Report
change_email_user: Change E-mail for User change_email_user: Change E-mail for User
change_role_user: Change Role of User
confirm_user: Confirm User confirm_user: Confirm User
create_account_warning: Create Warning create_account_warning: Create Warning
create_announcement: Create Announcement create_announcement: Create Announcement
create_canonical_email_block: Create E-mail Block
create_custom_emoji: Create Custom Emoji create_custom_emoji: Create Custom Emoji
create_domain_allow: Create Domain Allow create_domain_allow: Create Domain Allow
create_domain_block: Create Domain Block create_domain_block: Create Domain Block
create_email_domain_block: Create E-mail Domain Block create_email_domain_block: Create E-mail Domain Block
create_ip_block: Create IP rule create_ip_block: Create IP rule
create_unavailable_domain: Create Unavailable Domain create_unavailable_domain: Create Unavailable Domain
create_user_role: Create Role
demote_user: Demote User demote_user: Demote User
destroy_announcement: Delete Announcement destroy_announcement: Delete Announcement
destroy_canonical_email_block: Delete E-mail Block
destroy_custom_emoji: Delete Custom Emoji destroy_custom_emoji: Delete Custom Emoji
destroy_domain_allow: Delete Domain Allow destroy_domain_allow: Delete Domain Allow
destroy_domain_block: Delete Domain Block destroy_domain_block: Delete Domain Block
@ -254,6 +258,7 @@ en:
destroy_ip_block: Delete IP rule destroy_ip_block: Delete IP rule
destroy_status: Delete Post destroy_status: Delete Post
destroy_unavailable_domain: Delete Unavailable Domain destroy_unavailable_domain: Delete Unavailable Domain
destroy_user_role: Destroy Role
disable_2fa_user: Disable 2FA disable_2fa_user: Disable 2FA
disable_custom_emoji: Disable Custom Emoji disable_custom_emoji: Disable Custom Emoji
disable_sign_in_token_auth_user: Disable E-mail Token Authentication for User disable_sign_in_token_auth_user: Disable E-mail Token Authentication for User
@ -280,24 +285,30 @@ en:
update_announcement: Update Announcement update_announcement: Update Announcement
update_custom_emoji: Update Custom Emoji update_custom_emoji: Update Custom Emoji
update_domain_block: Update Domain Block update_domain_block: Update Domain Block
update_ip_block: Update IP rule
update_status: Update Post update_status: Update Post
update_user_role: Update Role
actions: actions:
approve_appeal_html: "%{name} approved moderation decision appeal from %{target}" approve_appeal_html: "%{name} approved moderation decision appeal from %{target}"
approve_user_html: "%{name} approved sign-up from %{target}" approve_user_html: "%{name} approved sign-up from %{target}"
assigned_to_self_report_html: "%{name} assigned report %{target} to themselves" assigned_to_self_report_html: "%{name} assigned report %{target} to themselves"
change_email_user_html: "%{name} changed the e-mail address of user %{target}" change_email_user_html: "%{name} changed the e-mail address of user %{target}"
change_role_user_html: "%{name} changed role of %{target}"
confirm_user_html: "%{name} confirmed e-mail address of user %{target}" confirm_user_html: "%{name} confirmed e-mail address of user %{target}"
create_account_warning_html: "%{name} sent a warning to %{target}" create_account_warning_html: "%{name} sent a warning to %{target}"
create_announcement_html: "%{name} created new announcement %{target}" create_announcement_html: "%{name} created new announcement %{target}"
create_canonical_email_block_html: "%{name} blocked e-mail with the hash %{target}"
create_custom_emoji_html: "%{name} uploaded new emoji %{target}" create_custom_emoji_html: "%{name} uploaded new emoji %{target}"
create_domain_allow_html: "%{name} allowed federation with domain %{target}" create_domain_allow_html: "%{name} allowed federation with domain %{target}"
create_domain_block_html: "%{name} blocked domain %{target}" create_domain_block_html: "%{name} blocked domain %{target}"
create_email_domain_block_html: "%{name} blocked e-mail domain %{target}" create_email_domain_block_html: "%{name} blocked e-mail domain %{target}"
create_ip_block_html: "%{name} created rule for IP %{target}" create_ip_block_html: "%{name} created rule for IP %{target}"
create_unavailable_domain_html: "%{name} stopped delivery to domain %{target}" create_unavailable_domain_html: "%{name} stopped delivery to domain %{target}"
create_user_role_html: "%{name} created %{target} role"
demote_user_html: "%{name} demoted user %{target}" demote_user_html: "%{name} demoted user %{target}"
destroy_announcement_html: "%{name} deleted announcement %{target}" destroy_announcement_html: "%{name} deleted announcement %{target}"
destroy_custom_emoji_html: "%{name} destroyed emoji %{target}" destroy_canonical_email_block_html: "%{name} unblocked e-mail with the hash %{target}"
destroy_custom_emoji_html: "%{name} deleted emoji %{target}"
destroy_domain_allow_html: "%{name} disallowed federation with domain %{target}" destroy_domain_allow_html: "%{name} disallowed federation with domain %{target}"
destroy_domain_block_html: "%{name} unblocked domain %{target}" destroy_domain_block_html: "%{name} unblocked domain %{target}"
destroy_email_domain_block_html: "%{name} unblocked e-mail domain %{target}" destroy_email_domain_block_html: "%{name} unblocked e-mail domain %{target}"
@ -305,6 +316,7 @@ en:
destroy_ip_block_html: "%{name} deleted rule for IP %{target}" destroy_ip_block_html: "%{name} deleted rule for IP %{target}"
destroy_status_html: "%{name} removed post by %{target}" destroy_status_html: "%{name} removed post by %{target}"
destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}" destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}"
destroy_user_role_html: "%{name} deleted %{target} role"
disable_2fa_user_html: "%{name} disabled two factor requirement for user %{target}" disable_2fa_user_html: "%{name} disabled two factor requirement for user %{target}"
disable_custom_emoji_html: "%{name} disabled emoji %{target}" disable_custom_emoji_html: "%{name} disabled emoji %{target}"
disable_sign_in_token_auth_user_html: "%{name} disabled e-mail token authentication for %{target}" disable_sign_in_token_auth_user_html: "%{name} disabled e-mail token authentication for %{target}"
@ -331,8 +343,9 @@ en:
update_announcement_html: "%{name} updated announcement %{target}" update_announcement_html: "%{name} updated announcement %{target}"
update_custom_emoji_html: "%{name} updated emoji %{target}" update_custom_emoji_html: "%{name} updated emoji %{target}"
update_domain_block_html: "%{name} updated domain block for %{target}" update_domain_block_html: "%{name} updated domain block for %{target}"
update_ip_block_html: "%{name} changed rule for IP %{target}"
update_status_html: "%{name} updated post by %{target}" update_status_html: "%{name} updated post by %{target}"
deleted_status: "(deleted post)" update_user_role_html: "%{name} changed %{target} role"
empty: No logs found. empty: No logs found.
filter_by_action: Filter by action filter_by_action: Filter by action
filter_by_user: Filter by user filter_by_user: Filter by user
@ -1220,12 +1233,22 @@ en:
trending_now: Trending now trending_now: Trending now
generic: generic:
all: All all: All
all_items_on_page_selected_html:
one: "<strong>%{count}</strong> item on this page is selected."
other: All <strong>%{count}</strong> items on this page are selected.
all_matching_items_selected_html:
one: "<strong>%{count}</strong> item matching your search is selected."
other: All <strong>%{count}</strong> items matching your search are selected.
changes_saved_msg: Changes successfully saved! changes_saved_msg: Changes successfully saved!
copy: Copy copy: Copy
delete: Delete delete: Delete
deselect: Deselect all
none: None none: None
order_by: Order by order_by: Order by
save_changes: Save changes save_changes: Save changes
select_all_matching_items:
one: Select %{count} item matching your search.
other: Select all %{count} items matching your search.
today: today today: today
validation_errors: validation_errors:
one: Something isn't quite right yet! Please review the error below one: Something isn't quite right yet! Please review the error below
@ -1334,17 +1357,6 @@ en:
subject: "%{name} submitted a report" subject: "%{name} submitted a report"
sign_up: sign_up:
subject: "%{name} signed up" subject: "%{name} signed up"
digest:
action: View all notifications
body: Here is a brief summary of the messages you missed since your last visit on %{since}
mention: "%{name} mentioned you in:"
new_followers_summary:
one: Also, you have acquired one new follower while being away! Yay!
other: Also, you have acquired %{count} new followers while being away! Amazing!
subject:
one: "1 new notification since your last visit 🐘"
other: "%{count} new notifications since your last visit 🐘"
title: In your absence...
favourite: favourite:
body: 'Your post was favourited by %{name}:' body: 'Your post was favourited by %{name}:'
subject: "%{name} favourited your post" subject: "%{name} favourited your post"

@ -615,6 +615,8 @@ Rails.application.routes.draw do
resources :domain_allows, only: [:index, :show, :create, :destroy] resources :domain_allows, only: [:index, :show, :create, :destroy]
resources :domain_blocks, only: [:index, :show, :update, :create, :destroy] resources :domain_blocks, only: [:index, :show, :update, :create, :destroy]
resources :email_domain_blocks, only: [:index, :show, :create, :destroy]
resources :ip_blocks, only: [:index, :show, :update, :create, :destroy]
namespace :trends do namespace :trends do
resources :tags, only: [:index] resources :tags, only: [:index]
@ -625,6 +627,12 @@ Rails.application.routes.draw do
post :measures, to: 'measures#create' post :measures, to: 'measures#create'
post :dimensions, to: 'dimensions#create' post :dimensions, to: 'dimensions#create'
post :retention, to: 'retention#create' post :retention, to: 'retention#create'
resources :canonical_email_blocks, only: [:index, :create, :show, :destroy] do
collection do
post :test
end
end
end end
end end

@ -49,10 +49,6 @@
cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *' cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
class: Scheduler::IpCleanupScheduler class: Scheduler::IpCleanupScheduler
queue: scheduler queue: scheduler
email_scheduler:
cron: '0 10 * * 2'
class: Scheduler::EmailScheduler
queue: scheduler
backup_cleanup_scheduler: backup_cleanup_scheduler:
cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *' cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
class: Scheduler::BackupCleanupScheduler class: Scheduler::BackupCleanupScheduler

@ -1,29 +1,16 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect // Note: You must restart bin/webpack-dev-server for changes to take effect
const path = require('path'); const { createHash } = require('crypto');
const { URL } = require('url'); const { readFileSync } = require('fs');
const { resolve } = require('path');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const OfflinePlugin = require('offline-plugin');
const TerserPlugin = require('terser-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin'); const CompressionPlugin = require('compression-webpack-plugin');
const { output } = require('./configuration'); const { InjectManifest } = require('workbox-webpack-plugin');
const sharedConfig = require('./shared'); const sharedConfig = require('./shared');
let attachmentHost; const root = resolve(__dirname, '..', '..');
if (process.env.S3_ENABLED === 'true') {
if (process.env.S3_ALIAS_HOST || process.env.S3_CLOUDFRONT_HOST) {
attachmentHost = process.env.S3_ALIAS_HOST || process.env.S3_CLOUDFRONT_HOST;
} else {
attachmentHost = process.env.S3_HOSTNAME || `s3-${process.env.S3_REGION || 'us-east-1'}.amazonaws.com`;
}
} else if (process.env.SWIFT_ENABLED === 'true') {
const { host } = new URL(process.env.SWIFT_OBJECT_URL);
attachmentHost = host;
} else {
attachmentHost = null;
}
module.exports = merge(sharedConfig, { module.exports = merge(sharedConfig, {
mode: 'production', mode: 'production',
@ -52,47 +39,28 @@ module.exports = merge(sharedConfig, {
openAnalyzer: false, openAnalyzer: false,
logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout
}), }),
new OfflinePlugin({ new InjectManifest({
publicPath: output.publicPath, // sw.js must be served from the root to avoid scope issues additionalManifestEntries: ['1f602.svg', 'sheet_13.png'].map((filename) => {
safeToUseOptionalCaches: true, const path = resolve(root, 'public', 'emoji', filename);
caches: { const body = readFileSync(path);
main: [':rest:'], const md5 = createHash('md5');
additional: [':externals:'],
optional: [ md5.update(body);
'**/locale_*.js', // don't fetch every locale; the user only needs one
'**/*_polyfills-*.js', // the user may not need polyfills return {
'**/*.woff2', // the user may have system-fonts enabled revision: md5.digest('hex'),
// images/audio can be cached on-demand url: `/emoji/${filename}`,
'**/*.png', };
'**/*.jpg', }),
'**/*.jpeg', exclude: [
'**/*.svg', /(?:base|extra)_polyfills-.*\.js$/,
'**/*.mp3', /locale_.*\.js$/,
'**/*.ogg', /mailer-.*\.(?:css|js)$/,
],
},
externals: [
'/emoji/1f602.svg', // used for emoji picker dropdown
'/emoji/sheet_10.png', // used in emoji-mart
],
excludes: [
'**/*.gz',
'**/*.map',
'stats.json',
'report.html',
// any browser that supports ServiceWorker will support woff2
'**/*.eot',
'**/*.ttf',
'**/*-webfont-*.svg',
'**/*.woff',
], ],
ServiceWorker: { include: [/\.js$/, /\.css$/],
entry: `imports-loader?additionalCode=${encodeURIComponent(`var ATTACHMENT_HOST=${JSON.stringify(attachmentHost)};`)}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`, maximumFileSizeToCacheInBytes: 2 * 1_024 * 1_024, // 2 MiB
cacheName: 'mastodon', swDest: resolve(root, 'public', 'packs', 'sw.js'),
output: '../assets/sw.js', swSrc: resolve(root, 'app', 'javascript', 'mastodon', 'service_worker', 'entry.js'),
publicPath: '/sw.js',
minify: true,
},
}), }),
], ],
}); });

@ -0,0 +1,7 @@
class AddHumanIdentifierToAdminActionLogs < ActiveRecord::Migration[6.1]
def change
add_column :admin_action_logs, :human_identifier, :string
add_column :admin_action_logs, :route_param, :string
add_column :admin_action_logs, :permalink, :string
end
end

@ -0,0 +1,5 @@
class ChangeCanonicalEmailBlocksNullable < ActiveRecord::Migration[6.1]
def change
safety_assured { change_column :canonical_email_blocks, :reference_account_id, :bigint, null: true, default: nil }
end
end

@ -0,0 +1,20 @@
# frozen_string_literal: true
class FixCustomFilterKeywordsIdSeq < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def up
# 20220613110711 manually inserts items with set `id` in the database, but
# we also need to bump the sequence number, otherwise
safety_assured do
execute <<-SQL.squish
BEGIN;
LOCK TABLE custom_filter_keywords IN EXCLUSIVE MODE;
SELECT setval('custom_filter_keywords_id_seq'::regclass, id) FROM custom_filter_keywords ORDER BY id DESC LIMIT 1;
COMMIT;
SQL
end
end
def down; end
end

@ -0,0 +1,9 @@
# frozen_string_literal: true
class RemoveRecordedChangesFromAdminActionLogs < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
def change
safety_assured { remove_column :admin_action_logs, :recorded_changes, :text }
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_08_08_101323) do ActiveRecord::Schema.define(version: 2022_08_27_195229) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -205,9 +205,11 @@ ActiveRecord::Schema.define(version: 2022_08_08_101323) do
t.string "action", default: "", null: false t.string "action", default: "", null: false
t.string "target_type" t.string "target_type"
t.bigint "target_id" t.bigint "target_id"
t.text "recorded_changes", default: "", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "human_identifier"
t.string "route_param"
t.string "permalink"
t.index ["account_id"], name: "index_admin_action_logs_on_account_id" t.index ["account_id"], name: "index_admin_action_logs_on_account_id"
t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id" t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
end end
@ -294,7 +296,7 @@ ActiveRecord::Schema.define(version: 2022_08_08_101323) do
create_table "canonical_email_blocks", force: :cascade do |t| create_table "canonical_email_blocks", force: :cascade do |t|
t.string "canonical_email_hash", default: "", null: false t.string "canonical_email_hash", default: "", null: false
t.bigint "reference_account_id", null: false t.bigint "reference_account_id"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.index ["canonical_email_hash"], name: "index_canonical_email_blocks_on_canonical_email_hash", unique: true t.index ["canonical_email_hash"], name: "index_canonical_email_blocks_on_canonical_email_hash", unique: true

@ -15,7 +15,7 @@ services:
redis: redis:
restart: always restart: always
image: redis:6-alpine image: redis:7-alpine
networks: networks:
- internal_network - internal_network
healthcheck: healthcheck:

@ -18,17 +18,15 @@ module Mastodon
When suspending a local user, a hash of a "canonical" version of their e-mail When suspending a local user, a hash of a "canonical" version of their e-mail
address is stored to prevent them from signing up again. address is stored to prevent them from signing up again.
This command can be used to find whether a known email address is blocked, This command can be used to find whether a known email address is blocked.
and if so, which account it was attached to.
LONG_DESC LONG_DESC
def find(email) def find(email)
accts = CanonicalEmailBlock.find_blocks(email).map(&:reference_account).map(&:acct).to_a accts = CanonicalEmailBlock.matching_email(email)
if accts.empty? if accts.empty?
say("#{email} is not blocked", :yellow) say("#{email} is not blocked", :green)
else else
accts.each do |acct| say("#{email} is blocked", :red)
say(acct, :white)
end
end end
end end
@ -40,24 +38,13 @@ module Mastodon
This command allows removing a canonical email block. This command allows removing a canonical email block.
LONG_DESC LONG_DESC
def remove(email) def remove(email)
blocks = CanonicalEmailBlock.find_blocks(email) blocks = CanonicalEmailBlock.matching_email(email)
if blocks.empty? if blocks.empty?
say("#{email} is not blocked", :yellow) say("#{email} is not blocked", :green)
else else
blocks.destroy_all blocks.destroy_all
say("Removed canonical email block for #{email}", :green) say("Unblocked #{email}", :green)
end
end
private
def color(processed, failed)
if !processed.zero? && failed.zero?
:green
elsif failed.zero?
:yellow
else
:red
end end
end end
end end

@ -84,7 +84,6 @@
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"object-fit-images": "^3.2.3", "object-fit-images": "^3.2.3",
"object.values": "^1.1.5", "object.values": "^1.1.5",
"offline-plugin": "^5.0.7",
"path-complete-extname": "^1.0.0", "path-complete-extname": "^1.0.0",
"pg": "^8.5.0", "pg": "^8.5.0",
"postcss": "^8.4.16", "postcss": "^8.4.16",
@ -138,6 +137,12 @@
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
"webpack-merge": "^5.8.0", "webpack-merge": "^5.8.0",
"wicg-inert": "^3.1.2", "wicg-inert": "^3.1.2",
"workbox-expiration": "^6.5.3",
"workbox-precaching": "^6.5.3",
"workbox-routing": "^6.5.3",
"workbox-strategies": "^6.5.3",
"workbox-webpack-plugin": "^6.5.3",
"workbox-window": "^6.5.3",
"ws": "^8.8.1" "ws": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {

@ -1 +1 @@
assets/sw.js packs/sw.js

@ -0,0 +1 @@
packs/sw.js.map

@ -3,32 +3,4 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Admin::ActionLogsHelper, type: :helper do RSpec.describe Admin::ActionLogsHelper, type: :helper do
klass = Class.new do
include ActionView::Helpers
include Admin::ActionLogsHelper
end
let(:hoge) { klass.new }
describe '#log_target' do
after do
hoge.log_target(log)
end
context 'log.target' do
let(:log) { double(target: true) }
it 'calls linkable_log_target' do
expect(hoge).to receive(:linkable_log_target).with(log.target)
end
end
context '!log.target' do
let(:log) { double(target: false, target_type: :type, recorded_changes: :change) }
it 'calls log_target_from_history' do
expect(hoge).to receive(:log_target_from_history).with(log.target_type, log.recorded_changes)
end
end
end
end end

@ -101,35 +101,4 @@ RSpec.describe NotificationMailer, type: :mailer do
expect(mail.body.encoded).to match("bob has requested to follow you") expect(mail.body.encoded).to match("bob has requested to follow you")
end end
end end
describe 'digest' do
before do
mention = Fabricate(:mention, account: receiver.account, status: foreign_status)
Fabricate(:notification, account: receiver.account, activity: mention)
sender.follow!(receiver.account)
end
context do
let!(:mail) { NotificationMailer.digest(receiver.account, since: 5.days.ago) }
include_examples 'localized subject', 'notification_mailer.digest.subject', count: 1, name: 'bob'
it 'renders the headers' do
expect(mail.subject).to match('notification since your last')
expect(mail.to).to eq([receiver.email])
end
it 'renders the body' do
expect(mail.body.encoded).to match('brief summary')
expect(mail.body.encoded).to include 'The body of the foreign status'
expect(mail.body.encoded).to include sender.username
end
end
it 'includes activities since the receiver last signed in' do
receiver.update!(last_emailed_at: nil, current_sign_in_at: '2000-03-01T00:00:00Z')
mail = NotificationMailer.digest(receiver.account)
expect(mail.body.encoded).to include 'Mar 01, 2000, 00:00'
end
end
end end

@ -1,36 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe DigestMailerWorker do
describe 'perform' do
let(:user) { Fabricate(:user, last_emailed_at: 3.days.ago) }
context 'for a user who receives digests' do
it 'sends the email' do
service = double(deliver_now!: nil)
allow(NotificationMailer).to receive(:digest).and_return(service)
update_user_digest_setting(true)
described_class.perform_async(user.id)
expect(NotificationMailer).to have_received(:digest)
expect(user.reload.last_emailed_at).to be_within(1).of(Time.now.utc)
end
end
context 'for a user who does not receive digests' do
it 'does not send the email' do
allow(NotificationMailer).to receive(:digest)
update_user_digest_setting(false)
described_class.perform_async(user.id)
expect(NotificationMailer).not_to have_received(:digest)
expect(user.last_emailed_at).to be_within(1).of(3.days.ago)
end
end
def update_user_digest_setting(value)
user.settings['notification_emails'] = user.settings['notification_emails'].merge('digest' => value)
end
end
end

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save