Explorar o código

Merge branch 'staging' into translate-story

daniel hai 2 meses
pai
achega
5ed46a505a
Modificáronse 100 ficheiros con 3732 adicións e 1263 borrados
  1. 2 0
      .gitignore
  2. 29 2
      CHANGELOG.md
  3. 15 4
      app/Console/Commands/InstanceUpdateTotalLocalPosts.php
  4. 563 563
      app/Http/Controllers/AccountController.php
  5. 210 17
      app/Http/Controllers/Api/ApiV1Controller.php
  6. 7 7
      app/Http/Controllers/Api/ApiV2Controller.php
  7. 82 101
      app/Http/Controllers/Api/BaseApiController.php
  8. 11 5
      app/Http/Controllers/ComposeController.php
  9. 503 0
      app/Http/Controllers/CustomFilterController.php
  10. 10 0
      app/Http/Controllers/CustomFilterKeywordController.php
  11. 10 0
      app/Http/Controllers/CustomFilterStatusController.php
  12. 16 19
      app/Http/Controllers/DiscoverController.php
  13. 140 57
      app/Http/Controllers/ImportPostController.php
  14. 6 2
      app/Http/Controllers/ProfileMigrationController.php
  15. 167 53
      app/Http/Controllers/PublicApiController.php
  16. 2 31
      app/Http/Controllers/RemoteAuthController.php
  17. 121 0
      app/Http/Controllers/RemoteOidcController.php
  18. 81 34
      app/Http/Controllers/ReportController.php
  19. 5 0
      app/Http/Controllers/SettingsController.php
  20. 90 0
      app/Jobs/NotificationPipeline/NotificationWarmUserCache.php
  21. 412 0
      app/Models/CustomFilter.php
  22. 37 0
      app/Models/CustomFilterKeyword.php
  23. 23 0
      app/Models/CustomFilterStatus.php
  24. 25 0
      app/Models/UserOidcMapping.php
  25. 61 0
      app/Policies/CustomFilterPolicy.php
  26. 4 1
      app/Providers/AppServiceProvider.php
  27. 3 1
      app/Providers/AuthServiceProvider.php
  28. 25 0
      app/Rules/EmailNotBanned.php
  29. 57 0
      app/Rules/PixelfedUsername.php
  30. 62 0
      app/Rules/Webfinger.php
  31. 1 0
      app/Services/LandingService.php
  32. 103 99
      app/Services/RelationshipService.php
  33. 15 3
      app/Services/SearchApiV2Service.php
  34. 87 1
      app/Services/StatusService.php
  35. 21 0
      app/Services/UserOidcService.php
  36. 16 0
      app/Services/WebfingerService.php
  37. 17 10
      app/Transformer/Api/RelationshipTransformer.php
  38. 1 0
      app/Transformer/Api/StatusStatelessTransformer.php
  39. 1 0
      app/Transformer/Api/StatusTransformer.php
  40. 3 2
      app/Util/ActivityPub/Helpers.php
  41. 43 5
      app/Util/ActivityPub/Inbox.php
  42. 31 0
      app/Util/ActivityPub/Validator/RejectValidator.php
  43. 1 0
      app/Util/Site/Config.php
  44. 1 0
      composer.json
  45. 68 3
      composer.lock
  46. 1 1
      config/federation.php
  47. 76 0
      config/instance.php
  48. 1 1
      config/pixelfed.php
  49. 75 0
      config/remote-auth.php
  50. 30 0
      database/migrations/2025_01_30_061434_create_user_oidc_mapping_table.php
  51. 30 0
      database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php
  52. 32 0
      database/migrations/2025_04_08_102711_create_custom_filters_table.php
  53. 30 0
      database/migrations/2025_04_08_103425_create_custom_filter_keywords_table.php
  54. 29 0
      database/migrations/2025_04_08_103433_create_custom_filter_statuses_table.php
  55. 1 1
      docker/README.md
  56. 228 227
      package-lock.json
  57. 11 13
      phpunit.xml
  58. BIN=BIN
      public/_lang/de.json
  59. BIN=BIN
      public/_lang/en.json
  60. BIN=BIN
      public/_lang/pt.json
  61. BIN=BIN
      public/js/account-import.js
  62. BIN=BIN
      public/js/app.js
  63. BIN=BIN
      public/js/changelog.bundle.0e485f4496100570.js
  64. BIN=BIN
      public/js/changelog.bundle.efd3d17aee17020e.js
  65. BIN=BIN
      public/js/compose.chunk.76891a6dad141928.js
  66. 0 0
      public/js/compose.chunk.76891a6dad141928.js.LICENSE.txt
  67. BIN=BIN
      public/js/compose.chunk.80e32f21442c8a91.js
  68. BIN=BIN
      public/js/compose.js
  69. BIN=BIN
      public/js/custom_filters.js
  70. BIN=BIN
      public/js/daci.chunk.8cf1cb07ac8a9100.js
  71. BIN=BIN
      public/js/daci.chunk.bf225e484f3a2b17.js
  72. BIN=BIN
      public/js/discover.chunk.0ca404627af971f2.js
  73. BIN=BIN
      public/js/discover.chunk.21616d9cb1229006.js
  74. BIN=BIN
      public/js/discover~findfriends.chunk.bf787612b58e5473.js
  75. BIN=BIN
      public/js/discover~findfriends.chunk.d916236b22d65518.js
  76. BIN=BIN
      public/js/discover~hashtag.bundle.53264f5d0ba3e59b.js
  77. BIN=BIN
      public/js/discover~hashtag.bundle.c8eb86fb63ede45e.js
  78. BIN=BIN
      public/js/discover~memories.chunk.777303732fff3560.js
  79. BIN=BIN
      public/js/discover~memories.chunk.9621c5ecf4482f0a.js
  80. BIN=BIN
      public/js/discover~myhashtags.chunk.c64e38339a1b7154.js
  81. BIN=BIN
      public/js/discover~myhashtags.chunk.f4257bc65189fde3.js
  82. BIN=BIN
      public/js/discover~serverfeed.chunk.4c5e477dc3fb4da0.js
  83. BIN=BIN
      public/js/discover~serverfeed.chunk.4e135dd1c07c17dd.js
  84. BIN=BIN
      public/js/discover~settings.chunk.295935b63f9c0971.js
  85. BIN=BIN
      public/js/discover~settings.chunk.523499873e915409.js
  86. BIN=BIN
      public/js/dms.chunk.090a307aa2f04c14.js
  87. BIN=BIN
      public/js/dms.chunk.13449036a5b769e6.js
  88. BIN=BIN
      public/js/dms~message.chunk.97018952bf2655a9.js
  89. BIN=BIN
      public/js/dms~message.chunk.f0d6ccb6f2f1cbf7.js
  90. BIN=BIN
      public/js/group-status.js
  91. BIN=BIN
      public/js/group-topic-feed.js
  92. BIN=BIN
      public/js/group.create.e34ad5621d07870d.js
  93. BIN=BIN
      public/js/groups.js
  94. BIN=BIN
      public/js/home.chunk.3d9801a7722f4dfb.js
  95. BIN=BIN
      public/js/home.chunk.ac1bf3c994e718f2.js
  96. 0 0
      public/js/home.chunk.ac1bf3c994e718f2.js.LICENSE.txt
  97. BIN=BIN
      public/js/i18n.bundle.3a0756c99b66371a.js
  98. BIN=BIN
      public/js/i18n.bundle.85976a3b9d6b922a.js
  99. BIN=BIN
      public/js/landing.js
  100. BIN=BIN
      public/js/manifest.js

+ 2 - 0
.gitignore

@@ -10,6 +10,8 @@
 /.gitconfig
 #/.gitignore
 /.idea
+/.phpunit.cache
+/.phpunit.result.cache
 /.vagrant
 /bootstrap/cache
 /docker-compose-state/

+ 29 - 2
CHANGELOG.md

@@ -1,6 +1,28 @@
 # Release Notes
 
-## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev)
+## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
+
+### Added
+- Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000))
+- Custom Filters ([#5928](https://github.com/pixelfed/pixelfed/pull/5928)) ([437d742ac](https://github.com/pixelfed/pixelfed/commit/437d742ac))
+
+### Updates
+- Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842))
+- Update Profile.vue, fix pagination ([2ea107805](https://github.com/pixelfed/pixelfed/commit/2ea107805))
+- Update ProfileMigrationController, fix race condition by chaining batched jobs ([3001365025](https://github.com/pixelfed/pixelfed/commit/3001365025))
+- Update Instance total post, add optional estimation for huge status tables ([5a5821fe8](https://github.com/pixelfed/pixelfed/commit/5a5821fe8))
+- Update ApiV1Controller, fix notifications favourited/reblogged/bookmarked state. Fixes #5901 ([8a86808a0](https://github.com/pixelfed/pixelfed/commit/8a86808a0))
+- Update ApiV1Controller, fix relationship fields. Fixes #5900 ([245ab3bc4](https://github.com/pixelfed/pixelfed/commit/245ab3bc4))
+- Update instance config, return proper matrix limits. Fixes #4780 ([473201908](https://github.com/pixelfed/pixelfed/commit/473201908))
+- Update SearchApiV2Service, fix offset bug. Fixes #5875 ([0a98b7ad2](https://github.com/pixelfed/pixelfed/commit/0a98b7ad2))
+- Update ApiV1Controller, add better direct error message. Fixes #4789 ([658fe6898](https://github.com/pixelfed/pixelfed/commit/658fe6898))
+- Update DiscoverController, improve public hashtag feed. Fixes #5866 ([32fc3180c](https://github.com/pixelfed/pixelfed/commit/32fc3180c))
+- Update report views, fix missing forms ([475d1d627](https://github.com/pixelfed/pixelfed/commit/475d1d627))
+- Update private settings, change "Private Account" to "Manually Review Follow Requests" ([31dd1ab35](https://github.com/pixelfed/pixelfed/commit/31dd1ab35))
+- Update ReportController, fix type validation ([ccc7f2fc6](https://github.com/pixelfed/pixelfed/commit/ccc7f2fc6))
+-  ([](https://github.com/pixelfed/pixelfed/commit/))
+
+## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
 
 ### Added
 - Add app register email verify resends ([dbd1e17](https://github.com/pixelfed/pixelfed/commit/dbd1e17))
@@ -57,7 +79,12 @@
 - Update DM config, allow new users to send DMs by default, with a new env variable to enforce a 72h limit ([717f17cde](https://github.com/pixelfed/pixelfed/commit/717f17cde))
 - Update ApiV1Controller, add pagination to conversations endpoint with min/max/since id pagination and link header support ([244e86bad](https://github.com/pixelfed/pixelfed/commit/244e86bad))
 - Update Direct message component, fix pagination ([e6ef64857](https://github.com/pixelfed/pixelfed/commit/e6ef64857))
--  ([](https://github.com/pixelfed/pixelfed/commit/))
+- Update ActivityPub helpers, improve private account handling ([75e7a678c](https://github.com/pixelfed/pixelfed/commit/75e7a678c))
+- Update ApiV1Controller, improve follower handling ([976a1873e](https://github.com/pixelfed/pixelfed/commit/976a1873e))
+- Update Inbox, improve Accept Follower handling ([3725c689e](https://github.com/pixelfed/pixelfed/commit/3725c689e))
+- Update Inbox handler, add Reject Follow support ([fbe76e37f](https://github.com/pixelfed/pixelfed/commit/fbe76e37f))
+- Update Inbox handler, improve Undo Follow logic ([5525369fe](https://github.com/pixelfed/pixelfed/commit/5525369fe))
+- Update ApiV1Controller, send UndoFollow when cancelling a follow request on remote accounts ([2cf301181](https://github.com/pixelfed/pixelfed/commit/2cf301181))
 
 ## [v0.12.4 (2024-11-08)](https://github.com/pixelfed/pixelfed/compare/v0.12.4...dev)
 

+ 15 - 4
app/Console/Commands/InstanceUpdateTotalLocalPosts.php

@@ -53,9 +53,8 @@ class InstanceUpdateTotalLocalPosts extends Command
 
     protected function initCache()
     {
-        $count = DB::table('statuses')->whereNull(['url', 'deleted_at'])->count();
         $res = [
-            'count' => $count,
+            'count' => $this->getTotalLocalPosts(),
         ];
         Storage::put('total_local_posts.json', json_encode($res, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
         ConfigCacheService::put('instance.stats.total_local_posts', $res['count']);
@@ -68,12 +67,24 @@ class InstanceUpdateTotalLocalPosts extends Command
 
     protected function updateAndCache()
     {
-        $count = DB::table('statuses')->whereNull(['url', 'deleted_at'])->count();
         $res = [
-            'count' => $count,
+            'count' => $this->getTotalLocalPosts(),
         ];
         Storage::put('total_local_posts.json', json_encode($res, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
         ConfigCacheService::put('instance.stats.total_local_posts', $res['count']);
 
     }
+
+    protected function getTotalLocalPosts()
+    {
+        if ((bool) config('instance.total_count_estimate') && config('database.default') === 'mysql') {
+            return DB::select("EXPLAIN SELECT COUNT(*) FROM statuses WHERE deleted_at IS NULL AND uri IS NULL and local = 1 AND type != 'share'")[0]->rows;
+        }
+
+        return DB::table('statuses')
+            ->whereNull('deleted_at')
+            ->where('local', true)
+            ->whereNot('type', 'share')
+            ->count();
+    }
 }

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 563 - 563
app/Http/Controllers/AccountController.php


+ 210 - 17
app/Http/Controllers/Api/ApiV1Controller.php

@@ -26,6 +26,7 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Jobs\LikePipeline\LikePipeline;
 use App\Jobs\MediaPipeline\MediaDeletePipeline;
 use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
 use App\Jobs\SharePipeline\SharePipeline;
 use App\Jobs\SharePipeline\UndoSharePipeline;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
@@ -34,6 +35,7 @@ use App\Jobs\VideoPipeline\VideoThumbnail;
 use App\Like;
 use App\Media;
 use App\Models\Conversation;
+use App\Models\CustomFilter;
 use App\Notification;
 use App\Profile;
 use App\Services\AccountService;
@@ -763,7 +765,8 @@ class ApiV1Controller extends Controller
             'reblog_of_id',
             'type',
             'id',
-            'scope'
+            'scope',
+            'pinned_order'
         )
             ->whereProfileId($profile['id'])
             ->whereNull('in_reply_to_id')
@@ -813,13 +816,13 @@ class ApiV1Controller extends Controller
         abort_unless($request->user()->tokenCan('follow'), 403);
 
         $user = $request->user();
+        abort_if($user->profile_id == $id, 400, 'Invalid profile');
+
         abort_if($user->has_roles && ! UserRoleService::can('can-follow', $user->id), 403, 'Invalid permissions for this action');
 
         AccountService::setLastActive($user->id);
 
-        $target = Profile::where('id', '!=', $user->profile_id)
-            ->whereNull('status')
-            ->findOrFail($id);
+        $target = Profile::whereNull('status')->findOrFail($id);
 
         abort_if($target && $target->moved_to_profile_id, 400, 'Cannot follow an account that has moved!');
 
@@ -864,15 +867,20 @@ class ApiV1Controller extends Controller
             if ($remote == true && config('federation.activitypub.remoteFollow') == true) {
                 (new FollowerController)->sendFollow($user->profile, $target);
             }
-        } else {
-            $follower = Follower::firstOrCreate([
-                'profile_id' => $user->profile_id,
+        } elseif ($remote == true) {
+            $follow = FollowRequest::firstOrCreate([
+                'follower_id' => $user->profile_id,
                 'following_id' => $target->id,
             ]);
 
-            if ($remote == true && config('federation.activitypub.remoteFollow') == true) {
+            if (config('federation.activitypub.remoteFollow') == true) {
                 (new FollowerController)->sendFollow($user->profile, $target);
             }
+        } else {
+            $follower = Follower::firstOrCreate([
+                'profile_id' => $user->profile_id,
+                'following_id' => $target->id,
+            ]);
             FollowPipeline::dispatch($follower)->onQueue('high');
         }
 
@@ -909,10 +917,11 @@ class ApiV1Controller extends Controller
 
         $user = $request->user();
 
+        abort_if($user->profile_id == $id, 400, 'Invalid profile');
+
         AccountService::setLastActive($user->id);
 
-        $target = Profile::where('id', '!=', $user->profile_id)
-            ->whereNull('status')
+        $target = Profile::whereNull('status')
             ->findOrFail($id);
 
         $private = (bool) $target->is_private;
@@ -929,6 +938,9 @@ class ApiV1Controller extends Controller
             if ($followRequest) {
                 $followRequest->delete();
                 RelationshipService::refresh($target->id, $user->profile_id);
+                if ($target->domain) {
+                    UnfollowPipeline::dispatch($user->profile_id, $target->id)->onQueue('high');
+                }
             }
             $resource = new Fractal\Resource\Item($target, new RelationshipTransformer);
             $res = $this->fractal->createData($resource)->toArray();
@@ -1528,7 +1540,7 @@ class ApiV1Controller extends Controller
 
         $user = $request->user();
 
-        $res = FollowRequest::whereFollowingId($user->profile->id)
+        $res = FollowRequest::whereFollowingId($user->profile_id)
             ->limit($request->input('limit', 40))
             ->pluck('follower_id')
             ->map(function ($id) {
@@ -1723,11 +1735,11 @@ class ApiV1Controller extends Controller
                 'mobile_registration' => (bool) config_cache('pixelfed.open_registration') && config('auth.in_app_registration'),
                 'configuration' => [
                     'media_attachments' => [
-                        'image_matrix_limit' => 16777216,
+                        'image_matrix_limit' => 2073600,
                         'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
                         'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
                         'video_frame_rate_limit' => 120,
-                        'video_matrix_limit' => 2304000,
+                        'video_matrix_limit' => 2073600,
                         'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
                     ],
                     'polls' => [
@@ -2378,7 +2390,7 @@ class ApiV1Controller extends Controller
         if (empty($res)) {
             if (! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
                 Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
-                NotificationService::warmCache($pid, 400, true);
+                NotificationWarmUserCache::dispatch($pid);
             }
         }
 
@@ -2430,6 +2442,15 @@ class ApiV1Controller extends Controller
 
                 return true;
             })
+            ->map(function ($n) use ($pid) {
+                if (isset($n['status'])) {
+                    $n['status']['favourited'] = (bool) LikeService::liked($pid, $n['status']['id']);
+                    $n['status']['reblogged'] = (bool) ReblogService::get($pid, $n['status']['id']);
+                    $n['status']['bookmarked'] = (bool) BookmarkService::get($pid, $n['status']['id']);
+                }
+
+                return $n;
+            })
             ->filter(function ($n) use ($types) {
                 if (! $types) {
                     return true;
@@ -2494,6 +2515,14 @@ class ApiV1Controller extends Controller
         ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
         AccountService::setLastActive($request->user()->id);
 
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $homeFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('home', $filter->context);
+        });
+
         if (config('exp.cached_home_timeline')) {
             $paddedLimit = $includeReblogs ? $limit + 10 : $limit + 50;
             if ($min || $max) {
@@ -2530,6 +2559,23 @@ class ApiV1Controller extends Controller
                 ->filter(function ($s) use ($includeReblogs) {
                     return $includeReblogs ? true : $s['reblog'] == null;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->map(function ($status) use ($pid) {
                     if ($pid) {
@@ -2638,6 +2684,23 @@ class ApiV1Controller extends Controller
 
                     return $status;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->values();
         } else {
@@ -2692,6 +2755,23 @@ class ApiV1Controller extends Controller
 
                     return $status;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->values();
         }
@@ -2753,7 +2833,7 @@ class ApiV1Controller extends Controller
             $limit = 40;
         }
         $user = $request->user();
-
+        $pid = $user->profile_id;
         $remote = $request->has('remote') && $request->boolean('remote');
         $local = $request->boolean('local');
         $userRoleKey = $remote ? 'can-view-network-feed' : 'can-view-public-feed';
@@ -2766,6 +2846,14 @@ class ApiV1Controller extends Controller
         $hideNsfw = config('instance.hide_nsfw_on_public_feeds');
         $amin = SnowflakeService::byDate(now()->subDays(config('federation.network_timeline_days_falloff')));
         $asf = AdminShadowFilterService::getHideFromPublicFeedsList();
+
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $homeFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('public', $filter->context);
+        });
         if ($local && $remote) {
             $feed = Status::select(
                 'id',
@@ -2956,6 +3044,23 @@ class ApiV1Controller extends Controller
 
                 return true;
             })
+            ->map(function ($status) use ($homeFilters) {
+                $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                if (! empty($filterResults)) {
+                    $status['filtered'] = $filterResults;
+                    $shouldHide = collect($filterResults)->contains(function ($result) {
+                        return $result['filter']['filter_action'] === 'hide';
+                    });
+
+                    if ($shouldHide) {
+                        return null;
+                    }
+                }
+
+                return $status;
+            })
+            ->filter()
             ->take($limit)
             ->values();
 
@@ -3505,13 +3610,19 @@ class ApiV1Controller extends Controller
             'in_reply_to_id' => 'nullable',
             'media_ids' => 'sometimes|array|max:'.(int) config_cache('pixelfed.max_album_length'),
             'sensitive' => 'nullable',
-            'visibility' => 'string|in:private,unlisted,public',
+            'visibility' => 'string|in:private,unlisted,public,direct',
             'spoiler_text' => 'sometimes|max:140',
             'place_id' => 'sometimes|integer|min:1|max:128769',
             'collection_ids' => 'sometimes|array|max:3',
             'comments_disabled' => 'sometimes|boolean',
         ]);
 
+        if ($request->filled('visibility') && $request->input('visibility') === 'direct') {
+            return $this->json([
+                'error' => 'Direct visibility is not available.',
+            ], 400);
+        }
+
         if ($request->hasHeader('idempotency-key')) {
             $key = 'pf:api:v1:status:idempotency-key:'.$request->user()->id.':'.hash('sha1', $request->header('idempotency-key'));
             $exists = Cache::has($key);
@@ -3893,8 +4004,16 @@ class ApiV1Controller extends Controller
         $pe = $request->has(self::PF_API_ENTITY_KEY);
         $pid = $request->user()->profile_id;
 
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $tagFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('tags', $filter->context);
+        });
+
         if ($min || $max) {
-            $minMax = SnowflakeService::byDate(now()->subMonths(6));
+            $minMax = SnowflakeService::byDate(now()->subMonths(9));
             if ($min && intval($min) < $minMax) {
                 return [];
             }
@@ -3949,6 +4068,23 @@ class ApiV1Controller extends Controller
 
                 return ! in_array($i['account']['id'], $filters) && ! in_array($domain, $domainBlocks);
             })
+            ->map(function ($status) use ($tagFilters) {
+                $filterResults = CustomFilter::applyCachedFilters($tagFilters, $status);
+
+                if (! empty($filterResults)) {
+                    $status['filtered'] = $filterResults;
+                    $shouldHide = collect($filterResults)->contains(function ($result) {
+                        return $result['filter']['filter_action'] === 'hide';
+                    });
+
+                    if ($shouldHide) {
+                        return null;
+                    }
+                }
+
+                return $status;
+            })
+            ->filter()
             ->take($limit)
             ->values()
             ->toArray();
@@ -4426,4 +4562,61 @@ class ApiV1Controller extends Controller
             })
         );
     }
+
+    /**
+     *  GET /api/v1/statuses/{id}/pin
+     */
+    public function statusPin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+        $user = $request->user();
+        $status = Status::whereScope('public')->find($id);
+
+        if (! $status) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
+        }
+
+        $res = StatusService::markPin($status->id);
+
+        if (! $res['success']) {
+            return $this->json([
+                'error' => $res['error'],
+            ], 422);
+        }
+
+        $statusRes = StatusService::get($status->id, true, true);
+        $status['pinned'] = true;
+
+        return $this->json($statusRes);
+    }
+
+    /**
+     *  GET /api/v1/statuses/{id}/unpin
+     */
+    public function statusUnpin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+        $status = Status::whereScope('public')->findOrFail($id);
+        $user = $request->user();
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        $res = StatusService::unmarkPin($status->id);
+        if (! $res) {
+            return $this->json($res, 422);
+        }
+
+        $status = StatusService::get($status->id, true, true);
+        $status['pinned'] = false;
+
+        return $this->json($status);
+    }
 }

+ 7 - 7
app/Http/Controllers/Api/ApiV2Controller.php

@@ -101,10 +101,10 @@ class ApiV2Controller extends Controller
                     'media_attachments' => [
                         'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
                         'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-                        'image_matrix_limit' => 3686400,
+                        'image_matrix_limit' => 2073600,
                         'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-                        'video_frame_rate_limit' => 240,
-                        'video_matrix_limit' => 3686400,
+                        'video_frame_rate_limit' => 120,
+                        'video_matrix_limit' => 2073600,
                     ],
                     'polls' => [
                         'max_options' => 0,
@@ -292,7 +292,7 @@ class ApiV2Controller extends Controller
             }
         }
 
-        $media = new Media();
+        $media = new Media;
         $media->status_id = null;
         $media->profile_id = $profile->id;
         $media->user_id = $user->id;
@@ -326,9 +326,9 @@ class ApiV2Controller extends Controller
         $user->save();
 
         Cache::forget($limitKey);
-        $fractal = new Fractal\Manager();
-        $fractal->setSerializer(new ArraySerializer());
-        $resource = new Fractal\Resource\Item($media, new MediaTransformer());
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer);
         $res = $fractal->createData($resource)->toArray();
         $res['preview_url'] = $media->url().'?v='.time();
         $res['url'] = null;

+ 82 - 101
app/Http/Controllers/Api/BaseApiController.php

@@ -2,46 +2,22 @@
 
 namespace App\Http\Controllers\Api;
 
-use Illuminate\Http\Request;
-use App\Http\Controllers\{
-    Controller,
-    AvatarController
-};
-use Auth, Cache, Storage, URL;
-use Carbon\Carbon;
-use App\{
-    Avatar,
-    Like,
-    Media,
-    Notification,
-    Profile,
-    Status,
-    StatusArchived
-};
-use App\Transformer\Api\{
-    AccountTransformer,
-    NotificationTransformer,
-    MediaTransformer,
-    MediaDraftTransformer,
-    StatusTransformer,
-    StatusStatelessTransformer
-};
-use League\Fractal;
-use App\Util\Media\Filter;
-use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Avatar;
+use App\Http\Controllers\AvatarController;
+use App\Http\Controllers\Controller;
 use App\Jobs\AvatarPipeline\AvatarOptimize;
-use App\Jobs\ImageOptimizePipeline\ImageOptimize;
-use App\Jobs\VideoPipeline\{
-    VideoOptimize,
-    VideoPostProcess,
-    VideoThumbnail
-};
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
 use App\Services\AccountService;
 use App\Services\NotificationService;
-use App\Services\MediaPathService;
-use App\Services\MediaBlocklistService;
 use App\Services\StatusService;
+use App\Status;
+use App\StatusArchived;
+use App\Transformer\Api\StatusStatelessTransformer;
+use Auth;
+use Cache;
+use Illuminate\Http\Request;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
 
 class BaseApiController extends Controller
 {
@@ -50,47 +26,47 @@ class BaseApiController extends Controller
     public function __construct()
     {
         // $this->middleware('auth');
-        $this->fractal = new Fractal\Manager();
-        $this->fractal->setSerializer(new ArraySerializer());
+        $this->fractal = new Fractal\Manager;
+        $this->fractal->setSerializer(new ArraySerializer);
     }
 
     public function notifications(Request $request)
     {
-        abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-		$limit = $request->input('limit', 20);
-
-		$since = $request->input('since_id');
-		$min = $request->input('min_id');
-		$max = $request->input('max_id');
-
-		if(!$since && !$min && !$max) {
-			$min = 1;
-		}
-
-		$maxId = null;
-		$minId = null;
-
-		if($max) {
-			$res = NotificationService::getMax($pid, $max, $limit);
-			$ids = NotificationService::getRankedMaxId($pid, $max, $limit);
-			if(!empty($ids)) {
-				$maxId = max($ids);
-				$minId = min($ids);
-			}
-		} else {
-			$res = NotificationService::getMin($pid, $min ?? $since, $limit);
-			$ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
-			if(!empty($ids)) {
-				$maxId = max($ids);
-				$minId = min($ids);
-			}
-		}
-
-        if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
-        	Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
-        	NotificationService::warmCache($pid, 100, true);
+        abort_if(! $request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+        $limit = $request->input('limit', 20);
+
+        $since = $request->input('since_id');
+        $min = $request->input('min_id');
+        $max = $request->input('max_id');
+
+        if (! $since && ! $min && ! $max) {
+            $min = 1;
+        }
+
+        $maxId = null;
+        $minId = null;
+
+        if ($max) {
+            $res = NotificationService::getMax($pid, $max, $limit);
+            $ids = NotificationService::getRankedMaxId($pid, $max, $limit);
+            if (! empty($ids)) {
+                $maxId = max($ids);
+                $minId = min($ids);
+            }
+        } else {
+            $res = NotificationService::getMin($pid, $min ?? $since, $limit);
+            $ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
+            if (! empty($ids)) {
+                $maxId = max($ids);
+                $minId = min($ids);
+            }
+        }
+
+        if (empty($res) && ! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
+            Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
+            NotificationWarmUserCache::dispatch($pid);
         }
 
         return response()->json($res);
@@ -98,17 +74,17 @@ class BaseApiController extends Controller
 
     public function avatarUpdate(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-            'upload'   => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
+            'upload' => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
         ]);
 
         try {
             $user = Auth::user();
             $profile = $user->profile;
             $file = $request->file('upload');
-            $path = (new AvatarController())->getPath($user, $file);
+            $path = (new AvatarController)->getPath($user, $file);
             $dir = $path['root'];
             $name = $path['name'];
             $public = $path['storage'];
@@ -129,13 +105,13 @@ class BaseApiController extends Controller
 
         return response()->json([
             'code' => 200,
-            'msg'  => 'Avatar successfully updated',
+            'msg' => 'Avatar successfully updated',
         ]);
     }
 
     public function verifyCredentials(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $user = $request->user();
         if ($user->status != null) {
@@ -143,47 +119,51 @@ class BaseApiController extends Controller
             abort(403);
         }
         $res = AccountService::get($user->profile_id);
+
         return response()->json($res);
     }
 
     public function accountLikes(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-        	'page' => 'sometimes|int|min:1|max:20',
-        	'limit' => 'sometimes|int|min:1|max:10'
+            'page' => 'sometimes|int|min:1|max:20',
+            'limit' => 'sometimes|int|min:1|max:10',
         ]);
 
         $user = $request->user();
         $limit = $request->input('limit', 10);
 
         $res = \DB::table('likes')
-        	->whereProfileId($user->profile_id)
-        	->latest()
-        	->simplePaginate($limit)
-        	->map(function($id) {
-        		$status = StatusService::get($id->status_id, false);
-        		$status['favourited'] = true;
-        		return $status;
-        	})
-        	->filter(function($post) {
-        		return $post && isset($post['account']);
-        	})
-        	->values();
+            ->whereProfileId($user->profile_id)
+            ->latest()
+            ->simplePaginate($limit)
+            ->map(function ($id) use ($user) {
+                $status = StatusService::get($id->status_id, false);
+                $status['favourited'] = true;
+                $status['reblogged'] = (bool) StatusService::isShared($id->status_id, $user->profile_id);
+
+                return $status;
+            })
+            ->filter(function ($post) {
+                return $post && isset($post['account']);
+            })
+            ->values();
+
         return response()->json($res);
     }
 
     public function archive(Request $request, $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $status = Status::whereNull('in_reply_to_id')
             ->whereNull('reblog_of_id')
             ->whereProfileId($request->user()->profile_id)
             ->findOrFail($id);
 
-        if($status->scope === 'archived') {
+        if ($status->scope === 'archived') {
             return [200];
         }
 
@@ -204,14 +184,14 @@ class BaseApiController extends Controller
 
     public function unarchive(Request $request, $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $status = Status::whereNull('in_reply_to_id')
             ->whereNull('reblog_of_id')
             ->whereProfileId($request->user()->profile_id)
             ->findOrFail($id);
 
-        if($status->scope !== 'archived') {
+        if ($status->scope !== 'archived') {
             return [200];
         }
 
@@ -231,16 +211,17 @@ class BaseApiController extends Controller
 
     public function archivedPosts(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $statuses = Status::whereProfileId($request->user()->profile_id)
             ->whereScope('archived')
             ->orderByDesc('id')
             ->simplePaginate(10);
 
-        $fractal = new Fractal\Manager();
-        $fractal->setSerializer(new ArraySerializer());
-        $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer());
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+        $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer);
+
         return $fractal->createData($resource)->toArray();
     }
 }

+ 11 - 5
app/Http/Controllers/ComposeController.php

@@ -30,7 +30,6 @@ use App\Util\Media\License;
 use Auth;
 use Cache;
 use DB;
-use Purify;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
 use League\Fractal;
@@ -240,7 +239,13 @@ class ComposeController extends Controller
         abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-            'q' => 'required|string|min:1|max:50',
+            'q' => [
+                'required',
+                'string',
+                'min:1',
+                'max:300',
+                new \App\Rules\WebFinger,
+            ],
         ]);
 
         $q = $request->input('q');
@@ -263,10 +268,11 @@ class ComposeController extends Controller
 
         $blocked->push($request->user()->profile_id);
 
+        $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like';
         $results = Profile::select('id', 'domain', 'username')
             ->whereNotIn('id', $blocked)
             ->whereNull('domain')
-            ->where('username', 'like', '%'.$q.'%')
+            ->where('username', $operator, '%'.$q.'%')
             ->limit(15)
             ->get()
             ->map(function ($r) {
@@ -571,7 +577,7 @@ class ComposeController extends Controller
             $status->cw_summary = $request->input('spoiler_text');
         }
 
-        $defaultCaption = "";
+        $defaultCaption = '';
         $status->caption = strip_tags($request->input('caption')) ?? $defaultCaption;
         $status->rendered = $defaultCaption;
         $status->scope = 'draft';
@@ -677,7 +683,7 @@ class ComposeController extends Controller
         $place = $request->input('place');
         $cw = $request->input('cw');
         $tagged = $request->input('tagged');
-        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : '';
 
         if ($place && is_array($place)) {
             $status->place_id = $place['id'];

+ 503 - 0
app/Http/Controllers/CustomFilterController.php

@@ -0,0 +1,503 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\CustomFilter;
+use App\Models\CustomFilterKeyword;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Gate;
+use Illuminate\Validation\Rule;
+
+class CustomFilterController extends Controller
+{
+    // const ACTIVE_TYPES = ['home', 'public', 'tags', 'notifications', 'thread', 'profile', 'groups'];
+    const ACTIVE_TYPES = ['home', 'public', 'tags'];
+
+    public function index(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filters = CustomFilter::where('profile_id', $request->user()->profile_id)
+            ->unexpired()
+            ->with(['keywords'])
+            ->orderByDesc('updated_at')
+            ->get()
+            ->map(function ($filter) {
+                return [
+                    'id' => $filter->id,
+                    'title' => $filter->title,
+                    'context' => $filter->context,
+                    'expires_at' => $filter->expires_at,
+                    'filter_action' => $filter->filterAction,
+                    'keywords' => $filter->keywords->map(function ($keyword) {
+                        return [
+                            'id' => $keyword->id,
+                            'keyword' => $keyword->keyword,
+                            'whole_word' => (bool) $keyword->whole_word,
+                        ];
+                    }),
+                    'statuses' => [],
+                ];
+            });
+
+        return response()->json($filters);
+    }
+
+    public function show(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('view', $filter);
+
+        $filter->load(['keywords']);
+
+        $res = [
+            'id' => $filter->id,
+            'title' => $filter->title,
+            'context' => $filter->context,
+            'expires_at' => $filter->expires_at,
+            'filter_action' => $filter->filterAction,
+            'keywords' => $filter->keywords->map(function ($keyword) {
+                return [
+                    'id' => $keyword->id,
+                    'keyword' => $keyword->keyword,
+                    'whole_word' => (bool) $keyword->whole_word,
+                ];
+            }),
+            'statuses' => [],
+        ];
+
+        return response()->json($res);
+    }
+
+    public function store(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        Gate::authorize('create', CustomFilter::class);
+
+        $validatedData = $request->validate([
+            'title' => 'required|string|max:100',
+            'context' => 'required|array',
+            'context.*' => [
+                'string',
+                Rule::in(self::ACTIVE_TYPES),
+            ],
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => 'required|array|min:1|max:'.CustomFilter::getMaxKeywordsPerFilter(),
+            'keywords_attributes.*.keyword' => [
+                'required',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::getMaxKeywordLength(),
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+        ]);
+        $profile_id = $request->user()->profile_id;
+        $userFilterCount = CustomFilter::where('profile_id', $profile_id)->count();
+        $maxFiltersPerUser = CustomFilter::getMaxFiltersPerUser();
+
+        if (! $request->user()->is_admin && $userFilterCount >= $maxFiltersPerUser) {
+            return response()->json([
+                'error' => 'Filter limit exceeded',
+                'message' => 'You can only have '.$maxFiltersPerUser.' filters at a time.',
+            ], 422);
+        }
+
+        $rateKey = 'filters_created:'.$request->user()->id;
+        $maxFiltersPerHour = CustomFilter::getMaxCreatePerHour();
+        $currentCount = Cache::get($rateKey, 0);
+
+        if (! $request->user()->is_admin && $currentCount >= $maxFiltersPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only create '.$maxFiltersPerHour.' filters per hour.',
+            ], 429);
+        }
+
+        DB::beginTransaction();
+
+        try {
+
+            $requestedKeywords = array_map(function ($item) {
+                return mb_strtolower(trim($item['keyword']));
+            }, $validatedData['keywords_attributes']);
+
+            $existingKeywords = DB::table('custom_filter_keywords')
+                ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                ->where('custom_filters.profile_id', $profile_id)
+                ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                ->pluck('custom_filter_keywords.keyword')
+                ->toArray();
+
+            if (! empty($existingKeywords)) {
+                return response()->json([
+                    'error' => 'Duplicate keywords found',
+                    'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                ], 422);
+            }
+
+            $expiresAt = null;
+            if (isset($validatedData['expires_in']) && $validatedData['expires_in'] > 0) {
+                $expiresAt = now()->addSeconds($validatedData['expires_in']);
+            }
+
+            $action = CustomFilter::ACTION_WARN;
+            if (isset($validatedData['filter_action'])) {
+                $action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
+            $filter = CustomFilter::create([
+                'title' => $validatedData['title'],
+                'context' => $validatedData['context'],
+                'action' => $action,
+                'expires_at' => $expiresAt,
+                'profile_id' => $request->user()->profile_id,
+            ]);
+
+            if (isset($validatedData['keywords_attributes'])) {
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    $keyword = trim($keywordData['keyword']);
+
+                    $filter->keywords()->create([
+                        'keyword' => $keyword,
+                        'whole_word' => (bool) $keywordData['whole_word'] ?? true,
+                    ]);
+                }
+            }
+
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
+            }
+
+            Cache::forget("filters:v3:{$profile_id}");
+
+            DB::commit();
+
+            $filter->load(['keywords', 'statuses']);
+
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res, 200);
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+
+            return response()->json([
+                'error' => 'Failed to create filter',
+                'message' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * Convert Mastodon filter_action string to internal action value
+     *
+     * @param  string  $filterAction
+     * @return int
+     */
+    private function filterActionToAction($filterAction)
+    {
+        switch ($filterAction) {
+            case 'warn':
+                return CustomFilter::ACTION_WARN;
+            case 'hide':
+                return CustomFilter::ACTION_HIDE;
+            case 'blur':
+                return CustomFilter::ACTION_BLUR;
+            default:
+                return CustomFilter::ACTION_WARN;
+        }
+    }
+
+    public function update(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        if ($filter->profile_id !== $pid) {
+            return response()->json(['error' => 'This action is unauthorized'], 401);
+        }
+        Gate::authorize('update', $filter);
+
+        $validatedData = $request->validate([
+            'title' => 'string|max:100',
+            'context' => 'array|max:10',
+            'context.*' => 'string|in:home,notifications,public,thread,account,tags,groups',
+            'context.*' => [
+                'string',
+                Rule::in(self::ACTIVE_TYPES),
+            ],
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => [
+                'required',
+                'array',
+                'min:1',
+                function ($attribute, $value, $fail) {
+                    $activeKeywords = collect($value)->filter(function ($keyword) {
+                        return ! isset($keyword['_destroy']) || $keyword['_destroy'] !== true;
+                    })->count();
+
+                    if ($activeKeywords > CustomFilter::getMaxKeywordsPerFilter()) {
+                        $fail('You may not have more than '.CustomFilter::getMaxKeywordsPerFilter().' active keywords.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.id' => 'nullable|integer|exists:custom_filter_keywords,id',
+            'keywords_attributes.*.keyword' => [
+                'required_without:keywords_attributes.*.id',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::getMaxKeywordLength(),
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+            'keywords_attributes.*._destroy' => 'boolean',
+        ]);
+
+        $rateKey = 'filters_updated:'.$request->user()->id;
+        $maxUpdatesPerHour = CustomFilter::getMaxUpdatesPerHour();
+        $currentCount = Cache::get($rateKey, 0);
+
+        if (! $request->user()->is_admin && $currentCount >= $maxUpdatesPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only update filters '.$maxUpdatesPerHour.' times per hour.',
+            ], 429);
+        }
+
+        DB::beginTransaction();
+
+        try {
+
+            $keywordIds = collect($validatedData['keywords_attributes'])->pluck('id')->filter()->toArray();
+            if (count($keywordIds) && ! CustomFilterKeyword::whereCustomFilterId($filter->id)->whereIn('id', $keywordIds)->count()) {
+                return response()->json([
+                    'error' => 'Record not found',
+                ], 404);
+            }
+
+            $requestedKeywords = [];
+            foreach ($validatedData['keywords_attributes'] as $item) {
+                if (isset($item['keyword']) && (! isset($item['_destroy']) || ! $item['_destroy'])) {
+                    $requestedKeywords[] = mb_strtolower(trim($item['keyword']));
+                }
+            }
+
+            if (! empty($requestedKeywords)) {
+                $existingKeywords = DB::table('custom_filter_keywords')
+                    ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                    ->where('custom_filters.profile_id', $pid)
+                    ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                    ->where('custom_filter_keywords.custom_filter_id', '!=', $id)
+                    ->pluck('custom_filter_keywords.keyword')
+                    ->toArray();
+
+                if (! empty($existingKeywords)) {
+                    return response()->json([
+                        'error' => 'Duplicate keywords found',
+                        'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                    ], 422);
+                }
+            }
+
+            if (isset($validatedData['expires_in'])) {
+                if ($validatedData['expires_in'] > 0) {
+                    $filter->expires_at = now()->addSeconds($validatedData['expires_in']);
+                } else {
+                    $filter->expires_at = null;
+                }
+            }
+
+            if (isset($validatedData['title'])) {
+                $filter->title = $validatedData['title'];
+            }
+
+            if (isset($validatedData['context'])) {
+                $filter->context = $validatedData['context'];
+            }
+
+            if (isset($validatedData['filter_action'])) {
+                $filter->action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
+            $filter->save();
+
+            if (isset($validatedData['keywords_attributes'])) {
+                $existingKeywords = $filter->keywords()->pluck('id')->toArray();
+
+                $processedIds = [];
+
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    // Case 1: Explicit deletion with _destroy flag
+                    if (isset($keywordData['id']) && isset($keywordData['_destroy']) && (bool) $keywordData['_destroy']) {
+                        // Verify this ID belongs to this filter before deletion
+                        $kwf = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if ($kwf) {
+                            $kwf->delete();
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 2: Update existing keyword
+                    elseif (isset($keywordData['id'])) {
+                        // Skip if we've already processed this ID
+                        if (in_array($keywordData['id'], $processedIds)) {
+                            continue;
+                        }
+
+                        // Verify this ID belongs to this filter before updating
+                        $keyword = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if (! isset($keywordData['_destroy']) && $filter->keywords()->pluck('id')->search($keywordData['id']) === false) {
+                            return response()->json([
+                                'error' => 'Duplicate keywords found',
+                                'message' => 'The following keywords already exist: '.$keywordData['keyword'],
+                            ], 422);
+                        }
+
+                        if ($keyword) {
+                            $updateData = [];
+
+                            if (isset($keywordData['keyword'])) {
+                                $updateData['keyword'] = trim($keywordData['keyword']);
+                            }
+
+                            if (isset($keywordData['whole_word'])) {
+                                $updateData['whole_word'] = (bool) $keywordData['whole_word'];
+                            }
+
+                            if (! empty($updateData)) {
+                                $keyword->update($updateData);
+                            }
+
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 3: Create new keyword
+                    elseif (isset($keywordData['keyword'])) {
+                        // Check if we're about to exceed the keyword limit
+                        $existingKeywordCount = $filter->keywords()->count();
+                        $maxKeywordsPerFilter = CustomFilter::getMaxKeywordsPerFilter();
+
+                        if ($existingKeywordCount >= $maxKeywordsPerFilter) {
+                            return response()->json([
+                                'error' => 'Keyword limit exceeded',
+                                'message' => 'A filter can have a maximum of '.$maxKeywordsPerFilter.' keywords.',
+                            ], 422);
+                        }
+
+                        // Skip existing case-insensitive keywords
+                        if ($filter->keywords()->pluck('keyword')->search(mb_strtolower(trim($keywordData['keyword']))) !== false) {
+                            continue;
+                        }
+
+                        $filter->keywords()->create([
+                            'keyword' => trim($keywordData['keyword']),
+                            'whole_word' => (bool) ($keywordData['whole_word'] ?? true),
+                        ]);
+                    }
+                }
+            }
+
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
+            }
+
+            Cache::forget("filters:v3:{$pid}");
+
+            DB::commit();
+
+            $filter->load(['keywords', 'statuses']);
+
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res);
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+
+            return response()->json([
+                'error' => 'Failed to update filter',
+                'message' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    public function delete(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('delete', $filter);
+        $filter->delete();
+
+        return response()->json((object) [], 200);
+    }
+}

+ 10 - 0
app/Http/Controllers/CustomFilterKeywordController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class CustomFilterKeywordController extends Controller
+{
+    //
+}

+ 10 - 0
app/Http/Controllers/CustomFilterStatusController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class CustomFilterStatusController extends Controller
+{
+    //
+}

+ 16 - 19
app/Http/Controllers/DiscoverController.php

@@ -57,11 +57,11 @@ class DiscoverController extends Controller
 
         $this->validate($request, [
             'hashtag' => 'required|string|min:1|max:124',
-            'page' => 'nullable|integer|min:1|max:'.($user ? 29 : 3),
+            'page' => 'nullable|integer|min:1',
         ]);
 
         $page = $request->input('page') ?? '1';
-        $end = $page > 1 ? $page * 9 : 0;
+        $end = $page > 1 ? $page * 9 : (($page * 9) + 9);
         $tag = $request->input('hashtag');
 
         if (config('database.default') === 'pgsql') {
@@ -80,6 +80,18 @@ class DiscoverController extends Controller
             'name' => $hashtag->name,
             'url' => $hashtag->url(),
         ];
+
+        $res['tags'] = [];
+
+        if ($page >= 8) {
+            if ($user) {
+                if ($page >= 29) {
+                    return $res;
+                }
+            } else {
+                return $res;
+            }
+        }
         if ($user) {
             $tags = StatusHashtagService::get($hashtag->id, $page, $end);
             $res['tags'] = collect($tags)
@@ -99,23 +111,8 @@ class DiscoverController extends Controller
                 })
                 ->values();
         } else {
-            if ($page != 1) {
-                $res['tags'] = [];
-
-                return $res;
-            }
-            $key = 'discover:tags:public_feed:'.$hashtag->id.':page:'.$page;
-            $tags = Cache::remember($key, 43200, function () use ($hashtag, $page, $end) {
-                return collect(StatusHashtagService::get($hashtag->id, $page, $end))
-                    ->filter(function ($tag) {
-                        if (! $tag['status']['local']) {
-                            return false;
-                        }
-
-                        return true;
-                    })
-                    ->values();
-            });
+            $key = 'discover:tags:public_feed:'.$hashtag->id.':page:'.$page.':end'.$end;
+            $tags = StatusHashtagService::get($hashtag->id, $page, $end);
             $res['tags'] = collect($tags)
                 ->filter(function ($tag) {
                     if (! StatusService::get($tag['status']['id'])) {

+ 140 - 57
app/Http/Controllers/ImportPostController.php

@@ -103,67 +103,95 @@ class ImportPostController extends Controller
 
         $uid = $request->user()->id;
         $pid = $request->user()->profile_id;
+        $successCount = 0;
+        $errors = [];
+
         foreach($request->input('files') as $file) {
-            $media = $file['media'];
-            $c = collect($media);
-            $postHash = hash('sha256', $c->toJson());
-            $exts = $c->map(function($m) {
-                $fn = last(explode('/', $m['uri']));
-                return last(explode('.', $fn));
-            });
-            $postType = 'photo';
-
-            if($exts->count() > 1) {
-                if($exts->contains('mp4')) {
-                    if($exts->contains('jpg', 'png')) {
-                        $postType = 'photo:video:album';
-                    } else {
-                        $postType = 'video:album';
-                    }
-                } else {
-                    $postType = 'photo:album';
+            try {
+                $media = $file['media'];
+                $c = collect($media);
+
+                $firstUri = isset($media[0]['uri']) ? $media[0]['uri'] : '';
+                $postHash = hash('sha256', $c->toJson() . $firstUri);
+
+                $exists = ImportPost::where('user_id', $uid)
+                    ->where('post_hash', $postHash)
+                    ->exists();
+
+                if ($exists) {
+                    $errors[] = "Duplicate post detected. Skipping...";
+                    continue;
                 }
-            } else {
-                if(in_array($exts[0], ['jpg', 'png'])) {
-                    $postType = 'photo';
-                } else if(in_array($exts[0], ['mp4'])) {
-                    $postType = 'video';
+
+                $exts = $c->map(function($m) {
+                    $fn = last(explode('/', $m['uri']));
+                    return last(explode('.', $fn));
+                });
+
+                $postType = $this->determinePostType($exts);
+
+                $ip = new ImportPost;
+                $ip->user_id = $uid;
+                $ip->profile_id = $pid;
+                $ip->post_hash = $postHash;
+                $ip->service = 'instagram';
+                $ip->post_type = $postType;
+                $ip->media_count = $c->count();
+
+                $ip->media = $c->map(function($m) {
+                    return [
+                        'uri' => $m['uri'],
+                        'title' => $this->formatHashtags($m['title'] ?? ''),
+                        'creation_timestamp' => $m['creation_timestamp'] ?? null
+                    ];
+                })->toArray();
+
+                $ip->caption = $c->count() > 1 ?
+                    $this->formatHashtags($file['title'] ?? '') :
+                    $this->formatHashtags($ip->media[0]['title'] ?? '');
+
+                $originalFilename = last(explode('/', $ip->media[0]['uri'] ?? ''));
+                $ip->filename = $this->sanitizeFilename($originalFilename);
+
+                $ip->metadata = $c->map(function($m) {
+                    return [
+                        'uri' => $m['uri'],
+                        'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
+                    ];
+                })->toArray();
+
+                $creationTimestamp = $c->count() > 1 ?
+                    ($file['creation_timestamp'] ?? null) :
+                    ($media[0]['creation_timestamp'] ?? null);
+
+                if ($creationTimestamp) {
+                    $ip->creation_date = now()->parse($creationTimestamp);
+                    $ip->creation_year = $ip->creation_date->format('y');
+                    $ip->creation_month = $ip->creation_date->format('m');
+                    $ip->creation_day = $ip->creation_date->format('d');
+                } else {
+                    $ip->creation_date = now();
+                    $ip->creation_year = now()->format('y');
+                    $ip->creation_month = now()->format('m');
+                    $ip->creation_day = now()->format('d');
                 }
-            }
 
-            $ip = new ImportPost;
-            $ip->user_id = $uid;
-            $ip->profile_id = $pid;
-            $ip->post_hash = $postHash;
-            $ip->service = 'instagram';
-            $ip->post_type = $postType;
-            $ip->media_count = $c->count();
-            $ip->media = $c->map(function($m) {
-                return [
-                    'uri' => $m['uri'],
-                    'title' => $this->formatHashtags($m['title']),
-                    'creation_timestamp' => $m['creation_timestamp']
-                ];
-            })->toArray();
-            $ip->caption = $c->count() > 1 ? $this->formatHashtags($file['title']) : $this->formatHashtags($ip->media[0]['title']);
-            $ip->filename = last(explode('/', $ip->media[0]['uri']));
-            $ip->metadata = $c->map(function($m) {
-                return [
-                    'uri' => $m['uri'],
-                    'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
-                ];
-            })->toArray();
-            $ip->creation_date = $c->count() > 1 ? now()->parse($file['creation_timestamp']) : now()->parse($media[0]['creation_timestamp']);
-            $ip->creation_year = now()->parse($ip->creation_date)->format('y');
-            $ip->creation_month = now()->parse($ip->creation_date)->format('m');
-            $ip->creation_day = now()->parse($ip->creation_date)->format('d');
-            $ip->save();
-
-            ImportService::getImportedFiles($pid, true);
-            ImportService::getPostCount($pid, true);
+                $ip->save();
+                $successCount++;
+
+                ImportService::getImportedFiles($pid, true);
+                ImportService::getPostCount($pid, true);
+            } catch (\Exception $e) {
+                $errors[] = $e->getMessage();
+                \Log::error('Import error: ' . $e->getMessage());
+                continue;
+            }
         }
+
         return [
-            'msg' => 'Success'
+            'success' => true,
+            'msg' => 'Successfully imported ' . $successCount . ' posts',
+            'errors' => $errors
         ];
     }
 
@@ -173,7 +201,17 @@ class ImportPostController extends Controller
 
         $this->checkPermissions($request);
 
-        $mimes = config('import.instagram.allow_video_posts') ? 'mimetypes:image/png,image/jpeg,video/mp4' : 'mimetypes:image/png,image/jpeg';
+        $allowedMimeTypes = ['image/png', 'image/jpeg'];
+
+        if (config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp')) {
+            $allowedMimeTypes[] = 'image/webp';
+        }
+
+        if (config('import.instagram.allow_video_posts')) {
+            $allowedMimeTypes[] = 'video/mp4';
+        }
+
+        $mimes = 'mimetypes:' . implode(',', $allowedMimeTypes);
 
         $this->validate($request, [
             'file' => 'required|array|max:10',
@@ -186,7 +224,12 @@ class ImportPostController extends Controller
         ]);
 
         foreach($request->file('file') as $file) {
-            $fileName = $file->getClientOriginalName();
+            $extension = $file->getClientOriginalExtension();
+
+            $originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
+            $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+            $fileName = $safeFilename . '.' . $extension;
+
             $file->storeAs('imports/' . $request->user()->id . '/', $fileName);
         }
 
@@ -197,6 +240,46 @@ class ImportPostController extends Controller
         ];
     }
 
+
+    private function determinePostType($exts)
+    {
+        if ($exts->count() > 1) {
+            if ($exts->contains('mp4')) {
+                if ($exts->contains('jpg', 'png', 'webp')) {
+                    return 'photo:video:album';
+                } else {
+                    return 'video:album';
+                }
+            } else {
+                return 'photo:album';
+            }
+        } else {
+            if ($exts->isEmpty()) {
+                return 'photo';
+            }
+
+            $ext = $exts[0];
+
+            if (in_array($ext, ['jpg', 'jpeg', 'png', 'webp'])) {
+                return 'photo';
+            } else if (in_array($ext, ['mp4'])) {
+                return 'video';
+            } else {
+                return 'photo';
+            }
+        }
+    }
+
+    private function sanitizeFilename($filename)
+    {
+        $parts = explode('.', $filename);
+        $extension = array_pop($parts);
+        $originalName = implode('.', $parts);
+
+        $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+        return $safeFilename . '.' . $extension;
+    }
+
     protected function checkPermissions($request, $abortOnFail = true)
     {
         $user = $request->user();

+ 6 - 2
app/Http/Controllers/ProfileMigrationController.php

@@ -63,8 +63,12 @@ class ProfileMigrationController extends Controller
         AccountService::del($user->profile_id);
 
         Bus::batch([
-            new ProfileMigrationDeliverMoveActivityPipeline($migration, $user->profile, $newAccount),
-            new ProfileMigrationMoveFollowersPipeline($user->profile_id, $newAccount->id),
+            [
+                new ProfileMigrationDeliverMoveActivityPipeline($migration, $user->profile, $newAccount),
+            ],
+            [
+                new ProfileMigrationMoveFollowersPipeline($user->profile_id, $newAccount->id),
+            ]
         ])->onQueue('follow')->dispatch();
 
         return redirect()->back()->with(['status' => 'Succesfully migrated account!']);

+ 167 - 53
app/Http/Controllers/PublicApiController.php

@@ -35,6 +35,11 @@ class PublicApiController extends Controller
         $this->fractal->setSerializer(new ArraySerializer);
     }
 
+    public function json($res, $code = 200, $headers = [])
+    {
+        return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
+    }
+
     protected function getUserData($user)
     {
         if (! $user) {
@@ -667,10 +672,8 @@ class PublicApiController extends Controller
             'only_media' => 'nullable',
             'pinned' => 'nullable',
             'exclude_replies' => 'nullable',
-            'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
-            'since_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
-            'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
             'limit' => 'nullable|integer|min:1|max:24',
+            'cursor' => 'nullable',
         ]);
 
         $user = $request->user();
@@ -683,83 +686,194 @@ class PublicApiController extends Controller
         }
 
         $limit = $request->limit ?? 9;
-        $max_id = $request->max_id;
-        $min_id = $request->min_id;
         $scope = ['photo', 'photo:album', 'video', 'video:album'];
         $onlyMedia = $request->input('only_media', true);
+        $pinned = $request->filled('pinned') && $request->boolean('pinned') == true;
+        $hasCursor = $request->filled('cursor');
+
+        $visibility = $this->determineVisibility($profile, $user);
+
+        if (empty($visibility)) {
+            return response()->json([]);
+        }
+
+        $result = collect();
+        $remainingLimit = $limit;
+
+        if ($pinned && ! $hasCursor) {
+            $pinnedStatuses = Status::whereProfileId($profile['id'])
+                ->whereNotNull('pinned_order')
+                ->orderBy('pinned_order')
+                ->get();
 
-        if (! $min_id && ! $max_id) {
-            $min_id = 1;
+            $pinnedResult = $this->processStatuses($pinnedStatuses, $user, $onlyMedia);
+            $result = $pinnedResult;
+
+            $remainingLimit = max(1, $limit - $pinnedResult->count());
+        }
+
+        $paginator = Status::whereProfileId($profile['id'])
+            ->whereNull('in_reply_to_id')
+            ->whereNull('reblog_of_id')
+            ->when($pinned, function ($query) {
+                return $query->whereNull('pinned_order');
+            })
+            ->whereIn('type', $scope)
+            ->whereIn('scope', $visibility)
+            ->orderByDesc('id')
+            ->cursorPaginate($remainingLimit)
+            ->withQueryString();
+
+        $headers = $this->generatePaginationHeaders($paginator);
+        $regularStatuses = $this->processStatuses($paginator->items(), $user, $onlyMedia);
+        $result = $result->concat($regularStatuses);
+
+        return response()->json($result, 200, $headers);
+    }
+
+    /**
+     *  GET /api/pixelfed/v1/statuses/{id}/pin
+     */
+    public function statusPin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        $user = $request->user();
+        $status = Status::whereScope('public')->find($id);
+
+        if (! $status) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
+        }
+
+        $res = StatusService::markPin($status->id);
+
+        if (! $res['success']) {
+            return $this->json([
+                'error' => $res['error'],
+            ], 422);
+        }
+
+        $statusRes = StatusService::get($status->id, true, true);
+        $status['pinned'] = true;
+
+        return $this->json($statusRes);
+    }
+
+    /**
+     *  GET /api/pixelfed/v1/statuses/{id}/unpin
+     */
+    public function statusUnpin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        $status = Status::whereScope('public')->findOrFail($id);
+        $user = $request->user();
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        $res = StatusService::unmarkPin($status->id);
+        if (! $res) {
+            return $this->json($res, 422);
+        }
+
+        $status = StatusService::get($status->id, true, true);
+        $status['pinned'] = false;
+
+        return $this->json($status);
+    }
+
+    private function determineVisibility($profile, $user)
+    {
+        if (! $profile || ! isset($profile['id'])) {
+            return [];
+        }
+
+        if ($user && $profile['id'] == $user->profile_id) {
+            return ['public', 'unlisted', 'private'];
         }
 
         if ($profile['locked']) {
             if (! $user) {
-                return response()->json([]);
+                return [];
             }
+
             $pid = $user->profile_id;
-            $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
-                $following = Follower::whereProfileId($pid)->pluck('following_id');
+            $isFollowing = FollowerService::follows($pid, $profile['id']);
 
-                return $following->push($pid)->toArray();
-            });
-            $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : [];
+            return $isFollowing ? ['public', 'unlisted', 'private'] : ['public'];
         } else {
             if ($user) {
                 $pid = $user->profile_id;
-                $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
-                    $following = Follower::whereProfileId($pid)->pluck('following_id');
+                $isFollowing = FollowerService::follows($pid, $profile['id']);
 
-                    return $following->push($pid)->toArray();
-                });
-                $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
+                return $isFollowing ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
             } else {
-                $visibility = ['public', 'unlisted'];
+                return ['public', 'unlisted'];
             }
         }
-        $dir = $min_id ? '>' : '<';
-        $id = $min_id ?? $max_id;
-        $res = Status::whereProfileId($profile['id'])
-            ->whereNull('in_reply_to_id')
-            ->whereNull('reblog_of_id')
-            ->whereIn('type', $scope)
-            ->where('id', $dir, $id)
-            ->whereIn('scope', $visibility)
-            ->limit($limit)
-            ->orderByDesc('id')
-            ->get()
-            ->map(function ($s) use ($user) {
-                try {
-                    $status = StatusService::get($s->id, false);
-                    if (! $status) {
-                        return false;
-                    }
-                } catch (\Exception $e) {
-                    $status = false;
+    }
+
+    private function processStatuses($statuses, $user, $onlyMedia)
+    {
+        return collect($statuses)->map(function ($status) use ($user) {
+            try {
+                $mastodonStatus = StatusService::get($status->id, false);
+                if (! $mastodonStatus) {
+                    return null;
                 }
-                if ($user && $status) {
-                    $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
+
+                if ($user) {
+                    $mastodonStatus['favourited'] = (bool) LikeService::liked($user->profile_id, $status->id);
+                    $mastodonStatus['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $status->id);
+                    $mastodonStatus['reblogged'] = (bool) StatusService::isShared($status->id, $user->profile_id);
                 }
 
-                return $status;
-            })
-            ->filter(function ($s) use ($onlyMedia) {
-                if (! $s) {
+                return $mastodonStatus;
+            } catch (\Exception $e) {
+                return null;
+            }
+        })
+            ->filter(function ($status) use ($onlyMedia) {
+                if (! $status) {
                     return false;
                 }
+
                 if ($onlyMedia) {
-                    if (
-                        ! isset($s['media_attachments']) ||
-                        ! is_array($s['media_attachments']) ||
-                        empty($s['media_attachments'])
-                    ) {
-                        return false;
-                    }
+                    return isset($status['media_attachments']) &&
+                           is_array($status['media_attachments']) &&
+                           ! empty($status['media_attachments']);
                 }
 
-                return $s;
+                return true;
             })
             ->values();
+    }
 
-        return response()->json($res);
+    /**
+     * Generate pagination link headers from paginator
+     */
+    private function generatePaginationHeaders($paginator)
+    {
+        $link = null;
+
+        if ($paginator->onFirstPage()) {
+            if ($paginator->hasMorePages()) {
+                $link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        } else {
+            if ($paginator->previousPageUrl()) {
+                $link = '<'.$paginator->previousPageUrl().'>; rel="next"';
+            }
+
+            if ($paginator->hasMorePages()) {
+                $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        }
+
+        return isset($link) ? ['Link' => $link] : [];
     }
 }

+ 2 - 31
app/Http/Controllers/RemoteAuthController.php

@@ -14,6 +14,7 @@ use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
+use App\Rules\PixelfedUsername;
 use InvalidArgumentException;
 use Purify;
 
@@ -359,37 +360,7 @@ class RemoteAuthController extends Controller
                 'required',
                 'min:2',
                 'max:30',
-                function ($attribute, $value, $fail) {
-                    $dash = substr_count($value, '-');
-                    $underscore = substr_count($value, '_');
-                    $period = substr_count($value, '.');
-
-                    if (ends_with($value, ['.php', '.js', '.css'])) {
-                        return $fail('Username is invalid.');
-                    }
-
-                    if (($dash + $underscore + $period) > 1) {
-                        return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
-                    }
-
-                    if (! ctype_alnum($value[0])) {
-                        return $fail('Username is invalid. Must start with a letter or number.');
-                    }
-
-                    if (! ctype_alnum($value[strlen($value) - 1])) {
-                        return $fail('Username is invalid. Must end with a letter or number.');
-                    }
-
-                    $val = str_replace(['_', '.', '-'], '', $value);
-                    if (! ctype_alnum($val)) {
-                        return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
-                    }
-
-                    $restricted = RestrictedNames::get();
-                    if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
-                        return $fail('Username cannot be used.');
-                    }
-                },
+                new PixelfedUsername(),
             ],
         ]);
         $username = strtolower($request->input('username'));

+ 121 - 0
app/Http/Controllers/RemoteOidcController.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserOidcMapping;
+use Purify;
+use App\Services\EmailService;
+use App\Services\UserOidcService;
+use App\User;
+use Illuminate\Auth\Events\Registered;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+use App\Rules\EmailNotBanned;
+use App\Rules\PixelfedUsername;
+
+class RemoteOidcController extends Controller
+{
+    protected $fractal;
+
+    public function start(UserOidcService $provider, Request $request)
+    {
+        abort_unless((bool) config('remote-auth.oidc.enabled'), 404);
+        if ($request->user()) {
+            return redirect('/');
+        }
+
+        $url = $provider->getAuthorizationUrl([
+            'scope' => $provider->getDefaultScopes(),
+        ]);
+
+        $request->session()->put('oauth2state', $provider->getState());
+
+        return redirect($url);
+    }
+
+    public function handleCallback(UserOidcService $provider, Request $request)
+    {
+        abort_unless((bool) config('remote-auth.oidc.enabled'), 404);
+
+        if ($request->user()) {
+            return redirect('/');
+        }
+
+        abort_unless($request->input("state"), 400);
+        abort_unless($request->input("code"), 400);
+
+        abort_unless(hash_equals($request->session()->pull('oauth2state'), $request->input("state")), 400, "invalid state");
+
+        $accessToken = $provider->getAccessToken('authorization_code', [
+            'code' => $request->get('code')
+        ]);
+
+        $userInfo = $provider->getResourceOwner($accessToken);
+        $userInfoId = $userInfo->getId();
+        $userInfoData = $userInfo->toArray();
+
+        $mappedUser = UserOidcMapping::where('oidc_id', $userInfoId)->first();
+        if ($mappedUser) {
+            $this->guarder()->login($mappedUser->user);
+            return redirect('/');
+        }
+
+        abort_if(EmailService::isBanned($userInfoData["email"]), 400, 'Banned email.');
+
+        $user = $this->createUser([
+            'username' => $userInfoData[config('remote-auth.oidc.field_username')],
+            'name' => $userInfoData["name"] ?? $userInfoData["display_name"] ?? $userInfoData[config('remote-auth.oidc.field_username')] ?? null,
+            'email' => $userInfoData["email"],
+        ]);
+
+        UserOidcMapping::create([
+            'user_id' => $user->id,
+            'oidc_id' => $userInfoId,
+        ]);
+
+        return redirect('/');
+    }
+
+    protected function createUser($data)
+    {
+        $this->validate(new Request($data), [
+            'email' => [
+                'required',
+                'string',
+                'email:strict,filter_unicode,dns,spoof',
+                'max:255',
+                'unique:users',
+                new EmailNotBanned(),
+            ],
+            'username' => [
+                'required',
+                'min:2',
+                'max:30',
+                'unique:users,username',
+                new PixelfedUsername(),
+            ],
+            'name' => 'nullable|max:30',
+        ]);
+
+        event(new Registered($user = User::create([
+            'name' => Purify::clean($data['name']),
+            'username' => $data['username'],
+            'email' => $data['email'],
+            'password' => Hash::make(Str::password()),
+            'email_verified_at' => now(),
+            'app_register_ip' => request()->ip(),
+            'register_source' => 'oidc',
+        ])));
+
+        $this->guarder()->login($user);
+
+        return $user;
+    }
+
+    protected function guarder()
+    {
+        return Auth::guard();
+    }
+}

+ 81 - 34
app/Http/Controllers/ReportController.php

@@ -2,13 +2,13 @@
 
 namespace App\Http\Controllers;
 
+use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
+use App\Models\Group;
 use App\Profile;
 use App\Report;
 use App\Status;
-use App\User;
 use Auth;
 use Illuminate\Http\Request;
-use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
 
 class ReportController extends Controller
 {
@@ -22,10 +22,33 @@ class ReportController extends Controller
     public function showForm(Request $request)
     {
         $this->validate($request, [
-          'type'    => 'required|alpha_dash',
-          'id'      => 'required|integer|min:1',
+            'type' => 'required|alpha_dash|in:comment,group,post,user',
+            'id' => 'required|integer|min:1',
         ]);
 
+        $type = $request->input('type');
+        $id = $request->input('id');
+        $pid = $request->user()->profile_id;
+
+        switch ($request->input('type')) {
+            case 'post':
+            case 'comment':
+                Status::findOrFail($id);
+                break;
+
+            case 'user':
+                Profile::findOrFail($id);
+                break;
+
+            case 'group':
+                Group::where('profile_id', '!=', $pid)->findOrFail($id);
+                break;
+
+            default:
+                // code...
+                break;
+        }
+
         return view('report.form');
     }
 
@@ -87,10 +110,10 @@ class ReportController extends Controller
     public function formStore(Request $request)
     {
         $this->validate($request, [
-            'report'  => 'required|alpha_dash',
-            'type'    => 'required|alpha_dash',
-            'id'      => 'required|integer|min:1',
-            'msg'     => 'nullable|string|max:150',
+            'report' => 'required|alpha_dash',
+            'type' => 'required|alpha_dash',
+            'id' => 'required|integer|min:1',
+            'msg' => 'nullable|string|max:150',
         ]);
 
         $profile = Auth::user()->profile;
@@ -101,8 +124,8 @@ class ReportController extends Controller
         $object = null;
         $types = [
             // original 3
-            'spam', 
-            'sensitive', 
+            'spam',
+            'sensitive',
             'abusive',
 
             // new
@@ -110,38 +133,62 @@ class ReportController extends Controller
             'copyright',
             'impersonation',
             'scam',
-            'terrorism'
+            'terrorism',
         ];
 
-        if (!in_array($reportType, $types)) {
-            if($request->wantsJson()) {
+        if (! in_array($reportType, $types)) {
+            if ($request->wantsJson()) {
                 return abort(400, 'Invalid report type');
             } else {
                 return redirect('/timeline')->with('error', 'Invalid report type');
             }
         }
 
+        $rpid = null;
+
         switch ($object_type) {
-        case 'post':
-          $object = Status::findOrFail($object_id);
-          $object_type = 'App\Status';
-          $exists = Report::whereUserId(Auth::id())
+            case 'post':
+                $object = Status::findOrFail($object_id);
+                $object_type = 'App\Status';
+                $exists = Report::whereUserId(Auth::id())
                     ->whereObjectId($object->id)
                     ->whereObjectType('App\Status')
                     ->count();
-          break;
 
-        default:
-            if($request->wantsJson()) {
-                return abort(400, 'Invalid report type');
-            } else {
-                return redirect('/timeline')->with('error', 'Invalid report type');
-            }
-          break;
-      }
+                $rpid = $object->profile_id;
+                break;
+
+            case 'user':
+                $object = Profile::findOrFail($object_id);
+                $object_type = 'App\Profile';
+                $exists = Report::whereUserId(Auth::id())
+                    ->whereObjectId($object->id)
+                    ->whereObjectType('App\Profile')
+                    ->count();
+                $rpid = $object->id;
+                break;
+
+            case 'group':
+                $object = Group::findOrFail($object_id);
+                $object_type = 'App\Models\Group';
+                $exists = Report::whereUserId(Auth::id())
+                    ->whereObjectId($object->id)
+                    ->whereObjectType('App\Models\Group')
+                    ->count();
+                $rpid = $object->profile_id;
+                break;
+
+            default:
+                if ($request->wantsJson()) {
+                    return abort(400, 'Invalid report type');
+                } else {
+                    return redirect('/timeline')->with('error', 'Invalid report type');
+                }
+                break;
+        }
 
         if ($exists !== 0) {
-            if($request->wantsJson()) {
+            if ($request->wantsJson()) {
                 return response()->json(200);
             } else {
                 return redirect('/timeline')->with('error', 'You have already reported this!');
@@ -149,28 +196,28 @@ class ReportController extends Controller
         }
 
         if ($object->profile_id == $profile->id) {
-            if($request->wantsJson()) {
+            if ($request->wantsJson()) {
                 return response()->json(200);
             } else {
                 return redirect('/timeline')->with('error', 'You cannot report your own content!');
             }
         }
 
-        $report = new Report();
+        $report = new Report;
         $report->profile_id = $profile->id;
         $report->user_id = Auth::id();
         $report->object_id = $object->id;
         $report->object_type = $object_type;
-        $report->reported_profile_id = $object->profile_id;
+        $report->reported_profile_id = $rpid;
         $report->type = $request->input('report');
         $report->message = e($request->input('msg'));
         $report->save();
 
-        if(config('instance.reports.email.enabled')) {
-			ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
-		}
+        if (config('instance.reports.email.enabled')) {
+            ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
+        }
 
-        if($request->wantsJson()) {
+        if ($request->wantsJson()) {
             return response()->json(200);
         } else {
             return redirect('/timeline')->with('status', 'Report successfully sent!');

+ 5 - 0
app/Http/Controllers/SettingsController.php

@@ -350,4 +350,9 @@ class SettingsController extends Controller
 
         return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
     }
+
+    public function filtersHome(Request $request)
+    {
+        return view('settings.filters.home');
+    }
 }

+ 90 - 0
app/Jobs/NotificationPipeline/NotificationWarmUserCache.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace App\Jobs\NotificationPipeline;
+
+use App\Services\NotificationService;
+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\Support\Facades\Log;
+
+class NotificationWarmUserCache implements ShouldBeUnique, ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    /**
+     * The profile ID to warm cache for.
+     *
+     * @var int
+     */
+    public $pid;
+
+    /**
+     * The number of times the job may be attempted.
+     *
+     * @var int
+     */
+    public $tries = 3;
+
+    /**
+     * The number of seconds to wait before retrying the job.
+     * This creates exponential backoff: 10s, 30s, 90s
+     *
+     * @var array
+     */
+    public $backoff = [10, 30, 90];
+
+    /**
+     * The number of seconds after which the job's unique lock will be released.
+     *
+     * @var int
+     */
+    public $uniqueFor = 3600; // 1 hour
+
+    /**
+     * The maximum number of unhandled exceptions to allow before failing.
+     *
+     * @var int
+     */
+    public $maxExceptions = 2;
+
+    /**
+     * Create a new job instance.
+     *
+     * @param  int  $pid  The profile ID to warm cache for
+     * @return void
+     */
+    public function __construct(int $pid)
+    {
+        $this->pid = $pid;
+    }
+
+    /**
+     * Get the unique ID for the job.
+     */
+    public function uniqueId(): string
+    {
+        return 'notifications:profile_warm_cache:'.$this->pid;
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle(): void
+    {
+        try {
+            NotificationService::warmCache($this->pid, 100, true);
+        } catch (\Exception $e) {
+            Log::error('Failed to warm notification cache', [
+                'profile_id' => $this->pid,
+                'exception' => get_class($e),
+                'message' => $e->getMessage(),
+                'attempt' => $this->attempts(),
+            ]);
+            throw $e;
+        }
+    }
+}

+ 412 - 0
app/Models/CustomFilter.php

@@ -0,0 +1,412 @@
+<?php
+
+namespace App\Models;
+
+use App\Profile;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Cache;
+
+class CustomFilter extends Model
+{
+    public $shouldInvalidateCache = false;
+
+    protected $fillable = [
+        'title', 'phrase', 'context', 'expires_at', 'action', 'profile_id',
+    ];
+
+    protected $casts = [
+        'id' => 'string',
+        'context' => 'array',
+        'expires_at' => 'datetime',
+        'action' => 'integer',
+    ];
+
+    protected $guarded = ['shouldInvalidateCache'];
+
+    const VALID_CONTEXTS = [
+        'home',
+        'notifications',
+        'public',
+        'thread',
+        'account',
+    ];
+
+    const MAX_STATUSES_PER_FILTER = 10;
+
+    const EXPIRATION_DURATIONS = [
+        1800,   // 30 minutes
+        3600,   // 1 hour
+        21600,  // 6 hours
+        43200,  // 12 hours
+        86400,  // 1 day
+        604800, // 1 week
+    ];
+
+    const ACTION_WARN = 0;
+
+    const ACTION_HIDE = 1;
+
+    const ACTION_BLUR = 2;
+
+    protected static ?int $maxContentScanLimit = null;
+
+    protected static ?int $maxFiltersPerUser = null;
+
+    protected static ?int $maxKeywordsPerFilter = null;
+
+    protected static ?int $maxKeywordsLength = null;
+
+    protected static ?int $maxPatternLength = null;
+
+    protected static ?int $maxCreatePerHour = null;
+
+    protected static ?int $maxUpdatesPerHour = null;
+
+    public function account()
+    {
+        return $this->belongsTo(Profile::class, 'profile_id');
+    }
+
+    public function keywords()
+    {
+        return $this->hasMany(CustomFilterKeyword::class);
+    }
+
+    public function statuses()
+    {
+        return $this->hasMany(CustomFilterStatus::class);
+    }
+
+    public function toFilterArray()
+    {
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'context' => $this->context,
+            'expires_at' => $this->expires_at,
+            'filter_action' => $this->filterAction,
+        ];
+    }
+
+    public function getFilterActionAttribute()
+    {
+        switch ($this->action) {
+            case 0:
+                return 'warn';
+                break;
+
+            case 1:
+                return 'hide';
+                break;
+
+            case 2:
+                return 'blur';
+                break;
+        }
+    }
+
+    public function getTitleAttribute()
+    {
+        return $this->phrase;
+    }
+
+    public function setTitleAttribute($value)
+    {
+        $this->attributes['phrase'] = $value;
+    }
+
+    public function setFilterActionAttribute($value)
+    {
+        $this->attributes['action'] = $value;
+    }
+
+    public function setIrreversibleAttribute($value)
+    {
+        $this->attributes['action'] = $value ? self::ACTION_HIDE : self::ACTION_WARN;
+    }
+
+    public function getIrreversibleAttribute()
+    {
+        return $this->action === self::ACTION_HIDE;
+    }
+
+    public function getExpiresInAttribute()
+    {
+        if ($this->expires_at === null) {
+            return null;
+        }
+
+        $now = now();
+        foreach (self::EXPIRATION_DURATIONS as $duration) {
+            if ($now->addSeconds($duration)->gte($this->expires_at)) {
+                return $duration;
+            }
+        }
+
+        return null;
+    }
+
+    public function scopeUnexpired($query)
+    {
+        return $query->where(function ($q) {
+            $q->whereNull('expires_at')
+                ->orWhere('expires_at', '>', now());
+        });
+    }
+
+    public function isExpired()
+    {
+        return $this->expires_at !== null && $this->expires_at->isPast();
+    }
+
+    protected static function boot()
+    {
+        parent::boot();
+
+        static::saving(function ($model) {
+            $model->prepareContextForStorage();
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::updating(function ($model) {
+            $model->prepareContextForStorage();
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::deleting(function ($model) {
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::saved(function ($model) {
+            $model->invalidateCache();
+        });
+
+        static::deleted(function ($model) {
+            $model->invalidateCache();
+        });
+    }
+
+    protected function prepareContextForStorage()
+    {
+        if (is_array($this->context)) {
+            $this->context = array_values(array_filter(array_map('trim', $this->context)));
+        }
+    }
+
+    protected function invalidateCache()
+    {
+        if (! isset($this->shouldInvalidateCache) || ! $this->shouldInvalidateCache) {
+            return;
+        }
+
+        $this->shouldInvalidateCache = false;
+
+        Cache::forget("filters:v3:{$this->profile_id}");
+    }
+
+    public static function getMaxContentScanLimit(): int
+    {
+        if (self::$maxContentScanLimit === null) {
+            self::$maxContentScanLimit = config('instance.custom_filters.max_content_scan_limit', 2500);
+        }
+
+        return self::$maxContentScanLimit;
+    }
+
+    public static function getMaxFiltersPerUser(): int
+    {
+        if (self::$maxFiltersPerUser === null) {
+            self::$maxFiltersPerUser = config('instance.custom_filters.max_filters_per_user', 20);
+        }
+
+        return self::$maxFiltersPerUser;
+    }
+
+    public static function getMaxKeywordsPerFilter(): int
+    {
+        if (self::$maxKeywordsPerFilter === null) {
+            self::$maxKeywordsPerFilter = config('instance.custom_filters.max_keywords_per_filter', 10);
+        }
+
+        return self::$maxKeywordsPerFilter;
+    }
+
+    public static function getMaxKeywordLength(): int
+    {
+        if (self::$maxKeywordsLength === null) {
+            self::$maxKeywordsLength = config('instance.custom_filters.max_keyword_length', 40);
+        }
+
+        return self::$maxKeywordsLength;
+    }
+
+    public static function getMaxPatternLength(): int
+    {
+        if (self::$maxPatternLength === null) {
+            self::$maxPatternLength = config('instance.custom_filters.max_pattern_length', 10000);
+        }
+
+        return self::$maxPatternLength;
+    }
+
+    public static function getMaxCreatePerHour(): int
+    {
+        if (self::$maxCreatePerHour === null) {
+            self::$maxCreatePerHour = config('instance.custom_filters.max_create_per_hour', 20);
+        }
+
+        return self::$maxCreatePerHour;
+    }
+
+    public static function getMaxUpdatesPerHour(): int
+    {
+        if (self::$maxUpdatesPerHour === null) {
+            self::$maxUpdatesPerHour = config('instance.custom_filters.max_updates_per_hour', 40);
+        }
+
+        return self::$maxUpdatesPerHour;
+    }
+
+    /**
+     * Get cached filters for an account with simplified, secure approach
+     *
+     * @param  int  $profileId  The profile ID
+     * @return Collection The collection of filters
+     */
+    public static function getCachedFiltersForAccount($profileId)
+    {
+        $activeFilters = Cache::remember("filters:v3:{$profileId}", 3600, function () use ($profileId) {
+            $filtersHash = [];
+
+            $keywordFilters = CustomFilterKeyword::with(['customFilter' => function ($query) use ($profileId) {
+                $query->unexpired()->where('profile_id', $profileId);
+            }])->get();
+
+            $keywordFilters->groupBy('custom_filter_id')->each(function ($keywords, $filterId) use (&$filtersHash) {
+                $filter = $keywords->first()->customFilter;
+
+                if (! $filter) {
+                    return;
+                }
+
+                $maxPatternsPerFilter = self::getMaxFiltersPerUser();
+                $keywordsToProcess = $keywords->take($maxPatternsPerFilter);
+
+                $regexPatterns = $keywordsToProcess->map(function ($keyword) {
+                    $pattern = preg_quote($keyword->keyword, '/');
+
+                    if ($keyword->whole_word) {
+                        $pattern = '\b'.$pattern.'\b';
+                    }
+
+                    return $pattern;
+                })->toArray();
+
+                if (empty($regexPatterns)) {
+                    return;
+                }
+
+                $combinedPattern = implode('|', $regexPatterns);
+                $maxPatternLength = self::getMaxPatternLength();
+                if (strlen($combinedPattern) > $maxPatternLength) {
+                    $combinedPattern = substr($combinedPattern, 0, $maxPatternLength);
+                }
+
+                $filtersHash[$filterId] = [
+                    'keywords' => '/'.$combinedPattern.'/i',
+                    'filter' => $filter,
+                ];
+            });
+
+            // $statusFilters = CustomFilterStatus::with(['customFilter' => function ($query) use ($profileId) {
+            //     $query->unexpired()->where('profile_id', $profileId);
+            // }])->get();
+
+            // $statusFilters->groupBy('custom_filter_id')->each(function ($statuses, $filterId) use (&$filtersHash) {
+            //     $filter = $statuses->first()->customFilter;
+
+            //     if (! $filter) {
+            //         return;
+            //     }
+
+            //     if (! isset($filtersHash[$filterId])) {
+            //         $filtersHash[$filterId] = ['filter' => $filter];
+            //     }
+
+            //     $maxStatusIds = self::MAX_STATUSES_PER_FILTER;
+            //     $filtersHash[$filterId]['status_ids'] = $statuses->take($maxStatusIds)->pluck('status_id')->toArray();
+            // });
+
+            return array_map(function ($item) {
+                $filter = $item['filter'];
+                unset($item['filter']);
+
+                return [$filter, $item];
+            }, $filtersHash);
+        });
+
+        return collect($activeFilters)->reject(function ($item) {
+            [$filter, $rules] = $item;
+
+            return $filter->isExpired();
+        })->toArray();
+    }
+
+    /**
+     * Apply cached filters to a status with reasonable safety measures
+     *
+     * @param  array  $cachedFilters  The cached filters
+     * @param  mixed  $status  The status to check
+     * @return array The filter matches
+     */
+    public static function applyCachedFilters($cachedFilters, $status)
+    {
+        $results = [];
+
+        foreach ($cachedFilters as [$filter, $rules]) {
+            $keywordMatches = [];
+            $statusMatches = null;
+
+            if (isset($rules['keywords'])) {
+                $text = strip_tags($status['content']);
+
+                $maxContentLength = self::getMaxContentScanLimit();
+                if (mb_strlen($text) > $maxContentLength) {
+                    $text = mb_substr($text, 0, $maxContentLength);
+                }
+
+                try {
+                    preg_match_all($rules['keywords'], $text, $matches, PREG_PATTERN_ORDER, 0);
+                    if (! empty($matches[0])) {
+                        $maxReportedMatches = (int) config('instance.custom_filters.max_reported_matches', 10);
+                        $keywordMatches = array_slice($matches[0], 0, $maxReportedMatches);
+                    }
+                } catch (\Throwable $e) {
+                    \Log::error('Filter regex error: '.$e->getMessage(), [
+                        'filter_id' => $filter->id,
+                    ]);
+                }
+            }
+
+            // if (isset($rules['status_ids'])) {
+            //     $statusId = $status->id;
+            //     $reblogId = $status->reblog_of_id ?? null;
+
+            //     $matchingIds = array_intersect($rules['status_ids'], array_filter([$statusId, $reblogId]));
+            //     if (! empty($matchingIds)) {
+            //         $statusMatches = $matchingIds;
+            //     }
+            // }
+
+            if (! empty($keywordMatches) || ! empty($statusMatches)) {
+                $results[] = [
+                    'filter' => $filter->toFilterArray(),
+                    'keyword_matches' => $keywordMatches ?: null,
+                    'status_matches' => ! empty($statusMatches) ? $statusMatches : null,
+                ];
+            }
+        }
+
+        return $results;
+    }
+}

+ 37 - 0
app/Models/CustomFilterKeyword.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class CustomFilterKeyword extends Model
+{
+    protected $fillable = [
+        'keyword', 'whole_word', 'custom_filter_id',
+    ];
+
+    protected $casts = [
+        'whole_word' => 'boolean',
+    ];
+
+    public function customFilter()
+    {
+        return $this->belongsTo(CustomFilter::class);
+    }
+
+    public function setKeywordAttribute($value)
+    {
+        $this->attributes['keyword'] = mb_strtolower(trim($value));
+    }
+
+    public function toRegex()
+    {
+        $pattern = preg_quote($this->keyword, '/');
+
+        if ($this->whole_word) {
+            $pattern = '\b'.$pattern.'\b';
+        }
+
+        return '/'.$pattern.'/i';
+    }
+}

+ 23 - 0
app/Models/CustomFilterStatus.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Models;
+
+use App\Status;
+use Illuminate\Database\Eloquent\Model;
+
+class CustomFilterStatus extends Model
+{
+    protected $fillable = [
+        'custom_filter_id', 'status_id',
+    ];
+
+    public function customFilter()
+    {
+        return $this->belongsTo(CustomFilter::class);
+    }
+
+    public function status()
+    {
+        return $this->belongsTo(Status::class);
+    }
+}

+ 25 - 0
app/Models/UserOidcMapping.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Models;
+
+use App\User;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+
+class UserOidcMapping extends Model
+{
+    use HasFactory;
+
+    public $timestamps = true;
+
+    protected $fillable = [
+        'user_id',
+        'oidc_id',
+    ];
+
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+}

+ 61 - 0
app/Policies/CustomFilterPolicy.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\CustomFilter;
+use App\User;
+
+class CustomFilterPolicy
+{
+    /**
+     * Determine whether the user can view any models.
+     */
+    public function viewAny(User $user): bool
+    {
+        return false;
+    }
+
+    /**
+     * Determine whether the user can view the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function view(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+
+    /**
+     * Determine whether the user can create models.
+     */
+    public function create(User $user): bool
+    {
+        return CustomFilter::whereProfileId($user->profile_id)->count() <= 100;
+    }
+
+    /**
+     * Determine whether the user can update the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function update(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+
+    /**
+     * Determine whether the user can delete the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function delete(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+}

+ 4 - 1
app/Providers/AppServiceProvider.php

@@ -21,6 +21,7 @@ use App\Observers\UserFilterObserver;
 use App\Observers\UserObserver;
 use App\Profile;
 use App\Services\AccountService;
+use App\Services\UserOidcService;
 use App\Status;
 use App\StatusHashtag;
 use App\User;
@@ -112,6 +113,8 @@ class AppServiceProvider extends ServiceProvider
      */
     public function register()
     {
-        //
+        $this->app->bind(UserOidcService::class, function() {
+            return UserOidcService::build();
+        });
     }
 }

+ 3 - 1
app/Providers/AuthServiceProvider.php

@@ -5,6 +5,8 @@ namespace App\Providers;
 use Gate;
 use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
 use Laravel\Passport\Passport;
+use App\Models\CustomFilter;
+use App\Policies\CustomFilterPolicy;
 
 class AuthServiceProvider extends ServiceProvider
 {
@@ -14,7 +16,7 @@ class AuthServiceProvider extends ServiceProvider
      * @var array
      */
     protected $policies = [
-        // 'App\Model' => 'App\Policies\ModelPolicy',
+        CustomFilter::class => CustomFilterPolicy::class,
     ];
 
     /**

+ 25 - 0
app/Rules/EmailNotBanned.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use App\Services\EmailService;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class EmailNotBanned implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
+     * @return void
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        if (EmailService::isBanned($value)) {
+            $fail('Email is invalid.');
+        }
+    }
+}

+ 57 - 0
app/Rules/PixelfedUsername.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use App\Util\Lexer\RestrictedNames;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class PixelfedUsername implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
+     * @return void
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        $dash = substr_count($value, '-');
+        $underscore = substr_count($value, '_');
+        $period = substr_count($value, '.');
+
+        if (ends_with($value, ['.php', '.js', '.css'])) {
+            $fail('Username is invalid.');
+            return;
+        }
+
+        if (($dash + $underscore + $period) > 1) {
+            $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
+            return;
+        }
+
+        if (! ctype_alnum($value[0])) {
+            $fail('Username is invalid. Must start with a letter or number.');
+            return;
+        }
+
+        if (! ctype_alnum($value[strlen($value) - 1])) {
+            $fail('Username is invalid. Must end with a letter or number.');
+            return;
+        }
+
+        $val = str_replace(['_', '.', '-'], '', $value);
+        if (! ctype_alnum($val)) {
+            $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
+            return;
+        }
+
+        $restricted = RestrictedNames::get();
+        if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
+            $fail('Username cannot be used.');
+            return;
+        }
+    }
+}

+ 62 - 0
app/Rules/Webfinger.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+
+class WebFinger implements Rule
+{
+    /**
+     * Determine if the validation rule passes.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @return bool
+     */
+    public function passes($attribute, $value)
+    {
+        if (! is_string($value)) {
+            return false;
+        }
+
+        $mention = $value;
+        if (str_starts_with($mention, '@')) {
+            $mention = substr($mention, 1);
+        }
+
+        $parts = explode('@', $mention);
+        if (count($parts) !== 2) {
+            return false;
+        }
+
+        [$username, $domain] = $parts;
+
+        if (empty($username) ||
+            ! preg_match('/^[a-zA-Z0-9_.-]+$/', $username) ||
+            strlen($username) >= 80) {
+            return false;
+        }
+
+        if (empty($domain) ||
+            ! str_contains($domain, '.') ||
+            ! preg_match('/^[a-zA-Z0-9.-]+$/', $domain) ||
+            strlen($domain) >= 255) {
+            return false;
+        }
+
+        // Optional: Check if domain resolves (can be enabled for stricter validation)
+        // return checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA') || checkdnsrr($domain, 'MX');
+
+        return true;
+    }
+
+    /**
+     * Get the validation error message.
+     *
+     * @return string
+     */
+    public function message()
+    {
+        return 'The :attribute must be a valid WebFinger address (username@domain.tld or @username@domain.tld)';
+    }
+}

+ 1 - 0
app/Services/LandingService.php

@@ -52,6 +52,7 @@ class LandingService
             'domain' => config('pixelfed.domain.app'),
             'show_directory' => (bool) config_cache('instance.landing.show_directory'),
             'show_explore_feed' => (bool) config_cache('instance.landing.show_explore'),
+            'show_legal_notice_link' => (bool) config('instance.has_legal_notice'),
             'open_registration' => (bool) $openReg,
             'curated_onboarding' => (bool) config_cache('instance.curated_registration.enabled'),
             'version' => config('pixelfed.version'),

+ 103 - 99
app/Services/RelationshipService.php

@@ -2,116 +2,120 @@
 
 namespace App\Services;
 
-use Illuminate\Support\Facades\Cache;
 use App\Follower;
 use App\FollowRequest;
-use App\Profile;
 use App\UserFilter;
+use Illuminate\Support\Facades\Cache;
 
 class RelationshipService
 {
-	const CACHE_KEY = 'pf:services:urel:';
-
-	public static function get($aid, $tid)
-	{
-		$actor = AccountService::get($aid, true);
-		$target = AccountService::get($tid, true);
-		if(!$actor || !$target) {
-			return self::defaultRelation($tid);
-		}
-
-		if($actor['id'] === $target['id']) {
-			return self::defaultRelation($tid);
-		}
-
-		return Cache::remember(self::key("a_{$aid}:t_{$tid}"), 1209600, function() use($aid, $tid) {
-			return [
-				'id' => (string) $tid,
-				'following' => Follower::whereProfileId($aid)->whereFollowingId($tid)->exists(),
-				'followed_by' => Follower::whereProfileId($tid)->whereFollowingId($aid)->exists(),
-				'blocking' => UserFilter::whereUserId($aid)
-					->whereFilterableType('App\Profile')
-					->whereFilterableId($tid)
-					->whereFilterType('block')
-					->exists(),
-				'muting' => UserFilter::whereUserId($aid)
-					->whereFilterableType('App\Profile')
-					->whereFilterableId($tid)
-					->whereFilterType('mute')
-					->exists(),
-				'muting_notifications' => null,
-				'requested' => FollowRequest::whereFollowerId($aid)
-					->whereFollowingId($tid)
-					->exists(),
-				'domain_blocking' => null,
-				'showing_reblogs' => null,
-				'endorsed' => false
-			];
-		});
-	}
-
-	public static function delete($aid, $tid)
-	{
-		Cache::forget(self::key("wd:a_{$aid}:t_{$tid}"));
-		return Cache::forget(self::key("a_{$aid}:t_{$tid}"));
-	}
-
-	public static function refresh($aid, $tid)
-	{
-		Cache::forget('pf:services:follower:audience:' . $aid);
-		Cache::forget('pf:services:follower:audience:' . $tid);
-		self::delete($tid, $aid);
-		self::delete($aid, $tid);
-		self::get($tid, $aid);
-		return self::get($aid, $tid);
-	}
-
-	public static function forget($aid, $tid)
-	{
-		Cache::forget('pf:services:follower:audience:' . $aid);
-		Cache::forget('pf:services:follower:audience:' . $tid);
-		self::delete($tid, $aid);
-		self::delete($aid, $tid);
-	}
-
-	public static function defaultRelation($tid)
-	{
-		return [
+    const CACHE_KEY = 'pf:services:urel:';
+
+    public static function get($aid, $tid)
+    {
+        $actor = AccountService::get($aid, true);
+        $target = AccountService::get($tid, true);
+        if (! $actor || ! $target) {
+            return self::defaultRelation($tid);
+        }
+
+        if ($actor['id'] === $target['id']) {
+            return self::defaultRelation($tid);
+        }
+
+        return Cache::remember(self::key("a_{$aid}:t_{$tid}"), 1209600, function () use ($aid, $tid) {
+            return [
+                'id' => (string) $tid,
+                'following' => Follower::whereProfileId($aid)->whereFollowingId($tid)->exists(),
+                'followed_by' => Follower::whereProfileId($tid)->whereFollowingId($aid)->exists(),
+                'blocking' => UserFilter::whereUserId($aid)
+                    ->whereFilterableType('App\Profile')
+                    ->whereFilterableId($tid)
+                    ->whereFilterType('block')
+                    ->exists(),
+                'muting' => UserFilter::whereUserId($aid)
+                    ->whereFilterableType('App\Profile')
+                    ->whereFilterableId($tid)
+                    ->whereFilterType('mute')
+                    ->exists(),
+                'muting_notifications' => false,
+                'requested' => FollowRequest::whereFollowerId($aid)
+                    ->whereFollowingId($tid)
+                    ->exists(),
+                'domain_blocking' => false,
+                'showing_reblogs' => false,
+                'endorsed' => false,
+            ];
+        });
+    }
+
+    public static function delete($aid, $tid)
+    {
+        Cache::forget(self::key("wd:a_{$aid}:t_{$tid}"));
+
+        return Cache::forget(self::key("a_{$aid}:t_{$tid}"));
+    }
+
+    public static function refresh($aid, $tid)
+    {
+        Cache::forget('pf:services:follower:audience:'.$aid);
+        Cache::forget('pf:services:follower:audience:'.$tid);
+        self::delete($tid, $aid);
+        self::delete($aid, $tid);
+        self::get($tid, $aid);
+
+        return self::get($aid, $tid);
+    }
+
+    public static function forget($aid, $tid)
+    {
+        Cache::forget('pf:services:follower:audience:'.$aid);
+        Cache::forget('pf:services:follower:audience:'.$tid);
+        self::delete($tid, $aid);
+        self::delete($aid, $tid);
+    }
+
+    public static function defaultRelation($tid)
+    {
+        return [
             'id' => (string) $tid,
             'following' => false,
             'followed_by' => false,
             'blocking' => false,
             'muting' => false,
-            'muting_notifications' => null,
+            'muting_notifications' => false,
             'requested' => false,
-            'domain_blocking' => null,
-            'showing_reblogs' => null,
-            'endorsed' => false
+            'domain_blocking' => false,
+            'showing_reblogs' => false,
+            'endorsed' => false,
         ];
-	}
-
-	protected static function key($suffix)
-	{
-		return self::CACHE_KEY . $suffix;
-	}
-
-	public static function getWithDate($aid, $tid)
-	{
-		$res = self::get($aid, $tid);
-
-		if(!$res || !$res['following']) {
-			$res['following_since'] = null;
-			return $res;
-		}
-
-		return Cache::remember(self::key("wd:a_{$aid}:t_{$tid}"), 1209600, function() use($aid, $tid, $res) {
-			$tmp = Follower::whereProfileId($aid)->whereFollowingId($tid)->first();
-			if(!$tmp) {
-				$res['following_since'] = null;
-				return $res;
-			}
-			$res['following_since'] = str_replace('+00:00', 'Z', $tmp->created_at->format(DATE_RFC3339_EXTENDED));
-			return $res;
-		});
-	}
+    }
+
+    protected static function key($suffix)
+    {
+        return self::CACHE_KEY.$suffix;
+    }
+
+    public static function getWithDate($aid, $tid)
+    {
+        $res = self::get($aid, $tid);
+
+        if (! $res || ! $res['following']) {
+            $res['following_since'] = null;
+
+            return $res;
+        }
+
+        return Cache::remember(self::key("wd:a_{$aid}:t_{$tid}"), 1209600, function () use ($aid, $tid, $res) {
+            $tmp = Follower::whereProfileId($aid)->whereFollowingId($tid)->first();
+            if (! $tmp) {
+                $res['following_since'] = null;
+
+                return $res;
+            }
+            $res['following_since'] = str_replace('+00:00', 'Z', $tmp->created_at->format(DATE_RFC3339_EXTENDED));
+
+            return $res;
+        });
+    }
 }

+ 15 - 3
app/Services/SearchApiV2Service.php

@@ -132,7 +132,6 @@ class SearchApiV2Service
         $q = $this->query->input('q');
         $limit = $this->query->input('limit') ?? 20;
         $offset = $this->query->input('offset') ?? 0;
-
         $query = Str::startsWith($q, '#') ? substr($q, 1) : $q;
         $query = $query.'%';
 
@@ -214,6 +213,9 @@ class SearchApiV2Service
         $user = request()->user();
         $mastodonMode = self::$mastodonMode;
         $query = urldecode($this->query->input('q'));
+        $limit = $this->query->input('limit') ?? 20;
+        $offset = $this->query->input('offset') ?? 0;
+
         $banned = InstanceService::getBannedDomains();
         $domainBlocks = UserFilterService::domainBlocks($user->profile_id);
         if ($domainBlocks && count($domainBlocks)) {
@@ -252,7 +254,12 @@ class SearchApiV2Service
                     if (in_array($domain, $banned)) {
                         return $default;
                     }
-                    $default['accounts'][] = $res;
+                    $paginated = collect($res)->take($limit)->skip($offset)->toArray();
+                    if (! empty($paginated)) {
+                        $default['accounts'][] = $paginated;
+                    } else {
+                        $default['accounts'] = [];
+                    }
 
                     return $default;
                 } else {
@@ -271,7 +278,12 @@ class SearchApiV2Service
                     if (in_array($domain, $banned)) {
                         return $default;
                     }
-                    $default['accounts'][] = $res;
+                    $paginated = collect($res)->take($limit)->skip($offset)->toArray();
+                    if (! empty($paginated)) {
+                        $default['accounts'][] = $paginated;
+                    } else {
+                        $default['accounts'] = [];
+                    }
 
                     return $default;
                 } else {

+ 87 - 1
app/Services/StatusService.php

@@ -12,6 +12,8 @@ class StatusService
 {
     const CACHE_KEY = 'pf:services:status:v1.1:';
 
+    const MAX_PINNED = 3;
+
     public static function key($id, $publicOnly = true)
     {
         $p = $publicOnly ? 'pub:' : 'all:';
@@ -82,7 +84,6 @@ class StatusService
             $status['shortcode'],
             $status['taggedPeople'],
             $status['thread'],
-            $status['pinned'],
             $status['account']['header_bg'],
             $status['account']['is_admin'],
             $status['account']['last_fetched_at'],
@@ -198,4 +199,89 @@ class StatusService
     {
         return InstanceService::totalLocalStatuses();
     }
+
+    public static function isPinned($id)
+    {
+        return Status::whereId($id)->whereNotNull('pinned_order')->exists();
+    }
+
+    public static function totalPins($pid)
+    {
+        return Status::whereProfileId($pid)->whereNotNull('pinned_order')->count();
+    }
+
+    public static function markPin($id)
+    {
+        $status = Status::find($id);
+
+        if (! $status) {
+            return [
+                'success' => false,
+                'error' => 'Record not found',
+            ];
+        }
+
+        if ($status->scope != 'public') {
+            return [
+                'success' => false,
+                'error' => 'Validation failed: you can only pin public posts',
+            ];
+        }
+
+        if (self::isPinned($id)) {
+            return [
+                'success' => false,
+                'error' => 'This post is already pinned',
+            ];
+        }
+
+        $totalPins = self::totalPins($status->profile_id);
+
+        if ($totalPins >= self::MAX_PINNED) {
+            return [
+                'success' => false,
+                'error' => 'Validation failed: You have already pinned the max number of posts',
+            ];
+        }
+
+        $status->pinned_order = $totalPins + 1;
+        $status->save();
+
+        self::refresh($id);
+
+        return [
+            'success' => true,
+            'error' => null,
+        ];
+    }
+
+    public static function unmarkPin($id)
+    {
+        $status = Status::find($id);
+
+        if (! $status || is_null($status->pinned_order)) {
+            return false;
+        }
+
+        $removedOrder = $status->pinned_order;
+        $profileId = $status->profile_id;
+
+        $status->pinned_order = null;
+        $status->save();
+
+        Status::where('profile_id', $profileId)
+            ->whereNotNull('pinned_order')
+            ->where('pinned_order', '>', $removedOrder)
+            ->orderBy('pinned_order', 'asc')
+            ->chunk(10, function ($statuses) {
+                foreach ($statuses as $s) {
+                    $s->pinned_order = $s->pinned_order - 1;
+                    $s->save();
+                }
+            });
+
+        self::refresh($id);
+
+        return true;
+    }
 }

+ 21 - 0
app/Services/UserOidcService.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Services;
+
+use League\OAuth2\Client\Provider\GenericProvider;
+
+class UserOidcService extends GenericProvider {
+    public static function build()
+    {
+        return new UserOidcService([
+            'clientId' => config('remote-auth.oidc.clientId'),
+            'clientSecret' => config('remote-auth.oidc.clientSecret'),
+            'redirectUri' => url('auth/oidc/callback'),
+            'urlAuthorize' => config('remote-auth.oidc.authorizeURL'),
+            'urlAccessToken' => config('remote-auth.oidc.tokenURL'),
+            'urlResourceOwnerDetails' => config('remote-auth.oidc.profileURL'),
+            'scopes' => config('remote-auth.oidc.scopes'),
+            'responseResourceOwnerId' => config('remote-auth.oidc.field_id'),
+        ]);
+    }
+}

+ 16 - 0
app/Services/WebfingerService.php

@@ -11,10 +11,26 @@ class WebfingerService
 {
     public static function rawGet($url)
     {
+        if (empty($url)) {
+            return false;
+        }
+
         $n = WebfingerUrl::get($url);
+
         if (! $n) {
             return false;
         }
+        if (empty($n) || ! str_starts_with($n, 'https://')) {
+            return false;
+        }
+        $host = parse_url($n, PHP_URL_HOST);
+        if (! $host) {
+            return false;
+        }
+
+        if (in_array($host, InstanceService::getBannedDomains())) {
+            return false;
+        }
         $webfinger = FetchCacheService::getJson($n);
         if (! $webfinger) {
             return false;

+ 17 - 10
app/Transformer/Api/RelationshipTransformer.php

@@ -2,11 +2,10 @@
 
 namespace App\Transformer\Api;
 
+use App\FollowRequest;
+use App\Models\UserDomainBlock;
+use App\Profile;
 use Auth;
-use App\{
-    FollowRequest,
-    Profile
-};
 use League\Fractal;
 
 class RelationshipTransformer extends Fractal\TransformerAbstract
@@ -14,27 +13,35 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
     public function transform(Profile $profile)
     {
         $auth = Auth::check();
-        if(!$auth) {
+        if (! $auth) {
             return [];
         }
         $user = $auth ? Auth::user()->profile : false;
         $requested = false;
-        if($user) {
+        $domainBlocking = false;
+        if ($user) {
             $requested = FollowRequest::whereFollowerId($user->id)
                 ->whereFollowingId($profile->id)
                 ->exists();
+
+            if ($profile->domain) {
+                $domainBlocking = UserDomainBlock::whereProfileId($user->id)
+                    ->whereDomain($profile->domain)
+                    ->exists();
+            }
         }
+
         return [
             'id' => (string) $profile->id,
             'following' => $auth ? $user->follows($profile) : false,
             'followed_by' => $auth ? $user->followedBy($profile) : false,
             'blocking' => $auth ? $user->blockedIds()->contains($profile->id) : false,
             'muting' => $auth ? $user->mutedIds()->contains($profile->id) : false,
-            'muting_notifications' => null,
+            'muting_notifications' => false,
             'requested' => $requested,
-            'domain_blocking' => null,
-            'showing_reblogs' => null,
-            'endorsed' => false
+            'domain_blocking' => $domainBlocking,
+            'showing_reblogs' => false,
+            'endorsed' => false,
         ];
     }
 }

+ 1 - 0
app/Transformer/Api/StatusStatelessTransformer.php

@@ -69,6 +69,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
             'tags' => StatusHashtagService::statusTags($status->id),
             'poll' => $poll,
             'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
+            'pinned' => (bool) $status->pinned_order,
         ];
     }
 }

+ 1 - 0
app/Transformer/Api/StatusTransformer.php

@@ -71,6 +71,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
             'poll' => $poll,
             'bookmarked' => BookmarkService::get($pid, $status->id),
             'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
+            'pinned' => (bool) $status->pinned_order,
         ];
     }
 }

+ 3 - 2
app/Util/ActivityPub/Helpers.php

@@ -599,7 +599,7 @@ class Helpers
         $reply_to = self::getReplyTo($activity);
         $ts = self::pluckval($activity['published']);
         $scope = self::getScope($activity, $url);
-        $commentsDisabled = isset($activity['commentsEnabled']) ? (bool)$activity['commentsEnabled'] == false : false;
+        $commentsDisabled = isset($activity['commentsEnabled']) ? (bool) $activity['commentsEnabled'] == false : false;
         $cw = self::getSensitive($activity, $url);
 
         if ($profile->unlisted) {
@@ -1278,8 +1278,9 @@ class Helpers
             'inbox_url' => $res['inbox'],
             'outbox_url' => $res['outbox'] ?? null,
             'public_key' => $res['publicKey']['publicKeyPem'],
-            'indexable' => $res['indexable'] ?? false,
+            'indexable' => isset($res['indexable']) ? (bool) $res['indexable'] : false,
             'moved_to_profile_id' => $movedToPid,
+            'is_private' => isset($res['manuallyApprovesFollowers']) ? (bool) $res['manuallyApprovesFollowers'] : true,
         ];
     }
 

+ 43 - 5
app/Util/ActivityPub/Inbox.php

@@ -32,6 +32,7 @@ use App\Services\NotificationAppGatewayService;
 use App\Services\PollService;
 use App\Services\PushNotificationService;
 use App\Services\ReblogService;
+use App\Services\RelationshipService;
 use App\Services\UserFilterService;
 use App\Status;
 use App\Story;
@@ -42,6 +43,7 @@ use App\Util\ActivityPub\Validator\Announce as AnnounceValidator;
 use App\Util\ActivityPub\Validator\Follow as FollowValidator;
 use App\Util\ActivityPub\Validator\Like as LikeValidator;
 use App\Util\ActivityPub\Validator\MoveValidator;
+use App\Util\ActivityPub\Validator\RejectValidator;
 use App\Util\ActivityPub\Validator\UpdatePersonValidator;
 use Cache;
 use Illuminate\Support\Facades\Bus;
@@ -120,6 +122,9 @@ class Inbox
                 break;
 
             case 'Reject':
+                if (RejectValidator::validate($this->payload) == false) {
+                    return;
+                }
                 $this->handleRejectActivity();
                 break;
 
@@ -245,7 +250,7 @@ class Inbox
         $cc = isset($activity['cc']) ? $activity['cc'] : [];
 
         if ($activity['type'] == 'Question') {
-            //$this->handlePollCreate();
+            // $this->handlePollCreate();
 
             return;
         }
@@ -614,6 +619,9 @@ class Inbox
             Cache::forget('profile:following_count:'.$target->id);
             Cache::forget('profile:following_count:'.$actor->id);
         }
+        RelationshipService::refresh($actor->id, $target->id);
+        AccountService::del($actor->id);
+        AccountService::del($target->id);
 
     }
 
@@ -706,10 +714,20 @@ class Inbox
             'profile_id' => $actor->id,
             'following_id' => $target->id,
         ]);
-        FollowPipeline::dispatch($follower);
-
+        FollowPipeline::dispatch($follower)->onQueue('high');
+        RelationshipService::refresh($actor->id, $target->id);
+        Cache::forget('profile:following:'.$target->id);
+        Cache::forget('profile:followers:'.$target->id);
+        Cache::forget('profile:following:'.$actor->id);
+        Cache::forget('profile:followers:'.$actor->id);
+        Cache::forget('profile:follower_count:'.$target->id);
+        Cache::forget('profile:follower_count:'.$actor->id);
+        Cache::forget('profile:following_count:'.$target->id);
+        Cache::forget('profile:following_count:'.$actor->id);
+        AccountService::del($actor->id);
+        AccountService::del($target->id);
+        RelationshipService::get($actor->id, $target->id);
         $request->delete();
-
     }
 
     public function handleDeleteActivity()
@@ -843,7 +861,21 @@ class Inbox
 
     }
 
-    public function handleRejectActivity() {}
+    public function handleRejectActivity()
+    {
+        $actorUrl = $this->payload['actor'];
+        $obj = $this->payload['object'];
+        $profileUrl = $obj['actor'];
+        if (! Helpers::validateUrl($actorUrl) || ! Helpers::validateLocalUrl($profileUrl)) {
+            return;
+        }
+        $actor = Helpers::profileFetch($actorUrl);
+        $profile = Helpers::profileFetch($profileUrl);
+
+        FollowRequest::whereFollowerId($profile->id)->whereFollowingId($actor->id)->forceDelete();
+        RelationshipService::refresh($actor->id, $profile->id);
+
+    }
 
     public function handleUndoActivity()
     {
@@ -909,6 +941,9 @@ class Inbox
                 Follower::whereProfileId($profile->id)
                     ->whereFollowingId($following->id)
                     ->delete();
+                FollowRequest::whereFollowingId($following->id)
+                    ->whereFollowerId($profile->id)
+                    ->forceDelete();
                 Notification::whereProfileId($following->id)
                     ->whereActorId($profile->id)
                     ->whereAction('follow')
@@ -916,6 +951,9 @@ class Inbox
                     ->whereItemType('App\Profile')
                     ->forceDelete();
                 FollowerService::remove($profile->id, $following->id);
+                RelationshipService::refresh($following->id, $profile->id);
+                AccountService::del($profile->id);
+                AccountService::del($following->id);
                 break;
 
             case 'Like':

+ 31 - 0
app/Util/ActivityPub/Validator/RejectValidator.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Util\ActivityPub\Validator;
+
+use Illuminate\Validation\Rule;
+use Validator;
+
+class RejectValidator
+{
+    public static function validate($payload)
+    {
+        $valid = Validator::make($payload, [
+            '@context' => 'required',
+            'id' => 'required|string',
+            'type' => [
+                'required',
+                Rule::in(['Reject']),
+            ],
+            'actor' => 'required|url',
+            'object.id' => 'required|url',
+            'object.actor' => 'required|url',
+            'object.object' => 'required|url',
+            'object.type' => [
+                'required',
+                Rule::in(['Follow']),
+            ],
+        ])->passes();
+
+        return $valid;
+    }
+}

+ 1 - 0
app/Util/Site/Config.php

@@ -29,6 +29,7 @@ class Config
             return [
                 'version' => config('pixelfed.version'),
                 'open_registration' => (bool) config_cache('pixelfed.open_registration'),
+                'show_legal_notice_link' => (bool) config('instance.has_legal_notice'),
                 'uploader' => [
                     'max_photo_size' => (int) config_cache('pixelfed.max_photo_size'),
                     'max_caption_length' => (int) config_cache('pixelfed.max_caption_length'),

+ 1 - 0
composer.json

@@ -31,6 +31,7 @@
         "laravel/ui": "^4.2",
         "league/flysystem-aws-s3-v3": "^3.0",
         "league/iso3166": "^2.1|^4.0",
+        "league/oauth2-client": "^2.8",
         "league/uri": "^7.4",
         "pbmedia/laravel-ffmpeg": "^8.0",
         "phpseclib/phpseclib": "~2.0",

+ 68 - 3
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "a011d3030ab0153865ef4cd6a7b615a3",
+    "content-hash": "ac363dfc5037ce5d118b7b4a8e75bffe",
     "packages": [
         {
             "name": "aws/aws-crt-php",
@@ -3872,6 +3872,71 @@
             ],
             "time": "2024-09-21T08:32:55+00:00"
         },
+        {
+            "name": "league/oauth2-client",
+            "version": "2.8.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/oauth2-client.git",
+                "reference": "9df2924ca644736c835fc60466a3a60390d334f9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/9df2924ca644736c835fc60466a3a60390d334f9",
+                "reference": "9df2924ca644736c835fc60466a3a60390d334f9",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5",
+                "php": "^7.1 || >=8.0.0 <8.5.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "^1.3.5",
+                "php-parallel-lint/php-parallel-lint": "^1.4",
+                "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
+                "squizlabs/php_codesniffer": "^3.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "League\\OAuth2\\Client\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Alex Bilbie",
+                    "email": "hello@alexbilbie.com",
+                    "homepage": "http://www.alexbilbie.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Woody Gilk",
+                    "homepage": "https://github.com/shadowhand",
+                    "role": "Contributor"
+                }
+            ],
+            "description": "OAuth 2.0 Client Library",
+            "keywords": [
+                "Authentication",
+                "SSO",
+                "authorization",
+                "identity",
+                "idp",
+                "oauth",
+                "oauth2",
+                "single sign on"
+            ],
+            "support": {
+                "issues": "https://github.com/thephpleague/oauth2-client/issues",
+                "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.1"
+            },
+            "time": "2025-02-26T04:37:30+00:00"
+        },
         {
             "name": "league/oauth2-server",
             "version": "8.5.5",
@@ -12680,7 +12745,7 @@
     ],
     "aliases": [],
     "minimum-stability": "stable",
-    "stability-flags": {},
+    "stability-flags": [],
     "prefer-stable": true,
     "prefer-lowest": false,
     "platform": {
@@ -12693,6 +12758,6 @@
         "ext-mbstring": "*",
         "ext-openssl": "*"
     },
-    "platform-dev": {},
+    "platform-dev": [],
     "plugin-api-version": "2.6.0"
 }

+ 1 - 1
config/federation.php

@@ -19,7 +19,7 @@ return [
         'remoteFollow' => env('AP_REMOTE_FOLLOW', true),
 
         'delivery' => [
-            'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 30.0),
+            'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 30),
             'concurrency' => env('ACTIVITYPUB_DELIVERY_CONCURRENCY', 10),
             'logger' => [
                 'enabled' => env('AP_LOGGER_ENABLED', false),

+ 76 - 0
config/instance.php

@@ -188,4 +188,80 @@ return [
     'show_peers' => env('INSTANCE_SHOW_PEERS', false),
 
     'allow_new_account_dms' => env('INSTANCE_ALLOW_NEW_DMS', true),
+
+    'total_count_estimate' => env('INSTANCE_TOTAL_POSTS_COUNT_ESTIMATE', false),
+
+    'custom_filters' => [
+        /*
+         * The maximum number of characters from a status that will be scanned
+         * for filter matching. Scanning too many characters can hurt performance,
+         * so this limit ensures that only the most relevant portion of a status is processed.
+         *
+         * For remote statuses, you might want to increase this value if you expect
+         * important content to appear later in long posts.
+         */
+        'max_content_scan_limit' => env('PF_CF_CONTENT_SCAN_LIMIT', 2500),
+
+        /*
+         * The maximum number of filters a single user can create.
+         * Limiting the number of filters per user helps prevent abuse and
+         * ensures that the filtering system remains performant.
+         */
+        'max_filters_per_user' => env('PF_CF_MAX_FILTERS_PER_USER', 20),
+
+        /*
+         * The maximum number of keywords that can be associated with a single filter.
+         * This limit helps control the complexity of the generated regular expressions
+         * and protects against potential performance issues during content scanning.
+         */
+        'max_keywords_per_filter' => env('PF_CF_MAX_KEYWORDS_PER_FILTER', 10),
+
+        /*
+         * The maximum length allowed for each keyword in a filter.
+         * Limiting keyword length not only curtails the size of the regex patterns created,
+         * but also guards against potential abuse where excessively long keywords might
+         * negatively impact matching performance or lead to unintended behavior.
+         */
+        'max_keyword_length' => env('PF_CF_MAX_KEYWORD_LENGTH', 40),
+
+        /*
+         * The maximum allowed length for the combined regex pattern.
+         * When constructing a regex that matches multiple filter keywords, each keyword
+         * (after escaping and adding boundaries) contributes to the total pattern length.
+         *
+         * This value is set to 10000 by default. If you increase either the number of keywords
+         * per filter or the maximum length allowed for each keyword, consider increasing this
+         * limit accordingly so that the final regex pattern can accommodate the additional length
+         * without being truncated or causing performance issues.
+         */
+        'max_pattern_length' => env('PF_CF_MAX_PATTERN_LENGTH', 10000),
+
+        /*
+         * The maximum number of keyword matches to report for a given status.
+         * When a filter is applied to a status, the matching process may find multiple occurrences
+         * of a keyword. This value limits the number of matches that are reported back,
+         * which helps manage output volume and processing overhead.
+         *
+         * The default is set to 10, but you can adjust this value through your environment configuration.
+         */
+        'max_reported_matches' => env('PF_CF_MAX_REPORTED_MATCHES', 10),
+
+        /*
+         * The maximum number of filter creation operations allowed per hour for a non-admin user.
+         * This rate limit prevents abuse by restricting how many filters a normal user can create
+         * within one hour. Admin users are exempt from this limit.
+         *
+         * Default is 20 creations per hour.
+         */
+        'max_create_per_hour' => env('PF_CF_MAX_CREATE_PER_HOUR', 20),
+
+        /*
+         * The maximum number of filter update operations allowed per hour for a non-admin user.
+         * This rate limit is designed to prevent abuse by limiting how many times a normal user
+         * can update their filters within one hour. Admin users are not subject to these limits.
+         *
+         * Default is 40 updates per hour.
+         */
+        'max_updates_per_hour' => env('PF_CF_MAX_UPDATES_PER_HOUR', 40),
+    ],
 ];

+ 1 - 1
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
 	| This value is the version of your Pixelfed instance.
 	|
 	*/
-	'version' => '0.12.4',
+	'version' => '0.12.5',
 
 	/*
 	|--------------------------------------------------------------------------

+ 75 - 0
config/remote-auth.php

@@ -54,4 +54,79 @@ return [
             'limit' => env('PF_LOGIN_WITH_MASTODON_MAX_USES_LIMIT', 3)
         ]
     ],
+
+    'oidc' => [
+        /*
+         *   Enable OIDC authentication
+         *
+         *   Enable Sign-in with OpenID Connect (OIDC) authentication providers
+         */
+        'enabled' => env('PF_OIDC_ENABLED', false),
+
+        /*
+         *   Client ID
+         *
+         *   The client ID provided by your OIDC provider
+         */
+        'clientId' => env('PF_OIDC_CLIENT_ID', false),
+
+        /*
+         *   Client Secret
+         *
+         *   The client secret provided by your OIDC provider
+         */
+        'clientSecret' => env('PF_OIDC_CLIENT_SECRET', false),
+
+        /*
+         *   OAuth Scopes
+         *
+         *   The scopes to request from the OIDC provider, typically including
+         *   'openid' (required), 'profile', and 'email' for basic user information
+         */
+        'scopes' =>  env('PF_OIDC_SCOPES', 'openid profile email'),
+
+        /*
+         *   Authorization URL
+         *
+         *   The endpoint used to start the OIDC authentication flow
+         */
+        'authorizeURL' => env('PF_OIDC_AUTHORIZE_URL', ''),
+
+        /*
+         *   Token URL
+         *
+         *   The endpoint used to exchange the authorization code for an access token
+         */
+        'tokenURL' => env('PF_OIDC_TOKEN_URL', ''),
+
+        /*
+         *   Profile URL
+         *
+         *   The endpoint used to retrieve user information with a valid access token
+         */
+        'profileURL' => env('PF_OIDC_PROFILE_URL', ''),
+
+        /*
+         *   Logout URL
+         *
+         *   The endpoint used to log the user out of the OIDC provider
+         */
+        'logoutURL' => env('PF_OIDC_LOGOUT_URL', ''),
+
+        /*
+         *   Username Field
+         *
+         *   The field from the OIDC profile response to use as the username
+         *   Default is 'preferred_username' but can be changed based on your provider
+         */
+        'field_username' => env('PF_OIDC_USERNAME_FIELD', "preferred_username"),
+
+        /*
+         *   ID Field
+         *
+         *   The field from the OIDC profile response to use as the unique identifier
+         *   Default is 'sub' (subject) which is standard in OIDC implementations
+         */
+        'field_id' => env('PF_OIDC_FIELD_ID', 'sub'),
+    ],
 ];

+ 30 - 0
database/migrations/2025_01_30_061434_create_user_oidc_mapping_table.php

@@ -0,0 +1,30 @@
+<?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_oidc_mappings', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->index();
+            $table->string('oidc_id')->unique()->index();
+            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('user_oidc_mappings');
+    }
+};

+ 30 - 0
database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php

@@ -0,0 +1,30 @@
+<?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::table('statuses', function (Blueprint $table) {
+            $table->tinyInteger('pinned_order')->nullable()->default(null)->index();
+
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        //
+        Schema::table('statuses', function (Blueprint $table) {
+            $table->dropColumn('pinned_order');
+        });
+    }
+};

+ 32 - 0
database/migrations/2025_04_08_102711_create_custom_filters_table.php

@@ -0,0 +1,32 @@
+<?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('custom_filters', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('profile_id')->constrained()->onDelete('cascade');
+            $table->text('phrase')->default('')->nullable(false);
+            $table->integer('action')->default(0)->nullable(false); // 0=warn, 1=hide, 2=blur
+            $table->json('context')->nullable(true);
+            $table->timestamp('expires_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filters');
+    }
+};

+ 30 - 0
database/migrations/2025_04_08_103425_create_custom_filter_keywords_table.php

@@ -0,0 +1,30 @@
+<?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('custom_filter_keywords', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('custom_filter_id')->constrained()->onDelete('cascade');
+            $table->string('keyword', 255)->nullable(false);
+            $table->boolean('whole_word')->default(true);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filter_keywords');
+    }
+};

+ 29 - 0
database/migrations/2025_04_08_103433_create_custom_filter_statuses_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('custom_filter_statuses', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('custom_filter_id')->constrained()->onDelete('cascade');
+            $table->foreignId('status_id')->constrained()->onDelete('cascade');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filter_statuses');
+    }
+};

+ 1 - 1
docker/README.md

@@ -1,5 +1,5 @@
 # Pixelfed + Docker + Docker Compose
 
-Please see the [Pixelfed Docs (Next)](https://jippi.github.io/pixelfed-docs-next/pr-preview/pr-1/running-pixelfed/) for current documentation on Docker usage.
+Please see the [Pixelfed Docs (Next)](https://jippi.github.io/docker-pixelfed/) for current documentation on Docker usage.
 
 The docs can be [reviewed in the pixelfed/docs-next](https://github.com/pixelfed/docs-next/pull/1) repository.

+ 228 - 227
package-lock.json

@@ -144,12 +144,12 @@
 			}
 		},
 		"node_modules/@babel/generator": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz",
-			"integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
+			"integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
 			"dependencies": {
-				"@babel/parser": "^7.26.10",
-				"@babel/types": "^7.26.10",
+				"@babel/parser": "^7.27.0",
+				"@babel/types": "^7.27.0",
 				"@jridgewell/gen-mapping": "^0.3.5",
 				"@jridgewell/trace-mapping": "^0.3.25",
 				"jsesc": "^3.0.2"
@@ -170,11 +170,11 @@
 			}
 		},
 		"node_modules/@babel/helper-compilation-targets": {
-			"version": "7.26.5",
-			"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz",
-			"integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
+			"integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
 			"dependencies": {
-				"@babel/compat-data": "^7.26.5",
+				"@babel/compat-data": "^7.26.8",
 				"@babel/helper-validator-option": "^7.25.9",
 				"browserslist": "^4.24.0",
 				"lru-cache": "^5.1.1",
@@ -193,16 +193,16 @@
 			}
 		},
 		"node_modules/@babel/helper-create-class-features-plugin": {
-			"version": "7.26.9",
-			"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
-			"integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
+			"integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
 			"dependencies": {
 				"@babel/helper-annotate-as-pure": "^7.25.9",
 				"@babel/helper-member-expression-to-functions": "^7.25.9",
 				"@babel/helper-optimise-call-expression": "^7.25.9",
 				"@babel/helper-replace-supers": "^7.26.5",
 				"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
-				"@babel/traverse": "^7.26.9",
+				"@babel/traverse": "^7.27.0",
 				"semver": "^6.3.1"
 			},
 			"engines": {
@@ -221,9 +221,9 @@
 			}
 		},
 		"node_modules/@babel/helper-create-regexp-features-plugin": {
-			"version": "7.26.3",
-			"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz",
-			"integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz",
+			"integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==",
 			"dependencies": {
 				"@babel/helper-annotate-as-pure": "^7.25.9",
 				"regexpu-core": "^6.2.0",
@@ -245,9 +245,9 @@
 			}
 		},
 		"node_modules/@babel/helper-define-polyfill-provider": {
-			"version": "0.6.3",
-			"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz",
-			"integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==",
+			"version": "0.6.4",
+			"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
+			"integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
 			"dependencies": {
 				"@babel/helper-compilation-targets": "^7.22.6",
 				"@babel/helper-plugin-utils": "^7.22.5",
@@ -400,23 +400,23 @@
 			}
 		},
 		"node_modules/@babel/helpers": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
-			"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
+			"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
 			"dependencies": {
-				"@babel/template": "^7.26.9",
-				"@babel/types": "^7.26.10"
+				"@babel/template": "^7.27.0",
+				"@babel/types": "^7.27.0"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/parser": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
-			"integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
+			"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
 			"dependencies": {
-				"@babel/types": "^7.26.10"
+				"@babel/types": "^7.27.0"
 			},
 			"bin": {
 				"parser": "bin/babel-parser.js"
@@ -655,11 +655,11 @@
 			}
 		},
 		"node_modules/@babel/plugin-transform-block-scoping": {
-			"version": "7.25.9",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz",
-			"integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz",
+			"integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==",
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.25.9"
+				"@babel/helper-plugin-utils": "^7.26.5"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -1158,11 +1158,11 @@
 			}
 		},
 		"node_modules/@babel/plugin-transform-regenerator": {
-			"version": "7.25.9",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz",
-			"integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz",
+			"integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==",
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.25.9",
+				"@babel/helper-plugin-utils": "^7.26.5",
 				"regenerator-transform": "^0.15.2"
 			},
 			"engines": {
@@ -1286,9 +1286,9 @@
 			}
 		},
 		"node_modules/@babel/plugin-transform-typeof-symbol": {
-			"version": "7.26.7",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz",
-			"integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz",
+			"integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==",
 			"dependencies": {
 				"@babel/helper-plugin-utils": "^7.26.5"
 			},
@@ -1462,9 +1462,9 @@
 			}
 		},
 		"node_modules/@babel/runtime": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
-			"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
+			"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
 			"dependencies": {
 				"regenerator-runtime": "^0.14.0"
 			},
@@ -1473,28 +1473,28 @@
 			}
 		},
 		"node_modules/@babel/template": {
-			"version": "7.26.9",
-			"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
-			"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
+			"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
 			"dependencies": {
 				"@babel/code-frame": "^7.26.2",
-				"@babel/parser": "^7.26.9",
-				"@babel/types": "^7.26.9"
+				"@babel/parser": "^7.27.0",
+				"@babel/types": "^7.27.0"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/traverse": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz",
-			"integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
+			"integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
 			"dependencies": {
 				"@babel/code-frame": "^7.26.2",
-				"@babel/generator": "^7.26.10",
-				"@babel/parser": "^7.26.10",
-				"@babel/template": "^7.26.9",
-				"@babel/types": "^7.26.10",
+				"@babel/generator": "^7.27.0",
+				"@babel/parser": "^7.27.0",
+				"@babel/template": "^7.27.0",
+				"@babel/types": "^7.27.0",
 				"debug": "^4.3.1",
 				"globals": "^11.1.0"
 			},
@@ -1503,9 +1503,9 @@
 			}
 		},
 		"node_modules/@babel/types": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
-			"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
+			"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
 			"dependencies": {
 				"@babel/helper-string-parser": "^7.25.9",
 				"@babel/helper-validator-identifier": "^7.25.9"
@@ -1532,9 +1532,9 @@
 			}
 		},
 		"node_modules/@esbuild/aix-ppc64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
-			"integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
+			"integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
 			"cpu": [
 				"ppc64"
 			],
@@ -1548,9 +1548,9 @@
 			}
 		},
 		"node_modules/@esbuild/android-arm": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
-			"integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
+			"integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
 			"cpu": [
 				"arm"
 			],
@@ -1564,9 +1564,9 @@
 			}
 		},
 		"node_modules/@esbuild/android-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
-			"integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
+			"integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
 			"cpu": [
 				"arm64"
 			],
@@ -1580,9 +1580,9 @@
 			}
 		},
 		"node_modules/@esbuild/android-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
-			"integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
+			"integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
 			"cpu": [
 				"x64"
 			],
@@ -1596,9 +1596,9 @@
 			}
 		},
 		"node_modules/@esbuild/darwin-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
-			"integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
+			"integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
 			"cpu": [
 				"arm64"
 			],
@@ -1612,9 +1612,9 @@
 			}
 		},
 		"node_modules/@esbuild/darwin-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
-			"integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
+			"integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
 			"cpu": [
 				"x64"
 			],
@@ -1628,9 +1628,9 @@
 			}
 		},
 		"node_modules/@esbuild/freebsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
 			"cpu": [
 				"arm64"
 			],
@@ -1644,9 +1644,9 @@
 			}
 		},
 		"node_modules/@esbuild/freebsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
-			"integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
+			"integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
 			"cpu": [
 				"x64"
 			],
@@ -1660,9 +1660,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-arm": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
-			"integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
+			"integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
 			"cpu": [
 				"arm"
 			],
@@ -1676,9 +1676,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
-			"integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
+			"integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
 			"cpu": [
 				"arm64"
 			],
@@ -1692,9 +1692,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-ia32": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
-			"integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
+			"integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
 			"cpu": [
 				"ia32"
 			],
@@ -1708,9 +1708,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-loong64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
-			"integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
+			"integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
 			"cpu": [
 				"loong64"
 			],
@@ -1724,9 +1724,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-mips64el": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
-			"integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
+			"integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
 			"cpu": [
 				"mips64el"
 			],
@@ -1740,9 +1740,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-ppc64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
-			"integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
+			"integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
 			"cpu": [
 				"ppc64"
 			],
@@ -1756,9 +1756,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-riscv64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
-			"integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
+			"integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
 			"cpu": [
 				"riscv64"
 			],
@@ -1772,9 +1772,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-s390x": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
-			"integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
+			"integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
 			"cpu": [
 				"s390x"
 			],
@@ -1788,9 +1788,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
-			"integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
+			"integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
 			"cpu": [
 				"x64"
 			],
@@ -1804,9 +1804,9 @@
 			}
 		},
 		"node_modules/@esbuild/netbsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
 			"cpu": [
 				"arm64"
 			],
@@ -1820,9 +1820,9 @@
 			}
 		},
 		"node_modules/@esbuild/netbsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
-			"integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
+			"integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
 			"cpu": [
 				"x64"
 			],
@@ -1836,9 +1836,9 @@
 			}
 		},
 		"node_modules/@esbuild/openbsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
 			"cpu": [
 				"arm64"
 			],
@@ -1852,9 +1852,9 @@
 			}
 		},
 		"node_modules/@esbuild/openbsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
-			"integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
+			"integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
 			"cpu": [
 				"x64"
 			],
@@ -1868,9 +1868,9 @@
 			}
 		},
 		"node_modules/@esbuild/sunos-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
-			"integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
+			"integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
 			"cpu": [
 				"x64"
 			],
@@ -1884,9 +1884,9 @@
 			}
 		},
 		"node_modules/@esbuild/win32-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
-			"integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
+			"integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
 			"cpu": [
 				"arm64"
 			],
@@ -1900,9 +1900,9 @@
 			}
 		},
 		"node_modules/@esbuild/win32-ia32": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
-			"integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
+			"integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
 			"cpu": [
 				"ia32"
 			],
@@ -1916,9 +1916,9 @@
 			}
 		},
 		"node_modules/@esbuild/win32-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
-			"integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
+			"integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
 			"cpu": [
 				"x64"
 			],
@@ -3198,9 +3198,9 @@
 			}
 		},
 		"node_modules/@types/babel__generator": {
-			"version": "7.6.8",
-			"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
-			"integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+			"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
 			"dependencies": {
 				"@babel/types": "^7.0.0"
 			}
@@ -3215,9 +3215,9 @@
 			}
 		},
 		"node_modules/@types/babel__traverse": {
-			"version": "7.20.6",
-			"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
-			"integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+			"version": "7.20.7",
+			"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
+			"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
 			"dependencies": {
 				"@babel/types": "^7.20.7"
 			}
@@ -3284,9 +3284,9 @@
 			}
 		},
 		"node_modules/@types/estree": {
-			"version": "1.0.6",
-			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
-			"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="
+			"version": "1.0.7",
+			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
+			"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
 		},
 		"node_modules/@types/express": {
 			"version": "4.17.21",
@@ -3399,11 +3399,11 @@
 			"integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA=="
 		},
 		"node_modules/@types/node": {
-			"version": "22.13.10",
-			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
-			"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
+			"version": "22.14.1",
+			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
+			"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
 			"dependencies": {
-				"undici-types": "~6.20.0"
+				"undici-types": "~6.21.0"
 			}
 		},
 		"node_modules/@types/node-forge": {
@@ -3475,9 +3475,9 @@
 			"integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug=="
 		},
 		"node_modules/@types/ws": {
-			"version": "8.18.0",
-			"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
-			"integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==",
+			"version": "8.18.1",
+			"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+			"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
 			"dependencies": {
 				"@types/node": "*"
 			}
@@ -3747,9 +3747,9 @@
 			"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
 		},
 		"node_modules/@zip.js/zip.js": {
-			"version": "2.7.57",
-			"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.57.tgz",
-			"integrity": "sha512-BtonQ1/jDnGiMed6OkV6rZYW78gLmLswkHOzyMrMb+CAR7CZO8phOHO6c2qw6qb1g1betN7kwEHhhZk30dv+NA==",
+			"version": "2.7.60",
+			"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.60.tgz",
+			"integrity": "sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA==",
 			"engines": {
 				"bun": ">=0.7.0",
 				"deno": ">=1.0.0",
@@ -4034,9 +4034,9 @@
 			}
 		},
 		"node_modules/axios": {
-			"version": "1.8.2",
-			"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
-			"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
+			"version": "1.8.4",
+			"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
+			"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
 			"dev": true,
 			"dependencies": {
 				"follow-redirects": "^1.15.6",
@@ -4073,12 +4073,12 @@
 			}
 		},
 		"node_modules/babel-plugin-polyfill-corejs2": {
-			"version": "0.4.12",
-			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz",
-			"integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==",
+			"version": "0.4.13",
+			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz",
+			"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
 			"dependencies": {
 				"@babel/compat-data": "^7.22.6",
-				"@babel/helper-define-polyfill-provider": "^0.6.3",
+				"@babel/helper-define-polyfill-provider": "^0.6.4",
 				"semver": "^6.3.1"
 			},
 			"peerDependencies": {
@@ -4106,11 +4106,11 @@
 			}
 		},
 		"node_modules/babel-plugin-polyfill-regenerator": {
-			"version": "0.6.3",
-			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz",
-			"integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==",
+			"version": "0.6.4",
+			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz",
+			"integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==",
 			"dependencies": {
-				"@babel/helper-define-polyfill-provider": "^0.6.3"
+				"@babel/helper-define-polyfill-provider": "^0.6.4"
 			},
 			"peerDependencies": {
 				"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -4673,9 +4673,9 @@
 			}
 		},
 		"node_modules/caniuse-lite": {
-			"version": "1.0.30001703",
-			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz",
-			"integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==",
+			"version": "1.0.30001713",
+			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
+			"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
 			"funding": [
 				{
 					"type": "opencollective",
@@ -5836,9 +5836,9 @@
 			"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
 		},
 		"node_modules/electron-to-chromium": {
-			"version": "1.5.114",
-			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.114.tgz",
-			"integrity": "sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA=="
+			"version": "1.5.136",
+			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.136.tgz",
+			"integrity": "sha512-kL4+wUTD7RSA5FHx5YwWtjDnEEkIIikFgWHR4P6fqjw1PPLlqYkxeOb++wAauAssat0YClCy8Y3C5SxgSkjibQ=="
 		},
 		"node_modules/elliptic": {
 			"version": "6.6.1",
@@ -5985,9 +5985,9 @@
 			"integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw=="
 		},
 		"node_modules/esbuild": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
-			"integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
+			"integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
 			"dev": true,
 			"hasInstallScript": true,
 			"bin": {
@@ -5997,31 +5997,31 @@
 				"node": ">=18"
 			},
 			"optionalDependencies": {
-				"@esbuild/aix-ppc64": "0.25.1",
-				"@esbuild/android-arm": "0.25.1",
-				"@esbuild/android-arm64": "0.25.1",
-				"@esbuild/android-x64": "0.25.1",
-				"@esbuild/darwin-arm64": "0.25.1",
-				"@esbuild/darwin-x64": "0.25.1",
-				"@esbuild/freebsd-arm64": "0.25.1",
-				"@esbuild/freebsd-x64": "0.25.1",
-				"@esbuild/linux-arm": "0.25.1",
-				"@esbuild/linux-arm64": "0.25.1",
-				"@esbuild/linux-ia32": "0.25.1",
-				"@esbuild/linux-loong64": "0.25.1",
-				"@esbuild/linux-mips64el": "0.25.1",
-				"@esbuild/linux-ppc64": "0.25.1",
-				"@esbuild/linux-riscv64": "0.25.1",
-				"@esbuild/linux-s390x": "0.25.1",
-				"@esbuild/linux-x64": "0.25.1",
-				"@esbuild/netbsd-arm64": "0.25.1",
-				"@esbuild/netbsd-x64": "0.25.1",
-				"@esbuild/openbsd-arm64": "0.25.1",
-				"@esbuild/openbsd-x64": "0.25.1",
-				"@esbuild/sunos-x64": "0.25.1",
-				"@esbuild/win32-arm64": "0.25.1",
-				"@esbuild/win32-ia32": "0.25.1",
-				"@esbuild/win32-x64": "0.25.1"
+				"@esbuild/aix-ppc64": "0.25.2",
+				"@esbuild/android-arm": "0.25.2",
+				"@esbuild/android-arm64": "0.25.2",
+				"@esbuild/android-x64": "0.25.2",
+				"@esbuild/darwin-arm64": "0.25.2",
+				"@esbuild/darwin-x64": "0.25.2",
+				"@esbuild/freebsd-arm64": "0.25.2",
+				"@esbuild/freebsd-x64": "0.25.2",
+				"@esbuild/linux-arm": "0.25.2",
+				"@esbuild/linux-arm64": "0.25.2",
+				"@esbuild/linux-ia32": "0.25.2",
+				"@esbuild/linux-loong64": "0.25.2",
+				"@esbuild/linux-mips64el": "0.25.2",
+				"@esbuild/linux-ppc64": "0.25.2",
+				"@esbuild/linux-riscv64": "0.25.2",
+				"@esbuild/linux-s390x": "0.25.2",
+				"@esbuild/linux-x64": "0.25.2",
+				"@esbuild/netbsd-arm64": "0.25.2",
+				"@esbuild/netbsd-x64": "0.25.2",
+				"@esbuild/openbsd-arm64": "0.25.2",
+				"@esbuild/openbsd-x64": "0.25.2",
+				"@esbuild/sunos-x64": "0.25.2",
+				"@esbuild/win32-arm64": "0.25.2",
+				"@esbuild/win32-ia32": "0.25.2",
+				"@esbuild/win32-x64": "0.25.2"
 			}
 		},
 		"node_modules/escalade": {
@@ -6909,9 +6909,9 @@
 			}
 		},
 		"node_modules/hls.js": {
-			"version": "1.5.20",
-			"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz",
-			"integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ=="
+			"version": "1.6.2",
+			"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.2.tgz",
+			"integrity": "sha512-rx+pETSCJEDThm/JCm8CuadcAC410cVjb1XVXFNDKFuylaayHk1+tFxhkjvnMDAfqsJHxZXDAJ3Uc2d5xQyWlQ=="
 		},
 		"node_modules/hmac-drbg": {
 			"version": "1.0.1",
@@ -6940,9 +6940,9 @@
 			}
 		},
 		"node_modules/html-entities": {
-			"version": "2.5.2",
-			"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz",
-			"integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==",
+			"version": "2.6.0",
+			"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
+			"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
 			"funding": [
 				{
 					"type": "github",
@@ -7084,9 +7084,9 @@
 			}
 		},
 		"node_modules/http-parser-js": {
-			"version": "0.5.9",
-			"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.9.tgz",
-			"integrity": "sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw=="
+			"version": "0.5.10",
+			"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
+			"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="
 		},
 		"node_modules/http-proxy": {
 			"version": "1.18.1",
@@ -7102,9 +7102,9 @@
 			}
 		},
 		"node_modules/http-proxy-middleware": {
-			"version": "2.0.7",
-			"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
-			"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
+			"version": "2.0.9",
+			"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
+			"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
 			"dependencies": {
 				"@types/http-proxy": "^1.17.8",
 				"http-proxy": "^1.18.1",
@@ -7242,9 +7242,9 @@
 			}
 		},
 		"node_modules/immutable": {
-			"version": "5.0.3",
-			"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
-			"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
+			"version": "5.1.1",
+			"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
+			"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
 			"dev": true
 		},
 		"node_modules/import-fresh": {
@@ -8198,9 +8198,9 @@
 			}
 		},
 		"node_modules/nanoid": {
-			"version": "3.3.9",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz",
-			"integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==",
+			"version": "3.3.11",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+			"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
 			"funding": [
 				{
 					"type": "github",
@@ -10028,9 +10028,9 @@
 			"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
 		},
 		"node_modules/sass": {
-			"version": "1.85.1",
-			"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
-			"integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
+			"version": "1.86.3",
+			"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz",
+			"integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==",
 			"dev": true,
 			"dependencies": {
 				"chokidar": "^4.0.0",
@@ -10689,9 +10689,9 @@
 			}
 		},
 		"node_modules/std-env": {
-			"version": "3.8.1",
-			"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz",
-			"integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA=="
+			"version": "3.9.0",
+			"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
+			"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="
 		},
 		"node_modules/stream-browserify": {
 			"version": "2.0.2",
@@ -11171,9 +11171,9 @@
 			}
 		},
 		"node_modules/undici-types": {
-			"version": "6.20.0",
-			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
-			"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
+			"version": "6.21.0",
+			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+			"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
 		},
 		"node_modules/unicode-canonical-property-names-ecmascript": {
 			"version": "2.0.1",
@@ -11422,7 +11422,8 @@
 		"node_modules/vue-i18n": {
 			"version": "8.28.2",
 			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.28.2.tgz",
-			"integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA=="
+			"integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA==",
+			"deprecated": "Vue I18n v8.x has reached EOL and is no longer actively maintained. About maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html"
 		},
 		"node_modules/vue-infinite-loading": {
 			"version": "2.4.5",
@@ -11657,9 +11658,9 @@
 			"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
 		},
 		"node_modules/webpack": {
-			"version": "5.98.0",
-			"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
-			"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
+			"version": "5.99.5",
+			"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.5.tgz",
+			"integrity": "sha512-q+vHBa6H9qwBLUlHL4Y7L0L1/LlyBKZtS9FHNCQmtayxjI5RKC9yD8gpvLeqGv5lCQp1Re04yi0MF40pf30Pvg==",
 			"dependencies": {
 				"@types/eslint-scope": "^3.7.7",
 				"@types/estree": "^1.0.6",

+ 11 - 13
phpunit.xml

@@ -1,20 +1,18 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
-    backupGlobals="false" 
-    backupStaticAttributes="false" 
-    bootstrap="vendor/autoload.php" 
-    colors="true" 
-    convertErrorsToExceptions="true" 
-    convertNoticesToExceptions="true" 
-    convertWarningsToExceptions="true" 
-    processIsolation="false" 
-    stopOnFailure="false" 
-    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
-  <coverage processUncoveredFiles="true">
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    backupGlobals="false"
+    backupStaticProperties="false"
+    bootstrap="vendor/autoload.php"
+    colors="true"
+    processIsolation="false"
+    stopOnFailure="false"
+    cacheDirectory=".phpunit.cache"
+    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd">
+  <source>
     <include>
       <directory suffix=".php">./app</directory>
     </include>
-  </coverage>
+  </source>
   <testsuites>
     <testsuite name="Feature">
       <directory suffix="Test.php">./tests/Feature</directory>

BIN=BIN
public/_lang/de.json


BIN=BIN
public/_lang/en.json


BIN=BIN
public/_lang/pt.json


BIN=BIN
public/js/account-import.js


BIN=BIN
public/js/app.js


BIN=BIN
public/js/changelog.bundle.0e485f4496100570.js


BIN=BIN
public/js/changelog.bundle.efd3d17aee17020e.js


BIN=BIN
public/js/compose.chunk.76891a6dad141928.js


+ 0 - 0
public/js/compose.chunk.80e32f21442c8a91.js.LICENSE.txt → public/js/compose.chunk.76891a6dad141928.js.LICENSE.txt


BIN=BIN
public/js/compose.chunk.80e32f21442c8a91.js


BIN=BIN
public/js/compose.js


BIN=BIN
public/js/custom_filters.js


BIN=BIN
public/js/daci.chunk.8cf1cb07ac8a9100.js


BIN=BIN
public/js/daci.chunk.bf225e484f3a2b17.js


BIN=BIN
public/js/discover.chunk.0ca404627af971f2.js


BIN=BIN
public/js/discover.chunk.21616d9cb1229006.js


BIN=BIN
public/js/discover~findfriends.chunk.bf787612b58e5473.js


BIN=BIN
public/js/discover~findfriends.chunk.d916236b22d65518.js


BIN=BIN
public/js/discover~hashtag.bundle.53264f5d0ba3e59b.js


BIN=BIN
public/js/discover~hashtag.bundle.c8eb86fb63ede45e.js


BIN=BIN
public/js/discover~memories.chunk.777303732fff3560.js


BIN=BIN
public/js/discover~memories.chunk.9621c5ecf4482f0a.js


BIN=BIN
public/js/discover~myhashtags.chunk.c64e38339a1b7154.js


BIN=BIN
public/js/discover~myhashtags.chunk.f4257bc65189fde3.js


BIN=BIN
public/js/discover~serverfeed.chunk.4c5e477dc3fb4da0.js


BIN=BIN
public/js/discover~serverfeed.chunk.4e135dd1c07c17dd.js


BIN=BIN
public/js/discover~settings.chunk.295935b63f9c0971.js


BIN=BIN
public/js/discover~settings.chunk.523499873e915409.js


BIN=BIN
public/js/dms.chunk.090a307aa2f04c14.js


BIN=BIN
public/js/dms.chunk.13449036a5b769e6.js


BIN=BIN
public/js/dms~message.chunk.97018952bf2655a9.js


BIN=BIN
public/js/dms~message.chunk.f0d6ccb6f2f1cbf7.js


BIN=BIN
public/js/group-status.js


BIN=BIN
public/js/group-topic-feed.js


BIN=BIN
public/js/group.create.38102523ebf4cde9.js → public/js/group.create.e34ad5621d07870d.js


BIN=BIN
public/js/groups.js


BIN=BIN
public/js/home.chunk.3d9801a7722f4dfb.js


BIN=BIN
public/js/home.chunk.ac1bf3c994e718f2.js


+ 0 - 0
public/js/home.chunk.3d9801a7722f4dfb.js.LICENSE.txt → public/js/home.chunk.ac1bf3c994e718f2.js.LICENSE.txt


BIN=BIN
public/js/i18n.bundle.3a0756c99b66371a.js


BIN=BIN
public/js/i18n.bundle.85976a3b9d6b922a.js


BIN=BIN
public/js/landing.js


BIN=BIN
public/js/manifest.js


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio