diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d6c00a4..df79210a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,38 @@ # Release Notes -## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.8...dev) +## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.9...dev) + +### Added +- Resilient Media Storage ([#4665](https://github.com/pixelfed/pixelfed/pull/4665)) ([fb1deb6](https://github.com/pixelfed/pixelfed/commit/fb1deb6)) + +### Federation +- Update Privacy Settings, add support for Mastodon `indexable` search flag ([fc24630e](https://github.com/pixelfed/pixelfed/commit/fc24630e)) +- Update AP Helpers, consume actor `indexable` attribute ([fbdcdd9d](https://github.com/pixelfed/pixelfed/commit/fbdcdd9d)) +- ([](https://github.com/pixelfed/pixelfed/commit/)) + +### Updates +- Update FollowerService, add forget method to RelationshipService call to reduce load when mass purging ([347e4f59](https://github.com/pixelfed/pixelfed/commit/347e4f59)) +- Update FollowServiceWarmCache, improve handling larger following/follower lists ([61a6d904](https://github.com/pixelfed/pixelfed/commit/61a6d904)) +- Update StoryApiV1Controller, add viewers route to view story viewers ([941736ce](https://github.com/pixelfed/pixelfed/commit/941736ce)) +- Update NotificationService, improve cache warming query ([2496386d](https://github.com/pixelfed/pixelfed/commit/2496386d)) +- Update StatusService, hydrate accounts on request instead of caching them along with status objects ([223661ec](https://github.com/pixelfed/pixelfed/commit/223661ec)) +- Update profile embed, fix resize ([dc23c21d](https://github.com/pixelfed/pixelfed/commit/dc23c21d)) +- Update Status model, improve thumb logic ([d969a973](https://github.com/pixelfed/pixelfed/commit/d969a973)) +- Update Status model, allow unlisted thumbnails ([1f0a45b7](https://github.com/pixelfed/pixelfed/commit/1f0a45b7)) +- Update StatusTagsPipeline, fix object tags and slug normalization ([d295e605](https://github.com/pixelfed/pixelfed/commit/d295e605)) +- Update Note and CreateNote transformers, include attachment blurhash, width and height ([ce1afe27](https://github.com/pixelfed/pixelfed/commit/ce1afe27)) +- Update ap helpers, store media attachment width and height if present ([8c969191](https://github.com/pixelfed/pixelfed/commit/8c969191)) +- Update Sign-in with Mastodon, allow usage when registrations are closed ([895dc4fa](https://github.com/pixelfed/pixelfed/commit/895dc4fa)) +- ([](https://github.com/pixelfed/pixelfed/commit/)) + +## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9) ### Added - Import from Instagram ([#4466](https://github.com/pixelfed/pixelfed/pull/4466)) ([cf3078c5](https://github.com/pixelfed/pixelfed/commit/cf3078c5)) +- Sign-in with Mastodon ([#4545](https://github.com/pixelfed/pixelfed/pull/4545)) ([45b9404e](https://github.com/pixelfed/pixelfed/commit/45b9404e)) +- Health check endpoint at /api/service/health-check ([ff58f970](https://github.com/pixelfed/pixelfed/commit/ff58f970)) +- Reblogs in home feed ([#4563](https://github.com/pixelfed/pixelfed/pull/4563)) ([b86d47bf](https://github.com/pixelfed/pixelfed/commit/b86d47bf)) +- Account Migrations ([#4578](https://github.com/pixelfed/pixelfed/pull/4578)) ([a9220e4e](https://github.com/pixelfed/pixelfed/commit/a9220e4e)) ### Updates - Update Notifications.vue component, fix filtering logic to prevent endless spinner ([3df9b53f](https://github.com/pixelfed/pixelfed/commit/3df9b53f)) @@ -26,7 +55,32 @@ - Update ComposeModal.vue, fix scroll issue and dont hide scrollbar ([2d959fb3](https://github.com/pixelfed/pixelfed/commit/2d959fb3)) - Update AccountImport, add select first 100 posts button ([625a76a5](https://github.com/pixelfed/pixelfed/commit/625a76a5)) - Update ApiV1Controller, add include_reblogs attribute to home timeline ([37fd0342](https://github.com/pixelfed/pixelfed/commit/37fd0342)) -- ([](https://github.com/pixelfed/pixelfed/commit/)) +- Update rate limits, fixes #4537 ([1cc6274a](https://github.com/pixelfed/pixelfed/commit/1cc6274a)) +- Update Services, use zpopmin on predis ([4b2c66f5](https://github.com/pixelfed/pixelfed/commit/4b2c66f5)) +- Update Inbox, allow storing Create->Note activities without any local followers, disabled by default ([9fa6b3f7](https://github.com/pixelfed/pixelfed/commit/9fa6b3f7)) +- Update AP Helpers, preserve admin unlisted state before adding to NetworkTimelineService ([0704c7e0](https://github.com/pixelfed/pixelfed/commit/0704c7e0)) +- Update SearchApiV2Service, improve resolve query logic to better handle remote posts/profiles and local posts/profiles ([c61d0b91](https://github.com/pixelfed/pixelfed/commit/c61d0b91)) +- Update FollowPipeline, improve follower/following count calculation ([0b515767](https://github.com/pixelfed/pixelfed/commit/0b515767)) +- Update TransformImports command, increment status_count on profile model ([ba7551d8](https://github.com/pixelfed/pixelfed/commit/ba7551d8)) +- Update AP Helpers, improve url validation and add optional dns verification, disabled by default ([2bef3e41](https://github.com/pixelfed/pixelfed/commit/2bef3e41)) +- Update admin users blade view, show last_active_at and other info ([e0b48b29](https://github.com/pixelfed/pixelfed/commit/e0b48b29)) +- Update MediaStorageService, improve head header handling ([3590adbd](https://github.com/pixelfed/pixelfed/commit/3590adbd)) +- Update admin user view, improve previews ([ff2c16fe](https://github.com/pixelfed/pixelfed/commit/ff2c16fe)) +- Update FanoutDeletePipeline, fix AP object ([0d802c31](https://github.com/pixelfed/pixelfed/commit/0d802c31)) +- Update Remote Auth feature, fix custom domain bug and enforce banned domains ([acabf603](https://github.com/pixelfed/pixelfed/commit/acabf603)) +- Update StatusService, reduce cache ttl from 7 days to 6 hours ([59b64378](https://github.com/pixelfed/pixelfed/commit/59b64378)) +- Update ProfileController, allow albums in atom feed. Closes #4561. Fixes #4526 ([1c105a6c](https://github.com/pixelfed/pixelfed/commit/1c105a6c)) +- Update admin users view, fix website value. Closes #4557 ([c469d475](https://github.com/pixelfed/pixelfed/commit/c469d475)) +- Update StatusStatelessTransformer, allow unlisted reblogs ([1c13b518](https://github.com/pixelfed/pixelfed/commit/1c13b518)) +- Update ApiV1Controller, hydrate reblog state in home timeline ([13bdaa2e](https://github.com/pixelfed/pixelfed/commit/13bdaa2e)) +- Update Timeline component, improve reblog support ([29de91e5](https://github.com/pixelfed/pixelfed/commit/29de91e5)) +- Update timeline settings, add photo reblogs only option ([e2705b9a](https://github.com/pixelfed/pixelfed/commit/e2705b9a)) +- Update PostContent, add text cw warning ([911504fa](https://github.com/pixelfed/pixelfed/commit/911504fa)) +- Update ActivityPubFetchService, add validateUrl parameter to bypass url validation to fetch content from blocked instances ([3d1b6516](https://github.com/pixelfed/pixelfed/commit/3d1b6516)) +- Update RemoteStatusDelete pipeline ([71e92261](https://github.com/pixelfed/pixelfed/commit/71e92261)) +- Update RemoteStatusDelete pipeline ([fab8f25e](https://github.com/pixelfed/pixelfed/commit/fab8f25e)) +- Update RemoteStatusPipeline, fix reply check ([618b6727](https://github.com/pixelfed/pixelfed/commit/618b6727)) +- Update ApiV1Controller, add bookmarked to timeline entities ([ca746717](https://github.com/pixelfed/pixelfed/commit/ca746717)) ## [v0.11.8 (2023-05-29)](https://github.com/pixelfed/pixelfed/compare/v0.11.7...v0.11.8) diff --git a/app/Console/Commands/TransformImports.php b/app/Console/Commands/TransformImports.php index cd63985ac..b88401178 100644 --- a/app/Console/Commands/TransformImports.php +++ b/app/Console/Commands/TransformImports.php @@ -9,6 +9,7 @@ use App\Media; use App\Profile; use App\Status; use Storage; +use App\Services\AccountService; use App\Services\MediaPathService; use Illuminate\Support\Str; use App\Util\Lexer\Autolink; @@ -38,7 +39,7 @@ class TransformImports extends Command return; } - $ips = ImportPost::whereNull('status_id')->where('skip_missing_media', '!=', true)->take(200)->get(); + $ips = ImportPost::whereNull('status_id')->where('skip_missing_media', '!=', true)->take(500)->get(); if(!$ips->count()) { return; @@ -135,6 +136,11 @@ class TransformImports extends Command $ip->creation_id = $idk['incr']; $ip->save(); + $profile->status_count = $profile->status_count + 1; + $profile->save(); + + AccountService::del($profile->id); + ImportService::clearAttempts($profile->id); ImportService::getPostCount($profile->id, true); } diff --git a/app/Http/Controllers/Admin/AdminReportController.php b/app/Http/Controllers/Admin/AdminReportController.php index d045edd55..311eac220 100644 --- a/app/Http/Controllers/Admin/AdminReportController.php +++ b/app/Http/Controllers/Admin/AdminReportController.php @@ -27,8 +27,8 @@ use App\Services\StoryService; use App\Services\ModLogService; use App\Jobs\DeletePipeline\DeleteAccountPipeline; use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline; -use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline; use App\Jobs\StatusPipeline\StatusDelete; +use App\Jobs\StatusPipeline\RemoteStatusDelete; use App\Http\Resources\AdminReport; use App\Http\Resources\AdminSpamReport; use App\Services\NotificationService; @@ -643,7 +643,7 @@ trait AdminReportController $q->whereNull('admin_seen') : $q->whereNotNull('admin_seen'); }) - ->groupBy(['object_id', 'object_type']) + ->groupBy(['id', 'object_id', 'object_type']) ->cursorPaginate(6) ->withQueryString() ); @@ -1049,7 +1049,7 @@ trait AdminReportController StatusDelete::dispatch($status)->onQueue('high'); } else { NetworkTimelineService::del($status->id); - DeleteRemoteStatusPipeline::dispatch($status)->onQueue('high'); + RemoteStatusDelete::dispatch($status)->onQueue('high'); } Report::whereObjectId($report->object_id) diff --git a/app/Http/Controllers/AdminShadowFilterController.php b/app/Http/Controllers/AdminShadowFilterController.php new file mode 100644 index 000000000..461e1d0c2 --- /dev/null +++ b/app/Http/Controllers/AdminShadowFilterController.php @@ -0,0 +1,122 @@ +middleware(['auth','admin']); + } + + public function home(Request $request) + { + $filter = $request->input('filter'); + $searchQuery = $request->input('q'); + $filters = AdminShadowFilter::when($filter, function($q, $filter) { + if($filter == 'all') { + return $q; + } else if($filter == 'inactive') { + return $q->whereActive(false); + } else { + return $q; + } + }, function($q, $filter) { + return $q->whereActive(true); + }) + ->when($searchQuery, function($q, $searchQuery) { + $ids = Profile::where('username', 'like', '%' . $searchQuery . '%') + ->limit(100) + ->pluck('id') + ->toArray(); + return $q->where('item_type', 'App\Profile')->whereIn('item_id', $ids); + }) + ->latest() + ->paginate(10) + ->withQueryString(); + + return view('admin.asf.home', compact('filters')); + } + + public function create(Request $request) + { + return view('admin.asf.create'); + } + + public function edit(Request $request, $id) + { + $filter = AdminShadowFilter::findOrFail($id); + $profile = AccountService::get($filter->item_id); + return view('admin.asf.edit', compact('filter', 'profile')); + } + + public function store(Request $request) + { + $this->validate($request, [ + 'username' => 'required', + 'active' => 'sometimes', + 'note' => 'sometimes', + 'hide_from_public_feeds' => 'sometimes' + ]); + + $profile = Profile::whereUsername($request->input('username'))->first(); + + if(!$profile) { + return back()->withErrors(['Invalid account']); + } + + if($profile->user && $profile->user->is_admin) { + return back()->withErrors(['Cannot filter an admin account']); + } + + $active = $request->has('active') && $request->has('hide_from_public_feeds'); + + AdminShadowFilter::updateOrCreate([ + 'item_id' => $profile->id, + 'item_type' => get_class($profile) + ], [ + 'is_local' => $profile->domain === null, + 'note' => $request->input('note'), + 'hide_from_public_feeds' => $request->has('hide_from_public_feeds'), + 'admin_id' => $request->user()->profile_id, + 'active' => $active + ]); + + AdminShadowFilterService::refresh(); + + return redirect('/i/admin/asf/home'); + } + + public function storeEdit(Request $request, $id) + { + $this->validate($request, [ + 'active' => 'sometimes', + 'note' => 'sometimes', + 'hide_from_public_feeds' => 'sometimes' + ]); + + $filter = AdminShadowFilter::findOrFail($id); + + $profile = Profile::findOrFail($filter->item_id); + + if($profile->user && $profile->user->is_admin) { + return back()->withErrors(['Cannot filter an admin account']); + } + + $active = $request->has('active'); + $filter->active = $active; + $filter->hide_from_public_feeds = $request->has('hide_from_public_feeds'); + $filter->note = $request->input('note'); + $filter->save(); + + AdminShadowFilterService::refresh(); + + return redirect('/i/admin/asf/home'); + } +} diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 3e67632a7..92d5d43ee 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -1621,7 +1621,7 @@ class ApiV1Controller extends Controller $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - return $dailyLimit >= 250; + return $dailyLimit >= 1250; }); abort_if($limitReached == true, 429); @@ -1826,7 +1826,7 @@ class ApiV1Controller extends Controller $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - return $dailyLimit >= 250; + return $dailyLimit >= 1250; }); abort_if($limitReached == true, 429); @@ -2193,12 +2193,22 @@ class ApiV1Controller extends Controller if($pid) { $status['favourited'] = (bool) LikeService::liked($pid, $s['id']); $status['reblogged'] = (bool) ReblogService::get($pid, $status['id']); + $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']); } return $status; }) ->filter(function($status) { return $status && isset($status['account']); }) + ->map(function($status) use($pid) { + if(!empty($status['reblog'])) { + $status['reblog']['favourited'] = (bool) LikeService::liked($pid, $status['reblog']['id']); + $status['reblog']['reblogged'] = (bool) ReblogService::get($pid, $status['reblog']['id']); + $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']); + } + + return $status; + }) ->take($limit) ->values(); } else { @@ -2236,12 +2246,22 @@ class ApiV1Controller extends Controller if($pid) { $status['favourited'] = (bool) LikeService::liked($pid, $s['id']); $status['reblogged'] = (bool) ReblogService::get($pid, $status['id']); + $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']); } return $status; }) ->filter(function($status) { return $status && isset($status['account']); }) + ->map(function($status) use($pid) { + if(!empty($status['reblog'])) { + $status['reblog']['favourited'] = (bool) LikeService::liked($pid, $status['reblog']['id']); + $status['reblog']['reblogged'] = (bool) ReblogService::get($pid, $status['reblog']['id']); + $status['bookmarked'] = (bool) BookmarkService::get($pid, $status['id']); + } + + return $status; + }) ->take($limit) ->values(); } @@ -2362,6 +2382,7 @@ class ApiV1Controller extends Controller if($user) { $status['favourited'] = (bool) LikeService::liked($user->profile_id, $k); $status['reblogged'] = (bool) ReblogService::get($user->profile_id, $status['id']); + $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $status['id']); } return $status; }) @@ -2838,7 +2859,7 @@ class ApiV1Controller extends Controller ->where('created_at', '>', now()->subDays(1)) ->count(); - return $dailyLimit >= 100; + return $dailyLimit >= 1000; }); abort_if($limitReached == true, 429); @@ -3599,8 +3620,8 @@ class ApiV1Controller extends Controller abort_if(!$request->user(), 403); $pid = $request->user()->profile_id; - $home = $request->input('home.last_read_id'); - $notifications = $request->input('notifications.last_read_id'); + $home = $request->input('home[last_read_id]'); + $notifications = $request->input('notifications[last_read_id]'); if($home) { return $this->json(MarkerService::set($pid, 'home', $home)); diff --git a/app/Http/Controllers/Api/ApiV1Dot1Controller.php b/app/Http/Controllers/Api/ApiV1Dot1Controller.php index 4c41a8a77..6ed047af9 100644 --- a/app/Http/Controllers/Api/ApiV1Dot1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Dot1Controller.php @@ -17,6 +17,7 @@ use App\Report; use App\Profile; use App\StatusArchived; use App\User; +use App\UserSetting; use App\Services\AccountService; use App\Services\StatusService; use App\Services\ProfileStatusService; @@ -33,6 +34,7 @@ use App\Mail\PasswordChange; use App\Mail\ConfirmAppEmail; use App\Http\Resources\StatusStateless; use App\Jobs\StatusPipeline\StatusDelete; +use App\Jobs\StatusPipeline\RemoteStatusDelete; use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail; use Illuminate\Support\Facades\RateLimiter; @@ -837,7 +839,7 @@ class ApiV1Dot1Controller extends Controller Cache::forget('profile:embed:' . $status->profile_id); StatusService::del($status->id, true); Cache::forget('profile:status_count:'.$status->profile_id); - StatusDelete::dispatch($status); + $status->uri ? RemoteStatusDelete::dispatch($status) : StatusDelete::dispatch($status); return []; } @@ -845,4 +847,41 @@ class ApiV1Dot1Controller extends Controller return StatusService::get($status->id, false); } + + public function getWebSettings(Request $request) + { + abort_if(!$request->user(), 403); + $uid = $request->user()->id; + $settings = UserSetting::firstOrCreate([ + 'user_id' => $uid + ]); + if(!$settings->other) { + return []; + } + return $settings->other; + } + + public function setWebSettings(Request $request) + { + abort_if(!$request->user(), 403); + $this->validate($request, [ + 'field' => 'required|in:enable_reblogs,hide_reblog_banner', + 'value' => 'required' + ]); + $field = $request->input('field'); + $value = $request->input('value'); + $settings = UserSetting::firstOrCreate([ + 'user_id' => $request->user()->id + ]); + if(!$settings->other) { + $other = []; + } else { + $other = $settings->other; + } + $other[$field] = $value; + $settings->other = $other; + $settings->save(); + + return [200]; + } } diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 63c63c56f..757e14dce 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -225,7 +225,7 @@ class ApiV2Controller extends Controller $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - return $dailyLimit >= 250; + return $dailyLimit >= 1250; }); abort_if($limitReached == true, 429); diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index 7a3614f3d..9be50f346 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -98,7 +98,7 @@ class ComposeController extends Controller $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - return $dailyLimit >= 250; + return $dailyLimit >= 1250; }); abort_if($limitReached == true, 429); @@ -190,7 +190,7 @@ class ComposeController extends Controller $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - return $dailyLimit >= 500; + return $dailyLimit >= 1500; }); abort_if($limitReached == true, 429); @@ -415,7 +415,7 @@ class ComposeController extends Controller $results = Profile::select('id','domain','username') ->whereNotIn('id', $blocked) ->where('username','like','%'.$q.'%') - ->groupBy('domain') + ->groupBy('id', 'domain') ->limit(15) ->get() ->map(function($profile) { @@ -499,7 +499,7 @@ class ComposeController extends Controller ->where('created_at', '>', now()->subDays(1)) ->count(); - return $dailyLimit >= 100; + return $dailyLimit >= 1000; }); abort_if($limitReached == true, 429); diff --git a/app/Http/Controllers/HealthCheckController.php b/app/Http/Controllers/HealthCheckController.php new file mode 100644 index 000000000..0af2f63f4 --- /dev/null +++ b/app/Http/Controllers/HealthCheckController.php @@ -0,0 +1,16 @@ +withHeaders([ + 'Content-Type' => 'text/plain', + 'Cache-Control' => 'max-age=0, must-revalidate, no-cache, no-store' + ]); + } +} diff --git a/app/Http/Controllers/ProfileAliasController.php b/app/Http/Controllers/ProfileAliasController.php new file mode 100644 index 000000000..024005a8e --- /dev/null +++ b/app/Http/Controllers/ProfileAliasController.php @@ -0,0 +1,64 @@ +middleware('auth'); + } + + 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 = $request->input('acct'); + + 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'])) { + 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->save(); + + return back()->with('status', 'Successfully added alias!'); + } + + public function delete(Request $request) + { + $this->validate($request, [ + 'acct' => 'required', + 'id' => 'required|exists:profile_aliases' + ]); + + $alias = ProfileAlias::where('profile_id', $request->user()->profile_id) + ->where('acct', $request->input('acct')) + ->findOrFail($request->input('id')); + + $alias->delete(); + + return back()->with('status', 'Successfully deleted alias!'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index df25b2d7b..26e9e5398 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -12,6 +12,7 @@ use App\Follower; use App\FollowRequest; use App\Profile; use App\Story; +use App\Status; use App\User; use App\UserSetting; use App\UserFilter; @@ -253,15 +254,14 @@ class ProfileController extends Controller abort_if(!$enabled, 404); $data = Cache::remember('pf:atom:user-feed:by-id:' . $profile['id'], 900, function() use($pid, $profile) { - $items = DB::table('statuses') - ->whereProfileId($pid) - ->whereVisibility('public') - ->whereType('photo') + $items = Status::whereProfileId($pid) + ->whereScope('public') + ->whereIn('type', ['photo', 'photo:album']) ->orderByDesc('id') ->take(10) ->get() ->map(function($status) { - return StatusService::get($status->id); + return StatusService::get($status->id, true); }) ->filter(function($status) { return $status && diff --git a/app/Http/Controllers/RemoteAuthController.php b/app/Http/Controllers/RemoteAuthController.php new file mode 100644 index 000000000..e068f5d75 --- /dev/null +++ b/app/Http/Controllers/RemoteAuthController.php @@ -0,0 +1,718 @@ +user()) { + return redirect('/'); + } + return view('auth.remote.start'); + } + + public function startRedirect(Request $request) + { + return redirect('/login'); + } + + public function getAuthDomains(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + + if(config('remote-auth.mastodon.domains.only_custom')) { + $res = config('remote-auth.mastodon.domains.custom'); + if(!$res || !strlen($res)) { + return []; + } + $res = explode(',', $res); + return response()->json($res); + } + + if( config('remote-auth.mastodon.domains.custom') && + !config('remote-auth.mastodon.domains.only_default') && + strlen(config('remote-auth.mastodon.domains.custom')) > 3 && + strpos(config('remote-auth.mastodon.domains.custom'), '.') > -1 + ) { + $res = config('remote-auth.mastodon.domains.custom'); + if(!$res || !strlen($res)) { + return []; + } + $res = explode(',', $res); + return response()->json($res); + } + + $res = config('remote-auth.mastodon.domains.default'); + $res = explode(',', $res); + + return response()->json($res); + } + + public function redirect(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + + $this->validate($request, ['domain' => 'required']); + + $domain = $request->input('domain'); + + if(str_starts_with(strtolower($domain), 'http')) { + $res = [ + 'domain' => $domain, + 'ready' => false, + 'action' => 'incompatible_domain' + ]; + return response()->json($res); + } + + $validateInstance = Helpers::validateUrl('https://' . $domain . '/?block-check=' . time()); + + if(!$validateInstance) { + $res = [ + 'domain' => $domain, + 'ready' => false, + 'action' => 'blocked_domain' + ]; + return response()->json($res); + } + + $compatible = RemoteAuthService::isDomainCompatible($domain); + + if(!$compatible) { + $res = [ + 'domain' => $domain, + 'ready' => false, + 'action' => 'incompatible_domain' + ]; + return response()->json($res); + } + + if(config('remote-auth.mastodon.domains.only_default')) { + $defaultDomains = explode(',', config('remote-auth.mastodon.domains.default')); + if(!in_array($domain, $defaultDomains)) { + $res = [ + 'domain' => $domain, + 'ready' => false, + 'action' => 'incompatible_domain' + ]; + return response()->json($res); + } + } + + if(config('remote-auth.mastodon.domains.only_custom') && config('remote-auth.mastodon.domains.custom')) { + $customDomains = explode(',', config('remote-auth.mastodon.domains.custom')); + if(!in_array($domain, $customDomains)) { + $res = [ + 'domain' => $domain, + 'ready' => false, + 'action' => 'incompatible_domain' + ]; + return response()->json($res); + } + } + + $client = RemoteAuthService::getMastodonClient($domain); + + abort_unless($client, 422, 'Invalid mastodon client'); + + $request->session()->put('state', $state = Str::random(40)); + $request->session()->put('oauth_domain', $domain); + + $query = http_build_query([ + 'client_id' => $client->client_id, + 'redirect_uri' => $client->redirect_uri, + 'response_type' => 'code', + 'scope' => 'read', + 'state' => $state, + ]); + + $request->session()->put('oauth_redirect_to', 'https://' . $domain . '/oauth/authorize?' . $query); + + $dsh = Str::random(17); + $res = [ + 'domain' => $domain, + 'ready' => true, + 'dsh' => $dsh + ]; + + return response()->json($res); + } + + public function preflight(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + + if(!$request->filled('d') || !$request->filled('dsh') || !$request->session()->exists('oauth_redirect_to')) { + return redirect('/login'); + } + + return redirect()->away($request->session()->pull('oauth_redirect_to')); + } + + public function handleCallback(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + + $domain = $request->session()->get('oauth_domain'); + + if($request->filled('code')) { + $code = $request->input('code'); + $state = $request->session()->pull('state'); + + throw_unless( + strlen($state) > 0 && $state === $request->state, + InvalidArgumentException::class, + 'Invalid state value.' + ); + + $res = RemoteAuthService::getToken($domain, $code); + + if(!$res || !isset($res['access_token'])) { + $request->session()->regenerate(); + return redirect('/login'); + } + + $request->session()->put('oauth_remote_session_token', $res['access_token']); + return redirect('/auth/mastodon/getting-started'); + } + + return redirect('/login'); + } + + public function onboarding(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + if($request->user()) { + return redirect('/'); + } + return view('auth.remote.onboarding'); + } + + public function sessionCheck(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_if($request->user(), 403); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + + $domain = $request->session()->get('oauth_domain'); + $token = $request->session()->get('oauth_remote_session_token'); + + $res = RemoteAuthService::getVerifyCredentials($domain, $token); + + abort_if(!$res || !isset($res['acct']), 403, 'Invalid credentials'); + + $webfinger = strtolower('@' . $res['acct'] . '@' . $domain); + $request->session()->put('oauth_masto_webfinger', $webfinger); + + if(config('remote-auth.mastodon.max_uses.enabled')) { + $limit = config('remote-auth.mastodon.max_uses.limit'); + $uses = RemoteAuthService::lookupWebfingerUses($webfinger); + if($uses >= $limit) { + return response()->json([ + 'code' => 200, + 'msg' => 'Success!', + 'action' => 'max_uses_reached' + ]); + } + } + + $exists = RemoteAuth::whereDomain($domain)->where('webfinger', $webfinger)->whereNotNull('user_id')->first(); + if($exists && $exists->user_id) { + return response()->json([ + 'code' => 200, + 'msg' => 'Success!', + 'action' => 'redirect_existing_user' + ]); + } + + return response()->json([ + 'code' => 200, + 'msg' => 'Success!', + 'action' => 'onboard' + ]); + } + + public function sessionGetMastodonData(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_if($request->user(), 403); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + + $domain = $request->session()->get('oauth_domain'); + $token = $request->session()->get('oauth_remote_session_token'); + + $res = RemoteAuthService::getVerifyCredentials($domain, $token); + $res['_webfinger'] = strtolower('@' . $res['acct'] . '@' . $domain); + $res['_domain'] = strtolower($domain); + $request->session()->put('oauth_remasto_id', $res['id']); + + $ra = RemoteAuth::updateOrCreate([ + 'domain' => $domain, + 'webfinger' => $res['_webfinger'], + ], [ + 'software' => 'mastodon', + 'ip_address' => $request->ip(), + 'bearer_token' => $token, + 'verify_credentials' => $res, + 'last_verify_credentials_at' => now(), + 'last_successful_login_at' => now() + ]); + + $request->session()->put('oauth_masto_raid', $ra->id); + + return response()->json($res); + } + + public function sessionValidateUsername(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_if($request->user(), 403); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + + $this->validate($request, [ + 'username' => [ + 'required', + 'min:2', + 'max:15', + function ($attribute, $value, $fail) { + $dash = substr_count($value, '-'); + $underscore = substr_count($value, '_'); + $period = substr_count($value, '.'); + + if(ends_with($value, ['.php', '.js', '.css'])) { + return $fail('Username is invalid.'); + } + + if(($dash + $underscore + $period) > 1) { + return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).'); + } + + if (!ctype_alnum($value[0])) { + return $fail('Username is invalid. Must start with a letter or number.'); + } + + if (!ctype_alnum($value[strlen($value) - 1])) { + return $fail('Username is invalid. Must end with a letter or number.'); + } + + $val = str_replace(['_', '.', '-'], '', $value); + if(!ctype_alnum($val)) { + return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).'); + } + + $restricted = RestrictedNames::get(); + if (in_array(strtolower($value), array_map('strtolower', $restricted))) { + return $fail('Username cannot be used.'); + } + } + ] + ]); + $username = strtolower($request->input('username')); + + $exists = User::where('username', $username)->exists(); + + return response()->json([ + 'code' => 200, + 'username' => $username, + 'exists' => $exists + ]); + } + + public function sessionValidateEmail(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_if($request->user(), 403); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + + $this->validate($request, [ + 'email' => [ + 'required', + 'email:strict,filter_unicode,dns,spoof', + ] + ]); + + $email = $request->input('email'); + $banned = EmailService::isBanned($email); + $exists = User::where('email', $email)->exists(); + + return response()->json([ + 'code' => 200, + 'email' => $email, + 'exists' => $exists, + 'banned' => $banned + ]); + } + + public function sessionGetMastodonFollowers(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + abort_unless($request->session()->exists('oauth_remasto_id'), 403); + + $domain = $request->session()->get('oauth_domain'); + $token = $request->session()->get('oauth_remote_session_token'); + $id = $request->session()->get('oauth_remasto_id'); + + $res = RemoteAuthService::getFollowing($domain, $token, $id); + + if(!$res) { + return response()->json([ + 'code' => 200, + 'following' => [] + ]); + } + + $res = collect($res)->filter(fn($acct) => Helpers::validateUrl($acct['url']))->values()->toArray(); + + return response()->json([ + 'code' => 200, + 'following' => $res + ]); + } + + public function handleSubmit(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + abort_unless($request->session()->exists('oauth_remasto_id'), 403); + abort_unless($request->session()->exists('oauth_masto_webfinger'), 403); + abort_unless($request->session()->exists('oauth_masto_raid'), 403); + + $this->validate($request, [ + 'email' => 'required|email:strict,filter_unicode,dns,spoof', + 'username' => [ + 'required', + 'min:2', + 'max:15', + 'unique:users,username', + function ($attribute, $value, $fail) { + $dash = substr_count($value, '-'); + $underscore = substr_count($value, '_'); + $period = substr_count($value, '.'); + + if(ends_with($value, ['.php', '.js', '.css'])) { + return $fail('Username is invalid.'); + } + + if(($dash + $underscore + $period) > 1) { + return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).'); + } + + if (!ctype_alnum($value[0])) { + return $fail('Username is invalid. Must start with a letter or number.'); + } + + if (!ctype_alnum($value[strlen($value) - 1])) { + return $fail('Username is invalid. Must end with a letter or number.'); + } + + $val = str_replace(['_', '.', '-'], '', $value); + if(!ctype_alnum($val)) { + return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).'); + } + + $restricted = RestrictedNames::get(); + if (in_array(strtolower($value), array_map('strtolower', $restricted))) { + return $fail('Username cannot be used.'); + } + } + ], + 'password' => 'required|string|min:8|confirmed', + 'name' => 'nullable|max:30' + ]); + + $email = $request->input('email'); + $username = $request->input('username'); + $password = $request->input('password'); + $name = $request->input('name'); + + $user = $this->createUser([ + 'name' => $name, + 'username' => $username, + 'password' => $password, + 'email' => $email + ]); + + $raid = $request->session()->pull('oauth_masto_raid'); + $webfinger = $request->session()->pull('oauth_masto_webfinger'); + $token = $user->createToken('Onboarding')->accessToken; + + $ra = RemoteAuth::where('id', $raid)->where('webfinger', $webfinger)->firstOrFail(); + $ra->user_id = $user->id; + $ra->save(); + + return [ + 'code' => 200, + 'msg' => 'Success', + 'token' => $token + ]; + } + + public function storeBio(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_unless($request->user(), 404); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + abort_unless($request->session()->exists('oauth_remasto_id'), 403); + + $this->validate($request, [ + 'bio' => 'required|nullable|max:500', + ]); + + $profile = $request->user()->profile; + $profile->bio = Purify::clean($request->input('bio')); + $profile->save(); + + return [200]; + } + + public function accountToId(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_if($request->user(), 404); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + abort_unless($request->session()->exists('oauth_remasto_id'), 403); + + $this->validate($request, [ + 'account' => 'required|url' + ]); + + $account = $request->input('account'); + abort_unless(substr(strtolower($account), 0, 8) === 'https://', 404); + + $host = strtolower(config('pixelfed.domain.app')); + $domain = strtolower(parse_url($account, PHP_URL_HOST)); + + if($domain == $host) { + $username = Str::of($account)->explode('/')->last(); + $user = User::where('username', $username)->first(); + if($user) { + return ['id' => (string) $user->profile_id]; + } else { + return []; + } + } else { + try { + $profile = Helpers::profileFetch($account); + if($profile) { + return ['id' => (string) $profile->id]; + } else { + return []; + } + } catch (\GuzzleHttp\Exception\RequestException $e) { + return; + } catch (Exception $e) { + return []; + } + } + } + + public function storeAvatar(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_unless($request->user(), 404); + $this->validate($request, [ + 'avatar_url' => 'required|active_url', + ]); + + $user = $request->user(); + $profile = $user->profile; + + abort_if(!$profile->avatar, 404, 'Missing avatar'); + + $avatar = $profile->avatar; + $avatar->remote_url = $request->input('avatar_url'); + $avatar->save(); + + MediaStorageService::avatar($avatar, config_cache('pixelfed.cloud_storage') == false); + + return [200]; + } + + public function finishUp(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_unless($request->user(), 404); + + $currentWebfinger = '@' . $request->user()->username . '@' . config('pixelfed.domain.app'); + $ra = RemoteAuth::where('user_id', $request->user()->id)->firstOrFail(); + RemoteAuthService::submitToBeagle( + $ra->webfinger, + $ra->verify_credentials['url'], + $currentWebfinger, + $request->user()->url() + ); + + return [200]; + } + + public function handleLogin(Request $request) + { + abort_unless(( + config_cache('pixelfed.open_registration') && + config('remote-auth.mastodon.enabled') + ) || ( + config('remote-auth.mastodon.ignore_closed_state') && + config('remote-auth.mastodon.enabled') + ), 404); + abort_if($request->user(), 404); + abort_unless($request->session()->exists('oauth_domain'), 403); + abort_unless($request->session()->exists('oauth_remote_session_token'), 403); + abort_unless($request->session()->exists('oauth_masto_webfinger'), 403); + + $domain = $request->session()->get('oauth_domain'); + $wf = $request->session()->get('oauth_masto_webfinger'); + + $ra = RemoteAuth::where('webfinger', $wf)->where('domain', $domain)->whereNotNull('user_id')->firstOrFail(); + + $user = User::findOrFail($ra->user_id); + abort_if($user->is_admin || $user->status != null, 422, 'Invalid auth action'); + Auth::loginUsingId($ra->user_id); + return [200]; + } + + protected function createUser($data) + { + event(new Registered($user = User::create([ + 'name' => Purify::clean($data['name']), + 'username' => $data['username'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + 'email_verified_at' => config('remote-auth.mastodon.contraints.skip_email_verification') ? now() : null, + 'app_register_ip' => request()->ip(), + 'register_source' => 'mastodon' + ]))); + + $this->guarder()->login($user); + + return $user; + } + + protected function guarder() + { + return Auth::guard(); + } +} diff --git a/app/Http/Controllers/Settings/PrivacySettings.php b/app/Http/Controllers/Settings/PrivacySettings.php index 3d1cd4515..9a5febe83 100644 --- a/app/Http/Controllers/Settings/PrivacySettings.php +++ b/app/Http/Controllers/Settings/PrivacySettings.php @@ -20,13 +20,13 @@ trait PrivacySettings public function privacy() { - $user = Auth::user(); - $settings = $user->settings; - $profile = $user->profile; - $is_private = $profile->is_private; - $settings['is_private'] = (bool) $is_private; + $user = Auth::user(); + $settings = $user->settings; + $profile = $user->profile; + $is_private = $profile->is_private; + $settings['is_private'] = (bool) $is_private; - return view('settings.privacy', compact('settings', 'profile')); + return view('settings.privacy', compact('settings', 'profile')); } public function privacyStore(Request $request) @@ -39,11 +39,13 @@ trait PrivacySettings 'public_dm', 'show_profile_follower_count', 'show_profile_following_count', + 'indexable', 'show_atom', ]; - $profile->is_suggestable = $request->input('is_suggestable') == 'on'; - $profile->save(); + $profile->indexable = $request->input('indexable') == 'on'; + $profile->is_suggestable = $request->input('is_suggestable') == 'on'; + $profile->save(); foreach ($fields as $field) { $form = $request->input($field); @@ -70,6 +72,8 @@ trait PrivacySettings } else { $settings->{$field} = false; } + } elseif ($field == 'indexable') { + } else { if ($form == 'on') { $settings->{$field} = true; diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index a2459f2d1..2eb9df65f 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -230,29 +230,51 @@ class SettingsController extends Controller public function timelineSettings(Request $request) { + $uid = $request->user()->id; $pid = $request->user()->profile_id; $top = Redis::zscore('pf:tl:top', $pid) != false; $replies = Redis::zscore('pf:tl:replies', $pid) != false; - return view('settings.timeline', compact('top', 'replies')); + $userSettings = UserSetting::firstOrCreate([ + 'user_id' => $uid + ]); + if(!$userSettings || !$userSettings->other) { + $userSettings = [ + 'enable_reblogs' => false, + 'photo_reblogs_only' => false + ]; + } else { + $userSettings = array_merge([ + 'enable_reblogs' => false, + 'photo_reblogs_only' => false + ], + $userSettings->other); + } + return view('settings.timeline', compact('top', 'replies', 'userSettings')); } public function updateTimelineSettings(Request $request) { - $pid = $request->user()->profile_id; - $top = $request->has('top') && $request->input('top') === 'on'; - $replies = $request->has('replies') && $request->input('replies') === 'on'; - - if($top) { - Redis::zadd('pf:tl:top', $pid, $pid); - } else { - Redis::zrem('pf:tl:top', $pid); - } - - if($replies) { - Redis::zadd('pf:tl:replies', $pid, $pid); - } else { - Redis::zrem('pf:tl:replies', $pid); - } + $pid = $request->user()->profile_id; + $uid = $request->user()->id; + $this->validate($request, [ + 'enable_reblogs' => 'sometimes', + 'photo_reblogs_only' => 'sometimes' + ]); + Redis::zrem('pf:tl:top', $pid); + Redis::zrem('pf:tl:replies', $pid); + $userSettings = UserSetting::firstOrCreate([ + 'user_id' => $uid + ]); + if($userSettings->other) { + $other = $userSettings->other; + $other['enable_reblogs'] = $request->has('enable_reblogs'); + $other['photo_reblogs_only'] = $request->has('photo_reblogs_only'); + } else { + $other['enable_reblogs'] = $request->has('enable_reblogs'); + $other['photo_reblogs_only'] = $request->has('photo_reblogs_only'); + } + $userSettings->other = $other; + $userSettings->save(); return redirect(route('settings'))->with('status', 'Timeline settings successfully updated!'); } diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php index 43ac03263..873f5eace 100644 --- a/app/Http/Controllers/StatusController.php +++ b/app/Http/Controllers/StatusController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\StatusPipeline\StatusDelete; +use App\Jobs\StatusPipeline\RemoteStatusDelete; use App\Jobs\SharePipeline\SharePipeline; use App\Jobs\SharePipeline\UndoSharePipeline; use App\AccountInterstitial; @@ -242,7 +243,7 @@ class StatusController extends Controller Cache::forget('profile:embed:' . $status->profile_id); StatusService::del($status->id, true); Cache::forget('profile:status_count:'.$status->profile_id); - StatusDelete::dispatch($status); + $status->uri ? RemoteStatusDelete::dispatch($status) : StatusDelete::dispatch($status); } } else if ($status->profile_id == $user->profile_id || $user->is_admin == true) { Cache::forget('_api:statuses:recent_9:' . $status->profile_id); @@ -250,7 +251,7 @@ class StatusController extends Controller Cache::forget('profile:embed:' . $status->profile_id); StatusService::del($status->id, true); Cache::forget('profile:status_count:'.$status->profile_id); - StatusDelete::dispatch($status); + $status->uri ? RemoteStatusDelete::dispatch($status) : StatusDelete::dispatch($status); } if($request->wantsJson()) { diff --git a/app/Http/Controllers/Stories/StoryApiV1Controller.php b/app/Http/Controllers/Stories/StoryApiV1Controller.php index e32fffa26..db2b1f533 100644 --- a/app/Http/Controllers/Stories/StoryApiV1Controller.php +++ b/app/Http/Controllers/Stories/StoryApiV1Controller.php @@ -20,6 +20,7 @@ use App\Jobs\StoryPipeline\StoryViewDeliver; use App\Services\AccountService; use App\Services\MediaPathService; use App\Services\StoryService; +use App\Http\Resources\StoryView as StoryViewResource; class StoryApiV1Controller extends Controller { @@ -355,4 +356,26 @@ class StoryApiV1Controller extends Controller $path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension()); return $path; } + + public function viewers(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $this->validate($request, [ + 'sid' => 'required|string|min:1|max:50' + ]); + + $pid = $request->user()->profile_id; + $sid = $request->input('sid'); + + $story = Story::whereProfileId($pid) + ->whereActive(true) + ->findOrFail($sid); + + $viewers = StoryView::whereStoryId($story->id) + ->orderByDesc('id') + ->cursorPaginate(10); + + return StoryViewResource::collection($viewers); + } } diff --git a/app/Http/Resources/AdminProfile.php b/app/Http/Resources/AdminProfile.php new file mode 100644 index 000000000..50db2d5ca --- /dev/null +++ b/app/Http/Resources/AdminProfile.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + $res = AccountService::get($this->id, true); + $res['domain'] = $this->domain; + $res['status'] = $this->status; + $res['limits'] = [ + 'exist' => $this->cw || $this->unlisted || $this->no_autolink, + 'autocw' => (bool) $this->cw, + 'unlisted' => (bool) $this->unlisted, + 'no_autolink' => (bool) $this->no_autolink, + 'banned' => (bool) $this->status == 'banned' + ]; + return $res; + } +} diff --git a/app/Http/Resources/StoryView.php b/app/Http/Resources/StoryView.php new file mode 100644 index 000000000..891bf2eee --- /dev/null +++ b/app/Http/Resources/StoryView.php @@ -0,0 +1,20 @@ + + */ + public function toArray(Request $request) + { + return AccountService::get($this->profile_id, true); + } +} diff --git a/app/Jobs/AdminPipeline/AdminProfileActionPipeline.php b/app/Jobs/AdminPipeline/AdminProfileActionPipeline.php new file mode 100644 index 000000000..24b24ba6e --- /dev/null +++ b/app/Jobs/AdminPipeline/AdminProfileActionPipeline.php @@ -0,0 +1,139 @@ +profile = $profile; + $this->action = $action; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $profile = $this->profile; + $action = $this->action; + + switch($action) { + case 'mark-all-cw': + return $this->markAllPostsWithContentWarnings(); + break; + case 'unlist-all': + return $this->unlistAllPosts(); + break; + case 'purge': + return $this->purgeAllPosts(); + break; + case 'refetch': + return $this->refetchAllPosts(); + break; + } + } + + protected function markAllPostsWithContentWarnings() + { + $profile = $this->profile; + + foreach(Status::whereProfileId($profile->id)->lazyById(10, 'id') as $status) { + if($status->scope == 'direct') { + continue; + } + $status->is_nsfw = true; + $status->save(); + StatusService::del($status->id); + } + } + + protected function unlistAllPosts() + { + $profile = $this->profile; + + foreach(Status::whereProfileId($profile->id)->lazyById(10, 'id') as $status) { + if($status->scope != 'public') { + continue; + } + $status->scope = 'unlisted'; + $status->visibility = 'unlisted'; + $status->save(); + StatusService::del($status->id); + } + } + + protected function purgeAllPosts() + { + $profile = $this->profile; + + foreach(Status::withTrashed()->whereProfileId($profile->id)->lazyById(10, 'id') as $status) { + RemoteStatusDelete::dispatch($status)->onQueue('delete'); + } + } + + protected function refetchAllPosts() + { + $profile = $this->profile; + $res = ActivityPubFetchService::get($profile->remote_url, false); + if(!$res) { + return; + } + $res = json_decode($res, true); + $profile->following_count = Follower::whereProfileId($profile->id)->count(); + $profile->followers_count = Follower::whereFollowingId($profile->id)->count(); + $profile->name = isset($res['name']) ? Purify::clean($res['name']) : $profile->username; + $profile->bio = isset($res['summary']) ? Purify::clean($res['summary']) : null; + if(isset($res['publicKey'])) { + $profile->public_key = $res['publicKey']['publicKeyPem']; + } + if( + isset($res['icon']) && + isset( + $res['icon']['type'], + $res['icon']['mediaType'], + $res['icon']['url']) && $res['icon']['type'] == 'Image' + ) { + if(in_array($res['icon']['mediaType'], ['image/jpeg', 'image/png'])) { + $profile->avatar->remote_url = $res['icon']['url']; + $profile->push(); + MediaStorageService::avatar($profile->avatar); + } + } + $profile->save(); + AccountService::del($profile->id); + } +} diff --git a/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php b/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php index 095cf98e3..7dce73fdc 100644 --- a/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php +++ b/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php @@ -51,6 +51,7 @@ use App\Models\Conversation; use App\Models\Poll; use App\Models\PollVote; use App\Services\AccountService; +use App\Jobs\StatusPipeline\RemoteStatusDelete; class DeleteRemoteProfilePipeline implements ShouldQueue { @@ -86,7 +87,7 @@ class DeleteRemoteProfilePipeline implements ShouldQueue Status::whereProfileId($pid) ->chunk(50, function($statuses) { foreach($statuses as $status) { - DeleteRemoteStatusPipeline::dispatch($status)->onQueue('delete'); + RemoteStatusDelete::dispatch($status)->onQueue('delete'); } }); diff --git a/app/Jobs/DeletePipeline/FanoutDeletePipeline.php b/app/Jobs/DeletePipeline/FanoutDeletePipeline.php index a5ee65ac3..0ccb9d5c4 100644 --- a/app/Jobs/DeletePipeline/FanoutDeletePipeline.php +++ b/app/Jobs/DeletePipeline/FanoutDeletePipeline.php @@ -53,10 +53,10 @@ class FanoutDeletePipeline implements ShouldQueue "id" => $profile->permalink('#delete'), "type" => "Delete", "actor" => $profile->permalink(), - "to" => [ - "https://www.w3.org/ns/activitystreams#Public", + "object" => [ + "type" => "Person", + "id" => $profile->permalink() ], - "object" => $profile->permalink(), ]; $payload = json_encode($activity); diff --git a/app/Jobs/FollowPipeline/FollowPipeline.php b/app/Jobs/FollowPipeline/FollowPipeline.php index 6db85fa6a..225334304 100644 --- a/app/Jobs/FollowPipeline/FollowPipeline.php +++ b/app/Jobs/FollowPipeline/FollowPipeline.php @@ -17,91 +17,71 @@ use App\Services\FollowerService; class FollowPipeline implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - protected $follower; + protected $follower; - /** - * Delete the job if its models no longer exist. - * - * @var bool - */ - public $deleteWhenMissingModels = true; - - /** - * Create a new job instance. - * - * @return void - */ - public function __construct($follower) - { - $this->follower = $follower; - } + /** + * Delete the job if its models no longer exist. + * + * @var bool + */ + public $deleteWhenMissingModels = true; - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - $follower = $this->follower; - $actor = $follower->actor; - $target = $follower->target; + /** + * Create a new job instance. + * + * @return void + */ + public function __construct($follower) + { + $this->follower = $follower; + } - if(!$actor || !$target) { - return; - } + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $follower = $this->follower; + $actor = $follower->actor; + $target = $follower->target; - Cache::forget('profile:following:' . $actor->id); - Cache::forget('profile:following:' . $target->id); + if(!$actor || !$target) { + return; + } - FollowerService::add($actor->id, $target->id); + if($target->domain || !$target->private_key) { + return; + } - $actorProfileSync = Cache::get(FollowerService::FOLLOWING_SYNC_KEY . $actor->id); - if(!$actorProfileSync) { - FollowServiceWarmCache::dispatch($actor->id)->onQueue('low'); - } else { - if($actor->following_count) { - $actor->increment('following_count'); - } else { - $count = Follower::whereProfileId($actor->id)->count(); - $actor->following_count = $count; - $actor->save(); - } - Cache::put(FollowerService::FOLLOWING_SYNC_KEY . $actor->id, 1, 604800); - AccountService::del($actor->id); - } + Cache::forget('profile:following:' . $actor->id); + Cache::forget('profile:following:' . $target->id); - $targetProfileSync = Cache::get(FollowerService::FOLLOWERS_SYNC_KEY . $target->id); - if(!$targetProfileSync) { - FollowServiceWarmCache::dispatch($target->id)->onQueue('low'); - } else { - if($target->followers_count) { - $target->increment('followers_count'); - } else { - $count = Follower::whereFollowingId($target->id)->count(); - $target->followers_count = $count; - $target->save(); - } - Cache::put(FollowerService::FOLLOWERS_SYNC_KEY . $target->id, 1, 604800); - AccountService::del($target->id); - } + FollowerService::add($actor->id, $target->id); - if($target->domain || !$target->private_key) { - return; - } + $count = Follower::whereProfileId($actor->id)->count(); + $actor->following_count = $count; + $actor->save(); + AccountService::del($actor->id); - try { - $notification = new Notification(); - $notification->profile_id = $target->id; - $notification->actor_id = $actor->id; - $notification->action = 'follow'; - $notification->item_id = $target->id; - $notification->item_type = "App\Profile"; - $notification->save(); - } catch (Exception $e) { - Log::error($e); - } - } + $count = Follower::whereFollowingId($target->id)->count(); + $target->followers_count = $count; + $target->save(); + AccountService::del($target->id); + + try { + $notification = new Notification(); + $notification->profile_id = $target->id; + $notification->actor_id = $actor->id; + $notification->action = 'follow'; + $notification->item_id = $target->id; + $notification->item_type = "App\Profile"; + $notification->save(); + } catch (Exception $e) { + Log::error($e); + } + } } diff --git a/app/Jobs/FollowPipeline/FollowServiceWarmCache.php b/app/Jobs/FollowPipeline/FollowServiceWarmCache.php index cabea9958..990236f69 100644 --- a/app/Jobs/FollowPipeline/FollowServiceWarmCache.php +++ b/app/Jobs/FollowPipeline/FollowServiceWarmCache.php @@ -8,10 +8,13 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Queue\Middleware\WithoutOverlapping; use App\Services\AccountService; use App\Services\FollowerService; use Cache; use DB; +use Storage; +use App\Follower; use App\Profile; class FollowServiceWarmCache implements ShouldQueue @@ -23,6 +26,16 @@ class FollowServiceWarmCache implements ShouldQueue public $timeout = 5000; public $failOnTimeout = false; + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new WithoutOverlapping($this->profileId))->dontRelease()]; + } + /** * Create a new job instance. * @@ -42,6 +55,10 @@ class FollowServiceWarmCache implements ShouldQueue { $id = $this->profileId; + if(Cache::has(FollowerService::FOLLOWERS_SYNC_KEY . $id) && Cache::has(FollowerService::FOLLOWING_SYNC_KEY . $id)) { + return; + } + $account = AccountService::get($id, true); if(!$account) { @@ -50,25 +67,43 @@ class FollowServiceWarmCache implements ShouldQueue return; } - DB::table('followers') - ->select('id', 'following_id', 'profile_id') - ->whereFollowingId($id) - ->orderBy('id') - ->chunk(200, function($followers) use($id) { - foreach($followers as $follow) { - FollowerService::add($follow->profile_id, $id); - } - }); + $hasFollowerPostProcessing = false; + $hasFollowingPostProcessing = false; - DB::table('followers') - ->select('id', 'following_id', 'profile_id') - ->whereProfileId($id) - ->orderBy('id') - ->chunk(200, function($followers) use($id) { - foreach($followers as $follow) { - FollowerService::add($id, $follow->following_id); - } - }); + if(Follower::whereProfileId($id)->orWhere('following_id', $id)->count()) { + $following = []; + $followers = []; + foreach(Follower::lazy() as $follow) { + if($follow->following_id != $id && $follow->profile_id != $id) { + continue; + } + if($follow->profile_id == $id) { + $following[] = $follow->following_id; + } else { + $followers[] = $follow->profile_id; + } + } + + if(count($followers) > 100) { + // store follower ids and process in another job + Storage::put('follow-warm-cache/' . $id . '/followers.json', json_encode($followers)); + $hasFollowerPostProcessing = true; + } else { + foreach($followers as $follower) { + FollowerService::add($follower, $id); + } + } + + if(count($following) > 100) { + // store following ids and process in another job + Storage::put('follow-warm-cache/' . $id . '/following.json', json_encode($following)); + $hasFollowingPostProcessing = true; + } else { + foreach($following as $following) { + FollowerService::add($id, $following); + } + } + } Cache::put(FollowerService::FOLLOWERS_SYNC_KEY . $id, 1, 604800); Cache::put(FollowerService::FOLLOWING_SYNC_KEY . $id, 1, 604800); @@ -82,6 +117,14 @@ class FollowServiceWarmCache implements ShouldQueue AccountService::del($id); + if($hasFollowingPostProcessing) { + FollowServiceWarmCacheLargeIngestPipeline::dispatch($id, 'following')->onQueue('follow'); + } + + if($hasFollowerPostProcessing) { + FollowServiceWarmCacheLargeIngestPipeline::dispatch($id, 'followers')->onQueue('follow'); + } + return; } } diff --git a/app/Jobs/FollowPipeline/FollowServiceWarmCacheLargeIngestPipeline.php b/app/Jobs/FollowPipeline/FollowServiceWarmCacheLargeIngestPipeline.php new file mode 100644 index 000000000..3299bf7a4 --- /dev/null +++ b/app/Jobs/FollowPipeline/FollowServiceWarmCacheLargeIngestPipeline.php @@ -0,0 +1,88 @@ +profileId = $profileId; + $this->followType = $followType; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $pid = $this->profileId; + $type = $this->followType; + + if($type === 'followers') { + $key = 'follow-warm-cache/' . $pid . '/followers.json'; + if(!Storage::exists($key)) { + return; + } + $file = Storage::get($key); + $json = json_decode($file, true); + + foreach($json as $id) { + FollowerService::add($id, $pid, false); + usleep(random_int(500, 3000)); + } + sleep(5); + Storage::delete($key); + } + + if($type === 'following') { + $key = 'follow-warm-cache/' . $pid . '/following.json'; + if(!Storage::exists($key)) { + return; + } + $file = Storage::get($key); + $json = json_decode($file, true); + + foreach($json as $id) { + FollowerService::add($pid, $id, false); + usleep(random_int(500, 3000)); + } + sleep(5); + Storage::delete($key); + } + + sleep(random_int(2, 5)); + $files = Storage::files('follow-warm-cache/' . $pid); + if(empty($files)) { + Storage::deleteDirectory('follow-warm-cache/' . $pid); + } + } +} diff --git a/app/Jobs/SharePipeline/SharePipeline.php b/app/Jobs/SharePipeline/SharePipeline.php index 0b580500b..ae184957e 100644 --- a/app/Jobs/SharePipeline/SharePipeline.php +++ b/app/Jobs/SharePipeline/SharePipeline.php @@ -49,7 +49,10 @@ class SharePipeline implements ShouldQueue public function handle() { $status = $this->status; - $parent = $this->status->parent(); + $parent = Status::find($this->status->reblog_of_id); + if(!$parent) { + return; + } $actor = $status->profile; $target = $parent->profile; @@ -84,7 +87,7 @@ class SharePipeline implements ShouldQueue public function remoteAnnounceDeliver() { - if(config_cache('federation.activitypub.enabled') == false) { + if(config('app.env') !== 'production' || config_cache('federation.activitypub.enabled') == false) { return true; } $status = $this->status; diff --git a/app/Jobs/SharePipeline/UndoSharePipeline.php b/app/Jobs/SharePipeline/UndoSharePipeline.php index 6d39dfa39..3850a4752 100644 --- a/app/Jobs/SharePipeline/UndoSharePipeline.php +++ b/app/Jobs/SharePipeline/UndoSharePipeline.php @@ -61,7 +61,7 @@ class UndoSharePipeline implements ShouldQueue return; } - if(config_cache('federation.activitypub.enabled') == false) { + if(config('app.env') !== 'production' || config_cache('federation.activitypub.enabled') == false) { return $status->delete(); } else { return $this->remoteAnnounceDeliver(); @@ -70,7 +70,8 @@ class UndoSharePipeline implements ShouldQueue public function remoteAnnounceDeliver() { - if(config_cache('federation.activitypub.enabled') == false) { + if(config('app.env') !== 'production' || config_cache('federation.activitypub.enabled') == false) { + $status->delete(); return 1; } diff --git a/app/Jobs/StatusPipeline/RemoteStatusDelete.php b/app/Jobs/StatusPipeline/RemoteStatusDelete.php new file mode 100644 index 000000000..19c17b54c --- /dev/null +++ b/app/Jobs/StatusPipeline/RemoteStatusDelete.php @@ -0,0 +1,142 @@ +status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $status = $this->status; + + if($status->deleted_at) { + return; + } + $profile = $this->status->profile; + + StatusService::del($status->id, true); + + if($profile->status_count && $profile->status_count > 0) { + $profile->status_count = $profile->status_count - 1; + $profile->save(); + } + + return $this->unlinkRemoveMedia($status); + } + + public function unlinkRemoveMedia($status) + { + + if($status->in_reply_to_id) { + $parent = Status::find($status->in_reply_to_id); + if($parent) { + --$parent->reply_count; + $parent->save(); + StatusService::del($parent->id); + } + } + + AccountInterstitial::where('item_type', 'App\Status') + ->where('item_id', $status->id) + ->delete(); + Bookmark::whereStatusId($status->id)->delete(); + CollectionItem::whereObjectType('App\Status') + ->whereObjectId($status->id) + ->get() + ->each(function($col) { + CollectionService::removeItem($col->collection_id, $col->object_id); + $col->delete(); + }); + DirectMessage::whereStatusId($status->id)->delete(); + Like::whereStatusId($status->id)->forceDelete(); + Media::whereStatusId($status->id) + ->get() + ->each(function($media) { + MediaDeletePipeline::dispatch($media)->onQueue('mmo'); + }); + MediaTag::where('status_id', $status->id)->delete(); + Mention::whereStatusId($status->id)->forceDelete(); + Notification::whereItemType('App\Status') + ->whereItemId($status->id) + ->forceDelete(); + Report::whereObjectType('App\Status') + ->whereObjectId($status->id) + ->delete(); + StatusArchived::whereStatusId($status->id)->delete(); + StatusHashtag::whereStatusId($status->id)->delete(); + StatusView::whereStatusId($status->id)->delete(); + Status::whereInReplyToId($status->id)->update(['in_reply_to_id' => null]); + + $status->delete(); + + StatusService::del($status->id, true); + AccountService::del($status->profile_id); + + return 1; + } +} diff --git a/app/Jobs/StatusPipeline/StatusEntityLexer.php b/app/Jobs/StatusPipeline/StatusEntityLexer.php index d205f1e21..2bbc92102 100644 --- a/app/Jobs/StatusPipeline/StatusEntityLexer.php +++ b/app/Jobs/StatusPipeline/StatusEntityLexer.php @@ -20,6 +20,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use App\Services\UserFilterService; +use App\Services\AdminShadowFilterService; class StatusEntityLexer implements ShouldQueue { @@ -176,7 +177,9 @@ class StatusEntityLexer implements ShouldQueue $status->reblog_of_id === null && ($hideNsfw ? $status->is_nsfw == false : true) ) { - PublicTimelineService::add($status->id); + if(AdminShadowFilterService::canAddToPublicFeedByProfileId($status->profile_id)) { + PublicTimelineService::add($status->id); + } } if(config_cache('federation.activitypub.enabled') == true && config('app.env') == 'production') { diff --git a/app/Jobs/StatusPipeline/StatusTagsPipeline.php b/app/Jobs/StatusPipeline/StatusTagsPipeline.php index a72e6d50e..893fa6a83 100644 --- a/app/Jobs/StatusPipeline/StatusTagsPipeline.php +++ b/app/Jobs/StatusPipeline/StatusTagsPipeline.php @@ -45,6 +45,11 @@ class StatusTagsPipeline implements ShouldQueue { $res = $this->activity; $status = $this->status; + + if(isset($res['tag']['type'], $res['tag']['name'])) { + $res['tag'] = [$res['tag']]; + } + $tags = collect($res['tag']); // Emoji @@ -73,19 +78,18 @@ class StatusTagsPipeline implements ShouldQueue if(config('database.default') === 'pgsql') { $hashtag = Hashtag::where('name', 'ilike', $name) - ->orWhere('slug', 'ilike', str_slug($name)) + ->orWhere('slug', 'ilike', str_slug($name, '-', false)) ->first(); - if(!$hashtag) { - $hashtag = new Hashtag; - $hashtag->name = $name; - $hashtag->slug = str_slug($name); - $hashtag->save(); - } + if(!$hashtag) { + $hashtag = Hashtag::updateOrCreate([ + 'slug' => str_slug($name, '-', false), + 'name' => $name + ]); + } } else { - $hashtag = Hashtag::firstOrCreate([ - 'slug' => str_slug($name) - ], [ + $hashtag = Hashtag::updateOrCreate([ + 'slug' => str_slug($name, '-', false), 'name' => $name ]); } diff --git a/app/Media.php b/app/Media.php index 16448dbbf..b3f9ccba0 100644 --- a/app/Media.php +++ b/app/Media.php @@ -20,8 +20,8 @@ class Media extends Model protected $guarded = []; protected $casts = [ - 'srcset' => 'array', - 'deleted_at' => 'datetime' + 'srcset' => 'array', + 'deleted_at' => 'datetime' ]; public function status() @@ -63,7 +63,7 @@ class Media extends Model } if($this->media_path && $this->mime && in_array($this->mime, ['image/jpeg', 'image/png'])) { - return $this->remote_media || Str::startsWith($this->media_path, 'http') ? + return $this->remote_media || Str::startsWith($this->media_path, 'http') ? $this->media_path : url(Storage::url($this->media_path)); } @@ -78,6 +78,9 @@ class Media extends Model public function mimeType() { + if(!$this->mime) { + return; + } return explode('/', $this->mime)[0]; } diff --git a/app/Models/AdminShadowFilter.php b/app/Models/AdminShadowFilter.php new file mode 100644 index 000000000..f98086f7f --- /dev/null +++ b/app/Models/AdminShadowFilter.php @@ -0,0 +1,27 @@ + 'datetime' + ]; + + public function account() + { + if($this->item_type === 'App\Profile') { + return AccountService::get($this->item_id, true); + } + + return; + } +} diff --git a/app/Models/ProfileAlias.php b/app/Models/ProfileAlias.php new file mode 100644 index 000000000..b7a3bdc9c --- /dev/null +++ b/app/Models/ProfileAlias.php @@ -0,0 +1,17 @@ +belongsTo(Profile::class); + } +} diff --git a/app/Models/RemoteAuth.php b/app/Models/RemoteAuth.php new file mode 100644 index 000000000..98909f09b --- /dev/null +++ b/app/Models/RemoteAuth.php @@ -0,0 +1,19 @@ + 'array', + 'last_successful_login_at' => 'datetime', + 'last_verify_credentials_at' => 'datetime' + ]; +} diff --git a/app/Models/RemoteAuthInstance.php b/app/Models/RemoteAuthInstance.php new file mode 100644 index 000000000..bdc03fcb2 --- /dev/null +++ b/app/Models/RemoteAuthInstance.php @@ -0,0 +1,13 @@ +hasMany(Story::class); } - public function reported() { return $this->hasMany(Report::class, 'object_id'); } + + public function aliases() + { + return $this->hasMany(ProfileAlias::class); + } } diff --git a/app/Services/Account/RemoteAuthService.php b/app/Services/Account/RemoteAuthService.php new file mode 100644 index 000000000..4412352a5 --- /dev/null +++ b/app/Services/Account/RemoteAuthService.php @@ -0,0 +1,191 @@ + config('remote-auth.mastodon.domains.only_default'), + 'custom_only' => config('remote-auth.mastodon.domains.only_custom'), + ]); + } + + public static function getMastodonClient($domain) + { + if(RemoteAuthInstance::whereDomain($domain)->exists()) { + return RemoteAuthInstance::whereDomain($domain)->first(); + } + + try { + $url = 'https://' . $domain . '/api/v1/apps'; + $res = Http::asForm()->throw()->timeout(10)->post($url, [ + 'client_name' => config('pixelfed.domain.app', 'pixelfed'), + 'redirect_uris' => url('/auth/mastodon/callback'), + 'scopes' => 'read', + 'website' => 'https://pixelfed.org' + ]); + + if(!$res->ok()) { + return false; + } + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (Exception $e) { + return false; + } + + $body = $res->json(); + + if(!$body || !isset($body['client_id'])) { + return false; + } + + $raw = RemoteAuthInstance::updateOrCreate([ + 'domain' => $domain + ], [ + 'client_id' => $body['client_id'], + 'client_secret' => $body['client_secret'], + 'redirect_uri' => $body['redirect_uri'], + ]); + + return $raw; + } + + public static function getToken($domain, $code) + { + $raw = RemoteAuthInstance::whereDomain($domain)->first(); + if(!$raw || !$raw->active || $raw->banned) { + return false; + } + + $url = 'https://' . $domain . '/oauth/token'; + $res = Http::asForm()->post($url, [ + 'code' => $code, + 'grant_type' => 'authorization_code', + 'client_id' => $raw->client_id, + 'client_secret' => $raw->client_secret, + 'redirect_uri' => $raw->redirect_uri, + 'scope' => 'read' + ]); + + return $res; + } + + public static function getVerifyCredentials($domain, $code) + { + $raw = RemoteAuthInstance::whereDomain($domain)->first(); + if(!$raw || !$raw->active || $raw->banned) { + return false; + } + + $url = 'https://' . $domain . '/api/v1/accounts/verify_credentials'; + + $res = Http::withToken($code)->get($url); + + return $res->json(); + } + + public static function getFollowing($domain, $code, $id) + { + $raw = RemoteAuthInstance::whereDomain($domain)->first(); + if(!$raw || !$raw->active || $raw->banned) { + return false; + } + + $url = 'https://' . $domain . '/api/v1/accounts/' . $id . '/following?limit=80'; + $key = self::CACHE_KEY . 'get-following:code:' . substr($code, 0, 16) . substr($code, -5) . ':domain:' . $domain. ':id:' .$id; + + return Cache::remember($key, 3600, function() use($url, $code) { + $res = Http::withToken($code)->get($url); + return $res->json(); + }); + } + + public static function isDomainCompatible($domain = false) + { + if(!$domain) { + return false; + } + + return Cache::remember(self::CACHE_KEY . 'domain-compatible:' . $domain, 14400, function() use($domain) { + try { + $res = Http::timeout(20)->retry(3, 750)->get('https://beagle.pixelfed.net/api/v1/raa/domain?domain=' . $domain); + if(!$res->ok()) { + return false; + } + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (Exception $e) { + return false; + } + $json = $res->json(); + + if(!in_array('compatible', $json)) { + return false; + } + + return $res['compatible']; + }); + } + + public static function lookupWebfingerUses($wf) + { + try { + $res = Http::timeout(20)->retry(3, 750)->get('https://beagle.pixelfed.net/api/v1/raa/lookup?webfinger=' . $wf); + if(!$res->ok()) { + return false; + } + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (Exception $e) { + return false; + } + $json = $res->json(); + if(!$json || !isset($json['count'])) { + return false; + } + + return $json['count']; + } + + public static function submitToBeagle($ow, $ou, $dw, $du) + { + try { + $url = 'https://beagle.pixelfed.net/api/v1/raa/submit'; + $res = Http::throw()->timeout(10)->get($url, [ + 'ow' => $ow, + 'ou' => $ou, + 'dw' => $dw, + 'du' => $du, + ]); + + if(!$res->ok()) { + return; + } + } catch (RequestException $e) { + return; + } catch (ConnectionException $e) { + return; + } catch (Exception $e) { + return; + } + + return; + } +} diff --git a/app/Services/ActivityPubFetchService.php b/app/Services/ActivityPubFetchService.php index d0accdcdc..3d1980a11 100644 --- a/app/Services/ActivityPubFetchService.php +++ b/app/Services/ActivityPubFetchService.php @@ -11,11 +11,13 @@ use Illuminate\Http\Client\RequestException; class ActivityPubFetchService { - public static function get($url) + public static function get($url, $validateUrl = true) { - if(!Helpers::validateUrl($url)) { - return 0; - } + if($validateUrl === true) { + if(!Helpers::validateUrl($url)) { + return 0; + } + } $baseHeaders = [ 'Accept' => 'application/activity+json, application/ld+json', diff --git a/app/Services/AdminShadowFilterService.php b/app/Services/AdminShadowFilterService.php new file mode 100644 index 000000000..a5933508a --- /dev/null +++ b/app/Services/AdminShadowFilterService.php @@ -0,0 +1,51 @@ +whereActive(1) + ->where('hide_from_public_feeds', true) + ->pluck('item_id') + ->toArray(); + } + + public static function getHideFromPublicFeedsList($refresh = false) + { + $key = self::CACHE_KEY . 'list:hide_from_public_feeds'; + if($refresh) { + Cache::forget($key); + } + return Cache::remember($key, 86400, function() { + return AdminShadowFilter::whereItemType('App\Profile') + ->whereActive(1) + ->where('hide_from_public_feeds', true) + ->pluck('item_id') + ->toArray(); + }); + } + + public static function canAddToPublicFeedByProfileId($profileId) + { + return !in_array($profileId, self::getHideFromPublicFeedsList()); + } + + public static function refresh() + { + $keys = [ + self::CACHE_KEY . 'list:hide_from_public_feeds' + ]; + + foreach($keys as $key) { + Cache::forget($key); + } + } +} diff --git a/app/Services/DomainService.php b/app/Services/DomainService.php new file mode 100644 index 000000000..01f050ca0 --- /dev/null +++ b/app/Services/DomainService.php @@ -0,0 +1,28 @@ + 0; + }); + } +} diff --git a/app/Services/FollowerService.php b/app/Services/FollowerService.php index 9398fa53f..1c00a6f49 100644 --- a/app/Services/FollowerService.php +++ b/app/Services/FollowerService.php @@ -20,10 +20,14 @@ class FollowerService const FOLLOWING_KEY = 'pf:services:follow:following:id:'; const FOLLOWERS_KEY = 'pf:services:follow:followers:id:'; - public static function add($actor, $target) + public static function add($actor, $target, $refresh = true) { $ts = (int) microtime(true); - RelationshipService::refresh($actor, $target); + if($refresh) { + RelationshipService::refresh($actor, $target); + } else { + RelationshipService::forget($actor, $target); + } Redis::zadd(self::FOLLOWING_KEY . $actor, $ts, $target); Redis::zadd(self::FOLLOWERS_KEY . $target, $ts, $actor); Cache::forget('profile:following:' . $actor); diff --git a/app/Services/InstanceService.php b/app/Services/InstanceService.php index 1cead8d48..2ad991063 100644 --- a/app/Services/InstanceService.php +++ b/app/Services/InstanceService.php @@ -120,6 +120,9 @@ class InstanceService $pixels[] = $row; } + // Free the allocated GdImage object from memory: + imagedestroy($image); + $components_x = 4; $components_y = 4; $blurhash = Blurhash::encode($pixels, $components_x, $components_y); diff --git a/app/Services/LikeService.php b/app/Services/LikeService.php index 34a2417d0..f0ea1ac57 100644 --- a/app/Services/LikeService.php +++ b/app/Services/LikeService.php @@ -24,9 +24,7 @@ class LikeService { public static function setAdd($profileId, $statusId) { if(self::setCount($profileId) > 400) { - if(config('database.redis.client') === 'phpredis') { - Redis::zpopmin(self::CACHE_SET_KEY . $profileId); - } + Redis::zpopmin(self::CACHE_SET_KEY . $profileId); } return Redis::zadd(self::CACHE_SET_KEY . $profileId, $statusId, $statusId); diff --git a/app/Services/MediaStorageService.php b/app/Services/MediaStorageService.php index fd70e3a0f..b547ee39c 100644 --- a/app/Services/MediaStorageService.php +++ b/app/Services/MediaStorageService.php @@ -16,6 +16,7 @@ use App\Services\AccountService; use App\Http\Controllers\AvatarController; use GuzzleHttp\Exception\RequestException; use App\Jobs\MediaPipeline\MediaDeletePipeline; +use Illuminate\Support\Arr; class MediaStorageService { @@ -42,27 +43,16 @@ class MediaStorageService { return false; } - $h = $r->getHeaders(); + $h = Arr::mapWithKeys($r->getHeaders(), function($item, $key) { + return [strtolower($key) => last($item)]; + }); - if (isset($h['content-length']) && isset($h['content-type'])) { - if(empty($h['content-length']) || empty($h['content-type'])) { - return false; - } - $len = is_array($h['content-length']) ? $h['content-length'][0] : $h['content-length']; - $mime = is_array($h['content-type']) ? $h['content-type'][0] : $h['content-type']; - } else { - if (isset($h['Content-Length'], $h['Content-Type']) == false) { - return false; - } - - if(empty($h['Content-Length']) || empty($h['Content-Type']) ) { - return false; - } - - $len = is_array($h['Content-Length']) ? $h['Content-Length'][0] : $h['Content-Length']; - $mime = is_array($h['Content-Type']) ? $h['Content-Type'][0] : $h['Content-Type']; - } + if(!isset($h['content-length'], $h['content-type'])) { + return false; + } + $len = (int) $h['content-length']; + $mime = $h['content-type']; if($len < 10 || $len > ((config_cache('pixelfed.max_photo_size') * 1000))) { return false; @@ -96,12 +86,11 @@ class MediaStorageService { $thumbname = array_pop($pt); $storagePath = implode('/', $p); - $disk = Storage::disk(config('filesystems.cloud')); - $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); - $url = $disk->url($file); - $thumbFile = $disk->putFileAs($storagePath, new File($thumb), $thumbname, 'public'); - $thumbUrl = $disk->url($thumbFile); - $media->thumbnail_url = $thumbUrl; + $url = ResilientMediaStorageService::store($storagePath, $path, $name); + if($thumb) { + $thumbUrl = ResilientMediaStorageService::store($storagePath, $thumb, $thumbname); + $media->thumbnail_url = $thumbUrl; + } $media->cdn_url = $url; $media->optimized_url = $url; $media->replicated_at = now(); diff --git a/app/Services/NetworkTimelineService.php b/app/Services/NetworkTimelineService.php index 570899017..9aea47af4 100644 --- a/app/Services/NetworkTimelineService.php +++ b/app/Services/NetworkTimelineService.php @@ -49,9 +49,7 @@ class NetworkTimelineService public static function add($val) { if(self::count() > config('instance.timeline.network.cache_dropoff')) { - if(config('database.redis.client') === 'phpredis') { - Redis::zpopmin(self::CACHE_KEY); - } + Redis::zpopmin(self::CACHE_KEY); } return Redis::zadd(self::CACHE_KEY, $val, $val); diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 139b13a69..c068f8278 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -16,6 +16,8 @@ use League\Fractal\Pagination\IlluminatePaginatorAdapter; class NotificationService { const CACHE_KEY = 'pf:services:notifications:ids:'; + const EPOCH_CACHE_KEY = 'pf:services:notifications:epoch-id:by-months:'; + const ITEM_CACHE_TTL = 86400; const MASTODON_TYPES = [ 'follow', 'follow_request', @@ -44,11 +46,19 @@ class NotificationService { return $res; } + public static function getEpochId($months = 6) + { + return Cache::remember(self::EPOCH_CACHE_KEY . $months, 1209600, function() use($months) { + return Notification::where('created_at', '>', now()->subMonths($months))->first()->id; + }); + } + public static function coldGet($id, $start = 0, $stop = 400) { $stop = $stop > 400 ? 400 : $stop; - $ids = Notification::whereProfileId($id) - ->latest() + $ids = Notification::where('id', '>', self::getEpochId()) + ->where('profile_id', $id) + ->orderByDesc('id') ->skip($start) ->take($stop) ->pluck('id'); @@ -227,7 +237,7 @@ class NotificationService { public static function getNotification($id) { - $notification = Cache::remember('service:notification:'.$id, 86400, function() use($id) { + $notification = Cache::remember('service:notification:'.$id, self::ITEM_CACHE_TTL, function() use($id) { $n = Notification::with('item')->find($id); if(!$n) { @@ -259,19 +269,20 @@ class NotificationService { public static function setNotification(Notification $notification) { - return Cache::remember('service:notification:'.$notification->id, now()->addDays(3), function() use($notification) { + return Cache::remember('service:notification:'.$notification->id, self::ITEM_CACHE_TTL, function() use($notification) { $fractal = new Fractal\Manager(); $fractal->setSerializer(new ArraySerializer()); $resource = new Fractal\Resource\Item($notification, new NotificationTransformer()); return $fractal->createData($resource)->toArray(); }); - } + } public static function warmCache($id, $stop = 400, $force = false) { if(self::count($id) == 0 || $force == true) { - $ids = Notification::whereProfileId($id) - ->latest() + $ids = Notification::where('profile_id', $id) + ->where('id', '>', self::getEpochId()) + ->orderByDesc('id') ->limit($stop) ->pluck('id'); foreach($ids as $key) { diff --git a/app/Services/PublicTimelineService.php b/app/Services/PublicTimelineService.php index e1275065c..7cd6816b3 100644 --- a/app/Services/PublicTimelineService.php +++ b/app/Services/PublicTimelineService.php @@ -49,9 +49,7 @@ class PublicTimelineService { public static function add($val) { if(self::count() > 400) { - if(config('database.redis.client') === 'phpredis') { - Redis::zpopmin(self::CACHE_KEY); - } + Redis::zpopmin(self::CACHE_KEY); } return Redis::zadd(self::CACHE_KEY, $val, $val); @@ -97,7 +95,7 @@ class PublicTimelineService { if(self::count() == 0 || $force == true) { $hideNsfw = config('instance.hide_nsfw_on_public_feeds'); Redis::del(self::CACHE_KEY); - $minId = SnowflakeService::byDate(now()->subDays(14)); + $minId = SnowflakeService::byDate(now()->subDays(90)); $ids = Status::where('id', '>', $minId) ->whereNull(['uri', 'in_reply_to_id', 'reblog_of_id']) ->when($hideNsfw, function($q, $hideNsfw) { @@ -107,9 +105,11 @@ class PublicTimelineService { ->whereScope('public') ->orderByDesc('id') ->limit($limit) - ->pluck('id'); - foreach($ids as $id) { - self::add($id); + ->pluck('id', 'profile_id'); + foreach($ids as $k => $id) { + if(AdminShadowFilterService::canAddToPublicFeedByProfileId($k)) { + self::add($id); + } } return 1; } diff --git a/app/Services/RelationshipService.php b/app/Services/RelationshipService.php index 3c6d2818f..476c9c9ae 100644 --- a/app/Services/RelationshipService.php +++ b/app/Services/RelationshipService.php @@ -66,6 +66,14 @@ class RelationshipService return self::get($aid, $tid); } + public static function forget($aid, $tid) + { + Cache::forget('pf:services:follower:audience:' . $aid); + Cache::forget('pf:services:follower:audience:' . $tid); + self::delete($tid, $aid); + self::delete($aid, $tid); + } + public static function defaultRelation($tid) { return [ diff --git a/app/Services/ResilientMediaStorageService.php b/app/Services/ResilientMediaStorageService.php new file mode 100644 index 000000000..ac1b089af --- /dev/null +++ b/app/Services/ResilientMediaStorageService.php @@ -0,0 +1,66 @@ +putFileAs($storagePath, new File($path), $name, 'public'); + return $disk->url($file); + }, random_int(100, 500)); + } + + public static function handleResilientStore($storagePath, $path, $name) + { + $attempts = 0; + return retry(4, function() use($storagePath, $path, $name, $attempts) { + self::$attempts++; + usleep(100000); + $baseDisk = self::$attempts > 1 ? self::getAltDriver() : config('filesystems.cloud'); + try { + $disk = Storage::disk($baseDisk); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + } catch (S3Exception | ClientException | ConnectException | UnableToWriteFile | Exception $e) {} + return $disk->url($file); + }, function (int $attempt, Exception $exception) { + return $attempt * 200; + }); + } + + public static function getAltDriver() + { + $drivers = []; + if(config('filesystems.disks.alt-primary.enabled')) { + $drivers[] = 'alt-primary'; + } + if(config('filesystems.disks.alt-secondary.enabled')) { + $drivers[] = 'alt-secondary'; + } + if(empty($drivers)) { + return false; + } + $key = array_rand($drivers, 1); + return $drivers[$key]; + } +} diff --git a/app/Services/SearchApiV2Service.php b/app/Services/SearchApiV2Service.php index 465bd98a4..90691f0bd 100644 --- a/app/Services/SearchApiV2Service.php +++ b/app/Services/SearchApiV2Service.php @@ -18,317 +18,355 @@ use App\Services\StatusService; class SearchApiV2Service { - private $query; - static $mastodonMode = false; + private $query; + static $mastodonMode = false; - public static function query($query, $mastodonMode = false) - { - self::$mastodonMode = $mastodonMode; - return (new self)->run($query); - } + public static function query($query, $mastodonMode = false) + { + self::$mastodonMode = $mastodonMode; + return (new self)->run($query); + } - protected function run($query) - { - $this->query = $query; - $q = urldecode($query->input('q')); + protected function run($query) + { + $this->query = $query; + $q = urldecode($query->input('q')); - if($query->has('resolve') && - ( Str::startsWith($q, 'https://') || - Str::substrCount($q, '@') >= 1) - ) { - return $this->resolveQuery(); - } + if($query->has('resolve') && + ( Str::startsWith($q, 'https://') || + Str::substrCount($q, '@') >= 1) + ) { + return $this->resolveQuery(); + } - if($query->has('type')) { - switch ($query->input('type')) { - case 'accounts': - return [ - 'accounts' => $this->accounts(), - 'hashtags' => [], - 'statuses' => [] - ]; - break; - case 'hashtags': - return [ - 'accounts' => [], - 'hashtags' => $this->hashtags(), - 'statuses' => [] - ]; - break; - case 'statuses': - return [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => $this->statuses() - ]; - break; - } - } + if($query->has('type')) { + switch ($query->input('type')) { + case 'accounts': + return [ + 'accounts' => $this->accounts(), + 'hashtags' => [], + 'statuses' => [] + ]; + break; + case 'hashtags': + return [ + 'accounts' => [], + 'hashtags' => $this->hashtags(), + 'statuses' => [] + ]; + break; + case 'statuses': + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => $this->statuses() + ]; + break; + } + } - if($query->has('account_id')) { - return [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => $this->statusesById() - ]; - } + if($query->has('account_id')) { + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => $this->statusesById() + ]; + } - return [ - 'accounts' => $this->accounts(), - 'hashtags' => $this->hashtags(), - 'statuses' => $this->statuses() - ]; - } + return [ + 'accounts' => $this->accounts(), + 'hashtags' => $this->hashtags(), + 'statuses' => $this->statuses() + ]; + } - protected function accounts($initalQuery = false) - { - $mastodonMode = self::$mastodonMode; - $user = request()->user(); - $limit = $this->query->input('limit') ?? 20; - $offset = $this->query->input('offset') ?? 0; - $rawQuery = $initalQuery ? $initalQuery : $this->query->input('q'); - $query = $rawQuery . '%'; - $webfingerQuery = $query; - if(Str::substrCount($rawQuery, '@') == 1 && substr($rawQuery, 0, 1) !== '@') { - $query = '@' . $query; - } - if(substr($webfingerQuery, 0, 1) !== '@') { - $webfingerQuery = '@' . $webfingerQuery; - } - $banned = InstanceService::getBannedDomains(); - $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like'; - $results = Profile::select('username', 'id', 'followers_count', 'domain') - ->where('username', $operator, $query) - ->orWhere('webfinger', $operator, $webfingerQuery) - ->orderByDesc('profiles.followers_count') - ->offset($offset) - ->limit($limit) - ->get() - ->filter(function($profile) use ($banned) { - return in_array($profile->domain, $banned) == false; - }) - ->map(function($res) use($mastodonMode) { - return $mastodonMode ? - AccountService::getMastodon($res['id']) : - AccountService::get($res['id']); - }) - ->filter(function($account) { - return $account && isset($account['id']); - }) - ->values(); + protected function accounts($initalQuery = false) + { + $mastodonMode = self::$mastodonMode; + $user = request()->user(); + $limit = $this->query->input('limit') ?? 20; + $offset = $this->query->input('offset') ?? 0; + $rawQuery = $initalQuery ? $initalQuery : $this->query->input('q'); + $query = $rawQuery . '%'; + $webfingerQuery = $query; + if(Str::substrCount($rawQuery, '@') == 1 && substr($rawQuery, 0, 1) !== '@') { + $query = '@' . $query; + } + if(substr($webfingerQuery, 0, 1) !== '@') { + $webfingerQuery = '@' . $webfingerQuery; + } + $banned = InstanceService::getBannedDomains(); + $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like'; + $results = Profile::select('username', 'id', 'followers_count', 'domain') + ->where('username', $operator, $query) + ->orWhere('webfinger', $operator, $webfingerQuery) + ->orderByDesc('profiles.followers_count') + ->offset($offset) + ->limit($limit) + ->get() + ->filter(function($profile) use ($banned) { + return in_array($profile->domain, $banned) == false; + }) + ->map(function($res) use($mastodonMode) { + return $mastodonMode ? + AccountService::getMastodon($res['id']) : + AccountService::get($res['id']); + }) + ->filter(function($account) { + return $account && isset($account['id']); + }) + ->values(); - return $results; - } + return $results; + } - protected function hashtags() - { - $mastodonMode = self::$mastodonMode; - $q = $this->query->input('q'); - $limit = $this->query->input('limit') ?? 20; - $offset = $this->query->input('offset') ?? 0; - $query = Str::startsWith($q, '#') ? '%' . substr($q, 1) . '%' : '%' . $q . '%'; - $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like'; - return Hashtag::where('name', $operator, $query) - ->orWhere('slug', $operator, $query) - ->where(function($q) { - return $q->where('can_search', true) - ->orWhereNull('can_search'); - }) - ->orderByDesc('cached_count') - ->offset($offset) - ->limit($limit) - ->get() - ->map(function($tag) use($mastodonMode) { - $res = [ - 'name' => $tag->name, - 'url' => $tag->url() - ]; + protected function hashtags() + { + $mastodonMode = self::$mastodonMode; + $q = $this->query->input('q'); + $limit = $this->query->input('limit') ?? 20; + $offset = $this->query->input('offset') ?? 0; + $query = Str::startsWith($q, '#') ? '%' . substr($q, 1) . '%' : '%' . $q . '%'; + $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like'; + return Hashtag::where('name', $operator, $query) + ->orWhere('slug', $operator, $query) + ->where(function($q) { + return $q->where('can_search', true) + ->orWhereNull('can_search'); + }) + ->orderByDesc('cached_count') + ->offset($offset) + ->limit($limit) + ->get() + ->map(function($tag) use($mastodonMode) { + $res = [ + 'name' => $tag->name, + 'url' => $tag->url() + ]; - if(!$mastodonMode) { - $res['history'] = []; - $res['count'] = HashtagService::count($tag->id); - } + if(!$mastodonMode) { + $res['history'] = []; + $res['count'] = HashtagService::count($tag->id); + } - return $res; - }); - } + return $res; + }); + } - protected function statuses() - { - // Removed until we provide more relevent sorting/results - return []; - } + protected function statuses() + { + // Removed until we provide more relevent sorting/results + return []; + } - protected function statusesById() - { - // Removed until we provide more relevent sorting/results - return []; - } + protected function statusesById() + { + // Removed until we provide more relevent sorting/results + return []; + } - protected function resolveQuery() - { - $default = [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => [], - ]; - $mastodonMode = self::$mastodonMode; - $query = urldecode($this->query->input('q')); - if(substr($query, 0, 1) === '@' && !Str::contains($query, '.')) { - $default['accounts'] = $this->accounts(substr($query, 1)); - return $default; - } - if(Helpers::validateLocalUrl($query)) { - if(Str::contains($query, '/p/')) { - return $this->resolveLocalStatus(); - } else { - return $this->resolveLocalProfile(); - } - } else { - if(!Helpers::validateUrl($query) && strpos($query, '@') == -1) { - return $default; - } + protected function resolveQuery() + { + $default = [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [], + ]; + $mastodonMode = self::$mastodonMode; + $query = urldecode($this->query->input('q')); + if(substr($query, 0, 1) === '@' && !Str::contains($query, '.')) { + $default['accounts'] = $this->accounts(substr($query, 1)); + return $default; + } + if(Helpers::validateLocalUrl($query)) { + if(Str::contains($query, '/p/') || Str::contains($query, 'i/web/post/')) { + return $this->resolveLocalStatus(); + } else if(Str::contains($query, 'i/web/profile/')) { + return $this->resolveLocalProfileId(); + } else { + return $this->resolveLocalProfile(); + } + } else { + if(!Helpers::validateUrl($query) && strpos($query, '@') == -1) { + return $default; + } - if(!Str::startsWith($query, 'http') && Str::substrCount($query, '@') == 1 && strpos($query, '@') !== 0) { - try { - $res = WebfingerService::lookup('@' . $query, $mastodonMode); - } catch (\Exception $e) { - return $default; - } - if($res && isset($res['id'])) { - $default['accounts'][] = $res; - return $default; - } else { - return $default; - } - } + if(!Str::startsWith($query, 'http') && Str::substrCount($query, '@') == 1 && strpos($query, '@') !== 0) { + try { + $res = WebfingerService::lookup('@' . $query, $mastodonMode); + } catch (\Exception $e) { + return $default; + } + if($res && isset($res['id'])) { + $default['accounts'][] = $res; + return $default; + } else { + return $default; + } + } - if(Str::substrCount($query, '@') == 2) { - try { - $res = WebfingerService::lookup($query, $mastodonMode); - } catch (\Exception $e) { - return $default; - } - if($res && isset($res['id'])) { - $default['accounts'][] = $res; - return $default; - } else { - return $default; - } - } + if(Str::substrCount($query, '@') == 2) { + try { + $res = WebfingerService::lookup($query, $mastodonMode); + } catch (\Exception $e) { + return $default; + } + if($res && isset($res['id'])) { + $default['accounts'][] = $res; + return $default; + } else { + return $default; + } + } - try { - $res = ActivityPubFetchService::get($query); - $banned = InstanceService::getBannedDomains(); - if($res) { - $json = json_decode($res, true); + if($sid = Status::whereUri($query)->first()) { + $s = StatusService::get($sid->id, false); + if(in_array($s['visibility'], ['public', 'unlisted'])) { + $default['statuses'][] = $s; + return $default; + } + } - if(!$json || !isset($json['@context']) || !isset($json['type']) || !in_array($json['type'], ['Note', 'Person'])) { - return [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => [], - ]; - } + try { + $res = ActivityPubFetchService::get($query); + $banned = InstanceService::getBannedDomains(); + if($res) { + $json = json_decode($res, true); - switch($json['type']) { - case 'Note': - $obj = Helpers::statusFetch($query); - if(!$obj || !isset($obj['id'])) { - return $default; - } - $note = $mastodonMode ? - StatusService::getMastodon($obj['id']) : - StatusService::get($obj['id']); - if(!$note) { - return $default; - } - $default['statuses'][] = $note; - return $default; - break; + if(!$json || !isset($json['@context']) || !isset($json['type']) || !in_array($json['type'], ['Note', 'Person'])) { + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [], + ]; + } - case 'Person': - $obj = Helpers::profileFetch($query); - if(!$obj) { - return $default; - } - if(in_array($obj['domain'], $banned)) { - return $default; - } - $default['accounts'][] = $mastodonMode ? - AccountService::getMastodon($obj['id']) : - AccountService::get($obj['id']); - return $default; - break; + switch($json['type']) { + case 'Note': + $obj = Helpers::statusFetch($query); + if(!$obj || !isset($obj['id'])) { + return $default; + } + $note = $mastodonMode ? + StatusService::getMastodon($obj['id'], false) : + StatusService::get($obj['id'], false); + if(!$note) { + return $default; + } + if(!isset($note['visibility']) || !in_array($note['visibility'], ['public', 'unlisted'])) { + return $default; + } + $default['statuses'][] = $note; + return $default; + break; - default: - return [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => [], - ]; - break; - } - } - } catch (\Exception $e) { - return [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => [], - ]; - } + case 'Person': + $obj = Helpers::profileFetch($query); + if(!$obj) { + return $default; + } + if(in_array($obj['domain'], $banned)) { + return $default; + } + $default['accounts'][] = $mastodonMode ? + AccountService::getMastodon($obj['id'], true) : + AccountService::get($obj['id'], true); + return $default; + break; - return $default; - } - } + default: + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [], + ]; + break; + } + } + } catch (\Exception $e) { + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [], + ]; + } - protected function resolveLocalStatus() - { - $query = urldecode($this->query->input('q')); - $query = last(explode('/', $query)); - $status = StatusService::getMastodon($query); - if(!$status) { - return [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => [] - ]; - } + return $default; + } + } - $res = [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => [$status] - ]; + protected function resolveLocalStatus() + { + $query = urldecode($this->query->input('q')); + $query = last(explode('/', parse_url($query, PHP_URL_PATH))); + $status = StatusService::getMastodon($query, false); + if(!$status || !in_array($status['visibility'], ['public', 'unlisted'])) { + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [] + ]; + } - return $res; - } + $res = [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [$status] + ]; - protected function resolveLocalProfile() - { - $query = urldecode($this->query->input('q')); - $query = last(explode('/', $query)); - $profile = Profile::whereNull('status') - ->whereNull('domain') - ->whereUsername($query) - ->first(); + return $res; + } - if(!$profile) { - return [ - 'accounts' => [], - 'hashtags' => [], - 'statuses' => [] - ]; - } + protected function resolveLocalProfile() + { + $query = urldecode($this->query->input('q')); + $query = last(explode('/', parse_url($query, PHP_URL_PATH))); + $profile = Profile::whereNull('status') + ->whereNull('domain') + ->whereUsername($query) + ->first(); - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); - return [ - 'accounts' => $fractal->createData($resource)->toArray(), - 'hashtags' => [], - 'statuses' => [] - ]; - } + if(!$profile) { + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [] + ]; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); + return [ + 'accounts' => [$fractal->createData($resource)->toArray()], + 'hashtags' => [], + 'statuses' => [] + ]; + } + + protected function resolveLocalProfileId() + { + $query = urldecode($this->query->input('q')); + $query = last(explode('/', parse_url($query, PHP_URL_PATH))); + $profile = Profile::whereNull('status') + ->find($query); + + if(!$profile) { + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [] + ]; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); + return [ + 'accounts' => [$fractal->createData($resource)->toArray()], + 'hashtags' => [], + 'statuses' => [] + ]; + } } diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index 99bcee2b0..4051bede4 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -14,173 +14,181 @@ use League\Fractal\Pagination\IlluminatePaginatorAdapter; class StatusService { - const CACHE_KEY = 'pf:services:status:'; + const CACHE_KEY = 'pf:services:status:'; - public static function key($id, $publicOnly = true) - { - $p = $publicOnly ? 'pub:' : 'all:'; - return self::CACHE_KEY . $p . $id; - } + public static function key($id, $publicOnly = true) + { + $p = $publicOnly ? 'pub:' : 'all:'; + return self::CACHE_KEY . $p . $id; + } - public static function get($id, $publicOnly = true) - { - return Cache::remember(self::key($id, $publicOnly), now()->addDays(7), function() use($id, $publicOnly) { - if($publicOnly) { - $status = Status::whereScope('public')->find($id); - } else { - $status = Status::whereIn('scope', ['public', 'private', 'unlisted', 'group'])->find($id); - } - if(!$status) { - return null; - } - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); - return $fractal->createData($resource)->toArray(); - }); - } + public static function get($id, $publicOnly = true, $mastodonMode = false) + { + $res = Cache::remember(self::key($id, $publicOnly), 21600, function() use($id, $publicOnly) { + if($publicOnly) { + $status = Status::whereScope('public')->find($id); + } else { + $status = Status::whereIn('scope', ['public', 'private', 'unlisted', 'group'])->find($id); + } + if(!$status) { + return null; + } + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); + $res = $fractal->createData($resource)->toArray(); + $res['_pid'] = isset($res['account']) && isset($res['account']['id']) ? $res['account']['id'] : null; + if(isset($res['_pid'])) { + unset($res['account']); + } + return $res; + }); + if($res && isset($res['_pid'])) { + $res['account'] = $mastodonMode === true ? AccountService::getMastodon($res['_pid'], true) : AccountService::get($res['_pid'], true); + unset($res['_pid']); + } + return $res; + } - public static function getMastodon($id, $publicOnly = true) - { - $status = self::get($id, $publicOnly); - if(!$status) { - return null; - } + public static function getMastodon($id, $publicOnly = true) + { + $status = self::get($id, $publicOnly, true); + if(!$status) { + return null; + } - if(!isset($status['account'])) { - return null; - } + if(!isset($status['account'])) { + return null; + } $status['replies_count'] = $status['reply_count']; - if(config('exp.emc') == false) { - return $status; - } + if(config('exp.emc') == false) { + return $status; + } - unset( - $status['_v'], - $status['comments_disabled'], - $status['content_text'], - $status['gid'], - $status['label'], - $status['liked_by'], - $status['local'], - $status['parent'], - $status['pf_type'], - $status['place'], - $status['replies'], - $status['reply_count'], - $status['shortcode'], - $status['taggedPeople'], - $status['thread'], - $status['pinned'], - $status['account']['header_bg'], - $status['account']['is_admin'], - $status['account']['last_fetched_at'], - $status['account']['local'], - $status['account']['location'], - $status['account']['note_text'], - $status['account']['pronouns'], - $status['account']['website'], - $status['media_attachments'], - ); - $status['account']['avatar_static'] = $status['account']['avatar']; - $status['account']['bot'] = false; - $status['account']['emojis'] = []; - $status['account']['fields'] = []; - $status['account']['header'] = url('/storage/headers/missing.png'); - $status['account']['header_static'] = url('/storage/headers/missing.png'); - $status['account']['last_status_at'] = null; + unset( + $status['_v'], + $status['comments_disabled'], + $status['content_text'], + $status['gid'], + $status['label'], + $status['liked_by'], + $status['local'], + $status['parent'], + $status['pf_type'], + $status['place'], + $status['replies'], + $status['reply_count'], + $status['shortcode'], + $status['taggedPeople'], + $status['thread'], + $status['pinned'], + $status['account']['header_bg'], + $status['account']['is_admin'], + $status['account']['last_fetched_at'], + $status['account']['local'], + $status['account']['location'], + $status['account']['note_text'], + $status['account']['pronouns'], + $status['account']['website'], + $status['media_attachments'], + ); + $status['account']['avatar_static'] = $status['account']['avatar']; + $status['account']['bot'] = false; + $status['account']['emojis'] = []; + $status['account']['fields'] = []; + $status['account']['header'] = url('/storage/headers/missing.png'); + $status['account']['header_static'] = url('/storage/headers/missing.png'); + $status['account']['last_status_at'] = null; - $status['media_attachments'] = array_values(MediaService::getMastodon($status['id'])); - $status['muted'] = false; - $status['reblogged'] = false; + $status['media_attachments'] = array_values(MediaService::getMastodon($status['id'])); + $status['muted'] = false; + $status['reblogged'] = false; - return $status; - } + return $status; + } - public static function getState($id, $pid) - { - $status = self::get($id, false); + public static function getState($id, $pid) + { + $status = self::get($id, false); - if(!$status) { - return [ - 'liked' => false, - 'shared' => false, - 'bookmarked' => false - ]; - } + if(!$status) { + return [ + 'liked' => false, + 'shared' => false, + 'bookmarked' => false + ]; + } - return [ - 'liked' => LikeService::liked($pid, $id), - 'shared' => self::isShared($id, $pid), - 'bookmarked' => self::isBookmarked($id, $pid) - ]; - } + return [ + 'liked' => LikeService::liked($pid, $id), + 'shared' => self::isShared($id, $pid), + 'bookmarked' => self::isBookmarked($id, $pid) + ]; + } - public static function getFull($id, $pid, $publicOnly = true) - { - $res = self::get($id, $publicOnly); - if(!$res || !isset($res['account']) || !isset($res['account']['id'])) { - return $res; - } - $res['relationship'] = RelationshipService::get($pid, $res['account']['id']); - return $res; - } + public static function getFull($id, $pid, $publicOnly = true) + { + $res = self::get($id, $publicOnly); + if(!$res || !isset($res['account']) || !isset($res['account']['id'])) { + return $res; + } + $res['relationship'] = RelationshipService::get($pid, $res['account']['id']); + return $res; + } - public static function getDirectMessage($id) - { - $status = Status::whereScope('direct')->find($id); + public static function getDirectMessage($id) + { + $status = Status::whereScope('direct')->find($id); - if(!$status) { - return null; - } + if(!$status) { + return null; + } - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); - return $fractal->createData($resource)->toArray(); - } + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); + return $fractal->createData($resource)->toArray(); + } - public static function del($id, $purge = false) - { - if($purge) { - $status = self::get($id); - if($status && isset($status['account']) && isset($status['account']['id'])) { - Cache::forget('profile:embed:' . $status['account']['id']); - } - Cache::forget('status:transformer:media:attachments:' . $id); - MediaService::del($id); - Cache::forget('status:thumb:nsfw0' . $id); - Cache::forget('status:thumb:nsfw1' . $id); - Cache::forget('pf:services:sh:id:' . $id); - PublicTimelineService::rem($id); - NetworkTimelineService::rem($id); - } + public static function del($id, $purge = false) + { + if($purge) { + $status = self::get($id); + if($status && isset($status['account']) && isset($status['account']['id'])) { + Cache::forget('profile:embed:' . $status['account']['id']); + } + Cache::forget('status:transformer:media:attachments:' . $id); + MediaService::del($id); + Cache::forget('pf:services:sh:id:' . $id); + PublicTimelineService::rem($id); + NetworkTimelineService::rem($id); + } - Cache::forget(self::key($id, false)); - return Cache::forget(self::key($id)); - } + Cache::forget(self::key($id, false)); + return Cache::forget(self::key($id)); + } - public static function refresh($id) - { - Cache::forget(self::key($id, false)); - Cache::forget(self::key($id, true)); - self::get($id, false); - self::get($id, true); - } + public static function refresh($id) + { + Cache::forget(self::key($id, false)); + Cache::forget(self::key($id, true)); + self::get($id, false); + self::get($id, true); + } - public static function isShared($id, $pid = null) - { - return $pid ? - ReblogService::get($pid, $id) : - false; - } + public static function isShared($id, $pid = null) + { + return $pid ? + ReblogService::get($pid, $id) : + false; + } - public static function isBookmarked($id, $pid = null) - { - return $pid ? - BookmarkService::get($pid, $id) : - false; - } + public static function isBookmarked($id, $pid = null) + { + return $pid ? + BookmarkService::get($pid, $id) : + false; + } } diff --git a/app/Status.php b/app/Status.php index 77262597e..d665464ae 100644 --- a/app/Status.php +++ b/app/Status.php @@ -9,7 +9,9 @@ use App\Http\Controllers\StatusController; use Illuminate\Database\Eloquent\SoftDeletes; use App\Models\Poll; use App\Services\AccountService; +use App\Services\StatusService; use App\Models\StatusEdit; +use Illuminate\Support\Str; class Status extends Model { @@ -95,16 +97,30 @@ class Status extends Model public function thumb($showNsfw = false) { - $key = $showNsfw ? 'status:thumb:nsfw1'.$this->id : 'status:thumb:nsfw0'.$this->id; - return Cache::remember($key, now()->addMinutes(15), function() use ($showNsfw) { - $type = $this->type ?? $this->setType(); - $is_nsfw = !$showNsfw ? $this->is_nsfw : false; - if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album', 'video'])) { - return url(Storage::url('public/no-preview.png')); - } + $entity = StatusService::get($this->id, false); - return url(Storage::url($this->firstMedia()->thumbnail_path)); - }); + if(!$entity || !isset($entity['media_attachments']) || empty($entity['media_attachments'])) { + return url(Storage::url('public/no-preview.png')); + } + + if((!isset($entity['sensitive']) || $entity['sensitive']) && !$showNsfw) { + return url(Storage::url('public/no-preview.png')); + } + + if(!isset($entity['visibility']) || !in_array($entity['visibility'], ['public', 'unlisted'])) { + return url(Storage::url('public/no-preview.png')); + } + + return collect($entity['media_attachments']) + ->filter(fn($media) => $media['type'] == 'image' && in_array($media['mime'], ['image/jpeg', 'image/png'])) + ->map(function($media) { + if(!Str::endsWith($media['preview_url'], ['no-preview.png', 'no-preview.jpg'])) { + return $media['preview_url']; + } + + return $media['url']; + }) + ->first() ?? url(Storage::url('public/no-preview.png')); } public function url($forceLocal = false) diff --git a/app/Transformer/ActivityPub/ProfileTransformer.php b/app/Transformer/ActivityPub/ProfileTransformer.php index 29f53425c..cdd4eb82d 100644 --- a/app/Transformer/ActivityPub/ProfileTransformer.php +++ b/app/Transformer/ActivityPub/ProfileTransformer.php @@ -4,17 +4,28 @@ namespace App\Transformer\ActivityPub; use App\Profile; use League\Fractal; +use App\Services\AccountService; class ProfileTransformer extends Fractal\TransformerAbstract { public function transform(Profile $profile) { - return [ + $res = [ '@context' => [ 'https://w3id.org/security/v1', 'https://www.w3.org/ns/activitystreams', [ + 'toot' => 'http://joinmastodon.org/ns#', 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', + 'alsoKnownAs' => [ + '@id' => 'as:alsoKnownAs', + '@type' => '@id' + ], + 'movedTo' => [ + '@id' => 'as:movedTo', + '@type' => '@id' + ], + 'indexable' => 'toot:indexable', ], ], 'id' => $profile->permalink(), @@ -28,6 +39,7 @@ class ProfileTransformer extends Fractal\TransformerAbstract 'summary' => $profile->bio, 'url' => $profile->url(), 'manuallyApprovesFollowers' => (bool) $profile->is_private, + 'indexable' => (bool) $profile->indexable, 'publicKey' => [ 'id' => $profile->permalink().'#main-key', 'owner' => $profile->permalink(), @@ -42,5 +54,15 @@ class ProfileTransformer extends Fractal\TransformerAbstract 'sharedInbox' => config('app.url') . '/f/inbox' ] ]; + + if($profile->aliases->count()) { + $res['alsoKnownAs'] = $profile->aliases->map(fn($alias) => $alias->uri); + } + + if($profile->moved_to_profile_id) { + $res['movedTo'] = AccountService::get($profile->moved_to_profile_id)['url']; + } + + return $res; } } diff --git a/app/Transformer/ActivityPub/Verb/CreateNote.php b/app/Transformer/ActivityPub/Verb/CreateNote.php index a9d40d9ed..55fdfa8f4 100644 --- a/app/Transformer/ActivityPub/Verb/CreateNote.php +++ b/app/Transformer/ActivityPub/Verb/CreateNote.php @@ -81,7 +81,8 @@ class CreateNote extends Fractal\TransformerAbstract '@type' => '@id' ], 'toot' => 'http://joinmastodon.org/ns#', - 'Emoji' => 'toot:Emoji' + 'Emoji' => 'toot:Emoji', + 'blurhash' => 'toot:blurhash', ] ], 'id' => $status->permalink(), @@ -103,12 +104,22 @@ class CreateNote extends Fractal\TransformerAbstract 'cc' => $status->scopeToAudience('cc'), 'sensitive' => (bool) $status->is_nsfw, 'attachment' => $status->media()->orderBy('order')->get()->map(function ($media) { - return [ + $res = [ 'type' => $media->activityVerb(), 'mediaType' => $media->mime, 'url' => $media->url(), 'name' => $media->caption, ]; + if($media->blurhash) { + $res['blurhash'] = $media->blurhash; + } + if($media->width) { + $res['width'] = $media->width; + } + if($media->height) { + $res['height'] = $media->height; + } + return $res; })->toArray(), 'tag' => $tags, 'commentsEnabled' => (bool) !$status->comments_disabled, diff --git a/app/Transformer/ActivityPub/Verb/Note.php b/app/Transformer/ActivityPub/Verb/Note.php index 777bd22b0..1350641d4 100644 --- a/app/Transformer/ActivityPub/Verb/Note.php +++ b/app/Transformer/ActivityPub/Verb/Note.php @@ -82,7 +82,8 @@ class Note extends Fractal\TransformerAbstract '@type' => '@id' ], 'toot' => 'http://joinmastodon.org/ns#', - 'Emoji' => 'toot:Emoji' + 'Emoji' => 'toot:Emoji', + 'blurhash' => 'toot:blurhash', ] ], 'id' => $status->url(), @@ -97,12 +98,22 @@ class Note extends Fractal\TransformerAbstract 'cc' => $status->scopeToAudience('cc'), 'sensitive' => (bool) $status->is_nsfw, 'attachment' => $status->media()->orderBy('order')->get()->map(function ($media) { - return [ + $res = [ 'type' => $media->activityVerb(), 'mediaType' => $media->mime, 'url' => $media->url(), 'name' => $media->caption, ]; + if($media->blurhash) { + $res['blurhash'] = $media->blurhash; + } + if($media->width) { + $res['width'] = $media->width; + } + if($media->height) { + $res['height'] = $media->height; + } + return $res; })->toArray(), 'tag' => $tags, 'commentsEnabled' => (bool) !$status->comments_disabled, diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index 2ed381c1a..c21720509 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -33,7 +33,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'url' => $status->url(), 'in_reply_to_id' => $status->in_reply_to_id ? (string) $status->in_reply_to_id : null, 'in_reply_to_account_id' => $status->in_reply_to_profile_id ? (string) $status->in_reply_to_profile_id : null, - 'reblog' => $status->reblog_of_id ? StatusService::get($status->reblog_of_id) : null, + 'reblog' => $status->reblog_of_id ? StatusService::get($status->reblog_of_id, false) : null, 'content' => $status->rendered ?? $status->caption, 'content_text' => $status->caption, 'created_at' => str_replace('+00:00', 'Z', $status->created_at->format(DATE_RFC3339_EXTENDED)), diff --git a/app/User.php b/app/User.php index 3f826c143..a39f650be 100644 --- a/app/User.php +++ b/app/User.php @@ -19,9 +19,10 @@ class User extends Authenticatable * @var array */ protected $casts = [ - 'deleted_at' => 'datetime', - 'email_verified_at' => 'datetime', - '2fa_setup_at' => 'datetime' + 'deleted_at' => 'datetime', + 'email_verified_at' => 'datetime', + '2fa_setup_at' => 'datetime', + 'last_active_at' => 'datetime', ]; /** @@ -30,7 +31,14 @@ class User extends Authenticatable * @var array */ protected $fillable = [ - 'name', 'username', 'email', 'password', 'app_register_ip' + 'name', + 'username', + 'email', + 'password', + 'app_register_ip', + 'email_verified_at', + 'last_active_at', + 'register_source' ]; /** @@ -39,8 +47,8 @@ class User extends Authenticatable * @var array */ protected $hidden = [ - 'email', 'password', 'is_admin', 'remember_token', - 'email_verified_at', '2fa_enabled', '2fa_secret', + 'email', 'password', 'is_admin', 'remember_token', + 'email_verified_at', '2fa_enabled', '2fa_secret', '2fa_backup_codes', '2fa_setup_at', 'deleted_at', 'updated_at' ]; @@ -100,11 +108,11 @@ class User extends Authenticatable public function avatarUrl() { - if(!$this->profile_id || $this->status) { - return config('app.url') . '/storage/avatars/default.jpg'; - } + if(!$this->profile_id || $this->status) { + return config('app.url') . '/storage/avatars/default.jpg'; + } - return AvatarService::get($this->profile_id); + return AvatarService::get($this->profile_id); } } diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index a61e1d572..2fa98b8bd 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -4,14 +4,14 @@ namespace App\Util\ActivityPub; use DB, Cache, Purify, Storage, Request, Validator; use App\{ - Activity, - Follower, - Instance, - Like, - Media, - Notification, - Profile, - Status + Activity, + Follower, + Instance, + Like, + Media, + Notification, + Profile, + Status }; use Zttp\Zttp; use Carbon\Carbon; @@ -40,766 +40,787 @@ use App\Models\Poll; use Illuminate\Contracts\Cache\LockTimeoutException; use App\Jobs\ProfilePipeline\IncrementPostCount; use App\Jobs\ProfilePipeline\DecrementPostCount; +use App\Services\DomainService; use App\Services\UserFilterService; class Helpers { - public static function validateObject($data) - { - $verbs = ['Create', 'Announce', 'Like', 'Follow', 'Delete', 'Accept', 'Reject', 'Undo', 'Tombstone']; - - $valid = Validator::make($data, [ - 'type' => [ - 'required', - 'string', - Rule::in($verbs) - ], - 'id' => 'required|string', - 'actor' => 'required|string|url', - 'object' => 'required', - 'object.type' => 'required_if:type,Create', - 'object.attributedTo' => 'required_if:type,Create|url', - 'published' => 'required_if:type,Create|date' - ])->passes(); - - return $valid; - } - - public static function verifyAttachments($data) - { - if(!isset($data['object']) || empty($data['object'])) { - $data = ['object'=>$data]; - } - - $activity = $data['object']; - - $mimeTypes = explode(',', config_cache('pixelfed.media_types')); - $mediaTypes = in_array('video/mp4', $mimeTypes) ? ['Document', 'Image', 'Video'] : ['Document', 'Image']; - - // Peertube - // $mediaTypes = in_array('video/mp4', $mimeTypes) ? ['Document', 'Image', 'Video', 'Link'] : ['Document', 'Image']; - - if(!isset($activity['attachment']) || empty($activity['attachment'])) { - return false; - } - - // peertube - // $attachment = is_array($activity['url']) ? - // collect($activity['url']) - // ->filter(function($media) { - // return $media['type'] == 'Link' && $media['mediaType'] == 'video/mp4'; - // }) - // ->take(1) - // ->values() - // ->toArray()[0] : $activity['attachment']; - - $attachment = $activity['attachment']; - - $valid = Validator::make($attachment, [ - '*.type' => [ - 'required', - 'string', - Rule::in($mediaTypes) - ], - '*.url' => 'required|url', - '*.mediaType' => [ - 'required', - 'string', - Rule::in($mimeTypes) - ], - '*.name' => 'sometimes|nullable|string' - ])->passes(); - - return $valid; - } - - public static function normalizeAudience($data, $localOnly = true) - { - if(!isset($data['to'])) { - return; - } - - $audience = []; - $audience['to'] = []; - $audience['cc'] = []; - $scope = 'private'; - - if(is_array($data['to']) && !empty($data['to'])) { - foreach ($data['to'] as $to) { - if($to == 'https://www.w3.org/ns/activitystreams#Public') { - $scope = 'public'; - continue; - } - $url = $localOnly ? self::validateLocalUrl($to) : self::validateUrl($to); - if($url != false) { - array_push($audience['to'], $url); - } - } - } - - if(is_array($data['cc']) && !empty($data['cc'])) { - foreach ($data['cc'] as $cc) { - if($cc == 'https://www.w3.org/ns/activitystreams#Public') { - $scope = 'unlisted'; - continue; - } - $url = $localOnly ? self::validateLocalUrl($cc) : self::validateUrl($cc); - if($url != false) { - array_push($audience['cc'], $url); - } - } - } - $audience['scope'] = $scope; - return $audience; - } - - public static function userInAudience($profile, $data) - { - $audience = self::normalizeAudience($data); - $url = $profile->permalink(); - return in_array($url, $audience['to']) || in_array($url, $audience['cc']); - } - - public static function validateUrl($url) - { - if(is_array($url)) { - $url = $url[0]; - } - - $hash = hash('sha256', $url); - $key = "helpers:url:valid:sha256-{$hash}"; - $ttl = now()->addMinutes(5); - - $valid = Cache::remember($key, $ttl, function() use($url) { - $localhosts = [ - '127.0.0.1', 'localhost', '::1' - ]; - - if(mb_substr($url, 0, 8) !== 'https://') { - return false; - } - - $valid = filter_var($url, FILTER_VALIDATE_URL); - - if(!$valid) { - return false; - } - - $host = parse_url($valid, PHP_URL_HOST); - - // if(count(dns_get_record($host, DNS_A | DNS_AAAA)) == 0) { - // return false; - // } - - if(config('costar.enabled') == true) { - if( - (config('costar.domain.block') != null && Str::contains($host, config('costar.domain.block')) == true) || - (config('costar.actor.block') != null && in_array($url, config('costar.actor.block')) == true) - ) { - return false; - } - } - - if(app()->environment() === 'production') { - $bannedInstances = InstanceService::getBannedDomains(); - if(in_array($host, $bannedInstances)) { - return false; - } - } - - - if(in_array($host, $localhosts)) { - return false; - } - - return $url; - }); - - return $valid; - } - - public static function validateLocalUrl($url) - { - $url = self::validateUrl($url); - if($url == true) { - $domain = config('pixelfed.domain.app'); - $host = parse_url($url, PHP_URL_HOST); - $url = $domain === $host ? $url : false; - return $url; - } - return false; - } - - public static function zttpUserAgent() - { - $version = config('pixelfed.version'); - $url = config('app.url'); - return [ - 'Accept' => 'application/activity+json', - 'User-Agent' => "(Pixelfed/{$version}; +{$url})", - ]; - } - - public static function fetchFromUrl($url = false) - { - if(self::validateUrl($url) == false) { - return; - } - - $hash = hash('sha256', $url); - $key = "helpers:url:fetcher:sha256-{$hash}"; - $ttl = now()->addMinutes(15); - - return Cache::remember($key, $ttl, function() use($url) { - $res = ActivityPubFetchService::get($url); - if(!$res || empty($res)) { - return false; - } - $res = json_decode($res, true, 8); - if(json_last_error() == JSON_ERROR_NONE) { - return $res; - } else { - return false; - } - }); - } - - public static function fetchProfileFromUrl($url) - { - return self::fetchFromUrl($url); - } - - public static function pluckval($val) - { - if(is_string($val)) { - return $val; - } - - if(is_array($val)) { - return !empty($val) ? $val[0] : null; - } - - return null; - } - - public static function statusFirstOrFetch($url, $replyTo = false) - { - $url = self::validateUrl($url); - if($url == false) { - return; - } - - $host = parse_url($url, PHP_URL_HOST); - $local = config('pixelfed.domain.app') == $host ? true : false; - - if($local) { - $id = (int) last(explode('/', $url)); - return Status::whereNotIn('scope', ['draft','archived'])->findOrFail($id); - } - - $cached = Status::whereNotIn('scope', ['draft','archived']) - ->whereUri($url) - ->orWhere('object_url', $url) - ->first(); - - if($cached) { - return $cached; - } - - $res = self::fetchFromUrl($url); - - if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) || !isset($res['published']) ) { - return; - } - - if(isset($res['object'])) { - $activity = $res; - } else { - $activity = ['object' => $res]; - } - - $scope = 'private'; - - $cw = isset($res['sensitive']) ? (bool) $res['sensitive'] : false; - - if(isset($res['to']) == true) { - if(is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) { - $scope = 'public'; - } - if(is_string($res['to']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['to']) { - $scope = 'public'; - } - } - - if(isset($res['cc']) == true) { - if(is_array($res['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['cc'])) { - $scope = 'unlisted'; - } - if(is_string($res['cc']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['cc']) { - $scope = 'unlisted'; - } - } - - if(config('costar.enabled') == true) { - $blockedKeywords = config('costar.keyword.block'); - if($blockedKeywords !== null) { - $keywords = config('costar.keyword.block'); - foreach($keywords as $kw) { - if(Str::contains($res['content'], $kw) == true) { - return; - } - } - } - - $unlisted = config('costar.domain.unlisted'); - if(in_array(parse_url($url, PHP_URL_HOST), $unlisted) == true) { - $unlisted = true; - $scope = 'unlisted'; - } else { - $unlisted = false; - } - - $cwDomains = config('costar.domain.cw'); - if(in_array(parse_url($url, PHP_URL_HOST), $cwDomains) == true) { - $cw = true; - } - } - - $id = isset($res['id']) ? self::pluckval($res['id']) : self::pluckval($url); - $idDomain = parse_url($id, PHP_URL_HOST); - $urlDomain = parse_url($url, PHP_URL_HOST); - - if(!self::validateUrl($id)) { - return; - } - - if(!isset($activity['object']['attributedTo'])) { - return; - } - - $attributedTo = is_string($activity['object']['attributedTo']) ? - $activity['object']['attributedTo'] : - (is_array($activity['object']['attributedTo']) ? - collect($activity['object']['attributedTo']) - ->filter(function($o) { - return $o && isset($o['type']) && $o['type'] == 'Person'; - }) - ->pluck('id') - ->first() : null - ); - - if($attributedTo) { - $actorDomain = parse_url($attributedTo, PHP_URL_HOST); - if(!self::validateUrl($attributedTo) || - $idDomain !== $actorDomain || - $actorDomain !== $urlDomain - ) - { - return; - } - } - - if($idDomain !== $urlDomain) { - return; - } - - $profile = self::profileFirstOrNew($attributedTo); - - if(!$profile) { - return; - } - - if(isset($activity['object']['inReplyTo']) && !empty($activity['object']['inReplyTo']) || $replyTo == true) { - $reply_to = self::statusFirstOrFetch(self::pluckval($activity['object']['inReplyTo']), false); - if($reply_to) { - $blocks = UserFilterService::blocks($reply_to->profile_id); - if(in_array($profile->id, $blocks)) { - return; - } - } - $reply_to = optional($reply_to)->id; - } else { - $reply_to = null; - } - $ts = self::pluckval($res['published']); - - if($scope == 'public' && in_array($urlDomain, InstanceService::getUnlistedDomains())) { - $scope = 'unlisted'; - } - - if(in_array($urlDomain, InstanceService::getNsfwDomains())) { - $cw = true; - } - - if($res['type'] === 'Question') { - $status = self::storePoll( - $profile, - $res, - $url, - $ts, - $reply_to, - $cw, - $scope, - $id - ); - return $status; - } else { - $status = self::storeStatus($url, $profile, $res); - } - - return $status; - } - - public static function storeStatus($url, $profile, $activity) - { - $id = isset($activity['id']) ? self::pluckval($activity['id']) : self::pluckval($activity['url']); - $url = isset($activity['url']) && is_string($activity['url']) ? self::pluckval($activity['url']) : self::pluckval($id); - $idDomain = parse_url($id, PHP_URL_HOST); - $urlDomain = parse_url($url, PHP_URL_HOST); - if(!self::validateUrl($id) || !self::validateUrl($url)) { - return; - } - - $reply_to = self::getReplyTo($activity); - - $ts = self::pluckval($activity['published']); - $scope = self::getScope($activity, $url); - $cw = self::getSensitive($activity, $url); - $pid = is_object($profile) ? $profile->id : (is_array($profile) ? $profile['id'] : null); - $commentsDisabled = isset($activity['commentsEnabled']) ? !boolval($activity['commentsEnabled']) : false; - - if(!$pid) { - return; - } - - $status = Status::updateOrCreate( - [ - 'uri' => $url - ], [ - 'profile_id' => $pid, - 'url' => $url, - 'object_url' => $id, - 'caption' => isset($activity['content']) ? Purify::clean(strip_tags($activity['content'])) : null, - 'rendered' => isset($activity['content']) ? Purify::clean($activity['content']) : null, - 'created_at' => Carbon::parse($ts)->tz('UTC'), - 'in_reply_to_id' => $reply_to, - 'local' => false, - 'is_nsfw' => $cw, - 'scope' => $scope, - 'visibility' => $scope, - 'cw_summary' => ($cw == true && isset($activity['summary']) ? - Purify::clean(strip_tags($activity['summary'])) : null), - 'comments_disabled' => $commentsDisabled - ] - ); - - if($reply_to == null) { - self::importNoteAttachment($activity, $status); - } else { - if(isset($activity['attachment']) && !empty($activity['attachment'])) { - self::importNoteAttachment($activity, $status); - } - StatusReplyPipeline::dispatch($status); - } - - if(isset($activity['tag']) && is_array($activity['tag']) && !empty($activity['tag'])) { - StatusTagsPipeline::dispatch($activity, $status); - } - - if( config('instance.timeline.network.cached') && - $status->in_reply_to_id === null && - $status->reblog_of_id === null && - in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) && - $status->created_at->gt(now()->subHours(config('instance.timeline.network.max_hours_old'))) && - (config('instance.hide_nsfw_on_public_feeds') == true ? $status->is_nsfw == false : true) - ) { - $filteredDomains = collect(InstanceService::getBannedDomains()) + public static function validateObject($data) + { + $verbs = ['Create', 'Announce', 'Like', 'Follow', 'Delete', 'Accept', 'Reject', 'Undo', 'Tombstone']; + + $valid = Validator::make($data, [ + 'type' => [ + 'required', + 'string', + Rule::in($verbs) + ], + 'id' => 'required|string', + 'actor' => 'required|string|url', + 'object' => 'required', + 'object.type' => 'required_if:type,Create', + 'object.attributedTo' => 'required_if:type,Create|url', + 'published' => 'required_if:type,Create|date' + ])->passes(); + + return $valid; + } + + public static function verifyAttachments($data) + { + if(!isset($data['object']) || empty($data['object'])) { + $data = ['object'=>$data]; + } + + $activity = $data['object']; + + $mimeTypes = explode(',', config_cache('pixelfed.media_types')); + $mediaTypes = in_array('video/mp4', $mimeTypes) ? ['Document', 'Image', 'Video'] : ['Document', 'Image']; + + // Peertube + // $mediaTypes = in_array('video/mp4', $mimeTypes) ? ['Document', 'Image', 'Video', 'Link'] : ['Document', 'Image']; + + if(!isset($activity['attachment']) || empty($activity['attachment'])) { + return false; + } + + // peertube + // $attachment = is_array($activity['url']) ? + // collect($activity['url']) + // ->filter(function($media) { + // return $media['type'] == 'Link' && $media['mediaType'] == 'video/mp4'; + // }) + // ->take(1) + // ->values() + // ->toArray()[0] : $activity['attachment']; + + $attachment = $activity['attachment']; + + $valid = Validator::make($attachment, [ + '*.type' => [ + 'required', + 'string', + Rule::in($mediaTypes) + ], + '*.url' => 'required|url', + '*.mediaType' => [ + 'required', + 'string', + Rule::in($mimeTypes) + ], + '*.name' => 'sometimes|nullable|string', + '*.blurhash' => 'sometimes|nullable|string|min:6|max:164', + '*.width' => 'sometimes|nullable|integer|min:1|max:5000', + '*.height' => 'sometimes|nullable|integer|min:1|max:5000', + ])->passes(); + + return $valid; + } + + public static function normalizeAudience($data, $localOnly = true) + { + if(!isset($data['to'])) { + return; + } + + $audience = []; + $audience['to'] = []; + $audience['cc'] = []; + $scope = 'private'; + + if(is_array($data['to']) && !empty($data['to'])) { + foreach ($data['to'] as $to) { + if($to == 'https://www.w3.org/ns/activitystreams#Public') { + $scope = 'public'; + continue; + } + $url = $localOnly ? self::validateLocalUrl($to) : self::validateUrl($to); + if($url != false) { + array_push($audience['to'], $url); + } + } + } + + if(is_array($data['cc']) && !empty($data['cc'])) { + foreach ($data['cc'] as $cc) { + if($cc == 'https://www.w3.org/ns/activitystreams#Public') { + $scope = 'unlisted'; + continue; + } + $url = $localOnly ? self::validateLocalUrl($cc) : self::validateUrl($cc); + if($url != false) { + array_push($audience['cc'], $url); + } + } + } + $audience['scope'] = $scope; + return $audience; + } + + public static function userInAudience($profile, $data) + { + $audience = self::normalizeAudience($data); + $url = $profile->permalink(); + return in_array($url, $audience['to']) || in_array($url, $audience['cc']); + } + + public static function validateUrl($url) + { + if(is_array($url)) { + $url = $url[0]; + } + + $hash = hash('sha256', $url); + $key = "helpers:url:valid:sha256-{$hash}"; + + $valid = Cache::remember($key, 900, function() use($url) { + $localhosts = [ + '127.0.0.1', 'localhost', '::1' + ]; + + if(strtolower(mb_substr($url, 0, 8)) !== 'https://') { + return false; + } + + if(substr_count($url, '://') !== 1) { + return false; + } + + if(mb_substr($url, 0, 8) !== 'https://') { + $url = 'https://' . substr($url, 8); + } + + $valid = filter_var($url, FILTER_VALIDATE_URL); + + if(!$valid) { + return false; + } + + $host = parse_url($valid, PHP_URL_HOST); + + if(in_array($host, $localhosts)) { + return false; + } + + if(config('security.url.verify_dns')) { + if(DomainService::hasValidDns($host) === false) { + return false; + } + } + + if(app()->environment() === 'production') { + $bannedInstances = InstanceService::getBannedDomains(); + if(in_array($host, $bannedInstances)) { + return false; + } + } + + return $url; + }); + + return $valid; + } + + public static function validateLocalUrl($url) + { + $url = self::validateUrl($url); + if($url == true) { + $domain = config('pixelfed.domain.app'); + $host = parse_url($url, PHP_URL_HOST); + $url = strtolower($domain) === strtolower($host) ? $url : false; + return $url; + } + return false; + } + + public static function zttpUserAgent() + { + $version = config('pixelfed.version'); + $url = config('app.url'); + return [ + 'Accept' => 'application/activity+json', + 'User-Agent' => "(Pixelfed/{$version}; +{$url})", + ]; + } + + public static function fetchFromUrl($url = false) + { + if(self::validateUrl($url) == false) { + return; + } + + $hash = hash('sha256', $url); + $key = "helpers:url:fetcher:sha256-{$hash}"; + $ttl = now()->addMinutes(15); + + return Cache::remember($key, $ttl, function() use($url) { + $res = ActivityPubFetchService::get($url); + if(!$res || empty($res)) { + return false; + } + $res = json_decode($res, true, 8); + if(json_last_error() == JSON_ERROR_NONE) { + return $res; + } else { + return false; + } + }); + } + + public static function fetchProfileFromUrl($url) + { + return self::fetchFromUrl($url); + } + + public static function pluckval($val) + { + if(is_string($val)) { + return $val; + } + + if(is_array($val)) { + return !empty($val) ? head($val) : null; + } + + return null; + } + + public static function statusFirstOrFetch($url, $replyTo = false) + { + $url = self::validateUrl($url); + if($url == false) { + return; + } + + $host = parse_url($url, PHP_URL_HOST); + $local = config('pixelfed.domain.app') == $host ? true : false; + + if($local) { + $id = (int) last(explode('/', $url)); + return Status::whereNotIn('scope', ['draft','archived'])->findOrFail($id); + } + + $cached = Status::whereNotIn('scope', ['draft','archived']) + ->whereUri($url) + ->orWhere('object_url', $url) + ->first(); + + if($cached) { + return $cached; + } + + $res = self::fetchFromUrl($url); + + if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) || !isset($res['published']) ) { + return; + } + + if(isset($res['object'])) { + $activity = $res; + } else { + $activity = ['object' => $res]; + } + + $scope = 'private'; + + $cw = isset($res['sensitive']) ? (bool) $res['sensitive'] : false; + + if(isset($res['to']) == true) { + if(is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) { + $scope = 'public'; + } + if(is_string($res['to']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['to']) { + $scope = 'public'; + } + } + + if(isset($res['cc']) == true) { + if(is_array($res['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['cc'])) { + $scope = 'unlisted'; + } + if(is_string($res['cc']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['cc']) { + $scope = 'unlisted'; + } + } + + if(config('costar.enabled') == true) { + $blockedKeywords = config('costar.keyword.block'); + if($blockedKeywords !== null) { + $keywords = config('costar.keyword.block'); + foreach($keywords as $kw) { + if(Str::contains($res['content'], $kw) == true) { + return; + } + } + } + + $unlisted = config('costar.domain.unlisted'); + if(in_array(parse_url($url, PHP_URL_HOST), $unlisted) == true) { + $unlisted = true; + $scope = 'unlisted'; + } else { + $unlisted = false; + } + + $cwDomains = config('costar.domain.cw'); + if(in_array(parse_url($url, PHP_URL_HOST), $cwDomains) == true) { + $cw = true; + } + } + + $id = isset($res['id']) ? self::pluckval($res['id']) : self::pluckval($url); + $idDomain = parse_url($id, PHP_URL_HOST); + $urlDomain = parse_url($url, PHP_URL_HOST); + + if(!self::validateUrl($id)) { + return; + } + + if(!isset($activity['object']['attributedTo'])) { + return; + } + + $attributedTo = is_string($activity['object']['attributedTo']) ? + $activity['object']['attributedTo'] : + (is_array($activity['object']['attributedTo']) ? + collect($activity['object']['attributedTo']) + ->filter(function($o) { + return $o && isset($o['type']) && $o['type'] == 'Person'; + }) + ->pluck('id') + ->first() : null + ); + + if($attributedTo) { + $actorDomain = parse_url($attributedTo, PHP_URL_HOST); + if(!self::validateUrl($attributedTo) || + $idDomain !== $actorDomain || + $actorDomain !== $urlDomain + ) + { + return; + } + } + + if($idDomain !== $urlDomain) { + return; + } + + $profile = self::profileFirstOrNew($attributedTo); + + if(!$profile) { + return; + } + + if(isset($activity['object']['inReplyTo']) && !empty($activity['object']['inReplyTo']) || $replyTo == true) { + $reply_to = self::statusFirstOrFetch(self::pluckval($activity['object']['inReplyTo']), false); + if($reply_to) { + $blocks = UserFilterService::blocks($reply_to->profile_id); + if(in_array($profile->id, $blocks)) { + return; + } + } + $reply_to = optional($reply_to)->id; + } else { + $reply_to = null; + } + $ts = self::pluckval($res['published']); + + if($scope == 'public' && in_array($urlDomain, InstanceService::getUnlistedDomains())) { + $scope = 'unlisted'; + } + + if(in_array($urlDomain, InstanceService::getNsfwDomains())) { + $cw = true; + } + + if($res['type'] === 'Question') { + $status = self::storePoll( + $profile, + $res, + $url, + $ts, + $reply_to, + $cw, + $scope, + $id + ); + return $status; + } else { + $status = self::storeStatus($url, $profile, $res); + } + + return $status; + } + + public static function storeStatus($url, $profile, $activity) + { + $id = isset($activity['id']) ? self::pluckval($activity['id']) : self::pluckval($activity['url']); + $url = isset($activity['url']) && is_string($activity['url']) ? self::pluckval($activity['url']) : self::pluckval($id); + $idDomain = parse_url($id, PHP_URL_HOST); + $urlDomain = parse_url($url, PHP_URL_HOST); + if(!self::validateUrl($id) || !self::validateUrl($url)) { + return; + } + + $reply_to = self::getReplyTo($activity); + + $ts = self::pluckval($activity['published']); + $scope = self::getScope($activity, $url); + $cw = self::getSensitive($activity, $url); + $pid = is_object($profile) ? $profile->id : (is_array($profile) ? $profile['id'] : null); + $isUnlisted = is_object($profile) ? $profile->unlisted : (is_array($profile) ? $profile['unlisted'] : false); + $commentsDisabled = isset($activity['commentsEnabled']) ? !boolval($activity['commentsEnabled']) : false; + + if(!$pid) { + return; + } + + if($scope == 'public') { + if($isUnlisted == true) { + $scope = 'unlisted'; + } + } + + $status = Status::updateOrCreate( + [ + 'uri' => $url + ], [ + 'profile_id' => $pid, + 'url' => $url, + 'object_url' => $id, + 'caption' => isset($activity['content']) ? Purify::clean(strip_tags($activity['content'])) : null, + 'rendered' => isset($activity['content']) ? Purify::clean($activity['content']) : null, + 'created_at' => Carbon::parse($ts)->tz('UTC'), + 'in_reply_to_id' => $reply_to, + 'local' => false, + 'is_nsfw' => $cw, + 'scope' => $scope, + 'visibility' => $scope, + 'cw_summary' => ($cw == true && isset($activity['summary']) ? + Purify::clean(strip_tags($activity['summary'])) : null), + 'comments_disabled' => $commentsDisabled + ] + ); + + if($reply_to == null) { + self::importNoteAttachment($activity, $status); + } else { + if(isset($activity['attachment']) && !empty($activity['attachment'])) { + self::importNoteAttachment($activity, $status); + } + StatusReplyPipeline::dispatch($status); + } + + if(isset($activity['tag']) && is_array($activity['tag']) && !empty($activity['tag'])) { + StatusTagsPipeline::dispatch($activity, $status); + } + + if( config('instance.timeline.network.cached') && + $status->in_reply_to_id === null && + $status->reblog_of_id === null && + in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) && + $status->created_at->gt(now()->subHours(config('instance.timeline.network.max_hours_old'))) && + (config('instance.hide_nsfw_on_public_feeds') == true ? $status->is_nsfw == false : true) + ) { + $filteredDomains = collect(InstanceService::getBannedDomains()) ->merge(InstanceService::getUnlistedDomains()) ->unique() ->values() ->toArray(); if(!in_array($urlDomain, $filteredDomains)) { - NetworkTimelineService::add($status->id); + if(!$isUnlisted) { + NetworkTimelineService::add($status->id); + } } - } + } - IncrementPostCount::dispatch($pid)->onQueue('low'); + IncrementPostCount::dispatch($pid)->onQueue('low'); - return $status; - } + return $status; + } - public static function getSensitive($activity, $url) - { - $id = isset($activity['id']) ? self::pluckval($activity['id']) : self::pluckval($url); - $url = isset($activity['url']) ? self::pluckval($activity['url']) : $id; - $urlDomain = parse_url($url, PHP_URL_HOST); + public static function getSensitive($activity, $url) + { + $id = isset($activity['id']) ? self::pluckval($activity['id']) : self::pluckval($url); + $url = isset($activity['url']) ? self::pluckval($activity['url']) : $id; + $urlDomain = parse_url($url, PHP_URL_HOST); - $cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false; + $cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false; - if(in_array($urlDomain, InstanceService::getNsfwDomains())) { - $cw = true; - } + if(in_array($urlDomain, InstanceService::getNsfwDomains())) { + $cw = true; + } - return $cw; - } + return $cw; + } - public static function getReplyTo($activity) - { - $reply_to = null; - $inReplyTo = isset($activity['inReplyTo']) && !empty($activity['inReplyTo']) ? - self::pluckval($activity['inReplyTo']) : - false; + public static function getReplyTo($activity) + { + $reply_to = null; + $inReplyTo = isset($activity['inReplyTo']) && !empty($activity['inReplyTo']) ? + self::pluckval($activity['inReplyTo']) : + false; - if($inReplyTo) { - $reply_to = self::statusFirstOrFetch($inReplyTo); - if($reply_to) { - $reply_to = optional($reply_to)->id; - } - } else { - $reply_to = null; - } + if($inReplyTo) { + $reply_to = self::statusFirstOrFetch($inReplyTo); + if($reply_to) { + $reply_to = optional($reply_to)->id; + } + } else { + $reply_to = null; + } - return $reply_to; - } + return $reply_to; + } - public static function getScope($activity, $url) - { - $id = isset($activity['id']) ? self::pluckval($activity['id']) : self::pluckval($url); - $url = isset($activity['url']) ? self::pluckval($activity['url']) : self::pluckval($id); - $urlDomain = parse_url(self::pluckval($url), PHP_URL_HOST); - $scope = 'private'; + public static function getScope($activity, $url) + { + $id = isset($activity['id']) ? self::pluckval($activity['id']) : self::pluckval($url); + $url = isset($activity['url']) ? self::pluckval($activity['url']) : self::pluckval($id); + $urlDomain = parse_url(self::pluckval($url), PHP_URL_HOST); + $scope = 'private'; - if(isset($activity['to']) == true) { - if(is_array($activity['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $activity['to'])) { - $scope = 'public'; - } - if(is_string($activity['to']) && 'https://www.w3.org/ns/activitystreams#Public' == $activity['to']) { - $scope = 'public'; - } - } + if(isset($activity['to']) == true) { + if(is_array($activity['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $activity['to'])) { + $scope = 'public'; + } + if(is_string($activity['to']) && 'https://www.w3.org/ns/activitystreams#Public' == $activity['to']) { + $scope = 'public'; + } + } - if(isset($activity['cc']) == true) { - if(is_array($activity['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $activity['cc'])) { - $scope = 'unlisted'; - } - if(is_string($activity['cc']) && 'https://www.w3.org/ns/activitystreams#Public' == $activity['cc']) { - $scope = 'unlisted'; - } - } + if(isset($activity['cc']) == true) { + if(is_array($activity['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $activity['cc'])) { + $scope = 'unlisted'; + } + if(is_string($activity['cc']) && 'https://www.w3.org/ns/activitystreams#Public' == $activity['cc']) { + $scope = 'unlisted'; + } + } - if($scope == 'public' && in_array($urlDomain, InstanceService::getUnlistedDomains())) { - $scope = 'unlisted'; - } + if($scope == 'public' && in_array($urlDomain, InstanceService::getUnlistedDomains())) { + $scope = 'unlisted'; + } - return $scope; - } + return $scope; + } - private static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) - { - if(!isset($res['endTime']) || !isset($res['oneOf']) || !is_array($res['oneOf']) || count($res['oneOf']) > 4) { - return; - } + private static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) + { + if(!isset($res['endTime']) || !isset($res['oneOf']) || !is_array($res['oneOf']) || count($res['oneOf']) > 4) { + return; + } - $options = collect($res['oneOf'])->map(function($option) { - return $option['name']; - })->toArray(); + $options = collect($res['oneOf'])->map(function($option) { + return $option['name']; + })->toArray(); - $cachedTallies = collect($res['oneOf'])->map(function($option) { - return $option['replies']['totalItems'] ?? 0; - })->toArray(); + $cachedTallies = collect($res['oneOf'])->map(function($option) { + return $option['replies']['totalItems'] ?? 0; + })->toArray(); - $status = new Status; - $status->profile_id = $profile->id; - $status->url = isset($res['url']) ? $res['url'] : $url; - $status->uri = isset($res['url']) ? $res['url'] : $url; - $status->object_url = $id; - $status->caption = strip_tags($res['content']); - $status->rendered = Purify::clean($res['content']); - $status->created_at = Carbon::parse($ts)->tz('UTC'); - $status->in_reply_to_id = null; - $status->local = false; - $status->is_nsfw = $cw; - $status->scope = 'draft'; - $status->visibility = 'draft'; - $status->cw_summary = $cw == true && isset($res['summary']) ? - Purify::clean(strip_tags($res['summary'])) : null; - $status->save(); + $status = new Status; + $status->profile_id = $profile->id; + $status->url = isset($res['url']) ? $res['url'] : $url; + $status->uri = isset($res['url']) ? $res['url'] : $url; + $status->object_url = $id; + $status->caption = strip_tags($res['content']); + $status->rendered = Purify::clean($res['content']); + $status->created_at = Carbon::parse($ts)->tz('UTC'); + $status->in_reply_to_id = null; + $status->local = false; + $status->is_nsfw = $cw; + $status->scope = 'draft'; + $status->visibility = 'draft'; + $status->cw_summary = $cw == true && isset($res['summary']) ? + Purify::clean(strip_tags($res['summary'])) : null; + $status->save(); - $poll = new Poll; - $poll->status_id = $status->id; - $poll->profile_id = $status->profile_id; - $poll->poll_options = $options; - $poll->cached_tallies = $cachedTallies; - $poll->votes_count = array_sum($cachedTallies); - $poll->expires_at = now()->parse($res['endTime']); - $poll->last_fetched_at = now(); - $poll->save(); + $poll = new Poll; + $poll->status_id = $status->id; + $poll->profile_id = $status->profile_id; + $poll->poll_options = $options; + $poll->cached_tallies = $cachedTallies; + $poll->votes_count = array_sum($cachedTallies); + $poll->expires_at = now()->parse($res['endTime']); + $poll->last_fetched_at = now(); + $poll->save(); - $status->type = 'poll'; - $status->scope = $scope; - $status->visibility = $scope; - $status->save(); + $status->type = 'poll'; + $status->scope = $scope; + $status->visibility = $scope; + $status->save(); - return $status; - } + return $status; + } - public static function statusFetch($url) - { - return self::statusFirstOrFetch($url); - } + public static function statusFetch($url) + { + return self::statusFirstOrFetch($url); + } - public static function importNoteAttachment($data, Status $status) - { - if(self::verifyAttachments($data) == false) { - // \Log::info('importNoteAttachment::failedVerification.', [$data['id']]); - $status->viewType(); - return; - } - $attachments = isset($data['object']) ? $data['object']['attachment'] : $data['attachment']; - // peertube - // if(!$attachments) { - // $obj = isset($data['object']) ? $data['object'] : $data; - // $attachments = is_array($obj['url']) ? $obj['url'] : null; - // } - $user = $status->profile; - $storagePath = MediaPathService::get($user, 2); - $allowed = explode(',', config_cache('pixelfed.media_types')); + public static function importNoteAttachment($data, Status $status) + { + if(self::verifyAttachments($data) == false) { + // \Log::info('importNoteAttachment::failedVerification.', [$data['id']]); + $status->viewType(); + return; + } + $attachments = isset($data['object']) ? $data['object']['attachment'] : $data['attachment']; + // peertube + // if(!$attachments) { + // $obj = isset($data['object']) ? $data['object'] : $data; + // $attachments = is_array($obj['url']) ? $obj['url'] : null; + // } + $user = $status->profile; + $storagePath = MediaPathService::get($user, 2); + $allowed = explode(',', config_cache('pixelfed.media_types')); - foreach($attachments as $key => $media) { - $type = $media['mediaType']; - $url = $media['url']; - $valid = self::validateUrl($url); - if(in_array($type, $allowed) == false || $valid == false) { - continue; - } - $blurhash = isset($media['blurhash']) ? $media['blurhash'] : null; - $license = isset($media['license']) ? License::nameToId($media['license']) : null; - $caption = isset($media['name']) ? Purify::clean($media['name']) : null; + foreach($attachments as $key => $media) { + $type = $media['mediaType']; + $url = $media['url']; + $valid = self::validateUrl($url); + if(in_array($type, $allowed) == false || $valid == false) { + continue; + } + $blurhash = isset($media['blurhash']) ? $media['blurhash'] : null; + $license = isset($media['license']) ? License::nameToId($media['license']) : null; + $caption = isset($media['name']) ? Purify::clean($media['name']) : null; + $width = isset($media['width']) ? $media['width'] : false; + $height = isset($media['height']) ? $media['height'] : false; - $media = new Media(); - $media->blurhash = $blurhash; - $media->remote_media = true; - $media->status_id = $status->id; - $media->profile_id = $status->profile_id; - $media->user_id = null; - $media->media_path = $url; - $media->remote_url = $url; - $media->caption = $caption; - $media->order = $key + 1; - if($license) { - $media->license = $license; - } - $media->mime = $type; - $media->version = 3; - $media->save(); + $media = new Media(); + $media->blurhash = $blurhash; + $media->remote_media = true; + $media->status_id = $status->id; + $media->profile_id = $status->profile_id; + $media->user_id = null; + $media->media_path = $url; + $media->remote_url = $url; + $media->caption = $caption; + $media->order = $key + 1; + if($width) { + $media->width = $width; + } + if($height) { + $media->height = $height; + } + if($license) { + $media->license = $license; + } + $media->mime = $type; + $media->version = 3; + $media->save(); - if(config_cache('pixelfed.cloud_storage') == true) { - MediaStoragePipeline::dispatch($media); - } - } + if(config_cache('pixelfed.cloud_storage') == true) { + MediaStoragePipeline::dispatch($media); + } + } - $status->viewType(); - return; - } + $status->viewType(); + return; + } - public static function profileFirstOrNew($url) - { - $url = self::validateUrl($url); - if($url == false) { - return; - } + public static function profileFirstOrNew($url) + { + $url = self::validateUrl($url); + if($url == false) { + return; + } - $host = parse_url($url, PHP_URL_HOST); - $local = config('pixelfed.domain.app') == $host ? true : false; + $host = parse_url($url, PHP_URL_HOST); + $local = config('pixelfed.domain.app') == $host ? true : false; - if($local == true) { - $id = last(explode('/', $url)); - return Profile::whereNull('status') - ->whereNull('domain') - ->whereUsername($id) - ->firstOrFail(); - } + if($local == true) { + $id = last(explode('/', $url)); + return Profile::whereNull('status') + ->whereNull('domain') + ->whereUsername($id) + ->firstOrFail(); + } - if($profile = Profile::whereRemoteUrl($url)->first()) { - if($profile->last_fetched_at && $profile->last_fetched_at->lt(now()->subHours(24))) { - return self::profileUpdateOrCreate($url); - } - return $profile; - } + if($profile = Profile::whereRemoteUrl($url)->first()) { + if($profile->last_fetched_at && $profile->last_fetched_at->lt(now()->subHours(24))) { + return self::profileUpdateOrCreate($url); + } + return $profile; + } - return self::profileUpdateOrCreate($url); - } + return self::profileUpdateOrCreate($url); + } - public static function profileUpdateOrCreate($url) - { - $res = self::fetchProfileFromUrl($url); - if(!$res || isset($res['id']) == false) { - return; - } - $domain = parse_url($res['id'], PHP_URL_HOST); - if(!isset($res['preferredUsername']) && !isset($res['nickname'])) { - return; - } - $username = (string) Purify::clean($res['preferredUsername'] ?? $res['nickname']); - if(empty($username)) { - return; - } - $remoteUsername = $username; - $webfinger = "@{$username}@{$domain}"; + public static function profileUpdateOrCreate($url) + { + $res = self::fetchProfileFromUrl($url); + if(!$res || isset($res['id']) == false) { + return; + } + $domain = parse_url($res['id'], PHP_URL_HOST); + if(!isset($res['preferredUsername']) && !isset($res['nickname'])) { + return; + } + $username = (string) Purify::clean($res['preferredUsername'] ?? $res['nickname']); + if(empty($username)) { + return; + } + $remoteUsername = $username; + $webfinger = "@{$username}@{$domain}"; - if(!self::validateUrl($res['inbox'])) { - return; - } - if(!self::validateUrl($res['id'])) { - return; - } + if(!self::validateUrl($res['inbox'])) { + return; + } + if(!self::validateUrl($res['id'])) { + return; + } - $instance = Instance::updateOrCreate([ - 'domain' => $domain - ]); - if($instance->wasRecentlyCreated == true) { - \App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance)->onQueue('low'); - } + $instance = Instance::updateOrCreate([ + 'domain' => $domain + ]); + if($instance->wasRecentlyCreated == true) { + \App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance)->onQueue('low'); + } - $profile = Profile::updateOrCreate( - [ - 'domain' => strtolower($domain), - 'username' => Purify::clean($webfinger), - ], - [ - 'webfinger' => Purify::clean($webfinger), - 'key_id' => $res['publicKey']['id'], - 'remote_url' => $res['id'], - 'name' => isset($res['name']) ? Purify::clean($res['name']) : 'user', - 'bio' => isset($res['summary']) ? Purify::clean($res['summary']) : null, - 'sharedInbox' => isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null, - 'inbox_url' => $res['inbox'], - 'outbox_url' => isset($res['outbox']) ? $res['outbox'] : null, - 'public_key' => $res['publicKey']['publicKeyPem'], - ] - ); + $profile = Profile::updateOrCreate( + [ + 'domain' => strtolower($domain), + 'username' => Purify::clean($webfinger), + ], + [ + 'webfinger' => Purify::clean($webfinger), + 'key_id' => $res['publicKey']['id'], + 'remote_url' => $res['id'], + 'name' => isset($res['name']) ? Purify::clean($res['name']) : 'user', + 'bio' => isset($res['summary']) ? Purify::clean($res['summary']) : null, + 'sharedInbox' => isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null, + 'inbox_url' => $res['inbox'], + 'outbox_url' => isset($res['outbox']) ? $res['outbox'] : null, + 'public_key' => $res['publicKey']['publicKeyPem'], + 'indexable' => isset($res['indexable']) && is_bool($res['indexable']) ? $res['indexable'] : false, + ] + ); - if( $profile->last_fetched_at == null || - $profile->last_fetched_at->lt(now()->subHours(24)) - ) { - RemoteAvatarFetch::dispatch($profile); - } - $profile->last_fetched_at = now(); - $profile->save(); - return $profile; - } + if( $profile->last_fetched_at == null || + $profile->last_fetched_at->lt(now()->subHours(24)) + ) { + RemoteAvatarFetch::dispatch($profile); + } + $profile->last_fetched_at = now(); + $profile->save(); + return $profile; + } - public static function profileFetch($url) - { - return self::profileFirstOrNew($url); - } + public static function profileFetch($url) + { + return self::profileFirstOrNew($url); + } - public static function sendSignedObject($profile, $url, $body) - { - ActivityPubDeliveryService::queue() - ->from($profile) - ->to($url) - ->payload($body) - ->send(); - } + public static function sendSignedObject($profile, $url, $body) + { + ActivityPubDeliveryService::queue() + ->from($profile) + ->to($url) + ->payload($body) + ->send(); + } } diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 0caf8f25d..4441becfb 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -25,7 +25,7 @@ use Illuminate\Support\Str; use App\Jobs\LikePipeline\LikePipeline; use App\Jobs\FollowPipeline\FollowPipeline; use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline; -use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline; +use App\Jobs\StatusPipeline\RemoteStatusDelete; use App\Jobs\StoryPipeline\StoryExpire; use App\Jobs\StoryPipeline\StoryFetch; use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline; @@ -281,7 +281,8 @@ class Inbox } if($actor->followers_count == 0) { - if(FollowerService::followerCount($actor->id, true) == 0) { + if(config('federation.activitypub.ingest.store_notes_without_followers')) { + } else if(FollowerService::followerCount($actor->id, true) == 0) { return; } } @@ -706,7 +707,7 @@ class Inbox if(!$status) { return; } - DeleteRemoteStatusPipeline::dispatch($status)->onQueue('high'); + RemoteStatusDelete::dispatch($status)->onQueue('high'); return; break; diff --git a/app/Util/Media/Blurhash.php b/app/Util/Media/Blurhash.php index c0cca59b9..8e232ea17 100644 --- a/app/Util/Media/Blurhash.php +++ b/app/Util/Media/Blurhash.php @@ -44,6 +44,9 @@ class Blurhash { $pixels[] = $row; } + // Free the allocated GdImage object from memory: + imagedestroy($image); + $components_x = 4; $components_y = 4; $blurhash = BlurhashEngine::encode($pixels, $components_x, $components_y); @@ -53,4 +56,4 @@ class Blurhash { return $blurhash; } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index e28376f20..74c453c67 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.275.5", + "version": "3.275.7", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "d46961b82e857f77059c0c78160719ecb26f6cc6" + "reference": "54dcef3349c81b46c0f5f6e54b5f9bfb5db19903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d46961b82e857f77059c0c78160719ecb26f6cc6", - "reference": "d46961b82e857f77059c0c78160719ecb26f6cc6", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/54dcef3349c81b46c0f5f6e54b5f9bfb5db19903", + "reference": "54dcef3349c81b46c0f5f6e54b5f9bfb5db19903", "shasum": "" }, "require": { @@ -151,9 +151,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.275.5" + "source": "https://github.com/aws/aws-sdk-php/tree/3.275.7" }, - "time": "2023-07-07T18:20:11+00:00" + "time": "2023-07-13T18:21:04+00:00" }, { "name": "bacon/bacon-qr-code", @@ -2357,16 +2357,16 @@ }, { "name": "laravel/framework", - "version": "v10.14.1", + "version": "v10.15.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6f89a2b74b232d8bf2e1d9ed87e311841263dfcb" + "reference": "c7599dc92e04532824bafbd226c2936ce6a905b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6f89a2b74b232d8bf2e1d9ed87e311841263dfcb", - "reference": "6f89a2b74b232d8bf2e1d9ed87e311841263dfcb", + "url": "https://api.github.com/repos/laravel/framework/zipball/c7599dc92e04532824bafbd226c2936ce6a905b8", + "reference": "c7599dc92e04532824bafbd226c2936ce6a905b8", "shasum": "" }, "require": { @@ -2553,7 +2553,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2023-06-28T14:25:16+00:00" + "time": "2023-07-11T13:43:52+00:00" }, { "name": "laravel/helpers", @@ -2613,16 +2613,16 @@ }, { "name": "laravel/horizon", - "version": "v5.17.0", + "version": "v5.18.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "569c7154033679a1ca05b43bfa640cc60aa3b37b" + "reference": "b14498a09af826035e46ae8d6b013d0ec849bdb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/569c7154033679a1ca05b43bfa640cc60aa3b37b", - "reference": "569c7154033679a1ca05b43bfa640cc60aa3b37b", + "url": "https://api.github.com/repos/laravel/horizon/zipball/b14498a09af826035e46ae8d6b013d0ec849bdb7", + "reference": "b14498a09af826035e46ae8d6b013d0ec849bdb7", "shasum": "" }, "require": { @@ -2685,9 +2685,9 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.17.0" + "source": "https://github.com/laravel/horizon/tree/v5.18.0" }, - "time": "2023-06-13T20:49:30+00:00" + "time": "2023-06-30T15:11:51+00:00" }, { "name": "laravel/passport", @@ -6651,23 +6651,24 @@ }, { "name": "react/promise", - "version": "v2.10.0", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/reactphp/promise.git", - "reference": "f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38" + "reference": "c86753c76fd3be465d93b308f18d189f01a22be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38", - "reference": "f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38", + "url": "https://api.github.com/repos/reactphp/promise/zipball/c86753c76fd3be465d93b308f18d189f01a22be4", + "reference": "c86753c76fd3be465d93b308f18d189f01a22be4", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.36" + "phpstan/phpstan": "1.10.20 || 1.4.10", + "phpunit/phpunit": "^9.5 || ^7.5" }, "type": "library", "autoload": { @@ -6711,7 +6712,7 @@ ], "support": { "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v2.10.0" + "source": "https://github.com/reactphp/promise/tree/v3.0.0" }, "funding": [ { @@ -6719,7 +6720,7 @@ "type": "open_collective" } ], - "time": "2023-05-02T15:15:43+00:00" + "time": "2023-07-11T16:12:49+00:00" }, { "name": "react/socket", @@ -10920,16 +10921,16 @@ }, { "name": "filp/whoops", - "version": "2.15.2", + "version": "2.15.3", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "aac9304c5ed61bf7b1b7a6064bf9806ab842ce73" + "reference": "c83e88a30524f9360b11f585f71e6b17313b7187" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/aac9304c5ed61bf7b1b7a6064bf9806ab842ce73", - "reference": "aac9304c5ed61bf7b1b7a6064bf9806ab842ce73", + "url": "https://api.github.com/repos/filp/whoops/zipball/c83e88a30524f9360b11f585f71e6b17313b7187", + "reference": "c83e88a30524f9360b11f585f71e6b17313b7187", "shasum": "" }, "require": { @@ -10979,7 +10980,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.15.2" + "source": "https://github.com/filp/whoops/tree/2.15.3" }, "funding": [ { @@ -10987,7 +10988,7 @@ "type": "github" } ], - "time": "2023-04-12T12:00:00+00:00" + "time": "2023-07-13T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -11101,16 +11102,16 @@ }, { "name": "laravel/telescope", - "version": "v4.15.0", + "version": "v4.15.2", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "572a19b4c9b09295848de9a2352737a756a0fb05" + "reference": "5d74ae4c9f269b756d7877ad1527770c59846e14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/572a19b4c9b09295848de9a2352737a756a0fb05", - "reference": "572a19b4c9b09295848de9a2352737a756a0fb05", + "url": "https://api.github.com/repos/laravel/telescope/zipball/5d74ae4c9f269b756d7877ad1527770c59846e14", + "reference": "5d74ae4c9f269b756d7877ad1527770c59846e14", "shasum": "" }, "require": { @@ -11166,9 +11167,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v4.15.0" + "source": "https://github.com/laravel/telescope/tree/v4.15.2" }, - "time": "2023-06-08T13:57:22+00:00" + "time": "2023-07-13T20:06:27+00:00" }, { "name": "mockery/mockery", diff --git a/config/federation.php b/config/federation.php index 4b6795687..773d3d16b 100644 --- a/config/federation.php +++ b/config/federation.php @@ -2,56 +2,59 @@ return [ - /* - |-------------------------------------------------------------------------- - | ActivityPub - |-------------------------------------------------------------------------- - | - | ActivityPub configuration - | - */ - 'activitypub' => [ - 'enabled' => env('ACTIVITY_PUB', false), - 'outbox' => env('AP_OUTBOX', true), - 'inbox' => env('AP_INBOX', true), - 'sharedInbox' => env('AP_SHAREDINBOX', true), + /* + |-------------------------------------------------------------------------- + | ActivityPub + |-------------------------------------------------------------------------- + | + | ActivityPub configuration + | + */ + 'activitypub' => [ + 'enabled' => env('ACTIVITY_PUB', false), + 'outbox' => env('AP_OUTBOX', true), + 'inbox' => env('AP_INBOX', true), + 'sharedInbox' => env('AP_SHAREDINBOX', true), - 'remoteFollow' => env('AP_REMOTE_FOLLOW', true), + 'remoteFollow' => env('AP_REMOTE_FOLLOW', true), - 'delivery' => [ - 'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 30.0), - 'concurrency' => env('ACTIVITYPUB_DELIVERY_CONCURRENCY', 10), - 'logger' => [ - 'enabled' => env('AP_LOGGER_ENABLED', false), - 'driver' => 'log' - ] - ] - ], + 'delivery' => [ + 'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 30.0), + 'concurrency' => env('ACTIVITYPUB_DELIVERY_CONCURRENCY', 10), + 'logger' => [ + 'enabled' => env('AP_LOGGER_ENABLED', false), + 'driver' => 'log' + ] + ], - 'atom' => [ - 'enabled' => env('ATOM_FEEDS', true), - ], + 'ingest' => [ + 'store_notes_without_followers' => env('AP_INGEST_STORE_NOTES_WITHOUT_FOLLOWERS', false), + ], + ], - 'avatars' => [ - 'store_local' => env('REMOTE_AVATARS', true), - ], + 'atom' => [ + 'enabled' => env('ATOM_FEEDS', true), + ], - 'nodeinfo' => [ - 'enabled' => env('NODEINFO', true), - ], + 'avatars' => [ + 'store_local' => env('REMOTE_AVATARS', true), + ], - 'webfinger' => [ - 'enabled' => env('WEBFINGER', true) - ], + 'nodeinfo' => [ + 'enabled' => env('NODEINFO', true), + ], - 'network_timeline' => env('PF_NETWORK_TIMELINE', true), - 'network_timeline_days_falloff' => env('PF_NETWORK_TIMELINE_DAYS_FALLOFF', 2), + 'webfinger' => [ + 'enabled' => env('WEBFINGER', true) + ], - 'custom_emoji' => [ - 'enabled' => env('CUSTOM_EMOJI', false), + 'network_timeline' => env('PF_NETWORK_TIMELINE', true), + 'network_timeline_days_falloff' => env('PF_NETWORK_TIMELINE_DAYS_FALLOFF', 2), - // max size in bytes, default is 2mb - 'max_size' => env('CUSTOM_EMOJI_MAX_SIZE', 2000000), - ] + 'custom_emoji' => [ + 'enabled' => env('CUSTOM_EMOJI', false), + // max size in bytes, default is 2mb + 'max_size' => env('CUSTOM_EMOJI_MAX_SIZE', 2000000), + ], ]; diff --git a/config/filesystems.php b/config/filesystems.php index 6817d5e34..80e63ed99 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -79,6 +79,34 @@ return [ 'throw' => true, ], + 'alt-primary' => [ + 'enabled' => env('ALT_PRI_ENABLED', false), + 'driver' => 's3', + 'key' => env('ALT_PRI_AWS_ACCESS_KEY_ID'), + 'secret' => env('ALT_PRI_AWS_SECRET_ACCESS_KEY'), + 'region' => env('ALT_PRI_AWS_DEFAULT_REGION'), + 'bucket' => env('ALT_PRI_AWS_BUCKET'), + 'visibility' => 'public', + 'url' => env('ALT_PRI_AWS_URL'), + 'endpoint' => env('ALT_PRI_AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('ALT_PRI_AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => true, + ], + + 'alt-secondary' => [ + 'enabled' => env('ALT_SEC_ENABLED', false), + 'driver' => 's3', + 'key' => env('ALT_SEC_AWS_ACCESS_KEY_ID'), + 'secret' => env('ALT_SEC_AWS_SECRET_ACCESS_KEY'), + 'region' => env('ALT_SEC_AWS_DEFAULT_REGION'), + 'bucket' => env('ALT_SEC_AWS_BUCKET'), + 'visibility' => 'public', + 'url' => env('ALT_SEC_AWS_URL'), + 'endpoint' => env('ALT_SEC_AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('ALT_SEC_AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => true, + ], + 'spaces' => [ 'driver' => 's3', 'key' => env('DO_SPACES_KEY'), diff --git a/config/media.php b/config/media.php index b7d6e95cc..f550ff291 100644 --- a/config/media.php +++ b/config/media.php @@ -1,24 +1,26 @@ env('MEDIA_DELETE_LOCAL_AFTER_CLOUD', true), + 'delete_local_after_cloud' => env('MEDIA_DELETE_LOCAL_AFTER_CLOUD', true), - 'exif' => [ - 'database' => env('MEDIA_EXIF_DATABASE', false), - ], + 'exif' => [ + 'database' => env('MEDIA_EXIF_DATABASE', false), + ], - 'storage' => [ - 'remote' => [ - /* - |-------------------------------------------------------------------------- - | Store remote media on cloud/S3 - |-------------------------------------------------------------------------- - | - | Set this to cache remote media on cloud/S3 filesystem drivers. - | Disabled by default. - | - */ - 'cloud' => env('MEDIA_REMOTE_STORE_CLOUD', false) - ], - ] + 'storage' => [ + 'remote' => [ + /* + |-------------------------------------------------------------------------- + | Store remote media on cloud/S3 + |-------------------------------------------------------------------------- + | + | Set this to cache remote media on cloud/S3 filesystem drivers. + | Disabled by default. + | + */ + 'cloud' => env('MEDIA_REMOTE_STORE_CLOUD', false), + + 'resilient_mode' => env('ALT_PRI_ENABLED', false) || env('ALT_SEC_ENABLED', false), + ], + ] ]; diff --git a/config/pixelfed.php b/config/pixelfed.php index 18e78b21d..fcdb1a4b7 100644 --- a/config/pixelfed.php +++ b/config/pixelfed.php @@ -23,7 +23,7 @@ return [ | This value is the version of your Pixelfed instance. | */ - 'version' => '0.11.8', + 'version' => '0.11.9', /* |-------------------------------------------------------------------------- diff --git a/config/remote-auth.php b/config/remote-auth.php new file mode 100644 index 000000000..182bb99a7 --- /dev/null +++ b/config/remote-auth.php @@ -0,0 +1,57 @@ + [ + 'enabled' => env('PF_LOGIN_WITH_MASTODON_ENABLED', false), + 'ignore_closed_state' => env('PF_LOGIN_WITH_MASTODON_ENABLED_SKIP_CLOSED', false), + + 'contraints' => [ + /* + * Skip email verification + * + * To improve the onboarding experience, you can opt to skip the email + * verification process and automatically verify their email + */ + 'skip_email_verification' => env('PF_LOGIN_WITH_MASTODON_SKIP_EMAIL', true), + ], + + 'domains' => [ + 'default' => 'mastodon.social,mastodon.online,mstdn.social,mas.to', + + /* + * Custom mastodon domains + * + * Define a comma separated list of custom domains to allow + */ + 'custom' => env('PF_LOGIN_WITH_MASTODON_DOMAINS'), + + /* + * Use only default domains + * + * Allow Sign-in with Mastodon using only the default domains + */ + 'only_default' => env('PF_LOGIN_WITH_MASTODON_ONLY_DEFAULT', false), + + /* + * Use only custom domains + * + * Allow Sign-in with Mastodon using only the custom domains + * you define, in comma separated format + */ + 'only_custom' => env('PF_LOGIN_WITH_MASTODON_ONLY_CUSTOM', false), + ], + + 'max_uses' => [ + /* + * Max Uses + * + * Using a centralized service operated by pixelfed.org that tracks mastodon imports, + * you can set a limit of how many times a mastodon account can be imported across + * all known and reporting Pixelfed instances to prevent the same masto account from + * abusing this + */ + 'enabled' => env('PF_LOGIN_WITH_MASTODON_ENFORCE_MAX_USES', true), + 'limit' => env('PF_LOGIN_WITH_MASTODON_MAX_USES_LIMIT', 3) + ] + ], +]; diff --git a/config/security.php b/config/security.php new file mode 100644 index 000000000..a8f92360d --- /dev/null +++ b/config/security.php @@ -0,0 +1,9 @@ + [ + 'verify_dns' => env('PF_SECURITY_URL_VERIFY_DNS', false), + + 'trusted_domains' => env('PF_SECURITY_URL_TRUSTED_DOMAINS', 'pixelfed.social,pixelfed.art,mastodon.social'), + ] +]; diff --git a/database/migrations/2021_08_04_095125_create_groups_table.php b/database/migrations/2021_08_04_095125_create_groups_table.php new file mode 100644 index 000000000..29c63f73e --- /dev/null +++ b/database/migrations/2021_08_04_095125_create_groups_table.php @@ -0,0 +1,42 @@ +bigInteger('id')->unsigned()->primary(); + $table->bigInteger('profile_id')->unsigned()->nullable()->index(); + $table->string('status')->nullable()->index(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->text('rules')->nullable(); + $table->boolean('local')->default(true)->index(); + $table->string('remote_url')->nullable(); + $table->string('inbox_url')->nullable(); + $table->boolean('is_private')->default(false); + $table->boolean('local_only')->default(false); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('groups'); + } +} diff --git a/database/migrations/2021_08_04_095143_create_group_members_table.php b/database/migrations/2021_08_04_095143_create_group_members_table.php new file mode 100644 index 000000000..33df26229 --- /dev/null +++ b/database/migrations/2021_08_04_095143_create_group_members_table.php @@ -0,0 +1,40 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->string('role')->default('member')->index(); + $table->boolean('local_group')->default(false)->index(); + $table->boolean('local_profile')->default(false)->index(); + $table->boolean('join_request')->default(false)->index(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('rejected_at')->nullable(); + $table->unique(['group_id', 'profile_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_members'); + } +} diff --git a/database/migrations/2021_08_04_095238_create_group_posts_table.php b/database/migrations/2021_08_04_095238_create_group_posts_table.php new file mode 100644 index 000000000..a5e637d8e --- /dev/null +++ b/database/migrations/2021_08_04_095238_create_group_posts_table.php @@ -0,0 +1,42 @@ +bigInteger('id')->unsigned()->primary(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->nullable()->index(); + $table->string('type')->nullable()->index(); + $table->bigInteger('status_id')->unsigned()->unique(); + $table->string('remote_url')->unique()->nullable()->index(); + $table->bigInteger('reply_child_id')->unsigned()->nullable(); + $table->bigInteger('in_reply_to_id')->unsigned()->nullable(); + $table->bigInteger('reblog_of_id')->unsigned()->nullable(); + $table->unsignedInteger('reply_count')->nullable(); + $table->string('status')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_posts'); + } +} diff --git a/database/migrations/2021_08_16_072457_create_group_invitations_table.php b/database/migrations/2021_08_16_072457_create_group_invitations_table.php new file mode 100644 index 000000000..aa13db23a --- /dev/null +++ b/database/migrations/2021_08_16_072457_create_group_invitations_table.php @@ -0,0 +1,38 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('from_profile_id')->unsigned()->index(); + $table->bigInteger('to_profile_id')->unsigned()->index(); + $table->string('role')->nullable(); + $table->boolean('to_local')->default(true)->index(); + $table->boolean('from_local')->default(true)->index(); + $table->unique(['group_id', 'to_profile_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_invitations'); + } +} diff --git a/database/migrations/2023_07_07_025757_create_remote_auths_table.php b/database/migrations/2023_07_07_025757_create_remote_auths_table.php new file mode 100644 index 000000000..774965aa2 --- /dev/null +++ b/database/migrations/2023_07_07_025757_create_remote_auths_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('software')->nullable(); + $table->string('domain')->nullable()->index(); + $table->string('webfinger')->nullable()->unique()->index(); + $table->unsignedInteger('instance_id')->nullable()->index(); + $table->unsignedInteger('user_id')->nullable()->unique()->index(); + $table->unsignedInteger('client_id')->nullable()->index(); + $table->string('ip_address')->nullable(); + $table->text('bearer_token')->nullable(); + $table->json('verify_credentials')->nullable(); + $table->timestamp('last_successful_login_at')->nullable(); + $table->timestamp('last_verify_credentials_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('remote_auths'); + } +}; diff --git a/database/migrations/2023_07_07_030427_create_remote_auth_instances_table.php b/database/migrations/2023_07_07_030427_create_remote_auth_instances_table.php new file mode 100644 index 000000000..690197b9b --- /dev/null +++ b/database/migrations/2023_07_07_030427_create_remote_auth_instances_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('domain')->nullable()->unique()->index(); + $table->unsignedInteger('instance_id')->nullable()->index(); + $table->string('client_id')->nullable(); + $table->string('client_secret')->nullable(); + $table->string('redirect_uri')->nullable(); + $table->string('root_domain')->nullable()->index(); + $table->boolean('allowed')->nullable()->index(); + $table->boolean('banned')->default(false)->index(); + $table->boolean('active')->default(true)->index(); + $table->timestamp('last_refreshed_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('remote_auth_instances'); + } +}; diff --git a/database/migrations/2023_07_11_080040_add_show_reblogs_to_followers_table.php b/database/migrations/2023_07_11_080040_add_show_reblogs_to_followers_table.php new file mode 100644 index 000000000..d84433747 --- /dev/null +++ b/database/migrations/2023_07_11_080040_add_show_reblogs_to_followers_table.php @@ -0,0 +1,30 @@ +boolean('show_reblogs')->default(true)->index()->after('local_following'); + $table->boolean('notify')->default(false)->index()->after('show_reblogs'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('followers', function (Blueprint $table) { + $table->dropColumn('show_reblogs'); + $table->dropColumn('notify'); + }); + } +}; diff --git a/database/migrations/2023_08_07_021252_create_profile_aliases_table.php b/database/migrations/2023_08_07_021252_create_profile_aliases_table.php new file mode 100644 index 000000000..ed9ab6ada --- /dev/null +++ b/database/migrations/2023_08_07_021252_create_profile_aliases_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('profile_id')->nullable()->index(); + $table->string('acct')->nullable(); + $table->string('uri')->nullable(); + $table->foreign('profile_id')->references('id')->on('profiles'); + $table->unique(['profile_id', 'acct'], 'profile_id_acct_unique'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('profile_aliases'); + } +}; diff --git a/database/migrations/2023_08_08_045430_add_moved_to_profile_id_to_profiles_table.php b/database/migrations/2023_08_08_045430_add_moved_to_profile_id_to_profiles_table.php new file mode 100644 index 000000000..a13bb1705 --- /dev/null +++ b/database/migrations/2023_08_08_045430_add_moved_to_profile_id_to_profiles_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('moved_to_profile_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('profiles', function (Blueprint $table) { + $table->dropColumn('moved_to_profile_id'); + }); + } +}; diff --git a/database/migrations/2023_08_25_050021_add_indexable_column_to_profiles_table.php b/database/migrations/2023_08_25_050021_add_indexable_column_to_profiles_table.php new file mode 100644 index 000000000..f735366bd --- /dev/null +++ b/database/migrations/2023_08_25_050021_add_indexable_column_to_profiles_table.php @@ -0,0 +1,28 @@ +boolean('indexable')->default(false)->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('profiles', function (Blueprint $table) { + $table->dropColumn('indexable'); + }); + } +}; diff --git a/database/migrations/2023_09_12_044900_create_admin_shadow_filters_table.php b/database/migrations/2023_09_12_044900_create_admin_shadow_filters_table.php new file mode 100644 index 000000000..6b62f32c2 --- /dev/null +++ b/database/migrations/2023_09_12_044900_create_admin_shadow_filters_table.php @@ -0,0 +1,47 @@ +id(); + $table->unsignedBigInteger('admin_id')->nullable(); + $table->morphs('item'); + $table->boolean('is_local')->default(true)->index(); + $table->text('note')->nullable(); + $table->boolean('active')->default(false)->index(); + $table->json('history')->nullable(); + $table->json('ruleset')->nullable(); + $table->boolean('prevent_ap_fanout')->default(false)->index(); + $table->boolean('prevent_new_dms')->default(false)->index(); + $table->boolean('ignore_reports')->default(false)->index(); + $table->boolean('ignore_mentions')->default(false)->index(); + $table->boolean('ignore_links')->default(false)->index(); + $table->boolean('ignore_hashtags')->default(false)->index(); + $table->boolean('hide_from_public_feeds')->default(false)->index(); + $table->boolean('hide_from_tag_feeds')->default(false)->index(); + $table->boolean('hide_embeds')->default(false)->index(); + $table->boolean('hide_from_story_carousel')->default(false)->index(); + $table->boolean('hide_from_search_autocomplete')->default(false)->index(); + $table->boolean('hide_from_search')->default(false)->index(); + $table->boolean('requires_login')->default(false)->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('admin_shadow_filters'); + } +}; diff --git a/public/css/spa.css b/public/css/spa.css index 37cce8287..fd4124d27 100644 Binary files a/public/css/spa.css and b/public/css/spa.css differ diff --git a/public/js/daci.chunk.914d307d69fcfcd4.js b/public/js/daci.chunk.914d307d69fcfcd4.js deleted file mode 100644 index 36ceb5372..000000000 Binary files a/public/js/daci.chunk.914d307d69fcfcd4.js and /dev/null differ diff --git a/public/js/daci.chunk.bfa9e4f459fec835.js b/public/js/daci.chunk.bfa9e4f459fec835.js new file mode 100644 index 000000000..672d36622 Binary files /dev/null and b/public/js/daci.chunk.bfa9e4f459fec835.js differ diff --git a/public/js/discover~findfriends.chunk.006f0079e9f5a3eb.js b/public/js/discover~findfriends.chunk.006f0079e9f5a3eb.js deleted file mode 100644 index 1817e2611..000000000 Binary files a/public/js/discover~findfriends.chunk.006f0079e9f5a3eb.js and /dev/null differ diff --git a/public/js/discover~findfriends.chunk.6bd4ddbabd979778.js b/public/js/discover~findfriends.chunk.6bd4ddbabd979778.js new file mode 100644 index 000000000..4c0abc8b2 Binary files /dev/null and b/public/js/discover~findfriends.chunk.6bd4ddbabd979778.js differ diff --git a/public/js/discover~memories.chunk.400f9f019bdb9fdf.js b/public/js/discover~memories.chunk.400f9f019bdb9fdf.js new file mode 100644 index 000000000..38fdde249 Binary files /dev/null and b/public/js/discover~memories.chunk.400f9f019bdb9fdf.js differ diff --git a/public/js/discover~memories.chunk.4c0973f4400f25b4.js b/public/js/discover~memories.chunk.4c0973f4400f25b4.js deleted file mode 100644 index ed1077251..000000000 Binary files a/public/js/discover~memories.chunk.4c0973f4400f25b4.js and /dev/null differ diff --git a/public/js/discover~myhashtags.chunk.70e91906f0ce857a.js b/public/js/discover~myhashtags.chunk.70e91906f0ce857a.js deleted file mode 100644 index bdc28118b..000000000 Binary files a/public/js/discover~myhashtags.chunk.70e91906f0ce857a.js and /dev/null differ diff --git a/public/js/discover~myhashtags.chunk.ee5af357937cad2f.js b/public/js/discover~myhashtags.chunk.ee5af357937cad2f.js new file mode 100644 index 000000000..f271b0d2c Binary files /dev/null and b/public/js/discover~myhashtags.chunk.ee5af357937cad2f.js differ diff --git a/public/js/discover~serverfeed.chunk.017fd16f00c55e60.js b/public/js/discover~serverfeed.chunk.017fd16f00c55e60.js deleted file mode 100644 index 29f853c66..000000000 Binary files a/public/js/discover~serverfeed.chunk.017fd16f00c55e60.js and /dev/null differ diff --git a/public/js/discover~serverfeed.chunk.fbe31eedcdafc87e.js b/public/js/discover~serverfeed.chunk.fbe31eedcdafc87e.js new file mode 100644 index 000000000..841a15b90 Binary files /dev/null and b/public/js/discover~serverfeed.chunk.fbe31eedcdafc87e.js differ diff --git a/public/js/discover~settings.chunk.72cc15c7b87b662d.js b/public/js/discover~settings.chunk.72cc15c7b87b662d.js deleted file mode 100644 index a021de414..000000000 Binary files a/public/js/discover~settings.chunk.72cc15c7b87b662d.js and /dev/null differ diff --git a/public/js/discover~settings.chunk.909aa0316f43235e.js b/public/js/discover~settings.chunk.909aa0316f43235e.js new file mode 100644 index 000000000..b334ca360 Binary files /dev/null and b/public/js/discover~settings.chunk.909aa0316f43235e.js differ diff --git a/public/js/home.chunk.2d93b527d492e6de.js b/public/js/home.chunk.2d93b527d492e6de.js deleted file mode 100644 index a1f10eddc..000000000 Binary files a/public/js/home.chunk.2d93b527d492e6de.js and /dev/null differ diff --git a/public/js/home.chunk.bd623a430a5584c2.js b/public/js/home.chunk.bd623a430a5584c2.js new file mode 100644 index 000000000..5fa049906 Binary files /dev/null and b/public/js/home.chunk.bd623a430a5584c2.js differ diff --git a/public/js/home.chunk.2d93b527d492e6de.js.LICENSE.txt b/public/js/home.chunk.bd623a430a5584c2.js.LICENSE.txt similarity index 100% rename from public/js/home.chunk.2d93b527d492e6de.js.LICENSE.txt rename to public/js/home.chunk.bd623a430a5584c2.js.LICENSE.txt diff --git a/public/js/landing.js b/public/js/landing.js index 0feb25b13..2980b9e5a 100644 Binary files a/public/js/landing.js and b/public/js/landing.js differ diff --git a/public/js/manifest.js b/public/js/manifest.js index 7380d20eb..ff9202503 100644 Binary files a/public/js/manifest.js and b/public/js/manifest.js differ diff --git a/public/js/post.chunk.729ca668f46545cb.js b/public/js/post.chunk.729ca668f46545cb.js new file mode 100644 index 000000000..3ede2963b Binary files /dev/null and b/public/js/post.chunk.729ca668f46545cb.js differ diff --git a/public/js/post.chunk.cd535334efc77c34.js.LICENSE.txt b/public/js/post.chunk.729ca668f46545cb.js.LICENSE.txt similarity index 100% rename from public/js/post.chunk.cd535334efc77c34.js.LICENSE.txt rename to public/js/post.chunk.729ca668f46545cb.js.LICENSE.txt diff --git a/public/js/post.chunk.cd535334efc77c34.js b/public/js/post.chunk.cd535334efc77c34.js deleted file mode 100644 index 6479707c6..000000000 Binary files a/public/js/post.chunk.cd535334efc77c34.js and /dev/null differ diff --git a/public/js/profile.chunk.029572d9018fc65f.js b/public/js/profile.chunk.029572d9018fc65f.js new file mode 100644 index 000000000..493a62661 Binary files /dev/null and b/public/js/profile.chunk.029572d9018fc65f.js differ diff --git a/public/js/profile.chunk.4049e1eecea398ee.js b/public/js/profile.chunk.4049e1eecea398ee.js deleted file mode 100644 index 2ef3482d3..000000000 Binary files a/public/js/profile.chunk.4049e1eecea398ee.js and /dev/null differ diff --git a/public/js/remote_auth.js b/public/js/remote_auth.js new file mode 100644 index 000000000..88c60f980 Binary files /dev/null and b/public/js/remote_auth.js differ diff --git a/public/js/vendor.js b/public/js/vendor.js index e95bc6e95..c4633d457 100644 Binary files a/public/js/vendor.js and b/public/js/vendor.js differ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 7874f175a..c580e4791 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/components/partials/TimelineStatus.vue b/resources/assets/components/partials/TimelineStatus.vue index 6351d3f51..06e225eb9 100644 --- a/resources/assets/components/partials/TimelineStatus.vue +++ b/resources/assets/components/partials/TimelineStatus.vue @@ -3,18 +3,20 @@
+ :status="shadowStatus" /> m.hasOwnProperty('license') && m.license && m.license.hasOwnProperty('id')) .map(m => m.license)[0] : false; this.admin = window._sharedData.user.is_admin; - this.owner = this.status.account.id == window._sharedData.user.id; - if(this.status.reply_count && this.autoloadComments && this.status.comments_disabled === false) { + this.owner = this.shadowStatus.account.id == window._sharedData.user.id; + if(this.shadowStatus.reply_count && this.autoloadComments && this.shadowStatus.comments_disabled === false) { setTimeout(() => { this.showCommentDrawer = true; }, 1000); @@ -127,6 +129,24 @@ get() { return this.$store.state.newReactions; }, + }, + + isReblog: { + get() { + return this.status.reblog != null; + } + }, + + reblogAccount: { + get() { + return this.status.reblog ? this.status.account : null; + } + }, + + shadowStatus: { + get() { + return this.status.reblog ? this.status.reblog : this.status; + } } }, @@ -137,7 +157,7 @@ handler: function(o, n) { this.isBookmarking = false; } - } + }, }, methods: { diff --git a/resources/assets/components/partials/post/PostContent.vue b/resources/assets/components/partials/post/PostContent.vue index 9c43b4b83..057b07fea 100644 --- a/resources/assets/components/partials/post/PostContent.vue +++ b/resources/assets/components/partials/post/PostContent.vue @@ -138,7 +138,18 @@
-
+
+
+

+ +

+

Sensitive Content

+

{{ status.spoiler_text && status.spoiler_text.length ? status.spoiler_text : 'This post may contain sensitive content' }}

+

+ +

+
+
diff --git a/resources/assets/components/partials/post/PostHeader.vue b/resources/assets/components/partials/post/PostHeader.vue index ddbbf740c..afa20b10a 100644 --- a/resources/assets/components/partials/post/PostHeader.vue +++ b/resources/assets/components/partials/post/PostHeader.vue @@ -1,348 +1,382 @@ diff --git a/resources/assets/components/remote-auth/GettingStartedComponent.vue b/resources/assets/components/remote-auth/GettingStartedComponent.vue new file mode 100644 index 000000000..241730fe8 --- /dev/null +++ b/resources/assets/components/remote-auth/GettingStartedComponent.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/resources/assets/components/remote-auth/StartComponent.vue b/resources/assets/components/remote-auth/StartComponent.vue new file mode 100644 index 000000000..c93fc8f71 --- /dev/null +++ b/resources/assets/components/remote-auth/StartComponent.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/resources/assets/components/sections/Timeline.vue b/resources/assets/components/sections/Timeline.vue index 2f222096a..dd2c7d2f0 100644 --- a/resources/assets/components/sections/Timeline.vue +++ b/resources/assets/components/sections/Timeline.vue @@ -8,6 +8,30 @@
+ +
+
+
+

Introducing Reblogs in feeds

+
+

+ See reblogs from accounts you follow in your home feed! +

+

+ You can disable reblogs in feeds on the Timeline Settings page. +

+
+
+ + +
+
+
+
+
{ + this.settings = res.data; + + if(!res.data) { + this.showReblogBanner = true; + } else { + if(res.data.hasOwnProperty('hide_reblog_banner')) { + } else if(res.data.hasOwnProperty('enable_reblogs')) { + if(!res.data.enable_reblogs) { + this.showReblogBanner = true; + } + } else { + this.showReblogBanner = true; + } } + }) + .finally(() => { + this.fetchTimeline(); + }) + }, + + fetchTimeline(scrollToTop = false) { + let url, params; + if(this.getScope() === 'home' && this.settings && this.settings.hasOwnProperty('enable_reblogs') && this.settings.enable_reblogs) { + url = `/api/v1/timelines/home`; + params = { + '_pe': 1, + max_id: this.max_id, + limit: 6, + include_reblogs: true, + } + } else { + url = `/api/pixelfed/v1/timelines/${this.getScope()}`; + params = { + max_id: this.max_id, + limit: 6, + } + } + axios.get(url, { + params: params }).then(res => { let ids = res.data.map(p => { if(p && p.hasOwnProperty('relationship')) { @@ -242,12 +304,24 @@ this.isFetchingMore = true; - let url = `/api/pixelfed/v1/timelines/${this.getScope()}`; - axios.get(url, { - params: { + let url, params; + if(this.getScope() === 'home' && this.settings && this.settings.hasOwnProperty('enable_reblogs') && this.settings.enable_reblogs) { + url = `/api/v1/timelines/home`; + params = { + '_pe': 1, max_id: this.max_id, - limit: 6 + limit: 6, + include_reblogs: true, } + } else { + url = `/api/pixelfed/v1/timelines/${this.getScope()}`; + params = { + max_id: this.max_id, + limit: 6, + } + } + axios.get(url, { + params: params }).then(res => { if(!res.data.length) { this.endFeedReached = true; @@ -287,17 +361,30 @@ likeStatus(index) { let status = this.feed[index]; - let state = status.favourited; - let count = status.favourites_count; - this.feed[index].favourites_count = count + 1; - this.feed[index].favourited = !status.favourited; + if(status.reblog) { + status = status.reblog; + let state = status.favourited; + let count = status.favourites_count; + this.feed[index].reblog.favourites_count = count + 1; + this.feed[index].reblog.favourited = !status.favourited; + } else { + let state = status.favourited; + let count = status.favourites_count; + this.feed[index].favourites_count = count + 1; + this.feed[index].favourited = !status.favourited; + } axios.post('/api/v1/statuses/' + status.id + '/favourite') .then(res => { // }).catch(err => { - this.feed[index].favourites_count = count; - this.feed[index].favourited = false; + if(status.reblog) { + this.feed[index].reblog.favourites_count = count; + this.feed[index].reblog.favourited = false; + } else { + this.feed[index].favourites_count = count; + this.feed[index].favourited = false; + } let el = document.createElement('p'); el.classList.add('text-left'); @@ -339,17 +426,30 @@ unlikeStatus(index) { let status = this.feed[index]; - let state = status.favourited; - let count = status.favourites_count; - this.feed[index].favourites_count = count - 1; - this.feed[index].favourited = !status.favourited; + if(status.reblog) { + status = status.reblog; + let state = status.favourited; + let count = status.favourites_count; + this.feed[index].reblog.favourites_count = count - 1; + this.feed[index].reblog.favourited = !status.favourited; + } else { + let state = status.favourited; + let count = status.favourites_count; + this.feed[index].favourites_count = count - 1; + this.feed[index].favourited = !status.favourited; + } axios.post('/api/v1/statuses/' + status.id + '/unfavourite') .then(res => { // }).catch(err => { - this.feed[index].favourites_count = count; - this.feed[index].favourited = false; + if(status.reblog && status.pf_type == 'share') { + this.feed[index].reblog.favourites_count = count; + this.feed[index].reblog.favourited = false; + } else { + this.feed[index].favourites_count = count; + this.feed[index].favourited = false; + } }) }, @@ -371,7 +471,8 @@ openLikesModal(idx) { this.postIndex = idx; - this.likesModalPost = this.feed[this.postIndex]; + let post = this.feed[this.postIndex]; + this.likesModalPost = post.reblog ? post.reblog : post; this.showLikesModal = true; this.$nextTick(() => { this.$refs.likesModal.open(); @@ -380,7 +481,8 @@ openSharesModal(idx) { this.postIndex = idx; - this.sharesModalPost = this.feed[this.postIndex]; + let post = this.feed[this.postIndex]; + this.sharesModalPost = post.reblog ? post.reblog : post; this.showSharesModal = true; this.$nextTick(() => { this.$refs.sharesModal.open(); @@ -418,19 +520,32 @@ }, counterChange(index, type) { + let post = this.feed[index]; switch(type) { case 'comment-increment': - this.feed[index].reply_count = this.feed[index].reply_count + 1; + if(post.reblog != null) { + this.feed[index].reblog.reply_count = this.feed[index].reblog.reply_count + 1; + } else { + this.feed[index].reply_count = this.feed[index].reply_count + 1; + } break; case 'comment-decrement': - this.feed[index].reply_count = this.feed[index].reply_count - 1; + if(post.reblog != null) { + this.feed[index].reblog.reply_count = this.feed[index].reblog.reply_count - 1; + } else { + this.feed[index].reply_count = this.feed[index].reply_count - 1; + } break; } }, openCommentLikesModal(post) { - this.likesModalPost = post; + if(post.reblog != null) { + this.likesModalPost = post.reblog; + } else { + this.likesModalPost = post; + } this.showLikesModal = true; this.$nextTick(() => { this.$refs.likesModal.open(); @@ -439,33 +554,59 @@ shareStatus(index) { let status = this.feed[index]; - let state = status.reblogged; - let count = status.reblogs_count; - this.feed[index].reblogs_count = count + 1; - this.feed[index].reblogged = !status.reblogged; + if(status.reblog) { + status = status.reblog; + let state = status.reblogged; + let count = status.reblogs_count; + this.feed[index].reblog.reblogs_count = count + 1; + this.feed[index].reblog.reblogged = !status.reblogged; + } else { + let state = status.reblogged; + let count = status.reblogs_count; + this.feed[index].reblogs_count = count + 1; + this.feed[index].reblogged = !status.reblogged; + } axios.post('/api/v1/statuses/' + status.id + '/reblog') .then(res => { // }).catch(err => { - this.feed[index].reblogs_count = count; - this.feed[index].reblogged = false; + if(status.reblog) { + this.feed[index].reblog.reblogs_count = count; + this.feed[index].reblog.reblogged = false; + } else { + this.feed[index].reblogs_count = count; + this.feed[index].reblogged = false; + } }) }, unshareStatus(index) { let status = this.feed[index]; - let state = status.reblogged; - let count = status.reblogs_count; - this.feed[index].reblogs_count = count - 1; - this.feed[index].reblogged = !status.reblogged; + if(status.reblog) { + status = status.reblog; + let state = status.reblogged; + let count = status.reblogs_count; + this.feed[index].reblog.reblogs_count = count - 1; + this.feed[index].reblog.reblogged = !status.reblogged; + } else { + let state = status.reblogged; + let count = status.reblogs_count; + this.feed[index].reblogs_count = count - 1; + this.feed[index].reblogged = !status.reblogged; + } axios.post('/api/v1/statuses/' + status.id + '/unreblog') .then(res => { // }).catch(err => { - this.feed[index].reblogs_count = count; - this.feed[index].reblogged = false; + if(status.reblog) { + this.feed[index].reblog.reblogs_count = count; + this.feed[index].reblog.reblogged = false; + } else { + this.feed[index].reblogs_count = count; + this.feed[index].reblogged = false; + } }) }, @@ -480,11 +621,19 @@ handleBookmark(index) { let p = this.feed[index]; + if(p.reblog) { + p = p.reblog; + } + axios.post('/i/bookmark', { item: p.id }) .then(res => { - this.feed[index].bookmarked = !p.bookmarked; + if(this.feed[index].reblog) { + this.feed[index].reblog.bookmarked = !p.bookmarked; + } else { + this.feed[index].bookmarked = !p.bookmarked; + } }) .catch(err => { // this.feed[index].bookmarked = false; @@ -497,31 +646,51 @@ }, follow(index) { - // this.feed[index].relationship.following = true; - - axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/follow') - .then(res => { - this.$store.commit('updateRelationship', [res.data]); - this.updateProfile({ following_count: this.profile.following_count + 1 }); - this.feed[index].account.followers_count = this.feed[index].account.followers_count + 1; - }).catch(err => { - swal('Oops!', 'An error occured when attempting to follow this account.', 'error'); - this.feed[index].relationship.following = false; - }); + if(this.feed[index].reblog) { + axios.post('/api/v1/accounts/' + this.feed[index].reblog.account.id + '/follow') + .then(res => { + this.$store.commit('updateRelationship', [res.data]); + this.updateProfile({ following_count: this.profile.following_count + 1 }); + this.feed[index].reblog.account.followers_count = this.feed[index].reblog.account.followers_count + 1; + }).catch(err => { + swal('Oops!', 'An error occured when attempting to follow this account.', 'error'); + this.feed[index].reblog.relationship.following = false; + }); + } else { + axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/follow') + .then(res => { + this.$store.commit('updateRelationship', [res.data]); + this.updateProfile({ following_count: this.profile.following_count + 1 }); + this.feed[index].account.followers_count = this.feed[index].account.followers_count + 1; + }).catch(err => { + swal('Oops!', 'An error occured when attempting to follow this account.', 'error'); + this.feed[index].relationship.following = false; + }); + } }, unfollow(index) { - // this.feed[index].relationship.following = false; - - axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/unfollow') - .then(res => { - this.$store.commit('updateRelationship', [res.data]); - this.updateProfile({ following_count: this.profile.following_count - 1 }); - this.feed[index].account.followers_count = this.feed[index].account.followers_count - 1; - }).catch(err => { - swal('Oops!', 'An error occured when attempting to unfollow this account.', 'error'); - this.feed[index].relationship.following = true; - }); + if(this.feed[index].reblog) { + axios.post('/api/v1/accounts/' + this.feed[index].reblog.account.id + '/unfollow') + .then(res => { + this.$store.commit('updateRelationship', [res.data]); + this.updateProfile({ following_count: this.profile.following_count - 1 }); + this.feed[index].reblog.account.followers_count = this.feed[index].reblog.account.followers_count - 1; + }).catch(err => { + swal('Oops!', 'An error occured when attempting to unfollow this account.', 'error'); + this.feed[index].reblog.relationship.following = true; + }); + } else { + axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/unfollow') + .then(res => { + this.$store.commit('updateRelationship', [res.data]); + this.updateProfile({ following_count: this.profile.following_count - 1 }); + this.feed[index].account.followers_count = this.feed[index].account.followers_count - 1; + }).catch(err => { + swal('Oops!', 'An error occured when attempting to unfollow this account.', 'error'); + this.feed[index].relationship.following = true; + }); + } }, updateProfile(delta) { @@ -568,7 +737,31 @@ this.$nextTick(() => { this.forceUpdateIdx++; }); - } + }, + + enableReblogs() { + this.enablingReblogs = true; + + axios.post('/api/pixelfed/v1/web/settings', { + field: 'enable_reblogs', + value: true + }) + .then(res => { + setTimeout(() => { + window.location.reload(); + }, 1000); + }) + }, + + hideReblogs() { + this.showReblogBanner = false; + axios.post('/api/pixelfed/v1/web/settings', { + field: 'hide_reblog_banner', + value: true + }) + .then(res => { + }) + }, }, watch: { diff --git a/resources/assets/js/remote_auth.js b/resources/assets/js/remote_auth.js new file mode 100644 index 000000000..a852e2d5f --- /dev/null +++ b/resources/assets/js/remote_auth.js @@ -0,0 +1,9 @@ +Vue.component( + 'remote-auth-start-component', + require('./../components/remote-auth/StartComponent.vue').default +); + +Vue.component( + 'remote-auth-getting-started-component', + require('./../components/remote-auth/GettingStartedComponent.vue').default +); diff --git a/resources/assets/sass/spa.scss b/resources/assets/sass/spa.scss index e2ed0e054..72e08e16b 100644 --- a/resources/assets/sass/spa.scss +++ b/resources/assets/sass/spa.scss @@ -1,166 +1,172 @@ @import "lib/ibmplexsans"; :root { - --light: #fff; - --dark: #000; - --body-bg: rgba(243,244,246,1); - --body-color: #212529; - --nav-bg: #fff; - --bg-light: #f8f9fa; + --light: #fff; + --dark: #000; + --body-bg: rgba(243,244,246,1); + --body-color: #212529; + --nav-bg: #fff; + --bg-light: #f8f9fa; - --primary: #3B82F6; - --light-gray: #f8f9fa; - --text-lighter: #94a3b8; + --primary: #3B82F6; + --light-gray: #f8f9fa; + --text-lighter: #94a3b8; - --card-bg: #fff; - --light-hover-bg: #f9fafb; - --btn-light-border: #fff; - --input-border: #e2e8f0; - --comment-bg: #eff2f5; - --border-color: #dee2e6; - --card-header-accent: #f9fafb; + --card-bg: #fff; + --light-hover-bg: #f9fafb; + --btn-light-border: #fff; + --input-border: #e2e8f0; + --comment-bg: #eff2f5; + --border-color: #dee2e6; + --card-header-accent: #f9fafb; - --dropdown-item-hover-bg: #e9ecef; - --dropdown-item-hover-color: #16181b; - --dropdown-item-color: #64748b; - --dropdown-item-active-color: #334155; + --dropdown-item-hover-bg: #e9ecef; + --dropdown-item-hover-color: #16181b; + --dropdown-item-color: #64748b; + --dropdown-item-active-color: #334155; } @media (prefers-color-scheme: dark) { - :root { - --light: #000; - --dark: #fff; - --body-bg: #000; - --body-color: #9ca3af; - --nav-bg: #000; - --bg-light: #212124; + :root { + --light: #000; + --dark: #fff; + --body-bg: #000; + --body-color: #9ca3af; + --nav-bg: #000; + --bg-light: #212124; - --light-gray: #212124; - --text-lighter: #818181; + --light-gray: #212124; + --text-lighter: #818181; - --card-bg: #161618; - --light-hover-bg: #212124; - --btn-light-border: #161618; - --input-border: #161618; - --comment-bg: #212124; - --border-color: #212124; - --card-header-accent: #212124; + --card-bg: #161618; + --light-hover-bg: #212124; + --btn-light-border: #161618; + --input-border: #161618; + --comment-bg: #212124; + --border-color: #212124; + --card-header-accent: #212124; - --dropdown-item-hover-bg: #000; - --dropdown-item-hover-color: #818181; - --dropdown-item-color: #64748b; - --dropdown-item-active-color: #fff; - } + --dropdown-item-hover-bg: #000; + --dropdown-item-hover-color: #818181; + --dropdown-item-color: #64748b; + --dropdown-item-active-color: #fff; + } } .force-light-mode { - --light: #fff; - --dark: #000; - --body-bg: rgba(243,244,246,1); - --body-color: #212529; - --nav-bg: #fff; - --bg-light: #f8f9fa; + --light: #fff; + --dark: #000; + --body-bg: rgba(243,244,246,1); + --body-color: #212529; + --nav-bg: #fff; + --bg-light: #f8f9fa; - --primary: #3B82F6; - --light-gray: #f8f9fa; - --text-lighter: #94a3b8; + --primary: #3B82F6; + --light-gray: #f8f9fa; + --text-lighter: #94a3b8; - --card-bg: #fff; - --light-hover-bg: #f9fafb; - --btn-light-border: #fff; - --input-border: #e2e8f0; - --comment-bg: #eff2f5; - --border-color: #dee2e6; - --card-header-accent: #f9fafb; + --card-bg: #fff; + --light-hover-bg: #f9fafb; + --btn-light-border: #fff; + --input-border: #e2e8f0; + --comment-bg: #eff2f5; + --border-color: #dee2e6; + --card-header-accent: #f9fafb; - --dropdown-item-hover-bg: #e9ecef; - --dropdown-item-hover-color: #16181b; - --dropdown-item-color: #64748b; - --dropdown-item-active-color: #334155; + --dropdown-item-hover-bg: #e9ecef; + --dropdown-item-hover-color: #16181b; + --dropdown-item-color: #64748b; + --dropdown-item-active-color: #334155; } .force-dark-mode { - --light: #000; - --dark: #fff; - --body-bg: #000; - --body-color: #9ca3af; - --nav-bg: #000; - --bg-light: #212124; + --light: #000; + --dark: #fff; + --body-bg: #000; + --body-color: #9ca3af; + --nav-bg: #000; + --bg-light: #212124; - --light-gray: #212124; - --text-lighter: #818181; + --light-gray: #212124; + --text-lighter: #818181; - --card-bg: #161618; - --light-hover-bg: #212124; - --btn-light-border: #161618; - --input-border: #161618; - --comment-bg: #212124; - --border-color: #212124; - --card-header-accent: #212124; + --card-bg: #161618; + --light-hover-bg: #212124; + --btn-light-border: #161618; + --input-border: #161618; + --comment-bg: #212124; + --border-color: #212124; + --card-header-accent: #212124; - --dropdown-item-hover-bg: #000; - --dropdown-item-hover-color: #818181; - --dropdown-item-color: #64748b; - --dropdown-item-active-color: #b3b3b3; + --dropdown-item-hover-bg: #000; + --dropdown-item-hover-color: #818181; + --dropdown-item-color: #64748b; + --dropdown-item-active-color: #b3b3b3; } body { - background: var(--body-bg); - font-family: 'IBM Plex Sans', sans-serif; - color: var(--body-color); + background: var(--body-bg); + font-family: 'IBM Plex Sans', sans-serif; + color: var(--body-color); } .web-wrapper { - margin-bottom: 10rem; + margin-bottom: 10rem; } .container-fluid { - max-width: 1440px !important; + max-width: 1440px !important; } .jumbotron { - border-radius: 18px; + border-radius: 18px; } .rounded-px { - border-radius: 18px; + border-radius: 18px; } .doc-body { - p:last-child { - margin-bottom: 0; - } + p:last-child { + margin-bottom: 0; + } } .navbar-laravel { - background-color: var(--nav-bg); + background-color: var(--nav-bg); } .sticky-top { - z-index: 2; + z-index: 2; } .navbar-light .navbar-brand { - color: var(--dark); + color: var(--dark); - &:hover { - color: var(--dark); - } + &:hover { + color: var(--dark); + } } .primary { - color: var(--primary); + color: var(--primary); +} + +.bg-g-amin { + background: #8E2DE2; + background: -webkit-linear-gradient(to right, #4A00E0, #8E2DE2); + background: linear-gradient(to left, #4A00E0, #8E2DE2); } .text-lighter { - color: var(--text-lighter) !important; + color: var(--text-lighter) !important; } .text-dark { color: var(--body-color) !important; &:hover { - color: var(--dark) !important; + color: var(--dark) !important; } } @@ -169,16 +175,16 @@ a.text-dark:hover { } .badge-primary { - background-color: var(--primary); + background-color: var(--primary); } .btn-primary { - background-color: var(--primary); - color: #fff !important; + background-color: var(--primary); + color: #fff !important; } .btn-outline-light { - border-color: var(--light-gray); + border-color: var(--light-gray); } .border { @@ -187,51 +193,51 @@ a.text-dark:hover { .bg-white, .bg-light { - background-color: var(--bg-light) !important; - border-color: var(--bg-light) !important; + background-color: var(--bg-light) !important; + border-color: var(--bg-light) !important; } .btn-light { - background-color: var(--light-gray); - border-color: var(--btn-light-border); - color: var(--body-color); + background-color: var(--light-gray); + border-color: var(--btn-light-border); + color: var(--body-color); - &:hover { - color: var(--body-color); - background-color: var(--card-bg); - border-color: var(--btn-light-border); - } + &:hover { + color: var(--body-color); + background-color: var(--card-bg); + border-color: var(--btn-light-border); + } } .autocomplete-input { - border: 1px solid var(--light-gray) !important; - color: var(--body-color); + border: 1px solid var(--light-gray) !important; + color: var(--body-color); } .autocomplete-result-list { - background: var(--light) !important; - z-index: 2 !important; + background: var(--light) !important; + z-index: 2 !important; } .dropdown-menu, span.twitter-typeahead .tt-menu, .form-control { - border: 1px solid var(--border-color) !important; - color: var(--body-color); - background-color: var(--card-bg); + border: 1px solid var(--border-color) !important; + color: var(--body-color); + background-color: var(--card-bg); } .tribute-container li, .dropdown-item, span.twitter-typeahead .tt-suggestion { - color: var(--body-color); + color: var(--body-color); } .dropdown-item:hover, span.twitter-typeahead .tt-suggestion:hover, .dropdown-item:focus, span.twitter-typeahead .tt-suggestion:focus { - color: var(--dropdown-item-hover-color); + color: var(--dropdown-item-hover-color); background-color: var(--dropdown-item-hover-bg); text-decoration: none; } @@ -245,7 +251,7 @@ span.twitter-typeahead .tt-suggestion:focus { .card-header, .card-footer, .ph-item { - background-color: var(--card-bg); + background-color: var(--card-bg); } .badge-light, @@ -253,143 +259,147 @@ span.twitter-typeahead .tt-suggestion:focus { .ph-avatar, .ph-picture, .ph-row div { - background-color: var(--light-gray); + background-color: var(--light-gray); } .card-header, .border-top, .border-bottom { - border-color: var(--border-color) !important; + border-color: var(--border-color) !important; } .modal-header, .modal-footer { - border-color: var(--border-color); + border-color: var(--border-color); } .compose-action:hover { - background-color: var(--light-gray) !important; + background-color: var(--light-gray) !important; } .dropdown-divider { - border-color: var(--dropdown-item-hover-bg); + border-color: var(--dropdown-item-hover-bg); } .metro-nav { - &.flex-column { - background-color: var(--card-bg); + &.flex-column { + background-color: var(--card-bg); - .nav-item { - .nav-link:hover { - background-color: var(--light-hover-bg); - } - } - } + .nav-item { + .nav-link:hover { + background-color: var(--light-hover-bg); + } + } + } } .child-reply-form { - .form-control { - border-color: var(--input-border); - color: var(--body-color); - } + .form-control { + border-color: var(--input-border); + color: var(--body-color); + } } .ui-menu { - .btn-group { - .btn:first-child { - border-top-left-radius: 50rem; - border-bottom-left-radius: 50rem; - } + .btn-group { + .btn:first-child { + border-top-left-radius: 50rem; + border-bottom-left-radius: 50rem; + } - .btn:last-child { - border-top-right-radius: 50rem; - border-bottom-right-radius: 50rem; - } + .btn:last-child { + border-top-right-radius: 50rem; + border-bottom-right-radius: 50rem; + } - .btn-primary { - font-weight: bold; - } - } + .btn-primary { + font-weight: bold; + } + } - .b-custom-control-lg { - padding-bottom: 8px; - } + .b-custom-control-lg { + padding-bottom: 8px; + } } .content-label { - &-wrapper { - div:not(.content-label) { - height: 100%; - } - } + &-wrapper { + div:not(.content-label) { + height: 100%; + } + } - &-text { - width: 80%; - @media (min-width: 768px) { - width: 50%; - } - } + &-text { + width: 80%; + @media (min-width: 768px) { + width: 50%; + } + } } .compose-modal-component { - .form-control:focus { - color: var(--body-color); + .form-control:focus { + color: var(--body-color); } } .modal-body { - .nav-tabs .nav-link.active, - .nav-tabs .nav-item.show .nav-link { - background-color: transparent; - border-color: var(--border-color); - } + .nav-tabs .nav-link.active, + .nav-tabs .nav-item.show .nav-link { + background-color: transparent; + border-color: var(--border-color); + } - .nav-tabs .nav-link:hover, - .nav-tabs .nav-link:focus { - border-color: var(--border-color); - } + .nav-tabs .nav-link:hover, + .nav-tabs .nav-link:focus { + border-color: var(--border-color); + } - .form-control:focus { - color: var(--body-color); + .form-control:focus { + color: var(--body-color); } } .tribute-container { - border: 0; + border: 0; - ul { - margin-top: 0; - border-color: var(--border-color); - } + ul { + margin-top: 0; + border-color: var(--border-color); + } - li { - padding: 0.5rem 1rem; - border-top: 0; - border-left: 0; - border-right: 0; - font-size: 13px; + li { + padding: 0.5rem 1rem; + border-top: 0; + border-left: 0; + border-right: 0; + font-size: 13px; - &:not(:last-child) { - border-bottom: 1px solid var(--border-color); - } + &:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } - &.highlight, - &:hover { - color: var(--body-color); - font-weight: bold; - background: rgba(44, 120, 191, 0.25); - } - } + &.highlight, + &:hover { + color: var(--body-color); + font-weight: bold; + background: rgba(44, 120, 191, 0.25); + } + } +} + +.ft-std { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } .timeline-status-component { - .username { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - margin-bottom: -3px; - word-break: break-word; + .username { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin-bottom: -3px; + word-break: break-word; - @media (min-width: 768px) { - font-size: 17px; - } - } + @media (min-width: 768px) { + font-size: 17px; + } + } } diff --git a/resources/views/admin/asf/create.blade.php b/resources/views/admin/asf/create.blade.php new file mode 100644 index 000000000..8fc88e4bd --- /dev/null +++ b/resources/views/admin/asf/create.blade.php @@ -0,0 +1,64 @@ +@extends('admin.partial.template-full') + +@section('section') +
+
+
+
+
+

New Shadow Filters

+

Creating a new admin shadow filter

+
+
+
+
+
+
+
+
+
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+
+ @csrf +
+ + +
+ +

Filters

+
+
+
+ + +
+
+ {{--
--}} +
+ +
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+@endsection diff --git a/resources/views/admin/asf/edit.blade.php b/resources/views/admin/asf/edit.blade.php new file mode 100644 index 000000000..6d7a633f0 --- /dev/null +++ b/resources/views/admin/asf/edit.blade.php @@ -0,0 +1,64 @@ +@extends('admin.partial.template-full') + +@section('section') +
+
+
+
+
+

Edit Shadow Filters

+

Editing shadow filters

+
+
+
+
+
+
+
+
+
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+
+ @csrf +
+ + +
+ +

Filters

+
+
+
+ hide_from_public_feeds ? 'checked=""' : '' !!}> + +
+
+ {{--
--}} +
+ +
+ + +
+
+ active ? 'checked=""' : ''}}> + +
+
+ +
+
+
+
+
+
+@endsection diff --git a/resources/views/admin/asf/home.blade.php b/resources/views/admin/asf/home.blade.php new file mode 100644 index 000000000..4fbb7730f --- /dev/null +++ b/resources/views/admin/asf/home.blade.php @@ -0,0 +1,81 @@ +@extends('admin.partial.template-full') + +@section('section') +
+
+
+
+
+

Admin Shadow Filters

+

Manage shadow filters across Accounts, Hashtags, Feeds and Stories

+
+
+
+
+
+
+
+
+
+ +
+ +
+
+ +
+
+
+ +
+ + + + + + + + + + + + @foreach($filters as $filter) + + + + + + + + @endforeach + +
IDUsernameHide FeedsActiveCreated
{{ $filter->id }} +
+ + +

+ @{{ $filter->account()['acct'] }} +

+
+
{{ $filter->hide_from_public_feeds ? '✅' : ''}}{{ $filter->active ? '✅' : ''}}{{ $filter->created_at->diffForHumans() }}
+ +
+ {{ $filters->links() }} +
+
+
+
+@endsection diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index 2c784ccbb..b5ac22438 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -78,7 +78,7 @@
- +
@@ -97,4 +97,4 @@
-@endsection \ No newline at end of file +@endsection diff --git a/resources/views/admin/users/show.blade.php b/resources/views/admin/users/show.blade.php index 7652db6ea..f55c1dac3 100644 --- a/resources/views/admin/users/show.blade.php +++ b/resources/views/admin/users/show.blade.php @@ -58,52 +58,97 @@ ADMIN

@endif -

- Joined {{$profile->created_at->diffForHumans()}} -

+ +
+
+

+ {{$profile->created_at->diffForHumans()}} +

+

+ Joined +

+
+ @if($user->last_active_at) +
+

+ {{$user->last_active_at->diffForHumans()}} +

+

+ Last Active +

+
+ @endif +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
bookmarks{{$profile->bookmarks()->count()}}
collections{{$profile->collections()->count()}}
likes{{$profile->likes()->count()}}
reports{{$profile->reports()->count()}}
reported{{$profile->reported()->count()}}
Active stories{{$profile->stories()->count()}}
storage used{{PrettyNumber::size($profile->media()->sum('size'))}} / {{PrettyNumber::size(config_cache('pixelfed.max_account_size') * 1000)}}
+ +
+
+

email

+

{{$user->email}}

+
+ + @if($profile->website) +
+

website

+

{{$profile->website}}

+
+ @endif + +
+

bookmarks

+

{{$profile->bookmarks()->count()}}

+
+ +
+

collections

+

{{$profile->collections()->count()}}

+
+ +
+

likes

+

{{$profile->likes()->count()}}

+
+ +
+

reports

+

{{$profile->reports()->count()}}

+
+ +
+

reported

+

{{$profile->reported()->count()}}

+
+ +
+

active stories

+

{{$profile->stories()->count()}}

+
+ +
+

storage used

+

{{PrettyNumber::size($profile->media()->sum('size'))}} / {{PrettyNumber::size(config_cache('pixelfed.max_account_size') * 1000)}}

+
+ +
+

bio

+

{{ $profile->bio }}

+
+

Recent Posts


- @foreach($profile->statuses()->whereHas('media')->latest()->take(9)->get() as $item) -
+ @foreach($profile->statuses()->whereHas('media')->latest()->take(16)->get() as $item) + @php($post = \App\Services\StatusService::get($item->id, false)) + @endforeach @@ -119,3 +164,44 @@
@endsection + +@push('styles') + +@endpush diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 5654980cf..3eff0cf06 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -64,7 +64,7 @@
@endif -
+
+ + @if( + (config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled')) || + (config('remote-auth.mastodon.ignore_closed_state') && config('remote-auth.mastodon.enabled')) + ) +
+
+ @csrf +
+
+ +
+
+
+ @endif
diff --git a/resources/views/auth/remote/onboarding.blade.php b/resources/views/auth/remote/onboarding.blade.php new file mode 100644 index 000000000..82212a75d --- /dev/null +++ b/resources/views/auth/remote/onboarding.blade.php @@ -0,0 +1,10 @@ +@extends('layouts.app') + +@section('content') + +@endsection + +@push('scripts') + + +@endpush diff --git a/resources/views/auth/remote/start.blade.php b/resources/views/auth/remote/start.blade.php new file mode 100644 index 000000000..f91a9118b --- /dev/null +++ b/resources/views/auth/remote/start.blade.php @@ -0,0 +1,10 @@ +@extends('layouts.app') + +@section('content') + +@endsection + +@push('scripts') + + +@endpush diff --git a/resources/views/profile/embed.blade.php b/resources/views/profile/embed.blade.php index 0050a90e2..cc6097e3a 100644 --- a/resources/views/profile/embed.blade.php +++ b/resources/views/profile/embed.blade.php @@ -1,7 +1,7 @@ - + @@ -16,8 +16,8 @@ - - + + + } +
-
-
-
- - - {{$profile['username']}} - +
+ -
-
-
-

-

Posts

-
-
-

-

Followers

-
-
-

Follow

-
-
-
-
- +
+
+
+
+

+

Posts

+
+

+

Followers

- - - - - - + + + + + - + }); + + diff --git a/resources/views/settings/aliases/index.blade.php b/resources/views/settings/aliases/index.blade.php new file mode 100644 index 000000000..af79f654d --- /dev/null +++ b/resources/views/settings/aliases/index.blade.php @@ -0,0 +1,121 @@ +@extends('layouts.app') + +@section('content') +@if (session('status')) +
+ {{ session('status') }} +
+@endif +@if ($errors->any()) +
+ @foreach($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+@endif +@if (session('error')) +
+ {{ session('error') }} +
+@endif + +
+
+
+
+
+
+
+
+

Manage Aliases

+ + + + Back to Settings + +
+ +
+ +
+
+

If you want to move from another account to this one, you can create an alias here first.

+

This alias is needed before you can move your followers from the old account to this one. Don't worry, making this change is safe and can be undone. The process of moving the account starts from the old one.

+ +

Your followers will be migrated to your new account, and in some instances your posts too! For more information on Aliases and Account Migration, visit the Help Center.

+
+ +
+
+
+

Old Account

+

oldUsername@example.org

+
+ +
+

Old Account

+

oldUsername2@example.net

+
+ +
+

We support migration to and from Pixelfed, Mastodon and most other platforms that use the Mastodon Account Migration extension.

+
+ +
+
+
+
+ +
+
+
+
+
+ @csrf + +
+ +

Enter the username@domain of your old account

+ +
+ + +
+
+ +
+

Aliases

+
+ @if(count($aliases)) + @foreach($aliases as $alias) +
+
+ {{ $alias->acct }} +
+ +
+
+ @csrf + + + +
+
+
+ @endforeach + @else +
+

No aliases found!

+
+ @endif +
+
+
+
+
+
+
+ +@endsection diff --git a/resources/views/settings/home.blade.php b/resources/views/settings/home.blade.php index 2c02cdde9..97cffd5ec 100644 --- a/resources/views/settings/home.blade.php +++ b/resources/views/settings/home.blade.php @@ -87,6 +87,14 @@

Select up to 4 pronouns that will appear on your profile.

+ +
+ +
+ Manage account alias +

To move from another account to this one, first you need to create an alias.

+
+
@if(config_cache('pixelfed.enforce_account_limit'))

Storage Usage

diff --git a/resources/views/settings/partial/sidebar.blade.php b/resources/views/settings/partial/sidebar.blade.php index b4acf8c9b..a3837066a 100644 --- a/resources/views/settings/partial/sidebar.blade.php +++ b/resources/views/settings/partial/sidebar.blade.php @@ -72,6 +72,8 @@ @media only screen and (min-width: 768px) { border-right: 1px solid #dee2e6 !important } + height: 100%; + flex-grow: 1; } @endpush diff --git a/resources/views/settings/privacy.blade.php b/resources/views/settings/privacy.blade.php index 78ead55ee..57f83c664 100644 --- a/resources/views/settings/privacy.blade.php +++ b/resources/views/settings/privacy.blade.php @@ -28,9 +28,17 @@
crawlable ? 'checked=""':''}} {{$settings->is_private ? 'disabled=""':''}}> -

When your account is visible to search engines, your information can be crawled and stored by search engines.

+

When your account is visible to search engines, your information can be crawled and stored by search engines. {!! $settings->is_private ? 'Not available when your account is private' : ''!!}

+
+ +
+ indexable ? 'checked=""':''}} {{$settings->is_private ? 'disabled=""':''}}> + +

Your public posts may appear in search results on Pixelfed and Mastodon. People who have interacted with your posts may be able to search them regardless. {!! $settings->is_private ? 'Not available when your account is private' : ''!!}

@@ -39,7 +47,7 @@ -

When this option is enabled, your profile is included in the Directory. Only public profiles are eligible.

+

When this option is enabled, your profile is included in the Directory. Only public profiles are eligible. {!! $settings->is_private ? 'Not available when your account is private' : ''!!}

@@ -97,10 +105,10 @@

Enable your profile atom feed. Only public profiles are eligible.

@if($settings->show_atom)

- - {{ $profile->permalink('.atom') }} - - + + {{ $profile->permalink('.atom') }} + +

@endif
diff --git a/resources/views/settings/timeline.blade.php b/resources/views/settings/timeline.blade.php index a3b0c3646..e65af8370 100644 --- a/resources/views/settings/timeline.blade.php +++ b/resources/views/settings/timeline.blade.php @@ -8,17 +8,29 @@
@csrf -
+

Show text-only posts from accounts you follow. (Home timeline only)

-
+

Show replies from accounts you follow. (Home timeline only)

+
+ + +

See reblogs from accounts you follow in your home feed. (Home timeline only)

+
+ +
+ + +

Only see reblogs of photos or photo albums. (Home timeline only)

+
+

diff --git a/resources/views/site/help/your-profile.blade.php b/resources/views/site/help/your-profile.blade.php index 76f1e7ebd..22a105d33 100644 --- a/resources/views/site/help/your-profile.blade.php +++ b/resources/views/site/help/your-profile.blade.php @@ -141,6 +141,45 @@


+

Migration

+

+ +

+
+ To migrate your account successfully, your old account must be on a Pixelfed or Mastodon server, or one that supports the Mastodon Account Migration extension. +
+

Navigate to the Account Aliases page in the Settings to begin.

+
+
+

+

+ +

+
+ It can take a few hours to process post migration imports, please contact admins if it takes longer than 24 hours. +
+
+

+

+ +

+
+ Post migrations are officially supported on Pixelfed servers running v0.11.9+ and higher, and when enabled by server admins. +
+ It can take a few hours to process post migration imports, please contact admins if it takes longer than 24 hours. +
+
+

+

Delete Your Account