浏览代码

Merge pull request #4834 from pixelfed/staging

Add User Domain Blocks
daniel 1 年之前
父节点
当前提交
eebed73a5e
共有 42 个文件被更改,包括 1666 次插入351 次删除
  1. 3 0
      CHANGELOG.md
  2. 106 0
      app/Console/Commands/AddUserDomainBlock.php
  3. 96 0
      app/Console/Commands/DeleteUserDomainBlock.php
  4. 2 1
      app/Http/Controllers/AdminShadowFilterController.php
  5. 27 9
      app/Http/Controllers/Api/ApiV1Controller.php
  6. 118 0
      app/Http/Controllers/Api/V1/DomainBlockController.php
  7. 12 33
      app/Http/Controllers/Settings/PrivacySettings.php
  8. 1 4
      app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php
  9. 20 2
      app/Jobs/HomeFeedPipeline/FeedInsertPipeline.php
  10. 20 2
      app/Jobs/HomeFeedPipeline/FeedInsertRemotePipeline.php
  11. 98 0
      app/Jobs/HomeFeedPipeline/FeedRemoveDomainPipeline.php
  12. 16 2
      app/Jobs/HomeFeedPipeline/HashtagInsertFanoutPipeline.php
  13. 3 0
      app/Jobs/ImageOptimizePipeline/ImageResize.php
  14. 119 0
      app/Jobs/ProfilePipeline/ProfilePurgeFollowersByDomain.php
  15. 91 0
      app/Jobs/ProfilePipeline/ProfilePurgeNotificationsByDomain.php
  16. 1 4
      app/Jobs/StatusPipeline/RemoteStatusDelete.php
  17. 1 4
      app/Jobs/StatusPipeline/StatusDelete.php
  18. 6 0
      app/Models/AdminShadowFilter.php
  19. 13 0
      app/Models/DefaultDomainBlock.php
  20. 21 0
      app/Models/UserDomainBlock.php
  21. 131 76
      app/Observers/UserObserver.php
  22. 10 0
      app/Services/AccountService.php
  23. 14 8
      app/Services/FollowerService.php
  24. 13 0
      app/Services/HomeTimelineService.php
  25. 1 1
      app/Services/MarkerService.php
  26. 32 3
      app/Services/SearchApiV2Service.php
  27. 143 128
      app/Services/UserFilterService.php
  28. 5 0
      app/UserFilter.php
  29. 44 1
      app/Util/ActivityPub/Inbox.php
  30. 2 1
      config/instance.php
  31. 29 0
      database/migrations/2023_12_16_052413_create_user_domain_blocks_table.php
  32. 35 0
      database/migrations/2023_12_19_081928_create_job_batches_table.php
  33. 25 0
      database/migrations/2023_12_21_103223_purge_deleted_status_hashtags.php
  34. 29 0
      database/migrations/2023_12_21_104103_create_default_domain_blocks_table.php
  35. 6 0
      resources/lang/en/profile.php
  36. 3 2
      resources/views/settings/privacy.blade.php
  37. 29 33
      resources/views/settings/privacy/blocked.blade.php
  38. 272 0
      resources/views/settings/privacy/domain-blocks.blade.php
  39. 28 34
      resources/views/settings/privacy/muted.blade.php
  40. 37 0
      resources/views/settings/template-vue.blade.php
  41. 3 3
      routes/api.php
  42. 1 0
      routes/web.php

+ 3 - 0
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)

+ 106 - 0
app/Console/Commands/AddUserDomainBlock.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\User;
+use App\Models\DefaultDomainBlock;
+use App\Models\UserDomainBlock;
+use function Laravel\Prompts\text;
+use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\progress;
+
+class AddUserDomainBlock extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:add-user-domain-block';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Apply a domain block to all users';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $domain = text('Enter domain you want to block');
+        $domain = strtolower($domain);
+        $domain = $this->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
+        ]);
+    }
+}

+ 96 - 0
app/Console/Commands/DeleteUserDomainBlock.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\User;
+use App\Models\DefaultDomainBlock;
+use App\Models\UserDomainBlock;
+use function Laravel\Prompts\text;
+use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\progress;
+
+class DeleteUserDomainBlock extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:delete-user-domain-block';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Remove a domain block for all users';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        $domain = text('Enter domain you want to unblock');
+        $domain = strtolower($domain);
+        $domain = $this->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();
+    }
+}

+ 2 - 1
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') {

+ 27 - 9
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);

+ 118 - 0
app/Http/Controllers/Api/V1/DomainBlockController.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Http\Controllers\Api\V1;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\UserDomainBlock;
+use App\Util\ActivityPub\Helpers;
+use App\Services\UserFilterService;
+use Illuminate\Bus\Batch;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Cache;
+use App\Jobs\HomeFeedPipeline\FeedRemoveDomainPipeline;
+use App\Jobs\ProfilePipeline\ProfilePurgeNotificationsByDomain;
+use App\Jobs\ProfilePipeline\ProfilePurgeFollowersByDomain;
+
+class DomainBlockController extends Controller
+{
+    public function json($res, $code = 200, $headers = [])
+    {
+        return response()->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([]);
+    }
+}

+ 12 - 33
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()

+ 1 - 4
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();

+ 20 - 2
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)) {

+ 20 - 2
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)) {

+ 98 - 0
app/Jobs/HomeFeedPipeline/FeedRemoveDomainPipeline.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace App\Jobs\HomeFeedPipeline;
+
+use Illuminate\Bus\Batchable;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\Middleware\WithoutOverlapping;
+use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
+use App\Services\StatusService;
+use App\Services\HomeTimelineService;
+
+class FeedRemoveDomainPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
+{
+    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $pid;
+    protected $domain;
+
+    public $timeout = 900;
+    public $tries = 3;
+    public $maxExceptions = 1;
+    public $failOnTimeout = true;
+
+    /**
+     * The number of seconds after which the job's unique lock will be released.
+     *
+     * @var int
+     */
+    public $uniqueFor = 3600;
+
+    /**
+     * Get the unique ID for the job.
+     */
+    public function uniqueId(): string
+    {
+        return 'hts:feed:remove:domain:' . $this->pid . ':d-' . $this->domain;
+    }
+
+    /**
+     * Get the middleware the job should pass through.
+     *
+     * @return array<int, object>
+     */
+    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']);
+            }
+        }
+    }
+}

+ 16 - 2
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);
 

+ 3 - 0
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');

+ 119 - 0
app/Jobs/ProfilePipeline/ProfilePurgeFollowersByDomain.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace App\Jobs\ProfilePipeline;
+
+use Illuminate\Bus\Batchable;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\Middleware\WithoutOverlapping;
+use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
+use App\Follower;
+use App\Profile;
+use App\Notification;
+use DB;
+use App\Services\AccountService;
+use App\Services\FollowerService;
+use App\Services\NotificationService;
+
+class ProfilePurgeFollowersByDomain implements ShouldQueue, ShouldBeUniqueUntilProcessing
+{
+    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $pid;
+    protected $domain;
+
+    public $timeout = 900;
+    public $tries = 3;
+    public $maxExceptions = 1;
+    public $failOnTimeout = true;
+
+    /**
+     * The number of seconds after which the job's unique lock will be released.
+     *
+     * @var int
+     */
+    public $uniqueFor = 3600;
+
+    /**
+     * Get the unique ID for the job.
+     */
+    public function uniqueId(): string
+    {
+        return 'followers:v1:purge-by-domain:' . $this->pid . ':d-' . $this->domain;
+    }
+
+    /**
+     * Get the middleware the job should pass through.
+     *
+     * @return array<int, object>
+     */
+    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);
+    }
+}

+ 91 - 0
app/Jobs/ProfilePipeline/ProfilePurgeNotificationsByDomain.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace App\Jobs\ProfilePipeline;
+
+use Illuminate\Bus\Batchable;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\Middleware\WithoutOverlapping;
+use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
+use App\Notification;
+use DB;
+use App\Services\NotificationService;
+
+class ProfilePurgeNotificationsByDomain implements ShouldQueue, ShouldBeUniqueUntilProcessing
+{
+    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $pid;
+    protected $domain;
+
+    public $timeout = 900;
+    public $tries = 3;
+    public $maxExceptions = 1;
+    public $failOnTimeout = true;
+
+    /**
+     * The number of seconds after which the job's unique lock will be released.
+     *
+     * @var int
+     */
+    public $uniqueFor = 3600;
+
+    /**
+     * Get the unique ID for the job.
+     */
+    public function uniqueId(): string
+    {
+        return 'notify:v1:purge-by-domain:' . $this->pid . ':d-' . $this->domain;
+    }
+
+    /**
+     * Get the middleware the job should pass through.
+     *
+     * @return array<int, object>
+     */
+    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);
+        }
+    }
+}

+ 1 - 4
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]);
 

+ 1 - 4
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]);
 

+ 6 - 0
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');
+    }
 }

+ 13 - 0
app/Models/DefaultDomainBlock.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class DefaultDomainBlock extends Model
+{
+    use HasFactory;
+
+    protected $guarded = [];
+}

+ 21 - 0
app/Models/UserDomainBlock.php

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

+ 131 - 76
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);
+    }
 
-		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
-				]);
-			});
-		}
-	}
+    /**
+     * 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);
+        }
+    }
 
     /**
      * 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))
+            ]);
+        }
+    }
 }

+ 10 - 0
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();
+    }
 }

+ 14 - 8
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);

+ 13 - 0
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);
             }
 

+ 1 - 1
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;

+ 32 - 3
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);
 

+ 143 - 128
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();
+        });
+    }
 }

+ 5 - 0
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');
+    }
 }

+ 44 - 1
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;
         }

+ 2 - 1
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' => [

+ 29 - 0
database/migrations/2023_12_16_052413_create_user_domain_blocks_table.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('user_domain_blocks', function (Blueprint $table) {
+            $table->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');
+    }
+};

+ 35 - 0
database/migrations/2023_12_19_081928_create_job_batches_table.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('job_batches', function (Blueprint $table) {
+            $table->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');
+    }
+};

+ 25 - 0
database/migrations/2023_12_21_103223_purge_deleted_status_hashtags.php

@@ -0,0 +1,25 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use App\StatusHashtag;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        StatusHashtag::doesntHave('status')->lazyById(200)->each->deleteQuietly();
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        //
+    }
+};

+ 29 - 0
database/migrations/2023_12_21_104103_create_default_domain_blocks_table.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('default_domain_blocks', function (Blueprint $table) {
+            $table->id();
+            $table->string('domain')->unique()->index();
+            $table->text('note')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('default_domain_blocks');
+    }
+};

+ 6 - 0
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',
 ];

+ 3 - 2
resources/views/settings/privacy.blade.php

@@ -8,8 +8,9 @@
   <hr>
   <div class="form-group pb-1">
     <p>
-      <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.muted-users')}}">Muted Users</a>
-      <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-users')}}">Blocked Users</a>
+      <a class="btn btn-link py-0 font-weight-bold" href="{{route('settings.privacy.muted-users')}}">{{ __('profile.mutedAccounts') }}</a>
+      <a class="btn btn-link py-0 font-weight-bold" href="{{route('settings.privacy.blocked-users')}}">{{ __('profile.blockedAccounts') }}</a>
+      <a class="btn btn-link py-0 font-weight-bold" href="{{route('settings.privacy.domain-blocks')}}">{{ __('profile.blockedDomains') }}</a>
     </p>
   </div>
   <form method="post">

+ 29 - 33
resources/views/settings/privacy/blocked.blade.php

@@ -2,40 +2,36 @@
 
 @section('section')
 
-  <div class="title">
-    <h3 class="font-weight-bold">Blocked Users</h3>
-  </div>
-  <hr>
-  <div class="form-group pb-1">
-    <p>
-      <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.muted-users')}}">Muted Users</a>
-      <a class="btn btn-outline-primary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-users')}}">Blocked Users</a>
-      {{-- <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-keywords')}}">Blocked keywords</a>
-      <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-instances')}}">Blocked instances</a> --}}
-    </p>
-  </div>
-  @if($users->count() > 0)
-  <ul class="list-group list-group-flush">
+<div class="d-flex justify-content-between align-items-center">
+    <div class="title d-flex align-items-center" style="gap: 1rem;">
+        <p class="mb-0"><a href="/settings/privacy"><i class="far fa-chevron-left fa-lg"></i></a></p>
+        <h3 class="font-weight-bold mb-0">Blocked Accounts</h3>
+    </div>
+</div>
+<hr />
+
+@if($users->count() > 0)
+<div class="list-group">
     @foreach($users as $user)
-    <li class="list-group-item">
-      <div class="d-flex justify-content-between align-items-center font-weight-bold">
-        <span><a href="{{$user->url()}}" class="text-decoration-none text-dark"><img class="rounded-circle mr-3" src="{{$user->avatarUrl()}}" width="32px">{{$user->username}}</a></span>
-        <span class="btn-group">
-          <form method="post">
-            @csrf
-            <input type="hidden" name="profile_id" value="{{$user->id}}">
-            <button type="submit" class="btn btn-outline-secondary btn-sm px-3 font-weight-bold">Unblock</button>
-          </form>
-        </span>
-      </div> 
-    </li>
+    <div class="list-group-item">
+        <div class="d-flex justify-content-between align-items-center font-weight-bold">
+            <span><a href="{{$user->url()}}" class="text-decoration-none text-dark"><img class="rounded-circle mr-3" src="{{$user->avatarUrl()}}" width="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">{{$user->username}}</a></span>
+            <span class="btn-group">
+                <form method="post">
+                    @csrf
+                    <input type="hidden" name="profile_id" value="{{$user->id}}">
+                    <button type="submit" class="btn btn-link btn-sm px-3 font-weight-bold">Unblock</button>
+                </form>
+            </span>
+        </div>
+    </div>
     @endforeach
-  </ul>
-  <div class="d-flex justify-content-center mt-3 font-weight-bold">
+</div>
+<div class="d-flex justify-content-center mt-3 font-weight-bold">
     {{$users->links()}}
-  </div>
-  @else
-  <p class="lead">You are not blocking any accounts.</p>
-  @endif
+</div>
+@else
+<p class="lead text-center font-weight-bold">You are not blocking any accounts.</p>
+@endif
 
-@endsection
+@endsection

+ 272 - 0
resources/views/settings/privacy/domain-blocks.blade.php

@@ -0,0 +1,272 @@
+@extends('settings.template-vue')
+
+@section('section')
+<div>
+    <div class="d-flex justify-content-between align-items-center">
+        <div class="title d-flex align-items-center" style="gap: 1rem;">
+            <p class="mb-0"><a href="/settings/privacy"><i class="far fa-chevron-left fa-lg"></i></a></p>
+            <h3 class="font-weight-bold mb-0">Domain Blocks</h3>
+        </div>
+    </div>
+
+    <p class="mt-3 mb-n2 small">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.</p>
+
+    <hr />
+
+    <div v-if="!loaded" class="d-flex justify-content-center align-items-center flex-grow-1">
+        <b-spinner />
+    </div>
+
+    <div v-else>
+        <div class="mb-3 d-flex flex-column flex-md-row justify-content-between align-items-center" style="gap: 2rem;">
+            <div style="width: 60%;">
+                <div class="input-group align-items-center">
+                    <input class="form-control form-control-sm rounded-lg" v-model="q" placeholder="Search by domain..." style="padding-right: 60px;" :disabled="!blocks || !blocks.length">
+                    <div style="margin-left: -60px;width: 60px;z-index:3">
+                        <button class="btn btn-link" type="button" style="font-size: 12px;text-decoration: none;" v-html="q && q.length ? 'Clear': '&nbsp;'" @click="searchAction()"></button>
+                    </div>
+                </div>
+            </div>
+
+            <button type="button" class="btn btn-outline-primary btn-sm font-weight-bold px-3 flex-grow" @click="openModal">
+                <i class="fas fa-plus mr-1"></i> New Block
+            </button>
+        </div>
+        <div v-if="blocks && blocks.length" class="list-group">
+            <div
+                v-for="(item, idx) in chunks[index]"
+                class="list-group-item">
+                <div class="d-flex justify-content-between align-items-center font-weight-bold">
+                <span>
+                    <span v-text="item"></span>
+                </span>
+                <span class="btn-group">
+                    <button type="button" class="btn btn-link btn-sm px-3 font-weight-bold" @click="handleUnblock(item)">Unblock</button>
+                </span>
+                </div>
+            </div>
+        </div>
+
+        <nav v-if="blocks && blocks.length && chunks && chunks.length > 1" class="mt-3" aria-label="Domain block pagination">
+            <ul class="pagination justify-content-center" style="gap: 1rem">
+                <li
+                    class="page-item"
+                    :class="[ !index ? 'disabled' : 'font-weight-bold' ]"
+                    :disabled="!index"
+                    @click="paginate('prev')">
+                    <span class="page-link px-5 rounded-lg">Previous</span>
+                </li>
+                <li
+                    class="page-item"
+                    :class="[ index + 1 === chunks.length ? 'disabled' : 'font-weight-bold' ]"
+                    @click="paginate('next')">
+                    <span class="page-link px-5 rounded-lg" href="#">Next</span>
+                </li>
+            </ul>
+        </nav>
+
+        <div v-if="!blocks || !blocks.length">
+            <hr />
+            <p class="lead text-center font-weight-bold">You are not blocking any domains.</p>
+        </div>
+    </div>
+</div>
+@endsection
+
+@push('scripts')
+<script type="text/javascript">
+    let app = new Vue({
+        el: '#content',
+
+        data: {
+            loaded: false,
+            q: undefined,
+            blocks: [],
+            filteredBlocks: [],
+            chunks: [],
+            index: 0,
+            pagination: [],
+        },
+
+        watch: {
+            q: function(newVal, oldVal) {
+                this.filterResults(newVal)
+            }
+        },
+
+        mounted() {
+            this.fetchBlocks()
+        },
+
+        methods: {
+            fetchBlocks() {
+                axios.get('/api/v1/domain_blocks', { params: { 'limit': 200 }})
+                .then(res => {
+                    let pages = false
+                    if(res.headers?.link) {
+                        pages = this.parseLinkHeader(res.headers['link'])
+                    }
+                    this.blocks = res.data
+                    if(!pages || !pages.hasOwnProperty('next')) {
+                        this.buildList()
+                    } else {
+                        this.handlePagination(pages)
+                    }
+                })
+                .catch(err => {
+                    console.log(err.response)
+                })
+            },
+
+            handlePagination(pages) {
+                if(!pages || !pages.hasOwnProperty('next')) {
+                    this.buildList()
+                    return
+                }
+                this.pagination = pages
+                this.fetchPagination()
+            },
+
+            buildList() {
+                this.index = 0
+                this.chunks = this.chunkify(this.blocks)
+                this.loaded = true
+            },
+
+            buildSearchList() {
+                this.index = 0
+                this.chunks = this.chunkify(this.filteredBlocks)
+                this.loaded = true
+            },
+
+            fetchPagination() {
+                axios.get(this.pagination.next)
+                .then(res => {
+                    let pages = false
+                    if(res.headers?.link) {
+                        pages = this.parseLinkHeader(res.headers['link'])
+                    }
+                    this.blocks.push(...res.data)
+                    if(!pages || !pages.hasOwnProperty('next')) {
+                        this.buildList()
+                    } else {
+                        this.handlePagination(pages)
+                    }
+                })
+                .catch(err => {
+                    this.buildList()
+                })
+            },
+
+            handleUnblock(domain) {
+                this.loaded = false
+                axios.delete('/api/v1/domain_blocks', {
+                    params: {
+                        domain: domain
+                    }
+                })
+                .then(res => {
+                    this.blocks = this.blocks.filter(d => d != domain)
+                    this.buildList()
+                })
+                .catch(err => {
+                    this.buildList()
+                })
+            },
+
+            filterResults(query) {
+                this.loaded = false
+                let formattedQuery = query.trim().toLowerCase()
+                this.filteredBlocks = this.blocks.filter(domain => domain.toLowerCase().startsWith(formattedQuery))
+                this.buildSearchList()
+            },
+
+            searchAction($event) {
+                event.currentTarget.blur()
+                this.q = ''
+            },
+
+            openModal() {
+                swal({
+                    title: 'Domain Block',
+                    text: 'Add domain to block, must start with https://',
+                    content: "input",
+                    button: {
+                        text: "Block",
+                        closeModal: false,
+                    }
+                }).then(val => {
+                    if (!val) {
+                        swal.stopLoading()
+                        swal.close()
+                        return
+                    }
+
+                    axios.post('/api/v1/domain_blocks', { domain: val })
+                    .then(res => {
+                        let parsedUrl = new URL(val)
+                        swal.stopLoading()
+                        swal.close()
+                        this.index = 0
+                        this.blocks.unshift(parsedUrl.hostname)
+                        this.buildList()
+                    })
+                    .catch(err => {
+                        swal.stopLoading()
+                        swal.close()
+                        if(err.response?.data?.message || err.response?.data?.error) {
+                            swal('Error', err.response?.data?.message ?? err.response?.data?.error, 'error')
+                        }
+                    })
+                })
+            },
+
+            chunkify(arr, len = 10) {
+                var chunks = [],
+                    i = 0,
+                    n = arr.length
+
+                while (i < n) {
+                    chunks.push(arr.slice(i, i += len))
+                }
+
+                return chunks
+            },
+
+            paginate(dir) {
+                if(dir === 'prev' && this.index > 0) {
+                    this.index--
+                    return
+                }
+
+                if(dir === 'next' && this.index + 1 < this.chunks.length) {
+                    this.index++
+                    return
+                }
+            },
+
+            parseLinkHeader(linkHeader) {
+                const links = {}
+
+                if (!linkHeader) {
+                    return links
+                }
+
+                linkHeader.split(',').forEach(part => {
+                    const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/)
+                    if (match) {
+                        const url = match[1]
+                        const rel = match[2]
+
+                        if (rel === 'prev' || rel === 'next') {
+                            links[rel] = url
+                        }
+                    }
+                })
+
+                return links
+            }
+        }
+    })
+</script>
+@endpush

+ 28 - 34
resources/views/settings/privacy/muted.blade.php

@@ -1,41 +1,35 @@
 @extends('settings.template')
 
 @section('section')
-
-  <div class="title">
-    <h3 class="font-weight-bold">Muted Users</h3>
-  </div>
-  <hr>
-  <div class="form-group pb-1">
-    <p>
-      <a class="btn btn-outline-primary py-0 font-weight-bold" href="{{route('settings.privacy.muted-users')}}">Muted Users</a>
-      <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-users')}}">Blocked Users</a>
-      {{-- <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-keywords')}}">Blocked keywords</a>
-      <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-instances')}}">Blocked instances</a> --}}
-    </p>
-  </div>
-  @if($users->count() > 0)
-  <ul class="list-group list-group-flush">
+<div class="d-flex justify-content-between align-items-center">
+    <div class="title d-flex align-items-center" style="gap: 1rem;">
+        <p class="mb-0"><a href="/settings/privacy"><i class="far fa-chevron-left fa-lg"></i></a></p>
+        <h3 class="font-weight-bold mb-0">Muted Accounts</h3>
+    </div>
+</div>
+<hr />
+@if($users->count() > 0)
+<div class="list-group">
     @foreach($users as $user)
-    <li class="list-group-item">
-      <div class="d-flex justify-content-between align-items-center font-weight-bold">
-        <span><a href="{{$user->url()}}" class="text-decoration-none text-dark"><img class="rounded-circle mr-3" src="{{$user->avatarUrl()}}" width="32px">{{$user->username}}</a></span>
-        <span class="btn-group">
-          <form method="post">
-            @csrf
-            <input type="hidden" name="profile_id" value="{{$user->id}}">
-            <button type="submit" class="btn btn-outline-secondary btn-sm px-3 font-weight-bold">Unmute</button>
-          </form>
-        </span>
-      </div> 
-    </li>
+    <div class="list-group-item">
+        <div class="d-flex justify-content-between align-items-center font-weight-bold">
+            <span><a href="{{$user->url()}}" class="text-decoration-none text-dark"><img class="rounded-circle mr-3" src="{{$user->avatarUrl()}}" width="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">{{$user->username}}</a></span>
+            <span class="btn-group">
+                <form method="post">
+                    @csrf
+                    <input type="hidden" name="profile_id" value="{{$user->id}}">
+                    <button type="submit" class="btn btn-link btn-sm px-3 font-weight-bold">Unmute</button>
+                </form>
+            </span>
+        </div>
+    </div>
     @endforeach
-  </ul>
-  <div class="d-flex justify-content-center mt-3 font-weight-bold">
+</div>
+<div class="d-flex justify-content-center mt-3 font-weight-bold">
     {{$users->links()}}
-  </div>
-  @else
-  <p class="lead">You are not muting any accounts.</p>
-  @endif
+</div>
+@else
+<p class="lead text-center font-weight-bold">You are not muting any accounts.</p>
+@endif
 
-@endsection
+@endsection

+ 37 - 0
resources/views/settings/template-vue.blade.php

@@ -0,0 +1,37 @@
+@extends('layouts.app')
+
+@section('content')
+@if (session('status'))
+<div class="alert alert-primary px-3 h6 text-center">
+    {{ session('status') }}
+</div>
+@endif
+@if ($errors->any())
+<div class="alert alert-danger px-3 h6 text-center">
+    @foreach($errors->all() as $error)
+    <p class="font-weight-bold mb-1">{{ $error }}</p>
+    @endforeach
+</div>
+@endif
+@if (session('error'))
+<div class="alert alert-danger px-3 h6 text-center">
+    {{ session('error') }}
+</div>
+@endif
+
+<div class="container">
+    <div class="col-12">
+        <div class="card shadow-none border mt-5">
+            <div class="card-body p-0">
+                <div class="row">
+                    @include('settings.partial.sidebar')
+                    <div class="col-12 col-md-9 p-5">
+                        @yield('section')
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+@endsection

+ 3 - 3
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);

+ 1 - 0
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');