Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
7610860
first pass from AI generation. Needs work
mattwiebe Mar 31, 2025
5cd4a8f
reign in the chaos
mattwiebe Apr 1, 2025
9cc6984
move logic into models
mattwiebe Apr 1, 2025
69f2ef9
Move constructor to below properties
obenland Apr 1, 2025
7620f65
Update function name
obenland Apr 1, 2025
6e9d2dd
Don't show `movedTo` on the same ID
mattwiebe Apr 1, 2025
5abb3d8
DRY old domain handling
mattwiebe Apr 1, 2025
9ad98a0
Add options to delete list on uninstall
obenland Apr 1, 2025
83358b6
Register user option
obenland Apr 1, 2025
35a34c1
Move actor saving inline
obenland Apr 1, 2025
b482332
changelog
mattwiebe Apr 1, 2025
2073be7
Remove all followers on `Move` to allow the new Actor to be re-followed
mattwiebe Apr 2, 2025
1f2693f
lint lol
mattwiebe Apr 2, 2025
8c87b74
Fix indent after merge
obenland Apr 2, 2025
4fc635e
Combine conditional on host
obenland Apr 2, 2025
61d658b
Fix unit test
obenland Apr 2, 2025
519fdc4
First pass at testing change_domain
obenland Apr 2, 2025
2e707d6
Revert "lint lol"
obenland Apr 3, 2025
a733aca
Revert "Remove all followers on `Move` to allow the new Actor to be r…
obenland Apr 3, 2025
698e2ac
Encapsulate domain check
obenland Apr 3, 2025
548b5a4
Remove unused defaults
obenland Apr 3, 2025
4331fb3
Hook in with feature flag
obenland Apr 3, 2025
6b79395
Merge branch 'trunk' into add/same-server-domain-move
pfefferle Apr 3, 2025
4106241
Add filters and handle username in callback
obenland Apr 3, 2025
bb40a10
Remove unused import
obenland Apr 3, 2025
eaee854
Add unit tests for old actors
obenland Apr 3, 2025
d51333d
Update reason for pre_update_option hook.
obenland Apr 3, 2025
840a673
Instantiate old actors through filters
obenland Apr 3, 2025
b2a2aab
Remove unused import
obenland Apr 3, 2025
3864e42
Merge branch 'trunk' into add/same-server-domain-move
obenland Apr 3, 2025
565f515
use query instead of Http client
pfefferle Apr 4, 2025
349588c
store check
pfefferle Apr 4, 2025
f4da950
simplify code
pfefferle Apr 4, 2025
d4a9141
call action before sending an Activity to inboxes
pfefferle Apr 4, 2025
12de169
check class attribute before generation an id
pfefferle Apr 4, 2025
111fd90
make old domain request setable
pfefferle Apr 4, 2025
9d08ee5
Merge branch 'trunk' into add/same-server-domain-move
pfefferle Apr 4, 2025
c2310ab
Merge branch 'trunk' into add/same-server-domain-move
obenland Apr 4, 2025
7650730
Account for old domain in two more places
obenland Apr 7, 2025
0367cb0
Fix json creation on update_option_home
obenland Apr 7, 2025
8590532
Fix tests
obenland Apr 7, 2025
11482d3
Merge branch 'trunk' into add/same-server-domain-move
pfefferle Apr 8, 2025
c2837b4
fix phpcs
pfefferle Apr 8, 2025
7372f23
use \WP_Error
pfefferle Apr 8, 2025
6d627bc
Domain -> Host
obenland Apr 8, 2025
17ec4e0
Merge branch 'trunk' into add/same-server-domain-move
pfefferle Apr 8, 2025
02681cc
global namespace
pfefferle Apr 9, 2025
90cd85a
replace `get_collection` with `get_all`
pfefferle Apr 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/add-same-server-domain-move
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: major
Type: added

Support same-server domain migrations ⏩
1 change: 1 addition & 0 deletions activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function plugin_init() {
\add_action( 'init', array( __NAMESPACE__ . '\Mailer', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ), 1 );
\add_action( 'init', array( __NAMESPACE__ . '\Move', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Options', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) );

Expand Down
9 changes: 9 additions & 0 deletions includes/class-activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,15 @@ public static function register_user_meta() {
)
);

\register_meta(
'user',
$blog_prefix . 'activitypub_old_host_data',
array(
'description' => 'Actor object for the user on the old host.',
'single' => true,
)
);

\register_meta(
'user',
$blog_prefix . 'activitypub_moved_to',
Expand Down
9 changes: 9 additions & 0 deletions includes/class-dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@ private static function send_to_inboxes( $inboxes, $outbox_item_id ) {
$actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
$retries = array();

/**
* Fires before sending an Activity to inboxes.
*
* @param string $json The ActivityPub Activity JSON.
* @param array $inboxes The inboxes to send to.
* @param int $outbox_item_id The Outbox item ID.
*/
\do_action( 'activitypub_pre_send_to_inboxes', $json, $inboxes, $outbox_item_id );

foreach ( $inboxes as $inbox ) {
$result = safe_remote_post( $inbox, $json, $actor->get__id() );

Expand Down
149 changes: 149 additions & 0 deletions includes/class-move.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,42 @@
use Activitypub\Activity\Actor;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Model\Blog;
use Activitypub\Model\User;

/**
* ActivityPub (Account) Move Class
*
* @author Matthias Pfefferle
*/
class Move {

/**
* Initialize the Move class.
*/
public static function init() {
/**
* Filter to enable automatically moving Fediverse accounts when the domain changes.
*
* @param bool $domain_moves_enabled Whether domain moves are enabled.
*/
$domain_moves_enabled = apply_filters( 'activitypub_enable_primary_domain_moves', false );
Copy link
Member

@pfefferle pfefferle Apr 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@obenland I know that you are not a fan of constants, but this would be the third possibility to enable a feature, aside from advanced settings and constants.

I think we should have a certain level of consistency. I am not arguing for a constant her, maybe we should think about a Developer Preview or Beta Features section in the Advanced settings to enable beta features!?!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be happy to deprecate feature constants in favor of filters or advanced settings


if ( $domain_moves_enabled ) {
// Add the filter to change the domain.
\add_filter( 'update_option_home', array( self::class, 'change_domain' ), 10, 2 );

if ( get_option( 'activitypub_old_host' ) ) {
\add_action( 'activitypub_construct_model_actor', array( self::class, 'maybe_initiate_old_user' ) );
\add_action( 'activitypub_pre_send_to_inboxes', array( self::class, 'pre_send_to_inboxes' ) );

if ( ! is_user_type_disabled( 'blog' ) ) {
\add_filter( 'activitypub_pre_get_by_username', array( self::class, 'old_blog_username' ), 10, 2 );
}
}
}
}

/**
* Move an ActivityPub account from one location to another.
*
Expand Down Expand Up @@ -161,4 +190,124 @@ private static function update_blog_also_known_as( $from ) {

\update_option( 'activitypub_blog_user_also_known_as', $also_known_as );
}

/**
* Change domain for all ActivityPub Actors.
*
* This method handles domain migration according to the ActivityPub Data Portability spec.
* It stores the old host and calls Move::internally for each available profile.
* It also caches the JSON representation of the old Actor for future lookups.
*
* @param string $from The old domain.
* @param string $to The new domain.
*
* @return array Array of results from Move::internally calls.
*/
public static function change_domain( $from, $to ) {
// Get all actors that need to be migrated.
$actors = Actors::get_all();

$results = array();
$to_host = \wp_parse_url( $to, \PHP_URL_HOST );
$from_host = \wp_parse_url( $from, \PHP_URL_HOST );

// Store the old host for future reference.
\update_option( 'activitypub_old_host', $from_host );

// Process each actor.
foreach ( $actors as $actor ) {
$actor_id = $actor->get_id();

// Replace the new host with the old host in the actor ID.
$old_actor_id = str_replace( $to_host, $from_host, $actor_id );

// Call Move::internally for this actor.
$result = self::internally( $old_actor_id, $actor_id );

if ( \is_wp_error( $result ) ) {
// Log the error and continue with the next actor.
Debug::write_log( 'Error moving actor: ' . $actor_id . ' - ' . $result->get_error_message() );
continue;
}

$json = str_replace( $to_host, $from_host, $actor->to_json() );

// Save the current actor data after migration.
if ( $actor instanceof Blog ) {
\update_option( 'activitypub_blog_user_old_host_data', $json, false );
} else {
\update_user_option( $actor->get__id(), 'activitypub_old_host_data', $json, false );
}

$results[] = array(
'actor' => $actor_id,
'result' => $result,
);
}

return $results;
}

/**
* Maybe initiate old user.
*
* This method checks if the current request domain matches the old host.
* If it does, it retrieves the cached data for the user and populates the instance.
*
* @param Blog|User $instance The Blog or User instance to populate.
*/
public static function maybe_initiate_old_user( $instance ) {
if ( ! Query::get_instance()->is_old_host_request() ) {
return;
}

if ( $instance instanceof Blog ) {
$cached_data = \get_option( 'activitypub_blog_user_old_host_data' );
} elseif ( $instance instanceof User ) {
$cached_data = \get_user_option( 'activitypub_old_host_data', $instance->get__id() );
}

if ( ! empty( $cached_data ) ) {
$instance->from_json( $cached_data );
}
}

/**
* Pre-send to inboxes.
*
* @param string $json The ActivityPub Activity JSON.
*/
public static function pre_send_to_inboxes( $json ) {
$json = json_decode( $json, true );

if ( 'Move' !== $json['type'] ) {
return;
}

if ( is_same_domain( $json['object'] ) ) {
return;
}

Query::get_instance()->set_old_host_request();
}

/**
* Filter to return the old blog username.
*
* @param null $pre The pre-existing value.
* @param string $username The username to check.
*
* @return Blog|null The old blog instance or null.
*/
public static function old_blog_username( $pre, $username ) {
$old_host = \get_option( 'activitypub_old_host' );

// Special case for Blog Actor on old host.
if ( $old_host === $username && Query::get_instance()->is_old_host_request() ) {
// Return a new Blog instance which will load the cached data in its constructor.
$pre = new Blog();
}

return $pre;
}
}
43 changes: 43 additions & 0 deletions includes/class-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ class Query {
*/
private $is_activitypub_request;

/**
* Whether the current request is from the old host.
*
* @var bool
*/
private $is_old_host_request;

/**
* The constructor.
*/
Expand Down Expand Up @@ -305,4 +312,40 @@ public function is_activitypub_request() {

return false;
}

/**
* Check if the current request is from the old host.
*
* @return bool True if the request is from the old host, false otherwise.
*/
public function is_old_host_request() {
if ( isset( $this->is_old_host_request ) ) {
return $this->is_old_host_request;
}

$old_host = \get_option( 'activitypub_old_host' );

if ( ! $old_host ) {
$this->is_old_host_request = false;
return false;
}

$request_host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
$referer_host = isset( $_SERVER['HTTP_REFERER'] ) ? \wp_parse_url( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';

// Check if the domain matches either the request domain or referer.
$check = $old_host === $request_host || $old_host === $referer_host;
$this->is_old_host_request = $check;

return $check;
}

/**
* Fake an old host request.
*
* @param bool $state Optional. The state to set. Default true.
*/
public function set_old_host_request( $state = true ) {
$this->is_old_host_request = $state;
}
}
8 changes: 1 addition & 7 deletions includes/class-signature.php
Original file line number Diff line number Diff line change
Expand Up @@ -267,17 +267,11 @@ public static function verify_http_signature( $request ) {
$headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
}

if ( ! isset( $headers['signature'] ) ) {
return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 401 ) );
}

if ( array_key_exists( 'signature', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['signature'][0] );
} elseif ( array_key_exists( 'authorization', $headers ) ) {
$signature_block = self::parse_signature_header( $headers['authorization'][0] );
}

if ( ! isset( $signature_block ) || ! $signature_block ) {
} else {
return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) );
}

Expand Down
63 changes: 60 additions & 3 deletions includes/collection/class-actors.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ public static function get_by_id( $user_id ) {
* @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found.
*/
public static function get_by_username( $username ) {
/**
* Filter the username before we do anything else.
*
* @param null $pre The pre-existing value.
* @param string $username The username.
*/
$pre = apply_filters( 'activitypub_pre_get_by_username', null, $username );
if ( null !== $pre ) {
return $pre;
}

// Check for blog user.
if ( Blog::get_default_username() === $username ) {
return new Blog();
Expand Down Expand Up @@ -110,7 +121,10 @@ public static function get_by_username( $username ) {
);

if ( $user->results ) {
return self::get_by_id( $user->results[0] );
$actor = self::get_by_id( $user->results[0] );
if ( ! \is_wp_error( $actor ) ) {
return $actor;
}
}

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

if ( $user->results ) {
return self::get_by_id( $user->results[0] );
$actor = self::get_by_id( $user->results[0] );
if ( ! \is_wp_error( $actor ) ) {
return $actor;
}
}

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

// @todo: handle old domain URIs here before we serve a new domain below when we shouldn't.
// Although maybe passing through to ::get_by_username() is enough?

switch ( $scheme ) {
// Check for http(s) URIs.
case 'http':
Expand Down Expand Up @@ -217,7 +237,7 @@ public static function get_by_resource( $uri ) {
$host = normalize_host( \substr( \strrchr( $uri, '@' ), 1 ) );
$blog_host = normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) );

if ( $blog_host !== $host ) {
if ( $blog_host !== $host && get_option( 'activitypub_old_host' ) !== $host ) {
return new WP_Error(
'activitypub_wrong_host',
\__( 'Resource host does not match blog host', 'activitypub' ),
Expand Down Expand Up @@ -299,6 +319,43 @@ public static function get_collection() {
return $return;
}

/**
* Get all active Actors including the Blog Actor.
*
* @return array The actor collection.
*/
public static function get_all() {
$return = array();

if ( ! is_user_type_disabled( 'user' ) ) {
$users = \get_users(
array(
'capability__in' => array( 'activitypub' ),
)
);

foreach ( $users as $user ) {
$actor = User::from_wp_user( $user->ID );

if ( \is_wp_error( $actor ) ) {
continue;
}

$return[] = $actor;
}
}

// Also include the blog actor if active.
if ( ! is_user_type_disabled( 'blog' ) ) {
$blog_actor = self::get_by_id( self::BLOG_USER_ID );
if ( ! \is_wp_error( $blog_actor ) ) {
$return[] = $blog_actor;
}
}

return $return;
}

/**
* Returns the actor type based on the user ID.
*
Expand Down
Loading
Loading