Merge pull request #4323 from pixelfed/staging

Redesigned Admin Dashboard Reports/Moderation
This commit is contained in:
daniel 2023-04-24 01:55:23 -06:00 committed by GitHub
commit 3b8072cde8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1652 additions and 94 deletions

View file

@ -7,6 +7,7 @@
- New media:fix-nonlocal-driver command. Fixes s3 media created with invalid FILESYSTEM_DRIVER=s3 configuration ([672cccd4](https://github.com/pixelfed/pixelfed/commit/672cccd4)) - New media:fix-nonlocal-driver command. Fixes s3 media created with invalid FILESYSTEM_DRIVER=s3 configuration ([672cccd4](https://github.com/pixelfed/pixelfed/commit/672cccd4))
- New landing page design ([09c0032b](https://github.com/pixelfed/pixelfed/commit/09c0032b)) - New landing page design ([09c0032b](https://github.com/pixelfed/pixelfed/commit/09c0032b))
- Add cloud ip bans to BouncerService ([50ab2e20](https://github.com/pixelfed/pixelfed/commit/50ab2e20)) - Add cloud ip bans to BouncerService ([50ab2e20](https://github.com/pixelfed/pixelfed/commit/50ab2e20))
- Redesigned Admin Dashboard Reports/Moderation ([c6cc6327](https://github.com/pixelfed/pixelfed/commit/c6cc6327))
### Updates ### Updates
- Update ApiV1Controller, fix blocking remote accounts. Closes #4256 ([8e71e0c0](https://github.com/pixelfed/pixelfed/commit/8e71e0c0)) - Update ApiV1Controller, fix blocking remote accounts. Closes #4256 ([8e71e0c0](https://github.com/pixelfed/pixelfed/commit/8e71e0c0))

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use Cache; use Cache;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use App\Services\AccountService; use App\Services\AccountService;
use App\Services\StatusService; use App\Services\StatusService;
@ -24,6 +25,13 @@ use Illuminate\Validation\Rule;
use App\Services\StoryService; use App\Services\StoryService;
use App\Services\ModLogService; use App\Services\ModLogService;
use App\Jobs\DeletePipeline\DeleteAccountPipeline; use App\Jobs\DeletePipeline\DeleteAccountPipeline;
use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
use App\Jobs\StatusPipeline\StatusDelete;
use App\Http\Resources\AdminReport;
use App\Http\Resources\AdminSpamReport;
use App\Services\PublicTimelineService;
use App\Services\NetworkTimelineService;
trait AdminReportController trait AdminReportController
{ {
@ -74,6 +82,9 @@ trait AdminReportController
public function showReport(Request $request, $id) public function showReport(Request $request, $id)
{ {
$report = Report::with('status')->findOrFail($id); $report = Report::with('status')->findOrFail($id);
if($request->has('ref') && $request->input('ref') == 'email') {
return redirect('/i/admin/reports?tab=report&id=' . $report->id);
}
return view('admin.reports.show', compact('report')); return view('admin.reports.show', compact('report'));
} }
@ -200,6 +211,9 @@ trait AdminReportController
{ {
$appeal = AccountInterstitial::whereType('post.autospam') $appeal = AccountInterstitial::whereType('post.autospam')
->findOrFail($id); ->findOrFail($id);
if($request->has('ref') && $request->input('ref') == 'email') {
return redirect('/i/admin/reports?tab=autospam&id=' . $appeal->id);
}
$meta = json_decode($appeal->meta); $meta = json_decode($appeal->meta);
return view('admin.reports.show_spam', compact('appeal', 'meta')); return view('admin.reports.show_spam', compact('appeal', 'meta'));
} }
@ -601,4 +615,588 @@ trait AdminReportController
Redis::del('email:manual-ignored'); Redis::del('email:manual-ignored');
return [200]; return [200];
} }
public function reportsStats(Request $request)
{
$stats = [
'total' => Report::count(),
'open' => Report::whereNull('admin_seen')->count(),
'closed' => Report::whereNotNull('admin_seen')->count(),
'autospam' => AccountInterstitial::whereType('post.autospam')->count(),
'autospam_open' => AccountInterstitial::whereType('post.autospam')->whereNull(['appeal_handled_at'])->count(),
'appeals' => AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count(),
'email_verification_requests' => Redis::scard('email:manual')
];
return $stats;
}
public function reportsApiAll(Request $request)
{
$filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
$reports = AdminReport::collection(
Report::orderBy('id','desc')
->when($filter, function($q, $filter) {
return $filter == 'open' ?
$q->whereNull('admin_seen') :
$q->whereNotNull('admin_seen');
})
->groupBy(['object_id', 'object_type'])
->cursorPaginate(6)
->withQueryString()
);
return $reports;
}
public function reportsApiGet(Request $request, $id)
{
$report = Report::findOrFail($id);
return new AdminReport($report);
}
public function reportsApiHandle(Request $request)
{
$this->validate($request, [
'object_id' => 'required',
'object_type' => 'required',
'id' => 'required',
'action' => 'required|in:ignore,nsfw,unlist,private,delete',
'action_type' => 'required|in:post,profile'
]);
$report = Report::whereObjectId($request->input('object_id'))->findOrFail($request->input('id'));
if($request->input('action_type') === 'profile') {
return $this->reportsHandleProfileAction($report, $request->input('action'));
} else if($request->input('action_type') === 'post') {
return $this->reportsHandleStatusAction($report, $request->input('action'));
}
return $report;
}
protected function reportsHandleProfileAction($report, $action)
{
switch($action) {
case 'ignore':
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'nsfw':
if($report->object_type === 'App\Profile') {
$profile = Profile::find($report->object_id);
} else if($report->object_type === 'App\Status') {
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = Profile::find($status->profile_id);
}
if(!$profile) {
return;
}
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
$profile->cw = true;
$profile->save();
foreach(Status::whereProfileId($profile->id)->cursor() as $status) {
$status->is_nsfw = true;
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
}
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user(request()->user())
->action('admin.user.moderate')
->metadata([
'action' => 'cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'nsfw' => true,
'admin_seen' => now()
]);
return [200];
break;
case 'unlist':
if($report->object_type === 'App\Profile') {
$profile = Profile::find($report->object_id);
} else if($report->object_type === 'App\Status') {
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = Profile::find($status->profile_id);
}
if(!$profile) {
return;
}
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
$profile->unlisted = true;
$profile->save();
foreach(Status::whereProfileId($profile->id)->whereScope('public')->cursor() as $status) {
$status->scope = 'unlisted';
$status->visibility = 'unlisted';
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
}
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user(request()->user())
->action('admin.user.moderate')
->metadata([
'action' => 'unlisted',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'private':
if($report->object_type === 'App\Profile') {
$profile = Profile::find($report->object_id);
} else if($report->object_type === 'App\Status') {
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = Profile::find($status->profile_id);
}
if(!$profile) {
return;
}
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
$profile->unlisted = true;
$profile->save();
foreach(Status::whereProfileId($profile->id)->cursor() as $status) {
$status->scope = 'private';
$status->visibility = 'private';
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
}
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user(request()->user())
->action('admin.user.moderate')
->metadata([
'action' => 'private',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'delete':
if(config('pixelfed.account_deletion') == false) {
abort(404);
}
if($report->object_type === 'App\Profile') {
$profile = Profile::find($report->object_id);
} else if($report->object_type === 'App\Status') {
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = Profile::find($status->profile_id);
}
if(!$profile) {
return;
}
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account.');
$ts = now()->addMonth();
if($profile->user_id) {
$user = $profile->user;
abort_if($user->is_admin, 403, 'You cannot delete admin accounts.');
$user->status = 'delete';
$user->delete_after = $ts;
$user->save();
}
$profile->status = 'delete';
$profile->delete_after = $ts;
$profile->save();
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user(request()->user())
->action('admin.user.delete')
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
if($profile->user_id) {
DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
$user->email = $user->id;
$user->password = '';
$user->status = 'delete';
$user->save();
$profile->status = 'delete';
$profile->delete_after = now()->addMonth();
$profile->save();
AccountService::del($profile->id);
DeleteAccountPipeline::dispatch($user)->onQueue('high');
} else {
$profile->status = 'delete';
$profile->delete_after = now()->addMonth();
$profile->save();
AccountService::del($profile->id);
DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('high');
}
return [200];
break;
}
}
protected function reportsHandleStatusAction($report, $action)
{
switch($action) {
case 'ignore':
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'nsfw':
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
$status->is_nsfw = true;
$status->save();
StatusService::del($status->id);
ModLogService::boot()
->objectUid($status->profile_id)
->objectId($status->profile_id)
->objectType('App\Status::class')
->user(request()->user())
->action('admin.status.moderate')
->metadata([
'action' => 'cw',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'nsfw' => true,
'admin_seen' => now()
]);
return [200];
break;
case 'private':
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
$status->scope = 'private';
$status->visibility = 'private';
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
ModLogService::boot()
->objectUid($status->profile_id)
->objectId($status->profile_id)
->objectType('App\Status::class')
->user(request()->user())
->action('admin.status.moderate')
->metadata([
'action' => 'private',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'unlist':
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
if($status->scope === 'public') {
$status->scope = 'unlisted';
$status->visibility = 'unlisted';
$status->save();
StatusService::del($status->id);
PublicTimelineService::rem($status->id);
}
ModLogService::boot()
->objectUid($status->profile_id)
->objectId($status->profile_id)
->objectType('App\Status::class')
->user(request()->user())
->action('admin.status.moderate')
->metadata([
'action' => 'unlist',
'message' => 'Success!'
])
->accessLevel('admin')
->save();
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
case 'delete':
$status = Status::find($report->object_id);
if(!$status) {
return [200];
}
$profile = $status->profile;
abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account post.');
StatusService::del($status->id);
if($profile->user_id != null && $profile->domain == null) {
PublicTimelineService::del($status->id);
StatusDelete::dispatch($status)->onQueue('high');
} else {
NetworkTimelineService::del($status->id);
DeleteRemoteStatusPipeline::dispatch($status)->onQueue('high');
}
Report::whereObjectId($report->object_id)
->whereObjectType($report->object_type)
->update([
'admin_seen' => now()
]);
return [200];
break;
}
}
public function reportsApiSpamAll(Request $request)
{
$tab = $request->input('tab', 'home');
$appeals = AdminSpamReport::collection(
AccountInterstitial::orderBy('id', 'desc')
->whereType('post.autospam')
->whereNull('appeal_handled_at')
->cursorPaginate(6)
->withQueryString()
);
return $appeals;
}
public function reportsApiSpamHandle(Request $request)
{
$this->validate($request, [
'id' => 'required',
'action' => 'required|in:mark-read,mark-not-spam,mark-all-read,mark-all-not-spam,delete-profile',
]);
$action = $request->input('action');
abort_if(
$action === 'delete-profile' &&
!config('pixelfed.account_deletion'),
404,
"Cannot delete profile, account_deletion is disabled.\n\n Set `ACCOUNT_DELETION=true` in .env and re-cache config."
);
$report = AccountInterstitial::with('user')
->whereType('post.autospam')
->whereNull('appeal_handled_at')
->findOrFail($request->input('id'));
$this->reportsHandleSpamAction($report, $action);
Cache::forget('admin-dash:reports:spam-count');
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $report->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $report->user->profile_id);
PublicTimelineService::warmCache(true, 400);
return [$action, $report];
}
public function reportsHandleSpamAction($appeal, $action)
{
$meta = json_decode($appeal->meta);
if($action == 'mark-read') {
$appeal->is_spam = true;
$appeal->appeal_handled_at = now();
$appeal->save();
}
if($action == 'mark-not-spam') {
$status = $appeal->status;
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
$appeal->is_spam = false;
$appeal->appeal_handled_at = now();
$appeal->save();
StatusService::del($status->id);
}
if($action == 'mark-all-read') {
AccountInterstitial::whereType('post.autospam')
->whereItemType('App\Status')
->whereNull('appeal_handled_at')
->whereUserId($appeal->user_id)
->update([
'appeal_handled_at' => now(),
'is_spam' => true
]);
}
if($action == 'mark-all-not-spam') {
AccountInterstitial::whereType('post.autospam')
->whereItemType('App\Status')
->whereUserId($appeal->user_id)
->get()
->each(function($report) use($meta) {
$report->is_spam = false;
$report->appeal_handled_at = now();
$report->save();
$status = Status::find($report->item_id);
if($status) {
$status->is_nsfw = $meta->is_nsfw;
$status->scope = 'public';
$status->visibility = 'public';
$status->save();
StatusService::del($status->id);
}
});
}
if($action == 'delete-profile') {
$user = User::findOrFail($appeal->user_id);
$profile = $user->profile;
if($user->is_admin == true) {
$mid = request()->user()->id;
abort_if($user->id < $mid, 403, 'You cannot delete an admin account.');
}
$ts = now()->addMonth();
$user->status = 'delete';
$profile->status = 'delete';
$user->delete_after = $ts;
$profile->delete_after = $ts;
$user->save();
$profile->save();
$appeal->appeal_handled_at = now();
$appeal->save();
ModLogService::boot()
->objectUid($user->id)
->objectId($user->id)
->objectType('App\User::class')
->user(request()->user())
->action('admin.user.delete')
->accessLevel('admin')
->save();
Cache::forget('profiles:private');
DeleteAccountPipeline::dispatch($user);
}
}
public function reportsApiSpamGet(Request $request, $id)
{
$report = AccountInterstitial::findOrFail($id);
return new AdminSpamReport($report);
}
} }

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use App\Services\AccountService;
use App\Services\StatusService;
class AdminReport extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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,
];
if($this->object_id && $this->object_type === 'App\Status') {
$res['status'] = StatusService::get($this->object_id, false);
}
return $res;
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use App\Services\AccountService;
use App\Services\StatusService;
class AdminSpamReport extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$res = [
'id' => $this->id,
'type' => $this->type,
'status' => null,
'read_at' => $this->read_at,
'created_at' => $this->created_at,
];
if($this->item_id && $this->item_type === 'App\Status') {
$res['status'] = StatusService::get($this->item_id, false);
}
return $res;
}
}

BIN
public/js/admin.js vendored

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,937 @@
<template>
<div>
<div class="header bg-primary pb-3 mt-n4">
<div class="container-fluid">
<div class="header-body">
<div class="row align-items-center py-4">
<div class="col-lg-6 col-7">
<p class="display-1 text-white d-inline-block mb-0">Moderation</p>
</div>
</div>
<div class="row">
<div class="col-12 col-sm-6 col-lg-3">
<div class="mb-3">
<h5 class="text-light text-uppercase mb-0">Active Reports</h5>
<span
class="text-white h2 font-weight-bold mb-0 human-size"
data-toggle="tooltip"
data-placement="bottom"
:title="stats.open + ' open reports'">
{{ prettyCount(stats.open) }}
</span>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<div class="mb-3">
<h5 class="text-light text-uppercase mb-0">Active Spam Detections</h5>
<span
class="text-white h2 font-weight-bold mb-0 human-size"
data-toggle="tooltip"
data-placement="bottom"
:title="stats.autospam_open + ' open spam detections'"
>{{ prettyCount(stats.autospam_open) }}</span>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<div class="mb-3">
<h5 class="text-light text-uppercase mb-0">Total Reports</h5>
<span
class="text-white h2 font-weight-bold mb-0 human-size"
data-toggle="tooltip"
data-placement="bottom"
:title="stats.total + ' total reports'"
>{{ prettyCount(stats.total) }}
</span>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<div class="mb-3">
<h5 class="text-light text-uppercase mb-0">Total Spam Detections</h5>
<span
class="text-white h2 font-weight-bold mb-0 human-size"
data-toggle="tooltip"
data-placement="bottom"
:title="stats.autospam + ' total spam detections'">
{{ prettyCount(stats.autospam) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="!loaded" class="my-5 text-center">
<b-spinner />
</div>
<div v-else class="m-n2 m-lg-4">
<div class="container-fluid mt-4">
<div class="row mb-3 justify-content-between">
<div class="col-12">
<ul class="nav nav-pills">
<li class="nav-item">
<a
:class="['nav-link d-flex align-items-center', { active: tabIndex == 0}]"
href="#"
@click.prevent="toggleTab(0)">
<span>Open Reports</span>
<span
v-if="stats.open"
class="badge badge-sm badge-floating badge-danger border-white ml-2"
style="background-color: red;color:white;font-size:11px;">
{{prettyCount(stats.open)}}
</span>
</a>
</li>
<li class="nav-item">
<a
:class="['nav-link d-flex align-items-center', { active: tabIndex == 2}]"
href="#"
@click.prevent="toggleTab(2)">
<span>Spam Detections</span>
<span
v-if="stats.autospam_open"
class="badge badge-sm badge-floating badge-danger border-white ml-2"
style="background-color: red;color:white;font-size:11px;">
{{prettyCount(stats.autospam_open)}}
</span>
</a>
</li>
<li class="d-none d-md-block nav-item">
<a
:class="['nav-link d-flex align-items-center', { active: tabIndex == 1}]"
href="#"
@click.prevent="toggleTab(1)">
<span>Closed Reports</span>
<span
v-if="stats.autospam_open"
class="badge badge-sm badge-floating badge-secondary border-white ml-2"
style="font-size:11px;">
{{prettyCount(stats.closed)}}
</span>
</a>
</li>
<li class="d-none d-md-block nav-item">
<a
href="/i/admin/reports/email-verifications"
class="nav-link d-flex align-items-center">
<span>Email Verification Requests</span>
<span
v-if="stats.email_verification_requests"
class="badge badge-sm badge-floating badge-secondary border-white ml-2"
style="font-size:11px;">
{{prettyCount(stats.email_verification_requests)}}
</span>
</a>
</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>
</a>
</li>
</ul>
</div>
</div>
<div v-if="[0, 1].includes(this.tabIndex)" class="table-responsive rounded">
<table v-if="reports && reports.length" class="table table-dark">
<thead class="thead-dark">
<tr>
<th scope="col">ID</th>
<th scope="col">Report</th>
<th scope="col">Reported Account</th>
<th scope="col">Reported By</th>
<th scope="col">Created</th>
<th scope="col">View Report</th>
</tr>
</thead>
<tbody>
<tr v-for="(report, idx) in reports">
<td class="font-weight-bold text-monospace text-muted align-middle">
<a href="#" @click.prevent="viewReport(report)">
{{ report.id }}
</a>
</td>
<td class="align-middle">
<p class="text-capitalize font-weight-bold mb-0" v-html="reportLabel(report)"></p>
</td>
<td class="align-middle">
<a v-if="report.reported && report.reported.id" :href="`/i/web/profile/${report.reported.id}`" target="_blank" class="text-white">
<div class="d-flex align-items-center" style="gap:0.61rem;">
<img
:src="report.reported.avatar"
width="30"
height="30"
style="object-fit: cover;border-radius:30px;"
onerror="this.src='/storage/avatars/default.png';this.error=null;">
<div class="d-flex flex-column">
<p class="font-weight-bold mb-0" style="font-size: 14px;">@{{report.reported.username}}</p>
<div class="d-flex small text-muted mb-0" style="gap: 0.5rem;">
<span>{{report.reported.followers_count}} Followers</span>
<span>·</span>
<span>Joined {{ timeAgo(report.reported.created_at) }}</span>
</div>
</div>
</div>
</a>
</td>
<td class="align-middle">
<a :href="`/i/web/profile/${report.reporter.id}`" target="_blank" class="text-white">
<div class="d-flex align-items-center" style="gap:0.61rem;">
<img
:src="report.reporter.avatar"
width="30"
height="30"
style="object-fit: cover;border-radius:30px;"
onerror="this.src='/storage/avatars/default.png';this.error=null;">
<div class="d-flex flex-column">
<p class="font-weight-bold mb-0" style="font-size: 14px;">@{{report.reporter.username}}</p>
<div class="d-flex small text-muted mb-0" style="gap: 0.5rem;">
<span>{{report.reporter.followers_count}} Followers</span>
<span>·</span>
<span>Joined {{ timeAgo(report.reporter.created_at) }}</span>
</div>
</div>
</div>
</a>
</td>
<td class="font-weight-bold align-middle">{{ timeAgo(report.created_at) }}</td>
<td class="align-middle"><a href="#" class="btn btn-primary btn-sm" @click.prevent="viewReport(report)">View</a></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">
<p class="mt-3 mb-0"><i class="far fa-check-circle fa-5x text-success"></i></p>
<p class="lead">{{ tabIndex === 0 ? 'No Active Reports Found!' : 'No Closed Reports Found!' }}</p>
</div>
</div>
</div>
</div>
<div v-if="[0, 1].includes(this.tabIndex) && reports.length && (pagination.prev || pagination.next)" class="d-flex align-items-center justify-content-center">
<button
class="btn btn-primary rounded-pill"
:disabled="!pagination.prev"
@click="paginate('prev')">
Prev
</button>
<button
class="btn btn-primary rounded-pill"
:disabled="!pagination.next"
@click="paginate('next')">
Next
</button>
</div>
<div v-if="this.tabIndex === 2" class="table-responsive rounded">
<template v-if="autospamLoaded">
<table v-if="autospam && autospam.length" class="table table-dark">
<thead class="thead-dark">
<tr>
<th scope="col">ID</th>
<th scope="col">Report</th>
<th scope="col">Reported Account</th>
<th scope="col">Created</th>
<th scope="col">View Report</th>
</tr>
</thead>
<tbody>
<tr v-for="(report, idx) in autospam">
<td class="font-weight-bold text-monospace text-muted align-middle">
<a href="#" @click.prevent="viewSpamReport(report)">
{{ report.id }}
</a>
</td>
<td class="align-middle">
<p class="text-capitalize font-weight-bold mb-0">Spam Post</p>
</td>
<td class="align-middle">
<a v-if="report.status && report.status.account" :href="`/i/web/profile/${report.status.account.id}`" target="_blank" class="text-white">
<div class="d-flex align-items-center" style="gap:0.61rem;">
<img
:src="report.status.account.avatar"
width="30"
height="30"
style="object-fit: cover;border-radius:30px;"
onerror="this.src='/storage/avatars/default.png';this.error=null;">
<div class="d-flex flex-column">
<p class="font-weight-bold mb-0" style="font-size: 14px;">@{{report.status.account.username}}</p>
<div class="d-flex small text-muted mb-0" style="gap: 0.5rem;">
<span>{{report.status.account.followers_count}} Followers</span>
<span>·</span>
<span>Joined {{ timeAgo(report.status.account.created_at) }}</span>
</div>
</div>
</div>
</a>
</td>
<td class="font-weight-bold align-middle">{{ timeAgo(report.created_at) }}</td>
<td class="align-middle"><a href="#" class="btn btn-primary btn-sm" @click.prevent="viewSpamReport(report)">View</a></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">
<p class="mt-3 mb-0"><i class="far fa-check-circle fa-5x text-success"></i></p>
<p class="lead">No Spam Reports Found!</p>
</div>
</div>
</div>
</template>
<div v-else class="d-flex align-items-center justify-content-center" style="min-height: 300px;">
<b-spinner />
</div>
</div>
<div v-if="this.tabIndex === 2 && autospamLoaded && autospam && autospam.length" class="d-flex align-items-center justify-content-center">
<button
class="btn btn-primary rounded-pill"
:disabled="!autospamPagination.prev"
@click="autospamPaginate('prev')">
Prev
</button>
<button
class="btn btn-primary rounded-pill"
:disabled="!autospamPagination.next"
@click="autospamPaginate('next')">
Next
</button>
</div>
</div>
</div>
<b-modal v-model="showReportModal" :title="tabIndex === 0 ? 'View Report' : 'Viewing Closed Report'" :ok-only="true" ok-title="Close" ok-variant="outline-primary">
<div v-if="viewingReportLoading" class="d-flex align-items-center justify-content-center">
<b-spinner />
</div>
<template v-else>
<div v-if="viewingReport" class="list-group">
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Type</div>
<div class="font-weight-bold text-capitalize" v-html="reportLabel(viewingReport)"></div>
</div>
<div v-if="viewingReport.admin_seen_at" class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-muted small">Report Closed</div>
<div class="font-weight-bold text-capitalize">{{ formatDate(viewingReport.admin_seen_at) }}</div>
</div>
<div v-if="viewingReport.reporter_message" class="list-group-item d-flex flex-column" style="gap:10px;">
<div class="text-muted small">Message</div>
<p class="mb-0 read-more" style="font-size: 12px;overflow-y: hidden;">{{ viewingReport.reporter_message }}</p>
</div>
</div>
<div class="list-group list-group-horizontal mt-3">
<div v-if="viewingReport && viewingReport.reported" class="list-group-item d-flex align-items-center justify-content-between flex-column flex-grow-1" style="gap:0.4rem;">
<div class="text-muted small font-weight-bold mt-n1">Reported Account</div>
<a v-if="viewingReport.reported && viewingReport.reported.id" :href="`/i/web/profile/${viewingReport.reported.id}`" target="_blank" class="text-primary">
<div class="d-flex align-items-center" style="gap:0.61rem;">
<img
:src="viewingReport.reported.avatar"
width="30"
height="30"
style="object-fit: cover;border-radius:30px;"
onerror="this.src='/storage/avatars/default.png';this.error=null;">
<div class="d-flex flex-column">
<p class="font-weight-bold mb-0 text-break" style="font-size: 12px;max-width: 140px;line-height: 16px;" :class="[ viewingReport.reported.is_admin ? 'text-danger': '']">@{{viewingReport.reported.acct}}</p>
<div class="d-flex text-muted mb-0" style="font-size: 10px;gap: 0.5rem;">
<span>{{viewingReport.reported.followers_count}} Followers</span>
<span>·</span>
<span>Joined {{ timeAgo(viewingReport.reported.created_at) }}</span>
</div>
</div>
</div>
</a>
</div>
<div v-if="viewingReport && viewingReport.reporter" class="list-group-item d-flex align-items-center justify-content-between flex-column flex-grow-1" style="gap:0.4rem;">
<div class="text-muted small font-weight-bold mt-n1">Reporter Account</div>
<a v-if="viewingReport.reporter && viewingReport.reporter.id" :href="`/i/web/profile/${viewingReport.reporter.id}`" target="_blank" class="text-primary">
<div class="d-flex align-items-center" style="gap:0.61rem;">
<img
:src="viewingReport.reporter.avatar"
width="30"
height="30"
style="object-fit: cover;border-radius:30px;"
onerror="this.src='/storage/avatars/default.png';this.error=null;">
<div class="d-flex flex-column">
<p class="font-weight-bold mb-0 text-break" style="font-size: 12px;max-width: 140px;line-height: 16px;">@{{viewingReport.reporter.acct}}</p>
<div class="d-flex text-muted mb-0" style="font-size: 10px;gap: 0.5rem;">
<span>{{viewingReport.reporter.followers_count}} Followers</span>
<span>·</span>
<span>Joined {{ timeAgo(viewingReport.reporter.created_at) }}</span>
</div>
</div>
</div>
</a>
</div>
</div>
<div v-if="viewingReport && viewingReport.object_type === 'App\\Status' && viewingReport.status" class="list-group mt-3">
<div v-if="viewingReport && viewingReport.status && viewingReport.status.media_attachments.length" class="list-group-item d-flex flex-column flex-grow-1" style="gap:0.4rem;">
<div class="d-flex justify-content-between mt-n1 text-muted small font-weight-bold">
<div>Reported Post</div>
<a class="font-weight-bold" :href="viewingReport.status.url" target="_blank">View</a>
</div>
<img
v-if="viewingReport.status.media_attachments[0].type === 'image'"
:src="viewingReport.status.media_attachments[0].url"
height="140"
class="rounded"
style="object-fit: cover;"
onerror="this.src='/storage/no-preview.png';this.error=null;" />
<video
v-else-if="viewingReport.status.media_attachments[0].type === 'video'"
height="140"
controls
:src="viewingReport.status.media_attachments[0].url"
onerror="this.src='/storage/no-preview.png';this.onerror=null;"
></video>
</div>
<div v-if="viewingReport && viewingReport.status" class="list-group-item d-flex flex-column flex-grow-1" style="gap:0.4rem;">
<div class="d-flex justify-content-between mt-n1 text-muted small font-weight-bold">
<div>Reported Post Caption</div>
<a class="font-weight-bold" :href="viewingReport.status.url" target="_blank">View</a>
</div>
<p class="mb-0 read-more" style="font-size:12px;overflow-y: hidden">{{ viewingReport.status.content_text }}</p>
</div>
</div>
<div v-if="viewingReport && viewingReport.admin_seen_at === null" class="mt-4">
<div v-if="viewingReport && viewingReport.object_type === 'App\\Profile'">
<button class="btn btn-dark btn-block rounded-pill" @click="handleAction('profile', 'ignore')">Ignore Report</button>
<hr v-if="viewingReport.reported && viewingReport.reported.id && !viewingReport.reported.is_admin" class="mt-3 mb-1">
<div
v-if="viewingReport.reported && viewingReport.reported.id && !viewingReport.reported.is_admin"
class="d-flex flex-row mt-2"
style="gap:0.3rem;">
<button
class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0"
@click="handleAction('profile', 'nsfw')">
Mark all Posts NSFW
</button>
<button
class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0"
@click="handleAction('profile', 'unlist')">
Unlist all Posts
</button>
</div>
<button
v-if="viewingReport.reported && viewingReport.reported.id && !viewingReport.reported.is_admin"
class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-2"
@click="handleAction('profile', 'delete')">
Delete Profile
</button>
</div>
<div v-if="viewingReport && viewingReport.object_type === 'App\\Status'">
<button class="btn btn-dark btn-block rounded-pill" @click="handleAction('post', 'ignore')">Ignore Report</button>
<hr v-if="viewingReport && viewingReport.reported && !viewingReport.reported.is_admin" class="mt-3 mb-1">
<div
v-if="viewingReport && viewingReport.reported && !viewingReport.reported.is_admin"
class="d-flex flex-row mt-2"
style="gap:0.3rem;">
<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('post', 'nsfw')">Mark Post NSFW</button>
<button v-if="viewingReport.status.visibility === 'public'" class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('post', 'unlist')">Unlist Post</button>
<button v-else-if="viewingReport.status.visibility === 'unlisted'" class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('post', 'private')">Make Post Private</button>
</div>
<div
v-if="viewingReport && viewingReport.reported && !viewingReport.reported.is_admin"
class="d-flex flex-row mt-2"
style="gap:0.3rem;">
<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('profile', 'nsfw')">Make all NSFW</button>
<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('profile', 'unlist')">Make all Unlisted</button>
<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('profile', 'private')">Make all Private</button>
</div>
<div v-if="viewingReport && viewingReport.reported && !viewingReport.reported.is_admin">
<hr class="my-2">
<div class="d-flex flex-row mt-2" style="gap:0.3rem;">
<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('post', 'delete')">Delete Post</button>
<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('profile', 'delete')">Delete Account</button>
</div>
</div>
</div>
</div>
</template>
</b-modal>
<b-modal v-model="showSpamReportModal" title="Potential Spam Post Detected" :ok-only="true" ok-title="Close" ok-variant="outline-primary">
<div v-if="viewingSpamReportLoading" class="d-flex align-items-center justify-content-center">
<b-spinner />
</div>
<template v-else>
<div class="list-group list-group-horizontal mt-3">
<div v-if="viewingSpamReport && viewingSpamReport.status && viewingSpamReport.status.account" class="list-group-item d-flex align-items-center justify-content-between flex-column flex-grow-1" style="gap:0.4rem;">
<div class="text-muted small font-weight-bold mt-n1">Reported Account</div>
<a v-if="viewingSpamReport.status.account && viewingSpamReport.status.account.id" :href="`/i/web/profile/${viewingSpamReport.status.account.id}`" target="_blank" class="text-primary">
<div class="d-flex align-items-center" style="gap:0.61rem;">
<img
:src="viewingSpamReport.status.account.avatar"
width="30"
height="30"
style="object-fit: cover;border-radius:30px;"
onerror="this.src='/storage/avatars/default.png';this.error=null;">
<div class="d-flex flex-column">
<p class="font-weight-bold mb-0 text-break" style="font-size: 12px;max-width: 140px;line-height: 16px;" :class="[ viewingSpamReport.status.account.is_admin ? 'text-danger': '']">@{{viewingSpamReport.status.account.acct}}</p>
<div class="d-flex text-muted mb-0" style="font-size: 10px;gap: 0.5rem;">
<span>{{viewingSpamReport.status.account.followers_count}} Followers</span>
<span>·</span>
<span>Joined {{ timeAgo(viewingSpamReport.status.account.created_at) }}</span>
</div>
</div>
</div>
</a>
</div>
</div>
<div v-if="viewingSpamReport && viewingSpamReport.status" class="list-group mt-3">
<div v-if="viewingSpamReport && viewingSpamReport.status && viewingSpamReport.status.media_attachments.length" class="list-group-item d-flex flex-column flex-grow-1" style="gap:0.4rem;">
<div class="d-flex justify-content-between mt-n1 text-muted small font-weight-bold">
<div>Reported Post</div>
<a class="font-weight-bold" :href="viewingSpamReport.status.url" target="_blank">View</a>
</div>
<img
v-if="viewingSpamReport.status.media_attachments[0].type === 'image'"
:src="viewingSpamReport.status.media_attachments[0].url"
height="140"
class="rounded"
style="object-fit: cover;"
onerror="this.src='/storage/no-preview.png';this.error=null;" />
<video
v-else-if="viewingSpamReport.status.media_attachments[0].type === 'video'"
height="140"
controls
:src="viewingSpamReport.status.media_attachments[0].url"
onerror="this.src='/storage/no-preview.png';this.onerror=null;"
></video>
</div>
<div
v-if="viewingSpamReport &&
viewingSpamReport.status &&
viewingSpamReport.status.content_text &&
viewingSpamReport.status.content_text.length"
class="list-group-item d-flex flex-column flex-grow-1"
style="gap:0.4rem;">
<div class="d-flex justify-content-between mt-n1 text-muted small font-weight-bold">
<div>Reported Post Caption</div>
<a class="font-weight-bold" :href="viewingSpamReport.status.url" target="_blank">View</a>
</div>
<p class="mb-0 read-more" style="font-size:12px;overflow-y: hidden">{{ viewingSpamReport.status.content_text }}</p>
</div>
</div>
<div class="mt-4">
<div>
<button
type="button"
class="btn btn-dark btn-block rounded-pill"
@click="handleSpamAction('mark-read')">
Mark as Read
</button>
<button
type="button"
class="btn btn-danger btn-block rounded-pill"
@click="handleSpamAction('mark-not-spam')">
Mark As Not Spam
</button>
<hr class="mt-3 mb-1">
<div
class="d-flex flex-row mt-2"
style="gap:0.3rem;">
<button
type="button"
class="btn btn-dark btn-block btn-sm rounded-pill mt-0"
@click="handleSpamAction('mark-all-read')">
Mark All As Read
</button>
<button
type="button"
class="btn btn-dark btn-block btn-sm rounded-pill mt-0"
@click="handleSpamAction('mark-all-not-spam')">
Mark All As Not Spam
</button>
</div>
<div>
<hr class="my-2">
<div class="d-flex flex-row mt-2" style="gap:0.3rem;">
<button
type="button"
class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0"
@click="handleSpamAction('delete-profile')">
Delete Account
</button>
</div>
</div>
</div>
</div>
</template>
</b-modal>
</div>
</template>
<script type="text/javascript">
export default {
data() {
return {
loaded: false,
stats: {
total: 0,
open: 0,
closed: 0,
autospam: 0,
autospam_open: 0,
},
tabIndex: 0,
reports: [],
pagination: {},
showReportModal: false,
viewingReport: undefined,
viewingReportLoading: false,
autospam: [],
autospamPagination: {},
autospamLoaded: false,
showSpamReportModal: false,
viewingSpamReport: undefined,
viewingSpamReportLoading: false
}
},
mounted() {
let u = new URLSearchParams(window.location.search);
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') {
this.fetchStats();
this.fetchReport(u.get('id'));
} else {
window.history.pushState(null, null, '/i/admin/reports');
this.fetchStats();
}
this.$root.$on('bv::modal::hide', (bvEvent, modalId) => {
window.history.pushState(null, null, '/i/admin/reports');
})
},
methods: {
toggleTab(idx) {
switch(idx) {
case 0:
this.fetchStats('/i/admin/api/reports/all');
break;
case 1:
this.fetchStats('/i/admin/api/reports/all?filter=closed')
break;
case 2:
this.fetchStats(null, '/i/admin/api/reports/spam/all');
break;
}
window.history.pushState(null, null, '/i/admin/reports');
this.tabIndex = idx;
},
prettyCount(str) {
if(str) {
return str.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"});
}
return str;
},
timeAgo(str) {
if(!str) {
return str;
}
return App.util.format.timeAgo(str);
},
formatDate(str) {
let date = new Date(str);
return new Intl.DateTimeFormat('default', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric'
}).format(date);
},
reportLabel(report) {
switch(report.object_type) {
case 'App\\Profile':
return `${report.type} Profile`;
break;
case 'App\\Status':
return `${report.type} Post`;
break;
}
},
fetchStats(fetchReportsUrl = '/i/admin/api/reports/all', fetchSpamUrl = null) {
axios.get('/i/admin/api/reports/stats')
.then(res => {
this.stats = res.data;
})
.finally(() => {
if(fetchReportsUrl) {
this.fetchReports(fetchReportsUrl);
} else if(fetchSpamUrl) {
this.fetchAutospam(fetchSpamUrl);
}
$('[data-toggle="tooltip"]').tooltip()
});
},
fetchReports(url = '/i/admin/api/reports/all') {
axios.get(url)
.then(res => {
this.reports = res.data.data;
this.pagination = {
next: res.data.links.next,
prev: res.data.links.prev
};
})
.finally(() => {
this.loaded = true;
});
},
paginate(dir) {
event.currentTarget.blur();
let url = dir == 'next' ? this.pagination.next : this.pagination.prev;
this.fetchReports(url);
},
viewReport(report) {
this.viewingReportLoading = false;
this.viewingReport = report;
this.showReportModal = true;
window.history.pushState(null, null, '/i/admin/reports?tab=report&id=' + report.id);
setTimeout(() => {
pixelfed.readmore()
}, 1000)
},
handleAction(type, action) {
event.currentTarget.blur();
this.viewingReportLoading = true;
if(action !== 'ignore' && !window.confirm(this.getActionLabel(type, action))) {
this.viewingReportLoading = false;
return;
}
this.loaded = false;
axios.post('/i/admin/api/reports/handle', {
id: this.viewingReport.id,
object_id: this.viewingReport.object_id,
object_type: this.viewingReport.object_type,
action: action,
action_type: type
})
.catch(err => {
swal('Error', err.response.data.error, 'error');
})
.finally(() => {
this.viewingReportLoading = true;
this.viewingReport = false;
this.showReportModal = false;
setTimeout(() => {
this.fetchStats();
}, 1000);
})
},
getActionLabel(type, action) {
if(type === 'profile') {
switch(action) {
case 'ignore':
return 'Are you sure you want to ignore this profile report?';
break;
case 'nsfw':
return 'Are you sure you want to mark this profile as NSFW?';
break;
case 'unlist':
return 'Are you sure you want to mark all posts by this profile as unlisted?';
break;
case 'private':
return 'Are you sure you want to mark all posts by this profile as private?';
break;
case 'delete':
return 'Are you sure you want to delete this profile?';
break;
}
} else if(type === 'post') {
switch(action) {
case 'ignore':
return 'Are you sure you want to ignore this post report?';
break;
case 'nsfw':
return 'Are you sure you want to mark this post as NSFW?';
break;
case 'unlist':
return 'Are you sure you want to mark this post as unlisted?';
break;
case 'private':
return 'Are you sure you want to mark this post as private?';
break;
case 'delete':
return 'Are you sure you want to delete this post?';
break;
}
}
},
fetchAutospam(url = '/i/admin/api/reports/spam/all') {
axios.get(url)
.then(res => {
this.autospam = res.data.data;
this.autospamPagination = {
next: res.data.links.next,
prev: res.data.links.prev
}
})
.finally(() => {
this.autospamLoaded = true;
this.loaded = true;
})
},
autospamPaginate(dir) {
event.currentTarget.blur();
let url = dir == 'next' ? this.autospamPagination.next : this.autospamPagination.prev;
this.fetchAutospam(url);
},
viewSpamReport(report) {
this.viewingSpamReportLoading = false;
this.viewingSpamReport = report;
this.showSpamReportModal = true;
window.history.pushState(null, null, '/i/admin/reports?tab=autospam&id=' + report.id);
setTimeout(() => {
pixelfed.readmore()
}, 1000)
},
getSpamActionLabel(action) {
switch(action) {
case 'mark-all-read':
return 'Are you sure you want to mark all spam reports by this account as read?';
break;
case 'mark-all-not-spam':
return 'Are you sure you want to mark all spam reports by this account as not spam?';
break;
case 'delete-profile':
return 'Are you sure you want to delete this profile?';
break;
}
},
handleSpamAction(action) {
event.currentTarget.blur();
this.viewingSpamReportLoading = true;
if(action !== 'mark-not-spam' && action !== 'mark-read' && !window.confirm(this.getSpamActionLabel(action))) {
this.viewingSpamReportLoading = false;
return;
}
this.loaded = false;
axios.post('/i/admin/api/reports/spam/handle', {
id: this.viewingSpamReport.id,
action: action,
})
.catch(err => {
swal('Error', err.response.data.error, 'error');
})
.finally(() => {
this.viewingSpamReportLoading = true;
this.viewingSpamReport = false;
this.showSpamReportModal = false;
setTimeout(() => {
this.fetchStats(null, '/i/admin/api/reports/spam/all');
}, 500);
})
},
fetchReport(id) {
axios.get('/i/admin/api/reports/get/' + id)
.then(res => {
this.tabIndex = 0;
this.viewReport(res.data.data);
})
.catch(err => {
this.fetchStats();
window.history.pushState(null, null, '/i/admin/reports');
})
},
fetchSpamReport(id) {
axios.get('/i/admin/api/reports/spam/get/' + id)
.then(res => {
this.tabIndex = 2;
this.viewSpamReport(res.data.data);
})
.catch(err => {
this.fetchStats();
window.history.pushState(null, null, '/i/admin/reports');
})
}
}
}
</script>

View file

@ -1,93 +1,12 @@
@extends('admin.partial.template-full') @extends('admin.partial.template-full')
@section('section') @section('section')
<div class="title mb-3 d-flex justify-content-between align-items-center">
<h3 class="font-weight-bold d-inline-block">Reports</h3>
<div class="float-right">
@if(request()->has('filter') && request()->filter == 'closed')
<a class="mr-3 font-weight-light small text-muted" href="{{route('admin.reports')}}">
View Open Reports
</a>
@else
<a class="mr-3 font-weight-light small text-muted" href="{{route('admin.reports',['filter'=>'closed'])}}">
View Closed Reports
</a>
@endif
</div>
</div>
<div class="col-12 col-md-8 offset-md-2">
<div class="mb-4">
<a class="btn btn-outline-primary px-5 py-3 mr-3" href="/i/admin/reports/email-verifications">
<p class="font-weight-bold h4 mb-0">{{$mailVerifications}}</p>
Email Verify {{$mailVerifications == 1 ? 'Request' : 'Requests'}}
</a>
<a class="btn btn-outline-primary px-5 py-3 mr-3" href="/i/admin/reports/appeals">
<p class="font-weight-bold h4 mb-0">{{$ai}}</p>
Appeal {{$ai == 1 ? 'Request' : 'Requests'}}
</a>
<a class="btn btn-outline-primary px-5 py-3" href="/i/admin/reports/autospam">
<p class="font-weight-bold h4 mb-0">{{$spam}}</p>
Flagged {{$ai == 1 ? 'Post' : 'Posts'}}
</a>
</div>
</div>
@if($reports->count())
<div class="col-12 col-md-8 offset-md-2">
<div class="card shadow-none border">
<div class="list-group list-group-flush">
@foreach($reports as $report)
<div class="list-group-item p-1 {{$report->admin_seen ? 'bg-light' : 'bg-white'}}">
<div class="p-0">
<div class="media d-flex align-items-center">
<a class="text-decoration-none" href="{{$report->url()}}">
<img src="{{$report->status->media && $report->status->media->count() ? $report->status->thumb(true) : '/storage/no-preview.png'}}" width="64" height="64" class="rounded border shadow mr-3" style="object-fit: cover">
</a>
<div class="media-body">
<p class="mb-1 small"><span class="font-weight-bold text-uppercase text-danger">{{$report->type}}</span></p>
@if($report->reporter && $report->status)
<p class="mb-0"><a class="font-weight-bold text-dark" href="{{$report->reporter->url()}}">{{$report->reporter->username}}</a> reported this <a href="{{$report->status->url()}}" class="font-weight-bold text-dark">post</a></p>
@else
<p class="mb-0 lead">
@if(!$report->reporter)
<span class="font-weight-bold text-dark">Deleted user</span>
@else
<a class="font-weight-bold text-dark" href="{{$report->reporter->url()}}">{{$report->reporter->username}}</a>
@endif
reported this
@if(!$report->status)
<span class="font-weight-bold text-muted">deleted post</span>
@else
<a href="{{$report->status->url()}}" class="font-weight-bold text-dark">post</a>
@endif
</p>
@endif
</div>
<div class="float-right">
@if($report->status)
<a class="text-lighter p-2 text-decoration-none" href="{{$report->url()}}">
View <i class="fas fa-chevron-right ml-2"></i>
</a>
@endif
</div>
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
@else
<div class="card shadow-none border">
<div class="card-body">
<p class="mb-0 p-5 text-center font-weight-bold lead">No reports found</p>
</div>
</div>
@endif
<div class="d-flex justify-content-center mt-5 small">
{{$reports->appends(['layout'=>request()->layout, 'filter' => request()->filter])->links()}}
</div> </div>
<admin-reports />
@endsection @endsection
@push('scripts')
<script type="text/javascript">
new Vue({ el: '#panel'});
</script>
@endpush

View file

@ -1,6 +1,14 @@
@extends('admin.partial.template-full') @extends('admin.partial.template-full')
@section('section') @section('section')
<div class="bg-primary px-4 py-3 mb-5 rounded d-flex align-items-center justify-content-between">
<div style="max-width: 70%;">
<p class="lead text-white my-0 font-weight-bold">Try the new Report UI</p>
<p class="text-white small mb-0">We are deprecating this Report UI in the next major version release. The updated Report UI is easier, faster and provides more options to handle reports and spam.</p>
</div>
<a href="/i/admin/reports?tab=report&id={{$report->id}}" class="btn btn-outline-white">View in new Report UI</a>
</div>
<div class="d-flex justify-content-between title mb-3"> <div class="d-flex justify-content-between title mb-3">
<div> <div>
<p class="font-weight-bold h3"> <p class="font-weight-bold h3">
@ -16,16 +24,17 @@
<div class="row"> <div class="row">
<div class="col-12 col-md-8 mt-3"> <div class="col-12 col-md-8 mt-3">
<div class="card shadow-none border"> <div class="card shadow-none border">
@if($report->status->media()->count()) @if($report->status && $report->status->media()->count())
<img class="card-img-top border-bottom" src="{{$report->status->thumb(true)}}"> <img class="card-img-top border-bottom" src="{{$report->status->thumb(true)}}">
@endif @endif
<div class="card-body"> <div class="card-body">
<div class="mt-2 p-3"> <div class="mt-2 p-3">
@if($report->status->caption) @if($report->status && $report->status->caption)
<p class="text-break"> <p class="text-break">
{{$report->status->media()->count() ? 'Caption' : 'Comment'}}: <span class="font-weight-bold">{{$report->status->caption}}</span> {{$report->status->media()->count() ? 'Caption' : 'Comment'}}: <span class="font-weight-bold">{{$report->status->caption}}</span>
</p> </p>
@endif @endif
@if($report->status)
<p class="mb-0"> <p class="mb-0">
Like Count: <span class="font-weight-bold">{{$report->status->likes_count}}</span> Like Count: <span class="font-weight-bold">{{$report->status->likes_count}}</span>
</p> </p>
@ -41,7 +50,8 @@
<p class="" style="word-break: break-all !important;"> <p class="" style="word-break: break-all !important;">
Local URL: <span class="font-weight-bold text-primary"><a href="/i/web/post/{{$report->status->id}}">{{url('/i/web/post/' . $report->status->id)}}</a></span> Local URL: <span class="font-weight-bold text-primary"><a href="/i/web/post/{{$report->status->id}}">{{url('/i/web/post/' . $report->status->id)}}</a></span>
</p> </p>
@if($report->status->in_reply_to_id) @endif
@if($report->status && $report->status->in_reply_to_id)
<p class="mt-n3" style="word-break: break-all !important;"> <p class="mt-n3" style="word-break: break-all !important;">
Parent Post: <span class="font-weight-bold text-primary"><a href="/i/web/post/{{$report->status->in_reply_to_id}}">{{url('/i/web/post/' . $report->status->in_reply_to_id)}}</a></span> Parent Post: <span class="font-weight-bold text-primary"><a href="/i/web/post/{{$report->status->in_reply_to_id}}">{{url('/i/web/post/' . $report->status->in_reply_to_id)}}</a></span>
</p> </p>

View file

@ -1,6 +1,13 @@
@extends('admin.partial.template-full') @extends('admin.partial.template-full')
@section('section') @section('section')
<div class="bg-primary px-4 py-3 mb-5 rounded d-flex align-items-center justify-content-between">
<div style="max-width: 70%;">
<p class="lead text-white my-0 font-weight-bold">Try the new Report UI</p>
<p class="text-white small mb-0">We are deprecating this Report UI in the next major version release. The updated Report UI is easier, faster and provides more options to handle reports and spam.</p>
</div>
<a href="/i/admin/reports?tab=autospam&id={{$appeal->id}}" class="btn btn-outline-white">View in new Report UI</a>
</div>
<div class="d-flex justify-content-between title mb-3"> <div class="d-flex justify-content-between title mb-3">
<div> <div>
<p class="font-weight-bold h3">Autospam</p> <p class="font-weight-bold h3">Autospam</p>
@ -15,7 +22,7 @@
<div class="card shadow-none border"> <div class="card shadow-none border">
<div class="card-header bg-light h5 font-weight-bold py-4">Unlisted + Content Warning</div> <div class="card-header bg-light h5 font-weight-bold py-4">Unlisted + Content Warning</div>
@if($appeal->has_media) @if($appeal->has_media)
<img class="card-img-top border-bottom" src="{{$appeal->status->thumb(true)}}" style="max-height: 40vh;object-fit: contain;"> <img class="card-img-top border-bottom" src="{{$appeal->status->thumb(true)}}" style="max-height: 40vh;object-fit: contain;" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0'">
@endif @endif
<div class="card-body"> <div class="card-body">
<div class="mt-2 p-3"> <div class="mt-2 p-3">
@ -34,7 +41,7 @@
Timestamp: <span class="font-weight-bold">{{now()->parse($meta->created_at)->format('r')}}</span> Timestamp: <span class="font-weight-bold">{{now()->parse($meta->created_at)->format('r')}}</span>
</p> </p>
<p class="" style="word-break: break-all !important;"> <p class="" style="word-break: break-all !important;">
URL: <span class="font-weight-bold text-primary"><a href="{{$meta->url}}">{{$meta->url}}</a></span> URL: <span class="font-weight-bold text-primary"><a href="/i/web/post/{{$appeal->item_id}}" target="_blank">{{$meta->url}}</a></span>
</p> </p>
</div> </div>
</div> </div>
@ -49,6 +56,8 @@
<hr> <hr>
<button type="button" class="btn btn-default border btn-block font-weight-bold mb-3 action-btn" data-action="dismiss-all">Mark all as read</button> <button type="button" class="btn btn-default border btn-block font-weight-bold mb-3 action-btn" data-action="dismiss-all">Mark all as read</button>
<button type="button" class="btn btn-light border btn-block font-weight-bold mb-3 action-btn" data-action="approve-all">Mark all as not spam</button> <button type="button" class="btn btn-light border btn-block font-weight-bold mb-3 action-btn" data-action="approve-all">Mark all as not spam</button>
<hr>
<button type="button" class="btn btn-light border btn-block font-weight-bold mb-3 action-btn" data-action="mark-spammer">Mark as spammer</button>
<button type="button" class="btn btn-danger border btn-block font-weight-bold mb-3 action-btn mb-5" data-action="delete-account">Delete Account</button> <button type="button" class="btn btn-danger border btn-block font-weight-bold mb-3 action-btn mb-5" data-action="delete-account">Delete Account</button>
@endif @endif
<div class="card shadow-none border"> <div class="card shadow-none border">
@ -107,6 +116,12 @@
} }
break; break;
case 'mark-spammer':
if(!window.confirm('Are you sure you want to mark this account as a spammer?')) {
return;
}
break;
case 'delete-account': case 'delete-account':
if(!window.confirm('Are you sure you want to delete this account?')) { if(!window.confirm('Are you sure you want to delete this account?')) {
return; return;

View file

@ -122,6 +122,13 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi'); Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi');
Route::get('instances/download-backup', 'AdminController@downloadBackup'); Route::get('instances/download-backup', 'AdminController@downloadBackup');
Route::post('instances/import-data', 'AdminController@importBackup'); Route::post('instances/import-data', 'AdminController@importBackup');
Route::get('reports/stats', 'AdminController@reportsStats');
Route::get('reports/all', 'AdminController@reportsApiAll');
Route::get('reports/get/{id}', 'AdminController@reportsApiGet');
Route::post('reports/handle', 'AdminController@reportsApiHandle');
Route::get('reports/spam/all', 'AdminController@reportsApiSpamAll');
Route::get('reports/spam/get/{id}', 'AdminController@reportsApiSpamGet');
Route::post('reports/spam/handle', 'AdminController@reportsApiSpamHandle');
}); });
}); });