Browse Source

Add Profile Migrations

Daniel Supernault 1 year ago
parent
commit
f8145a78cf

+ 40 - 11
app/Http/Controllers/ProfileAliasController.php

@@ -2,11 +2,13 @@
 
 namespace App\Http\Controllers;
 
-use Illuminate\Http\Request;
-use App\Util\Lexer\Nickname;
-use App\Util\Webfinger\WebfingerUrl;
 use App\Models\ProfileAlias;
+use App\Models\ProfileMigration;
+use App\Services\AccountService;
 use App\Services\WebfingerService;
+use App\Util\Lexer\Nickname;
+use Cache;
+use Illuminate\Http\Request;
 
 class ProfileAliasController extends Controller
 {
@@ -18,31 +20,47 @@ class ProfileAliasController extends Controller
     public function index(Request $request)
     {
         $aliases = $request->user()->profile->aliases;
+
         return view('settings.aliases.index', compact('aliases'));
     }
 
     public function store(Request $request)
     {
         $this->validate($request, [
-            'acct' => 'required'
+            'acct' => 'required',
         ]);
 
         $acct = $request->input('acct');
 
-        if($request->user()->profile->aliases->count() >= 3) {
+        $nn = Nickname::normalizeProfileUrl($acct);
+        if (! $nn) {
+            return back()->with('error', 'Invalid account alias.');
+        }
+
+        if ($nn['domain'] === config('pixelfed.domain.app')) {
+            if (strtolower($nn['username']) == ($request->user()->username)) {
+                return back()->with('error', 'You cannot add an alias to your own account.');
+            }
+        }
+
+        if ($request->user()->profile->aliases->count() >= 3) {
             return back()->with('error', 'You can only add 3 account aliases.');
         }
 
         $webfingerService = WebfingerService::lookup($acct);
-        if(!$webfingerService || !isset($webfingerService['url'])) {
+        $webfingerUrl = WebfingerService::rawGet($acct);
+
+        if (! $webfingerService || ! isset($webfingerService['url']) || ! $webfingerUrl || empty($webfingerUrl)) {
             return back()->with('error', 'Invalid account, cannot add alias at this time.');
         }
         $alias = new ProfileAlias;
         $alias->profile_id = $request->user()->profile_id;
         $alias->acct = $acct;
-        $alias->uri = $webfingerService['url'];
+        $alias->uri = $webfingerUrl;
         $alias->save();
 
+        Cache::forget('pf:activitypub:user-object:by-id:'.$request->user()->profile_id);
+
         return back()->with('status', 'Successfully added alias!');
     }
 
@@ -50,14 +68,25 @@ class ProfileAliasController extends Controller
     {
         $this->validate($request, [
             'acct' => 'required',
-            'id' => 'required|exists:profile_aliases'
+            'id' => 'required|exists:profile_aliases',
         ]);
-
-        $alias = ProfileAlias::where('profile_id', $request->user()->profile_id)
-            ->where('acct', $request->input('acct'))
+        $pid = $request->user()->profile_id;
+        $acct = $request->input('acct');
+        $alias = ProfileAlias::where('profile_id', $pid)
+            ->where('acct', $acct)
             ->findOrFail($request->input('id'));
+        $migration = ProfileMigration::whereProfileId($pid)
+            ->whereAcct($acct)
+            ->first();
+        if ($migration) {
+            $request->user()->profile->update([
+                'moved_to_profile_id' => null,
+            ]);
+        }
 
         $alias->delete();
+        Cache::forget('pf:activitypub:user-object:by-id:'.$pid);
+        AccountService::del($pid);
 
         return back()->with('status', 'Successfully deleted alias!');
     }

+ 62 - 0
app/Http/Controllers/ProfileMigrationController.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\ProfileMigrationStoreRequest;
+use App\Jobs\ProfilePipeline\ProfileMigrationMoveFollowersPipeline;
+use App\Models\ProfileAlias;
+use App\Models\ProfileMigration;
+use App\Services\AccountService;
+use App\Services\WebfingerService;
+use App\Util\ActivityPub\Helpers;
+use Illuminate\Http\Request;
+
+class ProfileMigrationController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function index(Request $request)
+    {
+        $hasExistingMigration = ProfileMigration::whereProfileId($request->user()->profile_id)
+            ->where('created_at', '>', now()->subDays(30))
+            ->exists();
+
+        return view('settings.migration.index', compact('hasExistingMigration'));
+    }
+
+    public function store(ProfileMigrationStoreRequest $request)
+    {
+        $acct = WebfingerService::rawGet($request->safe()->acct);
+        if (! $acct) {
+            return redirect()->back()->withErrors(['acct' => 'The new account you provided is not responding to our requests.']);
+        }
+        $newAccount = Helpers::profileFetch($acct);
+        if (! $newAccount) {
+            return redirect()->back()->withErrors(['acct' => 'An error occured, please try again later. Code: res-failed-account-fetch']);
+        }
+        $user = $request->user();
+        ProfileAlias::updateOrCreate([
+            'profile_id' => $user->profile_id,
+            'acct' => $request->safe()->acct,
+            'uri' => $acct,
+        ]);
+        ProfileMigration::create([
+            'profile_id' => $request->user()->profile_id,
+            'acct' => $request->safe()->acct,
+            'followers_count' => $request->user()->profile->followers_count,
+            'target_profile_id' => $newAccount['id'],
+        ]);
+        $user->profile->update([
+            'moved_to_profile_id' => $newAccount->id,
+            'indexable' => false,
+        ]);
+        AccountService::del($user->profile_id);
+
+        ProfileMigrationMoveFollowersPipeline::dispatch($user->profile_id, $newAccount->id);
+
+        return redirect()->back()->with(['status' => 'Succesfully migrated account!']);
+    }
+}

+ 76 - 0
app/Http/Requests/ProfileMigrationStoreRequest.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Models\ProfileMigration;
+use App\Services\FetchCacheService;
+use App\Services\WebfingerService;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Validator;
+
+class ProfileMigrationStoreRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     */
+    public function authorize(): bool
+    {
+        if (! $this->user() || $this->user()->status) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
+     */
+    public function rules(): array
+    {
+        return [
+            'acct' => 'required|email',
+            'password' => 'required|current_password',
+        ];
+    }
+
+    public function after(): array
+    {
+        return [
+            function (Validator $validator) {
+                $err = $this->validateNewAccount();
+                if ($err !== 'noerr') {
+                    $validator->errors()->add(
+                        'acct',
+                        $err
+                    );
+                }
+            },
+        ];
+    }
+
+    protected function validateNewAccount()
+    {
+        if (ProfileMigration::whereProfileId($this->user()->profile_id)->where('created_at', '>', now()->subDays(30))->exists()) {
+            return 'Error - You have migrated your account in the past 30 days, you can only perform a migration once per 30 days.';
+        }
+        $acct = WebfingerService::rawGet($this->acct);
+        if (! $acct) {
+            return 'The new account you provided is not responding to our requests.';
+        }
+        $pr = FetchCacheService::getJson($acct);
+        if (! $pr || ! isset($pr['alsoKnownAs'])) {
+            return 'Invalid account lookup response.';
+        }
+        if (! count($pr['alsoKnownAs']) || ! is_array($pr['alsoKnownAs'])) {
+            return 'The new account does not contain an alias to your current account.';
+        }
+        $curAcctUrl = $this->user()->profile->permalink();
+        if (! in_array($curAcctUrl, $pr['alsoKnownAs'])) {
+            return 'The new account does not contain an alias to your current account.';
+        }
+
+        return 'noerr';
+    }
+}

+ 55 - 0
app/Jobs/ProfilePipeline/ProfileMigrationMoveFollowersPipeline.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Jobs\ProfilePipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Follower;
+use App\Profile;
+use App\Services\AccountService;
+
+class ProfileMigrationMoveFollowersPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public $oldPid;
+    public $newPid;
+
+    /**
+     * Create a new job instance.
+     */
+    public function __construct($oldPid, $newPid)
+    {
+        $this->oldPid = $oldPid;
+        $this->newPid = $newPid;
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle(): void
+    {
+        $og = Profile::find($this->oldPid);
+        $ne = Profile::find($this->newPid);
+        if(!$og || !$ne || $og == $ne) {
+            return;
+        }
+        $ne->followers_count = $og->followers_count;
+        $ne->save();
+        $og->followers_count = 0;
+        $og->save();
+        foreach (Follower::whereFollowingId($this->oldPid)->lazyById(200, 'id') as $follower) {
+            try {
+                $follower->following_id = $this->newPid;
+                $follower->save();
+            } catch (Exception $e) {
+                $follower->delete();
+            }
+        }
+        AccountService::del($this->oldPid);
+        AccountService::del($this->newPid);
+    }
+}

+ 2 - 0
app/Models/ProfileAlias.php

@@ -10,6 +10,8 @@ class ProfileAlias extends Model
 {
     use HasFactory;
 
+    protected $guarded = [];
+
     public function profile()
     {
         return $this->belongsTo(Profile::class);

+ 19 - 0
app/Models/ProfileMigration.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use App\Profile;
+
+class ProfileMigration extends Model
+{
+    use HasFactory;
+
+    protected $guarded = [];
+
+    public function profile()
+    {
+        return $this->belongsTo(Profile::class, 'profile_id');
+    }
+}

+ 79 - 0
app/Services/FetchCacheService.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Services;
+
+use App\Util\ActivityPub\Helpers;
+use Illuminate\Http\Client\ConnectionException;
+use Illuminate\Http\Client\RequestException;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Http;
+
+class FetchCacheService
+{
+    const CACHE_KEY = 'pf:fetch_cache_service:getjson:';
+
+    public static function getJson($url, $verifyCheck = true, $ttl = 3600, $allowRedirects = true)
+    {
+        $vc = $verifyCheck ? 'vc1:' : 'vc0:';
+        $ar = $allowRedirects ? 'ar1:' : 'ar0';
+        $key = self::CACHE_KEY.sha1($url).':'.$vc.$ar.$ttl;
+        if (Cache::has($key)) {
+            return false;
+        }
+
+        if ($verifyCheck) {
+            if (! Helpers::validateUrl($url)) {
+                Cache::put($key, 1, $ttl);
+
+                return false;
+            }
+        }
+
+        $headers = [
+            'User-Agent' => '(Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')',
+        ];
+
+        if ($allowRedirects) {
+            $options = [
+                'allow_redirects' => [
+                    'max' => 2,
+                    'strict' => true,
+                ],
+            ];
+        } else {
+            $options = [
+                'allow_redirects' => false,
+            ];
+        }
+        try {
+            $res = Http::withOptions($options)
+                ->retry(3, function (int $attempt, $exception) {
+                    return $attempt * 500;
+                })
+                ->acceptJson()
+                ->withHeaders($headers)
+                ->timeout(40)
+                ->get($url);
+        } catch (RequestException $e) {
+            Cache::put($key, 1, $ttl);
+
+            return false;
+        } catch (ConnectionException $e) {
+            Cache::put($key, 1, $ttl);
+
+            return false;
+        } catch (Exception $e) {
+            Cache::put($key, 1, $ttl);
+
+            return false;
+        }
+
+        if (! $res->ok()) {
+            Cache::put($key, 1, $ttl);
+
+            return false;
+        }
+
+        return $res->json();
+    }
+}

+ 79 - 53
app/Services/WebfingerService.php

@@ -2,69 +2,95 @@
 
 namespace App\Services;
 
-use Cache;
 use App\Profile;
+use App\Util\ActivityPub\Helpers;
 use App\Util\Webfinger\WebfingerUrl;
 use Illuminate\Support\Facades\Http;
-use App\Util\ActivityPub\Helpers;
-use App\Services\AccountService;
 
 class WebfingerService
 {
-	public static function lookup($query, $mastodonMode = false)
-	{
-		return (new self)->run($query, $mastodonMode);
-	}
+    public static function rawGet($url)
+    {
+        $n = WebfingerUrl::get($url);
+        if (! $n) {
+            return false;
+        }
+        $webfinger = FetchCacheService::getJson($n);
+        if (! $webfinger) {
+            return false;
+        }
+
+        if (! isset($webfinger['links']) || ! is_array($webfinger['links']) || empty($webfinger['links'])) {
+            return false;
+        }
+        $link = collect($webfinger['links'])
+            ->filter(function ($link) {
+                return $link &&
+                    isset($link['rel'], $link['type'], $link['href']) &&
+                    $link['rel'] === 'self' &&
+                    in_array($link['type'], ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']);
+            })
+            ->pluck('href')
+            ->first();
+
+        return $link;
+    }
+
+    public static function lookup($query, $mastodonMode = false)
+    {
+        return (new self)->run($query, $mastodonMode);
+    }
+
+    protected function run($query, $mastodonMode)
+    {
+        if ($profile = Profile::whereUsername($query)->first()) {
+            return $mastodonMode ?
+                AccountService::getMastodon($profile->id, true) :
+                AccountService::get($profile->id);
+        }
+        $url = WebfingerUrl::generateWebfingerUrl($query);
+        if (! Helpers::validateUrl($url)) {
+            return [];
+        }
 
-	protected function run($query, $mastodonMode)
-	{
-		if($profile = Profile::whereUsername($query)->first()) {
-			return $mastodonMode ?
-				AccountService::getMastodon($profile->id, true) :
-				AccountService::get($profile->id);
-		}
-		$url = WebfingerUrl::generateWebfingerUrl($query);
-		if(!Helpers::validateUrl($url)) {
-			return [];
-		}
+        try {
+            $res = Http::retry(3, 100)
+                ->acceptJson()
+                ->withHeaders([
+                    'User-Agent' => '(Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')',
+                ])
+                ->timeout(20)
+                ->get($url);
+        } catch (\Illuminate\Http\Client\ConnectionException $e) {
+            return [];
+        }
 
-		try {
-			$res = Http::retry(3, 100)
-				->acceptJson()
-				->withHeaders([
-					'User-Agent' => '(Pixelfed/' . config('pixelfed.version') . '; +' . config('app.url') . ')'
-				])
-				->timeout(20)
-				->get($url);
-		} catch (\Illuminate\Http\Client\ConnectionException $e) {
-			return [];
-		}
+        if (! $res->successful()) {
+            return [];
+        }
 
-		if(!$res->successful()) {
-			return [];
-		}
+        $webfinger = $res->json();
+        if (! isset($webfinger['links']) || ! is_array($webfinger['links']) || empty($webfinger['links'])) {
+            return [];
+        }
 
-		$webfinger = $res->json();
-		if(!isset($webfinger['links']) || !is_array($webfinger['links']) || empty($webfinger['links'])) {
-			return [];
-		}
+        $link = collect($webfinger['links'])
+            ->filter(function ($link) {
+                return $link &&
+                    isset($link['rel'], $link['type'], $link['href']) &&
+                    $link['rel'] === 'self' &&
+                    in_array($link['type'], ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']);
+            })
+            ->pluck('href')
+            ->first();
 
-		$link = collect($webfinger['links'])
-			->filter(function($link) {
-				return $link &&
-					isset($link['rel'], $link['type'], $link['href']) &&
-					$link['rel'] === 'self' &&
-					in_array($link['type'], ['application/activity+json','application/ld+json; profile="https://www.w3.org/ns/activitystreams"']);
-			})
-			->pluck('href')
-			->first();
+        $profile = Helpers::profileFetch($link);
+        if (! $profile) {
+            return;
+        }
 
-		$profile = Helpers::profileFetch($link);
-		if(!$profile) {
-			return;
-		}
-		return $mastodonMode ?
-			AccountService::getMastodon($profile->id, true) :
-			AccountService::get($profile->id);
-	}
+        return $mastodonMode ?
+            AccountService::getMastodon($profile->id, true) :
+            AccountService::get($profile->id);
+    }
 }

+ 76 - 65
app/Transformer/Api/AccountTransformer.php

@@ -2,80 +2,91 @@
 
 namespace App\Transformer\Api;
 
-use Auth;
-use Cache;
 use App\Profile;
+use App\Services\AccountService;
+use App\Services\PronounService;
 use App\User;
 use App\UserSetting;
+use Cache;
 use League\Fractal;
-use App\Services\PronounService;
 
 class AccountTransformer extends Fractal\TransformerAbstract
 {
-	protected $defaultIncludes = [
-		// 'relationship',
-	];
+    protected $defaultIncludes = [
+        // 'relationship',
+    ];
+
+    public function transform(Profile $profile)
+    {
+        if (! $profile) {
+            return [];
+        }
+
+        $adminIds = Cache::remember('pf:admin-ids', 604800, function () {
+            return User::whereIsAdmin(true)->pluck('profile_id')->toArray();
+        });
+
+        $local = $profile->private_key != null;
+        $local = $profile->user_id && $profile->private_key != null;
+        $hideFollowing = false;
+        $hideFollowers = false;
+        if ($local) {
+            $hideFollowing = Cache::remember('pf:acct-trans:hideFollowing:'.$profile->id, 2592000, function () use ($profile) {
+                $settings = UserSetting::whereUserId($profile->user_id)->first();
+                if (! $settings) {
+                    return false;
+                }
+
+                return $settings->show_profile_following_count == false;
+            });
+            $hideFollowers = Cache::remember('pf:acct-trans:hideFollowers:'.$profile->id, 2592000, function () use ($profile) {
+                $settings = UserSetting::whereUserId($profile->user_id)->first();
+                if (! $settings) {
+                    return false;
+                }
 
-	public function transform(Profile $profile)
-	{
-		if(!$profile) {
-			return [];
-		}
+                return $settings->show_profile_follower_count == false;
+            });
+        }
+        $is_admin = ! $local ? false : in_array($profile->id, $adminIds);
+        $acct = $local ? $profile->username : substr($profile->username, 1);
+        $username = $local ? $profile->username : explode('@', $acct)[0];
+        $res = [
+            'id' => (string) $profile->id,
+            'username' => $username,
+            'acct' => $acct,
+            'display_name' => $profile->name,
+            'discoverable' => true,
+            'locked' => (bool) $profile->is_private,
+            'followers_count' => $hideFollowers ? 0 : (int) $profile->followers_count,
+            'following_count' => $hideFollowing ? 0 : (int) $profile->following_count,
+            'statuses_count' => (int) $profile->status_count,
+            'note' => $profile->bio ?? '',
+            'note_text' => $profile->bio ? strip_tags($profile->bio) : null,
+            'url' => $profile->url(),
+            'avatar' => $profile->avatarUrl(),
+            'website' => $profile->website,
+            'local' => (bool) $local,
+            'is_admin' => (bool) $is_admin,
+            'created_at' => $profile->created_at->toJSON(),
+            'header_bg' => $profile->header_bg,
+            'last_fetched_at' => optional($profile->last_fetched_at)->toJSON(),
+            'pronouns' => PronounService::get($profile->id),
+            'location' => $profile->location,
+        ];
 
-		$adminIds = Cache::remember('pf:admin-ids', 604800, function() {
-			return User::whereIsAdmin(true)->pluck('profile_id')->toArray();
-		});
+        if ($profile->moved_to_profile_id) {
+            $mt = AccountService::getMastodon($profile->moved_to_profile_id, true);
+            if ($mt) {
+                $res['moved'] = $mt;
+            }
+        }
 
-		$local = $profile->private_key != null;
-		$local = $profile->user_id && $profile->private_key != null;
-		$hideFollowing = false;
-		$hideFollowers = false;
-		if($local) {
-			$hideFollowing = Cache::remember('pf:acct-trans:hideFollowing:' . $profile->id, 2592000, function() use($profile) {
-				$settings = UserSetting::whereUserId($profile->user_id)->first();
-				if(!$settings) {
-					return false;
-				}
-				return $settings->show_profile_following_count == false;
-			});
-			$hideFollowers = Cache::remember('pf:acct-trans:hideFollowers:' . $profile->id, 2592000, function() use($profile) {
-				$settings = UserSetting::whereUserId($profile->user_id)->first();
-				if(!$settings) {
-					return false;
-				}
-				return $settings->show_profile_follower_count == false;
-			});
-		}
-		$is_admin = !$local ? false : in_array($profile->id, $adminIds);
-		$acct = $local ? $profile->username : substr($profile->username, 1);
-		$username = $local ? $profile->username : explode('@', $acct)[0];
-		return [
-			'id' => (string) $profile->id,
-			'username' => $username,
-			'acct' => $acct,
-			'display_name' => $profile->name,
-			'discoverable' => true,
-			'locked' => (bool) $profile->is_private,
-			'followers_count' => $hideFollowers ? 0 : (int) $profile->followers_count,
-			'following_count' => $hideFollowing ? 0 : (int) $profile->following_count,
-			'statuses_count' => (int) $profile->status_count,
-			'note' => $profile->bio ?? '',
-			'note_text' => $profile->bio ? strip_tags($profile->bio) : null,
-			'url' => $profile->url(),
-			'avatar' => $profile->avatarUrl(),
-			'website' => $profile->website,
-			'local' => (bool) $local,
-			'is_admin' => (bool) $is_admin,
-			'created_at' => $profile->created_at->toJSON(),
-			'header_bg' => $profile->header_bg,
-			'last_fetched_at' => optional($profile->last_fetched_at)->toJSON(),
-			'pronouns' => PronounService::get($profile->id),
-			'location' => $profile->location
-		];
-	}
+        return $res;
+    }
 
-	protected function includeRelationship(Profile $profile)
-	{
-		return $this->item($profile, new RelationshipTransformer());
-	}
+    protected function includeRelationship(Profile $profile)
+    {
+        return $this->item($profile, new RelationshipTransformer());
+    }
 }

+ 13 - 1
app/Util/Webfinger/WebfingerUrl.php

@@ -3,16 +3,28 @@
 namespace App\Util\Webfinger;
 
 use App\Util\Lexer\Nickname;
+use App\Services\InstanceService;
 
 class WebfingerUrl
 {
+    public static function get($url)
+    {
+        $n = Nickname::normalizeProfileUrl($url);
+        if(!$n || !isset($n['domain'], $n['username'])) {
+            return false;
+        }
+        if(in_array($n['domain'], InstanceService::getBannedDomains())) {
+            return false;
+        }
+        return 'https://' . $n['domain'] . '/.well-known/webfinger?resource=acct:' . $n['username'] . '@' . $n['domain'];
+    }
+
     public static function generateWebfingerUrl($url)
     {
         $url = Nickname::normalizeProfileUrl($url);
         $domain = $url['domain'];
         $username = $url['username'];
         $path = "https://{$domain}/.well-known/webfinger?resource=acct:{$username}@{$domain}";
-
         return $path;
     }
 }

+ 31 - 0
database/migrations/2024_03_02_094235_create_profile_migrations_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('profile_migrations', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('profile_id');
+            $table->string('acct')->nullable();
+            $table->unsignedBigInteger('followers_count')->default(0);
+            $table->unsignedBigInteger('target_profile_id')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('profile_migrations');
+    }
+};

+ 36 - 2
resources/assets/components/Profile.vue

@@ -1,7 +1,40 @@
 <template>
     <div class="profile-timeline-component">
         <div v-if="isLoaded" class="container-fluid mt-3">
-            <div class="row">
+            <div v-if="profile && profile.hasOwnProperty('moved') && profile.moved.hasOwnProperty('id') && !showMoved">
+                <div class="row justify-content-center">
+                    <div class="col-12 col-md-6">
+                        <div class="card shadow-none border card-body mt-5 mb-3 ft-std" style="border-radius: 20px;">
+                            <p class="lead font-weight-bold text-center mb-0"><i class="far fa-exclamation-triangle mr-2"></i>This account has indicated their new account is:</p>
+                        </div>
+                        <div class="card shadow-none border" style="border-radius: 20px;">
+                            <div class="card-body ft-std">
+                                <div class="d-flex justify-content-between align-items-center" style="gap: 1rem;">
+                                    <div class="d-flex align-items-center flex-shrink-1" style="gap: 1rem;">
+                                        <img :src="profile.moved.avatar" width="50" height="50" class="rounded-circle">
+                                        <p class="h3 font-weight-light mb-0 text-break">&commat;{{ profile.moved.acct }}</p>
+                                    </div>
+
+                                    <div class="d-flex flex-grow-1 justify-content-end" style="min-width: 200px;">
+                                        <router-link
+                                            :to="`/i/web/profile/${profile.moved.id}`"
+                                            class="btn btn-outline-primary rounded-pill font-weight-bold"
+                                            >View New Account</router-link>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                        <hr>
+                        <p class="lead text-center ft-std">
+                            <a
+                                href="#"
+                                class="btn btn-primary btn-lg rounded-pill font-weight-bold px-5"
+                                @click.prevent="showMoved = true">Proceed</a>
+                        </p>
+                    </div>
+                </div>
+            </div>
+            <div v-else class="row">
                 <div class="col-md-3 d-md-block px-md-3 px-xl-5">
                     <profile-sidebar
                         :profile="profile"
@@ -72,7 +105,8 @@
                 curUser: undefined,
                 tab: "index",
                 profile: undefined,
-                relationship: undefined
+                relationship: undefined,
+                showMoved: false,
             }
         },
 

+ 23 - 0
resources/assets/components/partials/profile/ProfileSidebar.vue

@@ -150,6 +150,19 @@
                     </a>
 				</div>
 
+                <div v-else-if="profile.hasOwnProperty('moved') && profile.moved.id" style="flex-grow: 1;">
+                    <div class="card shadow-none rounded-lg mb-3 bg-danger">
+                        <div class="card-body">
+                            <div class="d-flex align-items-center ft-std text-white mb-2">
+                                <i class="far fa-exclamation-triangle mr-2 text-white"></i>
+                                Account has moved to:
+                            </div>
+                            <p class="mb-0 lead ft-std text-white text-break">
+                                <router-link :to="`/i/web/profile/${profile.moved.id}`" class="btn btn-outline-light btn-block rounded-pill font-weight-bold">&commat;{{truncate(profile.moved.acct)}}</router-link>
+                            </p>
+                        </div>
+                    </div>
+                </div>
 				<div v-else-if="profile.locked" style="flex-grow: 1;">
 					<template v-if="!relationship.following && !relationship.requested">
 						<button
@@ -375,6 +388,16 @@
 				}
 			},
 
+            truncate(str) {
+                if(!str) {
+                    return;
+                }
+                if(str.length > 15) {
+                    return str.slice(0, 15) + '...';
+                }
+                return str;
+            },
+
 			formatCount(val) {
 				return App.util.format.count(val);
 			},

+ 8 - 0
resources/views/settings/home.blade.php

@@ -95,6 +95,14 @@
                 <p class="help-text text-muted small">To move from another account to this one, first you need to create an alias.</p>
             </div>
         </div>
+
+        <div class="form-group row">
+            <label for="aliases" class="col-sm-3 col-form-label font-weight-bold">Account Migrate</label>
+            <div class="col-sm-9" id="aliases">
+                <a class="font-weight-bold" href="/settings/account/migration/manage">Migrate to another account</a>
+                <p class="help-text text-muted small">To redirect this account to a different one (where supported).</p>
+            </div>
+        </div>
 		@if(config_cache('pixelfed.enforce_account_limit'))
 		<div class="pt-3">
 			<p class="font-weight-bold text-muted text-center">Storage Usage</p>

+ 99 - 0
resources/views/settings/migration/index.blade.php

@@ -0,0 +1,99 @@
+@extends('layouts.app')
+
+@section('content')
+@if (session('status'))
+    <div class="alert alert-primary px-3 h6 font-weight-bold text-center">
+        {{ session('status') }}
+    </div>
+@endif
+@if ($errors->any())
+    <div class="alert alert-danger px-3 h6 text-center">
+            @foreach($errors->all() as $error)
+                <p class="font-weight-bold mb-1">{{ $error }}</p>
+            @endforeach
+    </div>
+@endif
+@if (session('error'))
+    <div class="alert alert-danger px-3 h6 text-center">
+        {{ session('error') }}
+    </div>
+@endif
+
+<div class="container">
+  <div class="col-12">
+    <div class="card shadow-none border mt-5">
+      <div class="card-body">
+        <div class="row">
+          <div class="col-12 p-3 p-md-5">
+            <div class="title">
+                <div class="d-flex justify-content-between align-items-center">
+                    <h3 class="font-weight-bold">Account Migration</h3>
+
+                    <a class="font-weight-bold" href="/settings/home">
+                        <i class="far fa-long-arrow-left"></i>
+                        Back to Settings
+                    </a>
+                </div>
+
+                <hr />
+
+                <div class="row">
+                    <div class="col-12">
+                        <p class="lead">If you want to move this account to another account, please read the following carefully.</p>
+                        <ul class="text-danger lead">
+                            <li class="font-weight-bold">Only followers will be transferred; no other information will be moved automatically.</li>
+                            <li>This process will transfer all followers from your existing account to your new account.</li>
+                            <li>A redirect notice will be added to your current account's profile, and it will be removed from search results.</li>
+                            <li>You must set up the new account to link back to your current account before proceeding.</li>
+                            <li>Once the transfer is initiated, there will be a waiting period during which you cannot initiate another transfer.</li>
+                            <li>After the transfer, your current account will be limited in functionality, but you will retain the ability to export data and possibly reactivate the account.</li>
+                        </ul>
+                        <p class="mb-0">For more information on Aliases and Account Migration, visit the <a href="/site/kb/your-profile">Help Center</a>.</p>
+                        <hr>
+
+                        <form method="post" autocomplete="off">
+                            @csrf
+                            <div class="row">
+                                <div class="col-12 col-md-6">
+                                    <div class="form-group">
+                                        <label class="font-weight-bold mb-0">New Account Handle</label>
+                                        <p class="small text-muted">Enter the username@domain of the account you want to move to</p>
+                                        <input
+                                            type="email"
+                                            class="form-control"
+                                            name="acct"
+                                            placeholder="username@domain.tld"
+                                            role="presentation"
+                                            autocomplete="new-user-email"
+                                        />
+                                    </div>
+                                </div>
+                                <div class="col-12 col-md-6">
+                                    <div class="form-group">
+                                        <label class="font-weight-bold mb-0">Account Password</label>
+                                        <p class="small text-muted">For security purposes please enter the password of the current account</p>
+                                        <input
+                                            type="password"
+                                            class="form-control"
+                                            name="password"
+                                            role="presentation"
+                                            placeholder="Your account password"
+                                            autocomplete="new-password"
+                                            />
+                                    </div>
+                                </div>
+                            </div>
+
+                            <button class="btn btn-primary btn-block font-weight-bold btn-lg rounded-pill">Move Followers</button>
+                        </form>
+                    </div>
+                </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+@endsection

+ 5 - 0
routes/web.php

@@ -266,6 +266,11 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
             Route::post('manage', 'ProfileAliasController@store');
             Route::post('manage/delete', 'ProfileAliasController@delete');
         });
+
+        Route::group(['prefix' => 'account/migration', 'middleware' => 'dangerzone'], function() {
+            Route::get('manage', 'ProfileMigrationController@index');
+            Route::post('manage', 'ProfileMigrationController@store');
+        });
     });
 
     Route::group(['prefix' => 'site'], function () {