Skip to content

Commit 0b36dc1

Browse files
mattwiebeobenlandpfefferle
committed
Move: support same-server domain migrations (#1530)
* first pass from AI generation. Needs work * reign in the chaos * move logic into models * Move constructor to below properties * Update function name * Don't show `movedTo` on the same ID * DRY old domain handling * Add options to delete list on uninstall Updates blog user option to retain naming convention. * Register user option * Move actor saving inline * changelog * Remove all followers on `Move` to allow the new Actor to be re-followed * lint lol * Fix indent after merge * Combine conditional on host * Fix unit test * First pass at testing change_domain * Revert "lint lol" This reverts commit c50a441. * Revert "Remove all followers on `Move` to allow the new Actor to be re-followed" This reverts commit 1d8948f. * Encapsulate domain check * Remove unused defaults * Hook in with feature flag * Add filters and handle username in callback * Remove unused import * Add unit tests for old actors * Update reason for pre_update_option hook. * Instantiate old actors through filters * Remove unused import * use query instead of Http client * store check * simplify code * call action before sending an Activity to inboxes * check class attribute before generation an id * make old domain request setable * Account for old domain in two more places * Fix json creation on update_option_home * Fix tests * fix phpcs * use \WP_Error * Domain -> Host * global namespace * replace `get_collection` with `get_all` Even if `get_collection` is returning the correct result, it is very specific to the API endpoint and might change over time, so we should not rely on it here. --------- Co-authored-by: Konstantin Obenland <[email protected]> Co-authored-by: Matthias Pfefferle <[email protected]>
1 parent 0029928 commit 0b36dc1

14 files changed

+492
-38
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: major
2+
Type: added
3+
4+
Support same-server domain migrations ⏩

activitypub.php

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ function plugin_init() {
7676
\add_action( 'init', array( __NAMESPACE__ . '\Mailer', 'init' ) );
7777
\add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) );
7878
\add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ), 1 );
79+
\add_action( 'init', array( __NAMESPACE__ . '\Move', 'init' ) );
7980
\add_action( 'init', array( __NAMESPACE__ . '\Options', 'init' ) );
8081
\add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) );
8182

includes/class-activitypub.php

+9
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,15 @@ public static function register_user_meta() {
762762
)
763763
);
764764

765+
\register_meta(
766+
'user',
767+
$blog_prefix . 'activitypub_old_host_data',
768+
array(
769+
'description' => 'Actor object for the user on the old host.',
770+
'single' => true,
771+
)
772+
);
773+
765774
\register_meta(
766775
'user',
767776
$blog_prefix . 'activitypub_moved_to',

includes/class-dispatcher.php

+9
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ private static function send_to_inboxes( $inboxes, $outbox_item_id ) {
203203
$actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
204204
$retries = array();
205205

206+
/**
207+
* Fires before sending an Activity to inboxes.
208+
*
209+
* @param string $json The ActivityPub Activity JSON.
210+
* @param array $inboxes The inboxes to send to.
211+
* @param int $outbox_item_id The Outbox item ID.
212+
*/
213+
\do_action( 'activitypub_pre_send_to_inboxes', $json, $inboxes, $outbox_item_id );
214+
206215
foreach ( $inboxes as $inbox ) {
207216
$result = safe_remote_post( $inbox, $json, $actor->get__id() );
208217

includes/class-move.php

+149
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,42 @@
1010
use Activitypub\Activity\Actor;
1111
use Activitypub\Activity\Activity;
1212
use Activitypub\Collection\Actors;
13+
use Activitypub\Model\Blog;
14+
use Activitypub\Model\User;
1315

1416
/**
1517
* ActivityPub (Account) Move Class
1618
*
1719
* @author Matthias Pfefferle
1820
*/
1921
class Move {
22+
23+
/**
24+
* Initialize the Move class.
25+
*/
26+
public static function init() {
27+
/**
28+
* Filter to enable automatically moving Fediverse accounts when the domain changes.
29+
*
30+
* @param bool $domain_moves_enabled Whether domain moves are enabled.
31+
*/
32+
$domain_moves_enabled = apply_filters( 'activitypub_enable_primary_domain_moves', false );
33+
34+
if ( $domain_moves_enabled ) {
35+
// Add the filter to change the domain.
36+
\add_filter( 'update_option_home', array( self::class, 'change_domain' ), 10, 2 );
37+
38+
if ( get_option( 'activitypub_old_host' ) ) {
39+
\add_action( 'activitypub_construct_model_actor', array( self::class, 'maybe_initiate_old_user' ) );
40+
\add_action( 'activitypub_pre_send_to_inboxes', array( self::class, 'pre_send_to_inboxes' ) );
41+
42+
if ( ! is_user_type_disabled( 'blog' ) ) {
43+
\add_filter( 'activitypub_pre_get_by_username', array( self::class, 'old_blog_username' ), 10, 2 );
44+
}
45+
}
46+
}
47+
}
48+
2049
/**
2150
* Move an ActivityPub account from one location to another.
2251
*
@@ -161,4 +190,124 @@ private static function update_blog_also_known_as( $from ) {
161190

162191
\update_option( 'activitypub_blog_user_also_known_as', $also_known_as );
163192
}
193+
194+
/**
195+
* Change domain for all ActivityPub Actors.
196+
*
197+
* This method handles domain migration according to the ActivityPub Data Portability spec.
198+
* It stores the old host and calls Move::internally for each available profile.
199+
* It also caches the JSON representation of the old Actor for future lookups.
200+
*
201+
* @param string $from The old domain.
202+
* @param string $to The new domain.
203+
*
204+
* @return array Array of results from Move::internally calls.
205+
*/
206+
public static function change_domain( $from, $to ) {
207+
// Get all actors that need to be migrated.
208+
$actors = Actors::get_all();
209+
210+
$results = array();
211+
$to_host = \wp_parse_url( $to, \PHP_URL_HOST );
212+
$from_host = \wp_parse_url( $from, \PHP_URL_HOST );
213+
214+
// Store the old host for future reference.
215+
\update_option( 'activitypub_old_host', $from_host );
216+
217+
// Process each actor.
218+
foreach ( $actors as $actor ) {
219+
$actor_id = $actor->get_id();
220+
221+
// Replace the new host with the old host in the actor ID.
222+
$old_actor_id = str_replace( $to_host, $from_host, $actor_id );
223+
224+
// Call Move::internally for this actor.
225+
$result = self::internally( $old_actor_id, $actor_id );
226+
227+
if ( \is_wp_error( $result ) ) {
228+
// Log the error and continue with the next actor.
229+
Debug::write_log( 'Error moving actor: ' . $actor_id . ' - ' . $result->get_error_message() );
230+
continue;
231+
}
232+
233+
$json = str_replace( $to_host, $from_host, $actor->to_json() );
234+
235+
// Save the current actor data after migration.
236+
if ( $actor instanceof Blog ) {
237+
\update_option( 'activitypub_blog_user_old_host_data', $json, false );
238+
} else {
239+
\update_user_option( $actor->get__id(), 'activitypub_old_host_data', $json, false );
240+
}
241+
242+
$results[] = array(
243+
'actor' => $actor_id,
244+
'result' => $result,
245+
);
246+
}
247+
248+
return $results;
249+
}
250+
251+
/**
252+
* Maybe initiate old user.
253+
*
254+
* This method checks if the current request domain matches the old host.
255+
* If it does, it retrieves the cached data for the user and populates the instance.
256+
*
257+
* @param Blog|User $instance The Blog or User instance to populate.
258+
*/
259+
public static function maybe_initiate_old_user( $instance ) {
260+
if ( ! Query::get_instance()->is_old_host_request() ) {
261+
return;
262+
}
263+
264+
if ( $instance instanceof Blog ) {
265+
$cached_data = \get_option( 'activitypub_blog_user_old_host_data' );
266+
} elseif ( $instance instanceof User ) {
267+
$cached_data = \get_user_option( 'activitypub_old_host_data', $instance->get__id() );
268+
}
269+
270+
if ( ! empty( $cached_data ) ) {
271+
$instance->from_json( $cached_data );
272+
}
273+
}
274+
275+
/**
276+
* Pre-send to inboxes.
277+
*
278+
* @param string $json The ActivityPub Activity JSON.
279+
*/
280+
public static function pre_send_to_inboxes( $json ) {
281+
$json = json_decode( $json, true );
282+
283+
if ( 'Move' !== $json['type'] ) {
284+
return;
285+
}
286+
287+
if ( is_same_domain( $json['object'] ) ) {
288+
return;
289+
}
290+
291+
Query::get_instance()->set_old_host_request();
292+
}
293+
294+
/**
295+
* Filter to return the old blog username.
296+
*
297+
* @param null $pre The pre-existing value.
298+
* @param string $username The username to check.
299+
*
300+
* @return Blog|null The old blog instance or null.
301+
*/
302+
public static function old_blog_username( $pre, $username ) {
303+
$old_host = \get_option( 'activitypub_old_host' );
304+
305+
// Special case for Blog Actor on old host.
306+
if ( $old_host === $username && Query::get_instance()->is_old_host_request() ) {
307+
// Return a new Blog instance which will load the cached data in its constructor.
308+
$pre = new Blog();
309+
}
310+
311+
return $pre;
312+
}
164313
}

includes/class-query.php

+43
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ class Query {
4848
*/
4949
private $is_activitypub_request;
5050

51+
/**
52+
* Whether the current request is from the old host.
53+
*
54+
* @var bool
55+
*/
56+
private $is_old_host_request;
57+
5158
/**
5259
* The constructor.
5360
*/
@@ -305,4 +312,40 @@ public function is_activitypub_request() {
305312

306313
return false;
307314
}
315+
316+
/**
317+
* Check if the current request is from the old host.
318+
*
319+
* @return bool True if the request is from the old host, false otherwise.
320+
*/
321+
public function is_old_host_request() {
322+
if ( isset( $this->is_old_host_request ) ) {
323+
return $this->is_old_host_request;
324+
}
325+
326+
$old_host = \get_option( 'activitypub_old_host' );
327+
328+
if ( ! $old_host ) {
329+
$this->is_old_host_request = false;
330+
return false;
331+
}
332+
333+
$request_host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
334+
$referer_host = isset( $_SERVER['HTTP_REFERER'] ) ? \wp_parse_url( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';
335+
336+
// Check if the domain matches either the request domain or referer.
337+
$check = $old_host === $request_host || $old_host === $referer_host;
338+
$this->is_old_host_request = $check;
339+
340+
return $check;
341+
}
342+
343+
/**
344+
* Fake an old host request.
345+
*
346+
* @param bool $state Optional. The state to set. Default true.
347+
*/
348+
public function set_old_host_request( $state = true ) {
349+
$this->is_old_host_request = $state;
350+
}
308351
}

includes/class-signature.php

+1-7
Original file line numberDiff line numberDiff line change
@@ -267,17 +267,11 @@ public static function verify_http_signature( $request ) {
267267
$headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
268268
}
269269

270-
if ( ! isset( $headers['signature'] ) ) {
271-
return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 401 ) );
272-
}
273-
274270
if ( array_key_exists( 'signature', $headers ) ) {
275271
$signature_block = self::parse_signature_header( $headers['signature'][0] );
276272
} elseif ( array_key_exists( 'authorization', $headers ) ) {
277273
$signature_block = self::parse_signature_header( $headers['authorization'][0] );
278-
}
279-
280-
if ( ! isset( $signature_block ) || ! $signature_block ) {
274+
} else {
281275
return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) );
282276
}
283277

includes/collection/class-actors.php

+60-3
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ public static function get_by_id( $user_id ) {
7676
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
7777
*/
7878
public static function get_by_username( $username ) {
79+
/**
80+
* Filter the username before we do anything else.
81+
*
82+
* @param null $pre The pre-existing value.
83+
* @param string $username The username.
84+
*/
85+
$pre = apply_filters( 'activitypub_pre_get_by_username', null, $username );
86+
if ( null !== $pre ) {
87+
return $pre;
88+
}
89+
7990
// Check for blog user.
8091
if ( Blog::get_default_username() === $username ) {
8192
return new Blog();
@@ -110,7 +121,10 @@ public static function get_by_username( $username ) {
110121
);
111122

112123
if ( $user->results ) {
113-
return self::get_by_id( $user->results[0] );
124+
$actor = self::get_by_id( $user->results[0] );
125+
if ( ! \is_wp_error( $actor ) ) {
126+
return $actor;
127+
}
114128
}
115129

116130
$username = str_replace( array( '*', '%' ), '', $username );
@@ -128,7 +142,10 @@ public static function get_by_username( $username ) {
128142
);
129143

130144
if ( $user->results ) {
131-
return self::get_by_id( $user->results[0] );
145+
$actor = self::get_by_id( $user->results[0] );
146+
if ( ! \is_wp_error( $actor ) ) {
147+
return $actor;
148+
}
132149
}
133150

134151
return new WP_Error(
@@ -164,6 +181,9 @@ public static function get_by_resource( $uri ) {
164181
$scheme = \esc_attr( $match[1] );
165182
}
166183

184+
// @todo: handle old domain URIs here before we serve a new domain below when we shouldn't.
185+
// Although maybe passing through to ::get_by_username() is enough?
186+
167187
switch ( $scheme ) {
168188
// Check for http(s) URIs.
169189
case 'http':
@@ -217,7 +237,7 @@ public static function get_by_resource( $uri ) {
217237
$host = normalize_host( \substr( \strrchr( $uri, '@' ), 1 ) );
218238
$blog_host = normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );
219239

220-
if ( $blog_host !== $host ) {
240+
if ( $blog_host !== $host && get_option( 'activitypub_old_host' ) !== $host ) {
221241
return new WP_Error(
222242
'activitypub_wrong_host',
223243
\__( 'Resource host does not match blog host', 'activitypub' ),
@@ -299,6 +319,43 @@ public static function get_collection() {
299319
return $return;
300320
}
301321

322+
/**
323+
* Get all active Actors including the Blog Actor.
324+
*
325+
* @return array The actor collection.
326+
*/
327+
public static function get_all() {
328+
$return = array();
329+
330+
if ( ! is_user_type_disabled( 'user' ) ) {
331+
$users = \get_users(
332+
array(
333+
'capability__in' => array( 'activitypub' ),
334+
)
335+
);
336+
337+
foreach ( $users as $user ) {
338+
$actor = User::from_wp_user( $user->ID );
339+
340+
if ( \is_wp_error( $actor ) ) {
341+
continue;
342+
}
343+
344+
$return[] = $actor;
345+
}
346+
}
347+
348+
// Also include the blog actor if active.
349+
if ( ! is_user_type_disabled( 'blog' ) ) {
350+
$blog_actor = self::get_by_id( self::BLOG_USER_ID );
351+
if ( ! \is_wp_error( $blog_actor ) ) {
352+
$return[] = $blog_actor;
353+
}
354+
}
355+
356+
return $return;
357+
}
358+
302359
/**
303360
* Returns the actor type based on the user ID.
304361
*

0 commit comments

Comments
 (0)