diff --git a/app/Http/Controllers/ProfileAliasController.php b/app/Http/Controllers/ProfileAliasController.php index 024005a8e..559dcb9a6 100644 --- a/app/Http/Controllers/ProfileAliasController.php +++ b/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!'); } diff --git a/app/Http/Controllers/ProfileMigrationController.php b/app/Http/Controllers/ProfileMigrationController.php new file mode 100644 index 000000000..d9158b9a3 --- /dev/null +++ b/app/Http/Controllers/ProfileMigrationController.php @@ -0,0 +1,62 @@ +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!']); + } +} diff --git a/app/Http/Requests/ProfileMigrationStoreRequest.php b/app/Http/Requests/ProfileMigrationStoreRequest.php new file mode 100644 index 000000000..de9e31b8a --- /dev/null +++ b/app/Http/Requests/ProfileMigrationStoreRequest.php @@ -0,0 +1,76 @@ +user() || $this->user()->status) { + return false; + } + + return true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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'; + } +} diff --git a/app/Jobs/ProfilePipeline/ProfileMigrationMoveFollowersPipeline.php b/app/Jobs/ProfilePipeline/ProfileMigrationMoveFollowersPipeline.php new file mode 100644 index 000000000..f5c311888 --- /dev/null +++ b/app/Jobs/ProfilePipeline/ProfileMigrationMoveFollowersPipeline.php @@ -0,0 +1,55 @@ +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); + } +} diff --git a/app/Models/ProfileAlias.php b/app/Models/ProfileAlias.php index b7a3bdc9c..aef91bebc 100644 --- a/app/Models/ProfileAlias.php +++ b/app/Models/ProfileAlias.php @@ -10,6 +10,8 @@ class ProfileAlias extends Model { use HasFactory; + protected $guarded = []; + public function profile() { return $this->belongsTo(Profile::class); diff --git a/app/Models/ProfileMigration.php b/app/Models/ProfileMigration.php new file mode 100644 index 000000000..a40d52c95 --- /dev/null +++ b/app/Models/ProfileMigration.php @@ -0,0 +1,19 @@ +belongsTo(Profile::class, 'profile_id'); + } +} diff --git a/app/Services/FetchCacheService.php b/app/Services/FetchCacheService.php new file mode 100644 index 000000000..2e23fb009 --- /dev/null +++ b/app/Services/FetchCacheService.php @@ -0,0 +1,79 @@ + '(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(); + } +} diff --git a/app/Services/WebfingerService.php b/app/Services/WebfingerService.php index 385bff023..7340109f5 100644 --- a/app/Services/WebfingerService.php +++ b/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; + } - 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 []; - } + 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(); - 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 []; - } + return $link; + } - if(!$res->successful()) { - return []; - } + public static function lookup($query, $mastodonMode = false) + { + return (new self)->run($query, $mastodonMode); + } - $webfinger = $res->json(); - if(!isset($webfinger['links']) || !is_array($webfinger['links']) || empty($webfinger['links'])) { - 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 []; + } - $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(); + 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 []; + } - $profile = Helpers::profileFetch($link); - if(!$profile) { - return; - } - return $mastodonMode ? - AccountService::getMastodon($profile->id, true) : - AccountService::get($profile->id); - } + if (! $res->successful()) { + 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(); + + $profile = Helpers::profileFetch($link); + if (! $profile) { + return; + } + + return $mastodonMode ? + AccountService::getMastodon($profile->id, true) : + AccountService::get($profile->id); + } } diff --git a/app/Transformer/Api/AccountTransformer.php b/app/Transformer/Api/AccountTransformer.php index 52026eb8e..9411d5a18 100644 --- a/app/Transformer/Api/AccountTransformer.php +++ b/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 []; - } + public function transform(Profile $profile) + { + if (! $profile) { + return []; + } - $adminIds = Cache::remember('pf:admin-ids', 604800, function() { - return User::whereIsAdmin(true)->pluck('profile_id')->toArray(); - }); + $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; - } - 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 - ]; - } + $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; + } - protected function includeRelationship(Profile $profile) - { - return $this->item($profile, new RelationshipTransformer()); - } + 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]; + $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, + ]; + + if ($profile->moved_to_profile_id) { + $mt = AccountService::getMastodon($profile->moved_to_profile_id, true); + if ($mt) { + $res['moved'] = $mt; + } + } + + return $res; + } + + protected function includeRelationship(Profile $profile) + { + return $this->item($profile, new RelationshipTransformer()); + } } diff --git a/app/Util/Webfinger/WebfingerUrl.php b/app/Util/Webfinger/WebfingerUrl.php index 091d7ce14..b51d536d9 100644 --- a/app/Util/Webfinger/WebfingerUrl.php +++ b/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; } } diff --git a/database/migrations/2024_03_02_094235_create_profile_migrations_table.php b/database/migrations/2024_03_02_094235_create_profile_migrations_table.php new file mode 100644 index 000000000..2eafaa43b --- /dev/null +++ b/database/migrations/2024_03_02_094235_create_profile_migrations_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/resources/assets/components/Profile.vue b/resources/assets/components/Profile.vue index 1dc90d0ba..f526109e4 100644 --- a/resources/assets/components/Profile.vue +++ b/resources/assets/components/Profile.vue @@ -1,7 +1,40 @@