commit
2ea362dd85
|
@ -52,9 +52,9 @@ VAPID_PUBLIC_KEY=
|
|||
# Single user mode will disable registrations and redirect frontpage to the first profile
|
||||
# SINGLE_USER_MODE=true
|
||||
# Prevent registrations with following e-mail domains
|
||||
# EMAIL_DOMAIN_BLACKLIST=example1.com|example2.de|etc
|
||||
# EMAIL_DOMAIN_DENYLIST=example1.com|example2.de|etc
|
||||
# Only allow registrations with the following e-mail domains
|
||||
# EMAIL_DOMAIN_WHITELIST=example1.com|example2.de|etc
|
||||
# EMAIL_DOMAIN_ALLOWLIST=example1.com|example2.de|etc
|
||||
|
||||
# Optionally change default language
|
||||
# DEFAULT_LOCALE=de
|
||||
|
@ -286,7 +286,7 @@ STREAMING_CLUSTER_NUM=1
|
|||
# https://docs.joinmastodon.org/admin/config/#authorized_fetch
|
||||
# AUTHORIZED_FETCH=true
|
||||
|
||||
# Whitelist mode (optional)
|
||||
# Only allow federation with whitelisted domains, see
|
||||
# Limited federation mode (optional)
|
||||
# Only allow federation with specific domains, see
|
||||
# https://docs.joinmastodon.org/admin/config/#whitelist_mode
|
||||
# WHITELIST_MODE=true
|
||||
# LIMITED_FEDERATION_MODE=true
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
patreon: mastodon
|
||||
open_collective: mastodon
|
||||
github: [Gargron]
|
||||
|
|
|
@ -43,6 +43,11 @@ postgres
|
|||
redis
|
||||
elasticsearch
|
||||
|
||||
# ignore Helm lockfile, dependency charts, and local values file
|
||||
chart/Chart.lock
|
||||
chart/charts/*.tgz
|
||||
chart/values.yaml
|
||||
|
||||
# Ignore Apple files
|
||||
.DS_Store
|
||||
|
||||
|
|
6
Gemfile
6
Gemfile
|
@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
|
|||
gem 'pghero', '~> 2.5'
|
||||
gem 'dotenv-rails', '~> 2.7'
|
||||
|
||||
gem 'aws-sdk-s3', '~> 1.69', require: false
|
||||
gem 'aws-sdk-s3', '~> 1.72', require: false
|
||||
gem 'fog-core', '<= 2.1.0'
|
||||
gem 'fog-openstack', '~> 0.3', require: false
|
||||
gem 'paperclip', '~> 6.0'
|
||||
|
@ -122,7 +122,7 @@ end
|
|||
group :test do
|
||||
gem 'capybara', '~> 3.33'
|
||||
gem 'climate_control', '~> 0.2'
|
||||
gem 'faker', '~> 2.12'
|
||||
gem 'faker', '~> 2.13'
|
||||
gem 'microformats', '~> 4.2'
|
||||
gem 'rails-controller-testing', '~> 1.0'
|
||||
gem 'rspec-sidekiq', '~> 3.1'
|
||||
|
@ -141,7 +141,7 @@ group :development do
|
|||
gem 'letter_opener', '~> 1.7'
|
||||
gem 'letter_opener_web', '~> 1.4'
|
||||
gem 'memory_profiler'
|
||||
gem 'rubocop', '~> 0.85', require: false
|
||||
gem 'rubocop', '~> 0.86', require: false
|
||||
gem 'rubocop-rails', '~> 2.6', require: false
|
||||
gem 'brakeman', '~> 4.8', require: false
|
||||
gem 'bundler-audit', '~> 0.7', require: false
|
||||
|
|
40
Gemfile.lock
40
Gemfile.lock
|
@ -92,20 +92,20 @@ GEM
|
|||
av (0.9.0)
|
||||
cocaine (~> 0.5.3)
|
||||
aws-eventstream (1.1.0)
|
||||
aws-partitions (1.332.0)
|
||||
aws-sdk-core (3.100.0)
|
||||
aws-partitions (1.336.0)
|
||||
aws-sdk-core (3.102.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.239.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
jmespath (~> 1.0)
|
||||
aws-sdk-kms (1.34.1)
|
||||
aws-sdk-kms (1.35.0)
|
||||
aws-sdk-core (~> 3, >= 3.99.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.69.0)
|
||||
aws-sdk-core (~> 3, >= 3.99.0)
|
||||
aws-sdk-s3 (1.72.0)
|
||||
aws-sdk-core (~> 3, >= 3.102.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sigv4 (1.2.0)
|
||||
aws-sigv4 (1.2.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
bcrypt (3.1.13)
|
||||
better_errors (2.7.1)
|
||||
|
@ -188,7 +188,7 @@ GEM
|
|||
devise_pam_authenticatable2 (9.2.0)
|
||||
devise (>= 4.0.0)
|
||||
rpam2 (~> 4.0)
|
||||
diff-lcs (1.3)
|
||||
diff-lcs (1.4.3)
|
||||
discard (1.2.0)
|
||||
activerecord (>= 4.2, < 7)
|
||||
docile (1.3.2)
|
||||
|
@ -218,7 +218,7 @@ GEM
|
|||
tzinfo
|
||||
excon (0.75.0)
|
||||
fabrication (2.21.1)
|
||||
faker (2.12.0)
|
||||
faker (2.13.0)
|
||||
i18n (>= 1.6, < 2)
|
||||
faraday (1.0.1)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
|
@ -414,7 +414,7 @@ GEM
|
|||
equatable (~> 0.6)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.2.3)
|
||||
pghero (2.5.0)
|
||||
pghero (2.5.1)
|
||||
activerecord (>= 5)
|
||||
pkg-config (1.4.1)
|
||||
premailer (1.11.1)
|
||||
|
@ -463,10 +463,10 @@ GEM
|
|||
bundler (>= 1.3.0)
|
||||
railties (= 5.2.4.3)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-controller-testing (1.0.4)
|
||||
actionpack (>= 5.0.1.x)
|
||||
actionview (>= 5.0.1.x)
|
||||
activesupport (>= 5.0.1.x)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
activesupport (>= 5.0.1.rc1)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
|
@ -508,7 +508,7 @@ GEM
|
|||
redis-actionpack (>= 5.0, < 6)
|
||||
redis-activesupport (>= 5.0, < 6)
|
||||
redis-store (>= 1.2, < 2)
|
||||
redis-store (1.8.2)
|
||||
redis-store (1.9.0)
|
||||
redis (>= 4, < 5)
|
||||
regexp_parser (1.7.1)
|
||||
request_store (1.5.0)
|
||||
|
@ -545,16 +545,16 @@ GEM
|
|||
rspec-support (3.9.3)
|
||||
rspec_junit_formatter (0.4.1)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (0.85.1)
|
||||
rubocop (0.86.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 2.7.0.1)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.7)
|
||||
rexml
|
||||
rubocop-ast (>= 0.0.3)
|
||||
rubocop-ast (>= 0.0.3, < 1.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 2.0)
|
||||
rubocop-ast (0.0.3)
|
||||
rubocop-ast (0.1.0)
|
||||
parser (>= 2.7.0.1)
|
||||
rubocop-rails (2.6.0)
|
||||
activesupport (>= 4.2.0)
|
||||
|
@ -675,7 +675,7 @@ DEPENDENCIES
|
|||
active_record_query_trace (~> 1.7)
|
||||
addressable (~> 2.7)
|
||||
annotate (~> 3.1)
|
||||
aws-sdk-s3 (~> 1.69)
|
||||
aws-sdk-s3 (~> 1.72)
|
||||
better_errors (~> 2.7)
|
||||
binding_of_caller (~> 0.7)
|
||||
blurhash (~> 0.1)
|
||||
|
@ -704,7 +704,7 @@ DEPENDENCIES
|
|||
e2mmap (~> 0.1.0)
|
||||
ed25519 (~> 1.2)
|
||||
fabrication (~> 2.21)
|
||||
faker (~> 2.12)
|
||||
faker (~> 2.13)
|
||||
fast_blank (~> 1.0)
|
||||
fastimage
|
||||
fog-core (<= 2.1.0)
|
||||
|
@ -774,7 +774,7 @@ DEPENDENCIES
|
|||
rspec-rails (~> 4.0)
|
||||
rspec-sidekiq (~> 3.1)
|
||||
rspec_junit_formatter (~> 0.4)
|
||||
rubocop (~> 0.85)
|
||||
rubocop (~> 0.86)
|
||||
rubocop-rails (~> 2.6)
|
||||
ruby-progressbar (~> 1.10)
|
||||
sanitize (~> 5.2)
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::NotesController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
|
||||
before_action :require_user!
|
||||
before_action :set_account
|
||||
|
||||
def create
|
||||
if params[:comment].blank?
|
||||
AccountNote.find_by(account: current_account, target_account: @account)&.destroy
|
||||
else
|
||||
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
|
||||
@note.comment = params[:comment]
|
||||
@note.save! if @note.changed?
|
||||
end
|
||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def relationships_presenter
|
||||
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
|
||||
end
|
||||
end
|
|
@ -39,7 +39,7 @@ class Api::V1::MediaController < Api::BaseController
|
|||
end
|
||||
|
||||
def media_attachment_params
|
||||
params.permit(:file, :description, :focus)
|
||||
params.permit(:file, :thumbnail, :description, :focus)
|
||||
end
|
||||
|
||||
def file_type_error
|
||||
|
|
|
@ -28,8 +28,8 @@ class MediaProxyController < ApplicationController
|
|||
private
|
||||
|
||||
def redownload!
|
||||
@media_attachment.file_remote_url = @media_attachment.remote_url
|
||||
@media_attachment.created_at = Time.now.utc
|
||||
@media_attachment.download_file!
|
||||
@media_attachment.created_at = Time.now.utc
|
||||
@media_attachment.save!
|
||||
end
|
||||
|
||||
|
|
|
@ -7,13 +7,8 @@ module Settings
|
|||
before_action :set_picture
|
||||
|
||||
def destroy
|
||||
if valid_picture
|
||||
account_params = {
|
||||
@picture => nil,
|
||||
(@picture + '_remote_url') => nil,
|
||||
}
|
||||
|
||||
msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil
|
||||
if valid_picture?
|
||||
msg = I18n.t('generic.changes_saved_msg') if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
|
||||
redirect_to settings_profile_path, notice: msg, status: 303
|
||||
else
|
||||
bad_request
|
||||
|
@ -30,8 +25,8 @@ module Settings
|
|||
@picture = params[:id]
|
||||
end
|
||||
|
||||
def valid_picture
|
||||
@picture == 'avatar' || @picture == 'header'
|
||||
def valid_picture?
|
||||
%w(avatar header).include?(@picture)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
import api from 'flavours/glitch/util/api';
|
||||
|
||||
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
||||
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
||||
|
||||
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
|
||||
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
|
||||
|
||||
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
|
||||
|
||||
export function submitAccountNote() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(submitAccountNoteRequest());
|
||||
|
||||
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/note`, {
|
||||
comment: getState().getIn(['account_notes', 'edit', 'comment']),
|
||||
}).then(response => {
|
||||
dispatch(submitAccountNoteSuccess(response.data));
|
||||
}).catch(error => dispatch(submitAccountNoteFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteRequest() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function initEditAccountNote(account) {
|
||||
return (dispatch, getState) => {
|
||||
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
|
||||
|
||||
dispatch({
|
||||
type: ACCOUNT_NOTE_INIT_EDIT,
|
||||
account,
|
||||
comment,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function cancelAccountNote() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_CANCEL,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeAccountNoteComment(comment) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
comment,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Icon from 'flavours/glitch/components/icon';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
isEditing: PropTypes.bool,
|
||||
isSubmitting: PropTypes.bool,
|
||||
accountNote: PropTypes.string,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
onCancelAccountNote: PropTypes.func.isRequired,
|
||||
onSaveAccountNote: PropTypes.func.isRequired,
|
||||
onChangeAccountNote: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleChangeAccountNote = (e) => {
|
||||
this.props.onChangeAccountNote(e.target.value);
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.props.isEditing) {
|
||||
this.props.onCancelAccountNote();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
this.props.onSaveAccountNote();
|
||||
} else if (e.keyCode === 27) {
|
||||
this.props.onCancelAccountNote();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
|
||||
|
||||
if (!account || (!accountNote && !isEditing)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let action_buttons = null;
|
||||
if (isEditing) {
|
||||
action_buttons = (
|
||||
<div className='account__header__account-note__buttons'>
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
<div className='flex-spacer' />
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let note_container = null;
|
||||
if (isEditing) {
|
||||
note_container = (
|
||||
<Textarea
|
||||
className='account__header__account-note__content'
|
||||
disabled={isSubmitting}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={accountNote}
|
||||
onChange={this.handleChangeAccountNote}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<div className='account__header__account-note__header'>
|
||||
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{note_container}
|
||||
{action_buttons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -12,6 +12,7 @@ import Button from 'flavours/glitch/components/button';
|
|||
import { shortNumberFormat } from 'flavours/glitch/util/numbers';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import AccountNoteContainer from '../containers/account_note_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
|
@ -46,6 +47,7 @@ const messages = defineMessages({
|
|||
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
|
||||
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
|
||||
});
|
||||
|
||||
const dateFormatOptions = {
|
||||
|
@ -65,6 +67,7 @@ class Header extends ImmutablePureComponent {
|
|||
identity_props: ImmutablePropTypes.list,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
domain: PropTypes.string.isRequired,
|
||||
};
|
||||
|
@ -121,6 +124,8 @@ class Header extends ImmutablePureComponent {
|
|||
return null;
|
||||
}
|
||||
|
||||
const accountNote = account.getIn(['relationship', 'note']);
|
||||
|
||||
let info = [];
|
||||
let actionBtn = '';
|
||||
let lockedIcon = '';
|
||||
|
@ -172,6 +177,10 @@ class Header extends ImmutablePureComponent {
|
|||
menu.push(null);
|
||||
}
|
||||
|
||||
if (accountNote === null) {
|
||||
menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
|
||||
}
|
||||
|
||||
if (account.get('id') === me) {
|
||||
if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
|
||||
if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
|
||||
|
@ -278,6 +287,8 @@ class Header extends ImmutablePureComponent {
|
|||
</h1>
|
||||
</div>
|
||||
|
||||
<AccountNoteContainer account={account} />
|
||||
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{ (fields.size > 0 || identity_proofs.size > 0) && (
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'flavours/glitch/actions/account_notes';
|
||||
import AccountNote from '../components/account_note';
|
||||
|
||||
const mapStateToProps = (state, { account }) => {
|
||||
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
|
||||
|
||||
return {
|
||||
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
|
||||
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
|
||||
isEditing,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||
|
||||
onEditAccountNote() {
|
||||
dispatch(initEditAccountNote(account));
|
||||
},
|
||||
|
||||
onSaveAccountNote() {
|
||||
dispatch(submitAccountNote());
|
||||
},
|
||||
|
||||
onCancelAccountNote() {
|
||||
dispatch(cancelAccountNote());
|
||||
},
|
||||
|
||||
onChangeAccountNote(comment) {
|
||||
dispatch(changeAccountNoteComment(comment));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
|
@ -24,6 +24,7 @@ export default class Header extends ImmutablePureComponent {
|
|||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
onEndorseToggle: PropTypes.func.isRequired,
|
||||
onAddToList: PropTypes.func.isRequired,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
hideTabs: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
};
|
||||
|
@ -84,6 +85,10 @@ export default class Header extends ImmutablePureComponent {
|
|||
this.props.onAddToList(this.props.account);
|
||||
}
|
||||
|
||||
handleEditAccountNote = () => {
|
||||
this.props.onEditAccountNote(this.props.account);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, hideTabs, identity_proofs } = this.props;
|
||||
|
||||
|
@ -109,6 +114,7 @@ export default class Header extends ImmutablePureComponent {
|
|||
onUnblockDomain={this.handleUnblockDomain}
|
||||
onEndorseToggle={this.handleEndorseToggle}
|
||||
onAddToList={this.handleAddToList}
|
||||
onEditAccountNote={this.handleEditAccountNote}
|
||||
domain={this.props.domain}
|
||||
/>
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
|||
import { initReport } from 'flavours/glitch/actions/reports';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_blocks';
|
||||
import { initEditAccountNote } from 'flavours/glitch/actions/account_notes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { unfollowModal } from 'flavours/glitch/util/initial_state';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
@ -106,6 +107,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
}
|
||||
},
|
||||
|
||||
onEditAccountNote (account) {
|
||||
dispatch(initEditAccountNote(account));
|
||||
},
|
||||
|
||||
onBlockDomain (domain) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||
|
|
|
@ -7,7 +7,6 @@ import punycode from 'punycode';
|
|||
import classnames from 'classnames';
|
||||
import { decode as decodeIDNA } from 'flavours/glitch/util/idna';
|
||||
import Icon from 'flavours/glitch/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import { useBlurhash } from 'flavours/glitch/util/initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
|
@ -196,7 +195,7 @@ export default class Card extends React.PureComponent {
|
|||
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
||||
|
||||
const description = (
|
||||
<div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
|
||||
<div className='status-card__content'>
|
||||
{title}
|
||||
{!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
|
||||
<span className='status-card__host'>{provider}</span>
|
||||
|
@ -204,7 +203,7 @@ export default class Card extends React.PureComponent {
|
|||
);
|
||||
|
||||
let embed = '';
|
||||
let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
|
||||
let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classnames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
|
||||
let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
|
||||
let spoilerButton = (
|
||||
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
|
||||
|
@ -212,7 +211,7 @@ export default class Card extends React.PureComponent {
|
|||
</button>
|
||||
);
|
||||
spoilerButton = (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
|
||||
<div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
);
|
||||
|
@ -270,7 +269,6 @@ export default class Card extends React.PureComponent {
|
|||
<a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
|
||||
{embed}
|
||||
{description}
|
||||
{!revealed && spoilerButton}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
ACCOUNT_NOTE_INIT_EDIT,
|
||||
ACCOUNT_NOTE_CANCEL,
|
||||
ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
} from '../actions/account_notes';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
edit: ImmutableMap({
|
||||
isSubmitting: false,
|
||||
account_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function account_notes(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case ACCOUNT_NOTE_INIT_EDIT:
|
||||
return state.withMutations((state) => {
|
||||
state.setIn(['edit', 'isSubmitting'], false);
|
||||
state.setIn(['edit', 'account_id'], action.account.get('id'));
|
||||
state.setIn(['edit', 'comment'], action.comment);
|
||||
});
|
||||
case ACCOUNT_NOTE_CHANGE_COMMENT:
|
||||
return state.setIn(['edit', 'comment'], action.comment);
|
||||
case ACCOUNT_NOTE_SUBMIT_REQUEST:
|
||||
return state.setIn(['edit', 'isSubmitting'], true);
|
||||
case ACCOUNT_NOTE_SUBMIT_FAIL:
|
||||
return state.setIn(['edit', 'isSubmitting'], false);
|
||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
||||
case ACCOUNT_NOTE_CANCEL:
|
||||
return state.withMutations((state) => {
|
||||
state.setIn(['edit', 'isSubmitting'], false);
|
||||
state.setIn(['edit', 'account_id'], null);
|
||||
state.setIn(['edit', 'comment'], null);
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -37,6 +37,7 @@ import identity_proofs from './identity_proofs';
|
|||
import trends from './trends';
|
||||
import announcements from './announcements';
|
||||
import markers from './markers';
|
||||
import account_notes from './account_notes';
|
||||
|
||||
const reducers = {
|
||||
announcements,
|
||||
|
@ -77,6 +78,7 @@ const reducers = {
|
|||
polls,
|
||||
trends,
|
||||
markers,
|
||||
account_notes,
|
||||
};
|
||||
|
||||
export default combineReducers(reducers);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
MARKERS_SUBMIT_SUCCESS,
|
||||
} from '../actions/notifications';
|
||||
} from '../actions/markers';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
home: '0',
|
||||
|
|
|
@ -13,6 +13,9 @@ import {
|
|||
DOMAIN_BLOCK_SUCCESS,
|
||||
DOMAIN_UNBLOCK_SUCCESS,
|
||||
} from 'flavours/glitch/actions/domain_blocks';
|
||||
import {
|
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
} from 'flavours/glitch/actions/account_notes';
|
||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
|
||||
const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
|
||||
|
@ -45,6 +48,7 @@ export default function relationships(state = initialState, action) {
|
|||
case ACCOUNT_UNMUTE_SUCCESS:
|
||||
case ACCOUNT_PIN_SUCCESS:
|
||||
case ACCOUNT_UNPIN_SUCCESS:
|
||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
||||
return normalizeRelationship(state, action.relationship);
|
||||
case RELATIONSHIPS_FETCH_SUCCESS:
|
||||
return normalizeRelationships(state, action.relationships);
|
||||
|
|
|
@ -379,7 +379,6 @@
|
|||
color: $primary-text-color;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
vertical-align: top;
|
||||
background-color: $base-overlay-background;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
|
@ -605,7 +604,7 @@
|
|||
&__tabs {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 7px 5px;
|
||||
padding: 7px 10px;
|
||||
margin-top: -55px;
|
||||
|
||||
&__buttons {
|
||||
|
@ -627,7 +626,7 @@
|
|||
}
|
||||
|
||||
&__name {
|
||||
padding: 5px;
|
||||
padding: 5px 10px;
|
||||
|
||||
.account-role {
|
||||
vertical-align: top;
|
||||
|
@ -713,4 +712,65 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__account-note {
|
||||
margin: 5px;
|
||||
padding: 10px;
|
||||
background: $ui-highlight-color;
|
||||
color: $primary-text-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__content {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 5px;
|
||||
|
||||
.flex-spacer {
|
||||
flex: 0 0 20px;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
color: $inverted-text-color;
|
||||
background: $simple-background-color;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -875,11 +875,6 @@ a.status-card {
|
|||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
padding: 14px 14px 14px 8px;
|
||||
|
||||
&--blurred {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status-card__description {
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
import api from '../api';
|
||||
|
||||
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
||||
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
||||
|
||||
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
|
||||
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
|
||||
|
||||
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
|
||||
|
||||
export function submitAccountNote() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(submitAccountNoteRequest());
|
||||
|
||||
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/note`, {
|
||||
comment: getState().getIn(['account_notes', 'edit', 'comment']),
|
||||
}).then(response => {
|
||||
dispatch(submitAccountNoteSuccess(response.data));
|
||||
}).catch(error => dispatch(submitAccountNoteFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteRequest() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function initEditAccountNote(account) {
|
||||
return (dispatch, getState) => {
|
||||
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
|
||||
|
||||
dispatch({
|
||||
type: ACCOUNT_NOTE_INIT_EDIT,
|
||||
account,
|
||||
comment,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function cancelAccountNote() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_CANCEL,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeAccountNoteComment(comment) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
comment,
|
||||
};
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Button from '../button';
|
||||
|
@ -21,16 +21,16 @@ describe('<Button />', () => {
|
|||
|
||||
it('handles click events using the given handler', () => {
|
||||
const handler = jest.fn();
|
||||
const button = shallow(<Button onClick={handler} />);
|
||||
button.find('button').simulate('click');
|
||||
render(<Button onClick={handler}>button</Button>);
|
||||
fireEvent.click(screen.getByText('button'));
|
||||
|
||||
expect(handler.mock.calls.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('does not handle click events if props.disabled given', () => {
|
||||
const handler = jest.fn();
|
||||
const button = shallow(<Button onClick={handler} disabled />);
|
||||
button.find('button').simulate('click');
|
||||
render(<Button onClick={handler} disabled>button</Button>);
|
||||
fireEvent.click(screen.getByText('button'));
|
||||
|
||||
expect(handler.mock.calls.length).toEqual(0);
|
||||
});
|
||||
|
|
|
@ -352,7 +352,8 @@ class Status extends ImmutablePureComponent {
|
|||
<Component
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
poster={status.getIn(['account', 'avatar_static'])}
|
||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={110}
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
isEditing: PropTypes.bool,
|
||||
isSubmitting: PropTypes.bool,
|
||||
accountNote: PropTypes.string,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
onCancelAccountNote: PropTypes.func.isRequired,
|
||||
onSaveAccountNote: PropTypes.func.isRequired,
|
||||
onChangeAccountNote: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleChangeAccountNote = (e) => {
|
||||
this.props.onChangeAccountNote(e.target.value);
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.props.isEditing) {
|
||||
this.props.onCancelAccountNote();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
this.props.onSaveAccountNote();
|
||||
} else if (e.keyCode === 27) {
|
||||
this.props.onCancelAccountNote();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
|
||||
|
||||
if (!account || (!accountNote && !isEditing)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let action_buttons = null;
|
||||
if (isEditing) {
|
||||
action_buttons = (
|
||||
<div className='account__header__account-note__buttons'>
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
<div className='flex-spacer' />
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let note_container = null;
|
||||
if (isEditing) {
|
||||
note_container = (
|
||||
<Textarea
|
||||
className='account__header__account-note__content'
|
||||
disabled={isSubmitting}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={accountNote}
|
||||
onChange={this.handleChangeAccountNote}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<div className='account__header__account-note__header'>
|
||||
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{note_container}
|
||||
{action_buttons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -11,6 +11,7 @@ import Avatar from 'mastodon/components/avatar';
|
|||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import AccountNoteContainer from '../containers/account_note_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
|
@ -45,6 +46,7 @@ const messages = defineMessages({
|
|||
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
|
||||
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
|
||||
});
|
||||
|
||||
const dateFormatOptions = {
|
||||
|
@ -64,6 +66,7 @@ class Header extends ImmutablePureComponent {
|
|||
identity_props: ImmutablePropTypes.list,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
domain: PropTypes.string.isRequired,
|
||||
};
|
||||
|
@ -128,6 +131,8 @@ class Header extends ImmutablePureComponent {
|
|||
return null;
|
||||
}
|
||||
|
||||
const accountNote = account.getIn(['relationship', 'note']);
|
||||
|
||||
let info = [];
|
||||
let actionBtn = '';
|
||||
let lockedIcon = '';
|
||||
|
@ -178,6 +183,10 @@ class Header extends ImmutablePureComponent {
|
|||
menu.push(null);
|
||||
}
|
||||
|
||||
if (accountNote === null) {
|
||||
menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
|
||||
}
|
||||
|
||||
if (account.get('id') === me) {
|
||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
||||
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
|
||||
|
@ -284,6 +293,8 @@ class Header extends ImmutablePureComponent {
|
|||
</h1>
|
||||
</div>
|
||||
|
||||
<AccountNoteContainer account={account} />
|
||||
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{ (fields.size > 0 || identity_proofs.size > 0) && (
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes';
|
||||
import AccountNote from '../components/account_note';
|
||||
|
||||
const mapStateToProps = (state, { account }) => {
|
||||
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
|
||||
|
||||
return {
|
||||
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
|
||||
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
|
||||
isEditing,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||
|
||||
onEditAccountNote() {
|
||||
dispatch(initEditAccountNote(account));
|
||||
},
|
||||
|
||||
onSaveAccountNote() {
|
||||
dispatch(submitAccountNote());
|
||||
},
|
||||
|
||||
onCancelAccountNote() {
|
||||
dispatch(cancelAccountNote());
|
||||
},
|
||||
|
||||
onChangeAccountNote(comment) {
|
||||
dispatch(changeAccountNoteComment(comment));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
|
@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
|
|||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
onEndorseToggle: PropTypes.func.isRequired,
|
||||
onAddToList: PropTypes.func.isRequired,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
hideTabs: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
};
|
||||
|
@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
|
|||
this.props.onAddToList(this.props.account);
|
||||
}
|
||||
|
||||
handleEditAccountNote = () => {
|
||||
this.props.onEditAccountNote(this.props.account);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, hideTabs, identity_proofs } = this.props;
|
||||
|
||||
|
@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
|
|||
onUnblockDomain={this.handleUnblockDomain}
|
||||
onEndorseToggle={this.handleEndorseToggle}
|
||||
onAddToList={this.handleAddToList}
|
||||
onEditAccountNote={this.handleEditAccountNote}
|
||||
domain={this.props.domain}
|
||||
/>
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import { initBlockModal } from '../../../actions/blocks';
|
|||
import { initReport } from '../../../actions/reports';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
||||
import { initEditAccountNote } from 'mastodon/actions/account_notes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { unfollowModal } from '../../../initial_state';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
}
|
||||
},
|
||||
|
||||
onEditAccountNote (account) {
|
||||
dispatch(initEditAccountNote(account));
|
||||
},
|
||||
|
||||
onBlockDomain (domain) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||
|
|
|
@ -157,6 +157,7 @@ class Audio extends React.PureComponent {
|
|||
fullscreen: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
cacheWidth: PropTypes.func,
|
||||
blurhash: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -222,32 +223,42 @@ class Audio extends React.PureComponent {
|
|||
window.addEventListener('scroll', this.handleScroll);
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => this.handlePosterLoad(img);
|
||||
img.src = this.props.poster;
|
||||
if (!this.props.blurhash) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => this.handlePosterLoad(img);
|
||||
img.src = this.props.poster;
|
||||
} else {
|
||||
this._setColorScheme();
|
||||
this._decodeBlurhash();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps, prevState) {
|
||||
if (prevProps.poster !== this.props.poster) {
|
||||
if (prevProps.poster !== this.props.poster && !this.props.blurhash) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => this.handlePosterLoad(img);
|
||||
img.src = this.props.poster;
|
||||
}
|
||||
|
||||
if (prevState.blurhash !== this.state.blurhash) {
|
||||
const context = this.blurhashCanvas.getContext('2d');
|
||||
const pixels = decode(this.state.blurhash, 32, 32);
|
||||
const outputImageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
context.putImageData(outputImageData, 0, 0);
|
||||
if (prevState.blurhash !== this.state.blurhash || prevProps.blurhash !== this.props.blurhash) {
|
||||
this._setColorScheme();
|
||||
this._decodeBlurhash();
|
||||
}
|
||||
|
||||
this._clear();
|
||||
this._draw();
|
||||
}
|
||||
|
||||
_decodeBlurhash () {
|
||||
const context = this.blurhashCanvas.getContext('2d');
|
||||
const pixels = decode(this.props.blurhash || this.state.blurhash, 32, 32);
|
||||
const outputImageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
context.putImageData(outputImageData, 0, 0);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
|
@ -415,7 +426,7 @@ class Audio extends React.PureComponent {
|
|||
}
|
||||
|
||||
handlePosterLoad = image => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
canvas.width = image.width;
|
||||
|
@ -425,10 +436,15 @@ class Audio extends React.PureComponent {
|
|||
|
||||
const inputImageData = context.getImageData(0, 0, image.width, image.height);
|
||||
const blurhash = encode(inputImageData.data, image.width, image.height, 4, 4);
|
||||
|
||||
this.setState({ blurhash });
|
||||
}
|
||||
|
||||
_setColorScheme () {
|
||||
const blurhash = this.props.blurhash || this.state.blurhash;
|
||||
const averageColor = decodeRGB(decode83(blurhash.slice(2, 6)));
|
||||
|
||||
this.setState({
|
||||
blurhash,
|
||||
color: adjustColor(averageColor),
|
||||
darkText: luma(averageColor) >= 165,
|
||||
});
|
||||
|
|
|
@ -6,7 +6,6 @@ import { FormattedMessage } from 'react-intl';
|
|||
import punycode from 'punycode';
|
||||
import classnames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import { useBlurhash } from 'mastodon/initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
import { debounce } from 'lodash';
|
||||
|
@ -231,7 +230,7 @@ export default class Card extends React.PureComponent {
|
|||
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
||||
|
||||
const description = (
|
||||
<div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
|
||||
<div className='status-card__content'>
|
||||
{title}
|
||||
{!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
|
||||
<span className='status-card__host'>{provider}</span>
|
||||
|
@ -239,7 +238,7 @@ export default class Card extends React.PureComponent {
|
|||
);
|
||||
|
||||
let embed = '';
|
||||
let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
|
||||
let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classnames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
|
||||
let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
|
||||
let spoilerButton = (
|
||||
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
|
||||
|
@ -247,7 +246,7 @@ export default class Card extends React.PureComponent {
|
|||
</button>
|
||||
);
|
||||
spoilerButton = (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
|
||||
<div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
);
|
||||
|
@ -305,7 +304,6 @@ export default class Card extends React.PureComponent {
|
|||
<a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
|
||||
{embed}
|
||||
{description}
|
||||
{!revealed && spoilerButton}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -125,7 +125,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
poster={status.getIn(['account', 'avatar_static'])}
|
||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
height={150}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import Column from '../column';
|
||||
import ColumnHeader from '../column_header';
|
||||
|
||||
describe('<Column />', () => {
|
||||
describe('<ColumnHeader /> click handler', () => {
|
||||
it('runs the scroll animation if the column contains scrollable content', () => {
|
||||
const wrapper = mount(
|
||||
const scrollToMock = jest.fn();
|
||||
const { container } = render(
|
||||
<Column heading='notifications'>
|
||||
<div className='scrollable' />
|
||||
</Column>,
|
||||
);
|
||||
const scrollToMock = jest.fn();
|
||||
wrapper.find(Column).find('.scrollable').getDOMNode().scrollTo = scrollToMock;
|
||||
wrapper.find(ColumnHeader).find('button').simulate('click');
|
||||
container.querySelector('.scrollable').scrollTo = scrollToMock;
|
||||
fireEvent.click(screen.getByText('notifications'));
|
||||
expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 });
|
||||
});
|
||||
|
||||
it('does not try to scroll if there is no scrollable content', () => {
|
||||
const wrapper = mount(<Column heading='notifications' />);
|
||||
wrapper.find(ColumnHeader).find('button').simulate('click');
|
||||
render(<Column heading='notifications' />);
|
||||
fireEvent.click(screen.getByText('notifications'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
ACCOUNT_NOTE_INIT_EDIT,
|
||||
ACCOUNT_NOTE_CANCEL,
|
||||
ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
} from '../actions/account_notes';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
edit: ImmutableMap({
|
||||
isSubmitting: false,
|
||||
account_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function account_notes(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case ACCOUNT_NOTE_INIT_EDIT:
|
||||
return state.withMutations((state) => {
|
||||
state.setIn(['edit', 'isSubmitting'], false);
|
||||
state.setIn(['edit', 'account_id'], action.account.get('id'));
|
||||
state.setIn(['edit', 'comment'], action.comment);
|
||||
});
|
||||
case ACCOUNT_NOTE_CHANGE_COMMENT:
|
||||
return state.setIn(['edit', 'comment'], action.comment);
|
||||
case ACCOUNT_NOTE_SUBMIT_REQUEST:
|
||||
return state.setIn(['edit', 'isSubmitting'], true);
|
||||
case ACCOUNT_NOTE_SUBMIT_FAIL:
|
||||
return state.setIn(['edit', 'isSubmitting'], false);
|
||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
||||
case ACCOUNT_NOTE_CANCEL:
|
||||
return state.withMutations((state) => {
|
||||
state.setIn(['edit', 'isSubmitting'], false);
|
||||
state.setIn(['edit', 'account_id'], null);
|
||||
state.setIn(['edit', 'comment'], null);
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -36,6 +36,7 @@ import trends from './trends';
|
|||
import missed_updates from './missed_updates';
|
||||
import announcements from './announcements';
|
||||
import markers from './markers';
|
||||
import account_notes from './account_notes';
|
||||
|
||||
const reducers = {
|
||||
announcements,
|
||||
|
@ -75,6 +76,7 @@ const reducers = {
|
|||
trends,
|
||||
missed_updates,
|
||||
markers,
|
||||
account_notes,
|
||||
};
|
||||
|
||||
export default combineReducers(reducers);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
MARKERS_SUBMIT_SUCCESS,
|
||||
} from '../actions/notifications';
|
||||
} from '../actions/markers';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
home: '0',
|
||||
|
|
|
@ -17,6 +17,9 @@ import {
|
|||
DOMAIN_BLOCK_SUCCESS,
|
||||
DOMAIN_UNBLOCK_SUCCESS,
|
||||
} from '../actions/domain_blocks';
|
||||
import {
|
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
} from '../actions/account_notes';
|
||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
|
||||
const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
|
||||
|
@ -57,6 +60,7 @@ export default function relationships(state = initialState, action) {
|
|||
case ACCOUNT_UNMUTE_SUCCESS:
|
||||
case ACCOUNT_PIN_SUCCESS:
|
||||
case ACCOUNT_UNPIN_SUCCESS:
|
||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
||||
return normalizeRelationship(state, action.relationship);
|
||||
case RELATIONSHIPS_FETCH_SUCCESS:
|
||||
return normalizeRelationships(state, action.relationships);
|
||||
|
|
|
@ -1,5 +1 @@
|
|||
import { configure } from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
|
||||
const adapter = new Adapter();
|
||||
configure({ adapter });
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
|
|
@ -3105,11 +3105,6 @@ a.status-card {
|
|||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
padding: 14px 14px 14px 8px;
|
||||
|
||||
&--blurred {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status-card__description {
|
||||
|
@ -3846,7 +3841,6 @@ a.status-card.compact:hover {
|
|||
color: $primary-text-color;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
vertical-align: top;
|
||||
background-color: $base-overlay-background;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
|
@ -6502,7 +6496,7 @@ noscript {
|
|||
&__tabs {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 7px 5px;
|
||||
padding: 7px 10px;
|
||||
margin-top: -55px;
|
||||
|
||||
&__buttons {
|
||||
|
@ -6524,7 +6518,7 @@ noscript {
|
|||
}
|
||||
|
||||
&__name {
|
||||
padding: 5px;
|
||||
padding: 5px 10px;
|
||||
|
||||
.account-role {
|
||||
vertical-align: top;
|
||||
|
@ -6610,6 +6604,67 @@ noscript {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__account-note {
|
||||
margin: 5px;
|
||||
padding: 10px;
|
||||
background: $ui-highlight-color;
|
||||
color: $primary-text-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__content {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 5px;
|
||||
|
||||
.flex-spacer {
|
||||
flex: 0 0 20px;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
color: $inverted-text-color;
|
||||
background: $simple-background-color;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trends {
|
||||
|
|
|
@ -238,12 +238,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
|
||||
begin
|
||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
|
||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
|
||||
media_attachments << media_attachment
|
||||
|
||||
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
|
||||
|
||||
media_attachment.file_remote_url = href
|
||||
media_attachment.download_file!
|
||||
media_attachment.download_thumbnail!
|
||||
media_attachment.save
|
||||
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
|
||||
RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
|
||||
|
@ -256,6 +257,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
media_attachments
|
||||
end
|
||||
|
||||
def icon_url_from_attachment(attachment)
|
||||
url = attachment['icon'].is_a?(Hash) ? attachment['icon']['url'] : attachment['icon']
|
||||
Addressable::URI.parse(url).normalize.to_s if url.present?
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def process_poll
|
||||
return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array))
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_notes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# target_account_id :bigint(8)
|
||||
# comment :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class AccountNote < ApplicationRecord
|
||||
include RelationshipCacheable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
end
|
|
@ -44,6 +44,14 @@ module AccountInteractions
|
|||
follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
|
||||
end
|
||||
|
||||
def account_note_map(target_account_ids, account_id)
|
||||
AccountNote.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |note, mapping|
|
||||
mapping[note.target_account_id] = {
|
||||
comment: note.comment,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def domain_blocking_map(target_account_ids, account_id)
|
||||
accounts_map = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
|
||||
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
|
||||
|
|
|
@ -4,12 +4,12 @@ module Remotable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def remotable_attachment(attachment_name, limit, suppress_errors: true)
|
||||
attribute_name = "#{attachment_name}_remote_url".to_sym
|
||||
method_name = "#{attribute_name}=".to_sym
|
||||
alt_method_name = "reset_#{attachment_name}!".to_sym
|
||||
def remotable_attachment(attachment_name, limit, suppress_errors: true, download_on_assign: true, attribute_name: nil)
|
||||
attribute_name ||= "#{attachment_name}_remote_url".to_sym
|
||||
|
||||
define_method("download_#{attachment_name}!") do |url = nil|
|
||||
url ||= self[attribute_name]
|
||||
|
||||
define_method method_name do |url|
|
||||
return if url.blank?
|
||||
|
||||
begin
|
||||
|
@ -18,7 +18,7 @@ module Remotable
|
|||
return
|
||||
end
|
||||
|
||||
return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank? || (self[attribute_name] == url && send("#{attachment_name}_file_name").present?)
|
||||
return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank?
|
||||
|
||||
begin
|
||||
Request.new(:get, url).perform do |response|
|
||||
|
@ -36,10 +36,8 @@ module Remotable
|
|||
|
||||
basename = SecureRandom.hex(8)
|
||||
|
||||
send("#{attachment_name}_file_name=", basename + extname)
|
||||
send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
|
||||
|
||||
self[attribute_name] = url if has_attribute?(attribute_name)
|
||||
public_send("#{attachment_name}_file_name=", basename + extname)
|
||||
public_send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
|
||||
end
|
||||
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e
|
||||
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
||||
|
@ -50,14 +48,15 @@ module Remotable
|
|||
end
|
||||
end
|
||||
|
||||
define_method alt_method_name do
|
||||
url = self[attribute_name]
|
||||
define_method("#{attribute_name}=") do |url|
|
||||
return if self[attribute_name] == url && public_send("#{attachment_name}_file_name").present?
|
||||
|
||||
return if url.blank?
|
||||
self[attribute_name] = url if has_attribute?(attribute_name)
|
||||
|
||||
self[attribute_name] = ''
|
||||
send(method_name, url)
|
||||
public_send("download_#{attachment_name}!", url) if download_on_assign
|
||||
end
|
||||
|
||||
alias_method("reset_#{attachment_name}!", "download_#{attachment_name}!")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -21,6 +21,11 @@
|
|||
# blurhash :string
|
||||
# processing :integer
|
||||
# file_storage_schema_version :integer
|
||||
# thumbnail_file_name :string
|
||||
# thumbnail_content_type :string
|
||||
# thumbnail_file_size :integer
|
||||
# thumbnail_updated_at :datetime
|
||||
# thumbnail_remote_url :string
|
||||
#
|
||||
|
||||
class MediaAttachment < ApplicationRecord
|
||||
|
@ -49,13 +54,13 @@ class MediaAttachment < ApplicationRecord
|
|||
original: {
|
||||
pixels: 1_638_400, # 1280x1280px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
},
|
||||
}.freeze,
|
||||
|
||||
small: {
|
||||
pixels: 160_000, # 400x400px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_FORMAT = {
|
||||
|
@ -74,14 +79,14 @@ class MediaAttachment < ApplicationRecord
|
|||
'frames:v' => 60 * 60 * 3,
|
||||
'crf' => 18,
|
||||
'map_metadata' => '-1',
|
||||
},
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_PASSTHROUGH_OPTIONS = {
|
||||
video_codecs: ['h264'],
|
||||
audio_codecs: ['aac', nil],
|
||||
colorspaces: ['yuv420p'],
|
||||
video_codecs: ['h264'].freeze,
|
||||
audio_codecs: ['aac', nil].freeze,
|
||||
colorspaces: ['yuv420p'].freeze,
|
||||
options: {
|
||||
format: 'mp4',
|
||||
convert_options: {
|
||||
|
@ -90,9 +95,9 @@ class MediaAttachment < ApplicationRecord
|
|||
'map_metadata' => '-1',
|
||||
'c:v' => 'copy',
|
||||
'c:a' => 'copy',
|
||||
},
|
||||
},
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_STYLES = {
|
||||
|
@ -101,15 +106,15 @@ class MediaAttachment < ApplicationRecord
|
|||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
|
||||
},
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
format: 'png',
|
||||
time: 0,
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
},
|
||||
}.freeze,
|
||||
|
||||
original: VIDEO_FORMAT.merge(passthrough_options: VIDEO_PASSTHROUGH_OPTIONS),
|
||||
original: VIDEO_FORMAT.merge(passthrough_options: VIDEO_PASSTHROUGH_OPTIONS).freeze,
|
||||
}.freeze
|
||||
|
||||
AUDIO_STYLES = {
|
||||
|
@ -119,16 +124,23 @@ class MediaAttachment < ApplicationRecord
|
|||
convert_options: {
|
||||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
'map_metadata' => '-1',
|
||||
'q:a' => 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_CONVERTED_STYLES = {
|
||||
small: VIDEO_STYLES[:small],
|
||||
original: VIDEO_FORMAT,
|
||||
small: VIDEO_STYLES[:small].freeze,
|
||||
original: VIDEO_FORMAT.freeze,
|
||||
}.freeze
|
||||
|
||||
THUMBNAIL_STYLES = {
|
||||
original: IMAGE_STYLES[:small].freeze,
|
||||
}.freeze
|
||||
|
||||
GLOBAL_CONVERT_OPTIONS = {
|
||||
all: '-quality 90 -strip +set modify-date +set create-date',
|
||||
}.freeze
|
||||
|
||||
IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i
|
||||
|
@ -144,18 +156,28 @@ class MediaAttachment < ApplicationRecord
|
|||
has_attached_file :file,
|
||||
styles: ->(f) { file_styles f },
|
||||
processors: ->(f) { file_processors f },
|
||||
convert_options: { all: '-quality 90 -strip +set modify-date +set create-date' }
|
||||
convert_options: GLOBAL_CONVERT_OPTIONS
|
||||
|
||||
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
|
||||
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
|
||||
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :larger_media_format?
|
||||
remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false
|
||||
remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false, download_on_assign: false, attribute_name: :remote_url
|
||||
|
||||
has_attached_file :thumbnail,
|
||||
styles: THUMBNAIL_STYLES,
|
||||
processors: [:lazy_thumbnail, :blurhash_transcoder],
|
||||
convert_options: GLOBAL_CONVERT_OPTIONS
|
||||
|
||||
validates_attachment_content_type :thumbnail, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :thumbnail, less_than: IMAGE_LIMIT
|
||||
remotable_attachment :thumbnail, IMAGE_LIMIT, suppress_errors: true, download_on_assign: false
|
||||
|
||||
include Attachmentable
|
||||
|
||||
validates :account, presence: true
|
||||
validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?
|
||||
validates :file, presence: true, if: :local?
|
||||
validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? }
|
||||
|
||||
scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
|
||||
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
|
||||
|
@ -215,16 +237,21 @@ class MediaAttachment < ApplicationRecord
|
|||
@delay_processing
|
||||
end
|
||||
|
||||
def delay_processing_for_attachment?(attachment_name)
|
||||
@delay_processing && attachment_name == :file
|
||||
end
|
||||
|
||||
after_commit :enqueue_processing, on: :create
|
||||
after_commit :reset_parent_cache, on: :update
|
||||
|
||||
before_create :prepare_description, unless: :local?
|
||||
before_create :set_shortcode
|
||||
before_create :set_processing
|
||||
before_create :set_meta
|
||||
|
||||
before_post_process :set_type_and_extension
|
||||
before_post_process :check_video_dimensions
|
||||
after_post_process :set_meta
|
||||
|
||||
before_file_post_process :set_type_and_extension
|
||||
before_file_post_process :check_video_dimensions
|
||||
|
||||
class << self
|
||||
def supported_mime_types
|
||||
|
@ -237,25 +264,25 @@ class MediaAttachment < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
def file_styles(f)
|
||||
if f.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type)
|
||||
def file_styles(attachment)
|
||||
if attachment.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
VIDEO_CONVERTED_STYLES
|
||||
elsif IMAGE_MIME_TYPES.include?(f.instance.file_content_type)
|
||||
elsif IMAGE_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
IMAGE_STYLES
|
||||
elsif VIDEO_MIME_TYPES.include?(f.instance.file_content_type)
|
||||
elsif VIDEO_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
VIDEO_STYLES
|
||||
else
|
||||
AUDIO_STYLES
|
||||
end
|
||||
end
|
||||
|
||||
def file_processors(f)
|
||||
if f.file_content_type == 'image/gif'
|
||||
def file_processors(instance)
|
||||
if instance.file_content_type == 'image/gif'
|
||||
[:gif_transcoder, :blurhash_transcoder]
|
||||
elsif VIDEO_MIME_TYPES.include?(f.file_content_type)
|
||||
elsif VIDEO_MIME_TYPES.include?(instance.file_content_type)
|
||||
[:video_transcoder, :blurhash_transcoder, :type_corrector]
|
||||
elsif AUDIO_MIME_TYPES.include?(f.file_content_type)
|
||||
[:transcoder, :type_corrector]
|
||||
elsif AUDIO_MIME_TYPES.include?(instance.file_content_type)
|
||||
[:image_extractor, :transcoder, :type_corrector]
|
||||
else
|
||||
[:lazy_thumbnail, :blurhash_transcoder, :type_corrector]
|
||||
end
|
||||
|
@ -298,7 +325,7 @@ class MediaAttachment < ApplicationRecord
|
|||
def check_video_dimensions
|
||||
return unless (video? || gifv?) && file.queued_for_write[:original].present?
|
||||
|
||||
movie = FFMPEG::Movie.new(file.queued_for_write[:original].path)
|
||||
movie = ffmpeg_data(file.queued_for_write[:original].path)
|
||||
|
||||
return unless movie.valid?
|
||||
|
||||
|
@ -317,6 +344,8 @@ class MediaAttachment < ApplicationRecord
|
|||
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
|
||||
end
|
||||
|
||||
meta[:small] = image_geometry(thumbnail.queued_for_write[:original]) if thumbnail.queued_for_write.key?(:original)
|
||||
|
||||
meta
|
||||
end
|
||||
|
||||
|
@ -334,7 +363,7 @@ class MediaAttachment < ApplicationRecord
|
|||
end
|
||||
|
||||
def video_metadata(file)
|
||||
movie = FFMPEG::Movie.new(file.path)
|
||||
movie = ffmpeg_data(file.path)
|
||||
|
||||
return {} unless movie.valid?
|
||||
|
||||
|
@ -347,6 +376,13 @@ class MediaAttachment < ApplicationRecord
|
|||
}.compact
|
||||
end
|
||||
|
||||
# We call this method about 3 different times on potentially different
|
||||
# paths but ultimately the same file, so it makes sense to memoize the
|
||||
# result while disregarding the path
|
||||
def ffmpeg_data(path = nil)
|
||||
@ffmpeg_data ||= FFMPEG::Movie.new(path)
|
||||
end
|
||||
|
||||
def enqueue_processing
|
||||
PostProcessMediaWorker.perform_async(id) if delay_processing?
|
||||
end
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
class AccountRelationshipsPresenter
|
||||
attr_reader :following, :followed_by, :blocking, :blocked_by,
|
||||
:muting, :requested, :domain_blocking,
|
||||
:endorsed
|
||||
:endorsed, :account_note
|
||||
|
||||
def initialize(account_ids, current_account_id, **options)
|
||||
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
|
||||
|
@ -17,6 +17,7 @@ class AccountRelationshipsPresenter
|
|||
@requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
|
||||
@domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
|
||||
@endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
|
||||
@account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
|
||||
|
||||
cache_uncached!
|
||||
|
||||
|
@ -28,6 +29,7 @@ class AccountRelationshipsPresenter
|
|||
@requested.merge!(options[:requested_map] || {})
|
||||
@domain_blocking.merge!(options[:domain_blocking_map] || {})
|
||||
@endorsed.merge!(options[:endorsed_map] || {})
|
||||
@account_note.merge!(options[:account_note_map] || {})
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -44,6 +46,7 @@ class AccountRelationshipsPresenter
|
|||
requested: {},
|
||||
domain_blocking: {},
|
||||
endorsed: {},
|
||||
account_note: {},
|
||||
}
|
||||
|
||||
@uncached_account_ids = []
|
||||
|
@ -72,6 +75,7 @@ class AccountRelationshipsPresenter
|
|||
requested: { account_id => requested[account_id] },
|
||||
domain_blocking: { account_id => domain_blocking[account_id] },
|
||||
endorsed: { account_id => endorsed[account_id] },
|
||||
account_note: { account_id => account_note[account_id] },
|
||||
}
|
||||
|
||||
Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
|
||||
|
|
|
@ -172,6 +172,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||
attributes :type, :media_type, :url, :name, :blurhash
|
||||
attribute :focal_point, if: :focal_point?
|
||||
|
||||
has_one :icon, serializer: ActivityPub::ImageSerializer, if: :thumbnail?
|
||||
|
||||
def type
|
||||
'Document'
|
||||
end
|
||||
|
@ -195,6 +197,14 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||
def focal_point
|
||||
[object.file.meta['focus']['x'], object.file.meta['focus']['y']]
|
||||
end
|
||||
|
||||
def icon
|
||||
object.thumbnail
|
||||
end
|
||||
|
||||
def thumbnail?
|
||||
object.thumbnail.present?
|
||||
end
|
||||
end
|
||||
|
||||
class MentionSerializer < ActivityPub::Serializer
|
||||
|
|
|
@ -28,7 +28,9 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
|
|||
def preview_url
|
||||
if object.needs_redownload?
|
||||
media_proxy_url(object.id, :small)
|
||||
else
|
||||
elsif object.thumbnail.present?
|
||||
full_asset_url(object.thumbnail.url(:original))
|
||||
elsif object.file.styles.key?(:small)
|
||||
full_asset_url(object.file.url(:small))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
class REST::RelationshipSerializer < ActiveModel::Serializer
|
||||
attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
|
||||
:muting, :muting_notifications, :requested, :domain_blocking,
|
||||
:endorsed
|
||||
:endorsed, :note
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
|
@ -50,4 +50,8 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
|
|||
def endorsed
|
||||
instance_options[:relationships].endorsed[object.id] || false
|
||||
end
|
||||
|
||||
def note
|
||||
(instance_options[:relationships].account_note[object.id] || {})[:comment]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -89,8 +89,8 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
end
|
||||
|
||||
def set_fetchable_attributes!
|
||||
@account.avatar_remote_url = image_url('icon') unless skip_download?
|
||||
@account.header_remote_url = image_url('image') unless skip_download?
|
||||
@account.avatar_remote_url = image_url('icon') || '' unless skip_download?
|
||||
@account.header_remote_url = image_url('image') || '' unless skip_download?
|
||||
@account.public_key = public_key || ''
|
||||
@account.statuses_count = outbox_total_items if outbox_total_items.present?
|
||||
@account.following_count = following_total_items if following_total_items.present?
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: audio.file.url(:original), poster: full_asset_url(status.account.avatar_static_url), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
|
||||
= react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: audio.file.url(:original), poster: full_asset_url(status.account.avatar_static_url), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
|
||||
= react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
|
|
|
@ -12,6 +12,8 @@ class MoveWorker
|
|||
else
|
||||
queue_follow_unfollows!
|
||||
end
|
||||
|
||||
copy_account_notes!
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
@ -34,4 +36,19 @@ class MoveWorker
|
|||
UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id, bypass_locked] }
|
||||
end
|
||||
end
|
||||
|
||||
def copy_account_notes!
|
||||
AccountNote.where(target_account: @source_account).find_each do |note|
|
||||
text = I18n.with_locale(note.account.user.locale || I18n.default_locale) do
|
||||
I18n.t('move_handler.copy_account_note_text', acct: @source_account.acct)
|
||||
end
|
||||
|
||||
new_note = AccountNote.find_by(account: note.account, target_account: @target_account)
|
||||
if new_note.nil?
|
||||
AccountNote.create!(account: note.account, target_account: @target_account, comment: [text, note.comment].join('\n'))
|
||||
else
|
||||
new_note.update!(comment: [text, note.comment, '\n', new_note.comment].join('\n'))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,7 +32,7 @@ class PostProcessMediaWorker
|
|||
|
||||
media_attachment.file.reprocess!(:original)
|
||||
media_attachment.processing = :complete
|
||||
media_attachment.file_meta = previous_meta
|
||||
media_attachment.file_meta = previous_meta.merge(media_attachment.file_meta).with_indifferent_access.slice(:focus, :original, :small)
|
||||
media_attachment.save
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
|
|
|
@ -11,7 +11,8 @@ class RedownloadMediaWorker
|
|||
|
||||
return if media_attachment.remote_url.blank?
|
||||
|
||||
media_attachment.file_remote_url = media_attachment.remote_url
|
||||
media_attachment.download_file!
|
||||
media_attachment.download_thumbnail!
|
||||
media_attachment.save
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
|
@ -0,0 +1,35 @@
|
|||
apiVersion: v2
|
||||
name: mastodon
|
||||
description: Mastodon is a free, open-source social network server based on ActivityPub.
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
# to be deployed.
|
||||
#
|
||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.1.0
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
appVersion: 3.1.4
|
||||
|
||||
dependencies:
|
||||
- name: elasticsearch
|
||||
version: "12.x.x"
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: elasticsearch.enabled
|
||||
- name: postgresql
|
||||
version: "8.x.x"
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
- name: redis
|
||||
version: "10.x.x"
|
||||
repository: https://charts.bitnami.com/bitnami
|
|
@ -0,0 +1,44 @@
|
|||
# Introduction
|
||||
|
||||
This is a [Helm](https://helm.sh/) chart for installing Mastodon into a
|
||||
Kubernetes cluster. The basic usage is:
|
||||
|
||||
```
|
||||
cp values.yaml.template values.yaml
|
||||
edit values.yaml # configure required settings
|
||||
helm dep update
|
||||
helm upgrade --install my-mastodon ./
|
||||
```
|
||||
|
||||
This chart has been tested on Helm 3.0.1 and above.
|
||||
|
||||
# Configuration
|
||||
|
||||
The variables that _must_ be configured are:
|
||||
|
||||
- `ingress.hostname`; even if you aren’t using an Ingress, this value is used to
|
||||
set `LOCAL_DOMAIN`.
|
||||
|
||||
- password and keys in the `secrets`, `postgresql`, and `redis` groups; if
|
||||
left blank, some of those values will be autogenerated, but will not persist
|
||||
across upgrades.
|
||||
|
||||
- SMTP settings for your mailer in the `smtp` group.
|
||||
|
||||
# Missing features
|
||||
|
||||
Currently this chart does _not_ support:
|
||||
|
||||
- Hidden services
|
||||
- S3/Minio/GCS
|
||||
- Single Sign-On
|
||||
- Swift
|
||||
- configurations using `WEB_DOMAIN`
|
||||
|
||||
# Upgrading
|
||||
|
||||
Because database migrations are managed as a Job separate from the Rails and
|
||||
Sidekiq deployments, it’s possible they will occur in the wrong order. After
|
||||
upgrading Mastodon versions, it may sometimes be necessary to manually delete
|
||||
the Rails and Sidekiq pods so that they are recreated against the latest
|
||||
migration.
|
|
@ -0,0 +1,21 @@
|
|||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mastodon.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "mastodon.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "mastodon.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "mastodon.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80
|
||||
{{- end }}
|
|
@ -0,0 +1,79 @@
|
|||
{{/* vim: set filetype=mustache: */}}
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "mastodon.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "mastodon.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "mastodon.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "mastodon.labels" -}}
|
||||
helm.sh/chart: {{ include "mastodon.chart" . }}
|
||||
{{ include "mastodon.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "mastodon.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "mastodon.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "mastodon.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "mastodon.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified name for dependent services.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
*/}}
|
||||
{{- define "mastodon.elasticsearch.fullname" -}}
|
||||
{{- printf "%s-%s" .Release.Name "elasticsearch" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "mastodon.redis.fullname" -}}
|
||||
{{- printf "%s-%s" .Release.Name "redis" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "mastodon.postgresql.fullname" -}}
|
||||
{{- printf "%s-%s" .Release.Name "postgresql" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
|
@ -0,0 +1,65 @@
|
|||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-env
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
data:
|
||||
DB_HOST: {{ template "mastodon.postgresql.fullname" . }}
|
||||
DB_NAME: {{ .Values.postgresql.postgresqlDatabase }}
|
||||
DB_POOL: {{ .Values.application.sidekiq.concurrency | quote }}
|
||||
DB_PORT: "5432"
|
||||
DB_USER: {{ .Values.postgresql.postgresqlUsername }}
|
||||
DEFAULT_LOCALE: {{ .Values.locale }}
|
||||
{{- if .Values.elasticsearch.enabled }}
|
||||
ES_ENABLED: "true"
|
||||
ES_HOST: {{ template "mastodon.elasticsearch.fullname" . }}-master
|
||||
ES_PORT: "9200"
|
||||
{{- end }}
|
||||
LOCAL_DOMAIN: {{ .Values.ingress.hostname }}
|
||||
# https://devcenter.heroku.com/articles/tuning-glibc-memory-behavior
|
||||
MALLOC_ARENA_MAX: "2"
|
||||
NODE_ENV: "production"
|
||||
RAILS_ENV: "production"
|
||||
REDIS_HOST: {{ template "mastodon.redis.fullname" . }}-master
|
||||
REDIS_PORT: "6379"
|
||||
{{- if .Values.smtp.auth_method }}
|
||||
SMTP_AUTH_METHOD: {{ .Values.smtp.auth_method }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.ca_file }}
|
||||
SMTP_CA_FILE: {{ .Values.smtp.ca_file }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.delivery_method }}
|
||||
SMTP_DELIVERY_METHOD: {{ .Values.smtp.delivery_method }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.domain }}
|
||||
SMTP_DOMAIN: {{ .Values.smtp.domain }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.enable_starttls_auto }}
|
||||
SMTP_ENABLE_STARTTLS_AUTO: {{ .Values.smtp.enable_starttls_auto | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.from_address }}
|
||||
SMTP_FROM_ADDRESS: {{ .Values.smtp.from_address }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.login }}
|
||||
SMTP_LOGIN: {{ .Values.smtp.login }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.openssl_verify_mode }}
|
||||
SMTP_OPENSSL_VERIFY_MODE: {{ .Values.smtp.openssl_verify_mode }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.password }}
|
||||
SMTP_PASSWORD: {{ .Values.smtp.password }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.port }}
|
||||
SMTP_PORT: {{ .Values.smtp.port | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.reply_to }}
|
||||
SMTP_REPLY_TO: {{ .Values.smtp.reply_to }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.server }}
|
||||
SMTP_SERVER: {{ .Values.smtp.server }}
|
||||
{{- end }}
|
||||
{{- if .Values.smtp.tls }}
|
||||
SMTP_TLS: {{ .Values.smtp.tls | quote }}
|
||||
{{- end }}
|
||||
STREAMING_CLUSTER_NUM: {{ .Values.application.streaming.workers | quote }}
|
|
@ -0,0 +1,97 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-sidekiq
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "mastodon.selectorLabels" . | nindent 6 }}
|
||||
component: rails
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
# roll the pods to pick up any db migrations
|
||||
rollme: {{ randAlphaNum 5 | quote }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "mastodon.selectorLabels" . | nindent 8 }}
|
||||
component: rails
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "mastodon.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
# ensure we run on the same node as the other rails components; only
|
||||
# required when using PVCs that are ReadWriteOnce
|
||||
{{- if or (eq "ReadWriteOnce" .Values.persistence.assets.accessMode) (eq "ReadWriteOnce" .Values.persistence.system.accessMode) }}
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: component
|
||||
operator: In
|
||||
values:
|
||||
- rails
|
||||
topologyKey: kubernetes.io/hostname
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: assets
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-assets
|
||||
- name: system
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-system
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- sidekiq
|
||||
- -c
|
||||
- {{ .Values.application.sidekiq.concurrency | quote }}
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "mastodon.fullname" . }}-env
|
||||
- secretRef:
|
||||
name: {{ template "mastodon.fullname" . }}
|
||||
env:
|
||||
- name: "DB_PASS"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-postgresql
|
||||
key: postgresql-password
|
||||
- name: "REDIS_PASSWORD"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-redis
|
||||
key: redis-password
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /opt/mastodon/public/assets
|
||||
- name: system
|
||||
mountPath: /opt/mastodon/public/system
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
|
@ -0,0 +1,80 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-streaming
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "mastodon.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "mastodon.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "mastodon.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- node
|
||||
- ./streaming
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "mastodon.fullname" . }}-env
|
||||
env:
|
||||
- name: "DB_PASS"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-postgresql
|
||||
key: postgresql-password
|
||||
- name: "REDIS_PASSWORD"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-redis
|
||||
key: redis-password
|
||||
- name: "PORT"
|
||||
value: {{ .Values.application.streaming.port | quote }}
|
||||
ports:
|
||||
- name: streaming
|
||||
containerPort: {{ .Values.application.streaming.port }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/streaming/health
|
||||
port: streaming
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/streaming/health
|
||||
port: streaming
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
|
@ -0,0 +1,101 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-web
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "mastodon.selectorLabels" . | nindent 6 }}
|
||||
component: rails
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
# roll the pods to pick up any db migrations
|
||||
rollme: {{ randAlphaNum 5 | quote }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "mastodon.selectorLabels" . | nindent 8 }}
|
||||
component: rails
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "mastodon.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
volumes:
|
||||
- name: assets
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-assets
|
||||
- name: system
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-system
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- puma
|
||||
- -C
|
||||
- config/puma.rb
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "mastodon.fullname" . }}-env
|
||||
- secretRef:
|
||||
name: {{ template "mastodon.fullname" . }}
|
||||
env:
|
||||
- name: "DB_PASS"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-postgresql
|
||||
key: postgresql-password
|
||||
- name: "REDIS_PASSWORD"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-redis
|
||||
key: redis-password
|
||||
- name: "PORT"
|
||||
value: {{ .Values.application.web.port | quote }}
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /opt/mastodon/public/assets
|
||||
- name: system
|
||||
mountPath: /opt/mastodon/public/system
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.application.web.port }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
|
@ -0,0 +1,28 @@
|
|||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2beta1
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "mastodon.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
|
@ -0,0 +1,41 @@
|
|||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "mastodon.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.ingress.hostname | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: '/'
|
||||
backend:
|
||||
serviceName: {{ $fullName }}-web
|
||||
servicePort: {{ $svcPort }}
|
||||
- path: '/api/v1/streaming'
|
||||
backend:
|
||||
serviceName: {{ $fullName }}-streaming
|
||||
servicePort: {{ .Values.application.streaming.port }}
|
||||
{{- end }}
|
|
@ -0,0 +1,69 @@
|
|||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-assets-precompile
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": post-install
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
"helm.sh/hook-weight": "-2"
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-assets-precompile
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
# ensure we run on the same node as the other rails components; only
|
||||
# required when using PVCs that are ReadWriteOnce
|
||||
{{- if or (eq "ReadWriteOnce" .Values.persistence.assets.accessMode) (eq "ReadWriteOnce" .Values.persistence.system.accessMode) }}
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: component
|
||||
operator: In
|
||||
values:
|
||||
- rails
|
||||
topologyKey: kubernetes.io/hostname
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: assets
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-assets
|
||||
- name: system
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-system
|
||||
containers:
|
||||
- name: {{ include "mastodon.fullname" . }}-assets-precompile
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
bundle exec rake assets:precompile && yarn cache clean
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "mastodon.fullname" . }}-env
|
||||
- secretRef:
|
||||
name: {{ template "mastodon.fullname" . }}
|
||||
env:
|
||||
- name: "DB_PASS"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-postgresql
|
||||
key: postgresql-password
|
||||
- name: "REDIS_PASSWORD"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-redis
|
||||
key: redis-password
|
||||
- name: "PORT"
|
||||
value: {{ .Values.application.web.port | quote }}
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /opt/mastodon/public/assets
|
||||
- name: system
|
||||
mountPath: /opt/mastodon/public/system
|
|
@ -0,0 +1,71 @@
|
|||
{{- if .Values.elasticsearch.enabled }}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-chewy-upgrade
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": post-install
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
"helm.sh/hook-weight": "-1"
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-chewy-upgrade
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
# ensure we run on the same node as the other rails components; only
|
||||
# required when using PVCs that are ReadWriteOnce
|
||||
{{- if or (eq "ReadWriteOnce" .Values.persistence.assets.accessMode) (eq "ReadWriteOnce" .Values.persistence.system.accessMode) }}
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: component
|
||||
operator: In
|
||||
values:
|
||||
- rails
|
||||
topologyKey: kubernetes.io/hostname
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: assets
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-assets
|
||||
- name: system
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-system
|
||||
containers:
|
||||
- name: {{ include "mastodon.fullname" . }}-chewy-setup
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- rake
|
||||
- chewy:upgrade
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "mastodon.fullname" . }}-env
|
||||
- secretRef:
|
||||
name: {{ template "mastodon.fullname" . }}
|
||||
env:
|
||||
- name: "DB_PASS"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-postgresql
|
||||
key: postgresql-password
|
||||
- name: "REDIS_PASSWORD"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-redis
|
||||
key: redis-password
|
||||
- name: "PORT"
|
||||
value: {{ .Values.application.web.port | quote }}
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /opt/mastodon/public/assets
|
||||
- name: system
|
||||
mountPath: /opt/mastodon/public/system
|
||||
{{- end }}
|
|
@ -0,0 +1,76 @@
|
|||
{{- if .Values.createAdmin.enabled }}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-create-admin
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": post-install
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
"helm.sh/hook-weight": "-1"
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-create-admin
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
# ensure we run on the same node as the other rails components; only
|
||||
# required when using PVCs that are ReadWriteOnce
|
||||
{{- if or (eq "ReadWriteOnce" .Values.persistence.assets.accessMode) (eq "ReadWriteOnce" .Values.persistence.system.accessMode) }}
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: component
|
||||
operator: In
|
||||
values:
|
||||
- rails
|
||||
topologyKey: kubernetes.io/hostname
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: assets
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-assets
|
||||
- name: system
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-system
|
||||
containers:
|
||||
- name: {{ include "mastodon.fullname" . }}-create-admin
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- bin/tootctl
|
||||
- accounts
|
||||
- create
|
||||
- {{ .Values.createAdmin.username }}
|
||||
- --email
|
||||
- {{ .Values.createAdmin.email }}
|
||||
- --confirmed
|
||||
- --role
|
||||
- admin
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "mastodon.fullname" . }}-env
|
||||
- secretRef:
|
||||
name: {{ template "mastodon.fullname" . }}
|
||||
env:
|
||||
- name: "DB_PASS"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-postgresql
|
||||
key: postgresql-password
|
||||
- name: "REDIS_PASSWORD"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-redis
|
||||
key: redis-password
|
||||
- name: "PORT"
|
||||
value: {{ .Values.application.web.port | quote }}
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /opt/mastodon/public/assets
|
||||
- name: system
|
||||
mountPath: /opt/mastodon/public/system
|
||||
{{- end }}
|
|
@ -0,0 +1,69 @@
|
|||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-db-migrate
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,pre-upgrade
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
"helm.sh/hook-weight": "-2"
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-db-migrate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
# ensure we run on the same node as the other rails components; only
|
||||
# required when using PVCs that are ReadWriteOnce
|
||||
{{- if or (eq "ReadWriteOnce" .Values.persistence.assets.accessMode) (eq "ReadWriteOnce" .Values.persistence.system.accessMode) }}
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: component
|
||||
operator: In
|
||||
values:
|
||||
- rails
|
||||
topologyKey: kubernetes.io/hostname
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: assets
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-assets
|
||||
- name: system
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ template "mastodon.fullname" . }}-system
|
||||
containers:
|
||||
- name: {{ include "mastodon.fullname" . }}-db-migrate
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- bundle
|
||||
- exec
|
||||
- rake
|
||||
- db:migrate
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "mastodon.fullname" . }}-env
|
||||
- secretRef:
|
||||
name: {{ template "mastodon.fullname" . }}
|
||||
env:
|
||||
- name: "DB_PASS"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-postgresql
|
||||
key: postgresql-password
|
||||
- name: "REDIS_PASSWORD"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-redis
|
||||
key: redis-password
|
||||
- name: "PORT"
|
||||
value: {{ .Values.application.web.port | quote }}
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
mountPath: /opt/mastodon/public/assets
|
||||
- name: system
|
||||
mountPath: /opt/mastodon/public/system
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ template "mastodon.fullname" . }}-assets
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.system.accessMode }}
|
||||
resources:
|
||||
{{- toYaml .Values.persistence.assets.resources | nindent 4}}
|
||||
storageClassName: {{ .Values.persistence.assets.storageClassName }}
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ template "mastodon.fullname" . }}-system
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.system.accessMode }}
|
||||
resources:
|
||||
{{- toYaml .Values.persistence.system.resources | nindent 4}}
|
||||
storageClassName: {{ .Values.persistence.system.storageClassName }}
|
|
@ -0,0 +1,28 @@
|
|||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ template "mastodon.fullname" . }}
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{- if not (empty .Values.secrets.secret_key_base) }}
|
||||
SECRET_KEY_BASE: "{{ .Values.secrets.secret_key_base | b64enc }}"
|
||||
{{- else }}
|
||||
SECRET_KEY_BASE: {{ required "secret_key_base is required" .Values.secrets.secret_key_base }}
|
||||
{{- end }}
|
||||
{{- if not (empty .Values.secrets.otp_secret) }}
|
||||
OTP_SECRET: "{{ .Values.secrets.otp_secret | b64enc }}"
|
||||
{{- else }}
|
||||
OTP_SECRET: {{ required "otp_secret is required" .Values.secrets.otp_secret }}
|
||||
{{- end }}
|
||||
{{- if not (empty .Values.secrets.vapid.private_key) }}
|
||||
VAPID_PRIVATE_KEY: "{{ .Values.secrets.vapid.private_key | b64enc }}"
|
||||
{{- else }}
|
||||
VAPID_PRIVATE_KEY: {{ required "vapid.private_key is required" .Values.secrets.vapid.private_key }}
|
||||
{{- end }}
|
||||
{{- if not (empty .Values.secrets.vapid.public_key) }}
|
||||
VAPID_PUBLIC_KEY: "{{ .Values.secrets.vapid.public_key | b64enc }}"
|
||||
{{- else }}
|
||||
VAPID_PUBLIC_KEY: {{ required "vapid.public_key is required" .Values.secrets.vapid.public_key }}
|
||||
{{- end }}
|
|
@ -0,0 +1,15 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-streaming
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.application.streaming.port }}
|
||||
targetPort: streaming
|
||||
protocol: TCP
|
||||
name: streaming
|
||||
selector:
|
||||
{{- include "mastodon.selectorLabels" . | nindent 4 }}
|
|
@ -0,0 +1,15 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-web
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "mastodon.selectorLabels" . | nindent 4 }}
|
|
@ -0,0 +1,12 @@
|
|||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "mastodon.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
|
@ -0,0 +1,15 @@
|
|||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: "{{ include "mastodon.fullname" . }}-test-connection"
|
||||
labels:
|
||||
{{- include "mastodon.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": test-success
|
||||
spec:
|
||||
containers:
|
||||
- name: wget
|
||||
image: busybox
|
||||
command: ['wget']
|
||||
args: ['{{ include "mastodon.fullname" . }}:{{ .Values.service.port }}']
|
||||
restartPolicy: Never
|
|
@ -0,0 +1,163 @@
|
|||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: tootsuite/mastodon
|
||||
pullPolicy: Always
|
||||
# https://hub.docker.com/r/tootsuite/mastodon/tags
|
||||
tag: v3.1.4
|
||||
# alternatively, use `latest` for the latest release or `edge` for the image
|
||||
# built from the most recent commit
|
||||
#
|
||||
# tag: latest
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
kubernetes.io/tls-acme: "true"
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt"
|
||||
# this value is used for LOCAL_DOMAIN
|
||||
hostname: mastodon.local
|
||||
tls:
|
||||
- secretName: mastodon-tls
|
||||
hosts:
|
||||
- mastodon.local
|
||||
|
||||
# create an initial administrator user; the password is autogenerated and will
|
||||
# have to be reset
|
||||
createAdmin:
|
||||
enabled: false
|
||||
username: not_gargron
|
||||
email: not@example.com
|
||||
|
||||
# available locales: https://github.com/tootsuite/mastodon/blob/master/config/application.rb#L43
|
||||
locale: en
|
||||
|
||||
application:
|
||||
web:
|
||||
port: 3000
|
||||
streaming:
|
||||
port: 4000
|
||||
# this should be set manually since os.cpus() returns the number of CPUs on
|
||||
# the node running the pod, which is unrelated to the resources allocated to
|
||||
# the pod by k8s
|
||||
workers: 1
|
||||
sidekiq:
|
||||
concurrency: 25
|
||||
|
||||
# these must be set manually; autogenerated keys are rotated on each upgrade
|
||||
secrets:
|
||||
secret_key_base: ""
|
||||
otp_secret: ""
|
||||
vapid:
|
||||
private_key: ""
|
||||
public_key: ""
|
||||
|
||||
smtp:
|
||||
auth_method: plain
|
||||
ca_file:
|
||||
delivery_method: smtp
|
||||
domain:
|
||||
enable_starttls_auto: true
|
||||
from_address: notifications@example.com
|
||||
login:
|
||||
openssl_verify_mode: peer
|
||||
password:
|
||||
port: 587
|
||||
reply_to:
|
||||
server: smtp.mailgun.org
|
||||
tls: false
|
||||
|
||||
# https://github.com/bitnami/charts/tree/master/bitnami/elasticsearch#parameters
|
||||
elasticsearch:
|
||||
# `false` will disable full-text search
|
||||
#
|
||||
# if you enable ES after the initial install, you will need to manually run
|
||||
# RAILS_ENV=production bundle exec rake chewy:sync
|
||||
# (https://docs.joinmastodon.org/admin/optional/elasticsearch/)
|
||||
enabled: true
|
||||
# may be removed once https://github.com/tootsuite/mastodon/pull/13828 is part
|
||||
# of a tagged release
|
||||
image:
|
||||
tag: 6
|
||||
|
||||
# https://github.com/bitnami/charts/tree/master/bitnami/postgresql#parameters
|
||||
postgresql:
|
||||
postgresqlDatabase: mastodon_production
|
||||
# you must set a password; the password generated by the postgresql chart will
|
||||
# be rotated on each upgrade:
|
||||
# https://github.com/bitnami/charts/tree/master/bitnami/postgresql#upgrade
|
||||
postgresqlPassword: ""
|
||||
postgresqlUsername: postgres
|
||||
|
||||
# https://github.com/bitnami/charts/tree/master/bitnami/redis#parameters
|
||||
redis:
|
||||
# you must set a password; the password generated by the redis chart will be
|
||||
# rotated on each upgrade:
|
||||
password: ""
|
||||
|
||||
persistence:
|
||||
assets:
|
||||
# ReadWriteOnce is more widely supported than ReadWriteMany, but limits
|
||||
# scalability, since it requires the Rails and Sidekiq pods to run on the
|
||||
# same node.
|
||||
accessMode: ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 100Gi
|
||||
system:
|
||||
accessMode: ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
# https://github.com/tootsuite/mastodon/blob/master/Dockerfile#L88
|
||||
#
|
||||
# if you manually change the UID/GID environment variables, ensure these values
|
||||
# match:
|
||||
podSecurityContext:
|
||||
runAsUser: 991
|
||||
runAsGroup: 991
|
||||
fsGroup: 991
|
||||
|
||||
securityContext: {}
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
resources: {}
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 100
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
|
@ -1,5 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Rails.application.configure do
|
||||
config.x.whitelist_mode = ENV['WHITELIST_MODE'] == 'true'
|
||||
config.x.whitelist_mode = (ENV['LIMITED_FEDERATION_MODE'] || ENV['WHITELIST_MODE']) == 'true'
|
||||
end
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Rails.application.configure do
|
||||
config.x.email_domains_blacklist = ENV.fetch('EMAIL_DOMAIN_BLACKLIST') { 'mvrht.com' }
|
||||
config.x.email_domains_whitelist = ENV.fetch('EMAIL_DOMAIN_WHITELIST') { '' }
|
||||
config.x.email_domains_blacklist = (ENV['EMAIL_DOMAIN_DENYLIST'] || ENV['EMAIL_DOMAIN_BLACKLIST']) || ''
|
||||
config.x.email_domains_whitelist = (ENV['EMAIL_DOMAIN_ALLOWLIST'] || ENV['EMAIL_DOMAIN_WHITELIST']) || ''
|
||||
end
|
||||
|
|
|
@ -95,7 +95,7 @@ en:
|
|||
delete: Delete
|
||||
destroyed_msg: Moderation note successfully destroyed!
|
||||
accounts:
|
||||
add_email_domain_block: Blacklist e-mail domain
|
||||
add_email_domain_block: Block e-mail domain
|
||||
approve: Approve
|
||||
approve_all: Approve all
|
||||
are_you_sure: Are you sure?
|
||||
|
@ -196,7 +196,7 @@ en:
|
|||
username: Username
|
||||
warn: Warn
|
||||
web: Web
|
||||
whitelisted: Whitelisted
|
||||
whitelisted: Allowed for federation
|
||||
action_logs:
|
||||
action_types:
|
||||
assigned_to_self_report: Assign Report
|
||||
|
@ -241,15 +241,15 @@ en:
|
|||
create_account_warning: "%{name} sent a warning to %{target}"
|
||||
create_announcement: "%{name} created new announcement %{target}"
|
||||
create_custom_emoji: "%{name} uploaded new emoji %{target}"
|
||||
create_domain_allow: "%{name} whitelisted domain %{target}"
|
||||
create_domain_allow: "%{name} allowed federation with domain %{target}"
|
||||
create_domain_block: "%{name} blocked domain %{target}"
|
||||
create_email_domain_block: "%{name} blacklisted e-mail domain %{target}"
|
||||
create_email_domain_block: "%{name} blocked e-mail domain %{target}"
|
||||
demote_user: "%{name} demoted user %{target}"
|
||||
destroy_announcement: "%{name} deleted announcement %{target}"
|
||||
destroy_custom_emoji: "%{name} destroyed emoji %{target}"
|
||||
destroy_domain_allow: "%{name} removed domain %{target} from whitelist"
|
||||
destroy_domain_allow: "%{name} disallowed federation with domain %{target}"
|
||||
destroy_domain_block: "%{name} unblocked domain %{target}"
|
||||
destroy_email_domain_block: "%{name} whitelisted e-mail domain %{target}"
|
||||
destroy_email_domain_block: "%{name} unblocked e-mail domain %{target}"
|
||||
destroy_status: "%{name} removed status by %{target}"
|
||||
disable_2fa_user: "%{name} disabled two factor requirement for user %{target}"
|
||||
disable_custom_emoji: "%{name} disabled emoji %{target}"
|
||||
|
@ -350,12 +350,12 @@ en:
|
|||
week_interactions: interactions this week
|
||||
week_users_active: active this week
|
||||
week_users_new: users this week
|
||||
whitelist_mode: Whitelist mode
|
||||
whitelist_mode: Limited federation mode
|
||||
domain_allows:
|
||||
add_new: Whitelist domain
|
||||
created_msg: Domain has been successfully whitelisted
|
||||
destroyed_msg: Domain has been removed from the whitelist
|
||||
undo: Remove from whitelist
|
||||
add_new: Allow federation with domain
|
||||
created_msg: Domain has been successfully allowed for federation
|
||||
destroyed_msg: Domain has been disallowed from federation
|
||||
undo: Disallow federation with domain
|
||||
domain_blocks:
|
||||
add_new: Add new domain block
|
||||
created_msg: Domain block is now being processed
|
||||
|
@ -398,16 +398,16 @@ en:
|
|||
view: View domain block
|
||||
email_domain_blocks:
|
||||
add_new: Add new
|
||||
created_msg: Successfully added e-mail domain to blacklist
|
||||
created_msg: Successfully blocked e-mail domain
|
||||
delete: Delete
|
||||
destroyed_msg: Successfully deleted e-mail domain from blacklist
|
||||
destroyed_msg: Successfully unblocked e-mail domain
|
||||
domain: Domain
|
||||
empty: No e-mail domains currently blacklisted.
|
||||
empty: No e-mail domains currently blocked.
|
||||
from_html: from %{domain}
|
||||
new:
|
||||
create: Add domain
|
||||
title: New e-mail blacklist entry
|
||||
title: E-mail blacklist
|
||||
title: Block new e-mail domain
|
||||
title: Blocked e-mail domains
|
||||
instances:
|
||||
by_domain: Domain
|
||||
delivery_available: Delivery is available
|
||||
|
@ -451,7 +451,7 @@ en:
|
|||
pending: Waiting for relay's approval
|
||||
save_and_enable: Save and enable
|
||||
setup: Setup a relay connection
|
||||
signatures_not_enabled: Relays will not work correctly while secure mode or whitelist mode is enabled
|
||||
signatures_not_enabled: Relays will not work correctly while secure mode or limited federation mode is enabled
|
||||
status: Status
|
||||
title: Relays
|
||||
report_notes:
|
||||
|
@ -940,6 +940,8 @@ en:
|
|||
redirect: Your current account's profile will be updated with a redirect notice and be excluded from searches
|
||||
moderation:
|
||||
title: Moderation
|
||||
move_handler:
|
||||
copy_account_note_text: 'This user moved from %{acct}, here were your previous notes about them:'
|
||||
notification_mailer:
|
||||
digest:
|
||||
action: View all notifications
|
||||
|
|
|
@ -438,6 +438,7 @@ Rails.application.routes.draw do
|
|||
|
||||
resource :pin, only: :create, controller: 'accounts/pins'
|
||||
post :unpin, to: 'accounts/pins#destroy'
|
||||
resource :note, only: :create, controller: 'accounts/notes'
|
||||
end
|
||||
|
||||
resources :lists, only: [:index, :create, :show, :update, :destroy] do
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
class AddThumbnailColumnsToMediaAttachments < ActiveRecord::Migration[5.2]
|
||||
def up
|
||||
add_attachment :media_attachments, :thumbnail
|
||||
add_column :media_attachments, :thumbnail_remote_url, :string
|
||||
end
|
||||
|
||||
def down
|
||||
remove_attachment :media_attachments, :thumbnail
|
||||
remove_column :media_attachments, :thumbnail_remote_url
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
class CreateAccountNotes < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
create_table :account_notes do |t|
|
||||
t.references :account, foreign_key: { on_delete: :cascade }, index: false
|
||||
t.references :target_account, foreign_key: { to_table: :accounts, on_delete: :cascade }
|
||||
t.text :comment, null: false
|
||||
t.index [:account_id, :target_account_id], unique: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
|
19
db/schema.rb
19
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2020_06_20_164023) do
|
||||
ActiveRecord::Schema.define(version: 2020_06_28_133322) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -490,6 +490,11 @@ ActiveRecord::Schema.define(version: 2020_06_20_164023) do
|
|||
t.string "blurhash"
|
||||
t.integer "processing"
|
||||
t.integer "file_storage_schema_version"
|
||||
t.string "thumbnail_file_name"
|
||||
t.string "thumbnail_content_type"
|
||||
t.integer "thumbnail_file_size"
|
||||
t.datetime "thumbnail_updated_at"
|
||||
t.string "thumbnail_remote_url"
|
||||
t.index ["account_id"], name: "index_media_attachments_on_account_id"
|
||||
t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
|
||||
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
|
||||
|
@ -831,6 +836,16 @@ ActiveRecord::Schema.define(version: 2020_06_20_164023) do
|
|||
t.index ["user_id"], name: "index_user_invite_requests_on_user_id"
|
||||
end
|
||||
|
||||
create_table "account_notes", force: :cascade do |t|
|
||||
t.bigint "account_id"
|
||||
t.bigint "target_account_id"
|
||||
t.text "comment", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "target_account_id"], name: "index_account_notes_on_account_id_and_target_account_id", unique: true
|
||||
t.index ["target_account_id"], name: "index_account_notes_on_target_account_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "email", default: "", null: false
|
||||
t.datetime "created_at", null: false
|
||||
|
@ -987,6 +1002,8 @@ ActiveRecord::Schema.define(version: 2020_06_20_164023) do
|
|||
add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade
|
||||
add_foreign_key "tombstones", "accounts", on_delete: :cascade
|
||||
add_foreign_key "user_invite_requests", "users", on_delete: :cascade
|
||||
add_foreign_key "account_notes", "accounts", column: "target_account_id", on_delete: :cascade
|
||||
add_foreign_key "account_notes", "accounts", on_delete: :cascade
|
||||
add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade
|
||||
add_foreign_key "users", "invites", on_delete: :nullify
|
||||
add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify
|
||||
|
|
|
@ -54,7 +54,7 @@ module Mastodon
|
|||
desc 'upgrade SUBCOMMAND ...ARGS', 'Various version upgrade utilities'
|
||||
subcommand 'upgrade', Mastodon::UpgradeCLI
|
||||
|
||||
desc 'email-domain-blocks SUBCOMMAND ...ARGS', 'Manage E-mail domain blocks'
|
||||
desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks'
|
||||
subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI
|
||||
|
||||
option :dry_run, type: :boolean
|
||||
|
|
|
@ -16,22 +16,22 @@ module Mastodon
|
|||
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
||||
option :verbose, type: :boolean, aliases: [:v]
|
||||
option :dry_run, type: :boolean
|
||||
option :whitelist_mode, type: :boolean
|
||||
option :limited_federation_mode, type: :boolean
|
||||
desc 'purge [DOMAIN...]', 'Remove accounts from a DOMAIN without a trace'
|
||||
long_desc <<-LONG_DESC
|
||||
Remove all accounts from a given DOMAIN without leaving behind any
|
||||
records. Unlike a suspension, if the DOMAIN still exists in the wild,
|
||||
it means the accounts could return if they are resolved again.
|
||||
|
||||
When the --whitelist-mode option is given, instead of purging accounts
|
||||
from a single domain, all accounts from domains that are not whitelisted
|
||||
When the --limited-federation-mode option is given, instead of purging accounts
|
||||
from a single domain, all accounts from domains that have not been explicitly allowed
|
||||
are removed from the database.
|
||||
LONG_DESC
|
||||
def purge(*domains)
|
||||
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
||||
|
||||
scope = begin
|
||||
if options[:whitelist_mode]
|
||||
if options[:limited_federation_mode]
|
||||
Account.remote.where.not(domain: DomainAllow.pluck(:domain))
|
||||
elsif !domains.empty?
|
||||
Account.remote.where(domain: domains)
|
||||
|
|
|
@ -13,13 +13,11 @@ module Mastodon
|
|||
true
|
||||
end
|
||||
|
||||
desc 'list', 'list E-mail domain blocks'
|
||||
long_desc <<-LONG_DESC
|
||||
list up all E-mail domain blocks.
|
||||
LONG_DESC
|
||||
desc 'list', 'List blocked e-mail domains'
|
||||
def list
|
||||
EmailDomainBlock.where(parent_id: nil).order(id: 'DESC').find_each do |entry|
|
||||
say(entry.domain.to_s, :white)
|
||||
|
||||
EmailDomainBlock.where(parent_id: entry.id).order(id: 'DESC').find_each do |child|
|
||||
say(" #{child.domain}", :cyan)
|
||||
end
|
||||
|
@ -27,13 +25,17 @@ module Mastodon
|
|||
end
|
||||
|
||||
option :with_dns_records, type: :boolean
|
||||
desc 'add [DOMAIN...]', 'add E-mail domain blocks'
|
||||
desc 'add DOMAIN...', 'Block e-mail domain(s)'
|
||||
long_desc <<-LONG_DESC
|
||||
add E-mail domain blocks from a given DOMAIN.
|
||||
Blocking an e-mail domain prevents users from signing up
|
||||
with e-mail addresses from that domain. You can provide one or
|
||||
multiple domains to the command.
|
||||
|
||||
When the --with-dns-records option is given, An attempt to resolve the
|
||||
given domain's DNS records will be made and the results will also be
|
||||
blacklisted.
|
||||
When the --with-dns-records option is given, an attempt to resolve the
|
||||
given domains' DNS records will be made and the results (A, AAAA and MX) will
|
||||
also be blocked. This can be helpful if you are blocking an e-mail server that
|
||||
has many different domains pointing to it as it allows you to essentially block
|
||||
it at the root.
|
||||
LONG_DESC
|
||||
def add(*domains)
|
||||
if domains.empty?
|
||||
|
@ -72,11 +74,13 @@ module Mastodon
|
|||
|
||||
(hostnames + ips).uniq.each do |hostname|
|
||||
another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: email_domain_block)
|
||||
|
||||
if EmailDomainBlock.where(domain: hostname).exists?
|
||||
say("#{hostname} is already blocked.", :yellow)
|
||||
skipped += 1
|
||||
next
|
||||
end
|
||||
|
||||
another_email_domain_block.save!
|
||||
processed += 1
|
||||
end
|
||||
|
@ -85,7 +89,7 @@ module Mastodon
|
|||
say("Added #{processed}, skipped #{skipped}", color(processed, 0))
|
||||
end
|
||||
|
||||
desc 'remove [DOMAIN...]', 'remove E-mail domain blocks'
|
||||
desc 'remove DOMAIN...', 'Remove e-mail domain blocks'
|
||||
def remove(*domains)
|
||||
if domains.empty?
|
||||
say('No domain(s) given', :red)
|
||||
|
@ -98,6 +102,7 @@ module Mastodon
|
|||
|
||||
domains.each do |domain|
|
||||
entry = EmailDomainBlock.find_by(domain: domain)
|
||||
|
||||
if entry.nil?
|
||||
say("#{domain} is not yet blocked.", :yellow)
|
||||
skipped += 1
|
||||
|
@ -105,12 +110,12 @@ module Mastodon
|
|||
end
|
||||
|
||||
children_count = EmailDomainBlock.where(parent_id: entry.id).count
|
||||
|
||||
result = entry.destroy
|
||||
|
||||
if result
|
||||
processed += 1 + children_count
|
||||
else
|
||||
say("#{domain} was not unblocked. 'destroy' returns false.", :red)
|
||||
say("#{domain} could not be unblocked.", :red)
|
||||
failed += 1
|
||||
end
|
||||
end
|
||||
|
|
|
@ -31,10 +31,11 @@ module Mastodon
|
|||
processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where('created_at < ?', time_ago)) do |media_attachment|
|
||||
next if media_attachment.file.blank?
|
||||
|
||||
size = media_attachment.file_file_size
|
||||
size = media_attachment.file_file_size + (media_attachment.thumbnail_file_size || 0)
|
||||
|
||||
unless options[:dry_run]
|
||||
media_attachment.file.destroy
|
||||
media_attachment.thumbnail.destroy
|
||||
media_attachment.save
|
||||
end
|
||||
|
||||
|
@ -227,11 +228,12 @@ module Mastodon
|
|||
next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?)
|
||||
|
||||
unless options[:dry_run]
|
||||
media_attachment.file_remote_url = media_attachment.remote_url
|
||||
media_attachment.reset_file!
|
||||
media_attachment.reset_thumbnail!
|
||||
media_attachment.save
|
||||
end
|
||||
|
||||
media_attachment.file_file_size
|
||||
media_attachment.file_file_size + (media_attachment.thumbnail_file_size || 0)
|
||||
end
|
||||
|
||||
say("Downloaded #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true)
|
||||
|
@ -239,7 +241,7 @@ module Mastodon
|
|||
|
||||
desc 'usage', 'Calculate disk space consumed by Mastodon'
|
||||
def usage
|
||||
say("Attachments:\t#{number_to_human_size(MediaAttachment.sum(:file_file_size))} (#{number_to_human_size(MediaAttachment.where(account: Account.local).sum(:file_file_size))} local)")
|
||||
say("Attachments:\t#{number_to_human_size(MediaAttachment.sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')))} (#{number_to_human_size(MediaAttachment.where(account: Account.local).sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')))} local)")
|
||||
say("Custom emoji:\t#{number_to_human_size(CustomEmoji.sum(:image_file_size))} (#{number_to_human_size(CustomEmoji.local.sum(:image_file_size))} local)")
|
||||
say("Preview cards:\t#{number_to_human_size(PreviewCard.sum(:image_file_size))}")
|
||||
say("Avatars:\t#{number_to_human_size(Account.sum(:avatar_file_size))} (#{number_to_human_size(Account.local.sum(:avatar_file_size))} local)")
|
||||
|
|
|
@ -7,7 +7,7 @@ module Paperclip
|
|||
# usage, and we still want to generate thumbnails straight
|
||||
# away, it's the only style we need to exclude
|
||||
def process_style?(style_name, style_args)
|
||||
if style_name == :original && instance.respond_to?(:delay_processing?) && instance.delay_processing?
|
||||
if style_name == :original && instance.respond_to?(:delay_processing_for_attachment?) && instance.delay_processing_for_attachment?(name)
|
||||
false
|
||||
else
|
||||
style_args.empty? || style_args.include?(style_name)
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'mime/types/columnar'
|
||||
|
||||
module Paperclip
|
||||
class ImageExtractor < Paperclip::Processor
|
||||
IMAGE_EXTRACTION_OPTIONS = {
|
||||
convert_options: {
|
||||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
format: 'png',
|
||||
time: -1,
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
}.freeze
|
||||
|
||||
def make
|
||||
return @file unless options[:style] == :original
|
||||
|
||||
image = begin
|
||||
begin
|
||||
Paperclip::Transcoder.make(file, IMAGE_EXTRACTION_OPTIONS.dup, attachment)
|
||||
rescue Paperclip::Error, ::Av::CommandError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
unless image.nil?
|
||||
begin
|
||||
attachment.instance.thumbnail = image if image.size.positive?
|
||||
ensure
|
||||
# Paperclip does not automatically delete the source file of
|
||||
# a new attachment while working on copies of it, so we need
|
||||
# to make sure it's cleaned up
|
||||
|
||||
begin
|
||||
FileUtils.rm(image)
|
||||
rescue Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@file
|
||||
end
|
||||
end
|
||||
end
|
|
@ -5,13 +5,15 @@ require 'mime/types/columnar'
|
|||
module Paperclip
|
||||
class TypeCorrector < Paperclip::Processor
|
||||
def make
|
||||
target_extension = options[:format]
|
||||
extension = File.extname(attachment.instance.file_file_name)
|
||||
return @file unless options[:format]
|
||||
|
||||
target_extension = '.' + options[:format]
|
||||
extension = File.extname(attachment.instance_read(:file_name))
|
||||
|
||||
return @file unless options[:style] == :original && target_extension && extension != target_extension
|
||||
|
||||
attachment.instance.file_content_type = options[:content_type] || attachment.instance.file_content_type
|
||||
attachment.instance.file_file_name = File.basename(attachment.instance.file_file_name, '.*') + '.' + target_extension
|
||||
attachment.instance_write(:content_type, options[:content_type] || attachment.instance_read(:content_type))
|
||||
attachment.instance_write(:file_name, File.basename(attachment.instance_read(:file_name), '.*') + target_extension)
|
||||
|
||||
@file
|
||||
end
|
||||
|
|
12
package.json
12
package.json
|
@ -64,7 +64,7 @@
|
|||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
"@babel/plugin-proposal-decorators": "^7.10.3",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.10.1",
|
||||
"@babel/plugin-transform-runtime": "^7.10.1",
|
||||
"@babel/plugin-transform-runtime": "^7.10.3",
|
||||
"@babel/preset-env": "^7.10.2",
|
||||
"@babel/preset-react": "^7.10.1",
|
||||
"@babel/runtime": "^7.8.4",
|
||||
|
@ -144,7 +144,7 @@
|
|||
"react-select": "^3.1.0",
|
||||
"react-sparklines": "^1.7.0",
|
||||
"react-swipeable-views": "^0.13.9",
|
||||
"react-textarea-autosize": "^8.0.1",
|
||||
"react-textarea-autosize": "^8.1.1",
|
||||
"react-toggle": "^4.1.1",
|
||||
"redis": "^3.0.2",
|
||||
"redux": "^4.0.5",
|
||||
|
@ -163,20 +163,20 @@
|
|||
"tesseract.js": "^2.1.1",
|
||||
"throng": "^4.0.0",
|
||||
"tiny-queue": "^0.2.1",
|
||||
"uuid": "^8.1.0",
|
||||
"uuid": "^8.2.0",
|
||||
"wavesurfer.js": "^3.3.3",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-assets-manifest": "^3.1.1",
|
||||
"webpack-bundle-analyzer": "^3.8.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-merge": "^4.2.1",
|
||||
"wicg-inert": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.11.0",
|
||||
"@testing-library/react": "^10.4.3",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^25.2.4",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.2",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-import": "~2.21.2",
|
||||
"eslint-plugin-jsx-a11y": "~6.3.1",
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
Fabricator(:account_note) do
|
||||
account
|
||||
target_account { Fabricate(:account) }
|
||||
comment "User note text"
|
||||
end
|
|
@ -29,201 +29,179 @@ RSpec.describe Remotable do
|
|||
end
|
||||
end
|
||||
|
||||
context 'Remotable module is included' do
|
||||
before do
|
||||
class Foo
|
||||
include Remotable
|
||||
|
||||
remotable_attachment :hoge, 1.kilobyte
|
||||
end
|
||||
end
|
||||
|
||||
let(:attribute_name) { "#{hoge}_remote_url".to_sym }
|
||||
let(:code) { 200 }
|
||||
let(:file) { 'filename="foo.txt"' }
|
||||
let(:foo) { Foo.new }
|
||||
let(:headers) { { 'content-disposition' => file } }
|
||||
let(:hoge) { :hoge }
|
||||
let(:url) { 'https://google.com' }
|
||||
|
||||
it 'defines a method #hoge_remote_url=' do
|
||||
expect(foo).to respond_to(:hoge_remote_url=)
|
||||
end
|
||||
|
||||
it 'defines a method #reset_hoge!' do
|
||||
expect(foo).to respond_to(:reset_hoge!)
|
||||
end
|
||||
|
||||
it 'defines a method #download_hoge!' do
|
||||
expect(foo).to respond_to(:download_hoge!)
|
||||
end
|
||||
|
||||
describe '#hoge_remote_url=' do
|
||||
before do
|
||||
class Foo
|
||||
include Remotable
|
||||
remotable_attachment :hoge, 1.kilobyte
|
||||
stub_request(:get, url).to_return(status: code, headers: headers)
|
||||
end
|
||||
|
||||
it 'always returns its argument' do
|
||||
[nil, '', [], {}].each do |arg|
|
||||
expect(foo.hoge_remote_url = arg).to be arg
|
||||
end
|
||||
end
|
||||
|
||||
let(:attribute_name) { "#{hoge}_remote_url".to_sym }
|
||||
let(:code) { 200 }
|
||||
let(:file) { 'filename="foo.txt"' }
|
||||
let(:foo) { Foo.new }
|
||||
let(:headers) { { 'content-disposition' => file } }
|
||||
let(:hoge) { :hoge }
|
||||
let(:url) { 'https://google.com' }
|
||||
|
||||
let(:request) do
|
||||
stub_request(:get, url)
|
||||
.to_return(status: code, headers: headers)
|
||||
end
|
||||
|
||||
it 'defines a method #hoge_remote_url=' do
|
||||
expect(foo).to respond_to(:hoge_remote_url=)
|
||||
end
|
||||
|
||||
it 'defines a method #reset_hoge!' do
|
||||
expect(foo).to respond_to(:reset_hoge!)
|
||||
end
|
||||
|
||||
describe '#hoge_remote_url' do
|
||||
context 'with an invalid URL' do
|
||||
before do
|
||||
request
|
||||
allow(Addressable::URI).to receive_message_chain(:parse, :normalize).with(url).with(no_args).and_raise(Addressable::URI::InvalidURIError)
|
||||
end
|
||||
|
||||
it 'always returns arg' do
|
||||
[nil, '', [], {}].each do |arg|
|
||||
expect(foo.hoge_remote_url = arg).to be arg
|
||||
it 'makes no request' do
|
||||
foo.hoge_remote_url = url
|
||||
expect(a_request(:get, url)).to_not have_been_made
|
||||
end
|
||||
end
|
||||
|
||||
context 'with scheme that is neither http nor https' do
|
||||
let(:url) { 'ftp://google.com' }
|
||||
|
||||
it 'makes no request' do
|
||||
foo.hoge_remote_url = url
|
||||
expect(a_request(:get, url)).to_not have_been_made
|
||||
end
|
||||
end
|
||||
|
||||
context 'with relative URL' do
|
||||
let(:url) { 'https:///path' }
|
||||
|
||||
it 'makes no request' do
|
||||
foo.hoge_remote_url = url
|
||||
expect(a_request(:get, url)).to_not have_been_made
|
||||
end
|
||||
end
|
||||
|
||||
context 'when URL has not changed' do
|
||||
it 'makes no request if file is already saved' do
|
||||
allow(foo).to receive(:[]).with(attribute_name).and_return(url)
|
||||
allow(foo).to receive(:hoge_file_name).and_return('foo.jpg')
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
expect(a_request(:get, url)).to_not have_been_made
|
||||
end
|
||||
|
||||
it 'makes request if file is not already saved' do
|
||||
allow(foo).to receive(:[]).with(attribute_name).and_return(url)
|
||||
allow(foo).to receive(:hoge_file_name).and_return(nil)
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
expect(a_request(:get, url)).to have_been_made
|
||||
end
|
||||
end
|
||||
|
||||
context 'when instance has no attribute for URL' do
|
||||
before do
|
||||
allow(foo).to receive(:has_attribute?).with(attribute_name).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not try to write attribute' do
|
||||
expect(foo).to_not receive('[]=').with(attribute_name, url)
|
||||
foo.hoge_remote_url = url
|
||||
end
|
||||
end
|
||||
|
||||
context 'when instance has an attribute for URL' do
|
||||
before do
|
||||
allow(foo).to receive(:has_attribute?).with(attribute_name).and_return(true)
|
||||
end
|
||||
|
||||
it 'does not try to write attribute' do
|
||||
expect(foo).to receive('[]=').with(attribute_name, url)
|
||||
foo.hoge_remote_url = url
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid URL' do
|
||||
it 'makes a request' do
|
||||
foo.hoge_remote_url = url
|
||||
expect(a_request(:get, url)).to have_been_made
|
||||
end
|
||||
|
||||
context 'when the response is not successful' do
|
||||
let(:code) { 500 }
|
||||
|
||||
it 'does not assign file' do
|
||||
expect(foo).not_to receive(:public_send).with("#{hoge}=", any_args)
|
||||
expect(foo).not_to receive(:public_send).with("#{hoge}_file_name=", any_args)
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
end
|
||||
end
|
||||
|
||||
context 'Addressable::URI::InvalidURIError raised' do
|
||||
it 'makes no request' do
|
||||
allow(Addressable::URI).to receive_message_chain(:parse, :normalize)
|
||||
.with(url).with(no_args).and_raise(Addressable::URI::InvalidURIError)
|
||||
context 'when the response is successful' do
|
||||
let(:code) { 200 }
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
expect(request).not_to have_been_requested
|
||||
context 'and contains Content-Disposition header' do
|
||||
let(:file) { 'filename="foo.txt"' }
|
||||
let(:headers) { { 'content-disposition' => file } }
|
||||
|
||||
it 'assigns file' do
|
||||
string_io = StringIO.new('')
|
||||
extname = '.txt'
|
||||
basename = '0123456789abcdef'
|
||||
|
||||
allow(SecureRandom).to receive(:hex).and_return(basename)
|
||||
allow(StringIO).to receive(:new).with(anything).and_return(string_io)
|
||||
|
||||
expect(foo).to receive(:public_send).with("download_#{hoge}!", url)
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
|
||||
expect(foo).to receive(:public_send).with("#{hoge}=", string_io)
|
||||
expect(foo).to receive(:public_send).with("#{hoge}_file_name=", basename + extname)
|
||||
|
||||
foo.download_hoge!(url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'scheme is neither http nor https' do
|
||||
let(:url) { 'ftp://google.com' }
|
||||
|
||||
it 'makes no request' do
|
||||
foo.hoge_remote_url = url
|
||||
expect(request).not_to have_been_requested
|
||||
end
|
||||
end
|
||||
|
||||
context 'parsed_url.host is empty' do
|
||||
it 'makes no request' do
|
||||
parsed_url = double(scheme: 'https', host: double(blank?: true))
|
||||
allow(Addressable::URI).to receive_message_chain(:parse, :normalize)
|
||||
.with(url).with(no_args).and_return(parsed_url)
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
expect(request).not_to have_been_requested
|
||||
end
|
||||
end
|
||||
|
||||
context 'parsed_url.host is nil' do
|
||||
it 'makes no request' do
|
||||
parsed_url = Addressable::URI.parse('https:https://example.com/path/file.png')
|
||||
allow(Addressable::URI).to receive_message_chain(:parse, :normalize)
|
||||
.with(url).with(no_args).and_return(parsed_url)
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
expect(request).not_to have_been_requested
|
||||
end
|
||||
end
|
||||
|
||||
context 'foo[attribute_name] == url' do
|
||||
it 'makes no request if file is saved' do
|
||||
allow(foo).to receive(:[]).with(attribute_name).and_return(url)
|
||||
allow(foo).to receive(:hoge_file_name).and_return('foo.jpg')
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
expect(request).not_to have_been_requested
|
||||
context 'when an error is raised during the request' do
|
||||
before do
|
||||
stub_request(:get, url).to_raise(error_class)
|
||||
end
|
||||
|
||||
it 'makes request if file is not saved' do
|
||||
allow(foo).to receive(:[]).with(attribute_name).and_return(url)
|
||||
allow(foo).to receive(:hoge_file_name).and_return(nil)
|
||||
error_classes = [
|
||||
HTTP::TimeoutError,
|
||||
HTTP::ConnectionError,
|
||||
OpenSSL::SSL::SSLError,
|
||||
Paperclip::Errors::NotIdentifiedByImageMagickError,
|
||||
Addressable::URI::InvalidURIError,
|
||||
]
|
||||
|
||||
foo.hoge_remote_url = url
|
||||
expect(request).to have_been_requested
|
||||
end
|
||||
end
|
||||
error_classes.each do |error_class|
|
||||
let(:error_class) { error_class }
|
||||
|
||||
context "scheme is https, parsed_url.host isn't empty, and foo[attribute_name] != url" do
|
||||
it 'makes a request' do
|
||||
foo.hoge_remote_url = url
|
||||
expect(request).to have_been_requested
|
||||
end
|
||||
|
||||
context 'response.code != 200' do
|
||||
let(:code) { 500 }
|
||||
|
||||
it 'calls not send' do
|
||||
expect(foo).not_to receive(:send).with("#{hoge}=", any_args)
|
||||
expect(foo).not_to receive(:send).with("#{hoge}_file_name=", any_args)
|
||||
it 'calls Rails.logger.debug' do
|
||||
expect(Rails.logger).to receive(:debug).with(/^Error fetching remote #{hoge}: /)
|
||||
foo.hoge_remote_url = url
|
||||
end
|
||||
end
|
||||
|
||||
context 'response.code == 200' do
|
||||
let(:code) { 200 }
|
||||
|
||||
context 'response contains headers["content-disposition"]' do
|
||||
let(:file) { 'filename="foo.txt"' }
|
||||
let(:headers) { { 'content-disposition' => file } }
|
||||
|
||||
it 'calls send' do
|
||||
string_io = StringIO.new('')
|
||||
extname = '.txt'
|
||||
basename = '0123456789abcdef'
|
||||
|
||||
allow(SecureRandom).to receive(:hex).and_return(basename)
|
||||
allow(StringIO).to receive(:new).with(anything).and_return(string_io)
|
||||
|
||||
expect(foo).to receive(:send).with("#{hoge}=", string_io)
|
||||
expect(foo).to receive(:send).with("#{hoge}_file_name=", basename + extname)
|
||||
foo.hoge_remote_url = url
|
||||
end
|
||||
end
|
||||
|
||||
context 'if has_attribute?' do
|
||||
it 'calls foo[attribute_name] = url' do
|
||||
allow(foo).to receive(:has_attribute?).with(attribute_name).and_return(true)
|
||||
expect(foo).to receive('[]=').with(attribute_name, url)
|
||||
foo.hoge_remote_url = url
|
||||
end
|
||||
end
|
||||
|
||||
context 'unless has_attribute?' do
|
||||
it 'calls not foo[attribute_name] = url' do
|
||||
allow(foo).to receive(:has_attribute?)
|
||||
.with(attribute_name).and_return(false)
|
||||
expect(foo).not_to receive('[]=').with(attribute_name, url)
|
||||
foo.hoge_remote_url = url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'an error raised during the request' do
|
||||
let(:request) { stub_request(:get, url).to_raise(error_class) }
|
||||
|
||||
error_classes = [
|
||||
HTTP::TimeoutError,
|
||||
HTTP::ConnectionError,
|
||||
OpenSSL::SSL::SSLError,
|
||||
Paperclip::Errors::NotIdentifiedByImageMagickError,
|
||||
Addressable::URI::InvalidURIError,
|
||||
]
|
||||
|
||||
error_classes.each do |error_class|
|
||||
let(:error_class) { error_class }
|
||||
|
||||
it 'calls Rails.logger.debug' do
|
||||
expect(Rails.logger).to receive(:debug).with(/^Error fetching remote #{hoge}: /)
|
||||
foo.hoge_remote_url = url
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reset_hoge!' do
|
||||
context 'if url.blank?' do
|
||||
it 'returns nil, without clearing foo[attribute_name] and calling #hoge_remote_url=' do
|
||||
url = nil
|
||||
expect(foo).not_to receive(:send).with(:hoge_remote_url=, url)
|
||||
foo[attribute_name] = url
|
||||
expect(foo.reset_hoge!).to be_nil
|
||||
expect(foo[attribute_name]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'unless url.blank?' do
|
||||
it 'clears foo[attribute_name] and calls #hoge_remote_url=' do
|
||||
foo[attribute_name] = url
|
||||
expect(foo).to receive(:send).with(:hoge_remote_url=, url)
|
||||
foo.reset_hoge!
|
||||
expect(foo[attribute_name]).to be ''
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,8 @@ describe MoveWorker do
|
|||
let(:local_follower) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
|
||||
let(:source_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }
|
||||
let(:target_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }
|
||||
let(:local_user) { Fabricate(:user) }
|
||||
let!(:account_note) { Fabricate(:account_note, account: local_user.account, target_account: source_account) }
|
||||
|
||||
subject { described_class.new }
|
||||
|
||||
|
@ -13,6 +15,25 @@ describe MoveWorker do
|
|||
local_follower.follow!(source_account)
|
||||
end
|
||||
|
||||
shared_examples 'user note handling' do
|
||||
it 'copies user note' do
|
||||
allow(UnfollowFollowWorker).to receive(:push_bulk)
|
||||
subject.perform(source_account.id, target_account.id)
|
||||
expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(source_account.acct)
|
||||
expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment)
|
||||
end
|
||||
|
||||
it 'merges user notes when needed' do
|
||||
new_account_note = AccountNote.create!(account: account_note.account, target_account: target_account, comment: 'new note prior to move')
|
||||
|
||||
allow(UnfollowFollowWorker).to receive(:push_bulk)
|
||||
subject.perform(source_account.id, target_account.id)
|
||||
expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(source_account.acct)
|
||||
expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(account_note.comment)
|
||||
expect(AccountNote.find_by(account: account_note.account, target_account: target_account).comment).to include(new_account_note.comment)
|
||||
end
|
||||
end
|
||||
|
||||
context 'both accounts are distant' do
|
||||
describe 'perform' do
|
||||
it 'calls UnfollowFollowWorker' do
|
||||
|
@ -20,6 +41,8 @@ describe MoveWorker do
|
|||
subject.perform(source_account.id, target_account.id)
|
||||
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
|
||||
end
|
||||
|
||||
include_examples 'user note handling'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -32,6 +55,8 @@ describe MoveWorker do
|
|||
subject.perform(source_account.id, target_account.id)
|
||||
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
|
||||
end
|
||||
|
||||
include_examples 'user note handling'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -45,6 +70,8 @@ describe MoveWorker do
|
|||
expect(local_follower.following?(target_account)).to be true
|
||||
end
|
||||
|
||||
include_examples 'user note handling'
|
||||
|
||||
it 'does not fail when a local user is already following both accounts' do
|
||||
double_follower = Fabricate(:user, email: 'eve@example.com', account: Fabricate(:account, username: 'eve')).account
|
||||
double_follower.follow!(source_account)
|
||||
|
|
Loading…
Reference in New Issue