Compare commits

...

61 commits

Author SHA1 Message Date
Sebastian Jambor
42b609bdd0 clean up css 2023-04-15 19:52:07 +02:00
Sebastian Jambor
d7f271729a new signup page 2023-04-15 19:49:17 +02:00
Sebastian Jambor
d74d59705d follow redirects in json-ld proxy 2023-02-04 21:13:59 +01:00
Sebastian Jambor
91fecfa421 pass fetch method to explorer 2023-02-04 19:50:38 +01:00
Sebastian Jambor
5f29fe69f5 set cors header 2023-02-04 19:45:51 +01:00
Sebastian Jambor
0cfbad063f use faraday for json ld proxy again to allow for uris with queries 2023-02-04 19:32:39 +01:00
Sebastian Jambor
a20ddf7c0f add sender to activity log json 2023-02-04 19:10:56 +01:00
Sebastian Jambor
8ba896bcb6 replace faraday by net/http to set timeouts 2023-02-02 14:59:19 +01:00
Sebastian Jambor
d65f957327 make json-ld endpoint non-blocking 2023-02-01 19:44:24 +01:00
Sebastian Jambor
ea7f1ed1ad make activity_log endpoint non-blocking 2023-02-01 18:00:38 +01:00
Sebastian Jambor
88bc0a6c76 reword signup message 2023-02-01 17:58:18 +01:00
Sebastian Jambor
9beadd9ee8 add empty placeholder for activity log 2023-01-30 20:18:57 +01:00
Sebastian Jambor
9ed5142b0d some style adjustments 2023-01-29 20:12:00 +01:00
Sebastian Jambor
bd2e33f358 links in activity log open explorer 2023-01-27 17:47:50 +01:00
Sebastian Jambor
f06cb96098 add link between activity log and explorer 2023-01-25 22:50:16 +01:00
Sebastian Jambor
7be872a0b4 starting to add explorer 2023-01-25 21:41:29 +01:00
Sebastian Jambor
dbe589f983 add json ld route 2023-01-25 09:48:38 +01:00
Sebastian Jambor
6fd471295d remove unused index route 2023-01-16 19:47:50 +01:00
Sebastian Jambor
8a3c60672f move event source to top level
this way, activities are always logged while the app is open, and the
activity log survives local navigations
2023-01-15 20:00:51 +01:00
Sebastian Jambor
ac398426fc some rewording of signup message 2023-01-14 18:28:37 +01:00
Sebastian Jambor
e02602e445 change email confirmation flash to not mention email 2023-01-14 18:10:13 +01:00
Sebastian Jambor
ac32e5ec1d remove email and password section from account preferences 2023-01-14 18:00:30 +01:00
Sebastian Jambor
8d3ceafe34 allow account redirect without specifying password 2023-01-14 17:56:43 +01:00
Sebastian Jambor
58d83a8cb1 add description to activity log page 2023-01-14 17:19:37 +01:00
Sebastian Jambor
240160f877 fix issue in activity logger 2023-01-14 17:12:20 +01:00
Sebastian Jambor
a622b0b947 add hotkey to go to activity log, and hotkey to copy logs 2023-01-13 17:59:12 +01:00
Sebastian Jambor
f727ee6a45 increase number of syllables for last name 2023-01-13 17:37:34 +01:00
Sebastian Jambor
25008e0555 scheduler to delete old accounts 2023-01-13 16:49:12 +01:00
Sebastian Jambor
995c69a45b allow multiple clients for the same id 2023-01-12 19:48:04 +01:00
Sebastian Jambor
291027c1c3 first explanation on sign-up page 2023-01-10 18:50:16 +01:00
Sebastian Jambor
a84f182cf4 allow shorter time for sign up 2023-01-10 18:49:59 +01:00
Sebastian Jambor
12abf5e142 redirect to sign-up after logging out 2023-01-10 18:49:43 +01:00
Sebastian Jambor
638c1ed7d8 when not logged in, / redirects to sign-up; when logged-in, / redirects to activity_log 2023-01-10 15:23:33 +01:00
Sebastian Jambor
e90505cfdf use resdis configuration for activity log 2023-01-09 21:46:58 +01:00
Sebastian Jambor
d73bf5770d one-click sign-up with autogenerated usernames 2023-01-09 21:25:59 +01:00
Sebastian Jambor
fd92599890 use public package 2023-01-09 14:48:03 +01:00
Sebastian Jambor
5560887862 filter keep-alives 2023-01-09 14:48:03 +01:00
Sebastian Jambor
426e096a9b fix timestamp 2023-01-09 14:48:03 +01:00
Sebastian Jambor
9785c2849a integrate audience helper 2023-01-09 14:48:03 +01:00
Sebastian Jambor
57617faa27 handle duplicates 2023-01-09 14:48:03 +01:00
Sebastian Jambor
d5408766cc handle followers 2023-01-09 14:48:03 +01:00
Sebastian Jambor
7a30154bc5 handle audicence fields 2023-01-09 14:48:03 +01:00
Sebastian Jambor
f1ee1eadd9 extending functionality of audience helper 2023-01-09 14:48:03 +01:00
Sebastian Jambor
e884c39a03 fix keep-alive 2023-01-09 14:48:03 +01:00
Sebastian Jambor
f643515fdd starting a test for audience helper 2023-01-09 14:48:03 +01:00
Sebastian Jambor
078688149a remove mode settings 2023-01-09 14:48:03 +01:00
Sebastian Jambor
f94db7a54f convert to hooks 2023-01-09 14:48:03 +01:00
Sebastian Jambor
cb83422a8a extract dummy data 2023-01-09 14:48:03 +01:00
Sebastian Jambor
7a0e5c9900 starting column header 2023-01-09 14:48:03 +01:00
Sebastian Jambor
14570da001 some cleanup 2023-01-09 14:48:03 +01:00
Sebastian Jambor
0a479aa734 add link to main column 2023-01-09 14:48:03 +01:00
Sebastian Jambor
b9df613b31 enable dark mode 2023-01-09 14:48:03 +01:00
Sebastian Jambor
faf7925ce0 use external library 2023-01-09 14:48:03 +01:00
Sebastian Jambor
bfd9f4938d add timestamps 2023-01-09 14:48:03 +01:00
Sebastian Jambor
4a01c00ef2 starting with some styling 2023-01-09 14:48:03 +01:00
Sebastian Jambor
12c4213ecf removing debug output 2023-01-09 14:48:03 +01:00
Sebastian Jambor
3f4a72e7f3 log outbound events as well 2023-01-09 14:48:03 +01:00
Sebastian Jambor
a414adc582 showing inbound activities in frontend via redis pub/sub 2023-01-09 14:48:03 +01:00
Sebastian Jambor
313af50864 prototyping server sent events 2023-01-09 14:48:02 +01:00
Sebastian Jambor
3142c2b31a adding activity log page 2023-01-09 14:48:02 +01:00
Sebastian Jambor
e35b438f06 log activity pub messages to error log 2023-01-09 14:48:02 +01:00
58 changed files with 1122 additions and 100 deletions

View file

@ -155,3 +155,5 @@ gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1' gem 'xorcist', '~> 1.1'
gem 'cocoon', '~> 1.2' gem 'cocoon', '~> 1.2'
gem 'random_name_generator'

View file

@ -535,6 +535,7 @@ GEM
thor (~> 1.0) thor (~> 1.0)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.0.6)
random_name_generator (2.0.1)
rdf (3.2.9) rdf (3.2.9)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.5.0) rdf-normalize (0.5.0)
@ -820,6 +821,7 @@ DEPENDENCIES
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 6.0) rails-i18n (~> 6.0)
rails-settings-cached (~> 0.6) rails-settings-cached (~> 0.6)
random_name_generator
rdf-normalize (~> 0.5) rdf-normalize (~> 0.5)
redcarpet (~> 3.5) redcarpet (~> 3.5)
redis (~> 4.5) redis (~> 4.5)
@ -855,3 +857,9 @@ DEPENDENCIES
webpacker (~> 5.4) webpacker (~> 5.4)
webpush! webpush!
xorcist (~> 1.1) xorcist (~> 1.1)
RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.2.33

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'redis'
class ActivityPub::InboxesController < ActivityPub::BaseController class ActivityPub::InboxesController < ActivityPub::BaseController
include SignatureVerification include SignatureVerification
@ -9,6 +10,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
before_action :require_actor_signature! before_action :require_actor_signature!
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
def initialize
@activity_log_publisher = ActivityLogPublisher.new
end
def create def create
upgrade_account upgrade_account
process_collection_synchronization process_collection_synchronization
@ -71,6 +76,16 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
end end
def process_payload def process_payload
raw_signature = request.headers['Signature']
tree = SignatureParamsParser.new.parse(raw_signature)
signature_params = SignatureParamsTransformer.new.apply(tree)
sender = actor_from_key_id(signature_params['keyId'])
event = ActivityLogEvent.new('inbound', sender.uri, "https://#{Rails.configuration.x.web_domain}#{request.path}", Oj.load(body, mode: :strict))
@activity_log_publisher.publish(event)
ActivityPub::ProcessingWorker.perform_async(signed_request_actor.id, body, @account&.id, signed_request_actor.class.name) ActivityPub::ProcessingWorker.perform_async(signed_request_actor.id, body, @account&.id, signed_request_actor.class.name)
end end
end end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Api::V1::ActivityLogController < Api::BaseController
include ActionController::Live
before_action :require_user!
rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422
end
def show
response.headers['Content-Type'] = 'text/event-stream'
# hack to avoid computing Etag, which delays sending of data
response.headers['Last-Modified'] = Time.now.httpdate
# adapted from https://blog.chumakoff.com/en/posts/rails_sse_rack_hijacking_api
response.headers["rack.hijack"] = proc do |stream|
ActivityLogger.register(current_account.username, SSE.new(stream))
end
head :ok
end
end

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
require "faraday"
class Api::V1::JsonLdController < Api::BaseController
include ActionController::Live
rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422
end
def show
url = params[:url]
request.env['rack.hijack'].call
io = request.env['rack.hijack_io']
Thread.new {
begin
conn = Faraday::Connection.new
conn.options.timeout = 5
api_response = conn.get(url, nil, {'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'})
max_redirects = 5
while api_response.status == 301 || api_response.status == 302 and max_redirects > 0 do
api_response = conn.get(api_response.headers['Location'], nil, {'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'})
max_redirects -= 1
end
io.write("HTTP/1.1 #{api_response.status}\r\n")
io.write("Content-Type: #{api_response.headers['Content-Type']}\r\n")
io.write("Access-Control-Allow-Origin: *\r\n")
io.write("Connection: close\r\n")
io.write("\r\n")
io.write(api_response.body)
rescue
io.write("HTTP/1.1 500\r\n")
io.write("Access-Control-Allow-Origin: *\r\n")
io.write("Connection: close\r\n")
io.write("\r\n")
ensure
io.close
end
}
end
end

View file

@ -61,7 +61,7 @@ class ApplicationController < ActionController::Base
end end
def after_sign_out_path_for(_resource_or_scope) def after_sign_out_path_for(_resource_or_scope)
new_user_session_path "/auth/sign_up"
end end
protected protected

View file

@ -1,5 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'random_name_generator'
require 'securerandom'
class Auth::RegistrationsController < Devise::RegistrationsController class Auth::RegistrationsController < Devise::RegistrationsController
include RegistrationSpamConcern include RegistrationSpamConcern
@ -45,6 +48,21 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def build_resource(hash = nil) def build_resource(hash = nil)
# hack to always use auto-generated usernames and passwords
if !hash.nil?
username = generate_name
password = SecureRandom.hex
hash["account_attributes"] = {
"username": username.parameterize(separator: '_'),
"display_name": username
}
hash["email"] = "#{hash["account_attributes"]["username"]}@#{Rails.configuration.x.web_domain}"
hash["password"] = password
hash["password_confirmation"] = password
end
super(hash) super(hash)
resource.locale = I18n.locale resource.locale = I18n.locale
@ -62,7 +80,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def after_sign_up_path_for(_resource) def after_sign_up_path_for(_resource)
auth_setup_path # Hack to automatically visit the confirmation link after successful sign-up.
# This way we can use the default configuration but still get away without an email server.
"/auth/confirmation?confirmation_token=#{@user.confirmation_token}"
end end
def after_sign_in_path_for(_resource) def after_sign_in_path_for(_resource)
@ -125,7 +145,13 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def determine_layout def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth' if %w(edit update).include?(action_name)
'admin'
elsif action_name == 'new'
'academy-signup'
else
'auth'
end
end end
def set_sessions def set_sessions
@ -156,4 +182,16 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def set_cache_headers def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
end end
@@first_name_generator = RandomNameGenerator.new(File.new("#{File.dirname(__FILE__)}/roman.txt"))
@@last_name_generator = RandomNameGenerator.new(RandomNameGenerator::FANTASY)
def generate_name
# When there is a name collision, the user will be shown
# "Something isn't quite right yet! Please review 2 errors below"
# When they sign up again, it will most probably succeed (since there is no collision anymore)
# While this isn't the best UX, it's only a minor issue
# (collisions happen after > 1k of users and there's an easy fix) # so not worth fixing for now
"#{@@first_name_generator.compose(3)} #{@@last_name_generator.compose(3)}"
end
end end

View file

@ -0,0 +1,44 @@
-a
-al
-au +c
-an
-ba
-be
-bi
-br +v
-da
-di
-do
-du
-e
-eu +c
-fa
bi
be
bo
bu
nul +v
gu
da
au +c -c
fri
gus
+tus
+ta
+lus
+la
+lius
+lia
+nus
+na
+es
+ius -c
+ia -c
+cus
+ca
+tor
+cio
+cia
+tin
+tia
+ssia -v

View file

@ -6,7 +6,9 @@ class HomeController < ApplicationController
before_action :set_instance_presenter before_action :set_instance_presenter
def index def index
expires_in 0, public: true unless user_signed_in? if !user_signed_in?
redirect_to "/auth/sign_up"
end
end end
private private

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,11 @@
export const ACTIVITY_LOG_RESET ='ACTIVITY_LOG_RESET';
export const ACTIVITY_LOG_ADD = 'ACTIVITY_LOG_ADD';
export const resetActivityLog = () => ({
type: ACTIVITY_LOG_RESET,
});
export const addActivityLog = value => ({
type: ACTIVITY_LOG_ADD,
value,
});

View file

@ -0,0 +1,12 @@
export const ACTIVITYPUB_EXPLORER_DATA ='ACTIVITYPUB_EXPLORER_DATA';
export const ACTIVITYPUB_EXPLORER_URL ='ACTIVITYPUB_EXPLORER_URL';
export const setExplorerData = (value) => ({
type: ACTIVITYPUB_EXPLORER_DATA,
value,
});
export const setExplorerUrl = (value) => ({
type: ACTIVITYPUB_EXPLORER_URL,
value,
});

View file

@ -13,6 +13,7 @@ import { connectUserStream } from 'mastodon/actions/streaming';
import ErrorBoundary from 'mastodon/components/error_boundary'; import ErrorBoundary from 'mastodon/components/error_boundary';
import initialState, { title as siteTitle } from 'mastodon/initial_state'; import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { getLocale } from 'mastodon/locales'; import { getLocale } from 'mastodon/locales';
import { addActivityLog } from 'mastodon/actions/activity_log';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
@ -59,6 +60,14 @@ export default class Mastodon extends React.PureComponent {
componentDidMount() { componentDidMount() {
if (this.identity.signedIn) { if (this.identity.signedIn) {
this.disconnect = store.dispatch(connectUserStream()); this.disconnect = store.dispatch(connectUserStream());
this.eventSource = new EventSource('/api/v1/activity_log');
this.eventSource.onmessage = (event) => {
const parsed = JSON.parse(event.data);
if (parsed.type !== 'keep-alive') {
store.dispatch(addActivityLog(parsed));
}
};
} }
} }
@ -66,6 +75,7 @@ export default class Mastodon extends React.PureComponent {
if (this.disconnect) { if (this.disconnect) {
this.disconnect(); this.disconnect();
this.disconnect = null; this.disconnect = null;
this.eventSource.close();
} }
} }

View file

@ -0,0 +1,112 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { HotKeys } from 'react-hotkeys';
import { setExplorerData, setExplorerUrl } from 'mastodon/actions/activitypub_explorer';
import DismissableBanner from 'mastodon/components/dismissable_banner';
import { ActivityPubVisualization } from 'activitypub-visualization';
const mapStateToProps = (state) => {
return {
logs: state.getIn(['activity_log', 'logs']),
};
};
export default @connect(mapStateToProps)
class ActivityLog extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
multiColumn: PropTypes.bool,
};
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
render() {
const { dispatch, logs, multiColumn } = this.props;
const darkMode = !(document.body && document.body.classList.contains('theme-mastodon-light'));
// hijack the toggleHidden shortcut to copy the logs to clipbaord
const handlers = {
toggleHidden: () => navigator.clipboard.writeText(JSON.stringify(logs, null, 2)),
};
const Content = () => {
if (logs.length > 0) {
return ( <HotKeys handlers={handlers}>
<div className={`${darkMode ? 'dark' : ''}`} style={{height: '100%'}}>
<ActivityPubVisualization
logs={logs}
clickableLinks
onLinkClick={(url) => {
dispatch(setExplorerUrl(url));
this.context.router.history.push('/activitypub_explorer');
}}
showExplorerLink
onExplorerLinkClick={(data) => {
dispatch(setExplorerData(data));
this.context.router.history.push('/activitypub_explorer');
}}
/>
</div>
</HotKeys>) } else {
return (<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.activity_log' defaultMessage='The Activity Log is empty. Interact with accounts on other instances to trigger activities. You can find more information on my {blog}.'
values={{
blog: <a className='blog-link' href='//seb.jambor.dev/'>blog</a>,
}}
/>
</div>)
}
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label='Activity Log'>
<ColumnHeader
icon='comments'
title='Activity Log'
onClick={this.handleHeaderClick}
multiColumn={multiColumn}
/>
<DismissableBanner id='activity_log'>
<p>
<FormattedMessage
id='dismissable_banner.activity_log_information'
defaultMessage='When you interact with another instance (for example, follow an account on another instance), the resulting Activities will be shown here. You can find more information on my {blog}.'
values={{
blog: <a className='blog-link' href='//seb.jambor.dev/'>blog</a>,
}}
/>
</p>
<p style={{ paddingTop: '5px' }}>
<FormattedMessage
id='dismissable_banner.activity_log_clear'
defaultMessage='Note: Activities will only be logged while Mastodon is open. When you navigate elsewhere or reload the page, the log will be cleared.'
/>
</p>
</DismissableBanner>
<Content />
</Column>
);
}
}

View file

@ -0,0 +1,85 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import DismissableBanner from 'mastodon/components/dismissable_banner';
import { setExplorerData, setExplorerUrl } from 'mastodon/actions/activitypub_explorer';
import { ActivityPubExplorer as Explorer } from 'activitypub-visualization';
const mapStateToProps = (state) => {
return {
data: state.getIn(['activitypub_explorer', 'data']),
url: state.getIn(['activitypub_explorer', 'url']),
};
};
export default @connect(mapStateToProps)
class ActivityPubExplorer extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
multiColumn: PropTypes.bool,
};
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
componentWillUnmount () {
// clear explorer data on unbound so that we start with a clean slate on next navigation
this.props.dispatch(setExplorerData(null));
this.props.dispatch(setExplorerUrl(''));
}
render() {
const { data, url, multiColumn } = this.props;
const darkMode = !(document.body && document.body.classList.contains('theme-mastodon-light'));
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label='ActivityPub Explorer'>
<ColumnHeader
icon='wpexplorer'
title='ActivityPub Explorer'
onClick={this.handleHeaderClick}
multiColumn={multiColumn}
/>
<DismissableBanner id='activity_log'>
<p>
<FormattedMessage
id='dismissable_banner.activity_pub_explorer_information'
defaultMessage='The AcivityPub Explorer provides a convenient way to browse through ActivityPub data. Click on any {https} URL in the returned JSON to fetch the corresponding data. You can find more information on my {blog}.'
values={{
blog: <a className='blog-link' href='//seb.jambor.dev/'>blog</a>,
https: <code>https://</code>,
}}
/>
</p>
</DismissableBanner>
<div className={`${darkMode ? 'dark' : ''}`}>
<Explorer
fetchMethod={async (url) =>
fetch('/api/v1/json_ld?' + new URLSearchParams({ url }).toString())
}
initialValue={data}
initialUrl={url}
/>
</div>
</Column>
);
}
}

View file

@ -9,6 +9,7 @@ const messages = defineMessages({
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
activity_log: { id: 'navigation_bar.activity_log', defaultMessage: 'Activity log' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
@ -45,6 +46,7 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.activity_log), to: '/activity_log' });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });

View file

@ -158,6 +158,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>g</kbd>+<kbd>r</kbd></td> <td><kbd>g</kbd>+<kbd>r</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='to open follow requests list' /></td> <td><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='to open follow requests list' /></td>
</tr> </tr>
<tr>
<td><kbd>g</kbd>+<kbd>a</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.activity_log' defaultMessage='to open activity log' /></td>
</tr>
<tr> <tr>
<td><kbd>?</kbd></td> <td><kbd>?</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td> <td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td>

View file

@ -86,6 +86,10 @@ class NavigationPanel extends React.Component {
<ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} /> <ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} /> <ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
<ColumnLink transparent to='/activity_log' icon='comments' text='Activity Log' />
<ColumnLink transparent to='/activitypub_explorer' icon='wpexplorer' text='ActivityPub Explorer' />
<ListPanel /> <ListPanel />
<hr /> <hr />

View file

@ -48,6 +48,8 @@ import {
Mutes, Mutes,
PinnedStatuses, PinnedStatuses,
Lists, Lists,
ActivityLog,
ActivityPubExplorer,
Directory, Directory,
Explore, Explore,
FollowRecommendations, FollowRecommendations,
@ -105,6 +107,8 @@ const keyMap = {
goToBlocked: 'g b', goToBlocked: 'g b',
goToMuted: 'g m', goToMuted: 'g m',
goToRequests: 'g r', goToRequests: 'g r',
goToActivityLog: 'g a',
goToActivityPubExplorer: 'g e',
toggleHidden: 'x', toggleHidden: 'x',
toggleSensitive: 'h', toggleSensitive: 'h',
openMedia: 'e', openMedia: 'e',
@ -156,11 +160,7 @@ class SwitchingColumnsArea extends React.PureComponent {
let redirect; let redirect;
if (signedIn) { if (signedIn) {
if (mobile) { redirect = <Redirect from='/' to='/activity_log' exact />;
redirect = <Redirect from='/' to='/home' exact />;
} else {
redirect = <Redirect from='/' to='/getting-started' exact />;
}
} else if (singleUserMode && owner && initialState?.accounts[owner]) { } else if (singleUserMode && owner && initialState?.accounts[owner]) {
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />; redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
} else if (showTrends) { } else if (showTrends) {
@ -218,6 +218,8 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} /> <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/activity_log' component={ActivityLog} content={children} />
<WrappedRoute path='/activitypub_explorer' component={ActivityPubExplorer} content={children} />
<Route component={BundleColumnError} /> <Route component={BundleColumnError} />
</WrappedSwitch> </WrappedSwitch>
@ -387,12 +389,6 @@ class UI extends React.PureComponent {
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
} }
// On first launch, redirect to the follow recommendations page
if (signedIn && this.props.firstLaunch) {
this.context.router.history.replace('/start');
this.props.dispatch(closeOnboarding());
}
if (signedIn) { if (signedIn) {
this.props.dispatch(fetchMarkers()); this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
@ -495,6 +491,14 @@ class UI extends React.PureComponent {
this.context.router.history.push('/home'); this.context.router.history.push('/home');
} }
handleHotkeyGoToActivityLog = () => {
this.context.router.history.push('/activity_log');
}
handleHotkeyGoToActivityPubExplorer = () => {
this.context.router.history.push('/activitypub_explorer');
}
handleHotkeyGoToNotifications = () => { handleHotkeyGoToNotifications = () => {
this.context.router.history.push('/notifications'); this.context.router.history.push('/notifications');
} }
@ -552,6 +556,8 @@ class UI extends React.PureComponent {
focusColumn: this.handleHotkeyFocusColumn, focusColumn: this.handleHotkeyFocusColumn,
back: this.handleHotkeyBack, back: this.handleHotkeyBack,
goToHome: this.handleHotkeyGoToHome, goToHome: this.handleHotkeyGoToHome,
goToActivityLog: this.handleHotkeyGoToActivityLog,
goToActivityPubExplorer: this.handleHotkeyGoToActivityPubExplorer,
goToNotifications: this.handleHotkeyGoToNotifications, goToNotifications: this.handleHotkeyGoToNotifications,
goToLocal: this.handleHotkeyGoToLocal, goToLocal: this.handleHotkeyGoToLocal,
goToFederated: this.handleHotkeyGoToFederated, goToFederated: this.handleHotkeyGoToFederated,

View file

@ -38,6 +38,14 @@ export function Lists () {
return import(/* webpackChunkName: "features/lists" */'../../lists'); return import(/* webpackChunkName: "features/lists" */'../../lists');
} }
export function ActivityLog () {
return import(/* webpackChunkName: "features/activity_log" */'../../activity_log');
}
export function ActivityPubExplorer () {
return import(/* webpackChunkName: "features/activity_log" */'../../activitypub_explorer');
}
export function Status () { export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status'); return import(/* webpackChunkName: "features/status" */'../../status');
} }

View file

@ -0,0 +1,17 @@
import { Map as ImmutableMap } from 'immutable';
import { ACTIVITY_LOG_ADD, ACTIVITY_LOG_RESET } from '../actions/activity_log';
const initialState = ImmutableMap({
logs: [],
});
export default function activity_log(state = initialState, action) {
switch (action.type) {
case ACTIVITY_LOG_ADD:
return state.set('logs', [...state.get('logs'), action.value]);
case ACTIVITY_LOG_RESET:
return state.set('logs', []);
default:
return state;
}
}

View file

@ -0,0 +1,18 @@
import { Map as ImmutableMap } from 'immutable';
import { ACTIVITYPUB_EXPLORER_DATA, ACTIVITYPUB_EXPLORER_URL } from '../actions/activitypub_explorer';
const initialState = ImmutableMap({
data: null,
url: '',
});
export default function activitypub_explorer(state = initialState, action) {
switch (action.type) {
case ACTIVITYPUB_EXPLORER_DATA:
return state.set('data', action.value);
case ACTIVITYPUB_EXPLORER_URL:
return state.set('url', action.value);
default:
return state;
}
}

View file

@ -9,6 +9,8 @@ import user_lists from './user_lists';
import domain_lists from './domain_lists'; import domain_lists from './domain_lists';
import accounts from './accounts'; import accounts from './accounts';
import accounts_counters from './accounts_counters'; import accounts_counters from './accounts_counters';
import activity_log from './activity_log';
import activitypub_explorer from './activitypub_explorer';
import statuses from './statuses'; import statuses from './statuses';
import relationships from './relationships'; import relationships from './relationships';
import settings from './settings'; import settings from './settings';
@ -43,6 +45,8 @@ import tags from './tags';
const reducers = { const reducers = {
announcements, announcements,
activity_log,
activitypub_explorer,
dropdown_menu, dropdown_menu,
timelines, timelines,
meta, meta,

View file

@ -23,3 +23,6 @@
@import 'mastodon/dashboard'; @import 'mastodon/dashboard';
@import 'mastodon/rtl'; @import 'mastodon/rtl';
@import 'mastodon/accessibility'; @import 'mastodon/accessibility';
@import 'mastodon/academy';
@import 'activitypub-visualization';

View file

@ -0,0 +1,74 @@
.academy-signup-container {
max-width: 1150px;
margin: 20px auto;
display: flex;
align-items: center;
.title {
font-size: 50px;
font-weight: 700;
margin-bottom: 30px;
}
.subtitle {
font-size: 28px;
line-height: 32px;
color: #d9e1e8;
margin-bottom: 20px;
}
p {
font-size: 20px;
line-height: 26px;
color: #d9e1e8;
margin: 15px 0;
}
.mascot {
width: 500px;
max-width: 100%;
transform: scaleX(-1);
}
a {
color: #8c8dff;
text-decoration: underline;
&:hover,
&:active,
&:focus {
text-decoration: none;
}
}
& > * {
padding: 15px;
}
}
@media screen and (max-width: 1150px) {
.academy-signup-container {
max-width: 800px;
flex-direction: column;
.title {
font-size: 40px;
}
}
}
@media screen and (max-width: 450px) {
.academy-signup-container {
.title {
font-size: 30px;
}
.subtitle {
font-size: 24px;
}
p {
font-size: 18px;
}
}
}

View file

@ -8608,3 +8608,14 @@ noscript {
} }
} }
} }
a.blog-link {
color: $highlight-text-color;
text-decoration: none;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}

View file

@ -0,0 +1,58 @@
# frozen_string_literal: true
class ActivityLogAudienceHelper
def self.audience(activity_log_event)
domain = Rails.configuration.x.web_domain
if activity_log_event.type == 'outbound'
sender = activity_log_event.sender
if sender and match = sender.match(Regexp.new("https://#{domain}/users/([^/]*)"))
[match.captures[0]]
else
[]
end
elsif activity_log_event.type == 'inbound'
if match = activity_log_event.path.match(Regexp.new("https://#{domain}/users/([^/]*)/inbox"))
[match.captures[0]]
elsif activity_log_event.path == "https://#{domain}/inbox"
['to', 'bto', 'cc', 'bcc']
.map { |target| actors(activity_log_event.data[target]) }
.flatten
.uniq
else
[]
end
else
[]
end
end
private
def self.actors(string_or_array)
domain = Rails.configuration.x.web_domain
if string_or_array.nil?
[]
elsif string_or_array.is_a?(String)
self.actors([string_or_array])
else
string_or_array.map do |string|
if match = string.match(Regexp.new("https://#{domain}/users/([^/]*)"))
match.captures[0]
elsif string.ends_with?("/followers")
Account
.joins(
"JOIN follows ON follows.account_id = accounts.id
JOIN accounts AS followed ON follows.target_account_id = followed.id
WHERE followed.followers_url = '#{string}'")
.map { |account| account.username }
else
nil
end
end.flatten.compact
end
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class ActivityLogEvent
attr_accessor :type, :sender, :path, :data, :timestamp
def self.from_json_string(json_string)
json = Oj.load(json_string, mode: :strict)
ActivityLogEvent.new(json['type'], json['sender'], json['path'], json['data'])
end
def initialize(type, sender, path, data, timestamp = Time.now.utc.iso8601)
@type = type
@sender = sender
@path = path
@data = data
@timestamp = timestamp
end
end

View file

@ -0,0 +1,10 @@
class ActivityLogPublisher
def initialize
@redis = RedisConfiguration.new.connection
end
def publish(log_event)
@redis.publish('activity_log', Oj.dump(log_event))
end
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class ActivityLogger
@@loggers = Hash.new { |hash, key| hash[key] = [] }
def self.register(id, sse)
@@loggers[id] << sse
end
def self.unregister(id, sse)
@@loggers[id].delete(sse)
end
def self.log(id, event)
@@loggers[id].each do |logger|
logger.write event
rescue
puts 'rescued'
logger.close
puts 'closed logger'
end
end
def self.reset
@@loggers.clear
end
Thread.new {
while true
event = ActivityLogEvent.new('keep-alive', nil, nil, nil)
@@loggers.each_key do |key|
ActivityLogger.log(key, event)
end
sleep 10
end
}
end

View file

@ -34,14 +34,6 @@ class AccountMigration < ApplicationRecord
attr_accessor :current_password, :current_username attr_accessor :current_password, :current_username
def save_with_challenge(current_user) def save_with_challenge(current_user)
if current_user.encrypted_password.present?
errors.add(:current_password, :invalid) unless current_user.valid_password?(current_password)
else
errors.add(:current_username, :invalid) unless account.username == current_username
end
return false unless errors.empty?
with_lock("account_migration:#{account.id}") do with_lock("account_migration:#{account.id}") do
save save
end end

View file

@ -12,14 +12,6 @@ class Form::Redirect
validate :validate_target_account validate :validate_target_account
def valid_with_challenge?(current_user) def valid_with_challenge?(current_user)
if current_user.encrypted_password.present?
errors.add(:current_password, :invalid) unless current_user.valid_password?(current_password)
else
errors.add(:current_username, :invalid) unless account.username == current_username
end
return false unless errors.empty?
set_target_account set_target_account
valid? valid?
end end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class RegistrationFormTimeValidator < ActiveModel::Validator class RegistrationFormTimeValidator < ActiveModel::Validator
REGISTRATION_FORM_MIN_TIME = 3.seconds.freeze REGISTRATION_FORM_MIN_TIME = 0.5.seconds.freeze
def validate(user) def validate(user)
user.errors.add(:base, I18n.t('auth.too_fast')) if user.registration_form_time.present? && user.registration_form_time > REGISTRATION_FORM_MIN_TIME.ago user.errors.add(:base, I18n.t('auth.too_fast')) if user.registration_form_time.present? && user.registration_form_time > REGISTRATION_FORM_MIN_TIME.ago

View file

@ -3,31 +3,6 @@
= render 'status' = render 'status'
%h3= t('auth.security')
= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: 'auth_edit', novalidate: false }) do |f|
= render 'shared/error_messages', object: resource
- if !use_seamless_external_login? || resource.encrypted_password.present?
.fields-row
.fields-row__column.fields-group.fields-row__column-6
= f.input :email, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, required: true, disabled: current_account.suspended?
.fields-row__column.fields-group.fields-row__column-6
= f.input :current_password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'current-password' }, required: true, disabled: current_account.suspended?, hint: false
.fields-row
.fields-row__column.fields-group.fields-row__column-6
= f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'new-password', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: t('simple_form.hints.defaults.password'), disabled: current_account.suspended?
.fields-row__column.fields-group.fields-row__column-6
= f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'new-password' }, disabled: current_account.suspended?
.actions
= f.button :button, t('generic.save_changes'), type: :submit, class: 'button', disabled: current_account.suspended?
- else
%p.hint= t('users.seamless_external_login')
%hr.spacer/
= render 'sessions' = render 'sessions'
- unless current_account.suspended? - unless current_account.suspended?

View file

@ -4,40 +4,32 @@
- content_for :header_tags do - content_for :header_tags do
= render partial: 'shared/og', locals: { description: description_for_sign_up } = render partial: 'shared/og', locals: { description: description_for_sign_up }
= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f| .academy-introduction
%h1.title= t('auth.sign_up.title', domain: site_hostname) %h1.title ActivityPub.Academy
%p.lead= t('auth.sign_up.preamble') %p.subtitle Explore the ActivityPub protocol interactively
= render 'shared/error_messages', object: resource
- if @invite.present? && @invite.autofollow? %p ActivityPub.Academy is a learning resource for <a href="https://www.w3.org/TR/activitypub/">ActivityPub</a>. The protocol is brought to life by showing Activities sent between different instances in real time!
.fields-group.invited-by
%p.hint= t('invites.invited_by')
= render 'application/card', account: @invite.user.account
.fields-group %p Sign up for a fully functioning Mastodon account (accounts are deleted after one day, but you can always create a new one). Follow other accounts, create posts, boost &amp; like, and see the effects on the protocol visualized.
= f.simple_fields_for :account do |ff|
= ff.input :display_name, wrapper: :with_label, label: false, required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.display_name'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.display_name') } %p Learn more on my <a href="//seb.jambor.dev">blog</a>.
= ff.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false
= f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'username' }, hint: false = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f|
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'new-password', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false
= f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'new-password' }, hint: false = render 'shared/error_messages', object: resource
= f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }, hint: false
= f.input :website, as: :url, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }
- if approved_registrations? && !@invite.present?
.fields-group .fields-group
= f.simple_fields_for :invite_request, resource.invite_request || resource.build_invite_request do |invite_request_fields| = f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }, hint: false
= invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: Setting.require_invite_text = f.input :website, as: :url, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }
= hidden_field_tag :accept, params[:accept]
= f.input :invite_code, as: :hidden
= hidden_field_tag :accept, params[:accept] .fields-group
= f.input :invite_code, as: :hidden = f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.privacy_policy_agreement_html', rules_path: about_more_path, privacy_policy_path: privacy_policy_path), required: true
.fields-group .actions
= f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.privacy_policy_agreement_html', rules_path: about_more_path, privacy_policy_path: privacy_policy_path), required: true = f.button :button, @invite.present? ? t('auth.register') : sign_up_message, type: :submit
.actions = image_tag asset_pack_path('media/images/academy-mascot.webp'), alt: 'The Mastodon mascot wearing a university gown', class: 'mascot'
= f.button :button, @invite.present? ? t('auth.register') : sign_up_message, type: :submit
.form-footer= render 'auth/shared/links'

View file

@ -0,0 +1,10 @@
- content_for :header_tags do
= javascript_pack_tag 'public', crossorigin: 'anonymous'
- content_for :content do
.academy-signup-container
= render 'flashes'
= yield
= render template: 'layouts/application'

View file

@ -17,11 +17,5 @@
.fields-row__column.fields-group.fields-row__column-6 .fields-row__column.fields-group.fields-row__column-6
= f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' }, label: t('simple_form.labels.account_migration.acct'), hint: t('simple_form.hints.account_migration.acct') = f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' }, label: t('simple_form.labels.account_migration.acct'), hint: t('simple_form.hints.account_migration.acct')
.fields-row__column.fields-group.fields-row__column-6
- if current_user.encrypted_password.present?
= f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password' }, required: true
- else
= f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true
.actions .actions
= f.button :button, t('migrations.set_redirect'), type: :submit, class: 'button button--destructive' = f.button :button, t('migrations.set_redirect'), type: :submit, class: 'button button--destructive'

View file

@ -46,12 +46,6 @@
.fields-row__column.fields-group.fields-row__column-6 .fields-row__column.fields-group.fields-row__column-6
= f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' }, disabled: on_cooldown? = f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' }, disabled: on_cooldown?
.fields-row__column.fields-group.fields-row__column-6
- if current_user.encrypted_password.present?
= f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password' }, required: true, disabled: on_cooldown?
- else
= f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true, disabled: on_cooldown?
.actions .actions
= f.button :button, t('migrations.proceed_with_move'), type: :submit, class: 'button button--destructive', disabled: on_cooldown? = f.button :button, t('migrations.proceed_with_move'), type: :submit, class: 'button button--destructive', disabled: on_cooldown?

View file

@ -12,6 +12,10 @@ class ActivityPub::DeliveryWorker
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
def initialize
@activity_log_publisher = ActivityLogPublisher.new
end
def perform(json, source_account_id, inbox_url, options = {}) def perform(json, source_account_id, inbox_url, options = {})
return unless DeliveryFailureTracker.available?(inbox_url) return unless DeliveryFailureTracker.available?(inbox_url)
@ -22,6 +26,11 @@ class ActivityPub::DeliveryWorker
@host = Addressable::URI.parse(inbox_url).normalized_site @host = Addressable::URI.parse(inbox_url).normalized_site
@performed = false @performed = false
event = ActivityLogEvent.new('outbound', "https://#{Rails.configuration.x.web_domain}/users/#{@source_account.username}", inbox_url, Oj.load(json, mode: :strict))
@activity_log_publisher.publish(event)
perform_request perform_request
ensure ensure
if @inbox_url.present? if @inbox_url.present?

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
class Scheduler::OldAccountCleanupScheduler
include Sidekiq::Worker
# Each processed deletion request may enqueue an enormous
# amount of jobs in the `pull` queue, so only enqueue when
# the queue is empty or close to being so.
MAX_PULL_SIZE = 50
# Since account deletion is very expensive, we want to avoid
# overloading the server by queing too much at once.
MAX_DELETIONS_PER_JOB = 5
sidekiq_options retry: 0
def perform
return if Sidekiq::Queue.new('pull').size > MAX_PULL_SIZE
clean_old_accounts!
end
private
def clean_old_accounts!
Account
# only fetch local accounts
.where("domain IS NULL")
# id -99 is the instance actor
.where("id <> -99")
# don't delete admin
.where("username <> 'admin'")
.where("created_at < ?", 1.day.ago)
.order(created_at: :asc)
.limit(MAX_DELETIONS_PER_JOB)
.each do |account|
AccountDeletionWorker.perform_async(account.id, { :reserve_username => false })
end
end
end

View file

@ -74,7 +74,8 @@ Rails.application.configure do
# If using a Heroku, Vagrant or generic remote development environment, # If using a Heroku, Vagrant or generic remote development environment,
# use letter_opener_web, accessible at /letter_opener. # use letter_opener_web, accessible at /letter_opener.
# Otherwise, use letter_opener, which launches a browser window to view sent mail. # Otherwise, use letter_opener, which launches a browser window to view sent mail.
config.action_mailer.delivery_method = (ENV['HEROKU'] || ENV['VAGRANT'] || ENV['REMOTE_DEV']) ? :letter_opener_web : :letter_opener # config.action_mailer.delivery_method = (ENV['HEROKU'] || ENV['VAGRANT'] || ENV['REMOTE_DEV']) ? :letter_opener_web : :letter_opener
config.action_mailer.delivery_method = :file
config.after_initialize do config.after_initialize do
Bullet.enable = true Bullet.enable = true

View file

@ -130,7 +130,7 @@ Rails.application.configure do
:ssl => ENV['SMTP_SSL'].presence && ENV['SMTP_SSL'] == 'true', :ssl => ENV['SMTP_SSL'].presence && ENV['SMTP_SSL'] == 'true',
} }
config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym config.action_mailer.delivery_method = :file
config.action_dispatch.default_headers = { config.action_dispatch.default_headers = {
'Server' => 'Mastodon', 'Server' => 'Mastodon',

View file

@ -2,7 +2,7 @@
en: en:
devise: devise:
confirmations: confirmations:
confirmed: Your email address has been successfully confirmed. confirmed: Your account is fully functional.
send_instructions: You will receive an email with instructions for how to confirm your email address in a few minutes. Please check your spam folder if you didn't receive this email. send_instructions: You will receive an email with instructions for how to confirm your email address in a few minutes. Please check your spam folder if you didn't receive this email.
send_paranoid_instructions: If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes. Please check your spam folder if you didn't receive this email. send_paranoid_instructions: If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes. Please check your spam folder if you didn't receive this email.
failure: failure:

View file

@ -953,6 +953,7 @@ en:
title: Setup title: Setup
sign_up: sign_up:
preamble: With an account on this Mastodon server, you'll be able to follow any other person on the network, regardless of where their account is hosted. preamble: With an account on this Mastodon server, you'll be able to follow any other person on the network, regardless of where their account is hosted.
activity_log_preamble_html: ActivityPub.Academy is a learning resource for <a href="https://www.w3.org/TR/activitypub/">ActivityPub</a>. The protocol is brought to life by showing Activities between different instances in real time! <br/><br/> After signing up, you are given a fully functional Mastodon account. You can follow other accounts, create posts, repost, like, etc. These actions generate Activities following the ActivityPub spec, which will be shown to you in real time. You can learn more about this on my <a href="//seb.jambor.dev">blog</a>. <br/><br/> Note that your account is only valid for 24 hours and will be deleted automatically afterwards.
title: Let's get you set up on %{domain}. title: Let's get you set up on %{domain}.
status: status:
account_status: Account status account_status: Account status

View file

@ -1,3 +1,5 @@
require_relative '../lib/activity_log_subscriber'
persistent_timeout ENV.fetch('PERSISTENT_TIMEOUT') { 20 }.to_i persistent_timeout ENV.fetch('PERSISTENT_TIMEOUT') { 20 }.to_i
threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i
@ -18,6 +20,10 @@ on_worker_boot do
ActiveSupport.on_load(:active_record) do ActiveSupport.on_load(:active_record) do
ActiveRecord::Base.establish_connection ActiveRecord::Base.establish_connection
end end
Thread.new {
ActivityLogSubscriber.new.start
}
end end
plugin :tmp_restart plugin :tmp_restart

View file

@ -24,6 +24,8 @@ Rails.application.routes.draw do
/search /search
/publish /publish
/follow_requests /follow_requests
/activity_log
/activitypub_explorer
/blocks /blocks
/domain_blocks /domain_blocks
/mutes /mutes
@ -513,6 +515,10 @@ Rails.application.routes.draw do
resources :confirmations, only: [:create] resources :confirmations, only: [:create]
end end
resource :activity_log, only: [:show], controller: 'activity_log'
get '/json_ld', to: 'json_ld#show'
resource :instance, only: [:show] do resource :instance, only: [:show] do
resources :peers, only: [:index], controller: 'instances/peers' resources :peers, only: [:index], controller: 'instances/peers'
resources :rules, only: [:index], controller: 'instances/rules' resources :rules, only: [:index], controller: 'instances/rules'

View file

@ -58,3 +58,7 @@
interval: 1 minute interval: 1 minute
class: Scheduler::SuspendedUserCleanupScheduler class: Scheduler::SuspendedUserCleanupScheduler
queue: scheduler queue: scheduler
old_account_cleanup_scheduler:
interval: 1 minute
class: Scheduler::OldAccountCleanupScheduler
queue: scheduler

View file

@ -23,6 +23,7 @@ default: &default
- .jpg - .jpg
- .jpeg - .jpeg
- .png - .png
- .webp
- .tiff - .tiff
- .ico - .ico
- .svg - .svg

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'redis'
class ActivityLogSubscriber
def start
redis = RedisConfiguration.new.connection
redis.subscribe('activity_log') do |on|
on.message do |channel, message|
event = ActivityLogEvent.from_json_string(message)
ActivityLogAudienceHelper.audience(event)
.each { |username| ActivityLogger.log(username, event) }
end
end
end
end

View file

@ -35,6 +35,7 @@
"@github/webauthn-json": "^0.5.7", "@github/webauthn-json": "^0.5.7",
"@rails/ujs": "^6.1.7", "@rails/ujs": "^6.1.7",
"abortcontroller-polyfill": "^1.7.5", "abortcontroller-polyfill": "^1.7.5",
"activitypub-visualization": "^1.1.0",
"array-includes": "^3.1.5", "array-includes": "^3.1.5",
"arrow-key-navigation": "^1.2.0", "arrow-key-navigation": "^1.2.0",
"autoprefixer": "^9.8.8", "autoprefixer": "^9.8.8",

View file

@ -0,0 +1,13 @@
{
"timestamp":"2022-12-08T17:12:38Z",
"sender": "https://other.org/users/alice",
"type": "inbound",
"path": "https://example.com/users/bob/inbox",
"data": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://other.org/a5f25e0a-98d6-4e5c-baad-65318cd4d67d",
"type": "Follow",
"actor": "https://other.org/users/alice",
"object": "https://example.com/users/bob"
}
}

View file

@ -0,0 +1,44 @@
{
"timestamp":"2022-12-08T17:12:38Z",
"sender": "https://other.org/users/bob/",
"type": "inbound",
"path": "https://example.com/inbox",
"data": {
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://other.org/users/bob/",
"id": "https://other.org/users/bob/statuses/109473290785654613/activity",
"object": {
"attributedTo": "https://other.org/users/bob/",
"content": "A post to selected audiences",
"id": "https://other.org/users/bob/statuses/109473290785654613",
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"https://example.com/users/first-to",
"https://example.com/users/first-to",
"https://example.com/users/second-to"
],
"bto": "https://example.com/users/first-to",
"cc": [
"https://example.com/users/second-to"
],
"bcc": [
"https://example.com/users/first-to"
],
"type": "Note"
},
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"https://example.com/users/first-to",
"https://example.com/users/first-to",
"https://example.com/users/second-to"
],
"bto": "https://example.com/users/first-to",
"cc": [
"https://example.com/users/second-to"
],
"bcc": [
"https://example.com/users/first-to"
],
"type": "Create"
}
}

View file

@ -0,0 +1,26 @@
{
"timestamp":"2022-12-08T17:12:38Z",
"sender": "https://other.org/users/bob/",
"type": "inbound",
"path": "https://example.com/inbox",
"data": {
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://other.org/users/bob/",
"id": "https://other.org/users/bob/statuses/109473290785654613/activity",
"object": {
"attributedTo": "https://other.org/users/bob/",
"content": "A post to selected audiences",
"id": "https://other.org/users/bob/statuses/109473290785654613",
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"https://other.org/users/bob/followers"
],
"type": "Note"
},
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"https://other.org/users/bob/followers"
],
"type": "Create"
}
}

View file

@ -0,0 +1,44 @@
{
"timestamp":"2022-12-08T17:12:38Z",
"sender": "https://other.org/users/bob/",
"type": "inbound",
"path": "https://example.com/inbox",
"data": {
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://other.org/users/bob/",
"id": "https://other.org/users/bob/statuses/109473290785654613/activity",
"object": {
"attributedTo": "https://other.org/users/bob/",
"content": "A post to selected audiences",
"id": "https://other.org/users/bob/statuses/109473290785654613",
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"https://example.com/users/first-to",
"https://example.com/users/second-to",
"https://other.org/users/other-instance"
],
"bto": "https://example.com/users/single-bto",
"cc": [
"https://example.com/users/one-cc"
],
"bcc": [
"https://example.com/users/one-bcc"
],
"type": "Note"
},
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"https://example.com/users/first-to",
"https://example.com/users/second-to",
"https://other.org/users/other-instance"
],
"bto": "https://example.com/users/single-bto",
"cc": [
"https://example.com/users/one-cc"
],
"bcc": [
"https://example.com/users/one-bcc"
],
"type": "Create"
}
}

View file

@ -0,0 +1,13 @@
{
"timestamp":"2022-12-08T17:12:38Z",
"sender": "https://example.com/users/alice",
"type": "outbound",
"path": "https://other.org/users/bob/inbox",
"data": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/a5f25e0a-98d6-4e5c-baad-65318cd4d67d",
"type": "Follow",
"actor": "https://example.com/users/alice",
"object": "https://other.org/users/bob"
}
}

View file

@ -0,0 +1,89 @@
require 'json'
require 'rails_helper'
def activity_log_event_fixture(name)
json_string = File.read(Rails.root.join('spec', 'fixtures', 'activity_log_events', name))
ActivityLogEvent.from_json_string(json_string)
end
RSpec.describe ActivityLogAudienceHelper do
describe '#audience' do
around do |example|
before = Rails.configuration.x.web_domain
example.run
Rails.configuration.x.web_domain = before
end
describe 'for inbound events' do
it 'returns the author if the domain matches' do
Rails.configuration.x.web_domain = 'example.com'
outbound_event = activity_log_event_fixture('outbound.json')
expect(ActivityLogAudienceHelper.audience(outbound_event)).to eq ['alice']
end
it 'returns nothing if the domain does not match' do
Rails.configuration.x.web_domain = 'does-not-match.com'
outbound_event = activity_log_event_fixture('outbound.json')
expect(ActivityLogAudienceHelper.audience(outbound_event)).to eq []
end
it 'returns nothing if the activity does not have an actor' do
Rails.configuration.x.web_domain = 'example.com'
outbound_event = activity_log_event_fixture('outbound.json')
outbound_event.data.delete('actor')
expect(ActivityLogAudienceHelper.audience(outbound_event)).to eq []
end
end
describe 'for outbound events' do
it 'returns the inbox owner if it is sent to a personal inbox' do
Rails.configuration.x.web_domain = 'example.com'
inbound_event = activity_log_event_fixture('inbound-to-users-inbox.json')
expect(ActivityLogAudienceHelper.audience(inbound_event)).to eq ['bob']
end
it 'returns direct audience from to, bto, cc, bcc if sent to public inbox' do
Rails.configuration.x.web_domain = 'example.com'
inbound_event = activity_log_event_fixture('inbound-with-multiple-recipients.json')
expect(ActivityLogAudienceHelper.audience(inbound_event)).to match_array([
'first-to',
'second-to',
'single-bto',
'one-cc',
'one-bcc'
])
end
it 'returns followers from to, bto, cc, bcc if sent to public inbox' do
Rails.configuration.x.web_domain = 'example.com'
inbound_event = activity_log_event_fixture('inbound-with-follower-recipients.json')
bob = Fabricate(:account, username: 'bob', followers_url: 'https://other.org/users/bob/followers')
Fabricate(:account, username: 'first_follower').follow!(bob)
Fabricate(:account, username: 'second_follower').follow!(bob)
expect(ActivityLogAudienceHelper.audience(inbound_event)).to match_array([
'first_follower',
'second_follower',
])
end
it 'removes duplicates from audience' do
Rails.configuration.x.web_domain = 'example.com'
inbound_event = activity_log_event_fixture('inbound-with-duplicate-recipients.json')
expect(ActivityLogAudienceHelper.audience(inbound_event)).to match_array([
'first-to',
'second-to'
])
end
end
end
end

View file

@ -0,0 +1,67 @@
require 'json'
require 'rails_helper'
RSpec.describe ActivityLogger do
after(:each) do
ActivityLogger.reset
end
describe 'log' do
it 'sends events to all listeners of the same id' do
sse1 = spy('sse1')
sse2 = spy('sse2')
event = double('event')
ActivityLogger.register('test_id', sse1)
ActivityLogger.register('test_id', sse2)
ActivityLogger.log('test_id', event)
expect(sse1).to have_received(:write).with(event)
expect(sse2).to have_received(:write).with(event)
end
it 'sends events to all listeners even if some fail' do
sse1 = spy('sse1')
sse2 = spy('sse2')
event = double('event')
allow(sse1).to receive(:write).and_throw(:error)
ActivityLogger.register('test_id', sse1)
ActivityLogger.register('test_id', sse2)
ActivityLogger.log('test_id', event)
expect(sse2).to have_received(:write).with(event)
end
it 'does not send events to listeners of a different id' do
sse = spy('sse')
event = double('event')
ActivityLogger.register('test_id', sse)
ActivityLogger.log('other_id', event)
expect(sse).to_not have_received(:write).with(event)
end
it 'does not send events to listeners after unregistering' do
sse1 = spy('sse1')
sse2 = spy('sse2')
event = double('event')
ActivityLogger.register('test_id', sse1)
ActivityLogger.register('test_id', sse2)
ActivityLogger.unregister('test_id', sse1)
ActivityLogger.log('test_id', event)
expect(sse1).to_not have_received(:write).with(event)
expect(sse2).to have_received(:write).with(event)
end
end
end

View file

@ -2322,6 +2322,11 @@ acorn@^8.0.4, acorn@^8.1.0, acorn@^8.5.0, acorn@^8.8.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8"
integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
activitypub-visualization@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/activitypub-visualization/-/activitypub-visualization-1.1.0.tgz#db7875657aa3215f6d7be16795964c82c1021efe"
integrity sha512-0DrnCdmpx5551q0vZiQYHu3ZsVVFP0JJKLHQpbLmgetVJxGjRoZC7iwjh+tvyYixBhUneIAhVJ2VH0bugiAwFA==
agent-base@6: agent-base@6:
version "6.0.2" version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"