Compare commits
43 commits
activitypu
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
|
115fbe9942 | ||
|
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 |
47 changed files with 1086 additions and 87 deletions
4
Gemfile
4
Gemfile
|
@ -113,7 +113,7 @@ group :production, :test do
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
gem 'capybara', '~> 3.37'
|
gem 'capybara', '~> 3.38'
|
||||||
gem 'climate_control', '~> 0.2'
|
gem 'climate_control', '~> 0.2'
|
||||||
gem 'faker', '~> 2.23'
|
gem 'faker', '~> 2.23'
|
||||||
gem 'microformats', '~> 4.4'
|
gem 'microformats', '~> 4.4'
|
||||||
|
@ -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'
|
||||||
|
|
20
Gemfile.lock
20
Gemfile.lock
|
@ -152,7 +152,7 @@ GEM
|
||||||
sshkit (~> 1.3)
|
sshkit (~> 1.3)
|
||||||
capistrano-yarn (2.0.2)
|
capistrano-yarn (2.0.2)
|
||||||
capistrano (~> 3.0)
|
capistrano (~> 3.0)
|
||||||
capybara (3.37.1)
|
capybara (3.38.0)
|
||||||
addressable
|
addressable
|
||||||
matrix
|
matrix
|
||||||
mini_mime (>= 0.1.3)
|
mini_mime (>= 0.1.3)
|
||||||
|
@ -402,7 +402,7 @@ GEM
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2022.0105)
|
mime-types-data (3.2022.0105)
|
||||||
mini_mime (1.1.2)
|
mini_mime (1.1.2)
|
||||||
mini_portile2 (2.8.0)
|
mini_portile2 (2.8.1)
|
||||||
minitest (5.16.3)
|
minitest (5.16.3)
|
||||||
msgpack (1.5.4)
|
msgpack (1.5.4)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
|
@ -476,13 +476,13 @@ GEM
|
||||||
pry (>= 0.13, < 0.15)
|
pry (>= 0.13, < 0.15)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (5.0.0)
|
public_suffix (5.0.1)
|
||||||
puma (5.6.5)
|
puma (5.6.5)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.2.0)
|
pundit (2.2.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.6.0)
|
racc (1.6.2)
|
||||||
rack (2.2.4)
|
rack (2.2.4)
|
||||||
rack-attack (6.6.1)
|
rack-attack (6.6.1)
|
||||||
rack (>= 1.0, < 3)
|
rack (>= 1.0, < 3)
|
||||||
|
@ -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)
|
||||||
|
@ -543,7 +544,7 @@ GEM
|
||||||
redis (4.5.1)
|
redis (4.5.1)
|
||||||
redis-namespace (1.9.0)
|
redis-namespace (1.9.0)
|
||||||
redis (>= 4)
|
redis (>= 4)
|
||||||
regexp_parser (2.5.0)
|
regexp_parser (2.6.1)
|
||||||
request_store (1.5.1)
|
request_store (1.5.1)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
responders (3.0.1)
|
responders (3.0.1)
|
||||||
|
@ -748,7 +749,7 @@ DEPENDENCIES
|
||||||
capistrano-rails (~> 1.6)
|
capistrano-rails (~> 1.6)
|
||||||
capistrano-rbenv (~> 2.2)
|
capistrano-rbenv (~> 2.2)
|
||||||
capistrano-yarn (~> 2.0)
|
capistrano-yarn (~> 2.0)
|
||||||
capybara (~> 3.37)
|
capybara (~> 3.38)
|
||||||
charlock_holmes (~> 0.7.7)
|
charlock_holmes (~> 0.7.7)
|
||||||
chewy (~> 7.2)
|
chewy (~> 7.2)
|
||||||
climate_control (~> 0.2)
|
climate_control (~> 0.2)
|
||||||
|
@ -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,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_payload
|
def process_payload
|
||||||
|
event = ActivityLogEvent.new('inbound', "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
|
||||||
|
|
40
app/controllers/api/v1/activity_log_controller.rb
Normal file
40
app/controllers/api/v1/activity_log_controller.rb
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::ActivityLogController < Api::BaseController
|
||||||
|
include ActionController::Live
|
||||||
|
|
||||||
|
# before_action -> { doorkeeper_authorize! :read }, only: [:show]
|
||||||
|
before_action :require_user!
|
||||||
|
|
||||||
|
rescue_from ArgumentError do |e|
|
||||||
|
render json: { error: e.to_s }, status: 422
|
||||||
|
end
|
||||||
|
|
||||||
|
def index
|
||||||
|
render :text => "hello world"
|
||||||
|
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
|
||||||
|
|
||||||
|
sse = SSE.new(response.stream)
|
||||||
|
|
||||||
|
# id = current_account.local_username_and_domain
|
||||||
|
id = current_account.username
|
||||||
|
|
||||||
|
begin
|
||||||
|
ActivityLogger.register(id, sse)
|
||||||
|
|
||||||
|
while true
|
||||||
|
event = ActivityLogEvent.new('keep-alive', nil, nil)
|
||||||
|
ActivityLogger.log(id, event)
|
||||||
|
sleep 10
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
ActivityLogger.unregister(id, sse)
|
||||||
|
sse.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)
|
||||||
|
@ -156,4 +176,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
|
||||||
|
|
86
app/javascript/mastodon/features/activity_log/index.js
Normal file
86
app/javascript/mastodon/features/activity_log/index.js
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import React, { useEffect, useReducer, useRef } from 'react';
|
||||||
|
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 DismissableBanner from 'mastodon/components/dismissable_banner';
|
||||||
|
|
||||||
|
import ActivityPubVisualization from 'activitypub-visualization';
|
||||||
|
|
||||||
|
export default function ActivityLog({ multiColumn }) {
|
||||||
|
|
||||||
|
const [logs, dispatch] = useReducer((state, [type, data]) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'add-log-event':
|
||||||
|
return [...state, data];
|
||||||
|
case 'reset-logs':
|
||||||
|
return [];
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columnElement = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource('/api/v1/activity_log');
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
if (parsed.type !== 'keep-alive') {
|
||||||
|
dispatch(['add-log-event', parsed]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return function() {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn} ref={columnElement} label='Activity Log'>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='comments'
|
||||||
|
title='Activity Log'
|
||||||
|
onClick={() => { columnElement.current.scrollTop() }}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DismissableBanner id='activity_log'>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='dismissable_banner.activity_log_information'
|
||||||
|
defaultMessage='Open Mastodon in another tab and 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 href='//seb.jambor.dev/' style={{ color: darkMode ? '#8c8dff' : '#3a3bff', textDecoration: 'none' }}>blog</a>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p style={{ paddingTop: '5px' }}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='dismissable_banner.activity_log_clear'
|
||||||
|
defaultMessage='Note: Activities will only be logged while this view is open. When you navigate elsewhere, the log will be cleared.'
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</DismissableBanner>
|
||||||
|
|
||||||
|
<HotKeys handlers={handlers}>
|
||||||
|
<div className={`${darkMode ? 'dark' : ''}`}>
|
||||||
|
<ActivityPubVisualization logs={logs} />
|
||||||
|
</div>
|
||||||
|
</HotKeys>
|
||||||
|
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityLog.propTypes = {
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
};
|
|
@ -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,8 @@ 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' />
|
||||||
|
|
||||||
<ListPanel />
|
<ListPanel />
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
|
@ -48,6 +48,7 @@ import {
|
||||||
Mutes,
|
Mutes,
|
||||||
PinnedStatuses,
|
PinnedStatuses,
|
||||||
Lists,
|
Lists,
|
||||||
|
ActivityLog,
|
||||||
Directory,
|
Directory,
|
||||||
Explore,
|
Explore,
|
||||||
FollowRecommendations,
|
FollowRecommendations,
|
||||||
|
@ -105,6 +106,7 @@ const keyMap = {
|
||||||
goToBlocked: 'g b',
|
goToBlocked: 'g b',
|
||||||
goToMuted: 'g m',
|
goToMuted: 'g m',
|
||||||
goToRequests: 'g r',
|
goToRequests: 'g r',
|
||||||
|
goToActivityLog: 'g a',
|
||||||
toggleHidden: 'x',
|
toggleHidden: 'x',
|
||||||
toggleSensitive: 'h',
|
toggleSensitive: 'h',
|
||||||
openMedia: 'e',
|
openMedia: 'e',
|
||||||
|
@ -156,11 +158,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 +216,7 @@ 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} />
|
||||||
|
|
||||||
<Route component={BundleColumnError} />
|
<Route component={BundleColumnError} />
|
||||||
</WrappedSwitch>
|
</WrappedSwitch>
|
||||||
|
@ -387,12 +386,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 +488,10 @@ 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');
|
||||||
|
}
|
||||||
|
|
||||||
handleHotkeyGoToNotifications = () => {
|
handleHotkeyGoToNotifications = () => {
|
||||||
this.context.router.history.push('/notifications');
|
this.context.router.history.push('/notifications');
|
||||||
}
|
}
|
||||||
|
@ -552,6 +549,7 @@ 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,
|
||||||
goToNotifications: this.handleHotkeyGoToNotifications,
|
goToNotifications: this.handleHotkeyGoToNotifications,
|
||||||
goToLocal: this.handleHotkeyGoToLocal,
|
goToLocal: this.handleHotkeyGoToLocal,
|
||||||
goToFederated: this.handleHotkeyGoToFederated,
|
goToFederated: this.handleHotkeyGoToFederated,
|
||||||
|
|
|
@ -38,6 +38,10 @@ 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 Status () {
|
export function Status () {
|
||||||
return import(/* webpackChunkName: "features/status" */'../../status');
|
return import(/* webpackChunkName: "features/status" */'../../status');
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,3 +23,5 @@
|
||||||
@import 'mastodon/dashboard';
|
@import 'mastodon/dashboard';
|
||||||
@import 'mastodon/rtl';
|
@import 'mastodon/rtl';
|
||||||
@import 'mastodon/accessibility';
|
@import 'mastodon/accessibility';
|
||||||
|
|
||||||
|
@import 'activitypub-visualization';
|
||||||
|
|
|
@ -8608,3 +8608,321 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$blueGray-50: #F8FAFC;
|
||||||
|
$blueGray-100:#F1F5F9;
|
||||||
|
$blueGray-200: #E2E8F0;
|
||||||
|
$blueGray-300: #CBD5E1;
|
||||||
|
$blueGray-400: #94A3B8;
|
||||||
|
$blueGray-500: #64748B;
|
||||||
|
$blueGray-600: #475569;
|
||||||
|
$blueGray-700: #334155;
|
||||||
|
$blueGray-800: #1E293B;
|
||||||
|
$blueGray-900: #0F172A;
|
||||||
|
|
||||||
|
$coolGray-50: #F9FAFB;
|
||||||
|
$coolGray-100:#F3F4F6;
|
||||||
|
$coolGray-200: #E5E7EB;
|
||||||
|
$coolGray-300: #D1D5DB;
|
||||||
|
$coolGray-400: #9CA3AF;
|
||||||
|
$coolGray-500: #6B7280;
|
||||||
|
$coolGray-600: #4B5563;
|
||||||
|
$coolGray-700: #374151;
|
||||||
|
$coolGray-800: #1F2937;
|
||||||
|
$coolGray-900: #111827;
|
||||||
|
|
||||||
|
$gray-50: #FAFAFA;
|
||||||
|
$gray-100:#F4F4F5;
|
||||||
|
$gray-200: #E4E4E7;
|
||||||
|
$gray-300: #D4D4D8;
|
||||||
|
$gray-400: #A1A1AA;
|
||||||
|
$gray-500: #71717A;
|
||||||
|
$gray-600: #52525B;
|
||||||
|
$gray-700: #3F3F46;
|
||||||
|
$gray-800: #27272A;
|
||||||
|
$gray-900: #18181B;
|
||||||
|
|
||||||
|
$trueGray-50: #FAFAFA;
|
||||||
|
$trueGray-100:#F5F5F5;
|
||||||
|
$trueGray-200: #E5E5E5;
|
||||||
|
$trueGray-300: #D4D4D4;
|
||||||
|
$trueGray-400: #A3A3A3;
|
||||||
|
$trueGray-500: #737373;
|
||||||
|
$trueGray-600: #525252;
|
||||||
|
$trueGray-700: #404040;
|
||||||
|
$trueGray-800: #262626;
|
||||||
|
$trueGray-900: #171717;
|
||||||
|
|
||||||
|
$warmGray-50: #FAFAF9;
|
||||||
|
$warmGray-100:#F5F5F4;
|
||||||
|
$warmGray-200: #E7E5E4;
|
||||||
|
$warmGray-300: #D6D3D1;
|
||||||
|
$warmGray-400: #A8A29E;
|
||||||
|
$warmGray-500: #78716C;
|
||||||
|
$warmGray-600: #57534E;
|
||||||
|
$warmGray-700: #44403C;
|
||||||
|
$warmGray-800: #292524;
|
||||||
|
$warmGray-900: #1C1917;
|
||||||
|
|
||||||
|
$red-50: #FEF2F2;
|
||||||
|
$red-100:#FEE2E2;
|
||||||
|
$red-200: #FECACA;
|
||||||
|
$red-300: #FCA5A5;
|
||||||
|
$red-400: #F87171;
|
||||||
|
$red-500: #EF4444;
|
||||||
|
$red-600: #DC2626;
|
||||||
|
$red-700: #B91C1C;
|
||||||
|
$red-800: #991B1B;
|
||||||
|
$red-900: #7F1D1D;
|
||||||
|
|
||||||
|
$orange-50: #FFF7ED;
|
||||||
|
$orange-100:#FFEDD5;
|
||||||
|
$orange-200: #FED7AA;
|
||||||
|
$orange-300: #FDBA74;
|
||||||
|
$orange-400: #FB923C;
|
||||||
|
$orange-500: #F97316;
|
||||||
|
$orange-600: #EA580C;
|
||||||
|
$orange-700: #C2410C;
|
||||||
|
$orange-800: #9A3412;
|
||||||
|
$orange-900: #7C2D12;
|
||||||
|
|
||||||
|
$amber-50: #FFFBEB;
|
||||||
|
$amber-100:#FEF3C7;
|
||||||
|
$amber-200: #FDE68A;
|
||||||
|
$amber-300: #FCD34D;
|
||||||
|
$amber-400: #FBBF24;
|
||||||
|
$amber-500: #F59E0B;
|
||||||
|
$amber-600: #D97706;
|
||||||
|
$amber-700: #B45309;
|
||||||
|
$amber-800: #92400E;
|
||||||
|
$amber-900: #78350F;
|
||||||
|
|
||||||
|
$yellow-50: #FEFCE8;
|
||||||
|
$yellow-100:#FEF9C3;
|
||||||
|
$yellow-200: #FEF08A;
|
||||||
|
$yellow-300: #FDE047;
|
||||||
|
$yellow-400: #FACC15;
|
||||||
|
$yellow-500: #EAB308;
|
||||||
|
$yellow-600: #CA8A04;
|
||||||
|
$yellow-700: #A16207;
|
||||||
|
$yellow-800: #854D0E;
|
||||||
|
$yellow-900: #713F12;
|
||||||
|
|
||||||
|
$lime-50: #F7FEE7;
|
||||||
|
$lime-100: #ECFCCB;
|
||||||
|
$lime-200: #D9F99D;
|
||||||
|
$lime-300: #BEF264;
|
||||||
|
$lime-400: #A3E635;
|
||||||
|
$lime-500: #84CC16;
|
||||||
|
$lime-600: #65A30D;
|
||||||
|
$lime-700: #4D7C0F;
|
||||||
|
$lime-800: #3F6212;
|
||||||
|
$lime-900: #365314;
|
||||||
|
|
||||||
|
$green-50: #F0FDF4;
|
||||||
|
$green-100: #DCFCE7;
|
||||||
|
$green-200: #BBF7D0;
|
||||||
|
$green-300: #86EFAC;
|
||||||
|
$green-400: #4ADE80;
|
||||||
|
$green-500: #22C55E;
|
||||||
|
$green-600: #16A34A;
|
||||||
|
$green-700: #15803D;
|
||||||
|
$green-800: #166534;
|
||||||
|
$green-900: #14532D;
|
||||||
|
|
||||||
|
$emerald-50: #ECFDF5;
|
||||||
|
$emerald-100: #D1FAE5;
|
||||||
|
$emerald-200: #A7F3D0;
|
||||||
|
$emerald-300: #6EE7B7;
|
||||||
|
$emerald-400: #34D399;
|
||||||
|
$emerald-500: #10B981;
|
||||||
|
$emerald-600: #059669;
|
||||||
|
$emerald-700: #047857;
|
||||||
|
$emerald-800: #065F46;
|
||||||
|
$emerald-900: #064E3B;
|
||||||
|
|
||||||
|
$teal-50: #F0FDFA;
|
||||||
|
$teal-100: #CCFBF1;
|
||||||
|
$teal-200: #99F6E4;
|
||||||
|
$teal-300: #5EEAD4;
|
||||||
|
$teal-400: #2DD4BF;
|
||||||
|
$teal-500: #14B8A6;
|
||||||
|
$teal-600: #0D9488;
|
||||||
|
$teal-700: #0F766E;
|
||||||
|
$teal-800: #115E59;
|
||||||
|
$teal-900: #134E4A;
|
||||||
|
|
||||||
|
$cyan-50: #ECFEFF;
|
||||||
|
$cyan-100: #CFFAFE;
|
||||||
|
$cyan-200: #A5F3FC;
|
||||||
|
$cyan-300: #67E8F9;
|
||||||
|
$cyan-400: #22D3EE;
|
||||||
|
$cyan-500: #06B6D4;
|
||||||
|
$cyan-600: #0891B2;
|
||||||
|
$cyan-700: #0E7490;
|
||||||
|
$cyan-800: #155E75;
|
||||||
|
$cyan-900: #164E63;
|
||||||
|
|
||||||
|
$lightBlue-50: #F0F9FF;
|
||||||
|
$lightBlue-100: #E0F2FE;
|
||||||
|
$lightBlue-200: #BAE6FD;
|
||||||
|
$lightBlue-300: #7DD3FC;
|
||||||
|
$lightBlue-400: #38BDF8;
|
||||||
|
$lightBlue-500: #0EA5E9;
|
||||||
|
$lightBlue-600: #0284C7;
|
||||||
|
$lightBlue-700: #0369A1;
|
||||||
|
$lightBlue-800: #075985;
|
||||||
|
$lightBlue-900: #0C4A6E;
|
||||||
|
|
||||||
|
$blue-50: #EFF6FF;
|
||||||
|
$blue-100: #DBEAFE;
|
||||||
|
$blue-200: #BFDBFE;
|
||||||
|
$blue-300: #93C5FD;
|
||||||
|
$blue-400: #60A5FA;
|
||||||
|
$blue-500: #3B82F6;
|
||||||
|
$blue-600: #2563EB;
|
||||||
|
$blue-700: #1D4ED8;
|
||||||
|
$blue-800: #1E40AF;
|
||||||
|
$blue-900: #1E3A8A;
|
||||||
|
|
||||||
|
$indigo-50: #EEF2FF;
|
||||||
|
$indigo-100: #E0E7FF;
|
||||||
|
$indigo-200: #C7D2FE;
|
||||||
|
$indigo-300: #A5B4FC;
|
||||||
|
$indigo-400: #818CF8;
|
||||||
|
$indigo-500: #6366F1;
|
||||||
|
$indigo-600: #4F46E5;
|
||||||
|
$indigo-700: #4338CA;
|
||||||
|
$indigo-800: #3730A3;
|
||||||
|
$indigo-900: #312E81;
|
||||||
|
|
||||||
|
$violet-50: #F5F3FF;
|
||||||
|
$violet-100: #EDE9FE;
|
||||||
|
$violet-200: #DDD6FE;
|
||||||
|
$violet-300: #C4B5FD;
|
||||||
|
$violet-400: #A78BFA;
|
||||||
|
$violet-500: #8B5CF6;
|
||||||
|
$violet-600: #7C3AED;
|
||||||
|
$violet-700: #6D28D9;
|
||||||
|
$violet-800: #5B21B6;
|
||||||
|
$violet-900: #4C1D95;
|
||||||
|
|
||||||
|
$purple-50: #FAF5FF;
|
||||||
|
$purple-100: #F3E8FF;
|
||||||
|
$purple-200: #E9D5FF;
|
||||||
|
$purple-300: #D8B4FE;
|
||||||
|
$purple-400: #C084FC;
|
||||||
|
$purple-500: #A855F7;
|
||||||
|
$purple-600: #9333EA;
|
||||||
|
$purple-700: #7E22CE;
|
||||||
|
$purple-800: #6B21A8;
|
||||||
|
$purple-900: #581C87;
|
||||||
|
|
||||||
|
$fuchsia-50: #FDF4FF;
|
||||||
|
$fuchsia-100: #FAE8FF;
|
||||||
|
$fuchsia-200: #F5D0FE;
|
||||||
|
$fuchsia-300: #F0ABFC;
|
||||||
|
$fuchsia-400: #E879F9;
|
||||||
|
$fuchsia-500: #D946EF;
|
||||||
|
$fuchsia-600: #C026D3;
|
||||||
|
$fuchsia-700: #A21CAF;
|
||||||
|
$fuchsia-800: #86198F;
|
||||||
|
$fuchsia-900: #701A75;
|
||||||
|
|
||||||
|
$pink-50: #FDF2F8;
|
||||||
|
$pink-100: #FCE7F3;
|
||||||
|
$pink-200: #FBCFE8;
|
||||||
|
$pink-300: #F9A8D4;
|
||||||
|
$pink-400: #F472B6;
|
||||||
|
$pink-500: #EC4899;
|
||||||
|
$pink-600: #DB2777;
|
||||||
|
$pink-700: #BE185D;
|
||||||
|
$pink-800: #9D174D;
|
||||||
|
$pink-900: #831843;
|
||||||
|
|
||||||
|
$rose-50: #FFF1F2;
|
||||||
|
$rose-100: #FFE4E6;
|
||||||
|
$rose-200: #FECDD3;
|
||||||
|
$rose-300: #FDA4AF;
|
||||||
|
$rose-400: #FB7185;
|
||||||
|
$rose-500: #F43F5E;
|
||||||
|
$rose-600: #E11D48;
|
||||||
|
$rose-700: #BE123C;
|
||||||
|
$rose-800: #9F1239;
|
||||||
|
$rose-900: #881337;
|
||||||
|
|
||||||
|
.activity-log {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.log-event {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 80%;
|
||||||
|
padding: 4px;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
color: black;
|
||||||
|
|
||||||
|
.activity {
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
.actor {
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object {
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity {
|
||||||
|
border-left: 4px solid $blue-300;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: $gray-300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
font-family: monospace;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: $gray-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.path {
|
||||||
|
color: $gray-500;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: inherit;
|
||||||
|
color: $blue-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbound {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound {
|
||||||
|
align-self: flex-end;
|
||||||
|
background-color: #d9fdd3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -919,6 +919,17 @@ code {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:focus {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-pagination {
|
.action-pagination {
|
||||||
|
|
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'
|
||||||
|
actor = activity_log_event.data['actor']
|
||||||
|
|
||||||
|
if actor and match = actor.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
|
18
app/lib/activity_log_event.rb
Normal file
18
app/lib/activity_log_event.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityLogEvent
|
||||||
|
attr_accessor :type, :path, :data, :timestamp
|
||||||
|
|
||||||
|
def self.from_json_string(json_string)
|
||||||
|
json = Oj.load(json_string, mode: :strict)
|
||||||
|
ActivityLogEvent.new(json['type'], json['path'], json['data'])
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def initialize(type, path, data, timestamp = Time.now.utc.iso8601)
|
||||||
|
@type = type
|
||||||
|
@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
|
26
app/lib/activity_logger.rb
Normal file
26
app/lib/activity_logger.rb
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# 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'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.reset
|
||||||
|
@@loggers.clear
|
||||||
|
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?
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f|
|
= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f|
|
||||||
%h1.title= t('auth.sign_up.title', domain: site_hostname)
|
%h1.title= t('auth.sign_up.title', domain: site_hostname)
|
||||||
%p.lead= t('auth.sign_up.preamble')
|
%p.lead= t('auth.sign_up.activity_log_preamble_html')
|
||||||
|
|
||||||
= render 'shared/error_messages', object: resource
|
= render 'shared/error_messages', object: resource
|
||||||
|
|
||||||
|
@ -16,12 +16,6 @@
|
||||||
= render 'application/card', account: @invite.user.account
|
= render 'application/card', account: @invite.user.account
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
= 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') }
|
|
||||||
= 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
|
|
||||||
= 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
|
|
||||||
= 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 :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' }
|
= 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' }
|
||||||
|
|
||||||
|
@ -39,5 +33,3 @@
|
||||||
|
|
||||||
.actions
|
.actions
|
||||||
= f.button :button, @invite.present? ? t('auth.register') : sign_up_message, type: :submit
|
= f.button :button, @invite.present? ? t('auth.register') : sign_up_message, type: :submit
|
||||||
|
|
||||||
.form-footer= render 'auth/shared/links'
|
|
||||||
|
|
|
@ -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,9 +12,17 @@ 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)
|
||||||
|
|
||||||
|
event = ActivityLogEvent.new('outbound', inbox_url, Oj.load(json, mode: :strict))
|
||||||
|
|
||||||
|
@activity_log_publisher.publish(event)
|
||||||
|
|
||||||
@options = options.with_indifferent_access
|
@options = options.with_indifferent_access
|
||||||
@json = json
|
@json = json
|
||||||
@source_account = Account.find(source_account_id)
|
@source_account = Account.find(source_account_id)
|
||||||
|
|
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: This instance is intended to help you learn <a href="https://www.w3.org/TR/activitypub/">ActivityPub</a>, by showing Activities between different instances in real time. 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>. 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,7 @@ Rails.application.routes.draw do
|
||||||
/search
|
/search
|
||||||
/publish
|
/publish
|
||||||
/follow_requests
|
/follow_requests
|
||||||
|
/activity_log
|
||||||
/blocks
|
/blocks
|
||||||
/domain_blocks
|
/domain_blocks
|
||||||
/mutes
|
/mutes
|
||||||
|
@ -513,6 +514,8 @@ Rails.application.routes.draw do
|
||||||
resources :confirmations, only: [:create]
|
resources :confirmations, only: [:create]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resource :activity_log, only: [:show], controller: 'activity_log'
|
||||||
|
|
||||||
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
|
||||||
|
|
18
lib/activity_log_subscriber.rb
Normal file
18
lib/activity_log_subscriber.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# 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|
|
||||||
|
json = Oj.load(message, mode: :strict)
|
||||||
|
event = ActivityLogEvent.new(json['type'], json['path'], json['data'])
|
||||||
|
|
||||||
|
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.0.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",
|
||||||
|
|
12
spec/fixtures/activity_log_events/inbound-to-users-inbox.json
vendored
Normal file
12
spec/fixtures/activity_log_events/inbound-to-users-inbox.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"timestamp":"2022-12-08T17:12:38Z",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
43
spec/fixtures/activity_log_events/inbound-with-duplicate-recipients.json
vendored
Normal file
43
spec/fixtures/activity_log_events/inbound-with-duplicate-recipients.json
vendored
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"timestamp":"2022-12-08T17:12:38Z",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
25
spec/fixtures/activity_log_events/inbound-with-follower-recipients.json
vendored
Normal file
25
spec/fixtures/activity_log_events/inbound-with-follower-recipients.json
vendored
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"timestamp":"2022-12-08T17:12:38Z",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
43
spec/fixtures/activity_log_events/inbound-with-multiple-recipients.json
vendored
Normal file
43
spec/fixtures/activity_log_events/inbound-with-multiple-recipients.json
vendored
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"timestamp":"2022-12-08T17:12:38Z",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
12
spec/fixtures/activity_log_events/outbound.json
vendored
Normal file
12
spec/fixtures/activity_log_events/outbound.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"timestamp":"2022-12-08T17:12:38Z",
|
||||||
|
"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
|
73
spec/lib/activity_logger_spec.rb
Normal file
73
spec/lib/activity_logger_spec.rb
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
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 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.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/activitypub-visualization/-/activitypub-visualization-1.0.0.tgz#728ce9a20336bf927980748c37c8b5206cafecac"
|
||||||
|
integrity sha512-azxnf4POMTsI8J0PiOXeD70wZvxAd6KdcD2YIE+AqkW00Fgg39Jxi+OcImc8GoAiUh7dpFp6nLA+WVH4uVnIEw==
|
||||||
|
|
||||||
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