浏览代码

Merge pull request #5338 from pixelfed/staging

Staging
daniel 8 月之前
父节点
当前提交
ffd8815ee8

+ 270 - 38
app/Http/Controllers/Admin/AdminReportController.php

@@ -3,18 +3,20 @@
 namespace App\Http\Controllers\Admin;
 
 use App\AccountInterstitial;
-use App\Http\Resources\AdminReport;
+use App\Http\Resources\Admin\AdminModeratedProfileResource;
 use App\Http\Resources\AdminRemoteReport;
+use App\Http\Resources\AdminReport;
 use App\Http\Resources\AdminSpamReport;
 use App\Jobs\DeletePipeline\DeleteAccountPipeline;
 use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
 use App\Jobs\StatusPipeline\RemoteStatusDelete;
 use App\Jobs\StatusPipeline\StatusDelete;
 use App\Jobs\StoryPipeline\StoryDelete;
+use App\Models\ModeratedProfile;
+use App\Models\RemoteReport;
 use App\Notification;
 use App\Profile;
 use App\Report;
-use App\Models\RemoteReport;
 use App\Services\AccountService;
 use App\Services\ModLogService;
 use App\Services\NetworkTimelineService;
@@ -24,12 +26,13 @@ use App\Services\StatusService;
 use App\Status;
 use App\Story;
 use App\User;
+use App\Util\ActivityPub\Helpers;
 use Cache;
-use Storage;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Redis;
+use Storage;
 
 trait AdminReportController
 {
@@ -201,10 +204,7 @@ trait AdminReportController
                     return 0;
                 }
 
-                public function render()
-                {
-
-                }
+                public function render() {}
             };
         }
 
@@ -829,6 +829,16 @@ trait AdminReportController
                 $profile->cw = true;
                 $profile->save();
 
+                if ($profile->remote_url) {
+                    ModeratedProfile::updateOrCreate([
+                        'profile_url' => $profile->remote_url,
+                        'profile_id' => $profile->id,
+                    ], [
+                        'is_nsfw' => true,
+                        'domain' => $profile->domain,
+                    ]);
+                }
+
                 foreach (Status::whereProfileId($profile->id)->cursor() as $status) {
                     $status->is_nsfw = true;
                     $status->save();
@@ -879,6 +889,16 @@ trait AdminReportController
                 $profile->unlisted = true;
                 $profile->save();
 
+                if ($profile->remote_url) {
+                    ModeratedProfile::updateOrCreate([
+                        'profile_url' => $profile->remote_url,
+                        'profile_id' => $profile->id,
+                    ], [
+                        'is_unlisted' => true,
+                        'domain' => $profile->domain,
+                    ]);
+                }
+
                 foreach (Status::whereProfileId($profile->id)->whereScope('public')->cursor() as $status) {
                     $status->scope = 'unlisted';
                     $status->visibility = 'unlisted';
@@ -929,6 +949,16 @@ trait AdminReportController
                 $profile->unlisted = true;
                 $profile->save();
 
+                if ($profile->remote_url) {
+                    ModeratedProfile::updateOrCreate([
+                        'profile_url' => $profile->remote_url,
+                        'profile_id' => $profile->id,
+                    ], [
+                        'is_unlisted' => true,
+                        'domain' => $profile->domain,
+                    ]);
+                }
+
                 foreach (Status::whereProfileId($profile->id)->cursor() as $status) {
                     $status->scope = 'private';
                     $status->visibility = 'private';
@@ -982,6 +1012,16 @@ trait AdminReportController
 
                 $ts = now()->addMonth();
 
+                if ($profile->remote_url) {
+                    ModeratedProfile::updateOrCreate([
+                        'profile_url' => $profile->remote_url,
+                        'profile_id' => $profile->id,
+                    ], [
+                        'is_banned' => true,
+                        'domain' => $profile->domain,
+                    ]);
+                }
+
                 if ($profile->user_id) {
                     $user = $profile->user;
                     abort_if($user->is_admin, 403, 'You cannot delete admin accounts.');
@@ -1354,7 +1394,7 @@ trait AdminReportController
     {
         $this->validate($request, [
             'id' => 'required|exists:remote_reports,id',
-            'action' => 'required|in:mark-read,cw-posts,unlist-posts,delete-posts,private-posts,mark-all-read-by-domain,mark-all-read-by-username,cw-all-posts,private-all-posts,unlist-all-posts'
+            'action' => 'required|in:mark-read,cw-posts,unlist-posts,delete-posts,private-posts,mark-all-read-by-domain,mark-all-read-by-username,cw-all-posts,private-all-posts,unlist-all-posts',
         ]);
 
         $report = RemoteReport::findOrFail($request->input('id'));
@@ -1373,11 +1413,11 @@ trait AdminReportController
                 break;
             case 'cw-posts':
                 $statuses = Status::find($report->status_ids);
-                foreach($statuses as $status) {
-                    if($report->account_id != $status->profile_id) {
+                foreach ($statuses as $status) {
+                    if ($report->account_id != $status->profile_id) {
                         continue;
                     }
-                    if(!$status->is_nsfw) {
+                    if (! $status->is_nsfw) {
                         $ogNonCwStatuses[] = $status->id;
                     }
                     $status->is_nsfw = true;
@@ -1388,11 +1428,11 @@ trait AdminReportController
                 $report->save();
                 break;
             case 'cw-all-posts':
-                foreach(Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
-                    if($status->is_nsfw || $status->reblog_of_id) {
+                foreach (Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
+                    if ($status->is_nsfw || $status->reblog_of_id) {
                         continue;
                     }
-                    if(!$status->is_nsfw) {
+                    if (! $status->is_nsfw) {
                         $ogNonCwStatuses[] = $status->id;
                     }
                     $status->is_nsfw = true;
@@ -1402,11 +1442,11 @@ trait AdminReportController
                 break;
             case 'unlist-posts':
                 $statuses = Status::find($report->status_ids);
-                foreach($statuses as $status) {
-                    if($report->account_id != $status->profile_id) {
+                foreach ($statuses as $status) {
+                    if ($report->account_id != $status->profile_id) {
                         continue;
                     }
-                    if($status->scope === 'public') {
+                    if ($status->scope === 'public') {
                         $ogPublicStatuses[] = $status->id;
                         $status->scope = 'unlisted';
                         $status->visibility = 'unlisted';
@@ -1418,8 +1458,8 @@ trait AdminReportController
                 $report->save();
                 break;
             case 'unlist-all-posts':
-                foreach(Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
-                    if($status->visibility !== 'public' || $status->reblog_of_id) {
+                foreach (Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
+                    if ($status->visibility !== 'public' || $status->reblog_of_id) {
                         continue;
                     }
                     $ogPublicStatuses[] = $status->id;
@@ -1431,12 +1471,12 @@ trait AdminReportController
                 break;
             case 'private-posts':
                 $statuses = Status::find($report->status_ids);
-                foreach($statuses as $status) {
-                    if($report->account_id != $status->profile_id) {
+                foreach ($statuses as $status) {
+                    if ($report->account_id != $status->profile_id) {
                         continue;
                     }
-                    if(in_array($status->scope, ['public', 'unlisted', 'private'])) {
-                        if($status->scope === 'public') {
+                    if (in_array($status->scope, ['public', 'unlisted', 'private'])) {
+                        if ($status->scope === 'public') {
                             $ogPublicStatuses[] = $status->id;
                         }
                         $status->scope = 'private';
@@ -1449,13 +1489,13 @@ trait AdminReportController
                 $report->save();
                 break;
             case 'private-all-posts':
-                foreach(Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
-                    if(!in_array($status->visibility, ['public', 'unlisted']) || $status->reblog_of_id) {
+                foreach (Status::whereProfileId($report->account_id)->lazyById(50, 'id') as $status) {
+                    if (! in_array($status->visibility, ['public', 'unlisted']) || $status->reblog_of_id) {
                         continue;
                     }
-                    if($status->visibility === 'public') {
+                    if ($status->visibility === 'public') {
                         $ogPublicStatuses[] = $status->id;
-                    } else if($status->visibility === 'unlisted') {
+                    } elseif ($status->visibility === 'unlisted') {
                         $ogUnlistedStatuses[] = $status->id;
                     }
                     $status->visibility = 'private';
@@ -1466,8 +1506,8 @@ trait AdminReportController
                 break;
             case 'delete-posts':
                 $statuses = Status::find($report->status_ids);
-                foreach($statuses as $status) {
-                    if($report->account_id != $status->profile_id) {
+                foreach ($statuses as $status) {
+                    if ($report->account_id != $status->profile_id) {
                         continue;
                     }
                     StatusDelete::dispatch($status);
@@ -1484,16 +1524,16 @@ trait AdminReportController
                 break;
         }
 
-        if($ogPublicStatuses && count($ogPublicStatuses)) {
-            Storage::disk('local')->put('mod-log-cache/' . $report->account_id . '/' . now()->format('Y-m-d') . '-og-public-statuses.json', json_encode($ogPublicStatuses, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
+        if ($ogPublicStatuses && count($ogPublicStatuses)) {
+            Storage::disk('local')->put('mod-log-cache/'.$report->account_id.'/'.now()->format('Y-m-d').'-og-public-statuses.json', json_encode($ogPublicStatuses, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
         }
 
-        if($ogNonCwStatuses && count($ogNonCwStatuses)) {
-            Storage::disk('local')->put('mod-log-cache/' . $report->account_id . '/' . now()->format('Y-m-d') . '-og-noncw-statuses.json', json_encode($ogNonCwStatuses, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
+        if ($ogNonCwStatuses && count($ogNonCwStatuses)) {
+            Storage::disk('local')->put('mod-log-cache/'.$report->account_id.'/'.now()->format('Y-m-d').'-og-noncw-statuses.json', json_encode($ogNonCwStatuses, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
         }
 
-        if($ogUnlistedStatuses && count($ogUnlistedStatuses)) {
-            Storage::disk('local')->put('mod-log-cache/' . $report->account_id . '/' . now()->format('Y-m-d') . '-og-unlisted-statuses.json', json_encode($ogUnlistedStatuses, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
+        if ($ogUnlistedStatuses && count($ogUnlistedStatuses)) {
+            Storage::disk('local')->put('mod-log-cache/'.$report->account_id.'/'.now()->format('Y-m-d').'-og-unlisted-statuses.json', json_encode($ogUnlistedStatuses, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
         }
 
         ModLogService::boot()
@@ -1504,18 +1544,210 @@ trait AdminReportController
             ->action('admin.report.moderate')
             ->metadata([
                 'action' => $request->input('action'),
-                'duration_active' => now()->parse($report->created_at)->diffForHumans()
+                'duration_active' => now()->parse($report->created_at)->diffForHumans(),
             ])
             ->accessLevel('admin')
             ->save();
 
-        if($report->status_ids) {
-            foreach($report->status_ids as $sid) {
+        if ($report->status_ids) {
+            foreach ($report->status_ids as $sid) {
                 RemoteReport::whereNull('action_taken_at')
                     ->whereJsonContains('status_ids', [$sid])
                     ->update(['action_taken_at' => now()]);
             }
         }
+
         return [200];
     }
+
+    public function getModeratedProfiles(Request $request)
+    {
+        $this->validate($request, [
+            'search' => 'sometimes|string|min:3|max:120',
+        ]);
+
+        if ($request->filled('search')) {
+            $query = '%'.$request->input('search').'%';
+            $profiles = DB::table('moderated_profiles')
+                ->join('profiles', 'moderated_profiles.profile_id', '=', 'profiles.id')
+                ->where('profiles.username', 'LIKE', $query)
+                ->select('moderated_profiles.*', 'profiles.username')
+                ->orderByDesc('moderated_profiles.id')
+                ->cursorPaginate(10);
+
+            return AdminModeratedProfileResource::collection($profiles);
+        }
+        $profiles = ModeratedProfile::orderByDesc('id')->cursorPaginate(10);
+
+        return AdminModeratedProfileResource::collection($profiles);
+    }
+
+    public function getModeratedProfile(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required',
+        ]);
+
+        $profile = ModeratedProfile::findOrFail($request->input('id'));
+
+        return new AdminModeratedProfileResource($profile);
+    }
+
+    public function exportModeratedProfiles(Request $request)
+    {
+        return response()->streamDownload(function () {
+            $profiles = ModeratedProfile::get();
+            $res = AdminModeratedProfileResource::collection($profiles);
+            echo json_encode([
+                '_pixelfed_export' => true,
+                'meta' => [
+                    'ns' => 'https://pixelfed.org',
+                    'origin' => config('pixelfed.domain.app'),
+                    'date' => now()->format('c'),
+                    'type' => 'moderated-profiles',
+                    'version' => "1.0"
+                ],
+                'data' => $res
+            ], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+        }, 'data-export.json');
+    }
+
+    public function deleteModeratedProfile(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required',
+        ]);
+
+        $profile = ModeratedProfile::findOrFail($request->input('id'));
+
+        ModLogService::boot()
+            ->objectUid($profile->profile_id)
+            ->objectId($profile->id)
+            ->objectType('App\Models\ModeratedProfile::class')
+            ->user(request()->user())
+            ->action('admin.moderated-profiles.delete')
+            ->metadata([
+                'profile_url' => $profile->profile_url,
+                'profile_id' => $profile->profile_id,
+                'domain' => $profile->domain,
+                'note' => $profile->note,
+                'is_banned' => $profile->is_banned,
+                'is_nsfw' => $profile->is_nsfw,
+                'is_unlisted' => $profile->is_unlisted,
+                'is_noautolink' => $profile->is_noautolink,
+                'is_nodms' => $profile->is_nodms,
+                'is_notrending' => $profile->is_notrending,
+            ])
+            ->accessLevel('admin')
+            ->save();
+
+        $profile->delete();
+
+        return ['status' => 200, 'message' => 'Successfully deleted moderated profile!'];
+    }
+
+    public function updateModeratedProfile(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required|exists:moderated_profiles',
+            'note' => 'sometimes|nullable|string|max:500',
+            'is_banned' => 'required|boolean',
+            'is_noautolink' => 'required|boolean',
+            'is_nodms' => 'required|boolean',
+            'is_notrending' => 'required|boolean',
+            'is_nsfw' => 'required|boolean',
+            'is_unlisted' => 'required|boolean',
+        ]);
+
+        $fields = [
+            'note',
+            'is_banned',
+            'is_noautolink',
+            'is_nodms',
+            'is_notrending',
+            'is_nsfw',
+            'is_unlisted',
+        ];
+
+        $profile = ModeratedProfile::findOrFail($request->input('id'));
+        $profile->update($request->only($fields));
+
+        ModLogService::boot()
+            ->objectUid($profile->profile_id)
+            ->objectId($profile->id)
+            ->objectType('App\Models\ModeratedProfile::class')
+            ->user(request()->user())
+            ->action('admin.moderated-profiles.update')
+            ->metadata($request->only($fields))
+            ->accessLevel('admin')
+            ->save();
+
+        return [200];
+    }
+
+    public function createModeratedProfile(Request $request)
+    {
+        $this->validate($request, [
+            'url' => 'required|url|starts_with:https://',
+        ]);
+
+        $url = $request->input('url');
+        $host = parse_url($url, PHP_URL_HOST);
+
+        abort_if($host === config('pixelfed.domain.app'), 400, 'You cannot add local users!');
+
+        $exists = ModeratedProfile::whereProfileUrl($url)->exists();
+        abort_if($exists, 400, 'Moderated profile already exists!');
+
+        $profile = Profile::whereRemoteUrl($url)->first();
+
+        if ($profile) {
+            $rec = ModeratedProfile::updateOrCreate([
+                'profile_id' => $profile->id,
+            ], [
+                'profile_url' => $profile->remote_url,
+                'domain' => $profile->domain,
+            ]);
+
+            ModLogService::boot()
+                ->objectUid($rec->profile_id)
+                ->objectId($rec->id)
+                ->objectType('App\Models\ModeratedProfile::class')
+                ->user(request()->user())
+                ->action('admin.moderated-profiles.create')
+                ->metadata([
+                    'profile_existed' => true,
+                ])
+                ->accessLevel('admin')
+                ->save();
+
+            return $rec;
+        }
+
+        $remoteSearch = Helpers::profileFetch($url);
+
+        if ($remoteSearch) {
+            $rec = ModeratedProfile::updateOrCreate([
+                'profile_id' => $remoteSearch->id,
+            ], [
+                'profile_url' => $remoteSearch->remote_url,
+                'domain' => $remoteSearch->domain,
+            ]);
+
+            ModLogService::boot()
+                ->objectUid($rec->profile_id)
+                ->objectId($rec->id)
+                ->objectType('App\Models\ModeratedProfile::class')
+                ->user(request()->user())
+                ->action('admin.moderated-profiles.create')
+                ->metadata([
+                    'profile_existed' => false,
+                ])
+                ->accessLevel('admin')
+                ->save();
+
+            return $rec;
+        }
+        abort(400, 'Invalid account');
+    }
 }

+ 45 - 0
app/Http/Resources/Admin/AdminModeratedProfileResource.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Http\Resources\Admin;
+
+use App\Profile;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class AdminModeratedProfileResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @return array<string, mixed>
+     */
+    public function toArray(Request $request): array
+    {
+        $profileObj = [];
+        $profile = Profile::withTrashed()->find($this->profile_id);
+        if ($profile) {
+            $profileObj = [
+                'name' => $profile->name,
+                'username' => $profile->username,
+                'username_str' => explode('@', $profile->username)[1],
+                'remote_url' => $profile->remote_url,
+            ];
+        }
+
+        return [
+            'id' => $this->id,
+            'domain' => $this->domain,
+            'profile' => $profileObj,
+            'profile_id' => $this->profile_id,
+            'profile_url' => $this->profile_url,
+            'note' => $this->note,
+            'is_banned' => (bool) $this->is_banned,
+            'is_nsfw' => (bool) $this->is_nsfw,
+            'is_unlisted' => (bool) $this->is_unlisted,
+            'is_noautolink' => (bool) $this->is_noautolink,
+            'is_nodms' => (bool) $this->is_nodms,
+            'is_notrending' => (bool) $this->is_notrending,
+            'created_at' => now()->parse($this->created_at)->format('c'),
+        ];
+    }
+}

+ 25 - 17
app/Http/Resources/AdminReport.php

@@ -2,10 +2,11 @@
 
 namespace App\Http\Resources;
 
-use Illuminate\Http\Request;
-use Illuminate\Http\Resources\Json\JsonResource;
 use App\Services\AccountService;
 use App\Services\StatusService;
+use App\Story;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
 
 class AdminReport extends JsonResource
 {
@@ -16,22 +17,29 @@ class AdminReport extends JsonResource
      */
     public function toArray(Request $request): array
     {
-    	$res = [
-    		'id' => $this->id,
-    		'reporter' => AccountService::get($this->profile_id, true),
-    		'type' => $this->type,
-    		'object_id' => (string) $this->object_id,
-    		'object_type' => $this->object_type,
-    		'reported' => AccountService::get($this->reported_profile_id, true),
-    		'status' => null,
-    		'reporter_message' => $this->message,
-    		'admin_seen_at' => $this->admin_seen,
-    		'created_at' => $this->created_at,
-    	];
+        $res = [
+            'id' => $this->id,
+            'reporter' => AccountService::get($this->profile_id, true),
+            'type' => $this->type,
+            'object_id' => (string) $this->object_id,
+            'object_type' => $this->object_type,
+            'reported' => AccountService::get($this->reported_profile_id, true),
+            'status' => null,
+            'reporter_message' => $this->message,
+            'admin_seen_at' => $this->admin_seen,
+            'created_at' => $this->created_at,
+        ];
+
+        if ($this->object_id && $this->object_type === 'App\Status') {
+            $res['status'] = StatusService::get($this->object_id, false);
+        }
 
-    	if($this->object_id && $this->object_type === 'App\Status') {
-    		$res['status'] = StatusService::get($this->object_id, false);
-    	}
+        if ($this->object_id && $this->object_type === 'App\Story') {
+            $story = Story::find($this->object_id);
+            if ($story) {
+                $res['story'] = $story->toAdminEntity();
+            }
+        }
 
         return $res;
     }

+ 13 - 0
app/Models/ModeratedProfile.php

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

+ 6 - 0
app/Util/ActivityPub/Helpers.php

@@ -9,6 +9,7 @@ use App\Jobs\MediaPipeline\MediaStoragePipeline;
 use App\Jobs\StatusPipeline\StatusReplyPipeline;
 use App\Jobs\StatusPipeline\StatusTagsPipeline;
 use App\Media;
+use App\Models\ModeratedProfile;
 use App\Models\Poll;
 use App\Profile;
 use App\Services\Account\AccountStatService;
@@ -814,6 +815,11 @@ class Helpers
         if (! self::validateUrl($res['id'])) {
             return;
         }
+
+        if (ModeratedProfile::whereProfileUrl($res['id'])->whereIsBanned(true)->exists()) {
+            return;
+        }
+
         $urlDomain = parse_url($url, PHP_URL_HOST);
         $domain = parse_url($res['id'], PHP_URL_HOST);
         if (strtolower($urlDomain) !== strtolower($domain)) {

文件差异内容过多而无法显示
+ 195 - 177
composer.lock


+ 56 - 0
database/migrations/2024_10_15_044935_create_moderated_profiles_table.php

@@ -0,0 +1,56 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use App\Profile;
+use App\ModLog;
+use App\Models\ModeratedProfile;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('moderated_profiles', function (Blueprint $table) {
+            $table->id();
+            $table->string('profile_url')->unique()->nullable()->index();
+            $table->unsignedBigInteger('profile_id')->unique()->nullable();
+            $table->string('domain')->nullable();
+            $table->text('note')->nullable();
+            $table->boolean('is_banned')->default(false);
+            $table->boolean('is_nsfw')->default(false);
+            $table->boolean('is_unlisted')->default(false);
+            $table->boolean('is_noautolink')->default(false);
+            $table->boolean('is_nodms')->default(false);
+            $table->boolean('is_notrending')->default(false);
+            $table->timestamps();
+        });
+
+        $logs = ModLog::whereObjectType('App\Profile::class')->whereAction('admin.user.delete')->get();
+
+        foreach($logs as $log) {
+            $profile = Profile::withTrashed()->find($log->object_id);
+            if(!$profile || $profile->private_key) {
+                continue;
+            }
+            ModeratedProfile::updateOrCreate([
+                'profile_url' => $profile->remote_url,
+                'profile_id' => $profile->id,
+            ], [
+                'is_banned' => true,
+                'domain' => $profile->domain,
+            ]);
+        }
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('moderated_profiles');
+    }
+};

二进制
public/js/admin.js


二进制
public/mix-manifest.json


+ 543 - 12
resources/assets/components/admin/AdminReports.vue

@@ -147,15 +147,10 @@
                         </li>
                         <li class="d-none d-md-block nav-item">
                             <a
-                                href="/i/admin/reports/appeals"
-                                class="nav-link d-flex align-items-center">
-                                <span>Appeal Requests</span>
-                                <span
-                                    v-if="stats.appeals"
-                                    class="badge badge-sm badge-floating badge-secondary border-white ml-2"
-                                    style="font-size:11px;">
-                                    {{ prettyCount(stats.appeals) }}
-                                </span>
+                                :class="['nav-link d-flex align-items-center', { active: tabIndex == 4}]"
+                                href="#"
+                                @click.prevent="toggleTab(4)">
+                                <span>Moderated Profiles</span>
                             </a>
                         </li>
                     </ul>
@@ -418,6 +413,114 @@
                     Next
                 </button>
             </div>
+
+            <div v-if="this.tabIndex === 4" class="table-responsive rounded">
+                <div class="d-flex justify-content-between align-items-center mb-3">
+                    <form class="navbar-search navbar-search-dark form-inline mr-sm-3" @submit.prevent="handleModeratedProfileSearch">
+                        <div class="form-group mb-0">
+                            <div class="input-group input-group-alternative input-group-merge">
+                                <div class="input-group-prepend">
+                                    <span class="input-group-text">
+                                        <i class="fas fa-search"></i>
+                                    </span>
+                                </div>
+                                <input
+                                    type="text"
+                                    name="username"
+                                    placeholder="Search by username"
+                                    class="form-control"
+                                    v-model="moderatedProfilesSearchInput">
+                            </div>
+                        </div>
+                    </form>
+                    <div class="d-flex gap-1">
+                        <button type="button" class="btn btn-outline-primary fw-bold" @click="exportModeratedProfiles()">Export</button>
+                        <button type="button" class="btn btn-primary fw-bold" @click.prevent="addModeratedProfile()">Add Moderated Profile</button>
+                    </div>
+                </div>
+
+                <table v-if="moderatedProfiles && moderatedProfiles.length" class="table table-dark">
+                    <thead class="thead-dark">
+                        <tr>
+                            <th scope="col">ID</th>
+                            <th scope="col">Username</th>
+                            <th scope="col">Moderation</th>
+                            <th scope="col">Comment</th>
+                            <th scope="col">Created</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr
+                            v-for="(report, idx) in moderatedProfiles"
+                            :key="`remote-reports-${report.id}-${idx}`">
+                            <td class="font-weight-bold text-monospace text-muted align-middle">
+                                <button class="btn btn-primary btn-sm" @click.prevent="openModeratedProfileModal(report)">
+                                    {{ report.id }}
+                                </button>
+                            </td>
+                            <td class="align-middle">
+                                <p v-if="report.profile.name" class="small mb-0 text-muted">
+                                    {{ truncateText(report.profile.name, 40) }}
+                                </p>
+                                <p
+                                    class="font-weight-bold mb-0"
+                                    data-toggle="tooltip"
+                                    data-placement="bottom"
+                                    :title="report.profile.username">
+                                    {{ truncateText(report.profile.username, 40) }}
+                                </p>
+                            </td>
+                            <td class="align-middle">
+                                <p class="mb-0" v-html="getModerationLabels(report)"></p>
+                            </td>
+                            <td class="align-middle">
+                                <p class="small mb-0 text-wrap" style="max-width: 200px;word-break: break-word;">{{ truncateText(report.note, 140) }}</p>
+                            </td>
+                            <td class="font-weight-bold align-middle">
+                                <span
+                                    data-toggle="tooltip"
+                                    data-placement="bottom"
+                                    :title="report.created_at">
+                                    {{ timeAgo(report.created_at) }}
+                                </span>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+
+                <div v-else>
+                    <div class="card card-body p-5">
+                        <div class="d-flex justify-content-between align-items-center flex-column">
+
+                            <template v-if="moderatedProfilesSearchInput">
+                                <p class="mt-3 mb-0"><i class="far fa-times fa-5x text-danger"></i></p>
+                                <p class="lead">No results found!</p>
+                                <button class="btn btn-primary" @click.prevent="clearModeratedProfileSearch()">Go back</button>
+                            </template>
+
+                            <template v-else>
+                                <p class="mt-3 mb-0"><i class="far fa-check-circle fa-5x text-success"></i></p>
+                                <p class="lead">No active moderation accounts found!</p>
+                            </template>
+                        </div>
+                    </div>
+                </div>
+
+                <div v-if="moderatedProfiles && moderatedProfiles.length && (moderatedProfilesPagination.prev || moderatedProfilesPagination.next)" class="mt-3 d-flex align-items-center justify-content-center">
+                    <button
+                        class="btn btn-primary rounded-pill"
+                        :disabled="!moderatedProfilesPagination.prev"
+                        @click="paginateModeratedAccounts('prev')">
+                        Prev
+                    </button>
+                    <button
+                        class="btn btn-primary rounded-pill"
+                        :disabled="!moderatedProfilesPagination.next"
+                        @click="paginateModeratedAccounts('next')">
+                        Next
+                    </button>
+                </div>
+            </div>
         </div>
     </div>
 
@@ -756,6 +859,165 @@
             v-on:close="handleCloseRemoteReportModal()"
             v-on:refresh="refreshRemoteReports()" />
     </template>
+
+    <div
+        class="modal fade"
+        id="moderatedProfileView"
+        tabindex="-1"
+        role="dialog"
+        aria-labelledby="moderatedProfileViewLabel"
+        aria-hidden="true"
+        data-backdrop="static"
+        ref="moderatedProfileModal">
+      <div class="modal-dialog modal-dialog-centered" role="document">
+        <div v-if="modModalData" class="modal-content">
+          <div class="modal-header">
+            <div class="w-100 d-flex justify-content-between align-items-center">
+                <div class="flex-grow-1">
+                    <i class="far fa-shield-alt"></i>
+                </div>
+                <h5 class="mb-0 lead mt-0 font-weight-bold">Moderated Profile</h5>
+                <div class="flex-grow-1">
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" @click="closeModeratedProfileModal()">
+                      <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+            </div>
+          </div>
+          <div class="modal-body">
+            <div class="card mb-0">
+                <div class="card-body bg-lighter text-dark p-3 font-weight-bold d-flex align-items-center justify-content-center flex-column">
+                    <p v-if="modModalData?.profile?.name" class="mb-0 small text-muted">{{ modModalData?.profile?.name }}</p>
+                    <p class="mb-0 font-weight-bold">
+                        {{ modModalData?.profile?.username }}
+                    </p>
+                </div>
+            </div>
+            <p v-if="modModalData?.profile?.remote_url" class="small text-muted text-right mb-1">
+                <a :href="modModalData?.profile?.remote_url" rel="noreferrer" target="_blank">
+                    View remote profile
+                </a>
+            </p>
+
+            <div class="list-group mpl-form">
+                <div class="list-group-item d-flex justify-content-between align-items-center">
+                    <div class="mp-form-label">
+                        <div class="d-flex flex-column">
+                            <p class="mb-0 font-weight-bold">
+                                Banned
+                            </p>
+                            <p class="mb-0 small text-muted">
+                                Ban any activities from this account.
+                            </p>
+                        </div>
+                    </div>
+                    <div class="custom-control custom-checkbox">
+                        <input type="checkbox" class="custom-control-input" id="mp-form-is_banned" v-model="modModalModel.is_banned">
+                        <label class="custom-control-label" for="mp-form-is_banned"></label>
+                    </div>
+                </div>
+
+                <div class="list-group-item d-none justify-content-between align-items-center">
+                    <div class="mp-form-label">
+                        <div class="d-flex flex-column">
+                            <p class="mb-0 font-weight-bold">
+                                No Autolink
+                            </p>
+                            <p class="mb-0 small text-muted">
+                                Disable hashtag, mention and url autolinking from this account.
+                            </p>
+                        </div>
+                    </div>
+                    <div class="custom-control custom-checkbox">
+                        <input type="checkbox" class="custom-control-input" id="mp-form-is_noautolink" v-model="modModalModel.is_noautolink">
+                        <label class="custom-control-label" for="mp-form-is_noautolink"></label>
+                    </div>
+                </div>
+
+                <div class="list-group-item d-none justify-content-between align-items-center">
+                    <div class="mp-form-label">
+                        <div class="d-flex flex-column">
+                            <p class="mb-0 font-weight-bold">
+                                No DMs
+                            </p>
+                            <p class="mb-0 small text-muted">
+                                Ignore DMs from this account.
+                            </p>
+                        </div>
+                    </div>
+                    <div class="custom-control custom-checkbox">
+                        <input type="checkbox" class="custom-control-input" id="mp-form-is_nodms" v-model="modModalModel.is_nodms">
+                        <label class="custom-control-label" for="mp-form-is_nodms"></label>
+                    </div>
+                </div>
+
+                <div class="list-group-item d-none justify-content-between align-items-center">
+                    <div class="mp-form-label">
+                        <div class="d-flex flex-column">
+                            <p class="mb-0 font-weight-bold">
+                                No Trending
+                            </p>
+                            <p class="mb-0 small text-muted">
+                                Prevent posts from this account from appearing in trending lists or feeds.
+                            </p>
+                        </div>
+                    </div>
+                    <div class="custom-control custom-checkbox">
+                        <input type="checkbox" class="custom-control-input" id="mp-form-is_notrending" v-model="modModalModel.is_notrending">
+                        <label class="custom-control-label" for="mp-form-is_notrending"></label>
+                    </div>
+                </div>
+
+                <div class="list-group-item d-none justify-content-between align-items-center">
+                    <div class="mp-form-label">
+                        <div class="d-flex flex-column">
+                            <p class="mb-0 font-weight-bold">
+                                Mark NSFW
+                            </p>
+                            <p class="mb-0 small text-muted">
+                                Mark all posts as sensitive, and apply CWs to future posts.
+                            </p>
+                        </div>
+                    </div>
+                    <div class="custom-control custom-checkbox">
+                        <input type="checkbox" class="custom-control-input" id="mp-form-is_nsfw" v-model="modModalModel.is_nsfw">
+                        <label class="custom-control-label" for="mp-form-is_nsfw"></label>
+                    </div>
+                </div>
+
+                <div class="list-group-item d-none justify-content-between align-items-center">
+                    <div class="mp-form-label">
+                        <div class="d-flex flex-column">
+                            <p class="mb-0 font-weight-bold">
+                                Mark Unlisted
+                            </p>
+                            <p class="mb-0 small text-muted">
+                                Mark all future posts as unlisted, hidden from global/tag feeds.
+                            </p>
+                        </div>
+                    </div>
+                    <div class="custom-control custom-checkbox">
+                        <input type="checkbox" class="custom-control-input" id="mp-form-is_unlisted" v-model="modModalModel.is_unlisted">
+                        <label class="custom-control-label" for="mp-form-is_unlisted"></label>
+                    </div>
+                </div>
+            </div>
+
+            <div class="py-3">
+                <label class="small text-muted">Account Notes (only visible to admins)</label>
+                <textarea class="form-control" v-model="modModalData.note" placeholder="Add an optional note" maxlength="500"></textarea>
+            </div>
+          </div>
+          <div class="modal-footer d-flex justify-content-between align-items-center">
+            <button type="button" class="btn btn-link text-dark" data-dismiss="modal" @click="closeModeratedProfileModal()">Close</button>
+            <div class="d-flex flex-grow-1 align-items-center gap-1">
+                <button type="button" class="btn btn-danger" @click.prevent="handleModProfileModalDelete()">Delete</button>
+                <button type="button" class="btn btn-primary btn-block" @click.prevent="handleModProfileModalUpdate()">Save</button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
 </div>
 </template>
 
@@ -792,13 +1054,34 @@
                 viewingSpamReportLoading: false,
                 remoteReportsLoaded: false,
                 showRemoteReportModal: undefined,
-                remoteReportModalModel: {}
+                remoteReportModalModel: {},
+                moderatedProfiles: [],
+                moderatedProfilesPagination: {},
+                moderatedProfilesSearchInput: undefined,
+                modModalData: undefined,
+                modModalModel: {},
             }
         },
 
         mounted() {
             let u = new URLSearchParams(window.location.search);
-            if(u.has('tab') && u.has('id') && u.get('tab') === 'autospam') {
+            if(u.has('tab') && u.get('tab') === 'moderated-profiles' && u.has('action') && u.has('id') && u.get('action') === 'view') {
+                this.tabIndex = 4;
+                this.fetchModeratedAccounts();
+                this.fetchModeratedProfile(u.get('id'));
+            } else if(u.has('tab') && u.get('tab') === 'autospam' && !u.has('id')) {
+                this.tabIndex = 2;
+                this.fetchStats(null, '/i/admin/api/reports/spam/all');
+            } else if(u.has('tab') && u.get('tab') === 'closed') {
+                this.tabIndex = 1;
+                this.fetchStats('/i/admin/api/reports/all?filter=closed')
+            } else if(u.has('tab') && u.get('tab') === 'closed') {
+                this.tabIndex = 3;
+                this.fetchStats('/i/admin/api/reports/all?filter=remote')
+            } else if(u.has('tab') && u.get('tab') === 'moderated-profiles') {
+                this.tabIndex = 4;
+                this.fetchModeratedAccounts();
+            } else if(u.has('tab') && u.has('id') && u.get('tab') === 'autospam') {
                 this.fetchStats(null, '/i/admin/api/reports/spam/all');
                 this.fetchSpamReport(u.get('id'));
             } else if(u.has('tab') && u.has('id') && u.get('tab') === 'report') {
@@ -819,21 +1102,29 @@
                 switch(idx) {
                     case 0:
                         this.fetchStats('/i/admin/api/reports/all');
+                        window.history.pushState(null, null, '/i/admin/reports');
                     break;
 
                     case 1:
                         this.fetchStats('/i/admin/api/reports/all?filter=closed')
+                        window.history.pushState(null, null, '/i/admin/reports?tab=closed');
                     break;
 
                     case 2:
                         this.fetchStats(null, '/i/admin/api/reports/spam/all');
+                        window.history.pushState(null, null, '/i/admin/reports?tab=autospam');
                     break;
 
                     case 3:
                         this.fetchRemoteReports();
+                        window.history.pushState(null, null, '/i/admin/reports?tab=remote');
+                    break;
+
+                    case 4:
+                        this.fetchModeratedAccounts();
+                        window.history.pushState(null, null, '/i/admin/reports?tab=moderated-profiles');
                     break;
                 }
-                window.history.pushState(null, null, '/i/admin/reports');
                 this.tabIndex = idx;
             },
 
@@ -891,6 +1182,27 @@
                 });
             },
 
+            fetchModeratedAccounts(apiUrl = '/i/admin/api/reports/moderated-profiles') {
+                axios.get(apiUrl)
+                .then(res => {
+                    this.moderatedProfiles = res.data.data;
+                    this.moderatedProfilesPagination = {
+                        prev: res.data.links.prev,
+                        next: res.data.links.next
+                    };
+                })
+                .finally(() => {
+                    this.loaded = true;
+                    $('[data-toggle="tooltip"]').tooltip()
+                })
+            },
+
+            paginateModeratedAccounts(dir) {
+                event.currentTarget.blur();
+                let url = dir == 'next' ? this.moderatedProfilesPagination.next : this.moderatedProfilesPagination.prev;
+                this.fetchModeratedAccounts(url);
+            },
+
             fetchReports(url = '/i/admin/api/reports/all') {
                 axios.get(url)
                 .then(res => {
@@ -1149,7 +1461,226 @@
                     this.fetchStats();
                     window.history.pushState(null, null, '/i/admin/reports');
                 })
+            },
+
+            truncateText(text, maxLength, appendEllipsis = true) {
+                if(!text || !text.length) {
+                    return
+                }
+
+                if (text.length <= maxLength) {
+                    return text;
+                }
+
+                const truncated = text.slice(0, maxLength).trim();
+                return appendEllipsis ? truncated + '...' : truncated;
+            },
+
+            getModerationLabels(acct) {
+                if(acct.is_banned) {
+                    return `<span class="badge badge-danger">Banned</span>`
+                }
+
+                let labels = [];
+
+                if(acct.is_banned) labels.push('Banned')
+                if(acct.is_noautolink) labels.push('No Autolink')
+                if(acct.is_nodms) labels.push('No DMS')
+                if(acct.is_notrending) labels.push('No Trending')
+                if(acct.is_nsfw) labels.push('NSFW')
+                if(acct.is_unlisted) labels.push('Unlisted')
+
+                return labels.map((item, index) => {
+                    const colorClass = item === 'Banned' ? 'danger' : 'primary';
+                    return `<span class="badge badge-${colorClass}">${item}</span>`;
+                }).join(' ');
+            },
+
+            handleModeratedProfileSearch(event) {
+                event.currentTarget.blur()
+                let url = `/i/admin/api/reports/moderated-profiles?search=${this.moderatedProfilesSearchInput}`
+                this.fetchModeratedAccounts(url)
+            },
+
+            clearModeratedProfileSearch() {
+                this.moderatedProfilesSearchInput = undefined;
+                this.fetchModeratedAccounts();
+            },
+
+            openModeratedProfileModal(report) {
+                this.modModalData = report;
+                this.modModalModel = {
+                    is_banned: report.is_banned,
+                    is_noautolink: report.is_noautolink,
+                    is_nodms: report.is_nodms,
+                    is_notrending: report.is_notrending,
+                    is_nsfw: report.is_nsfw,
+                    is_unlisted: report.is_unlisted,
+                }
+                $(this.$refs.moderatedProfileModal).modal('show');
+                window.history.pushState(null, null, `/i/admin/reports?tab=moderated-profiles&action=view&id=${report.id}`)
+            },
+
+            handleModProfileModalUpdate() {
+                axios.post(
+                    '/i/admin/api/reports/moderated-profiles/update',
+                    {...this.modModalData, ...this.modModalModel}
+                ).then(res => {
+                    window.history.pushState(null, null, `/i/admin/reports?tab=moderated-profiles`)
+                    window.location.reload();
+                }).catch(error => {
+                    let errorMessage = 'An error occurred';
+                    if (error.response) {
+                        errorMessage = `Error ${error.response.status}: ${error.response.data.error || error.response.data.message || error.response.statusText}`;
+                    } else if (error.request) {
+                        errorMessage = 'No response received from server';
+                    } else {
+                        errorMessage = error.message;
+                    }
+                    swal('Error', errorMessage, 'error')
+                }).finally(() => {
+                    $(this.$refs.moderatedProfileModal).modal('hide');
+                })
+            },
+
+            handleModProfileModalDelete() {
+                swal({
+                    title: 'Confirm Delete',
+                    text: 'Are you sure you want to delete this moderated profile ruleset?',
+                    buttons: {
+                        cancel: "Cancel",
+                        danger: {
+                            text: "Delete",
+                            value: 'delete',
+                        }
+                    }
+                }).then((val) => {
+                    if(val === 'delete') {
+                        axios.post('/i/admin/api/reports/moderated-profiles/delete', { id: this.modModalData.id})
+                        .then(res => {
+                            window.history.pushState(null, null, '/i/admin/reports?tab=moderated-profiles');
+                            window.location.reload();
+                        })
+                    }
+                    $(this.$refs.moderatedProfileModal).modal('hide');
+                    swal.close()
+                })
+            },
+
+            fetchModeratedProfile(id) {
+                axios.get(`/i/admin/api/reports/moderated-profiles/show?id=${id}`)
+                .then(res => {
+                    this.modModalData = res.data.data;
+                    let report = res.data.data;
+
+                    this.modModalModel = {
+                        is_banned: report.is_banned,
+                        is_noautolink: report.is_noautolink,
+                        is_nodms: report.is_nodms,
+                        is_notrending: report.is_notrending,
+                        is_nsfw: report.is_nsfw,
+                        is_unlisted: report.is_unlisted,
+                    }
+
+                    $(this.$refs.moderatedProfileModal).modal('show');
+                }).catch(err => {
+                    window.history.pushState(null, null, '/i/admin/reports?tab=moderated-profiles');
+                    swal('Error', 'Invalid moderated profile id!', 'error');
+                })
+            },
+
+            addModeratedProfile() {
+                swal({
+                    text: 'Enter profile URL (ie: https://mastodon.social/@Mastodon)',
+                    content: "input",
+                    button: {
+                        text: "Add",
+                        closeModal: false,
+                    },
+                }).then(val => {
+                    if (!val) throw null;
+
+                    if(val.startsWith('@')) {
+                        swal('Error', 'Invalid URL, webfinger is not supported yet.', 'error');
+                        throw null;
+                    }
+
+                    if(!val.startsWith('http')) {
+                        swal('Error', 'Invalid URL', 'error');
+                        throw null;
+                    }
+
+                    if(val.indexOf('.') === -1) {
+                        swal('Error', 'Invalid URL', 'error');
+                        throw null;
+                    }
+
+                    let params = {
+                        url: val
+                    }
+
+                    return axios.post('/i/admin/api/reports/moderated-profiles/create', params);
+                }).then(json => {
+                    if(json && json.data && json.data?.id) {
+                        window.location.href = `/i/admin/reports?tab=moderated-profiles&action=view&id=${json.data?.id}`
+                        return;
+                    }
+                    swal.stopLoading();
+                    swal.close();
+                }).catch(err => {
+                    if (err) {
+                        if(err?.response?.data?.error) {
+                            swal("Error", err?.response?.data?.error, "error");
+                        } else {
+                            swal("Error", "Something went wrong!", "error");
+                        }
+                    } else {
+                        swal.stopLoading();
+                        swal.close();
+                    }
+                });
+            },
+
+            closeModeratedProfileModal() {
+                window.history.pushState(null, null, '/i/admin/reports?tab=moderated-profiles');
+            },
+
+            exportModeratedProfiles() {
+                axios.get('/i/admin/api/reports/moderated-profiles/export', {
+                    responseType: "blob"
+                })
+                .then(res => {
+                    let host = new URL(window.location.href)
+                    let date = new Date();
+                    let dateStamp = `${date.getMonth()}-${date.getDate()}-${date.getFullYear()}-${Date.now()}`;
+                    let filename = host.host + '-moderated-profiles-' + dateStamp + '.json';
+                    let el = document.createElement('a');
+                    el.setAttribute('download', filename)
+                    const href = URL.createObjectURL(res.data);
+                    el.href = href;
+                    el.setAttribute('target', '_blank');
+                    el.click();
+
+                    swal(
+                        'Success!',
+                        'You have successfully exported the moderated profile backup.',
+                        'success'
+                    )
+                })
             }
         }
     }
 </script>
+
+<style lang="scss" scoped>
+    .mpl-form {
+        p {
+            line-height: 1;
+
+            &:first-child {
+                font-size: 14px;
+                line-height: 1.6;
+            }
+        }
+    }
+</style>

+ 6 - 0
routes/web-admin.php

@@ -148,6 +148,12 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
         Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi');
         Route::get('instances/download-backup', 'AdminController@downloadBackup');
         Route::post('instances/import-data', 'AdminController@importBackup');
+        Route::get('reports/moderated-profiles', 'AdminController@getModeratedProfiles');
+        Route::post('reports/moderated-profiles/update', 'AdminController@updateModeratedProfile');
+        Route::post('reports/moderated-profiles/create', 'AdminController@createModeratedProfile');
+        Route::get('reports/moderated-profiles/show', 'AdminController@getModeratedProfile');
+        Route::post('reports/moderated-profiles/delete', 'AdminController@deleteModeratedProfile');
+        Route::get('reports/moderated-profiles/export', 'AdminController@exportModeratedProfiles');
         Route::get('reports/stats', 'AdminController@reportsStats');
         Route::get('reports/all', 'AdminController@reportsApiAll');
         Route::get('reports/remote', 'AdminController@reportsApiRemote');

部分文件因为文件数量过多而无法显示