diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b545a30..8ab35fec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Added `app:hashtag-cached-count-update` command to update cached_count of hashtags and add to scheduler to run every 25 minutes past the hour ([1e31fee6](https://github.com/pixelfed/pixelfed/commit/1e31fee6)) - Added `app:hashtag-related-generate` command to generate related hashtags ([176b4ed7](https://github.com/pixelfed/pixelfed/commit/176b4ed7)) - Added Mutual Followers API endpoint ([33dbbe46](https://github.com/pixelfed/pixelfed/commit/33dbbe46)) +- Added User Domain Blocks ([#4834](https://github.com/pixelfed/pixelfed/pull/4834)) ([fa0380ac](https://github.com/pixelfed/pixelfed/commit/fa0380ac)) ### Federation - Update Privacy Settings, add support for Mastodon `indexable` search flag ([fc24630e](https://github.com/pixelfed/pixelfed/commit/fc24630e)) @@ -80,6 +81,8 @@ - Update Inbox, improve tombstone query efficiency ([759a4393](https://github.com/pixelfed/pixelfed/commit/759a4393)) - Update AccountService, add setLastActive method ([ebbd98e7](https://github.com/pixelfed/pixelfed/commit/ebbd98e7)) - Update ApiV1Controller, set last_active_at ([b6419545](https://github.com/pixelfed/pixelfed/commit/b6419545)) +- Update AdminShadowFilter, fix deleted profile bug ([a492a95a](https://github.com/pixelfed/pixelfed/commit/a492a95a)) +- Update FollowerService, add $silent param to remove method to more efficently purge relationships ([1664a5bc](https://github.com/pixelfed/pixelfed/commit/1664a5bc)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9) diff --git a/app/Console/Commands/AddUserDomainBlock.php b/app/Console/Commands/AddUserDomainBlock.php new file mode 100644 index 000000000..6d5c192bf --- /dev/null +++ b/app/Console/Commands/AddUserDomainBlock.php @@ -0,0 +1,106 @@ +validateDomain($domain); + if(!$domain || empty($domain)) { + $this->error('Invalid domain'); + return; + } + $this->processBlocks($domain); + return; + } + + protected function validateDomain($domain) + { + if(!strpos($domain, '.')) { + return; + } + + if(str_starts_with($domain, 'https://')) { + $domain = str_replace('https://', '', $domain); + } + + if(str_starts_with($domain, 'http://')) { + $domain = str_replace('http://', '', $domain); + } + + $domain = strtolower(parse_url('https://' . $domain, PHP_URL_HOST)); + + $valid = filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME|FILTER_NULL_ON_FAILURE); + if(!$valid) { + return; + } + + if($domain === config('pixelfed.domain.app')) { + $this->error('Invalid domain'); + return; + } + + $confirmed = confirm('Are you sure you want to block ' . $domain . '?'); + if(!$confirmed) { + return; + } + + return $domain; + } + + protected function processBlocks($domain) + { + DefaultDomainBlock::updateOrCreate([ + 'domain' => $domain + ]); + progress( + label: 'Updating user domain blocks...', + steps: User::lazyById(500), + callback: fn ($user) => $this->performTask($user, $domain), + ); + } + + protected function performTask($user, $domain) + { + if(!$user->profile_id || $user->delete_after) { + return; + } + + if($user->status != null && $user->status != 'disabled') { + return; + } + + UserDomainBlock::updateOrCreate([ + 'profile_id' => $user->profile_id, + 'domain' => $domain + ]); + } +} diff --git a/app/Console/Commands/DeleteUserDomainBlock.php b/app/Console/Commands/DeleteUserDomainBlock.php new file mode 100644 index 000000000..405b6fe76 --- /dev/null +++ b/app/Console/Commands/DeleteUserDomainBlock.php @@ -0,0 +1,96 @@ +validateDomain($domain); + if(!$domain || empty($domain)) { + $this->error('Invalid domain'); + return; + } + $this->processUnblocks($domain); + return; + } + + protected function validateDomain($domain) + { + if(!strpos($domain, '.')) { + return; + } + + if(str_starts_with($domain, 'https://')) { + $domain = str_replace('https://', '', $domain); + } + + if(str_starts_with($domain, 'http://')) { + $domain = str_replace('http://', '', $domain); + } + + $domain = strtolower(parse_url('https://' . $domain, PHP_URL_HOST)); + + $valid = filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME|FILTER_NULL_ON_FAILURE); + if(!$valid) { + return; + } + + if($domain === config('pixelfed.domain.app')) { + return; + } + + $confirmed = confirm('Are you sure you want to unblock ' . $domain . '?'); + if(!$confirmed) { + return; + } + + return $domain; + } + + protected function processUnblocks($domain) + { + DefaultDomainBlock::whereDomain($domain)->delete(); + if(!UserDomainBlock::whereDomain($domain)->count()) { + $this->info('No results found!'); + return; + } + progress( + label: 'Updating user domain blocks...', + steps: UserDomainBlock::whereDomain($domain)->lazyById(500), + callback: fn ($domainBlock) => $this->performTask($domainBlock), + ); + } + + protected function performTask($domainBlock) + { + $domainBlock->deleteQuietly(); + } +} diff --git a/app/Http/Controllers/AdminShadowFilterController.php b/app/Http/Controllers/AdminShadowFilterController.php index 461e1d0c2..e181be5c1 100644 --- a/app/Http/Controllers/AdminShadowFilterController.php +++ b/app/Http/Controllers/AdminShadowFilterController.php @@ -19,7 +19,8 @@ class AdminShadowFilterController extends Controller { $filter = $request->input('filter'); $searchQuery = $request->input('q'); - $filters = AdminShadowFilter::when($filter, function($q, $filter) { + $filters = AdminShadowFilter::whereHas('profile') + ->when($filter, function($q, $filter) { if($filter == 'all') { return $q; } else if($filter == 'inactive') { diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index f5e1202c0..798d9ee55 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -31,6 +31,7 @@ use App\{ UserSetting, UserFilter, }; +use App\Models\UserDomainBlock; use League\Fractal; use App\Transformer\Api\Mastodon\v1\{ AccountTransformer, @@ -2422,6 +2423,7 @@ class ApiV1Controller extends Controller $local = $request->has('local'); $filtered = $user ? UserFilterService::filters($user->profile_id) : []; AccountService::setLastActive($user->id); + $domainBlocks = UserFilterService::domainBlocks($user->profile_id); if($remote && config('instance.timeline.network.cached')) { Cache::remember('api:v1:timelines:network:cache_check', 10368000, function() { @@ -2496,6 +2498,13 @@ class ApiV1Controller extends Controller ->filter(function($s) use($filtered) { return $s && isset($s['account']) && in_array($s['account']['id'], $filtered) == false; }) + ->filter(function($s) use($domainBlocks) { + if(!$domainBlocks || !count($domainBlocks)) { + return $s; + } + $domain = strtolower(parse_url($s['url'], PHP_URL_HOST)); + return !in_array($domain, $domainBlocks); + }) ->take($limit) ->values(); @@ -3276,6 +3285,7 @@ class ApiV1Controller extends Controller $limit = $request->input('limit', 20); $onlyMedia = $request->input('only_media', true); $pe = $request->has(self::PF_API_ENTITY_KEY); + $pid = $request->user()->profile_id; if($min || $max) { $minMax = SnowflakeService::byDate(now()->subMonths(6)); @@ -3287,7 +3297,8 @@ class ApiV1Controller extends Controller } } - $filters = UserFilterService::filters($request->user()->profile_id); + $filters = UserFilterService::filters($pid); + $domainBlocks = UserFilterService::domainBlocks($pid); if(!$min && !$max) { $id = 1; @@ -3313,10 +3324,11 @@ class ApiV1Controller extends Controller if($onlyMedia && !isset($i['media_attachments']) || !count($i['media_attachments'])) { return false; } - return $i && isset($i['account']); + return $i && isset($i['account'], $i['url']); }) - ->filter(function($i) use($filters) { - return !in_array($i['account']['id'], $filters); + ->filter(function($i) use($filters, $domainBlocks) { + $domain = strtolower(parse_url($i['url'], PHP_URL_HOST)); + return !in_array($i['account']['id'], $filters) && !in_array($domain, $domainBlocks); }) ->values() ->toArray(); @@ -3619,25 +3631,31 @@ class ApiV1Controller extends Controller $pid = $request->user()->profile_id; - $ids = Cache::remember('api:v1.1:discover:accounts:popular', 86400, function() { + $ids = Cache::remember('api:v1.1:discover:accounts:popular', 3600, function() { return DB::table('profiles') ->where('is_private', false) ->whereNull('status') ->orderByDesc('profiles.followers_count') - ->limit(20) + ->limit(30) ->get(); }); - + $filters = UserFilterService::filters($pid); $ids = $ids->map(function($profile) { return AccountService::get($profile->id, true); }) ->filter(function($profile) use($pid) { - return $profile && isset($profile['id']); + return $profile && isset($profile['id'], $profile['locked']) && !$profile['locked']; }) ->filter(function($profile) use($pid) { return $profile['id'] != $pid; }) - ->take(6) + ->filter(function($profile) use($pid) { + return !FollowerService::follows($pid, $profile['id'], true); + }) + ->filter(function($profile) use($filters) { + return !in_array($profile['id'], $filters); + }) + ->take(16) ->values(); return $this->json($ids); diff --git a/app/Http/Controllers/Api/V1/DomainBlockController.php b/app/Http/Controllers/Api/V1/DomainBlockController.php new file mode 100644 index 000000000..2186c0936 --- /dev/null +++ b/app/Http/Controllers/Api/V1/DomainBlockController.php @@ -0,0 +1,118 @@ +json($res, $code, $headers, JSON_UNESCAPED_SLASHES); + } + + public function index(Request $request) + { + abort_unless($request->user(), 403); + $this->validate($request, [ + 'limit' => 'sometimes|integer|min:1|max:200' + ]); + $limit = $request->input('limit', 100); + $id = $request->user()->profile_id; + $filters = UserDomainBlock::whereProfileId($id)->orderByDesc('id')->cursorPaginate($limit); + $links = null; + $headers = []; + + if($filters->nextCursor()) { + $links .= '<'.$filters->nextPageUrl().'&limit='.$limit.'>; rel="next"'; + } + + if($filters->previousCursor()) { + if($links != null) { + $links .= ', '; + } + $links .= '<'.$filters->previousPageUrl().'&limit='.$limit.'>; rel="prev"'; + } + + if($links) { + $headers = ['Link' => $links]; + } + return $this->json($filters->pluck('domain'), 200, $headers); + } + + public function store(Request $request) + { + abort_unless($request->user(), 403); + + $this->validate($request, [ + 'domain' => 'required|active_url|min:1|max:120' + ]); + + $pid = $request->user()->profile_id; + + $domain = trim($request->input('domain')); + + if(Helpers::validateUrl($domain) == false) { + return abort(500, 'Invalid domain or already blocked by server admins'); + } + + $domain = strtolower(parse_url($domain, PHP_URL_HOST)); + + abort_if(config_cache('pixelfed.domain.app') == $domain, 400, 'Cannot ban your own server'); + + $existingCount = UserDomainBlock::whereProfileId($pid)->count(); + $maxLimit = config('instance.user_filters.max_domain_blocks'); + $errorMsg = __('profile.block.domain.max', ['max' => $maxLimit]); + + abort_if($existingCount >= $maxLimit, 400, $errorMsg); + + $block = UserDomainBlock::updateOrCreate([ + 'profile_id' => $pid, + 'domain' => $domain + ]); + + if($block->wasRecentlyCreated) { + Bus::batch([ + [ + new FeedRemoveDomainPipeline($pid, $domain), + new ProfilePurgeNotificationsByDomain($pid, $domain), + new ProfilePurgeFollowersByDomain($pid, $domain) + ] + ])->allowFailures()->onQueue('feed')->dispatch(); + + Cache::forget('profile:following:' . $pid); + UserFilterService::domainBlocks($pid, true); + } + + return $this->json([]); + } + + public function delete(Request $request) + { + abort_unless($request->user(), 403); + + $this->validate($request, [ + 'domain' => 'required|min:1|max:120' + ]); + + $pid = $request->user()->profile_id; + + $domain = strtolower(trim($request->input('domain'))); + + $filters = UserDomainBlock::whereProfileId($pid)->whereDomain($domain)->delete(); + + UserFilterService::domainBlocks($pid, true); + + return $this->json([]); + } +} diff --git a/app/Http/Controllers/Settings/PrivacySettings.php b/app/Http/Controllers/Settings/PrivacySettings.php index 9a5febe83..bd2222d48 100644 --- a/app/Http/Controllers/Settings/PrivacySettings.php +++ b/app/Http/Controllers/Settings/PrivacySettings.php @@ -14,6 +14,7 @@ use App\Util\Lexer\PrettyNumber; use App\Util\ActivityPub\Helpers; use Auth, Cache, DB; use Illuminate\Http\Request; +use App\Models\UserDomainBlock; trait PrivacySettings { @@ -149,47 +150,25 @@ trait PrivacySettings public function blockedInstances() { - $pid = Auth::user()->profile->id; - $filters = UserFilter::whereUserId($pid) - ->whereFilterableType('App\Instance') - ->whereFilterType('block') - ->orderByDesc('id') - ->paginate(10); - return view('settings.privacy.blocked-instances', compact('filters')); + // deprecated + abort(404); + } + + public function domainBlocks() + { + return view('settings.privacy.domain-blocks'); } public function blockedInstanceStore(Request $request) { - $this->validate($request, [ - 'domain' => 'required|url|min:1|max:120' - ]); - $domain = $request->input('domain'); - if(Helpers::validateUrl($domain) == false) { - return abort(400, 'Invalid domain'); - } - $domain = parse_url($domain, PHP_URL_HOST); - $instance = Instance::firstOrCreate(['domain' => $domain]); - $filter = new UserFilter; - $filter->user_id = Auth::user()->profile->id; - $filter->filterable_id = $instance->id; - $filter->filterable_type = 'App\Instance'; - $filter->filter_type = 'block'; - $filter->save(); - return response()->json(['msg' => 200]); + // deprecated + abort(404); } public function blockedInstanceUnblock(Request $request) { - $this->validate($request, [ - 'id' => 'required|integer|min:1' - ]); - $pid = Auth::user()->profile->id; - - $filter = UserFilter::whereFilterableType('App\Instance') - ->whereUserId($pid) - ->findOrFail($request->input('id')); - $filter->delete(); - return redirect(route('settings.privacy.blocked-instances')); + // deprecated + abort(404); } public function blockedKeywords() diff --git a/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php b/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php index 353509c6c..824323cda 100644 --- a/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php +++ b/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php @@ -76,10 +76,7 @@ class DeleteRemoteStatusPipeline implements ShouldQueue }); Mention::whereStatusId($status->id)->forceDelete(); Report::whereObjectType('App\Status')->whereObjectId($status->id)->delete(); - $statusHashtags = StatusHashtag::whereStatusId($status->id)->get(); - foreach($statusHashtags as $stag) { - $stag->delete(); - } + StatusHashtag::whereStatusId($status->id)->deleteQuietly(); StatusView::whereStatusId($status->id)->delete(); Status::whereReblogOfId($status->id)->forceDelete(); $status->forceDelete(); diff --git a/app/Jobs/HomeFeedPipeline/FeedInsertPipeline.php b/app/Jobs/HomeFeedPipeline/FeedInsertPipeline.php index 19a546e83..4237a7b1a 100644 --- a/app/Jobs/HomeFeedPipeline/FeedInsertPipeline.php +++ b/app/Jobs/HomeFeedPipeline/FeedInsertPipeline.php @@ -11,6 +11,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use App\UserFilter; +use App\Models\UserDomainBlock; use App\Services\FollowerService; use App\Services\HomeTimelineService; use App\Services\StatusService; @@ -69,7 +70,7 @@ class FeedInsertPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing $sid = $this->sid; $status = StatusService::get($sid, false); - if(!$status || !isset($status['account']) || !isset($status['account']['id'])) { + if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) { return; } @@ -85,7 +86,24 @@ class FeedInsertPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing return; } - $skipIds = UserFilter::whereFilterableType('App\Profile')->whereFilterableId($status['account']['id'])->whereIn('filter_type', ['mute', 'block'])->pluck('user_id')->toArray(); + $domain = strtolower(parse_url($status['url'], PHP_URL_HOST)); + $skipIds = []; + + if(strtolower(config('pixelfed.domain.app')) !== $domain) { + $skipIds = UserDomainBlock::where('domain', $domain)->pluck('profile_id')->toArray(); + } + + $filters = UserFilter::whereFilterableType('App\Profile') + ->whereFilterableId($status['account']['id']) + ->whereIn('filter_type', ['mute', 'block']) + ->pluck('user_id') + ->toArray(); + + if($filters && count($filters)) { + $skipIds = array_merge($skipIds, $filters); + } + + $skipIds = array_unique(array_values($skipIds)); foreach($ids as $id) { if(!in_array($id, $skipIds)) { diff --git a/app/Jobs/HomeFeedPipeline/FeedInsertRemotePipeline.php b/app/Jobs/HomeFeedPipeline/FeedInsertRemotePipeline.php index e24696bd8..6c4ce0c35 100644 --- a/app/Jobs/HomeFeedPipeline/FeedInsertRemotePipeline.php +++ b/app/Jobs/HomeFeedPipeline/FeedInsertRemotePipeline.php @@ -11,6 +11,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use App\UserFilter; +use App\Models\UserDomainBlock; use App\Services\FollowerService; use App\Services\HomeTimelineService; use App\Services\StatusService; @@ -69,7 +70,7 @@ class FeedInsertRemotePipeline implements ShouldQueue, ShouldBeUniqueUntilProces $sid = $this->sid; $status = StatusService::get($sid, false); - if(!$status || !isset($status['account']) || !isset($status['account']['id'])) { + if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) { return; } @@ -83,7 +84,24 @@ class FeedInsertRemotePipeline implements ShouldQueue, ShouldBeUniqueUntilProces return; } - $skipIds = UserFilter::whereFilterableType('App\Profile')->whereFilterableId($status['account']['id'])->whereIn('filter_type', ['mute', 'block'])->pluck('user_id')->toArray(); + $domain = strtolower(parse_url($status['url'], PHP_URL_HOST)); + $skipIds = []; + + if(strtolower(config('pixelfed.domain.app')) !== $domain) { + $skipIds = UserDomainBlock::where('domain', $domain)->pluck('profile_id')->toArray(); + } + + $filters = UserFilter::whereFilterableType('App\Profile') + ->whereFilterableId($status['account']['id']) + ->whereIn('filter_type', ['mute', 'block']) + ->pluck('user_id') + ->toArray(); + + if($filters && count($filters)) { + $skipIds = array_merge($skipIds, $filters); + } + + $skipIds = array_unique(array_values($skipIds)); foreach($ids as $id) { if(!in_array($id, $skipIds)) { diff --git a/app/Jobs/HomeFeedPipeline/FeedRemoveDomainPipeline.php b/app/Jobs/HomeFeedPipeline/FeedRemoveDomainPipeline.php new file mode 100644 index 000000000..018ea3794 --- /dev/null +++ b/app/Jobs/HomeFeedPipeline/FeedRemoveDomainPipeline.php @@ -0,0 +1,98 @@ +pid . ':d-' . $this->domain; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new WithoutOverlapping("hts:feed:remove:domain:{$this->pid}:d-{$this->domain}"))->shared()->dontRelease()]; + } + + /** + * Create a new job instance. + */ + public function __construct($pid, $domain) + { + $this->pid = $pid; + $this->domain = $domain; + } + + /** + * Execute the job. + */ + public function handle(): void + { + if(!config('exp.cached_home_timeline')) { + return; + } + + if ($this->batch()->cancelled()) { + return; + } + + if(!$this->pid || !$this->domain) { + return; + } + $domain = strtolower($this->domain); + $pid = $this->pid; + $posts = HomeTimelineService::get($pid, '0', '-1'); + + foreach($posts as $post) { + $status = StatusService::get($post, false); + if(!$status || !isset($status['url'])) { + HomeTimelineService::rem($pid, $post); + continue; + } + $host = strtolower(parse_url($status['url'], PHP_URL_HOST)); + if($host === strtolower(config('pixelfed.domain.app')) || !$host) { + continue; + } + if($host === $domain) { + HomeTimelineService::rem($pid, $status['id']); + } + } + } +} diff --git a/app/Jobs/HomeFeedPipeline/HashtagInsertFanoutPipeline.php b/app/Jobs/HomeFeedPipeline/HashtagInsertFanoutPipeline.php index a200c06e8..eca598e49 100644 --- a/app/Jobs/HomeFeedPipeline/HashtagInsertFanoutPipeline.php +++ b/app/Jobs/HomeFeedPipeline/HashtagInsertFanoutPipeline.php @@ -11,6 +11,7 @@ use Illuminate\Queue\SerializesModels; use App\Hashtag; use App\StatusHashtag; use App\UserFilter; +use App\Models\UserDomainBlock; use App\Services\HashtagFollowService; use App\Services\HomeTimelineService; use App\Services\StatusService; @@ -77,7 +78,7 @@ class HashtagInsertFanoutPipeline implements ShouldQueue, ShouldBeUniqueUntilPro $sid = $hashtag->status_id; $status = StatusService::get($sid, false); - if(!$status || !isset($status['account']) || !isset($status['account']['id'])) { + if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) { return; } @@ -85,7 +86,20 @@ class HashtagInsertFanoutPipeline implements ShouldQueue, ShouldBeUniqueUntilPro return; } - $skipIds = UserFilter::whereFilterableType('App\Profile')->whereFilterableId($status['account']['id'])->whereIn('filter_type', ['mute', 'block'])->pluck('user_id')->toArray(); + $domain = strtolower(parse_url($status['url'], PHP_URL_HOST)); + $skipIds = []; + + if(strtolower(config('pixelfed.domain.app')) !== $domain) { + $skipIds = UserDomainBlock::where('domain', $domain)->pluck('profile_id')->toArray(); + } + + $filters = UserFilter::whereFilterableType('App\Profile')->whereFilterableId($status['account']['id'])->whereIn('filter_type', ['mute', 'block'])->pluck('user_id')->toArray(); + + if($filters && count($filters)) { + $skipIds = array_merge($skipIds, $filters); + } + + $skipIds = array_unique(array_values($skipIds)); $ids = HashtagFollowService::getPidByHid($hashtag->hashtag_id); diff --git a/app/Jobs/ImageOptimizePipeline/ImageResize.php b/app/Jobs/ImageOptimizePipeline/ImageResize.php index 9bb896a40..c1b4ea7f0 100644 --- a/app/Jobs/ImageOptimizePipeline/ImageResize.php +++ b/app/Jobs/ImageOptimizePipeline/ImageResize.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Log; class ImageResize implements ShouldQueue { @@ -46,6 +47,7 @@ class ImageResize implements ShouldQueue } $path = storage_path('app/'.$media->media_path); if (!is_file($path) || $media->skip_optimize) { + Log::info('Tried to optimize media that does not exist or is not readable. ' . $path); return; } @@ -57,6 +59,7 @@ class ImageResize implements ShouldQueue $img = new Image(); $img->resizeImage($media); } catch (Exception $e) { + Log::error($e); } ImageThumbnail::dispatch($media)->onQueue('mmo'); diff --git a/app/Jobs/ProfilePipeline/ProfilePurgeFollowersByDomain.php b/app/Jobs/ProfilePipeline/ProfilePurgeFollowersByDomain.php new file mode 100644 index 000000000..24fcdc832 --- /dev/null +++ b/app/Jobs/ProfilePipeline/ProfilePurgeFollowersByDomain.php @@ -0,0 +1,119 @@ +pid . ':d-' . $this->domain; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new WithoutOverlapping("followers:v1:purge-by-domain:{$this->pid}:d-{$this->domain}"))->shared()->dontRelease()]; + } + + /** + * Create a new job instance. + */ + public function __construct($pid, $domain) + { + $this->pid = $pid; + $this->domain = $domain; + } + + /** + * Execute the job. + */ + public function handle(): void + { + if ($this->batch()->cancelled()) { + return; + } + + $pid = $this->pid; + $domain = $this->domain; + + $query = 'SELECT f.* + FROM followers f + JOIN profiles p ON p.id = f.profile_id OR p.id = f.following_id + WHERE (f.profile_id = ? OR f.following_id = ?) + AND p.domain = ?;'; + $params = [$pid, $pid, $domain]; + + foreach(DB::cursor($query, $params) as $n) { + if(!$n || !$n->id) { + continue; + } + $follower = Follower::find($n->id); + if($follower->following_id == $pid && $follower->profile_id) { + FollowerService::remove($follower->profile_id, $pid, true); + $follower->delete(); + } else if ($follower->profile_id == $pid && $follower->following_id) { + FollowerService::remove($follower->following_id, $pid, true); + $follower->delete(); + } + } + + $profile = Profile::find($pid); + + $followerCount = DB::table('profiles') + ->join('followers', 'profiles.id', '=', 'followers.following_id') + ->where('followers.following_id', $pid) + ->count(); + + $followingCount = DB::table('profiles') + ->join('followers', 'profiles.id', '=', 'followers.following_id') + ->where('followers.profile_id', $pid) + ->count(); + + $profile->followers_count = $followerCount; + $profile->following_count = $followingCount; + $profile->save(); + + AccountService::del($profile->id); + } +} diff --git a/app/Jobs/ProfilePipeline/ProfilePurgeNotificationsByDomain.php b/app/Jobs/ProfilePipeline/ProfilePurgeNotificationsByDomain.php new file mode 100644 index 000000000..ea5a45e4a --- /dev/null +++ b/app/Jobs/ProfilePipeline/ProfilePurgeNotificationsByDomain.php @@ -0,0 +1,91 @@ +pid . ':d-' . $this->domain; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new WithoutOverlapping("notify:v1:purge-by-domain:{$this->pid}:d-{$this->domain}"))->shared()->dontRelease()]; + } + + /** + * Create a new job instance. + */ + public function __construct($pid, $domain) + { + $this->pid = $pid; + $this->domain = $domain; + } + + /** + * Execute the job. + */ + public function handle(): void + { + if ($this->batch()->cancelled()) { + return; + } + + $pid = $this->pid; + $domain = $this->domain; + + $query = 'SELECT notifications.* + FROM profiles + JOIN notifications on profiles.id = notifications.actor_id + WHERE notifications.profile_id = ? + AND profiles.domain = ?'; + $params = [$pid, $domain]; + + foreach(DB::cursor($query, $params) as $n) { + if(!$n || !$n->id) { + continue; + } + Notification::where('id', $n->id)->delete(); + NotificationService::del($pid, $n->id); + } + } +} diff --git a/app/Jobs/StatusPipeline/RemoteStatusDelete.php b/app/Jobs/StatusPipeline/RemoteStatusDelete.php index 78c41ed3d..9898d3c82 100644 --- a/app/Jobs/StatusPipeline/RemoteStatusDelete.php +++ b/app/Jobs/StatusPipeline/RemoteStatusDelete.php @@ -174,10 +174,7 @@ class RemoteStatusDelete implements ShouldQueue, ShouldBeUniqueUntilProcessing ->whereObjectId($status->id) ->delete(); StatusArchived::whereStatusId($status->id)->delete(); - $statusHashtags = StatusHashtag::whereStatusId($status->id)->get(); - foreach($statusHashtags as $stag) { - $stag->delete(); - } + StatusHashtag::whereStatusId($status->id)->deleteQuietly(); StatusView::whereStatusId($status->id)->delete(); Status::whereInReplyToId($status->id)->update(['in_reply_to_id' => null]); diff --git a/app/Jobs/StatusPipeline/StatusDelete.php b/app/Jobs/StatusPipeline/StatusDelete.php index c0ced1368..a053bfe75 100644 --- a/app/Jobs/StatusPipeline/StatusDelete.php +++ b/app/Jobs/StatusPipeline/StatusDelete.php @@ -151,10 +151,7 @@ class StatusDelete implements ShouldQueue ->delete(); StatusArchived::whereStatusId($status->id)->delete(); - $statusHashtags = StatusHashtag::whereStatusId($status->id)->get(); - foreach($statusHashtags as $stag) { - $stag->delete(); - } + StatusHashtag::whereStatusId($status->id)->deleteQuietly(); StatusView::whereStatusId($status->id)->delete(); Status::whereInReplyToId($status->id)->update(['in_reply_to_id' => null]); diff --git a/app/Models/AdminShadowFilter.php b/app/Models/AdminShadowFilter.php index f98086f7f..8a163feeb 100644 --- a/app/Models/AdminShadowFilter.php +++ b/app/Models/AdminShadowFilter.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use App\Services\AccountService; +use App\Profile; class AdminShadowFilter extends Model { @@ -24,4 +25,9 @@ class AdminShadowFilter extends Model return; } + + public function profile() + { + return $this->belongsTo(Profile::class, 'item_id'); + } } diff --git a/app/Models/DefaultDomainBlock.php b/app/Models/DefaultDomainBlock.php new file mode 100644 index 000000000..d90816a32 --- /dev/null +++ b/app/Models/DefaultDomainBlock.php @@ -0,0 +1,13 @@ +belongsTo(Profile::class, 'profile_id'); + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php index ec4ef9f34..d587bd7e8 100644 --- a/app/Observers/UserObserver.php +++ b/app/Observers/UserObserver.php @@ -7,90 +7,52 @@ use App\Follower; use App\Profile; use App\User; use App\UserSetting; +use App\Services\UserFilterService; +use App\Models\DefaultDomainBlock; +use App\Models\UserDomainBlock; use App\Jobs\FollowPipeline\FollowPipeline; use DB; use App\Services\FollowerService; class UserObserver { - /** - * Listen to the User created event. - * - * @param \App\User $user - * - * @return void - */ - public function saved(User $user) - { - if($user->status == 'deleted') { - return; - } + /** + * Handle the notification "created" event. + * + * @param \App\User $user + * @return void + */ + public function created(User $user): void + { + $this->handleUser($user); + } - if(Profile::whereUsername($user->username)->exists()) { - return; + /** + * Listen to the User saved event. + * + * @param \App\User $user + * + * @return void + */ + public function saved(User $user) + { + $this->handleUser($user); + } + + /** + * Listen to the User updated event. + * + * @param \App\User $user + * + * @return void + */ + public function updated(User $user): void + { + $this->handleUser($user); + if($user->profile) { + $this->applyDefaultDomainBlocks($user); } - - if (empty($user->profile)) { - $profile = DB::transaction(function() use($user) { - $profile = new Profile(); - $profile->user_id = $user->id; - $profile->username = $user->username; - $profile->name = $user->name; - $pkiConfig = [ - 'digest_alg' => 'sha512', - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]; - $pki = openssl_pkey_new($pkiConfig); - openssl_pkey_export($pki, $pki_private); - $pki_public = openssl_pkey_get_details($pki); - $pki_public = $pki_public['key']; - - $profile->private_key = $pki_private; - $profile->public_key = $pki_public; - $profile->save(); - return $profile; - }); - - DB::transaction(function() use($user, $profile) { - $user = User::findOrFail($user->id); - $user->profile_id = $profile->id; - $user->save(); - - CreateAvatar::dispatch($profile); - }); - - if(config_cache('account.autofollow') == true) { - $names = config_cache('account.autofollow_usernames'); - $names = explode(',', $names); - - if(!$names || !last($names)) { - return; - } - - $profiles = Profile::whereIn('username', $names)->get(); - - if($profiles) { - foreach($profiles as $p) { - $follower = new Follower; - $follower->profile_id = $profile->id; - $follower->following_id = $p->id; - $follower->save(); - - FollowPipeline::dispatch($follower); - } - } - } - } - - if (empty($user->settings)) { - DB::transaction(function() use($user) { - UserSetting::firstOrCreate([ - 'user_id' => $user->id - ]); - }); - } - } + } /** * Handle the user "deleted" event. @@ -102,4 +64,97 @@ class UserObserver { FollowerService::delCache($user->profile_id); } + + protected function handleUser($user) + { + if(in_array($user->status, ['deleted', 'delete'])) { + return; + } + + if(Profile::whereUsername($user->username)->exists()) { + return; + } + + if (empty($user->profile)) { + $profile = DB::transaction(function() use($user) { + $profile = new Profile(); + $profile->user_id = $user->id; + $profile->username = $user->username; + $profile->name = $user->name; + $pkiConfig = [ + 'digest_alg' => 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + $pki = openssl_pkey_new($pkiConfig); + openssl_pkey_export($pki, $pki_private); + $pki_public = openssl_pkey_get_details($pki); + $pki_public = $pki_public['key']; + + $profile->private_key = $pki_private; + $profile->public_key = $pki_public; + $profile->save(); + $this->applyDefaultDomainBlocks($user); + return $profile; + }); + + + DB::transaction(function() use($user, $profile) { + $user = User::findOrFail($user->id); + $user->profile_id = $profile->id; + $user->save(); + + CreateAvatar::dispatch($profile); + }); + + if(config_cache('account.autofollow') == true) { + $names = config_cache('account.autofollow_usernames'); + $names = explode(',', $names); + + if(!$names || !last($names)) { + return; + } + + $profiles = Profile::whereIn('username', $names)->get(); + + if($profiles) { + foreach($profiles as $p) { + $follower = new Follower; + $follower->profile_id = $profile->id; + $follower->following_id = $p->id; + $follower->save(); + + FollowPipeline::dispatch($follower); + } + } + } + } + + if (empty($user->settings)) { + DB::transaction(function() use($user) { + UserSetting::firstOrCreate([ + 'user_id' => $user->id + ]); + }); + } + } + + protected function applyDefaultDomainBlocks($user) + { + if($user->profile_id == null) { + return; + } + $defaultDomainBlocks = DefaultDomainBlock::pluck('domain')->toArray(); + + if(!$defaultDomainBlocks || !count($defaultDomainBlocks)) { + return; + } + + foreach($defaultDomainBlocks as $domain) { + UserDomainBlock::updateOrCreate([ + 'profile_id' => $user->profile_id, + 'domain' => strtolower(trim($domain)) + ]); + } + } } diff --git a/app/Services/AccountService.php b/app/Services/AccountService.php index fa1613cae..98e878845 100644 --- a/app/Services/AccountService.php +++ b/app/Services/AccountService.php @@ -7,6 +7,7 @@ use App\Profile; use App\Status; use App\User; use App\UserSetting; +use App\Models\UserDomainBlock; use App\Transformer\Api\AccountTransformer; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; @@ -234,4 +235,13 @@ class AccountService } return; } + + public static function blocksDomain($pid, $domain = false) + { + if(!$domain) { + return; + } + + return UserDomainBlock::whereProfileId($pid)->whereDomain($domain)->exists(); + } } diff --git a/app/Services/FollowerService.php b/app/Services/FollowerService.php index 8b5eeced9..cec8f7068 100644 --- a/app/Services/FollowerService.php +++ b/app/Services/FollowerService.php @@ -35,16 +35,18 @@ class FollowerService Cache::forget('profile:following:' . $actor); } - public static function remove($actor, $target) + public static function remove($actor, $target, $silent = false) { Redis::zrem(self::FOLLOWING_KEY . $actor, $target); Redis::zrem(self::FOLLOWERS_KEY . $target, $actor); - Cache::forget('pf:services:follower:audience:' . $actor); - Cache::forget('pf:services:follower:audience:' . $target); - AccountService::del($actor); - AccountService::del($target); - RelationshipService::refresh($actor, $target); - Cache::forget('profile:following:' . $actor); + if($silent !== true) { + AccountService::del($actor); + AccountService::del($target); + RelationshipService::refresh($actor, $target); + Cache::forget('profile:following:' . $actor); + } else { + RelationshipService::forget($actor, $target); + } } public static function followers($id, $start = 0, $stop = 10) @@ -89,12 +91,16 @@ class FollowerService return Redis::zCard(self::FOLLOWING_KEY . $id); } - public static function follows(string $actor, string $target) + public static function follows(string $actor, string $target, $quickCheck = false) { if($actor == $target) { return false; } + if($quickCheck) { + return (bool) Redis::zScore(self::FOLLOWERS_KEY . $target, $actor); + } + if(self::followerCount($target, false) && self::followingCount($actor, false)) { self::cacheSyncCheck($target, 'followers'); return (bool) Redis::zScore(self::FOLLOWERS_KEY . $target, $actor); diff --git a/app/Services/HomeTimelineService.php b/app/Services/HomeTimelineService.php index 6a2db0482..08d990591 100644 --- a/app/Services/HomeTimelineService.php +++ b/app/Services/HomeTimelineService.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Redis; use App\Follower; use App\Status; +use App\Models\UserDomainBlock; class HomeTimelineService { @@ -81,6 +82,8 @@ class HomeTimelineService $following = array_diff($following, $filters); } + $domainBlocks = UserDomainBlock::whereProfileId($id)->pluck('domain')->toArray(); + $ids = Status::where('id', '>', $minId) ->whereIn('profile_id', $following) ->whereNull(['in_reply_to_id', 'reblog_of_id']) @@ -91,6 +94,16 @@ class HomeTimelineService ->pluck('id'); foreach($ids as $pid) { + $status = StatusService::get($pid, false); + if(!$status || !isset($status['account'], $status['url'])) { + continue; + } + if($domainBlocks && count($domainBlocks)) { + $domain = strtolower(parse_url($status['url'], PHP_URL_HOST)); + if(in_array($domain, $domainBlocks)) { + continue; + } + } self::add($id, $pid); } diff --git a/app/Services/MarkerService.php b/app/Services/MarkerService.php index 6b407b567..130f0b017 100644 --- a/app/Services/MarkerService.php +++ b/app/Services/MarkerService.php @@ -13,7 +13,7 @@ class MarkerService return Cache::get(self::CACHE_KEY . $timeline . ':' . $profileId); } - public static function set($profileId, $timeline = 'home', $entityId) + public static function set($profileId, $timeline = 'home', $entityId = false) { $existing = self::get($profileId, $timeline); $key = self::CACHE_KEY . $timeline . ':' . $profileId; diff --git a/app/Services/SearchApiV2Service.php b/app/Services/SearchApiV2Service.php index 90691f0bd..f926c2c27 100644 --- a/app/Services/SearchApiV2Service.php +++ b/app/Services/SearchApiV2Service.php @@ -95,7 +95,15 @@ class SearchApiV2Service if(substr($webfingerQuery, 0, 1) !== '@') { $webfingerQuery = '@' . $webfingerQuery; } - $banned = InstanceService::getBannedDomains(); + $banned = InstanceService::getBannedDomains() ?? []; + $domainBlocks = UserFilterService::domainBlocks($user->profile_id); + if($domainBlocks && count($domainBlocks)) { + $banned = array_unique( + array_values( + array_merge($banned, $domainBlocks) + ) + ); + } $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like'; $results = Profile::select('username', 'id', 'followers_count', 'domain') ->where('username', $operator, $query) @@ -172,8 +180,18 @@ class SearchApiV2Service 'hashtags' => [], 'statuses' => [], ]; + $user = request()->user(); $mastodonMode = self::$mastodonMode; $query = urldecode($this->query->input('q')); + $banned = InstanceService::getBannedDomains(); + $domainBlocks = UserFilterService::domainBlocks($user->profile_id); + if($domainBlocks && count($domainBlocks)) { + $banned = array_unique( + array_values( + array_merge($banned, $domainBlocks) + ) + ); + } if(substr($query, 0, 1) === '@' && !Str::contains($query, '.')) { $default['accounts'] = $this->accounts(substr($query, 1)); return $default; @@ -197,7 +215,11 @@ class SearchApiV2Service } catch (\Exception $e) { return $default; } - if($res && isset($res['id'])) { + if($res && isset($res['id'], $res['url'])) { + $domain = strtolower(parse_url($res['url'], PHP_URL_HOST)); + if(in_array($domain, $banned)) { + return $default; + } $default['accounts'][] = $res; return $default; } else { @@ -212,6 +234,10 @@ class SearchApiV2Service return $default; } if($res && isset($res['id'])) { + $domain = strtolower(parse_url($res['url'], PHP_URL_HOST)); + if(in_array($domain, $banned)) { + return $default; + } $default['accounts'][] = $res; return $default; } else { @@ -221,6 +247,9 @@ class SearchApiV2Service if($sid = Status::whereUri($query)->first()) { $s = StatusService::get($sid->id, false); + if(!$s) { + return $default; + } if(in_array($s['visibility'], ['public', 'unlisted'])) { $default['statuses'][] = $s; return $default; @@ -229,7 +258,7 @@ class SearchApiV2Service try { $res = ActivityPubFetchService::get($query); - $banned = InstanceService::getBannedDomains(); + if($res) { $json = json_decode($res, true); diff --git a/app/Services/UserFilterService.php b/app/Services/UserFilterService.php index 1dcdc819a..5673db60c 100644 --- a/app/Services/UserFilterService.php +++ b/app/Services/UserFilterService.php @@ -4,145 +4,160 @@ namespace App\Services; use Cache; use App\UserFilter; +use App\Models\UserDomainBlock; use Illuminate\Support\Facades\Redis; class UserFilterService { - const USER_MUTES_KEY = 'pf:services:mutes:ids:'; - const USER_BLOCKS_KEY = 'pf:services:blocks:ids:'; + const USER_MUTES_KEY = 'pf:services:mutes:ids:'; + const USER_BLOCKS_KEY = 'pf:services:blocks:ids:'; + const USER_DOMAIN_KEY = 'pf:services:domain-blocks:ids:'; - public static function mutes(int $profile_id) - { - $key = self::USER_MUTES_KEY . $profile_id; - $warm = Cache::has($key . ':cached-v0'); - if($warm) { - return Redis::zrevrange($key, 0, -1) ?? []; - } else { - if(Redis::zrevrange($key, 0, -1)) { - return Redis::zrevrange($key, 0, -1); - } - $ids = UserFilter::whereFilterType('mute') - ->whereUserId($profile_id) - ->pluck('filterable_id') - ->map(function($id) { - $acct = AccountService::get($id, true); - if(!$acct) { - return false; - } - return $acct['id']; - }) - ->filter(function($res) { - return $res; - }) - ->values() - ->toArray(); - foreach ($ids as $muted_id) { - Redis::zadd($key, (int) $muted_id, (int) $muted_id); - } - Cache::set($key . ':cached-v0', 1, 7776000); - return $ids; - } - } + public static function mutes(int $profile_id) + { + $key = self::USER_MUTES_KEY . $profile_id; + $warm = Cache::has($key . ':cached-v0'); + if($warm) { + return Redis::zrevrange($key, 0, -1) ?? []; + } else { + if(Redis::zrevrange($key, 0, -1)) { + return Redis::zrevrange($key, 0, -1); + } + $ids = UserFilter::whereFilterType('mute') + ->whereUserId($profile_id) + ->pluck('filterable_id') + ->map(function($id) { + $acct = AccountService::get($id, true); + if(!$acct) { + return false; + } + return $acct['id']; + }) + ->filter(function($res) { + return $res; + }) + ->values() + ->toArray(); + foreach ($ids as $muted_id) { + Redis::zadd($key, (int) $muted_id, (int) $muted_id); + } + Cache::set($key . ':cached-v0', 1, 7776000); + return $ids; + } + } - public static function blocks(int $profile_id) - { - $key = self::USER_BLOCKS_KEY . $profile_id; - $warm = Cache::has($key . ':cached-v0'); - if($warm) { - return Redis::zrevrange($key, 0, -1) ?? []; - } else { - if(Redis::zrevrange($key, 0, -1)) { - return Redis::zrevrange($key, 0, -1); - } - $ids = UserFilter::whereFilterType('block') - ->whereUserId($profile_id) - ->pluck('filterable_id') - ->map(function($id) { - $acct = AccountService::get($id, true); - if(!$acct) { - return false; - } - return $acct['id']; - }) - ->filter(function($res) { - return $res; - }) - ->values() - ->toArray(); - foreach ($ids as $blocked_id) { - Redis::zadd($key, (int) $blocked_id, (int) $blocked_id); - } - Cache::set($key . ':cached-v0', 1, 7776000); - return $ids; - } - } + public static function blocks(int $profile_id) + { + $key = self::USER_BLOCKS_KEY . $profile_id; + $warm = Cache::has($key . ':cached-v0'); + if($warm) { + return Redis::zrevrange($key, 0, -1) ?? []; + } else { + if(Redis::zrevrange($key, 0, -1)) { + return Redis::zrevrange($key, 0, -1); + } + $ids = UserFilter::whereFilterType('block') + ->whereUserId($profile_id) + ->pluck('filterable_id') + ->map(function($id) { + $acct = AccountService::get($id, true); + if(!$acct) { + return false; + } + return $acct['id']; + }) + ->filter(function($res) { + return $res; + }) + ->values() + ->toArray(); + foreach ($ids as $blocked_id) { + Redis::zadd($key, (int) $blocked_id, (int) $blocked_id); + } + Cache::set($key . ':cached-v0', 1, 7776000); + return $ids; + } + } - public static function filters(int $profile_id) - { - return array_unique(array_merge(self::mutes($profile_id), self::blocks($profile_id))); - } + public static function filters(int $profile_id) + { + return array_unique(array_merge(self::mutes($profile_id), self::blocks($profile_id))); + } - public static function mute(int $profile_id, int $muted_id) - { - if($profile_id == $muted_id) { - return false; - } - $key = self::USER_MUTES_KEY . $profile_id; - $mutes = self::mutes($profile_id); - $exists = in_array($muted_id, $mutes); - if(!$exists) { - Redis::zadd($key, $muted_id, $muted_id); - } - return true; - } + public static function mute(int $profile_id, int $muted_id) + { + if($profile_id == $muted_id) { + return false; + } + $key = self::USER_MUTES_KEY . $profile_id; + $mutes = self::mutes($profile_id); + $exists = in_array($muted_id, $mutes); + if(!$exists) { + Redis::zadd($key, $muted_id, $muted_id); + } + return true; + } - public static function unmute(int $profile_id, string $muted_id) - { - if($profile_id == $muted_id) { - return false; - } - $key = self::USER_MUTES_KEY . $profile_id; - $mutes = self::mutes($profile_id); - $exists = in_array($muted_id, $mutes); - if($exists) { - Redis::zrem($key, $muted_id); - } - return true; - } + public static function unmute(int $profile_id, string $muted_id) + { + if($profile_id == $muted_id) { + return false; + } + $key = self::USER_MUTES_KEY . $profile_id; + $mutes = self::mutes($profile_id); + $exists = in_array($muted_id, $mutes); + if($exists) { + Redis::zrem($key, $muted_id); + } + return true; + } - public static function block(int $profile_id, int $blocked_id) - { - if($profile_id == $blocked_id) { - return false; - } - $key = self::USER_BLOCKS_KEY . $profile_id; - $exists = in_array($blocked_id, self::blocks($profile_id)); - if(!$exists) { - Redis::zadd($key, $blocked_id, $blocked_id); - } - return true; - } + public static function block(int $profile_id, int $blocked_id) + { + if($profile_id == $blocked_id) { + return false; + } + $key = self::USER_BLOCKS_KEY . $profile_id; + $exists = in_array($blocked_id, self::blocks($profile_id)); + if(!$exists) { + Redis::zadd($key, $blocked_id, $blocked_id); + } + return true; + } - public static function unblock(int $profile_id, string $blocked_id) - { - if($profile_id == $blocked_id) { - return false; - } - $key = self::USER_BLOCKS_KEY . $profile_id; - $exists = in_array($blocked_id, self::blocks($profile_id)); - if($exists) { - Redis::zrem($key, $blocked_id); - } - return $exists; - } + public static function unblock(int $profile_id, string $blocked_id) + { + if($profile_id == $blocked_id) { + return false; + } + $key = self::USER_BLOCKS_KEY . $profile_id; + $exists = in_array($blocked_id, self::blocks($profile_id)); + if($exists) { + Redis::zrem($key, $blocked_id); + } + return $exists; + } - public static function blockCount(int $profile_id) - { - return Redis::zcard(self::USER_BLOCKS_KEY . $profile_id); - } + public static function blockCount(int $profile_id) + { + return Redis::zcard(self::USER_BLOCKS_KEY . $profile_id); + } - public static function muteCount(int $profile_id) - { - return Redis::zcard(self::USER_MUTES_KEY . $profile_id); - } + public static function muteCount(int $profile_id) + { + return Redis::zcard(self::USER_MUTES_KEY . $profile_id); + } + + public static function domainBlocks($pid, $purge = false) + { + if($purge) { + Cache::forget(self::USER_DOMAIN_KEY . $pid); + } + return Cache::remember( + self::USER_DOMAIN_KEY . $pid, + 21600, + function() use($pid) { + return UserDomainBlock::whereProfileId($pid)->pluck('domain')->toArray(); + }); + } } diff --git a/app/UserFilter.php b/app/UserFilter.php index b0af2d777..dfa0d4662 100644 --- a/app/UserFilter.php +++ b/app/UserFilter.php @@ -33,4 +33,9 @@ class UserFilter extends Model { return $this->belongsTo(Instance::class, 'filterable_id'); } + + public function user() + { + return $this->belongsTo(Profile::class, 'user_id'); + } } diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 0dd4722e9..e26f0a48c 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -39,6 +39,7 @@ use App\Util\ActivityPub\Validator\Like as LikeValidator; use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator; use App\Util\ActivityPub\Validator\UpdatePersonValidator; +use App\Services\AccountService; use App\Services\PollService; use App\Services\FollowerService; use App\Services\ReblogService; @@ -372,7 +373,11 @@ class Inbox ->whereUsername(array_last(explode('/', $activity['to'][0]))) ->firstOrFail(); - if(in_array($actor->id, $profile->blockedIds()->toArray())) { + if(!$actor || in_array($actor->id, $profile->blockedIds()->toArray())) { + return; + } + + if(AccountService::blocksDomain($profile->id, $actor->domain) == true) { return; } @@ -510,6 +515,10 @@ class Inbox return; } + if(AccountService::blocksDomain($target->id, $actor->domain) == true) { + return; + } + if( Follower::whereProfileId($actor->id) ->whereFollowingId($target->id) @@ -581,6 +590,10 @@ class Inbox return; } + if(AccountService::blocksDomain($parent->profile_id, $actor->domain) == true) { + return; + } + $blocks = UserFilterService::blocks($parent->profile_id); if($blocks && in_array($actor->id, $blocks)) { return; @@ -634,6 +647,10 @@ class Inbox return; } + if(AccountService::blocksDomain($target->id, $actor->domain) == true) { + return; + } + $request = FollowRequest::whereFollowerId($actor->id) ->whereFollowingId($target->id) ->whereIsRejected(false) @@ -759,6 +776,10 @@ class Inbox return; } + if(AccountService::blocksDomain($status->profile_id, $profile->domain) == true) { + return; + } + $blocks = UserFilterService::blocks($status->profile_id); if($blocks && in_array($profile->id, $blocks)) { return; @@ -816,6 +837,9 @@ class Inbox if(!$status) { return; } + if(AccountService::blocksDomain($status->profile_id, $profile->domain) == true) { + return; + } FeedRemoveRemotePipeline::dispatch($status->id, $status->profile_id)->onQueue('feed'); Status::whereProfileId($profile->id) ->whereReblogOfId($status->id) @@ -837,6 +861,9 @@ class Inbox if(!$following) { return; } + if(AccountService::blocksDomain($following->id, $profile->domain) == true) { + return; + } Follower::whereProfileId($profile->id) ->whereFollowingId($following->id) ->delete(); @@ -862,6 +889,9 @@ class Inbox if(!$status) { return; } + if(AccountService::blocksDomain($status->profile_id, $profile->domain) == true) { + return; + } Like::whereProfileId($profile->id) ->whereStatusId($status->id) ->forceDelete(); @@ -915,6 +945,10 @@ class Inbox return; } + if(AccountService::blocksDomain($story->profile_id, $profile->domain) == true) { + return; + } + if(!FollowerService::follows($profile->id, $story->profile_id)) { return; } @@ -985,6 +1019,10 @@ class Inbox $actorProfile = Helpers::profileFetch($actor); + if(AccountService::blocksDomain($targetProfile->id, $actorProfile->domain) == true) { + return; + } + if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) { return; } @@ -1103,6 +1141,11 @@ class Inbox $actorProfile = Helpers::profileFetch($actor); + + if(AccountService::blocksDomain($targetProfile->id, $actorProfile->domain) == true) { + return; + } + if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) { return; } diff --git a/config/instance.php b/config/instance.php index 5161ecb80..6357afe63 100644 --- a/config/instance.php +++ b/config/instance.php @@ -110,7 +110,8 @@ return [ 'user_filters' => [ 'max_user_blocks' => env('PF_MAX_USER_BLOCKS', 50), - 'max_user_mutes' => env('PF_MAX_USER_MUTES', 50) + 'max_user_mutes' => env('PF_MAX_USER_MUTES', 50), + 'max_domain_blocks' => env('PF_MAX_DOMAIN_BLOCKS', 50), ], 'reports' => [ diff --git a/database/migrations/2023_12_16_052413_create_user_domain_blocks_table.php b/database/migrations/2023_12_16_052413_create_user_domain_blocks_table.php new file mode 100644 index 000000000..16f8f3fb2 --- /dev/null +++ b/database/migrations/2023_12_16_052413_create_user_domain_blocks_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('profile_id')->index(); + $table->string('domain')->index(); + $table->unique(['profile_id', 'domain'], 'user_domain_blocks_by_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_domain_blocks'); + } +}; diff --git a/database/migrations/2023_12_19_081928_create_job_batches_table.php b/database/migrations/2023_12_19_081928_create_job_batches_table.php new file mode 100644 index 000000000..50e38c20f --- /dev/null +++ b/database/migrations/2023_12_19_081928_create_job_batches_table.php @@ -0,0 +1,35 @@ +string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_batches'); + } +}; diff --git a/database/migrations/2023_12_21_103223_purge_deleted_status_hashtags.php b/database/migrations/2023_12_21_103223_purge_deleted_status_hashtags.php new file mode 100644 index 000000000..bf2acc34e --- /dev/null +++ b/database/migrations/2023_12_21_103223_purge_deleted_status_hashtags.php @@ -0,0 +1,25 @@ +lazyById(200)->each->deleteQuietly(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/database/migrations/2023_12_21_104103_create_default_domain_blocks_table.php b/database/migrations/2023_12_21_104103_create_default_domain_blocks_table.php new file mode 100644 index 000000000..72e61bda5 --- /dev/null +++ b/database/migrations/2023_12_21_104103_create_default_domain_blocks_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('domain')->unique()->index(); + $table->text('note')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('default_domain_blocks'); + } +}; diff --git a/resources/lang/en/profile.php b/resources/lang/en/profile.php index 7851852a2..04a2bcd90 100644 --- a/resources/lang/en/profile.php +++ b/resources/lang/en/profile.php @@ -12,4 +12,10 @@ return [ 'status.disabled.header' => 'Profile Unavailable', 'status.disabled.body' => 'Sorry, this profile is not available at the moment. Please try again shortly.', + + 'block.domain.max' => 'Max limit of domain blocks reached! You can only block :max domains at a time. Ask your admin to adjust this limit.', + + 'mutedAccounts' => 'Muted Accounts', + 'blockedAccounts' => 'Blocked Accounts', + 'blockedDomains' => 'Blocked Domains', ]; diff --git a/resources/views/settings/privacy.blade.php b/resources/views/settings/privacy.blade.php index 57f83c664..369ddbbb4 100644 --- a/resources/views/settings/privacy.blade.php +++ b/resources/views/settings/privacy.blade.php @@ -8,8 +8,9 @@
diff --git a/resources/views/settings/privacy/blocked.blade.php b/resources/views/settings/privacy/blocked.blade.php index e3b2eab2b..8906eae7d 100644 --- a/resources/views/settings/privacy/blocked.blade.php +++ b/resources/views/settings/privacy/blocked.blade.php @@ -2,40 +2,36 @@ @section('section') -
-

Blocked Users

-
-
- - @if($users->count() > 0) -
    - @foreach($users as $user) -
  • -
    - {{$user->username}} - - - @csrf - - - - -
    -
  • - @endforeach -
-
- {{$users->links()}} -
- @else -

You are not blocking any accounts.

- @endif +
+
+

+

Blocked Accounts

+
+
+
-@endsection \ No newline at end of file +@if($users->count() > 0) +
+ @foreach($users as $user) +
+
+ {{$user->username}} + +
+ @csrf + + +
+
+
+
+ @endforeach +
+
+ {{$users->links()}} +
+@else +

You are not blocking any accounts.

+@endif + +@endsection diff --git a/resources/views/settings/privacy/domain-blocks.blade.php b/resources/views/settings/privacy/domain-blocks.blade.php new file mode 100644 index 000000000..d93e0b58e --- /dev/null +++ b/resources/views/settings/privacy/domain-blocks.blade.php @@ -0,0 +1,272 @@ +@extends('settings.template-vue') + +@section('section') +
+
+
+

+

Domain Blocks

+
+
+ +

You can block entire domains, this prevents users on that instance from interacting with your content and from you seeing content from that domain on public feeds.

+ +
+ +
+ +
+ +
+
+
+
+ +
+ +
+
+
+ + +
+
+
+
+ + + + + + +
+
+
+ + + +
+
+

You are not blocking any domains.

+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/settings/privacy/muted.blade.php b/resources/views/settings/privacy/muted.blade.php index a13b9b716..152b8d641 100644 --- a/resources/views/settings/privacy/muted.blade.php +++ b/resources/views/settings/privacy/muted.blade.php @@ -1,41 +1,35 @@ @extends('settings.template') @section('section') - -
-

Muted Users

-
-
- - @if($users->count() > 0) -
    +
    +
    +

    +

    Muted Accounts

    +
    +
    +
    +@if($users->count() > 0) +
    @foreach($users as $user) -
  • -
    - {{$user->username}} - -
    - @csrf - - -
    -
    -
    -
  • +
    +
    + {{$user->username}} + +
    + @csrf + + +
    +
    +
    +
    @endforeach -
-
+
+
{{$users->links()}} -
- @else -

You are not muting any accounts.

- @endif + +@else +

You are not muting any accounts.

+@endif -@endsection \ No newline at end of file +@endsection diff --git a/resources/views/settings/template-vue.blade.php b/resources/views/settings/template-vue.blade.php new file mode 100644 index 000000000..adcbb34c2 --- /dev/null +++ b/resources/views/settings/template-vue.blade.php @@ -0,0 +1,37 @@ +@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 + +
+
+
+
+
+ @include('settings.partial.sidebar') +
+ @yield('section') +
+
+
+
+
+
+ +@endsection diff --git a/routes/api.php b/routes/api.php index 10a4363c2..af40e27bc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -51,9 +51,9 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('blocks', 'Api\ApiV1Controller@accountBlocks')->middleware($middleware); Route::get('conversations', 'Api\ApiV1Controller@conversations')->middleware($middleware); Route::get('custom_emojis', 'Api\ApiV1Controller@customEmojis'); - Route::get('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware($middleware); - Route::post('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware($middleware); - Route::delete('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware($middleware); + Route::get('domain_blocks', 'Api\V1\DomainBlockController@index')->middleware($middleware); + Route::post('domain_blocks', 'Api\V1\DomainBlockController@store')->middleware($middleware); + Route::delete('domain_blocks', 'Api\V1\DomainBlockController@delete')->middleware($middleware); Route::get('endorsements', 'Api\ApiV1Controller@accountEndorsements')->middleware($middleware); Route::get('favourites', 'Api\ApiV1Controller@accountFavourites')->middleware($middleware); Route::get('filters', 'Api\ApiV1Controller@accountFilters')->middleware($middleware); diff --git a/routes/web.php b/routes/web.php index b823b8729..b8149a605 100644 --- a/routes/web.php +++ b/routes/web.php @@ -489,6 +489,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::post('privacy/muted-users', 'SettingsController@mutedUsersUpdate'); Route::get('privacy/blocked-users', 'SettingsController@blockedUsers')->name('settings.privacy.blocked-users'); Route::post('privacy/blocked-users', 'SettingsController@blockedUsersUpdate'); + Route::get('privacy/domain-blocks', 'SettingsController@domainBlocks')->name('settings.privacy.domain-blocks'); Route::get('privacy/blocked-instances', 'SettingsController@blockedInstances')->name('settings.privacy.blocked-instances'); Route::post('privacy/blocked-instances', 'SettingsController@blockedInstanceStore'); Route::post('privacy/blocked-instances/unblock', 'SettingsController@blockedInstanceUnblock')->name('settings.privacy.blocked-instances.unblock');