Compare commits

...

43 commits

Author SHA1 Message Date
dependabot[bot]
115fbe9942
Bump capybara from 3.37.1 to 3.38.0
Bumps [capybara](https://github.com/teamcapybara/capybara) from 3.37.1 to 3.38.0.
- [Release notes](https://github.com/teamcapybara/capybara/releases)
- [Changelog](https://github.com/teamcapybara/capybara/blob/master/History.md)
- [Commits](https://github.com/teamcapybara/capybara/compare/3.37.1...3.38.0)

---
updated-dependencies:
- dependency-name: capybara
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-14 20:12:11 +00:00
Sebastian Jambor
ac398426fc some rewording of signup message 2023-01-14 18:28:37 +01:00
Sebastian Jambor
e02602e445 change email confirmation flash to not mention email 2023-01-14 18:10:13 +01:00
Sebastian Jambor
ac32e5ec1d remove email and password section from account preferences 2023-01-14 18:00:30 +01:00
Sebastian Jambor
8d3ceafe34 allow account redirect without specifying password 2023-01-14 17:56:43 +01:00
Sebastian Jambor
58d83a8cb1 add description to activity log page 2023-01-14 17:19:37 +01:00
Sebastian Jambor
240160f877 fix issue in activity logger 2023-01-14 17:12:20 +01:00
Sebastian Jambor
a622b0b947 add hotkey to go to activity log, and hotkey to copy logs 2023-01-13 17:59:12 +01:00
Sebastian Jambor
f727ee6a45 increase number of syllables for last name 2023-01-13 17:37:34 +01:00
Sebastian Jambor
25008e0555 scheduler to delete old accounts 2023-01-13 16:49:12 +01:00
Sebastian Jambor
995c69a45b allow multiple clients for the same id 2023-01-12 19:48:04 +01:00
Sebastian Jambor
291027c1c3 first explanation on sign-up page 2023-01-10 18:50:16 +01:00
Sebastian Jambor
a84f182cf4 allow shorter time for sign up 2023-01-10 18:49:59 +01:00
Sebastian Jambor
12abf5e142 redirect to sign-up after logging out 2023-01-10 18:49:43 +01:00
Sebastian Jambor
638c1ed7d8 when not logged in, / redirects to sign-up; when logged-in, / redirects to activity_log 2023-01-10 15:23:33 +01:00
Sebastian Jambor
e90505cfdf use resdis configuration for activity log 2023-01-09 21:46:58 +01:00
Sebastian Jambor
d73bf5770d one-click sign-up with autogenerated usernames 2023-01-09 21:25:59 +01:00
Sebastian Jambor
fd92599890 use public package 2023-01-09 14:48:03 +01:00
Sebastian Jambor
5560887862 filter keep-alives 2023-01-09 14:48:03 +01:00
Sebastian Jambor
426e096a9b fix timestamp 2023-01-09 14:48:03 +01:00
Sebastian Jambor
9785c2849a integrate audience helper 2023-01-09 14:48:03 +01:00
Sebastian Jambor
57617faa27 handle duplicates 2023-01-09 14:48:03 +01:00
Sebastian Jambor
d5408766cc handle followers 2023-01-09 14:48:03 +01:00
Sebastian Jambor
7a30154bc5 handle audicence fields 2023-01-09 14:48:03 +01:00
Sebastian Jambor
f1ee1eadd9 extending functionality of audience helper 2023-01-09 14:48:03 +01:00
Sebastian Jambor
e884c39a03 fix keep-alive 2023-01-09 14:48:03 +01:00
Sebastian Jambor
f643515fdd starting a test for audience helper 2023-01-09 14:48:03 +01:00
Sebastian Jambor
078688149a remove mode settings 2023-01-09 14:48:03 +01:00
Sebastian Jambor
f94db7a54f convert to hooks 2023-01-09 14:48:03 +01:00
Sebastian Jambor
cb83422a8a extract dummy data 2023-01-09 14:48:03 +01:00
Sebastian Jambor
7a0e5c9900 starting column header 2023-01-09 14:48:03 +01:00
Sebastian Jambor
14570da001 some cleanup 2023-01-09 14:48:03 +01:00
Sebastian Jambor
0a479aa734 add link to main column 2023-01-09 14:48:03 +01:00
Sebastian Jambor
b9df613b31 enable dark mode 2023-01-09 14:48:03 +01:00
Sebastian Jambor
faf7925ce0 use external library 2023-01-09 14:48:03 +01:00
Sebastian Jambor
bfd9f4938d add timestamps 2023-01-09 14:48:03 +01:00
Sebastian Jambor
4a01c00ef2 starting with some styling 2023-01-09 14:48:03 +01:00
Sebastian Jambor
12c4213ecf removing debug output 2023-01-09 14:48:03 +01:00
Sebastian Jambor
3f4a72e7f3 log outbound events as well 2023-01-09 14:48:03 +01:00
Sebastian Jambor
a414adc582 showing inbound activities in frontend via redis pub/sub 2023-01-09 14:48:03 +01:00
Sebastian Jambor
313af50864 prototyping server sent events 2023-01-09 14:48:02 +01:00
Sebastian Jambor
3142c2b31a adding activity log page 2023-01-09 14:48:02 +01:00
Sebastian Jambor
e35b438f06 log activity pub messages to error log 2023-01-09 14:48:02 +01:00
47 changed files with 1086 additions and 87 deletions

View file

@ -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'

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'redis'
class ActivityPub::InboxesController < ActivityPub::BaseController class ActivityPub::InboxesController < ActivityPub::BaseController
include SignatureVerification include SignatureVerification
@ -9,6 +10,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
before_action :require_actor_signature! before_action :require_actor_signature!
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
def initialize
@activity_log_publisher = ActivityLogPublisher.new
end
def create def create
upgrade_account upgrade_account
process_collection_synchronization process_collection_synchronization
@ -71,6 +76,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

View 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

View file

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

View file

@ -1,5 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'random_name_generator'
require 'securerandom'
class Auth::RegistrationsController < Devise::RegistrationsController class Auth::RegistrationsController < Devise::RegistrationsController
include RegistrationSpamConcern include RegistrationSpamConcern
@ -45,6 +48,21 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def build_resource(hash = nil) def build_resource(hash = nil)
# hack to always use auto-generated usernames and passwords
if !hash.nil?
username = generate_name
password = SecureRandom.hex
hash["account_attributes"] = {
"username": username.parameterize(separator: '_'),
"display_name": username
}
hash["email"] = "#{hash["account_attributes"]["username"]}@#{Rails.configuration.x.web_domain}"
hash["password"] = password
hash["password_confirmation"] = password
end
super(hash) super(hash)
resource.locale = I18n.locale resource.locale = I18n.locale
@ -62,7 +80,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def after_sign_up_path_for(_resource) def after_sign_up_path_for(_resource)
auth_setup_path # Hack to automatically visit the confirmation link after successful sign-up.
# This way we can use the default configuration but still get away without an email server.
"/auth/confirmation?confirmation_token=#{@user.confirmation_token}"
end end
def after_sign_in_path_for(_resource) def after_sign_in_path_for(_resource)
@ -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

View file

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

View file

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

View 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,
};

View file

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

View file

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

View file

@ -86,6 +86,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 />

View file

@ -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,

View file

@ -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');
} }

View file

@ -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';

View file

@ -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;
}
}

View file

@ -919,6 +919,17 @@ code {
} }
} }
} }
a {
color: $highlight-text-color;
text-decoration: underline;
&:hover,
&:active,
&:focus {
text-decoration: none;
}
}
} }
.action-pagination { .action-pagination {

View file

@ -0,0 +1,58 @@
# frozen_string_literal: true
class ActivityLogAudienceHelper
def self.audience(activity_log_event)
domain = Rails.configuration.x.web_domain
if activity_log_event.type == 'outbound'
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

View 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

View file

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

View file

@ -0,0 +1,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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'

View file

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

View file

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

View file

@ -12,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)

View file

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

View file

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

View file

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

View file

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

View file

@ -953,6 +953,7 @@ en:
title: Setup title: Setup
sign_up: sign_up:
preamble: With an account on this Mastodon server, you'll be able to follow any other person on the network, regardless of where their account is hosted. preamble: With an account on this Mastodon server, you'll be able to follow any other person on the network, regardless of where their account is hosted.
activity_log_preamble_html: 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

View file

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

View file

@ -24,6 +24,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'

View file

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

View file

@ -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

View file

@ -35,6 +35,7 @@
"@github/webauthn-json": "^0.5.7", "@github/webauthn-json": "^0.5.7",
"@rails/ujs": "^6.1.7", "@rails/ujs": "^6.1.7",
"abortcontroller-polyfill": "^1.7.5", "abortcontroller-polyfill": "^1.7.5",
"activitypub-visualization": "^1.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",

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View file

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

View file

@ -0,0 +1,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

View file

@ -2322,6 +2322,11 @@ acorn@^8.0.4, acorn@^8.1.0, acorn@^8.5.0, acorn@^8.8.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8"
integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
activitypub-visualization@^1.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"