Merge pull request #4968 from pixelfed/staging

Add Profile Migrations
This commit is contained in:
daniel 2024-03-05 00:23:35 -07:00 committed by GitHub
commit 712b6d27a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1129 additions and 282 deletions

View File

@ -2,18 +2,18 @@
namespace App\Http\Controllers\Admin;
use Artisan, Cache, DB;
use Illuminate\Http\Request;
use Carbon\Carbon;
use App\{Comment, Like, Media, Page, Profile, Report, Status, User};
use App\Models\InstanceActor;
use App\Http\Controllers\Controller;
use App\Util\Lexer\PrettyNumber;
use App\Models\ConfigCache;
use App\Models\InstanceActor;
use App\Page;
use App\Profile;
use App\Services\AccountService;
use App\Services\ConfigCacheService;
use App\User;
use App\Util\Site\Config;
use Illuminate\Support\Str;
use Artisan;
use Cache;
use DB;
use Illuminate\Http\Request;
trait AdminSettingsController
{
@ -21,7 +21,7 @@ trait AdminSettingsController
{
$cloud_storage = ConfigCacheService::get('pixelfed.cloud_storage');
$cloud_disk = config('filesystems.cloud');
$cloud_ready = !empty(config('filesystems.disks.' . $cloud_disk . '.key')) && !empty(config('filesystems.disks.' . $cloud_disk . '.secret'));
$cloud_ready = ! empty(config('filesystems.disks.'.$cloud_disk.'.key')) && ! empty(config('filesystems.disks.'.$cloud_disk.'.secret'));
$types = explode(',', ConfigCacheService::get('pixelfed.media_types'));
$rules = ConfigCacheService::get('app.rules') ? json_decode(ConfigCacheService::get('app.rules'), true) : null;
$jpeg = in_array('image/jpg', $types) || in_array('image/jpeg', $types);
@ -35,6 +35,7 @@ trait AdminSettingsController
$openReg = (bool) config_cache('pixelfed.open_registration');
$curOnboarding = (bool) config_cache('instance.curated_registration.enabled');
$regState = $openReg ? 'open' : ($curOnboarding ? 'filtered' : 'closed');
$accountMigration = (bool) config_cache('federation.migration');
return view('admin.settings.home', compact(
'jpeg',
@ -48,7 +49,8 @@ trait AdminSettingsController
'cloud_ready',
'availableAdmins',
'currentAdmin',
'regState'
'regState',
'accountMigration'
));
}
@ -67,41 +69,42 @@ trait AdminSettingsController
'type_mp4' => 'nullable',
'type_webp' => 'nullable',
'admin_account_id' => 'nullable',
'regs' => 'required|in:open,filtered,closed'
'regs' => 'required|in:open,filtered,closed',
'account_migration' => 'nullable',
]);
$orb = false;
$cob = false;
switch($request->input('regs')) {
switch ($request->input('regs')) {
case 'open':
$orb = true;
$cob = false;
break;
break;
case 'filtered':
$orb = false;
$cob = true;
break;
break;
case 'closed':
$orb = false;
$cob = false;
break;
break;
}
ConfigCacheService::put('pixelfed.open_registration', (bool) $orb);
ConfigCacheService::put('instance.curated_registration.enabled', (bool) $cob);
if($request->filled('admin_account_id')) {
if ($request->filled('admin_account_id')) {
ConfigCacheService::put('instance.admin.pid', $request->admin_account_id);
Cache::forget('api:v1:instance-data:contact');
Cache::forget('api:v1:instance-data-response-v1');
}
if($request->filled('rule_delete')) {
if ($request->filled('rule_delete')) {
$index = (int) $request->input('rule_delete');
$rules = ConfigCacheService::get('app.rules');
$json = json_decode($rules, true);
if(!$rules || empty($json)) {
if (! $rules || empty($json)) {
return;
}
unset($json[$index]);
@ -109,6 +112,7 @@ trait AdminSettingsController
ConfigCacheService::put('app.rules', $json);
Cache::forget('api:v1:instance-data:rules');
Cache::forget('api:v1:instance-data-response-v1');
return 200;
}
@ -124,8 +128,8 @@ trait AdminSettingsController
];
foreach ($mimes as $key => $value) {
if($request->input($key) == 'on') {
if(!in_array($value, $media_types)) {
if ($request->input($key) == 'on') {
if (! in_array($value, $media_types)) {
array_push($media_types, $value);
}
} else {
@ -133,7 +137,7 @@ trait AdminSettingsController
}
}
if($media_types !== $media_types_original) {
if ($media_types !== $media_types_original) {
ConfigCacheService::put('pixelfed.media_types', implode(',', array_unique($media_types)));
}
@ -147,15 +151,15 @@ trait AdminSettingsController
'account_limit' => 'pixelfed.max_account_size',
'custom_css' => 'uikit.custom.css',
'custom_js' => 'uikit.custom.js',
'about_title' => 'about.title'
'about_title' => 'about.title',
];
foreach ($keys as $key => $value) {
$cc = ConfigCache::whereK($value)->first();
$val = $request->input($key);
if($cc && $cc->v != $val) {
if ($cc && $cc->v != $val) {
ConfigCacheService::put($value, $val);
} else if(!empty($val)) {
} elseif (! empty($val)) {
ConfigCacheService::put($value, $val);
}
}
@ -175,33 +179,34 @@ trait AdminSettingsController
'account_autofollow' => 'account.autofollow',
'show_directory' => 'instance.landing.show_directory',
'show_explore_feed' => 'instance.landing.show_explore',
'account_migration' => 'federation.migration',
];
foreach ($bools as $key => $value) {
$active = $request->input($key) == 'on';
if($key == 'activitypub' && $active && !InstanceActor::exists()) {
if ($key == 'activitypub' && $active && ! InstanceActor::exists()) {
Artisan::call('instance:actor');
}
if( $key == 'mobile_apis' &&
if ($key == 'mobile_apis' &&
$active &&
!file_exists(storage_path('oauth-public.key')) &&
!file_exists(storage_path('oauth-private.key'))
! file_exists(storage_path('oauth-public.key')) &&
! file_exists(storage_path('oauth-private.key'))
) {
Artisan::call('passport:keys');
Artisan::call('route:cache');
}
if(config_cache($value) !== $active) {
if (config_cache($value) !== $active) {
ConfigCacheService::put($value, (bool) $active);
}
}
if($request->filled('new_rule')) {
if ($request->filled('new_rule')) {
$rules = ConfigCacheService::get('app.rules');
$val = $request->input('new_rule');
if(!$rules) {
if (! $rules) {
ConfigCacheService::put('app.rules', json_encode([$val]));
} else {
$json = json_decode($rules, true);
@ -212,13 +217,13 @@ trait AdminSettingsController
Cache::forget('api:v1:instance-data-response-v1');
}
if($request->filled('account_autofollow_usernames')) {
if ($request->filled('account_autofollow_usernames')) {
$usernames = explode(',', $request->input('account_autofollow_usernames'));
$names = [];
foreach($usernames as $n) {
foreach ($usernames as $n) {
$p = Profile::whereUsername($n)->first();
if(!$p) {
if (! $p) {
continue;
}
array_push($names, $p->username);
@ -236,6 +241,7 @@ trait AdminSettingsController
{
$path = storage_path('app/'.config('app.name'));
$files = is_dir($path) ? new \DirectoryIterator($path) : [];
return view('admin.settings.backups', compact('files'));
}
@ -247,6 +253,7 @@ trait AdminSettingsController
public function settingsStorage(Request $request)
{
$storage = [];
return view('admin.settings.storage', compact('storage'));
}
@ -258,6 +265,7 @@ trait AdminSettingsController
public function settingsPages(Request $request)
{
$pages = Page::orderByDesc('updated_at')->paginate(10);
return view('admin.pages.home', compact('pages'));
}
@ -275,30 +283,31 @@ trait AdminSettingsController
];
switch (config('database.default')) {
case 'pgsql':
$exp = DB::raw('select version();');
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$sys['database'] = [
'name' => 'Postgres',
'version' => explode(' ', DB::select($expQuery)[0]->version)[1]
];
break;
$exp = DB::raw('select version();');
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$sys['database'] = [
'name' => 'Postgres',
'version' => explode(' ', DB::select($expQuery)[0]->version)[1],
];
break;
case 'mysql':
$exp = DB::raw('select version()');
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$sys['database'] = [
'name' => 'MySQL',
'version' => DB::select($expQuery)[0]->{'version()'}
];
break;
$exp = DB::raw('select version()');
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$sys['database'] = [
'name' => 'MySQL',
'version' => DB::select($expQuery)[0]->{'version()'},
];
break;
default:
$sys['database'] = [
'name' => 'Unknown',
'version' => '?'
];
break;
$sys['database'] = [
'name' => 'Unknown',
'version' => '?',
];
break;
}
return view('admin.settings.system', compact('sys'));
}
}

View File

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

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileMigrationStoreRequest;
use App\Jobs\ProfilePipeline\ProfileMigrationDeliverMoveActivityPipeline;
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;
use Illuminate\Support\Facades\Bus;
class ProfileMigrationController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index(Request $request)
{
abort_if((bool) config_cache('federation.activitypub.enabled') === false, 404);
if ((bool) config_cache('federation.migration') === false) {
return redirect(route('help.account-migration'));
}
$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)
{
abort_if((bool) config_cache('federation.activitypub.enabled') === false, 404);
$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,
]);
$migration = 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);
Bus::batch([
new ProfileMigrationDeliverMoveActivityPipeline($migration, $user->profile, $newAccount),
new ProfileMigrationMoveFollowersPipeline($user->profile_id, $newAccount->id),
])->onQueue('follow')->dispatch();
return redirect()->back()->with(['status' => 'Succesfully migrated account!']);
}
}

View File

@ -0,0 +1,80 @@
<?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 ((bool) config_cache('federation.activitypub.enabled') === false ||
(bool) config_cache('federation.migration') === false) {
return false;
}
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';
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Jobs\ProfilePipeline;
use App\Transformer\ActivityPub\Verb\Move;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
class ProfileMigrationDeliverMoveActivityPipeline implements ShouldBeUniqueUntilProcessing, ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $migration;
public $oldAccount;
public $newAccount;
public $timeout = 1400;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var int
*/
public $uniqueFor = 3600;
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'profile:migration:deliver-move-followers:id:'.$this->migration->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping('profile:migration:deliver-move-followers:id:'.$this->migration->id))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($migration, $oldAccount, $newAccount)
{
$this->migration = $migration;
$this->oldAccount = $oldAccount;
$this->newAccount = $newAccount;
}
/**
* Execute the job.
*/
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
$migration = $this->migration;
$profile = $this->oldAccount;
$newAccount = $this->newAccount;
if ($profile->domain || ! $profile->private_key) {
return;
}
$audience = $profile->getAudienceInbox();
$activitypubObject = new Move();
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($migration, $activitypubObject);
$activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$requests = function ($audience) use ($client, $activity, $profile, $payload, $userAgent) {
foreach ($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
},
]);
$promise = $pool->promise();
$promise->wait();
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Jobs\ProfilePipeline;
use App\Follower;
use App\Profile;
use App\Services\AccountService;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class ProfileMigrationMoveFollowersPipeline implements ShouldBeUniqueUntilProcessing, ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $oldPid;
public $newPid;
public $timeout = 1400;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var int
*/
public $uniqueFor = 3600;
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'profile:migration:move-followers:oldpid-'.$this->oldPid.':newpid-'.$this->newPid;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping('profile:migration:move-followers:oldpid-'.$this->oldPid.':newpid-'.$this->newPid))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($oldPid, $newPid)
{
$this->oldPid = $oldPid;
$this->newPid = $newPid;
}
/**
* Execute the job.
*/
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
$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);
}
}

View File

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

View File

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

View File

@ -2,127 +2,129 @@
namespace App\Services;
use Cache;
use Config;
use App\Models\ConfigCache as ConfigCacheModel;
use Cache;
class ConfigCacheService
{
const CACHE_KEY = 'config_cache:_v0-key:';
const CACHE_KEY = 'config_cache:_v0-key:';
public static function get($key)
{
$cacheKey = self::CACHE_KEY . $key;
$ttl = now()->addHours(12);
if(!config('instance.enable_cc')) {
return config($key);
}
public static function get($key)
{
$cacheKey = self::CACHE_KEY.$key;
$ttl = now()->addHours(12);
if (! config('instance.enable_cc')) {
return config($key);
}
return Cache::remember($cacheKey, $ttl, function() use($key) {
return Cache::remember($cacheKey, $ttl, function () use ($key) {
$allowed = [
'app.name',
'app.short_description',
'app.description',
'app.rules',
$allowed = [
'app.name',
'app.short_description',
'app.description',
'app.rules',
'pixelfed.max_photo_size',
'pixelfed.max_album_length',
'pixelfed.image_quality',
'pixelfed.media_types',
'pixelfed.max_photo_size',
'pixelfed.max_album_length',
'pixelfed.image_quality',
'pixelfed.media_types',
'pixelfed.open_registration',
'federation.activitypub.enabled',
'instance.stories.enabled',
'pixelfed.oauth_enabled',
'pixelfed.import.instagram.enabled',
'pixelfed.bouncer.enabled',
'pixelfed.open_registration',
'federation.activitypub.enabled',
'instance.stories.enabled',
'pixelfed.oauth_enabled',
'pixelfed.import.instagram.enabled',
'pixelfed.bouncer.enabled',
'pixelfed.enforce_email_verification',
'pixelfed.max_account_size',
'pixelfed.enforce_account_limit',
'pixelfed.enforce_email_verification',
'pixelfed.max_account_size',
'pixelfed.enforce_account_limit',
'uikit.custom.css',
'uikit.custom.js',
'uikit.show_custom.css',
'uikit.show_custom.js',
'about.title',
'uikit.custom.css',
'uikit.custom.js',
'uikit.show_custom.css',
'uikit.show_custom.js',
'about.title',
'pixelfed.cloud_storage',
'pixelfed.cloud_storage',
'account.autofollow',
'account.autofollow_usernames',
'config.discover.features',
'account.autofollow',
'account.autofollow_usernames',
'config.discover.features',
'instance.has_legal_notice',
'instance.avatar.local_to_cloud',
'instance.has_legal_notice',
'instance.avatar.local_to_cloud',
'pixelfed.directory',
'app.banner_image',
'pixelfed.directory.submission-key',
'pixelfed.directory.submission-ts',
'pixelfed.directory.has_submitted',
'pixelfed.directory.latest_response',
'pixelfed.directory.is_synced',
'pixelfed.directory.testimonials',
'pixelfed.directory',
'app.banner_image',
'pixelfed.directory.submission-key',
'pixelfed.directory.submission-ts',
'pixelfed.directory.has_submitted',
'pixelfed.directory.latest_response',
'pixelfed.directory.is_synced',
'pixelfed.directory.testimonials',
'instance.landing.show_directory',
'instance.landing.show_explore',
'instance.admin.pid',
'instance.banner.blurhash',
'instance.landing.show_directory',
'instance.landing.show_explore',
'instance.admin.pid',
'instance.banner.blurhash',
'autospam.nlp.enabled',
'autospam.nlp.enabled',
'instance.curated_registration.enabled',
// 'system.user_mode'
];
'instance.curated_registration.enabled',
if(!config('instance.enable_cc')) {
return config($key);
}
'federation.migration',
// 'system.user_mode'
];
if(!in_array($key, $allowed)) {
return config($key);
}
if (! config('instance.enable_cc')) {
return config($key);
}
$v = config($key);
$c = ConfigCacheModel::where('k', $key)->first();
if (! in_array($key, $allowed)) {
return config($key);
}
if($c) {
return $c->v ?? config($key);
}
$v = config($key);
$c = ConfigCacheModel::where('k', $key)->first();
if(!$v) {
return;
}
if ($c) {
return $c->v ?? config($key);
}
$cc = new ConfigCacheModel;
$cc->k = $key;
$cc->v = $v;
$cc->save();
if (! $v) {
return;
}
return $v;
});
}
$cc = new ConfigCacheModel;
$cc->k = $key;
$cc->v = $v;
$cc->save();
public static function put($key, $val)
{
$exists = ConfigCacheModel::whereK($key)->first();
return $v;
});
}
if($exists) {
$exists->v = $val;
$exists->save();
Cache::put(self::CACHE_KEY . $key, $val, now()->addHours(12));
return self::get($key);
}
public static function put($key, $val)
{
$exists = ConfigCacheModel::whereK($key)->first();
$cc = new ConfigCacheModel;
$cc->k = $key;
$cc->v = $val;
$cc->save();
if ($exists) {
$exists->v = $val;
$exists->save();
Cache::put(self::CACHE_KEY.$key, $val, now()->addHours(12));
Cache::put(self::CACHE_KEY . $key, $val, now()->addHours(12));
return self::get($key);
}
return self::get($key);
}
$cc = new ConfigCacheModel;
$cc->k = $key;
$cc->v = $val;
$cc->save();
Cache::put(self::CACHE_KEY.$key, $val, now()->addHours(12));
return self::get($key);
}
}

View File

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

View File

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

View File

@ -0,0 +1,26 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use App\Models\ProfileMigration;
use League\Fractal;
class Move extends Fractal\TransformerAbstract
{
public function transform(ProfileMigration $migration)
{
$objUrl = $migration->target->permalink();
$id = $migration->target->permalink('#moves/'.$migration->id);
$to = $migration->target->permalink('/followers');
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $id,
'actor' => $objUrl,
'type' => 'Move',
'object' => $objUrl,
'target' => $migration->profile->permalink(),
'to' => $to,
];
}
}

View File

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

View File

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

View File

@ -57,4 +57,6 @@ return [
// max size in bytes, default is 2mb
'max_size' => env('CUSTOM_EMOJI_MAX_SIZE', 2000000),
],
'migration' => env('PF_ACCT_MIGRATION_ENABLED', true),
];

View File

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

View File

@ -1 +1 @@
(()=>{"use strict";var e,r,n,o={},t={};function a(e){var r=t[e];if(void 0!==r)return r.exports;var n=t[e]={id:e,loaded:!1,exports:{}};return o[e].call(n.exports,n,n.exports,a),n.loaded=!0,n.exports}a.m=o,e=[],a.O=(r,n,o,t)=>{if(!n){var c=1/0;for(f=0;f<e.length;f++){for(var[n,o,t]=e[f],d=!0,i=0;i<n.length;i++)(!1&t||c>=t)&&Object.keys(a.O).every((e=>a.O[e](n[i])))?n.splice(i--,1):(d=!1,t<c&&(c=t));if(d){e.splice(f--,1);var s=o();void 0!==s&&(r=s)}}return r}t=t||0;for(var f=e.length;f>0&&e[f-1][2]>t;f--)e[f]=e[f-1];e[f]=[n,o,t]},a.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return a.d(r,{a:r}),r},a.d=(e,r)=>{for(var n in r)a.o(r,n)&&!a.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},a.f={},a.e=e=>Promise.all(Object.keys(a.f).reduce(((r,n)=>(a.f[n](e,r),r)),[])),a.u=e=>"js/"+{1179:"daci.chunk",1240:"discover~myhashtags.chunk",1645:"profile~following.bundle",2156:"dms.chunk",2966:"discover~hashtag.bundle",3688:"discover~serverfeed.chunk",4951:"home.chunk",6250:"discover~settings.chunk",6535:"discover.chunk",6740:"discover~memories.chunk",7399:"dms~message.chunk",7413:"error404.bundle",7521:"discover~findfriends.chunk",7744:"notifications.chunk",8087:"profile.chunk",8119:"i18n.bundle",8408:"post.chunk",8977:"profile~followers.bundle",9124:"compose.chunk",9919:"changelog.bundle"}[e]+"."+{1179:"34dc7bad3a0792cc",1240:"8886fc0d4736d819",1645:"7ca7cfa5aaae75e2",2156:"2b55effc0e8ba89f",2966:"a0f00fc7df1f313c",3688:"262bf7e3bce843c3",4951:"264eeb47bfac56c1",6250:"65d6f3cbe5323ed4",6535:"c2229e1d15bd3ada",6740:"37e0c325f900e163",7399:"976f7edaa6f71137",7413:"b397483e3991ab20",7521:"b1858bea66d9723b",7744:"0c5151643e4534aa",8087:"7a6c846c4cb3cfd4",8119:"93a02e275ac1a708",8408:"5ff16664f9adb901",8977:"5d796e79f32d066c",9124:"ffae318db42f1072",9919:"bf44edbbfa14bd53"}[e]+".js",a.miniCssF=e=>({2305:"css/portfolio",2540:"css/landing",3364:"css/admin",6952:"css/appdark",8252:"css/app",8759:"css/spa"}[e]+".css"),a.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),a.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},n="pixelfed:",a.l=(e,o,t,c)=>{if(r[e])r[e].push(o);else{var d,i;if(void 0!==t)for(var s=document.getElementsByTagName("script"),f=0;f<s.length;f++){var l=s[f];if(l.getAttribute("src")==e||l.getAttribute("data-webpack")==n+t){d=l;break}}d||(i=!0,(d=document.createElement("script")).charset="utf-8",d.timeout=120,a.nc&&d.setAttribute("nonce",a.nc),d.setAttribute("data-webpack",n+t),d.src=e),r[e]=[o];var u=(n,o)=>{d.onerror=d.onload=null,clearTimeout(b);var t=r[e];if(delete r[e],d.parentNode&&d.parentNode.removeChild(d),t&&t.forEach((e=>e(o))),n)return n(o)},b=setTimeout(u.bind(null,void 0,{type:"timeout",target:d}),12e4);d.onerror=u.bind(null,d.onerror),d.onload=u.bind(null,d.onload),i&&document.head.appendChild(d)}},a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),a.p="/",(()=>{var e={461:0,6952:0,8252:0,2305:0,3364:0,2540:0,8759:0};a.f.j=(r,n)=>{var o=a.o(e,r)?e[r]:void 0;if(0!==o)if(o)n.push(o[2]);else if(/^((69|82)52|2305|2540|3364|461|8759)$/.test(r))e[r]=0;else{var t=new Promise(((n,t)=>o=e[r]=[n,t]));n.push(o[2]=t);var c=a.p+a.u(r),d=new Error;a.l(c,(n=>{if(a.o(e,r)&&(0!==(o=e[r])&&(e[r]=void 0),o)){var t=n&&("load"===n.type?"missing":n.type),c=n&&n.target&&n.target.src;d.message="Loading chunk "+r+" failed.\n("+t+": "+c+")",d.name="ChunkLoadError",d.type=t,d.request=c,o[1](d)}}),"chunk-"+r,r)}},a.O.j=r=>0===e[r];var r=(r,n)=>{var o,t,[c,d,i]=n,s=0;if(c.some((r=>0!==e[r]))){for(o in d)a.o(d,o)&&(a.m[o]=d[o]);if(i)var f=i(a)}for(r&&r(n);s<c.length;s++)t=c[s],a.o(e,t)&&e[t]&&e[t][0](),e[t]=0;return a.O(f)},n=self.webpackChunkpixelfed=self.webpackChunkpixelfed||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))})(),a.nc=void 0})();
(()=>{"use strict";var e,r,n,o={},t={};function a(e){var r=t[e];if(void 0!==r)return r.exports;var n=t[e]={id:e,loaded:!1,exports:{}};return o[e].call(n.exports,n,n.exports,a),n.loaded=!0,n.exports}a.m=o,e=[],a.O=(r,n,o,t)=>{if(!n){var d=1/0;for(f=0;f<e.length;f++){for(var[n,o,t]=e[f],i=!0,c=0;c<n.length;c++)(!1&t||d>=t)&&Object.keys(a.O).every((e=>a.O[e](n[c])))?n.splice(c--,1):(i=!1,t<d&&(d=t));if(i){e.splice(f--,1);var s=o();void 0!==s&&(r=s)}}return r}t=t||0;for(var f=e.length;f>0&&e[f-1][2]>t;f--)e[f]=e[f-1];e[f]=[n,o,t]},a.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return a.d(r,{a:r}),r},a.d=(e,r)=>{for(var n in r)a.o(r,n)&&!a.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},a.f={},a.e=e=>Promise.all(Object.keys(a.f).reduce(((r,n)=>(a.f[n](e,r),r)),[])),a.u=e=>"js/"+{1179:"daci.chunk",1240:"discover~myhashtags.chunk",1645:"profile~following.bundle",2156:"dms.chunk",2966:"discover~hashtag.bundle",3688:"discover~serverfeed.chunk",4951:"home.chunk",6250:"discover~settings.chunk",6535:"discover.chunk",6740:"discover~memories.chunk",7399:"dms~message.chunk",7413:"error404.bundle",7521:"discover~findfriends.chunk",7744:"notifications.chunk",8087:"profile.chunk",8119:"i18n.bundle",8408:"post.chunk",8977:"profile~followers.bundle",9124:"compose.chunk",9919:"changelog.bundle"}[e]+"."+{1179:"34dc7bad3a0792cc",1240:"8886fc0d4736d819",1645:"7ca7cfa5aaae75e2",2156:"2b55effc0e8ba89f",2966:"a0f00fc7df1f313c",3688:"262bf7e3bce843c3",4951:"264eeb47bfac56c1",6250:"65d6f3cbe5323ed4",6535:"c2229e1d15bd3ada",6740:"37e0c325f900e163",7399:"976f7edaa6f71137",7413:"b397483e3991ab20",7521:"b1858bea66d9723b",7744:"0c5151643e4534aa",8087:"f74967e7910990ca",8119:"93a02e275ac1a708",8408:"5ff16664f9adb901",8977:"5d796e79f32d066c",9124:"ffae318db42f1072",9919:"bf44edbbfa14bd53"}[e]+".js",a.miniCssF=e=>({2305:"css/portfolio",2540:"css/landing",3364:"css/admin",6952:"css/appdark",8252:"css/app",8759:"css/spa"}[e]+".css"),a.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),a.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},n="pixelfed:",a.l=(e,o,t,d)=>{if(r[e])r[e].push(o);else{var i,c;if(void 0!==t)for(var s=document.getElementsByTagName("script"),f=0;f<s.length;f++){var l=s[f];if(l.getAttribute("src")==e||l.getAttribute("data-webpack")==n+t){i=l;break}}i||(c=!0,(i=document.createElement("script")).charset="utf-8",i.timeout=120,a.nc&&i.setAttribute("nonce",a.nc),i.setAttribute("data-webpack",n+t),i.src=e),r[e]=[o];var u=(n,o)=>{i.onerror=i.onload=null,clearTimeout(b);var t=r[e];if(delete r[e],i.parentNode&&i.parentNode.removeChild(i),t&&t.forEach((e=>e(o))),n)return n(o)},b=setTimeout(u.bind(null,void 0,{type:"timeout",target:i}),12e4);i.onerror=u.bind(null,i.onerror),i.onload=u.bind(null,i.onload),c&&document.head.appendChild(i)}},a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),a.p="/",(()=>{var e={461:0,6952:0,8252:0,2305:0,3364:0,2540:0,8759:0};a.f.j=(r,n)=>{var o=a.o(e,r)?e[r]:void 0;if(0!==o)if(o)n.push(o[2]);else if(/^((69|82)52|2305|2540|3364|461|8759)$/.test(r))e[r]=0;else{var t=new Promise(((n,t)=>o=e[r]=[n,t]));n.push(o[2]=t);var d=a.p+a.u(r),i=new Error;a.l(d,(n=>{if(a.o(e,r)&&(0!==(o=e[r])&&(e[r]=void 0),o)){var t=n&&("load"===n.type?"missing":n.type),d=n&&n.target&&n.target.src;i.message="Loading chunk "+r+" failed.\n("+t+": "+d+")",i.name="ChunkLoadError",i.type=t,i.request=d,o[1](i)}}),"chunk-"+r,r)}},a.O.j=r=>0===e[r];var r=(r,n)=>{var o,t,[d,i,c]=n,s=0;if(d.some((r=>0!==e[r]))){for(o in i)a.o(i,o)&&(a.m[o]=i[o]);if(c)var f=c(a)}for(r&&r(n);s<d.length;s++)t=d[s],a.o(e,t)&&e[t]&&e[t][0](),e[t]=0;return a.O(f)},n=self.webpackChunkpixelfed=self.webpackChunkpixelfed||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))})(),a.nc=void 0})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -24,11 +24,11 @@
"/js/admin_invite.js": "/js/admin_invite.js?id=0a0036f59cfb186f7698207ae432365b",
"/js/landing.js": "/js/landing.js?id=753a52aacb8bb884f50ed3ae9ed99a38",
"/js/remote_auth.js": "/js/remote_auth.js?id=37e5bdf3bc1896eee063db7a186b9876",
"/js/manifest.js": "/js/manifest.js?id=18dc7db8eec69f0dad0dbab5fd8114d6",
"/js/manifest.js": "/js/manifest.js?id=566b4c6c967184a821d65a3b456f0d5e",
"/js/home.chunk.264eeb47bfac56c1.js": "/js/home.chunk.264eeb47bfac56c1.js?id=c5704eda3f241103f1ed1fa6fa4cefad",
"/js/compose.chunk.ffae318db42f1072.js": "/js/compose.chunk.ffae318db42f1072.js?id=b19c7fb0ba0fb6f2c5b94b7c550043b9",
"/js/post.chunk.5ff16664f9adb901.js": "/js/post.chunk.5ff16664f9adb901.js?id=ec6e4751696de3e6df205277b67bcff3",
"/js/profile.chunk.7a6c846c4cb3cfd4.js": "/js/profile.chunk.7a6c846c4cb3cfd4.js?id=76cfdfa0b4f72e92efe6154086f3b153",
"/js/profile.chunk.f74967e7910990ca.js": "/js/profile.chunk.f74967e7910990ca.js?id=9b7dc3907736376f18db109b9d70c0fa",
"/js/discover~memories.chunk.37e0c325f900e163.js": "/js/discover~memories.chunk.37e0c325f900e163.js?id=02137ba179f0f2f3819597f262f423b8",
"/js/discover~myhashtags.chunk.8886fc0d4736d819.js": "/js/discover~myhashtags.chunk.8886fc0d4736d819.js?id=0397f095e24b2bbdffd84be14bb9d8c4",
"/js/daci.chunk.34dc7bad3a0792cc.js": "/js/daci.chunk.34dc7bad3a0792cc.js?id=53da4c2b40ecc1164592bd0f66767284",

View File

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

View File

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

View File

@ -103,6 +103,16 @@
</div>
<p class="mb-4 small">ActivityPub federation, compatible with Pixelfed, Mastodon and other projects.</p>
<div class="custom-control custom-checkbox mt-2">
<input type="checkbox" name="account_migration" class="custom-control-input" id="ap_mig" {{(bool)config_cache('federation.migration') ? 'checked' : ''}} {{(bool) config_cache('federation.activitypub.enabled') ? '' : 'disabled="disabled"'}}>
<label class="custom-control-label font-weight-bold" for="ap_mig">Account Migration</label>
</div>
@if((bool) config_cache('federation.activitypub.enabled'))
<p class="mb-4 small">Allow local accounts to migrate to other local or remote accounts.</p>
@else
<p class="mb-4 small text-muted"><strong>ActivityPub Required</strong> Allow local accounts to migrate to other local or remote accounts.</p>
@endif
{{-- <div class="custom-control custom-checkbox mt-2">
<input type="checkbox" name="open_registration" class="custom-control-input" id="openReg" {{config_cache('pixelfed.open_registration') ? 'checked' : ''}}>
<label class="custom-control-label font-weight-bold" for="openReg">Open Registrations</label>

View File

@ -88,6 +88,7 @@
</div>
</div>
@if((bool) config_cache('federation.activitypub.enabled'))
<div class="form-group row">
<label for="aliases" class="col-sm-3 col-form-label font-weight-bold">Account Aliases</label>
<div class="col-sm-9" id="aliases">
@ -95,6 +96,17 @@
<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>
@if((bool) config_cache('federation.migration'))
<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>
@endif
@endif
@if(config_cache('pixelfed.enforce_account_limit'))
<div class="pt-3">
<p class="font-weight-bold text-muted text-center">Storage Usage</p>

View File

@ -0,0 +1,108 @@
@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>
<div>
@if($hasExistingMigration)
<div class="row">
<div class="col-12 mt-5">
<p class="lead text-center">You have migrated your account already.</p>
<p>You can only migrate your account once per 30 days. If you want to migrate your followers back to this account, follow this process in reverse.</p>
</div>
</div>
@else
<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>
@endif
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,19 @@
@extends('site.help.partial.template', ['breadcrumb'=>'Account Migration'])
@section('section')
<div class="title">
<h3 class="font-weight-bold">Account Migration</h3>
</div>
<hr>
@if((bool) config_cache('federation.migration') === false)
<div class="alert alert-danger">
<p class="font-weight-bold mb-0">Account Migration is not available on this server.</p>
</div>
@endif
<p class="lead">Account Migration is a feature that allows users to move their account followers from one Pixelfed instance (server) to another.</p>
<p class="lead">This can be useful if a user wants to switch to a different instance due to preferences for its community, policies, or features.</p>
@endsection

View File

@ -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 () {
@ -309,6 +314,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::view('parental-controls', 'site.help.parental-controls');
Route::view('email-confirmation-issues', 'site.help.email-confirmation-issues')->name('help.email-confirmation-issues');
Route::view('curated-onboarding', 'site.help.curated-onboarding')->name('help.curated-onboarding');
Route::view('account-migration', 'site.help.account-migration')->name('help.account-migration');
});
Route::get('newsroom/{year}/{month}/{slug}', 'NewsroomController@show');
Route::get('newsroom/archive', 'NewsroomController@archive');