mirror of
https://github.com/glitch-soc/mastodon
synced 2025-04-25 00:44:51 +00:00
Merge commit '902aab1245a06319b7acd20045bd9ce051627300' into glitch-soc/merge-upstream
This commit is contained in:
commit
f8b0f38636
65 changed files with 2115 additions and 671 deletions
1
Gemfile
1
Gemfile
|
@ -62,6 +62,7 @@ gem 'inline_svg'
|
|||
gem 'irb', '~> 1.8'
|
||||
gem 'kaminari', '~> 1.2'
|
||||
gem 'link_header', '~> 0.0'
|
||||
gem 'linzer', '~> 0.6.1'
|
||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||
gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar'
|
||||
gem 'mutex_m'
|
||||
|
|
|
@ -395,6 +395,12 @@ GEM
|
|||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.6.2)
|
||||
openssl (~> 3.0, >= 3.0.0)
|
||||
rack (>= 2.2, < 4.0)
|
||||
starry (~> 0.2)
|
||||
stringio (~> 3.1, >= 3.1.2)
|
||||
uri (~> 1.0, >= 1.0.2)
|
||||
llhttp-ffi (0.5.1)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
|
@ -829,6 +835,8 @@ GEM
|
|||
simplecov-lcov (0.8.0)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
stackprof (0.2.27)
|
||||
starry (0.2.0)
|
||||
base64
|
||||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.5)
|
||||
|
@ -980,6 +988,7 @@ DEPENDENCIES
|
|||
letter_opener (~> 1.8)
|
||||
letter_opener_web (~> 3.0)
|
||||
link_header (~> 0.0)
|
||||
linzer (~> 0.6.1)
|
||||
lograge (~> 0.12)
|
||||
mail (~> 2.8)
|
||||
mario-redis-lock (~> 1.2)
|
||||
|
|
20
app/controllers/admin/fasp/debug/callbacks_controller.rb
Normal file
20
app/controllers/admin/fasp/debug/callbacks_controller.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Fasp::Debug::CallbacksController < Admin::BaseController
|
||||
def index
|
||||
authorize [:admin, :fasp, :provider], :update?
|
||||
|
||||
@callbacks = Fasp::DebugCallback
|
||||
.includes(:fasp_provider)
|
||||
.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize [:admin, :fasp, :provider], :update?
|
||||
|
||||
callback = Fasp::DebugCallback.find(params[:id])
|
||||
callback.destroy
|
||||
|
||||
redirect_to admin_fasp_debug_callbacks_path
|
||||
end
|
||||
end
|
19
app/controllers/admin/fasp/debug_calls_controller.rb
Normal file
19
app/controllers/admin/fasp/debug_calls_controller.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Fasp::DebugCallsController < Admin::BaseController
|
||||
before_action :set_provider
|
||||
|
||||
def create
|
||||
authorize [:admin, @provider], :update?
|
||||
|
||||
@provider.perform_debug_call
|
||||
|
||||
redirect_to admin_fasp_providers_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_provider
|
||||
@provider = Fasp::Provider.find(params[:provider_id])
|
||||
end
|
||||
end
|
47
app/controllers/admin/fasp/providers_controller.rb
Normal file
47
app/controllers/admin/fasp/providers_controller.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Fasp::ProvidersController < Admin::BaseController
|
||||
before_action :set_provider, only: [:show, :edit, :update, :destroy]
|
||||
|
||||
def index
|
||||
authorize [:admin, :fasp, :provider], :index?
|
||||
|
||||
@providers = Fasp::Provider.order(confirmed: :asc, created_at: :desc)
|
||||
end
|
||||
|
||||
def show
|
||||
authorize [:admin, @provider], :show?
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize [:admin, @provider], :update?
|
||||
end
|
||||
|
||||
def update
|
||||
authorize [:admin, @provider], :update?
|
||||
|
||||
if @provider.update(provider_params)
|
||||
redirect_to admin_fasp_providers_path
|
||||
else
|
||||
render :edit
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize [:admin, @provider], :destroy?
|
||||
|
||||
@provider.destroy
|
||||
|
||||
redirect_to admin_fasp_providers_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_params
|
||||
params.expect(fasp_provider: [capabilities_attributes: {}])
|
||||
end
|
||||
|
||||
def set_provider
|
||||
@provider = Fasp::Provider.find(params[:id])
|
||||
end
|
||||
end
|
23
app/controllers/admin/fasp/registrations_controller.rb
Normal file
23
app/controllers/admin/fasp/registrations_controller.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Fasp::RegistrationsController < Admin::BaseController
|
||||
before_action :set_provider
|
||||
|
||||
def new
|
||||
authorize [:admin, @provider], :create?
|
||||
end
|
||||
|
||||
def create
|
||||
authorize [:admin, @provider], :create?
|
||||
|
||||
@provider.update_info!(confirm: true)
|
||||
|
||||
redirect_to edit_admin_fasp_provider_path(@provider)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_provider
|
||||
@provider = Fasp::Provider.find(params[:provider_id])
|
||||
end
|
||||
end
|
81
app/controllers/api/fasp/base_controller.rb
Normal file
81
app/controllers/api/fasp/base_controller.rb
Normal file
|
@ -0,0 +1,81 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::Fasp::BaseController < ApplicationController
|
||||
class Error < ::StandardError; end
|
||||
|
||||
DIGEST_PATTERN = /sha-256=:(.*?):/
|
||||
KEYID_PATTERN = /keyid="(.*?)"/
|
||||
|
||||
attr_reader :current_provider
|
||||
|
||||
skip_forgery_protection
|
||||
|
||||
before_action :check_fasp_enabled
|
||||
before_action :require_authentication
|
||||
after_action :sign_response
|
||||
|
||||
private
|
||||
|
||||
def require_authentication
|
||||
validate_content_digest!
|
||||
validate_signature!
|
||||
rescue Error, Linzer::Error, ActiveRecord::RecordNotFound => e
|
||||
logger.debug("FASP Authentication error: #{e}")
|
||||
authentication_error
|
||||
end
|
||||
|
||||
def authentication_error
|
||||
respond_to do |format|
|
||||
format.json { head 401 }
|
||||
end
|
||||
end
|
||||
|
||||
def validate_content_digest!
|
||||
content_digest_header = request.headers['content-digest']
|
||||
raise Error, 'content-digest missing' if content_digest_header.blank?
|
||||
|
||||
digest_received = content_digest_header.match(DIGEST_PATTERN)[1]
|
||||
|
||||
digest_computed = OpenSSL::Digest.base64digest('sha256', request.body&.string || '')
|
||||
|
||||
raise Error, 'content-digest does not match' if digest_received != digest_computed
|
||||
end
|
||||
|
||||
def validate_signature!
|
||||
signature_input = request.headers['signature-input']&.encode('UTF-8')
|
||||
raise Error, 'signature-input is missing' if signature_input.blank?
|
||||
|
||||
keyid = signature_input.match(KEYID_PATTERN)[1]
|
||||
provider = Fasp::Provider.find(keyid)
|
||||
linzer_request = Linzer.new_request(
|
||||
request.method,
|
||||
request.original_url,
|
||||
{},
|
||||
{
|
||||
'content-digest' => request.headers['content-digest'],
|
||||
'signature-input' => signature_input,
|
||||
'signature' => request.headers['signature'],
|
||||
}
|
||||
)
|
||||
message = Linzer::Message.new(linzer_request)
|
||||
key = Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid)
|
||||
signature = Linzer::Signature.build(message.headers)
|
||||
Linzer.verify(key, message, signature)
|
||||
@current_provider = provider
|
||||
end
|
||||
|
||||
def sign_response
|
||||
response.headers['content-digest'] = "sha-256=:#{OpenSSL::Digest.base64digest('sha256', response.body || '')}:"
|
||||
|
||||
linzer_response = Linzer.new_response(response.body, response.status, { 'content-digest' => response.headers['content-digest'] })
|
||||
message = Linzer::Message.new(linzer_response)
|
||||
key = Linzer.new_ed25519_key(current_provider.server_private_key_pem)
|
||||
signature = Linzer.sign(key, message, %w(@status content-digest))
|
||||
|
||||
response.headers.merge!(signature.to_h)
|
||||
end
|
||||
|
||||
def check_fasp_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.fasp_enabled?
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::Fasp::Debug::V0::Callback::ResponsesController < Api::Fasp::BaseController
|
||||
def create
|
||||
Fasp::DebugCallback.create(
|
||||
fasp_provider: current_provider,
|
||||
ip: request.remote_ip,
|
||||
request_body: request.raw_post
|
||||
)
|
||||
|
||||
respond_to do |format|
|
||||
format.json { head 201 }
|
||||
end
|
||||
end
|
||||
end
|
26
app/controllers/api/fasp/registrations_controller.rb
Normal file
26
app/controllers/api/fasp/registrations_controller.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::Fasp::RegistrationsController < Api::Fasp::BaseController
|
||||
skip_before_action :require_authentication
|
||||
|
||||
def create
|
||||
@current_provider = Fasp::Provider.create!(
|
||||
name: params[:name],
|
||||
base_url: params[:baseUrl],
|
||||
remote_identifier: params[:serverId],
|
||||
provider_public_key_base64: params[:publicKey]
|
||||
)
|
||||
|
||||
render json: registration_confirmation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def registration_confirmation
|
||||
{
|
||||
faspId: current_provider.id.to_s,
|
||||
publicKey: current_provider.server_public_key_base64,
|
||||
registrationCompletionUri: new_admin_fasp_provider_registration_url(current_provider),
|
||||
}
|
||||
end
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
import { animated, useSpring, config } from '@react-spring/web';
|
||||
|
||||
import { reduceMotion } from '../initial_state';
|
||||
|
||||
|
@ -11,53 +11,49 @@ interface Props {
|
|||
}
|
||||
export const AnimatedNumber: React.FC<Props> = ({ value }) => {
|
||||
const [previousValue, setPreviousValue] = useState(value);
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
const direction = value > previousValue ? -1 : 1;
|
||||
|
||||
if (previousValue !== value) {
|
||||
setPreviousValue(value);
|
||||
setDirection(value > previousValue ? 1 : -1);
|
||||
}
|
||||
|
||||
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
||||
const willLeave = useCallback(
|
||||
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
||||
[direction],
|
||||
const [styles, api] = useSpring(
|
||||
() => ({
|
||||
from: { transform: `translateY(${100 * direction}%)` },
|
||||
to: { transform: 'translateY(0%)' },
|
||||
onRest() {
|
||||
setPreviousValue(value);
|
||||
},
|
||||
config: { ...config.gentle, duration: 200 },
|
||||
immediate: true, // This ensures that the animation is not played when the component is first rendered
|
||||
}),
|
||||
[value, previousValue],
|
||||
);
|
||||
|
||||
// When the value changes, start the animation
|
||||
useEffect(() => {
|
||||
if (value !== previousValue) {
|
||||
void api.start({ reset: true });
|
||||
}
|
||||
}, [api, previousValue, value]);
|
||||
|
||||
if (reduceMotion) {
|
||||
return <ShortNumber value={value} />;
|
||||
}
|
||||
|
||||
const styles = [
|
||||
{
|
||||
key: `${value}`,
|
||||
data: value,
|
||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<TransitionMotion
|
||||
styles={styles}
|
||||
willEnter={willEnter}
|
||||
willLeave={willLeave}
|
||||
>
|
||||
{(items) => (
|
||||
<span className='animated-number'>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<span
|
||||
key={key}
|
||||
style={{
|
||||
position:
|
||||
direction * (style.y ?? 0) > 0 ? 'absolute' : 'static',
|
||||
transform: `translateY(${(style.y ?? 0) * 100}%)`,
|
||||
}}
|
||||
>
|
||||
<ShortNumber value={data as number} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className='animated-number'>
|
||||
<animated.span style={styles}>
|
||||
<ShortNumber value={value} />
|
||||
</animated.span>
|
||||
{value !== previousValue && (
|
||||
<animated.span
|
||||
style={{
|
||||
...styles,
|
||||
position: 'absolute',
|
||||
top: `${-100 * direction}%`, // Adds extra space on top of translateY
|
||||
}}
|
||||
role='presentation'
|
||||
>
|
||||
<ShortNumber value={previousValue} />
|
||||
</animated.span>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,248 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import emojify from 'mastodon/features/emoji/emoji';
|
||||
import Motion from 'mastodon/features/ui/util/optional_motion';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
closed: {
|
||||
id: 'poll.closed',
|
||||
defaultMessage: 'Closed',
|
||||
},
|
||||
voted: {
|
||||
id: 'poll.voted',
|
||||
defaultMessage: 'You voted for this answer',
|
||||
},
|
||||
votes: {
|
||||
id: 'poll.votes',
|
||||
defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
|
||||
},
|
||||
});
|
||||
|
||||
class Poll extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
poll: ImmutablePropTypes.record.isRequired,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
lang: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
refresh: PropTypes.func,
|
||||
onVote: PropTypes.func,
|
||||
onInteractionModal: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
selected: {},
|
||||
expired: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps (props, state) {
|
||||
const { poll } = props;
|
||||
const expires_at = poll.get('expires_at');
|
||||
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now();
|
||||
return (expired === state.expired) ? null : { expired };
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this._setupTimer();
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
this._setupTimer();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
clearTimeout(this._timer);
|
||||
}
|
||||
|
||||
_setupTimer () {
|
||||
const { poll } = this.props;
|
||||
clearTimeout(this._timer);
|
||||
if (!this.state.expired) {
|
||||
const delay = (new Date(poll.get('expires_at'))).getTime() - Date.now();
|
||||
this._timer = setTimeout(() => {
|
||||
this.setState({ expired: true });
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
_toggleOption = value => {
|
||||
if (this.props.poll.get('multiple')) {
|
||||
const tmp = { ...this.state.selected };
|
||||
if (tmp[value]) {
|
||||
delete tmp[value];
|
||||
} else {
|
||||
tmp[value] = true;
|
||||
}
|
||||
this.setState({ selected: tmp });
|
||||
} else {
|
||||
const tmp = {};
|
||||
tmp[value] = true;
|
||||
this.setState({ selected: tmp });
|
||||
}
|
||||
};
|
||||
|
||||
handleOptionChange = ({ target: { value } }) => {
|
||||
this._toggleOption(value);
|
||||
};
|
||||
|
||||
handleOptionKeyPress = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this._toggleOption(e.target.getAttribute('data-index'));
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
handleVote = () => {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.identity.signedIn) {
|
||||
this.props.onVote(Object.keys(this.state.selected));
|
||||
} else {
|
||||
this.props.onInteractionModal('vote', this.props.status);
|
||||
}
|
||||
};
|
||||
|
||||
handleRefresh = () => {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.refresh();
|
||||
};
|
||||
|
||||
handleReveal = () => {
|
||||
this.setState({ revealed: true });
|
||||
};
|
||||
|
||||
renderOption (option, optionIndex, showResults) {
|
||||
const { poll, lang, disabled, intl } = this.props;
|
||||
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
|
||||
const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
|
||||
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
|
||||
const active = !!this.state.selected[`${optionIndex}`];
|
||||
const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
|
||||
|
||||
const title = option.getIn(['translation', 'title']) || option.get('title');
|
||||
let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
|
||||
|
||||
if (!titleHtml) {
|
||||
const emojiMap = emojiMap(poll);
|
||||
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={option.get('title')}>
|
||||
<label className={classNames('poll__option', { selectable: !showResults })}>
|
||||
<input
|
||||
name='vote-options'
|
||||
type={poll.get('multiple') ? 'checkbox' : 'radio'}
|
||||
value={optionIndex}
|
||||
checked={active}
|
||||
onChange={this.handleOptionChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{!showResults && (
|
||||
<span
|
||||
className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
|
||||
tabIndex={0}
|
||||
role={poll.get('multiple') ? 'checkbox' : 'radio'}
|
||||
onKeyPress={this.handleOptionKeyPress}
|
||||
aria-checked={active}
|
||||
aria-label={title}
|
||||
lang={lang}
|
||||
data-index={optionIndex}
|
||||
/>
|
||||
)}
|
||||
{showResults && (
|
||||
<span
|
||||
className='poll__number'
|
||||
title={intl.formatMessage(messages.votes, {
|
||||
votes: option.get('votes_count'),
|
||||
})}
|
||||
>
|
||||
{Math.round(percent)}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
className='poll__option__text translate'
|
||||
lang={lang}
|
||||
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
||||
/>
|
||||
|
||||
{!!voted && <span className='poll__voted'>
|
||||
<Icon id='check' icon={CheckIcon} className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
|
||||
</span>}
|
||||
</label>
|
||||
|
||||
{showResults && (
|
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ width }) =>
|
||||
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
|
||||
}
|
||||
</Motion>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { poll, intl } = this.props;
|
||||
const { revealed, expired } = this.state;
|
||||
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
|
||||
const showResults = poll.get('voted') || revealed || expired;
|
||||
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
|
||||
|
||||
let votesCount = null;
|
||||
|
||||
if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
|
||||
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
|
||||
} else {
|
||||
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='poll'>
|
||||
<ul>
|
||||
{poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
|
||||
</ul>
|
||||
|
||||
<div className='poll__footer'>
|
||||
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
|
||||
{!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
|
||||
{showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
|
||||
{votesCount}
|
||||
{poll.get('expires_at') && <> · {timeRemaining}</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(withIdentity(Poll));
|
352
app/javascript/mastodon/components/poll.tsx
Normal file
352
app/javascript/mastodon/components/poll.tsx
Normal file
|
@ -0,0 +1,352 @@
|
|||
import type { KeyboardEventHandler } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { fetchPoll, vote } from 'mastodon/actions/polls';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import emojify from 'mastodon/features/emoji/emoji';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { reduceMotion } from 'mastodon/initial_state';
|
||||
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
|
||||
import type * as Model from 'mastodon/models/poll';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
closed: {
|
||||
id: 'poll.closed',
|
||||
defaultMessage: 'Closed',
|
||||
},
|
||||
voted: {
|
||||
id: 'poll.voted',
|
||||
defaultMessage: 'You voted for this answer',
|
||||
},
|
||||
votes: {
|
||||
id: 'poll.votes',
|
||||
defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
|
||||
},
|
||||
});
|
||||
|
||||
interface PollProps {
|
||||
pollId: string;
|
||||
status: Status;
|
||||
lang?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Poll: React.FC<PollProps> = (props) => {
|
||||
const { pollId, status } = props;
|
||||
|
||||
// Third party hooks
|
||||
const poll = useAppSelector((state) => state.polls.get(pollId));
|
||||
const identity = useIdentity();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// State
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Derived values
|
||||
const expired = useMemo(() => {
|
||||
if (!poll) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = poll.get('expires_at');
|
||||
return poll.get('expired') || new Date(expiresAt).getTime() < Date.now();
|
||||
}, [poll]);
|
||||
const timeRemaining = useMemo(() => {
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
if (expired) {
|
||||
return intl.formatMessage(messages.closed);
|
||||
}
|
||||
return <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
|
||||
}, [expired, intl, poll]);
|
||||
const votesCount = useMemo(() => {
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
if (poll.get('voters_count')) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='poll.total_people'
|
||||
defaultMessage='{count, plural, one {# person} other {# people}}'
|
||||
values={{ count: poll.get('voters_count') }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='poll.total_votes'
|
||||
defaultMessage='{count, plural, one {# vote} other {# votes}}'
|
||||
values={{ count: poll.get('votes_count') }}
|
||||
/>
|
||||
);
|
||||
}, [poll]);
|
||||
|
||||
const disabled =
|
||||
props.disabled || Object.values(selected).every((item) => !item);
|
||||
|
||||
// Event handlers
|
||||
const handleVote = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (identity.signedIn) {
|
||||
void dispatch(vote({ pollId, choices: Object.keys(selected) }));
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'vote',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [disabled, dispatch, identity, pollId, selected, status]);
|
||||
|
||||
const handleReveal = useCallback(() => {
|
||||
setRevealed(true);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
debounce(
|
||||
() => {
|
||||
void dispatch(fetchPoll({ pollId }));
|
||||
},
|
||||
1000,
|
||||
{ leading: true },
|
||||
);
|
||||
}, [disabled, dispatch, pollId]);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
(choiceIndex: number) => {
|
||||
if (!poll) {
|
||||
return;
|
||||
}
|
||||
if (poll.get('multiple')) {
|
||||
setSelected((prev) => ({
|
||||
...prev,
|
||||
[choiceIndex]: !prev[choiceIndex],
|
||||
}));
|
||||
} else {
|
||||
setSelected({ [choiceIndex]: true });
|
||||
}
|
||||
},
|
||||
[poll],
|
||||
);
|
||||
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
const showResults = poll.get('voted') || revealed || expired;
|
||||
|
||||
return (
|
||||
<div className='poll'>
|
||||
<ul>
|
||||
{poll.get('options').map((option, i) => (
|
||||
<PollOption
|
||||
key={option.get('title') || i}
|
||||
index={i}
|
||||
poll={poll}
|
||||
option={option}
|
||||
showResults={showResults}
|
||||
active={!!selected[i]}
|
||||
onChange={handleOptionChange}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className='poll__footer'>
|
||||
{!showResults && (
|
||||
<button
|
||||
className='button button-secondary'
|
||||
disabled={disabled}
|
||||
onClick={handleVote}
|
||||
>
|
||||
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
|
||||
</button>
|
||||
)}
|
||||
{!showResults && (
|
||||
<>
|
||||
<button className='poll__link' onClick={handleReveal}>
|
||||
<FormattedMessage id='poll.reveal' defaultMessage='See results' />
|
||||
</button>{' '}
|
||||
·{' '}
|
||||
</>
|
||||
)}
|
||||
{showResults && !disabled && (
|
||||
<>
|
||||
<button className='poll__link' onClick={handleRefresh}>
|
||||
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
|
||||
</button>{' '}
|
||||
·{' '}
|
||||
</>
|
||||
)}
|
||||
{votesCount}
|
||||
{poll.get('expires_at') && <> · {timeRemaining}</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PollOptionProps = Pick<PollProps, 'disabled' | 'lang'> & {
|
||||
active: boolean;
|
||||
onChange: (index: number) => void;
|
||||
poll: Model.Poll;
|
||||
option: Model.PollOption;
|
||||
index: number;
|
||||
showResults?: boolean;
|
||||
};
|
||||
|
||||
const PollOption: React.FC<PollOptionProps> = (props) => {
|
||||
const { active, lang, disabled, poll, option, index, showResults, onChange } =
|
||||
props;
|
||||
const voted = option.get('voted') || poll.get('own_votes')?.includes(index);
|
||||
const title =
|
||||
(option.getIn(['translation', 'title']) as string) || option.get('title');
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
// Derived values
|
||||
const percent = useMemo(() => {
|
||||
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
|
||||
return pollVotesCount === 0
|
||||
? 0
|
||||
: (option.get('votes_count') / pollVotesCount) * 100;
|
||||
}, [option, poll]);
|
||||
const isLeading = useMemo(
|
||||
() =>
|
||||
poll
|
||||
.get('options')
|
||||
.filterNot((other) => other.get('title') === option.get('title'))
|
||||
.every(
|
||||
(other) => option.get('votes_count') >= other.get('votes_count'),
|
||||
),
|
||||
[poll, option],
|
||||
);
|
||||
const titleHtml = useMemo(() => {
|
||||
let titleHtml =
|
||||
(option.getIn(['translation', 'titleHtml']) as string) ||
|
||||
option.get('titleHtml');
|
||||
|
||||
if (!titleHtml) {
|
||||
const emojiMap = makeEmojiMap(poll.get('emojis'));
|
||||
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
||||
}
|
||||
|
||||
return titleHtml;
|
||||
}, [option, poll, title]);
|
||||
|
||||
// Handlers
|
||||
const handleOptionChange = useCallback(() => {
|
||||
onChange(index);
|
||||
}, [index, onChange]);
|
||||
const handleOptionKeyPress: KeyboardEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
onChange(index);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[index, onChange],
|
||||
);
|
||||
|
||||
const widthSpring = useSpring({
|
||||
from: {
|
||||
width: '0%',
|
||||
},
|
||||
to: {
|
||||
width: `${percent}%`,
|
||||
},
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
return (
|
||||
<li>
|
||||
<label
|
||||
className={classNames('poll__option', { selectable: !showResults })}
|
||||
>
|
||||
<input
|
||||
name='vote-options'
|
||||
type={poll.get('multiple') ? 'checkbox' : 'radio'}
|
||||
value={index}
|
||||
checked={active}
|
||||
onChange={handleOptionChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{!showResults && (
|
||||
<span
|
||||
className={classNames('poll__input', {
|
||||
checkbox: poll.get('multiple'),
|
||||
active,
|
||||
})}
|
||||
tabIndex={0}
|
||||
role={poll.get('multiple') ? 'checkbox' : 'radio'}
|
||||
onKeyDown={handleOptionKeyPress}
|
||||
aria-checked={active}
|
||||
aria-label={title}
|
||||
lang={lang}
|
||||
data-index={index}
|
||||
/>
|
||||
)}
|
||||
{showResults && (
|
||||
<span
|
||||
className='poll__number'
|
||||
title={intl.formatMessage(messages.votes, {
|
||||
votes: option.get('votes_count'),
|
||||
})}
|
||||
>
|
||||
{Math.round(percent)}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
className='poll__option__text translate'
|
||||
lang={lang}
|
||||
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
||||
/>
|
||||
|
||||
{!!voted && (
|
||||
<span className='poll__voted'>
|
||||
<Icon
|
||||
id='check'
|
||||
icon={CheckIcon}
|
||||
className='poll__voted__mark'
|
||||
title={intl.formatMessage(messages.voted)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{showResults && (
|
||||
<animated.span
|
||||
className={classNames('poll__chart', { leading: isLeading })}
|
||||
style={widthSpring}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
|
@ -11,7 +11,7 @@ import { connect } from 'react-redux';
|
|||
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import PollContainer from 'mastodon/containers/poll_container';
|
||||
import { Poll } from 'mastodon/components/poll';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||
|
||||
|
@ -245,7 +245,7 @@ class StatusContent extends PureComponent {
|
|||
);
|
||||
|
||||
const poll = !!status.get('poll') && (
|
||||
<PollContainer pollId={status.get('poll')} status={status} lang={language} />
|
||||
<Poll pollId={status.get('poll')} status={status} lang={language} />
|
||||
);
|
||||
|
||||
if (this.props.onClick) {
|
||||
|
|
|
@ -7,7 +7,7 @@ import { fromJS } from 'immutable';
|
|||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||
import MediaGallery from 'mastodon/components/media_gallery';
|
||||
import ModalRoot from 'mastodon/components/modal_root';
|
||||
import Poll from 'mastodon/components/poll';
|
||||
import { Poll } from 'mastodon/components/poll';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import Card from 'mastodon/features/status/components/card';
|
||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { fetchPoll, vote } from 'mastodon/actions/polls';
|
||||
import Poll from 'mastodon/components/poll';
|
||||
|
||||
const mapDispatchToProps = (dispatch, { pollId }) => ({
|
||||
refresh: debounce(
|
||||
() => {
|
||||
dispatch(fetchPoll({ pollId }));
|
||||
},
|
||||
1000,
|
||||
{ leading: true },
|
||||
),
|
||||
|
||||
onVote (choices) {
|
||||
dispatch(vote({ pollId, choices }));
|
||||
},
|
||||
|
||||
onInteractionModal (type, status) {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type,
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { pollId }) => ({
|
||||
poll: state.polls.get(pollId),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Poll);
|
|
@ -20,7 +20,6 @@ import PollButtonContainer from '../containers/poll_button_container';
|
|||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import WarningContainer from '../containers/warning_container';
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
import { CharacterCounter } from './character_counter';
|
||||
|
@ -30,6 +29,7 @@ import { NavigationBar } from './navigation_bar';
|
|||
import { PollForm } from "./poll_form";
|
||||
import { ReplyIndicator } from './reply_indicator';
|
||||
import { UploadForm } from './upload_form';
|
||||
import { Warning } from './warning';
|
||||
|
||||
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
||||
|
||||
|
@ -233,7 +233,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
<ReplyIndicator />
|
||||
{!withoutNavigation && <NavigationBar />}
|
||||
<WarningContainer />
|
||||
<Warning />
|
||||
|
||||
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
||||
<div className='compose-form__scrollable'>
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
|
||||
export const UploadProgress = ({ active, progress, isProcessing }) => {
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message;
|
||||
|
||||
if (isProcessing) {
|
||||
message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
|
||||
} else {
|
||||
message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='upload-progress'>
|
||||
<Icon id='upload' icon={UploadFileIcon} />
|
||||
|
||||
<div className='upload-progress__message'>
|
||||
{message}
|
||||
|
||||
<div className='upload-progress__backdrop'>
|
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
|
||||
{({ width }) =>
|
||||
<div className='upload-progress__tracker' style={{ width: `${width}%` }} />
|
||||
}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UploadProgress.propTypes = {
|
||||
active: PropTypes.bool,
|
||||
progress: PropTypes.number,
|
||||
isProcessing: PropTypes.bool,
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { reduceMotion } from 'mastodon/initial_state';
|
||||
|
||||
interface UploadProgressProps {
|
||||
active: boolean;
|
||||
progress: number;
|
||||
isProcessing: boolean;
|
||||
}
|
||||
|
||||
export const UploadProgress: React.FC<UploadProgressProps> = ({
|
||||
active,
|
||||
progress,
|
||||
isProcessing,
|
||||
}) => {
|
||||
const styles = useSpring({
|
||||
from: { width: '0%' },
|
||||
to: { width: `${progress}%` },
|
||||
reset: true,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message;
|
||||
|
||||
if (isProcessing) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='upload_progress.processing'
|
||||
defaultMessage='Processing…'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='upload_progress.label'
|
||||
defaultMessage='Uploading…'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='upload-progress'>
|
||||
<Icon id='upload' icon={UploadFileIcon} />
|
||||
|
||||
<div className='upload-progress__message'>
|
||||
{message}
|
||||
|
||||
<div className='upload-progress__backdrop'>
|
||||
<animated.div className='upload-progress__tracker' style={styles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,28 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
|
||||
export default class Warning extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
message: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { message } = this.props;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
|
||||
|
||||
const selector = createSelector(
|
||||
(state: RootState) => state.compose.get('privacy') as string,
|
||||
(state: RootState) => !!state.compose.getIn(['accounts', me, 'locked']),
|
||||
(state: RootState) => state.compose.get('text') as string,
|
||||
(privacy, locked, text) => ({
|
||||
needsLockWarning: privacy === 'private' && !locked,
|
||||
hashtagWarning: privacy !== 'public' && HASHTAG_PATTERN_REGEX.test(text),
|
||||
directMessageWarning: privacy === 'direct',
|
||||
}),
|
||||
);
|
||||
|
||||
export const Warning = () => {
|
||||
const { needsLockWarning, hashtagWarning, directMessageWarning } =
|
||||
useAppSelector(selector);
|
||||
if (needsLockWarning) {
|
||||
return (
|
||||
<WarningMessage>
|
||||
<FormattedMessage
|
||||
id='compose_form.lock_disclaimer'
|
||||
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
|
||||
values={{
|
||||
locked: (
|
||||
<a href='/settings/profile'>
|
||||
<FormattedMessage
|
||||
id='compose_form.lock_disclaimer.lock'
|
||||
defaultMessage='locked'
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</WarningMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (hashtagWarning) {
|
||||
return (
|
||||
<WarningMessage>
|
||||
<FormattedMessage
|
||||
id='compose_form.hashtag_warning'
|
||||
defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag."
|
||||
/>
|
||||
</WarningMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (directMessageWarning) {
|
||||
return (
|
||||
<WarningMessage>
|
||||
<FormattedMessage
|
||||
id='compose_form.encryption_warning'
|
||||
defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.'
|
||||
/>{' '}
|
||||
<a href='/terms' target='_blank'>
|
||||
<FormattedMessage
|
||||
id='compose_form.direct_message_warning_learn_more'
|
||||
defaultMessage='Learn more'
|
||||
/>
|
||||
</a>
|
||||
</WarningMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const WarningMessage: React.FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
const styles = useSpring({
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.85, 0.75)',
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
transform: 'scale(1, 1)',
|
||||
},
|
||||
});
|
||||
return (
|
||||
<animated.div className='compose-form__warning' style={styles}>
|
||||
{children}
|
||||
</animated.div>
|
||||
);
|
||||
};
|
|
@ -1,46 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
|
||||
|
||||
import Warning from '../components/warning';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
|
||||
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
|
||||
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
|
||||
});
|
||||
|
||||
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
|
||||
if (needsLockWarning) {
|
||||
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
|
||||
}
|
||||
|
||||
if (hashtagWarning) {
|
||||
return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag." />} />;
|
||||
}
|
||||
|
||||
if (directMessageWarning) {
|
||||
const message = (
|
||||
<span>
|
||||
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
|
||||
</span>
|
||||
);
|
||||
|
||||
return <Warning message={message} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
WarningWrapper.propTypes = {
|
||||
needsLockWarning: PropTypes.bool,
|
||||
hashtagWarning: PropTypes.bool,
|
||||
directMessageWarning: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(WarningWrapper);
|
|
@ -1,5 +1,5 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
import { PureComponent, useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
|
||||
|
||||
|
@ -9,8 +9,7 @@ import { withRouter } from 'react-router-dom';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import TransitionMotion from 'react-motion/lib/TransitionMotion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||
|
@ -239,72 +238,76 @@ class Reaction extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
|
||||
<animated.button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
|
||||
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
||||
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
|
||||
</button>
|
||||
</animated.button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ReactionsBar extends ImmutablePureComponent {
|
||||
const ReactionsBar = ({
|
||||
announcementId,
|
||||
reactions,
|
||||
emojiMap,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
}) => {
|
||||
const visibleReactions = useMemo(() => reactions.filter(x => x.get('count') > 0).toArray(), [reactions]);
|
||||
|
||||
static propTypes = {
|
||||
announcementId: PropTypes.string.isRequired,
|
||||
reactions: ImmutablePropTypes.list.isRequired,
|
||||
addReaction: PropTypes.func.isRequired,
|
||||
removeReaction: PropTypes.func.isRequired,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
const handleEmojiPick = useCallback((emoji) => {
|
||||
addReaction(announcementId, emoji.native.replaceAll(/:/g, ''));
|
||||
}, [addReaction, announcementId]);
|
||||
|
||||
handleEmojiPick = data => {
|
||||
const { addReaction, announcementId } = this.props;
|
||||
addReaction(announcementId, data.native.replace(/:/g, ''));
|
||||
};
|
||||
const transitions = useTransition(visibleReactions, {
|
||||
from: {
|
||||
scale: 0,
|
||||
},
|
||||
enter: {
|
||||
scale: 1,
|
||||
},
|
||||
leave: {
|
||||
scale: 0,
|
||||
},
|
||||
immediate: reduceMotion,
|
||||
keys: visibleReactions.map(x => x.get('name')),
|
||||
});
|
||||
|
||||
willEnter () {
|
||||
return { scale: reduceMotion ? 1 : 0 };
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={classNames('reactions-bar', {
|
||||
'reactions-bar--empty': visibleReactions.length === 0
|
||||
})}
|
||||
>
|
||||
{transitions(({ scale }, reaction) => (
|
||||
<Reaction
|
||||
key={reaction.get('name')}
|
||||
reaction={reaction}
|
||||
style={{ transform: scale.to((s) => `scale(${s})`) }}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
announcementId={announcementId}
|
||||
emojiMap={emojiMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
willLeave () {
|
||||
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
|
||||
}
|
||||
|
||||
render () {
|
||||
const { reactions } = this.props;
|
||||
const visibleReactions = reactions.filter(x => x.get('count') > 0);
|
||||
|
||||
const styles = visibleReactions.map(reaction => ({
|
||||
key: reaction.get('name'),
|
||||
data: reaction,
|
||||
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
|
||||
})).toArray();
|
||||
|
||||
return (
|
||||
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
|
||||
{items => (
|
||||
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<Reaction
|
||||
key={key}
|
||||
reaction={data}
|
||||
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
|
||||
announcementId={this.props.announcementId}
|
||||
addReaction={this.props.addReaction}
|
||||
removeReaction={this.props.removeReaction}
|
||||
emojiMap={this.props.emojiMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' icon={AddIcon} />} />}
|
||||
</div>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
{visibleReactions.length < 8 && (
|
||||
<EmojiPickerDropdown
|
||||
onPickEmoji={handleEmojiPick}
|
||||
button={<Icon id='plus' icon={AddIcon} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ReactionsBar.propTypes = {
|
||||
announcementId: PropTypes.string.isRequired,
|
||||
reactions: ImmutablePropTypes.list.isRequired,
|
||||
addReaction: PropTypes.func.isRequired,
|
||||
removeReaction: PropTypes.func.isRequired,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
class Announcement extends ImmutablePureComponent {
|
||||
|
||||
|
|
|
@ -1,55 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import Motion from '../util/optional_motion';
|
||||
|
||||
export default class UploadArea extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
handleKeyUp = (e) => {
|
||||
const keyCode = e.keyCode;
|
||||
if (this.props.active) {
|
||||
switch(keyCode) {
|
||||
case 27:
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('keyup', this.handleKeyUp, false);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('keyup', this.handleKeyUp);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { active } = this.props;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
|
||||
{({ backgroundOpacity, backgroundScale }) => (
|
||||
<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
|
||||
<div className='upload-area__drop'>
|
||||
<div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} />
|
||||
<div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { animated, config, useSpring } from '@react-spring/web';
|
||||
|
||||
import { reduceMotion } from 'mastodon/initial_state';
|
||||
|
||||
interface UploadAreaProps {
|
||||
active?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UploadArea: React.FC<UploadAreaProps> = ({ active, onClose }) => {
|
||||
const handleKeyUp = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (active && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[active, onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keyup', handleKeyUp, false);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [handleKeyUp]);
|
||||
|
||||
const wrapperAnimStyles = useSpring({
|
||||
from: {
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
},
|
||||
reverse: !active,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
const backgroundAnimStyles = useSpring({
|
||||
from: {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
to: {
|
||||
transform: 'scale(1)',
|
||||
},
|
||||
reverse: !active,
|
||||
config: config.wobbly,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className='upload-area'
|
||||
style={{
|
||||
...wrapperAnimStyles,
|
||||
visibility: active ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className='upload-area__drop'>
|
||||
<animated.div
|
||||
className='upload-area__background'
|
||||
style={backgroundAnimStyles}
|
||||
/>
|
||||
<div className='upload-area__content'>
|
||||
<FormattedMessage
|
||||
id='upload_area.title'
|
||||
defaultMessage='Drag & drop to upload'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
};
|
|
@ -30,7 +30,7 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding
|
|||
|
||||
import BundleColumnError from './components/bundle_column_error';
|
||||
import Header from './components/header';
|
||||
import UploadArea from './components/upload_area';
|
||||
import { UploadArea } from './components/upload_area';
|
||||
import ColumnsAreaContainer from './containers/columns_area_container';
|
||||
import LoadingBarContainer from './containers/loading_bar_container';
|
||||
import ModalContainer from './containers/modal_container';
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import Motion from 'react-motion/lib/Motion';
|
||||
|
||||
import { reduceMotion } from '../../../initial_state';
|
||||
|
||||
import ReducedMotion from './reduced_motion';
|
||||
|
||||
export default reduceMotion ? ReducedMotion : Motion;
|
|
@ -1,45 +0,0 @@
|
|||
// Like react-motion's Motion, but reduces all animations to cross-fades
|
||||
// for the benefit of users with motion sickness.
|
||||
import PropTypes from 'prop-types';
|
||||
import { Component } from 'react';
|
||||
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
|
||||
const stylesToKeep = ['opacity', 'backgroundOpacity'];
|
||||
|
||||
const extractValue = (value) => {
|
||||
// This is either an object with a "val" property or it's a number
|
||||
return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
|
||||
};
|
||||
|
||||
class ReducedMotion extends Component {
|
||||
|
||||
static propTypes = {
|
||||
defaultStyle: PropTypes.object,
|
||||
style: PropTypes.object,
|
||||
children: PropTypes.func,
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
const { style, defaultStyle, children } = this.props;
|
||||
|
||||
Object.keys(style).forEach(key => {
|
||||
if (stylesToKeep.includes(key)) {
|
||||
return;
|
||||
}
|
||||
// If it's setting an x or height or scale or some other value, we need
|
||||
// to preserve the end-state value without actually animating it
|
||||
style[key] = defaultStyle[key] = extractValue(style[key]);
|
||||
});
|
||||
|
||||
return (
|
||||
<Motion style={style} defaultStyle={defaultStyle}>
|
||||
{children}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ReducedMotion;
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M352-120H200q-33 0-56.5-23.5T120-200v-152q48 0 84-30.5t36-77.5q0-47-36-77.5T120-568v-152q0-33 23.5-56.5T200-800h160q0-42 29-71t71-29q42 0 71 29t29 71h160q33 0 56.5 23.5T800-720v160q42 0 71 29t29 71q0 42-29 71t-71 29v160q0 33-23.5 56.5T720-120H568q0-50-31.5-85T460-240q-45 0-76.5 35T352-120Z"/></svg>
|
After Width: | Height: | Size: 396 B |
1
app/javascript/material-icons/400-24px/extension.svg
Normal file
1
app/javascript/material-icons/400-24px/extension.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M352-120H200q-33 0-56.5-23.5T120-200v-152q48 0 84-30.5t36-77.5q0-47-36-77.5T120-568v-152q0-33 23.5-56.5T200-800h160q0-42 29-71t71-29q42 0 71 29t29 71h160q33 0 56.5 23.5T800-720v160q42 0 71 29t29 71q0 42-29 71t-71 29v160q0 33-23.5 56.5T720-120H568q0-50-31.5-85T460-240q-45 0-76.5 35T352-120Zm-152-80h85q24-66 77-93t98-27q45 0 98 27t77 93h85v-240h80q8 0 14-6t6-14q0-8-6-14t-14-6h-80v-240H480v-80q0-8-6-14t-14-6q-8 0-14 6t-6 14v80H200v88q54 20 87 67t33 105q0 57-33 104t-87 68v88Zm260-260Z"/></svg>
|
After Width: | Height: | Size: 591 B |
76
app/lib/fasp/request.rb
Normal file
76
app/lib/fasp/request.rb
Normal file
|
@ -0,0 +1,76 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Fasp::Request
|
||||
def initialize(provider)
|
||||
@provider = provider
|
||||
end
|
||||
|
||||
def get(path)
|
||||
perform_request(:get, path)
|
||||
end
|
||||
|
||||
def post(path, body: nil)
|
||||
perform_request(:post, path, body:)
|
||||
end
|
||||
|
||||
def delete(path, body: nil)
|
||||
perform_request(:delete, path, body:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_request(verb, path, body: nil)
|
||||
url = @provider.url(path)
|
||||
body = body.present? ? body.to_json : ''
|
||||
headers = request_headers(verb, url, body)
|
||||
response = HTTP.headers(headers).send(verb, url, body:)
|
||||
validate!(response)
|
||||
|
||||
response.parse if response.body.present?
|
||||
end
|
||||
|
||||
def request_headers(verb, url, body = '')
|
||||
result = {
|
||||
'accept' => 'application/json',
|
||||
'content-digest' => content_digest(body),
|
||||
}
|
||||
result.merge(signature_headers(verb, url, result))
|
||||
end
|
||||
|
||||
def content_digest(body)
|
||||
"sha-256=:#{OpenSSL::Digest.base64digest('sha256', body || '')}:"
|
||||
end
|
||||
|
||||
def signature_headers(verb, url, headers)
|
||||
linzer_request = Linzer.new_request(verb, url, {}, headers)
|
||||
message = Linzer::Message.new(linzer_request)
|
||||
key = Linzer.new_ed25519_key(@provider.server_private_key_pem, @provider.remote_identifier)
|
||||
signature = Linzer.sign(key, message, %w(@method @target-uri content-digest))
|
||||
Linzer::Signer.send(:populate_parameters, key, {})
|
||||
|
||||
signature.to_h
|
||||
end
|
||||
|
||||
def validate!(response)
|
||||
content_digest_header = response.headers['content-digest']
|
||||
raise SignatureVerification::SignatureVerificationError, 'content-digest missing' if content_digest_header.blank?
|
||||
raise SignatureVerification::SignatureVerificationError, 'content-digest does not match' if content_digest_header != content_digest(response.body)
|
||||
|
||||
signature_input = response.headers['signature-input']&.encode('UTF-8')
|
||||
raise SignatureVerification::SignatureVerificationError, 'signature-input is missing' if signature_input.blank?
|
||||
|
||||
linzer_response = Linzer.new_response(
|
||||
response.body,
|
||||
response.status,
|
||||
{
|
||||
'content-digest' => content_digest_header,
|
||||
'signature-input' => signature_input,
|
||||
'signature' => response.headers['signature'],
|
||||
}
|
||||
)
|
||||
message = Linzer::Message.new(linzer_response)
|
||||
key = Linzer.new_ed25519_public_key(@provider.provider_public_key_pem)
|
||||
signature = Linzer::Signature.build(message.headers)
|
||||
Linzer.verify(key, message, signature)
|
||||
end
|
||||
end
|
10
app/models/concerns/fasp/provider/debug_concern.rb
Normal file
10
app/models/concerns/fasp/provider/debug_concern.rb
Normal file
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Fasp::Provider::DebugConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def perform_debug_call
|
||||
Fasp::Request.new(self)
|
||||
.post('/debug/v0/callback/logs', body: { hello: 'world' })
|
||||
end
|
||||
end
|
7
app/models/fasp.rb
Normal file
7
app/models/fasp.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Fasp
|
||||
def self.table_name_prefix
|
||||
'fasp_'
|
||||
end
|
||||
end
|
10
app/models/fasp/capability.rb
Normal file
10
app/models/fasp/capability.rb
Normal file
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Fasp::Capability
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
|
||||
attribute :id, :string
|
||||
attribute :version, :string
|
||||
attribute :enabled, :boolean, default: false
|
||||
end
|
16
app/models/fasp/debug_callback.rb
Normal file
16
app/models/fasp/debug_callback.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: fasp_debug_callbacks
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# ip :string not null
|
||||
# request_body :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# fasp_provider_id :bigint(8) not null
|
||||
#
|
||||
class Fasp::DebugCallback < ApplicationRecord
|
||||
belongs_to :fasp_provider, class_name: 'Fasp::Provider'
|
||||
end
|
141
app/models/fasp/provider.rb
Normal file
141
app/models/fasp/provider.rb
Normal file
|
@ -0,0 +1,141 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: fasp_providers
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# base_url :string not null
|
||||
# capabilities :jsonb not null
|
||||
# confirmed :boolean default(FALSE), not null
|
||||
# contact_email :string
|
||||
# fediverse_account :string
|
||||
# name :string not null
|
||||
# privacy_policy :jsonb
|
||||
# provider_public_key_pem :string not null
|
||||
# remote_identifier :string not null
|
||||
# server_private_key_pem :string not null
|
||||
# sign_in_url :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class Fasp::Provider < ApplicationRecord
|
||||
include DebugConcern
|
||||
|
||||
has_many :fasp_debug_callbacks, inverse_of: :fasp_provider, class_name: 'Fasp::DebugCallback', dependent: :delete_all
|
||||
|
||||
validates :name, presence: true
|
||||
validates :base_url, presence: true, url: true
|
||||
validates :provider_public_key_pem, presence: true
|
||||
validates :remote_identifier, presence: true
|
||||
|
||||
before_create :create_keypair
|
||||
after_commit :update_remote_capabilities
|
||||
|
||||
def capabilities
|
||||
read_attribute(:capabilities).map do |attributes|
|
||||
Fasp::Capability.new(attributes)
|
||||
end
|
||||
end
|
||||
|
||||
def capabilities_attributes=(attributes)
|
||||
capability_objects = attributes.values.map { |a| Fasp::Capability.new(a) }
|
||||
self[:capabilities] = capability_objects.map(&:attributes)
|
||||
end
|
||||
|
||||
def enabled_capabilities
|
||||
capabilities.select(&:enabled).map(&:id)
|
||||
end
|
||||
|
||||
def capability?(capability_name)
|
||||
return false unless confirmed?
|
||||
|
||||
capabilities.present? && capabilities.any? do |capability|
|
||||
capability.id == capability_name
|
||||
end
|
||||
end
|
||||
|
||||
def capability_enabled?(capability_name)
|
||||
return false unless confirmed?
|
||||
|
||||
capabilities.present? && capabilities.any? do |capability|
|
||||
capability.id == capability_name && capability.enabled
|
||||
end
|
||||
end
|
||||
|
||||
def server_private_key
|
||||
@server_private_key ||= OpenSSL::PKey.read(server_private_key_pem)
|
||||
end
|
||||
|
||||
def server_public_key_base64
|
||||
Base64.strict_encode64(server_private_key.raw_public_key)
|
||||
end
|
||||
|
||||
def provider_public_key_base64=(string)
|
||||
return if string.blank?
|
||||
|
||||
self.provider_public_key_pem =
|
||||
OpenSSL::PKey.new_raw_public_key(
|
||||
'ed25519',
|
||||
Base64.strict_decode64(string)
|
||||
).public_to_pem
|
||||
end
|
||||
|
||||
def provider_public_key
|
||||
@provider_public_key ||= OpenSSL::PKey.read(provider_public_key_pem)
|
||||
end
|
||||
|
||||
def provider_public_key_raw
|
||||
provider_public_key.raw_public_key
|
||||
end
|
||||
|
||||
def provider_public_key_fingerprint
|
||||
OpenSSL::Digest.base64digest('sha256', provider_public_key_raw)
|
||||
end
|
||||
|
||||
def url(path)
|
||||
base = base_url
|
||||
base = base.chomp('/') if path.start_with?('/')
|
||||
"#{base}#{path}"
|
||||
end
|
||||
|
||||
def update_info!(confirm: false)
|
||||
self.confirmed = true if confirm
|
||||
provider_info = Fasp::Request.new(self).get('/provider_info')
|
||||
assign_attributes(
|
||||
privacy_policy: provider_info['privacyPolicy'],
|
||||
capabilities: provider_info['capabilities'],
|
||||
sign_in_url: provider_info['signInUrl'],
|
||||
contact_email: provider_info['contactEmail'],
|
||||
fediverse_account: provider_info['fediverseAccount']
|
||||
)
|
||||
save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_keypair
|
||||
self.server_private_key_pem ||=
|
||||
OpenSSL::PKey.generate_key('ed25519').private_to_pem
|
||||
end
|
||||
|
||||
def update_remote_capabilities
|
||||
return unless saved_change_to_attribute?(:capabilities)
|
||||
|
||||
old, current = saved_change_to_attribute(:capabilities)
|
||||
old ||= []
|
||||
current.each do |capability|
|
||||
update_remote_capability(capability) if capability.key?('enabled') && !old.include?(capability)
|
||||
end
|
||||
end
|
||||
|
||||
def update_remote_capability(capability)
|
||||
version, = capability['version'].split('.')
|
||||
path = "/capabilities/#{capability['id']}/#{version}/activation"
|
||||
if capability['enabled']
|
||||
Fasp::Request.new(self).post(path)
|
||||
else
|
||||
Fasp::Request.new(self).delete(path)
|
||||
end
|
||||
end
|
||||
end
|
23
app/policies/admin/fasp/provider_policy.rb
Normal file
23
app/policies/admin/fasp/provider_policy.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Fasp::ProviderPolicy < ApplicationPolicy
|
||||
def index?
|
||||
role.can?(:manage_federation)
|
||||
end
|
||||
|
||||
def show?
|
||||
role.can?(:manage_federation)
|
||||
end
|
||||
|
||||
def create?
|
||||
role.can?(:manage_federation)
|
||||
end
|
||||
|
||||
def update?
|
||||
role.can?(:manage_federation)
|
||||
end
|
||||
|
||||
def destroy?
|
||||
role.can?(:manage_federation)
|
||||
end
|
||||
end
|
10
app/views/admin/fasp/debug/callbacks/_callback.html.haml
Normal file
10
app/views/admin/fasp/debug/callbacks/_callback.html.haml
Normal file
|
@ -0,0 +1,10 @@
|
|||
%tr
|
||||
%td= callback.fasp_provider.name
|
||||
%td= callback.fasp_provider.base_url
|
||||
%td= callback.ip
|
||||
%td
|
||||
%time.relative-formatted{ datetime: callback.created_at.iso8601 }
|
||||
%td
|
||||
%code= callback.request_body
|
||||
%td
|
||||
= table_link_to 'close', t('admin.fasp.debug.callbacks.delete'), admin_fasp_debug_callback_path(callback), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
|
22
app/views/admin/fasp/debug/callbacks/index.html.haml
Normal file
22
app/views/admin/fasp/debug/callbacks/index.html.haml
Normal file
|
@ -0,0 +1,22 @@
|
|||
- content_for :page_title do
|
||||
= t('admin.fasp.debug.callbacks.title')
|
||||
|
||||
- content_for :heading do
|
||||
%h2= t('admin.fasp.debug.callbacks.title')
|
||||
= render 'admin/fasp/shared/links'
|
||||
|
||||
- unless @callbacks.empty?
|
||||
%hr.spacer
|
||||
|
||||
.table-wrapper
|
||||
%table.table
|
||||
%thead
|
||||
%tr
|
||||
%th= t('admin.fasp.providers.name')
|
||||
%th= t('admin.fasp.providers.base_url')
|
||||
%th= t('admin.fasp.debug.callbacks.ip')
|
||||
%th= t('admin.fasp.debug.callbacks.created_at')
|
||||
%th= t('admin.fasp.debug.callbacks.request_body')
|
||||
%th
|
||||
%tbody
|
||||
= render partial: 'callback', collection: @callbacks
|
19
app/views/admin/fasp/providers/_provider.html.haml
Normal file
19
app/views/admin/fasp/providers/_provider.html.haml
Normal file
|
@ -0,0 +1,19 @@
|
|||
%tr
|
||||
%td= provider.name
|
||||
%td= provider.base_url
|
||||
%td
|
||||
- if provider.confirmed?
|
||||
= t('admin.fasp.providers.active')
|
||||
- else
|
||||
= t('admin.fasp.providers.registration_requested')
|
||||
%td
|
||||
- if provider.confirmed?
|
||||
= table_link_to 'edit', t('admin.fasp.providers.edit'), edit_admin_fasp_provider_path(provider)
|
||||
- else
|
||||
= table_link_to 'check', t('admin.fasp.providers.finish_registration'), new_admin_fasp_provider_registration_path(provider)
|
||||
- if provider.sign_in_url.present?
|
||||
= table_link_to 'open_in_new', t('admin.fasp.providers.sign_in'), provider.sign_in_url, target: '_blank'
|
||||
- if provider.capability_enabled?('callback')
|
||||
= table_link_to 'repeat', t('admin.fasp.providers.callback'), admin_fasp_provider_debug_calls_path(provider), data: { method: :post }
|
||||
|
||||
= table_link_to 'close', t('admin.fasp.providers.delete'), admin_fasp_provider_path(provider), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
|
16
app/views/admin/fasp/providers/edit.html.haml
Normal file
16
app/views/admin/fasp/providers/edit.html.haml
Normal file
|
@ -0,0 +1,16 @@
|
|||
- content_for :page_title do
|
||||
= t('admin.fasp.providers.edit')
|
||||
|
||||
= simple_form_for [:admin, @provider] do |f|
|
||||
= render 'shared/error_messages', object: @provider
|
||||
|
||||
%h4= t('admin.fasp.providers.select_capabilities')
|
||||
|
||||
.fields_group
|
||||
= f.fields_for :capabilities do |cf|
|
||||
= cf.input :id, as: :hidden
|
||||
= cf.input :version, as: :hidden
|
||||
= cf.input :enabled, as: :boolean, label: cf.object.id, wrapper: :with_label
|
||||
|
||||
.actions
|
||||
= f.button :button, t('admin.fasp.providers.save'), type: :submit
|
20
app/views/admin/fasp/providers/index.html.haml
Normal file
20
app/views/admin/fasp/providers/index.html.haml
Normal file
|
@ -0,0 +1,20 @@
|
|||
- content_for :page_title do
|
||||
= t('admin.fasp.providers.title')
|
||||
|
||||
- content_for :heading do
|
||||
%h2= t('admin.fasp.providers.title')
|
||||
= render 'admin/fasp/shared/links'
|
||||
|
||||
- unless @providers.empty?
|
||||
%hr.spacer
|
||||
|
||||
.table-wrapper
|
||||
%table.table#providers
|
||||
%thead
|
||||
%tr
|
||||
%th= t('admin.fasp.providers.name')
|
||||
%th= t('admin.fasp.providers.base_url')
|
||||
%th= t('admin.fasp.providers.status')
|
||||
%th
|
||||
%tbody
|
||||
= render partial: 'provider', collection: @providers
|
19
app/views/admin/fasp/registrations/new.html.haml
Normal file
19
app/views/admin/fasp/registrations/new.html.haml
Normal file
|
@ -0,0 +1,19 @@
|
|||
- content_for :page_title do
|
||||
= t('admin.fasp.providers.registrations.title')
|
||||
|
||||
%p= t('admin.fasp.providers.registrations.description')
|
||||
|
||||
%table.table.inline-table
|
||||
%tbody
|
||||
%tr
|
||||
%th= t('admin.fasp.providers.name')
|
||||
%td= @provider.name
|
||||
%tr
|
||||
%th= t('admin.fasp.providers.public_key_fingerprint')
|
||||
%td
|
||||
%code= @provider.provider_public_key_fingerprint
|
||||
|
||||
= form_with url: admin_fasp_provider_registration_path(@provider), class: :simple_form do |f|
|
||||
.actions
|
||||
= link_to t('admin.fasp.providers.registrations.reject'), admin_fasp_provider_path(@provider), data: { method: :delete }, class: 'btn negative'
|
||||
= f.button t('admin.fasp.providers.registrations.confirm'), type: :submit, class: 'btn'
|
5
app/views/admin/fasp/shared/_links.html.haml
Normal file
5
app/views/admin/fasp/shared/_links.html.haml
Normal file
|
@ -0,0 +1,5 @@
|
|||
.content__heading__tabs
|
||||
= render_navigation renderer: :links do |primary|
|
||||
:ruby
|
||||
primary.item :providers, safe_join([material_symbol('database'), t('admin.fasp.providers.providers')]), admin_fasp_providers_path
|
||||
primary.item :debug_callbacks, safe_join([material_symbol('repeat'), t('admin.fasp.debug.callbacks.title')]), admin_fasp_debug_callbacks_path
|
|
@ -36,8 +36,8 @@ require_relative '../lib/paperclip/response_with_limit_adapter'
|
|||
require_relative '../lib/terrapin/multi_pipe_extensions'
|
||||
require_relative '../lib/mastodon/middleware/public_file_server'
|
||||
require_relative '../lib/mastodon/middleware/socket_cleanup'
|
||||
require_relative '../lib/mastodon/snowflake'
|
||||
require_relative '../lib/mastodon/feature'
|
||||
require_relative '../lib/mastodon/snowflake'
|
||||
require_relative '../lib/mastodon/version'
|
||||
require_relative '../lib/devise/strategies/two_factor_ldap_authenticatable'
|
||||
require_relative '../lib/devise/strategies/two_factor_pam_authenticatable'
|
||||
|
|
|
@ -479,6 +479,36 @@ en:
|
|||
new:
|
||||
title: Import domain blocks
|
||||
no_file: No file selected
|
||||
fasp:
|
||||
debug:
|
||||
callbacks:
|
||||
created_at: Created at
|
||||
delete: Delete
|
||||
ip: IP address
|
||||
request_body: Request body
|
||||
title: Debug Callbacks
|
||||
providers:
|
||||
active: Active
|
||||
base_url: Base URL
|
||||
callback: Callback
|
||||
delete: Delete
|
||||
edit: Edit Provider
|
||||
finish_registration: Finish registration
|
||||
name: Name
|
||||
providers: Providers
|
||||
public_key_fingerprint: Public key fingerprint
|
||||
registration_requested: Registration requested
|
||||
registrations:
|
||||
confirm: Confirm
|
||||
description: You received a registration from a FASP. Reject it if you did not initiate this. If you initiated this, carefully compare name and key fingerprint before confirming the registration.
|
||||
reject: Reject
|
||||
title: Confirm FASP Registration
|
||||
save: Save
|
||||
select_capabilities: Select Capabilities
|
||||
sign_in: Sign In
|
||||
status: Status
|
||||
title: Fediverse Auxiliary Service Providers
|
||||
title: FASP
|
||||
follow_recommendations:
|
||||
description_html: "<strong>Follow recommendations help new users quickly find interesting content</strong>. When a user has not interacted with others enough to form personalized follow recommendations, these accounts are recommended instead. They are re-calculated on a daily basis from a mix of accounts with the highest recent engagements and highest local follower counts for a given language."
|
||||
language: For language
|
||||
|
|
|
@ -79,6 +79,7 @@ SimpleNavigation::Configuration.run do |navigation|
|
|||
s.item :announcements, safe_join([material_symbol('campaign'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}, if: -> { current_user.can?(:manage_announcements) }
|
||||
s.item :custom_emojis, safe_join([material_symbol('mood'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) }
|
||||
s.item :webhooks, safe_join([material_symbol('inbox'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) }
|
||||
s.item :fasp, safe_join([material_symbol('extension'), t('admin.fasp.title')]), admin_fasp_providers_path, highlights_on: %r{/admin/fasp}, if: -> { current_user.can?(:manage_federation) } if Mastodon::Feature.fasp_enabled?
|
||||
s.item :relays, safe_join([material_symbol('captive_portal'), t('admin.relays.title')]), admin_relays_path, highlights_on: %r{/admin/relays}, if: -> { !limited_federation_mode? && current_user.can?(:manage_federation) }
|
||||
end
|
||||
|
||||
|
|
|
@ -196,6 +196,8 @@ Rails.application.routes.draw do
|
|||
|
||||
draw(:api)
|
||||
|
||||
draw(:fasp)
|
||||
|
||||
draw(:web_app)
|
||||
|
||||
get '/web/(*any)', to: redirect('/%{any}', status: 302), as: :web, defaults: { any: '' }, format: false
|
||||
|
|
29
config/routes/fasp.rb
Normal file
29
config/routes/fasp.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
namespace :api, format: false do
|
||||
namespace :fasp do
|
||||
namespace :debug do
|
||||
namespace :v0 do
|
||||
namespace :callback do
|
||||
resources :responses, only: [:create]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resource :registration, only: [:create]
|
||||
end
|
||||
end
|
||||
|
||||
namespace :admin do
|
||||
namespace :fasp do
|
||||
namespace :debug do
|
||||
resources :callbacks, only: [:index, :destroy]
|
||||
end
|
||||
|
||||
resources :providers, only: [:index, :show, :edit, :update, :destroy] do
|
||||
resources :debug_calls, only: [:create]
|
||||
|
||||
resource :registration, only: [:new, :create]
|
||||
end
|
||||
end
|
||||
end
|
21
db/migrate/20241205103523_create_fasp_providers.rb
Normal file
21
db/migrate/20241205103523_create_fasp_providers.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateFaspProviders < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :fasp_providers do |t|
|
||||
t.boolean :confirmed, null: false, default: false
|
||||
t.string :name, null: false
|
||||
t.string :base_url, null: false, index: { unique: true }
|
||||
t.string :sign_in_url
|
||||
t.string :remote_identifier, null: false
|
||||
t.string :provider_public_key_pem, null: false
|
||||
t.string :server_private_key_pem, null: false
|
||||
t.jsonb :capabilities, null: false, default: []
|
||||
t.jsonb :privacy_policy
|
||||
t.string :contact_email
|
||||
t.string :fediverse_account
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
13
db/migrate/20241206131513_create_fasp_debug_callbacks.rb
Normal file
13
db/migrate/20241206131513_create_fasp_debug_callbacks.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateFaspDebugCallbacks < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :fasp_debug_callbacks do |t|
|
||||
t.references :fasp_provider, null: false, foreign_key: true
|
||||
t.string :ip, null: false
|
||||
t.text :request_body, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
27
db/schema.rb
27
db/schema.rb
|
@ -445,6 +445,32 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
|||
t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
|
||||
end
|
||||
|
||||
create_table "fasp_debug_callbacks", force: :cascade do |t|
|
||||
t.bigint "fasp_provider_id", null: false
|
||||
t.string "ip", null: false
|
||||
t.text "request_body", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["fasp_provider_id"], name: "index_fasp_debug_callbacks_on_fasp_provider_id"
|
||||
end
|
||||
|
||||
create_table "fasp_providers", force: :cascade do |t|
|
||||
t.boolean "confirmed", default: false, null: false
|
||||
t.string "name", null: false
|
||||
t.string "base_url", null: false
|
||||
t.string "sign_in_url"
|
||||
t.string "remote_identifier", null: false
|
||||
t.string "provider_public_key_pem", null: false
|
||||
t.string "server_private_key_pem", null: false
|
||||
t.jsonb "capabilities", default: [], null: false
|
||||
t.jsonb "privacy_policy"
|
||||
t.string "contact_email"
|
||||
t.string "fediverse_account"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["base_url"], name: "index_fasp_providers_on_base_url", unique: true
|
||||
end
|
||||
|
||||
create_table "favourites", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
|
@ -1292,6 +1318,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
|||
add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade
|
||||
add_foreign_key "custom_filters", "accounts", on_delete: :cascade
|
||||
add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade
|
||||
add_foreign_key "fasp_debug_callbacks", "fasp_providers"
|
||||
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
|
||||
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
|
||||
add_foreign_key "featured_tags", "accounts", on_delete: :cascade
|
||||
|
|
|
@ -107,7 +107,6 @@
|
|||
"react-immutable-proptypes": "^2.2.0",
|
||||
"react-immutable-pure-component": "^2.2.2",
|
||||
"react-intl": "^7.0.0",
|
||||
"react-motion": "^0.5.2",
|
||||
"react-overlays": "^5.2.1",
|
||||
"react-redux": "^9.0.4",
|
||||
"react-redux-loading-bar": "^5.0.8",
|
||||
|
@ -167,7 +166,6 @@
|
|||
"@types/react-dom": "^18.2.4",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@types/react-immutable-proptypes": "^2.1.0",
|
||||
"@types/react-motion": "^0.0.40",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-sparklines": "^1.7.2",
|
||||
|
|
7
spec/fabricators/fasp/debug_callback_fabricator.rb
Normal file
7
spec/fabricators/fasp/debug_callback_fabricator.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:fasp_debug_callback, from: 'Fasp::DebugCallback') do
|
||||
fasp_provider
|
||||
ip '127.0.0.234'
|
||||
request_body 'MyText'
|
||||
end
|
31
spec/fabricators/fasp/provider_fabricator.rb
Normal file
31
spec/fabricators/fasp/provider_fabricator.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:fasp_provider, from: 'Fasp::Provider') do
|
||||
name { Faker::App.name }
|
||||
base_url { Faker::Internet.unique.url }
|
||||
sign_in_url { Faker::Internet.url }
|
||||
remote_identifier 'MyString'
|
||||
provider_public_key_pem "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAh2ldXsaej2MXj0DHdCx7XibSo66uKlrLfJ5J6hte1Gk=\n-----END PUBLIC KEY-----\n"
|
||||
server_private_key_pem "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEICDjlajhVb8XfzyTchQWKraMKwtQW+r4opoAg7V3kw1Q\n-----END PRIVATE KEY-----\n"
|
||||
capabilities []
|
||||
end
|
||||
|
||||
Fabricator(:confirmed_fasp, from: :fasp_provider) do
|
||||
confirmed true
|
||||
capabilities [
|
||||
{ id: 'callback', version: '0.1' },
|
||||
{ id: 'data_sharing', version: '0.1' },
|
||||
]
|
||||
end
|
||||
|
||||
Fabricator(:debug_fasp, from: :fasp_provider) do
|
||||
confirmed true
|
||||
capabilities [
|
||||
{ id: 'callback', version: '0.1', enabled: true },
|
||||
]
|
||||
|
||||
after_build do |fasp|
|
||||
# Prevent fabrication from attempting an HTTP call to the provider
|
||||
def fasp.update_remote_capabilities = true
|
||||
end
|
||||
end
|
57
spec/lib/fasp/request_spec.rb
Normal file
57
spec/lib/fasp/request_spec.rb
Normal file
|
@ -0,0 +1,57 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require 'securerandom'
|
||||
|
||||
RSpec.describe Fasp::Request do
|
||||
include ProviderRequestHelper
|
||||
|
||||
subject { described_class.new(provider) }
|
||||
|
||||
let(:provider) do
|
||||
Fabricate(:fasp_provider, base_url: 'https://reqprov.example.com/fasp')
|
||||
end
|
||||
|
||||
shared_examples 'a provider request' do |method|
|
||||
context 'when the response is signed by the provider' do
|
||||
before do
|
||||
stub_provider_request(provider, method:, path: '/test_path')
|
||||
end
|
||||
|
||||
it "performs a signed #{method.to_s.upcase} request relative to the base_path of the fasp" do
|
||||
subject.send(method, '/test_path')
|
||||
|
||||
expect(WebMock).to have_requested(method, 'https://reqprov.example.com/fasp/test_path')
|
||||
.with(headers: {
|
||||
'Signature' => /.+/,
|
||||
'Signature-Input' => /.+/,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the response is not signed' do
|
||||
before do
|
||||
stub_request(method, 'https://reqprov.example.com/fasp/test_path')
|
||||
.to_return(status: 200)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
subject.send(method, '/test_path')
|
||||
end.to raise_error(SignatureVerification::SignatureVerificationError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get' do
|
||||
include_examples 'a provider request', :get
|
||||
end
|
||||
|
||||
describe '#post' do
|
||||
include_examples 'a provider request', :post
|
||||
end
|
||||
|
||||
describe '#delete' do
|
||||
include_examples 'a provider request', :delete
|
||||
end
|
||||
end
|
209
spec/models/fasp/provider_spec.rb
Normal file
209
spec/models/fasp/provider_spec.rb
Normal file
|
@ -0,0 +1,209 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Fasp::Provider do
|
||||
include ProviderRequestHelper
|
||||
|
||||
describe '#capabilities' do
|
||||
subject { described_class.new(confirmed: true, capabilities:) }
|
||||
|
||||
let(:capabilities) do
|
||||
[
|
||||
{ 'id' => 'one', 'enabled' => false },
|
||||
{ 'id' => 'two' },
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns an array of `Fasp::Capability` objects' do
|
||||
expect(subject.capabilities).to all(be_a(Fasp::Capability))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#capabilities_attributes=' do
|
||||
subject { described_class.new(confirmed: true) }
|
||||
|
||||
let(:capabilities_params) do
|
||||
{
|
||||
'0' => { 'id' => 'one', 'enabled' => '1' },
|
||||
'1' => { 'id' => 'two', 'enabled' => '0' },
|
||||
'2' => { 'id' => 'three' },
|
||||
}
|
||||
end
|
||||
|
||||
it 'sets capabilities from nested form style hash' do
|
||||
subject.capabilities_attributes = capabilities_params
|
||||
|
||||
expect(subject).to be_capability('one')
|
||||
expect(subject).to be_capability('two')
|
||||
expect(subject).to be_capability('three')
|
||||
expect(subject).to be_capability_enabled('one')
|
||||
expect(subject).to_not be_capability_enabled('two')
|
||||
expect(subject).to_not be_capability_enabled('three')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#capability?' do
|
||||
subject { described_class.new(confirmed:, capabilities:) }
|
||||
|
||||
let(:capabilities) do
|
||||
[
|
||||
{ 'id' => 'one', 'enabled' => false },
|
||||
{ 'id' => 'two', 'enabled' => true },
|
||||
]
|
||||
end
|
||||
|
||||
context 'when the provider is not confirmed' do
|
||||
let(:confirmed) { false }
|
||||
|
||||
it 'always returns false' do
|
||||
expect(subject.capability?('one')).to be false
|
||||
expect(subject.capability?('two')).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the provider is confirmed' do
|
||||
let(:confirmed) { true }
|
||||
|
||||
it 'returns true for available and false for missing capabilities' do
|
||||
expect(subject.capability?('one')).to be true
|
||||
expect(subject.capability?('two')).to be true
|
||||
expect(subject.capability?('three')).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#capability_enabled?' do
|
||||
subject { described_class.new(confirmed:, capabilities:) }
|
||||
|
||||
let(:capabilities) do
|
||||
[
|
||||
{ 'id' => 'one', 'enabled' => false },
|
||||
{ 'id' => 'two', 'enabled' => true },
|
||||
]
|
||||
end
|
||||
|
||||
context 'when the provider is not confirmed' do
|
||||
let(:confirmed) { false }
|
||||
|
||||
it 'always returns false' do
|
||||
expect(subject).to_not be_capability_enabled('one')
|
||||
expect(subject).to_not be_capability_enabled('two')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the provider is confirmed' do
|
||||
let(:confirmed) { true }
|
||||
|
||||
it 'returns true for enabled and false for disabled or missing capabilities' do
|
||||
expect(subject).to_not be_capability_enabled('one')
|
||||
expect(subject).to be_capability_enabled('two')
|
||||
expect(subject).to_not be_capability_enabled('three')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#server_private_key' do
|
||||
subject { Fabricate(:fasp_provider) }
|
||||
|
||||
it 'returns an OpenSSL::PKey::PKey' do
|
||||
expect(subject.server_private_key).to be_a OpenSSL::PKey::PKey
|
||||
end
|
||||
end
|
||||
|
||||
describe '#server_public_key_base64' do
|
||||
subject { Fabricate(:fasp_provider) }
|
||||
|
||||
it 'returns the server public key base64 encoded' do
|
||||
expect(subject.server_public_key_base64).to eq 'T2RHkakkqAOWEMRYv9OY7LGsuIcAdmBlxuXOKax6sjw='
|
||||
end
|
||||
end
|
||||
|
||||
describe '#provider_public_key_base64=' do
|
||||
subject { Fabricate(:fasp_provider) }
|
||||
|
||||
it 'allows setting the provider public key from a base64 encoded raw key' do
|
||||
subject.provider_public_key_base64 = '9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4='
|
||||
|
||||
expect(subject.provider_public_key_pem).to eq "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4=\n-----END PUBLIC KEY-----\n"
|
||||
end
|
||||
end
|
||||
|
||||
describe '#provider_public_key' do
|
||||
subject { Fabricate(:fasp_provider) }
|
||||
|
||||
it 'returns an OpenSSL::PKey::PKey' do
|
||||
expect(subject.provider_public_key).to be_a OpenSSL::PKey::PKey
|
||||
end
|
||||
end
|
||||
|
||||
describe '#provider_public_key_raw' do
|
||||
subject { Fabricate(:fasp_provider) }
|
||||
|
||||
it 'returns a string comprised of raw bytes' do
|
||||
expect(subject.provider_public_key_raw).to be_a String
|
||||
expect(subject.provider_public_key_raw.encoding).to eq Encoding::BINARY
|
||||
end
|
||||
end
|
||||
|
||||
describe '#provider_public_key_fingerprint' do
|
||||
subject { Fabricate(:fasp_provider) }
|
||||
|
||||
it 'returns a base64 encoded sha256 hash of the public key' do
|
||||
expect(subject.provider_public_key_fingerprint).to eq '/AmW9EMlVq4o+Qcu9lNfTE8Ss/v9+evMPtyj2R437qE='
|
||||
end
|
||||
end
|
||||
|
||||
describe '#url' do
|
||||
subject { Fabricate(:fasp_provider, base_url: 'https://myprovider.example.com/fasp_base/') }
|
||||
|
||||
it 'returns a full URL for a given path' do
|
||||
url = subject.url('/test_path')
|
||||
expect(url).to eq 'https://myprovider.example.com/fasp_base/test_path'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_info!' do
|
||||
subject { Fabricate(:fasp_provider, base_url: 'https://myprov.example.com/fasp/') }
|
||||
|
||||
before do
|
||||
stub_provider_request(subject,
|
||||
path: '/provider_info',
|
||||
response_body: {
|
||||
capabilities: [
|
||||
{ id: 'debug', version: '0.1' },
|
||||
],
|
||||
contactEmail: 'newcontact@example.com',
|
||||
fediverseAccount: '@newfedi@social.example.com',
|
||||
privacyPolicy: 'https::///example.com/privacy',
|
||||
signInUrl: 'https://myprov.example.com/sign_in',
|
||||
})
|
||||
end
|
||||
|
||||
context 'when setting confirm to `true`' do
|
||||
it 'updates the provider and marks it as `confirmed`' do
|
||||
subject.update_info!(confirm: true)
|
||||
|
||||
expect(subject.contact_email).to eq 'newcontact@example.com'
|
||||
expect(subject.fediverse_account).to eq '@newfedi@social.example.com'
|
||||
expect(subject.privacy_policy).to eq 'https::///example.com/privacy'
|
||||
expect(subject.sign_in_url).to eq 'https://myprov.example.com/sign_in'
|
||||
expect(subject).to be_confirmed
|
||||
expect(subject).to be_persisted
|
||||
end
|
||||
end
|
||||
|
||||
context 'when setting confirm to `false`' do
|
||||
it 'updates the provider but does not mark it as `confirmed`' do
|
||||
subject.update_info!
|
||||
|
||||
expect(subject.contact_email).to eq 'newcontact@example.com'
|
||||
expect(subject.fediverse_account).to eq '@newfedi@social.example.com'
|
||||
expect(subject.privacy_policy).to eq 'https::///example.com/privacy'
|
||||
expect(subject.sign_in_url).to eq 'https://myprov.example.com/sign_in'
|
||||
expect(subject).to_not be_confirmed
|
||||
expect(subject).to be_persisted
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
34
spec/policies/admin/fasp/provider_policy_spec.rb
Normal file
34
spec/policies/admin/fasp/provider_policy_spec.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Admin::Fasp::ProviderPolicy, type: :policy do
|
||||
subject { described_class }
|
||||
|
||||
let(:admin) { Fabricate(:admin_user).account }
|
||||
let(:user) { Fabricate(:account) }
|
||||
|
||||
shared_examples 'admin only' do |target|
|
||||
let(:provider) { target.is_a?(Symbol) ? Fabricate(target) : target }
|
||||
|
||||
context 'with an admin' do
|
||||
it 'permits' do
|
||||
expect(subject).to permit(admin, provider)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a non-admin' do
|
||||
it 'denies' do
|
||||
expect(subject).to_not permit(user, provider)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
permissions :index?, :create? do
|
||||
include_examples 'admin only', Fasp::Provider
|
||||
end
|
||||
|
||||
permissions :show?, :create?, :update?, :destroy? do
|
||||
include_examples 'admin only', :fasp_provider
|
||||
end
|
||||
end
|
28
spec/requests/api/fasp/debug/v0/callback/responses_spec.rb
Normal file
28
spec/requests/api/fasp/debug/v0/callback/responses_spec.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::Fasp::Debug::V0::Callback::Responses', feature: :fasp do
|
||||
include ProviderRequestHelper
|
||||
|
||||
describe 'POST /api/fasp/debug/v0/callback/responses' do
|
||||
let(:provider) { Fabricate(:debug_fasp) }
|
||||
|
||||
it 'create a record of the callback' do
|
||||
payload = { test: 'call' }
|
||||
headers = request_authentication_headers(provider,
|
||||
url: api_fasp_debug_v0_callback_responses_url,
|
||||
method: :post,
|
||||
body: payload)
|
||||
|
||||
expect do
|
||||
post api_fasp_debug_v0_callback_responses_path, headers:, params: payload, as: :json
|
||||
end.to change(Fasp::DebugCallback, :count).by(1)
|
||||
expect(response).to have_http_status(201)
|
||||
|
||||
debug_callback = Fasp::DebugCallback.last
|
||||
expect(debug_callback.fasp_provider).to eq provider
|
||||
expect(debug_callback.request_body).to eq '{"test":"call"}'
|
||||
end
|
||||
end
|
||||
end
|
42
spec/requests/api/fasp/registrations_spec.rb
Normal file
42
spec/requests/api/fasp/registrations_spec.rb
Normal file
|
@ -0,0 +1,42 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::Fasp::Registrations', feature: :fasp do
|
||||
describe 'POST /api/fasp/registration' do
|
||||
subject do
|
||||
post api_fasp_registration_path, params:
|
||||
end
|
||||
|
||||
context 'when given valid data' do
|
||||
let(:params) do
|
||||
{
|
||||
name: 'Test Provider',
|
||||
baseUrl: 'https://newprovider.example.com/fasp',
|
||||
serverId: '123',
|
||||
publicKey: '9qgjOfWRhozWc9dwx5JmbshizZ7TyPBhYk9+b5tE3e4=',
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new provider' do
|
||||
expect { subject }.to change(Fasp::Provider, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status 200
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given invalid data' do
|
||||
let(:params) do
|
||||
{
|
||||
name: 'incomplete',
|
||||
}
|
||||
end
|
||||
|
||||
it 'does not create a provider and returns an error code' do
|
||||
expect { subject }.to_not change(Fasp::Provider, :count)
|
||||
|
||||
expect(response).to have_http_status 422
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
72
spec/support/fasp/provider_request_helper.rb
Normal file
72
spec/support/fasp/provider_request_helper.rb
Normal file
|
@ -0,0 +1,72 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ProviderRequestHelper
|
||||
private
|
||||
|
||||
def stub_provider_request(provider, path: '/', method: :get, response_status: 200, response_body: '')
|
||||
response_body = encode_body(response_body)
|
||||
response_headers = {
|
||||
'content-type' => 'application/json',
|
||||
}.merge(response_authentication_headers(provider, response_status, response_body))
|
||||
|
||||
stub_request(method, provider.url(path))
|
||||
.to_return do |_request|
|
||||
{
|
||||
status: response_status,
|
||||
body: response_body,
|
||||
headers: response_headers,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def request_authentication_headers(provider, url: root_url, method: :get, body: '')
|
||||
body = encode_body(body)
|
||||
headers = {}
|
||||
headers['content-digest'] = content_digest(body)
|
||||
request = Linzer.new_request(method, url, {}, headers)
|
||||
key = private_key_for(provider)
|
||||
signature = sign(request, key, %w(@method @target-uri content-digest))
|
||||
headers.merge(signature.to_h)
|
||||
end
|
||||
|
||||
def response_authentication_headers(provider, status, body)
|
||||
headers = {}
|
||||
headers['content-digest'] = content_digest(body)
|
||||
response = Linzer.new_response(body, status, headers)
|
||||
key = private_key_for(provider)
|
||||
signature = sign(response, key, %w(@status content-digest))
|
||||
headers.merge(signature.to_h)
|
||||
end
|
||||
|
||||
def private_key_for(provider)
|
||||
@cached_provider_keys ||= {}
|
||||
@cached_provider_keys[provider] ||=
|
||||
begin
|
||||
key = OpenSSL::PKey.generate_key('ed25519')
|
||||
provider.update!(provider_public_key_pem: key.public_to_pem)
|
||||
key
|
||||
end
|
||||
|
||||
{
|
||||
id: provider.id.to_s,
|
||||
private_key: @cached_provider_keys[provider].private_to_pem,
|
||||
}
|
||||
end
|
||||
|
||||
def sign(request_or_response, key, components)
|
||||
message = Linzer::Message.new(request_or_response)
|
||||
linzer_key = Linzer.new_ed25519_key(key[:private_key], key[:id])
|
||||
Linzer.sign(linzer_key, message, components)
|
||||
end
|
||||
|
||||
def encode_body(body)
|
||||
return body if body.nil? || body.is_a?(String)
|
||||
|
||||
encoder = ActionDispatch::RequestEncoder.encoder(:json)
|
||||
encoder.encode_params(body)
|
||||
end
|
||||
|
||||
def content_digest(content)
|
||||
"sha-256=:#{OpenSSL::Digest.base64digest('sha256', content)}:"
|
||||
end
|
||||
end
|
29
spec/system/admin/fasp/debug/callbacks_spec.rb
Normal file
29
spec/system/admin/fasp/debug/callbacks_spec.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Debug FASP Callback Management', feature: :fasp do
|
||||
before { sign_in Fabricate(:admin_user) }
|
||||
|
||||
describe 'Viewing and deleting callbacks' do
|
||||
let(:provider) { Fabricate(:fasp_provider, name: 'debug prov') }
|
||||
|
||||
before do
|
||||
Fabricate(:fasp_debug_callback, fasp_provider: provider, request_body: 'called back')
|
||||
end
|
||||
|
||||
it 'displays callbacks and allows to delete them' do
|
||||
visit admin_fasp_debug_callbacks_path
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.debug.callbacks.title'))
|
||||
expect(page).to have_css('td', text: 'debug prov')
|
||||
expect(page).to have_css('code', text: 'called back')
|
||||
|
||||
expect do
|
||||
click_on I18n.t('admin.fasp.debug.callbacks.delete')
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.debug.callbacks.title'))
|
||||
end.to change(Fasp::DebugCallback, :count).by(-1)
|
||||
end
|
||||
end
|
||||
end
|
33
spec/system/admin/fasp/debug_calls_spec.rb
Normal file
33
spec/system/admin/fasp/debug_calls_spec.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'FASP Debug Calls', feature: :fasp do
|
||||
include ProviderRequestHelper
|
||||
|
||||
before { sign_in Fabricate(:admin_user) }
|
||||
|
||||
describe 'Triggering a FASP debug call' do
|
||||
let!(:provider) { Fabricate(:debug_fasp) }
|
||||
let!(:debug_call) do
|
||||
stub_provider_request(provider,
|
||||
method: :post,
|
||||
path: '/debug/v0/callback/logs',
|
||||
response_status: 201)
|
||||
end
|
||||
|
||||
it 'makes a debug call to the provider' do
|
||||
visit admin_fasp_providers_path
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
|
||||
expect(page).to have_css('td', text: provider.name)
|
||||
|
||||
within 'table#providers' do
|
||||
click_on I18n.t('admin.fasp.providers.callback')
|
||||
end
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
|
||||
expect(debug_call).to have_been_requested
|
||||
end
|
||||
end
|
||||
end
|
81
spec/system/admin/fasp/providers_spec.rb
Normal file
81
spec/system/admin/fasp/providers_spec.rb
Normal file
|
@ -0,0 +1,81 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'FASP Management', feature: :fasp do
|
||||
include ProviderRequestHelper
|
||||
|
||||
before { sign_in Fabricate(:admin_user) }
|
||||
|
||||
describe 'Managing capabilities' do
|
||||
let!(:provider) { Fabricate(:confirmed_fasp) }
|
||||
let!(:enable_call) do
|
||||
stub_provider_request(provider,
|
||||
method: :post,
|
||||
path: '/capabilities/callback/0/activation')
|
||||
end
|
||||
let!(:disable_call) do
|
||||
stub_provider_request(provider,
|
||||
method: :delete,
|
||||
path: '/capabilities/callback/0/activation')
|
||||
end
|
||||
|
||||
before do
|
||||
# We currently err on the side of caution and prefer to send
|
||||
# a "disable capability" call too often over risking to miss
|
||||
# one. So the following call _can_ happen here, and if it does
|
||||
# that is fine, but it has no bearing on the behavior that is
|
||||
# being tested.
|
||||
stub_provider_request(provider,
|
||||
method: :delete,
|
||||
path: '/capabilities/data_sharing/0/activation')
|
||||
end
|
||||
|
||||
it 'allows enabling and disabling of capabilities' do
|
||||
visit admin_fasp_providers_path
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
|
||||
expect(page).to have_css('td', text: provider.name)
|
||||
|
||||
click_on I18n.t('admin.fasp.providers.edit')
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit'))
|
||||
|
||||
check 'callback'
|
||||
|
||||
click_on I18n.t('admin.fasp.providers.save')
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
|
||||
expect(provider.reload).to be_capability_enabled('callback')
|
||||
expect(enable_call).to have_been_requested
|
||||
|
||||
click_on I18n.t('admin.fasp.providers.edit')
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit'))
|
||||
|
||||
uncheck 'callback'
|
||||
|
||||
click_on I18n.t('admin.fasp.providers.save')
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
|
||||
expect(provider.reload).to_not be_capability_enabled('callback')
|
||||
expect(disable_call).to have_been_requested
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Removing a provider' do
|
||||
let!(:provider) { Fabricate(:fasp_provider) }
|
||||
|
||||
it 'allows to completely remove a provider' do
|
||||
visit admin_fasp_providers_path
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
|
||||
expect(page).to have_css('td', text: provider.name)
|
||||
|
||||
click_on I18n.t('admin.fasp.providers.delete')
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.title'))
|
||||
expect(page).to have_no_css('td', text: provider.name)
|
||||
end
|
||||
end
|
||||
end
|
39
spec/system/admin/fasp/registrations_spec.rb
Normal file
39
spec/system/admin/fasp/registrations_spec.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'FASP registration', feature: :fasp do
|
||||
include ProviderRequestHelper
|
||||
|
||||
before { sign_in Fabricate(:admin_user) }
|
||||
|
||||
describe 'Confirming an unconfirmed FASP' do
|
||||
let(:provider) { Fabricate(:fasp_provider, confirmed: false) }
|
||||
|
||||
before do
|
||||
stub_provider_request(provider,
|
||||
path: '/provider_info',
|
||||
response_body: {
|
||||
capabilities: [
|
||||
{ id: 'debug', version: '0.1' },
|
||||
],
|
||||
contactEmail: 'newcontact@example.com',
|
||||
fediverseAccount: '@newfedi@social.example.com',
|
||||
privacyPolicy: 'https::///example.com/privacy',
|
||||
signInUrl: 'https://myprov.example.com/sign_in',
|
||||
})
|
||||
end
|
||||
|
||||
it 'displays key fingerprint and updates the provider on confirmation' do
|
||||
visit new_admin_fasp_provider_registration_path(provider)
|
||||
|
||||
expect(page).to have_css('code', text: provider.provider_public_key_fingerprint)
|
||||
|
||||
click_on I18n.t('admin.fasp.providers.registrations.confirm')
|
||||
|
||||
expect(page).to have_css('h2', text: I18n.t('admin.fasp.providers.edit'))
|
||||
|
||||
expect(provider.reload).to be_confirmed
|
||||
end
|
||||
end
|
||||
end
|
49
yarn.lock
49
yarn.lock
|
@ -2771,7 +2771,6 @@ __metadata:
|
|||
"@types/react-dom": "npm:^18.2.4"
|
||||
"@types/react-helmet": "npm:^6.1.6"
|
||||
"@types/react-immutable-proptypes": "npm:^2.1.0"
|
||||
"@types/react-motion": "npm:^0.0.40"
|
||||
"@types/react-router": "npm:^5.1.20"
|
||||
"@types/react-router-dom": "npm:^5.3.3"
|
||||
"@types/react-sparklines": "npm:^1.7.2"
|
||||
|
@ -2853,7 +2852,6 @@ __metadata:
|
|||
react-immutable-proptypes: "npm:^2.2.0"
|
||||
react-immutable-pure-component: "npm:^2.2.2"
|
||||
react-intl: "npm:^7.0.0"
|
||||
react-motion: "npm:^0.5.2"
|
||||
react-overlays: "npm:^5.2.1"
|
||||
react-redux: "npm:^9.0.4"
|
||||
react-redux-loading-bar: "npm:^5.0.8"
|
||||
|
@ -4053,15 +4051,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-motion@npm:^0.0.40":
|
||||
version: 0.0.40
|
||||
resolution: "@types/react-motion@npm:0.0.40"
|
||||
dependencies:
|
||||
"@types/react": "npm:*"
|
||||
checksum: 10c0/8a560051be917833fdbe051185b53aeafbe8657968ac8e073ac874b9a55c6f16e3793748b13cfb9bd6d9a3d27aba116d6f8f296ec1950f4175dc94d17c5e8470
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-router-dom@npm:^5.3.3":
|
||||
version: 5.3.3
|
||||
resolution: "@types/react-router-dom@npm:5.3.3"
|
||||
|
@ -13213,20 +13202,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"performance-now@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "performance-now@npm:0.2.0"
|
||||
checksum: 10c0/d7f3824e443491208f7124b45d3280dbff889f8f048c3aee507109c24644d51a226eb07fd7ac51dd0eef144639590c57410c2d167bd4fdf0c5caa0101a449c3d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"performance-now@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "performance-now@npm:2.1.0"
|
||||
checksum: 10c0/22c54de06f269e29f640e0e075207af57de5052a3d15e360c09b9a8663f393f6f45902006c1e71aa8a5a1cdfb1a47fe268826f8496d6425c362f00f5bc3e85d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pg-cloudflare@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "pg-cloudflare@npm:1.1.1"
|
||||
|
@ -14489,7 +14464,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prop-types@npm:^15.5.10, prop-types@npm:^15.5.4, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
|
||||
"prop-types@npm:^15.5.10, prop-types@npm:^15.5.4, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
|
||||
version: 15.8.1
|
||||
resolution: "prop-types@npm:15.8.1"
|
||||
dependencies:
|
||||
|
@ -14620,15 +14595,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"raf@npm:^3.1.0":
|
||||
version: 3.4.1
|
||||
resolution: "raf@npm:3.4.1"
|
||||
dependencies:
|
||||
performance-now: "npm:^2.1.0"
|
||||
checksum: 10c0/337f0853c9e6a77647b0f499beedafea5d6facfb9f2d488a624f88b03df2be72b8a0e7f9118a3ff811377d534912039a3311815700d2b6d2313f82f736f9eb6e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "randombytes@npm:2.1.0"
|
||||
|
@ -14801,19 +14767,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-motion@npm:^0.5.2":
|
||||
version: 0.5.2
|
||||
resolution: "react-motion@npm:0.5.2"
|
||||
dependencies:
|
||||
performance-now: "npm:^0.2.0"
|
||||
prop-types: "npm:^15.5.8"
|
||||
raf: "npm:^3.1.0"
|
||||
peerDependencies:
|
||||
react: ^0.14.9 || ^15.3.0 || ^16.0.0
|
||||
checksum: 10c0/4ea6f1cc7079f0161fd786cc755133a822d87d9c0510369b8fb348d9ad602111efa2e3496dbcc390c967229e39e3eb5f6dd5dd6d3d124289443de31d6035a6c8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-overlays@npm:^5.2.1":
|
||||
version: 5.2.1
|
||||
resolution: "react-overlays@npm:5.2.1"
|
||||
|
|
Loading…
Add table
Reference in a new issue