Compare commits
61 commits
activitypu
...
activitypu
Author | SHA1 | Date | |
---|---|---|---|
|
42b609bdd0 | ||
|
d7f271729a | ||
|
d74d59705d | ||
|
91fecfa421 | ||
|
5f29fe69f5 | ||
|
0cfbad063f | ||
|
a20ddf7c0f | ||
|
8ba896bcb6 | ||
|
d65f957327 | ||
|
ea7f1ed1ad | ||
|
88bc0a6c76 | ||
|
9beadd9ee8 | ||
|
9ed5142b0d | ||
|
bd2e33f358 | ||
|
f06cb96098 | ||
|
7be872a0b4 | ||
|
dbe589f983 | ||
|
6fd471295d | ||
|
8a3c60672f | ||
|
ac398426fc | ||
|
e02602e445 | ||
|
ac32e5ec1d | ||
|
8d3ceafe34 | ||
|
58d83a8cb1 | ||
|
240160f877 | ||
|
a622b0b947 | ||
|
f727ee6a45 | ||
|
25008e0555 | ||
|
995c69a45b | ||
|
291027c1c3 | ||
|
a84f182cf4 | ||
|
12abf5e142 | ||
|
638c1ed7d8 | ||
|
e90505cfdf | ||
|
d73bf5770d | ||
|
fd92599890 | ||
|
5560887862 | ||
|
426e096a9b | ||
|
9785c2849a | ||
|
57617faa27 | ||
|
d5408766cc | ||
|
7a30154bc5 | ||
|
f1ee1eadd9 | ||
|
e884c39a03 | ||
|
f643515fdd | ||
|
078688149a | ||
|
f94db7a54f | ||
|
cb83422a8a | ||
|
7a0e5c9900 | ||
|
14570da001 | ||
|
0a479aa734 | ||
|
b9df613b31 | ||
|
faf7925ce0 | ||
|
bfd9f4938d | ||
|
4a01c00ef2 | ||
|
12c4213ecf | ||
|
3f4a72e7f3 | ||
|
a414adc582 | ||
|
313af50864 | ||
|
3142c2b31a | ||
|
e35b438f06 |
58 changed files with 1122 additions and 100 deletions
2
Gemfile
2
Gemfile
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
24
app/controllers/api/v1/activity_log_controller.rb
Normal file
24
app/controllers/api/v1/activity_log_controller.rb
Normal 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
|
46
app/controllers/api/v1/json_ld_controller.rb
Normal file
46
app/controllers/api/v1/json_ld_controller.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
44
app/controllers/auth/roman.txt
Normal file
44
app/controllers/auth/roman.txt
Normal 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
|
|
@ -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
|
||||||
|
|
BIN
app/javascript/images/academy-mascot.webp
Normal file
BIN
app/javascript/images/academy-mascot.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
11
app/javascript/mastodon/actions/activity_log.js
Normal file
11
app/javascript/mastodon/actions/activity_log.js
Normal 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,
|
||||||
|
});
|
12
app/javascript/mastodon/actions/activitypub_explorer.js
Normal file
12
app/javascript/mastodon/actions/activitypub_explorer.js
Normal 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,
|
||||||
|
});
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
112
app/javascript/mastodon/features/activity_log/index.js
Normal file
112
app/javascript/mastodon/features/activity_log/index.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -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' });
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
17
app/javascript/mastodon/reducers/activity_log.js
Normal file
17
app/javascript/mastodon/reducers/activity_log.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
18
app/javascript/mastodon/reducers/activitypub_explorer.js
Normal file
18
app/javascript/mastodon/reducers/activitypub_explorer.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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';
|
||||||
|
|
74
app/javascript/styles/mastodon/academy.scss
Normal file
74
app/javascript/styles/mastodon/academy.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8608,3 +8608,14 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.blog-link {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
58
app/lib/activity_log_audience_helper.rb
Normal file
58
app/lib/activity_log_audience_helper.rb
Normal 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
|
19
app/lib/activity_log_event.rb
Normal file
19
app/lib/activity_log_event.rb
Normal 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
|
10
app/lib/activity_log_publisher.rb
Normal file
10
app/lib/activity_log_publisher.rb
Normal 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
|
39
app/lib/activity_logger.rb
Normal file
39
app/lib/activity_logger.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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 & 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'
|
|
||||||
|
|
10
app/views/layouts/academy-signup.html.haml
Normal file
10
app/views/layouts/academy-signup.html.haml
Normal 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'
|
|
@ -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'
|
||||||
|
|
|
@ -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?
|
||||||
|
|
||||||
|
|
|
@ -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?
|
||||||
|
|
40
app/workers/scheduler/old_account_cleanup_scheduler.rb
Normal file
40
app/workers/scheduler/old_account_cleanup_scheduler.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -23,6 +23,7 @@ default: &default
|
||||||
- .jpg
|
- .jpg
|
||||||
- .jpeg
|
- .jpeg
|
||||||
- .png
|
- .png
|
||||||
|
- .webp
|
||||||
- .tiff
|
- .tiff
|
||||||
- .ico
|
- .ico
|
||||||
- .svg
|
- .svg
|
||||||
|
|
17
lib/activity_log_subscriber.rb
Normal file
17
lib/activity_log_subscriber.rb
Normal 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
|
|
@ -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",
|
||||||
|
|
13
spec/fixtures/activity_log_events/inbound-to-users-inbox.json
vendored
Normal file
13
spec/fixtures/activity_log_events/inbound-to-users-inbox.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
44
spec/fixtures/activity_log_events/inbound-with-duplicate-recipients.json
vendored
Normal file
44
spec/fixtures/activity_log_events/inbound-with-duplicate-recipients.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
26
spec/fixtures/activity_log_events/inbound-with-follower-recipients.json
vendored
Normal file
26
spec/fixtures/activity_log_events/inbound-with-follower-recipients.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
44
spec/fixtures/activity_log_events/inbound-with-multiple-recipients.json
vendored
Normal file
44
spec/fixtures/activity_log_events/inbound-with-multiple-recipients.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
13
spec/fixtures/activity_log_events/outbound.json
vendored
Normal file
13
spec/fixtures/activity_log_events/outbound.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
89
spec/lib/activity_log_audience_helper_spec.rb
Normal file
89
spec/lib/activity_log_audience_helper_spec.rb
Normal 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
|
67
spec/lib/activity_logger_spec.rb
Normal file
67
spec/lib/activity_logger_spec.rb
Normal 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
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue