Quellcode durchsuchen

Merge pull request #2829 from pixelfed/staging

Staging
daniel vor 4 Jahren
Ursprung
Commit
8513b6d1e5

+ 1 - 0
CHANGELOG.md

@@ -4,6 +4,7 @@
 ### Added
 - WebP Support ([069a0e4a](https://github.com/pixelfed/pixelfed/commit/069a0e4a))
 - Auto Following support for admins ([68aa2540](https://github.com/pixelfed/pixelfed/commit/68aa2540))
+- Mark as spammer mod tool, unlists and applies content warning to existing and future post ([6d956a86](https://github.com/pixelfed/pixelfed/commit/6d956a86))
 
 ### Updated
 - Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b))

+ 431 - 413
app/Http/Controllers/InternalApiController.php

@@ -4,30 +4,31 @@ namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
 use App\{
-    AccountInterstitial,
-    DirectMessage,
-    DiscoverCategory,
-    Hashtag,
-    Follower,
-    Like,
-    Media,
-    MediaTag,
-    Notification,
-    Profile,
-    StatusHashtag,
-    Status,
-    UserFilter,
+	AccountInterstitial,
+	DirectMessage,
+	DiscoverCategory,
+	Hashtag,
+	Follower,
+	Like,
+	Media,
+	MediaTag,
+	Notification,
+	Profile,
+	StatusHashtag,
+	Status,
+	UserFilter,
 };
 use Auth,Cache;
 use Carbon\Carbon;
 use League\Fractal;
 use App\Transformer\Api\{
-    AccountTransformer,
-    StatusTransformer,
-    // StatusMediaContainerTransformer,
+	AccountTransformer,
+	StatusTransformer,
+	// StatusMediaContainerTransformer,
 };
 use App\Util\Media\Filter;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
+use App\Jobs\ModPipeline\HandleSpammerPipeline;
 use League\Fractal\Serializer\ArraySerializer;
 use League\Fractal\Pagination\IlluminatePaginatorAdapter;
 use Illuminate\Validation\Rule;
@@ -40,401 +41,418 @@ use App\Services\StatusService;
 
 class InternalApiController extends Controller
 {
-    protected $fractal;
-
-    public function __construct()
-    {
-        $this->middleware('auth');
-        $this->fractal = new Fractal\Manager();
-        $this->fractal->setSerializer(new ArraySerializer());
-    }
-
-    // deprecated v2 compose api
-    public function compose(Request $request)
-    {
-        return redirect('/');
-    }
-
-    // deprecated
-    public function discover(Request $request)
-    {
-        return;
-    }
-
-    public function discoverPosts(Request $request)
-    {
-        $profile = Auth::user()->profile;
-        $pid = $profile->id;
-        $following = Cache::remember('feature:discover:following:'.$pid, now()->addMinutes(15), function() use ($pid) {
-            return Follower::whereProfileId($pid)->pluck('following_id')->toArray();
-        });
-        $filters = Cache::remember("user:filter:list:$pid", now()->addMinutes(15), function() use($pid) {
-            $private = Profile::whereIsPrivate(true)
-                ->orWhere('unlisted', true)
-                ->orWhere('status', '!=', null)
-                ->pluck('id')
-                ->toArray();
-            $filters = UserFilter::whereUserId($pid)
-                ->whereFilterableType('App\Profile')
-                ->whereIn('filter_type', ['mute', 'block'])
-                ->pluck('filterable_id')
-                ->toArray();
-            return array_merge($private, $filters);
-        });
-        $following = array_merge($following, $filters);
-
-        $sql = config('database.default') !== 'pgsql';
-        $min_id = SnowflakeService::byDate(now()->subMonths(3));
-        $posts = Status::select(
-                'id', 
-                'is_nsfw',
-                'profile_id',
-                'type',
-                'uri',
-              )
-              ->whereNull('uri')
-              ->whereIn('type', ['photo','photo:album', 'video'])
-              ->whereIsNsfw(false)
-              ->whereVisibility('public')
-              ->whereNotIn('profile_id', $following)
-              ->where('id', '>', $min_id)
-              ->inRandomOrder()
-              ->take(39)
-              ->pluck('id');
-
-        $res = [
-            'posts' => $posts->map(function($post) {
-                return StatusService::get($post);
-            })
-        ];
-        return response()->json($res);
-    }
-
-    public function directMessage(Request $request, $profileId, $threadId)
-    {
-        $profile = Auth::user()->profile;
-
-        if($profileId != $profile->id) { 
-            abort(403); 
-        }
-
-        $msg = DirectMessage::whereToId($profile->id)
-            ->orWhere('from_id',$profile->id)
-            ->findOrFail($threadId);
-
-        $thread = DirectMessage::with('status')->whereIn('to_id', [$profile->id, $msg->from_id])
-            ->whereIn('from_id', [$profile->id,$msg->from_id])
-            ->orderBy('created_at', 'asc')
-            ->paginate(30);
-
-        return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
-    }
-
-    public function statusReplies(Request $request, int $id)
-    {
-        $this->validate($request, [
-            'limit' => 'nullable|int|min:1|max:6'
-        ]);
-        $parent = Status::whereScope('public')->findOrFail($id);
-        $limit = $request->input('limit') ?? 3;
-        $children = Status::whereInReplyToId($parent->id)
-            ->orderBy('created_at', 'desc')
-            ->take($limit)
-            ->get();
-        $resource = new Fractal\Resource\Collection($children, new StatusTransformer());
-        $res = $this->fractal->createData($resource)->toArray();
-
-        return response()->json($res);
-    }
-
-    public function stories(Request $request)
-    {
-        
-    }
-
-    public function discoverCategories(Request $request)
-    {
-        $categories = DiscoverCategory::whereActive(true)->orderBy('order')->take(10)->get();
-        $res = $categories->map(function($item) {
-            return [
-                'name' => $item->name,
-                'url' => $item->url(),
-                'thumb' => $item->thumb()
-            ];
-        });
-        return response()->json($res);
-    }
-
-    public function modAction(Request $request)
-    {
-        abort_unless(Auth::user()->is_admin, 400);
-        $this->validate($request, [
-            'action' => [
-                'required',
-                'string',
-                Rule::in([
-                    'addcw',
-                    'remcw',
-                    'unlist'
-                    
-                ])
-            ],
-            'item_id' => 'required|integer|min:1',
-            'item_type' => [
-                'required',
-                'string',
-                Rule::in(['profile', 'status'])
-            ]
-        ]);
-
-        $action = $request->input('action');
-        $item_id = $request->input('item_id');
-        $item_type = $request->input('item_type');
-
-        switch($action) {
-            case 'addcw':
-                $status = Status::findOrFail($item_id);
-                $status->is_nsfw = true;
-                $status->save();
-                ModLogService::boot()
-                    ->user(Auth::user())
-                    ->objectUid($status->profile->user_id)
-                    ->objectId($status->id)
-                    ->objectType('App\Status::class')
-                    ->action('admin.status.moderate')
-                    ->metadata([
-                        'action' => 'cw',
-                        'message' => 'Success!'
-                    ])
-                    ->accessLevel('admin')
-                    ->save();
-
-
-                if($status->uri == null) {
-                    $media = $status->media;
-                    $ai = new AccountInterstitial;
-                    $ai->user_id = $status->profile->user_id;
-                    $ai->type = 'post.cw';
-                    $ai->view = 'account.moderation.post.cw';
-                    $ai->item_type = 'App\Status';
-                    $ai->item_id = $status->id;
-                    $ai->has_media = (bool) $media->count();
-                    $ai->blurhash = $media->count() ? $media->first()->blurhash : null;
-                    $ai->meta = json_encode([
-                        'caption' => $status->caption,
-                        'created_at' => $status->created_at,
-                        'type' => $status->type,
-                        'url' => $status->url(),
-                        'is_nsfw' => $status->is_nsfw,
-                        'scope' => $status->scope,
-                        'reblog' => $status->reblog_of_id,
-                        'likes_count' => $status->likes_count,
-                        'reblogs_count' => $status->reblogs_count,
-                    ]);
-                    $ai->save();
-
-                    $u = $status->profile->user;
-                    $u->has_interstitial = true;
-                    $u->save();
-                }
-            break;
-
-            case 'remcw':
-                $status = Status::findOrFail($item_id);
-                $status->is_nsfw = false;
-                $status->save();
-                ModLogService::boot()
-                    ->user(Auth::user())
-                    ->objectUid($status->profile->user_id)
-                    ->objectId($status->id)
-                    ->objectType('App\Status::class')
-                    ->action('admin.status.moderate')
-                    ->metadata([
-                        'action' => 'remove_cw',
-                        'message' => 'Success!'
-                    ])
-                    ->accessLevel('admin')
-                    ->save();
-                if($status->uri == null) {
-                    $ai = AccountInterstitial::whereUserId($status->profile->user_id)
-                        ->whereType('post.cw')
-                        ->whereItemId($status->id)
-                        ->whereItemType('App\Status')
-                        ->first();
-                    $ai->delete();
-                }
-            break;
-
-            case 'unlist':
-                $status = Status::whereScope('public')->findOrFail($item_id);
-                $status->scope = $status->visibility = 'unlisted';
-                $status->save();
-                PublicTimelineService::del($status->id);
-                ModLogService::boot()
-                    ->user(Auth::user())
-                    ->objectUid($status->profile->user_id)
-                    ->objectId($status->id)
-                    ->objectType('App\Status::class')
-                    ->action('admin.status.moderate')
-                    ->metadata([
-                        'action' => 'unlist',
-                        'message' => 'Success!'
-                    ])
-                    ->accessLevel('admin')
-                    ->save();
-
-                if($status->uri == null) {
-                    $media = $status->media;
-                    $ai = new AccountInterstitial;
-                    $ai->user_id = $status->profile->user_id;
-                    $ai->type = 'post.unlist';
-                    $ai->view = 'account.moderation.post.unlist';
-                    $ai->item_type = 'App\Status';
-                    $ai->item_id = $status->id;
-                    $ai->has_media = (bool) $media->count();
-                    $ai->blurhash = $media->count() ? $media->first()->blurhash : null;
-                    $ai->meta = json_encode([
-                        'caption' => $status->caption,
-                        'created_at' => $status->created_at,
-                        'type' => $status->type,
-                        'url' => $status->url(),
-                        'is_nsfw' => $status->is_nsfw,
-                        'scope' => $status->scope,
-                        'reblog' => $status->reblog_of_id,
-                        'likes_count' => $status->likes_count,
-                        'reblogs_count' => $status->reblogs_count,
-                    ]);
-                    $ai->save();
-
-                    $u = $status->profile->user;
-                    $u->has_interstitial = true;
-                    $u->save();
-                }
-            break;
-        }
-
-        Cache::forget('_api:statuses:recent_9:' . $status->profile_id);
-        Cache::forget('profile:embed:' . $status->profile_id);
-
-        return ['msg' => 200];
-    }
-
-    public function composePost(Request $request)
-    {
-        abort(400, 'Endpoint deprecated');
-    }
-
-    public function bookmarks(Request $request)
-    {
-        $statuses = Auth::user()->profile
-            ->bookmarks()
-            ->withCount(['likes','comments'])
-            ->orderBy('created_at', 'desc')
-            ->simplePaginate(10);
-
-        $resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
-        $res = $this->fractal->createData($resource)->toArray();
-
-        return response()->json($res);
-    }
-
-    public function accountStatuses(Request $request, $id)
-    {
-        $this->validate($request, [
-            'only_media' => 'nullable',
-            'pinned' => 'nullable',
-            'exclude_replies' => 'nullable',
-            'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-            'since_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-            'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
-            'limit' => 'nullable|integer|min:1|max:24'
-        ]);
-
-        $profile = Profile::whereNull('status')->findOrFail($id);
-
-        $limit = $request->limit ?? 9;
-        $max_id = $request->max_id;
-        $min_id = $request->min_id;
-        $scope = $request->only_media == true ? 
-            ['photo', 'photo:album', 'video', 'video:album'] :
-            ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
-       
-        if($profile->is_private) {
-            if(!Auth::check()) {
-                return response()->json([]);
-            }
-            $pid = Auth::user()->profile->id;
-            $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
-                $following = Follower::whereProfileId($pid)->pluck('following_id');
-                return $following->push($pid)->toArray();
-            });
-            $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : [];
-        } else {
-            if(Auth::check()) {
-                $pid = Auth::user()->profile->id;
-                $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
-                    $following = Follower::whereProfileId($pid)->pluck('following_id');
-                    return $following->push($pid)->toArray();
-                });
-                $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
-            } else {
-                $visibility = ['public', 'unlisted'];
-            }
-        }
-
-        $dir = $min_id ? '>' : '<';
-        $id = $min_id ?? $max_id;
-        $timeline = Status::select(
-            'id', 
-            'uri',
-            'caption',
-            'rendered',
-            'profile_id', 
-            'type',
-            'in_reply_to_id',
-            'reblog_of_id',
-            'is_nsfw',
-            'likes_count',
-            'reblogs_count',
-            'scope',
-            'local',
-            'created_at',
-            'updated_at'
-          )->whereProfileId($profile->id)
-          ->whereIn('type', $scope)
-          ->where('id', $dir, $id)
-          ->whereIn('visibility', $visibility)
-          ->latest()
-          ->limit($limit)
-          ->get();
-
-        $resource = new Fractal\Resource\Collection($timeline, new StatusTransformer());
-        $res = $this->fractal->createData($resource)->toArray();
-
-        return response()->json($res);
-    }
-
-    public function remoteProfile(Request $request, $id)
-    {
-        $profile = Profile::whereNull('status')
-            ->whereNotNull('domain')
-            ->findOrFail($id);
-        $user = Auth::user();
-
-        return view('profile.remote', compact('profile', 'user'));
-    }
-
-    public function remoteStatus(Request $request, $profileId, $statusId)
-    {
-        $user = Profile::whereNull('status')
-            ->whereNotNull('domain')
-            ->findOrFail($profileId);
-
-        $status = Status::whereProfileId($user->id)
-                        ->whereNull('reblog_of_id')
-                        ->whereIn('visibility', ['public', 'unlisted'])
-                        ->findOrFail($statusId);
-        $template = $status->in_reply_to_id ? 'status.reply' : 'status.remote';
-        return view($template, compact('user', 'status'));
-    }
+	protected $fractal;
+
+	public function __construct()
+	{
+		$this->middleware('auth');
+		$this->fractal = new Fractal\Manager();
+		$this->fractal->setSerializer(new ArraySerializer());
+	}
+
+	// deprecated v2 compose api
+	public function compose(Request $request)
+	{
+		return redirect('/');
+	}
+
+	// deprecated
+	public function discover(Request $request)
+	{
+		return;
+	}
+
+	public function discoverPosts(Request $request)
+	{
+		$profile = Auth::user()->profile;
+		$pid = $profile->id;
+		$following = Cache::remember('feature:discover:following:'.$pid, now()->addMinutes(15), function() use ($pid) {
+			return Follower::whereProfileId($pid)->pluck('following_id')->toArray();
+		});
+		$filters = Cache::remember("user:filter:list:$pid", now()->addMinutes(15), function() use($pid) {
+			$private = Profile::whereIsPrivate(true)
+				->orWhere('unlisted', true)
+				->orWhere('status', '!=', null)
+				->pluck('id')
+				->toArray();
+			$filters = UserFilter::whereUserId($pid)
+				->whereFilterableType('App\Profile')
+				->whereIn('filter_type', ['mute', 'block'])
+				->pluck('filterable_id')
+				->toArray();
+			return array_merge($private, $filters);
+		});
+		$following = array_merge($following, $filters);
+
+		$sql = config('database.default') !== 'pgsql';
+		$min_id = SnowflakeService::byDate(now()->subMonths(3));
+		$posts = Status::select(
+				'id',
+				'is_nsfw',
+				'profile_id',
+				'type',
+				'uri',
+			  )
+			  ->whereNull('uri')
+			  ->whereIn('type', ['photo','photo:album', 'video'])
+			  ->whereIsNsfw(false)
+			  ->whereVisibility('public')
+			  ->whereNotIn('profile_id', $following)
+			  ->where('id', '>', $min_id)
+			  ->inRandomOrder()
+			  ->take(39)
+			  ->pluck('id');
+
+		$res = [
+			'posts' => $posts->map(function($post) {
+				return StatusService::get($post);
+			})
+		];
+		return response()->json($res);
+	}
+
+	public function directMessage(Request $request, $profileId, $threadId)
+	{
+		$profile = Auth::user()->profile;
+
+		if($profileId != $profile->id) {
+			abort(403);
+		}
+
+		$msg = DirectMessage::whereToId($profile->id)
+			->orWhere('from_id',$profile->id)
+			->findOrFail($threadId);
+
+		$thread = DirectMessage::with('status')->whereIn('to_id', [$profile->id, $msg->from_id])
+			->whereIn('from_id', [$profile->id,$msg->from_id])
+			->orderBy('created_at', 'asc')
+			->paginate(30);
+
+		return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
+	}
+
+	public function statusReplies(Request $request, int $id)
+	{
+		$this->validate($request, [
+			'limit' => 'nullable|int|min:1|max:6'
+		]);
+		$parent = Status::whereScope('public')->findOrFail($id);
+		$limit = $request->input('limit') ?? 3;
+		$children = Status::whereInReplyToId($parent->id)
+			->orderBy('created_at', 'desc')
+			->take($limit)
+			->get();
+		$resource = new Fractal\Resource\Collection($children, new StatusTransformer());
+		$res = $this->fractal->createData($resource)->toArray();
+
+		return response()->json($res);
+	}
+
+	public function stories(Request $request)
+	{
+
+	}
+
+	public function discoverCategories(Request $request)
+	{
+		$categories = DiscoverCategory::whereActive(true)->orderBy('order')->take(10)->get();
+		$res = $categories->map(function($item) {
+			return [
+				'name' => $item->name,
+				'url' => $item->url(),
+				'thumb' => $item->thumb()
+			];
+		});
+		return response()->json($res);
+	}
+
+	public function modAction(Request $request)
+	{
+		abort_unless(Auth::user()->is_admin, 400);
+		$this->validate($request, [
+			'action' => [
+				'required',
+				'string',
+				Rule::in([
+					'addcw',
+					'remcw',
+					'unlist',
+					'spammer'
+				])
+			],
+			'item_id' => 'required|integer|min:1',
+			'item_type' => [
+				'required',
+				'string',
+				Rule::in(['profile', 'status'])
+			]
+		]);
+
+		$action = $request->input('action');
+		$item_id = $request->input('item_id');
+		$item_type = $request->input('item_type');
+
+		switch($action) {
+			case 'addcw':
+				$status = Status::findOrFail($item_id);
+				$status->is_nsfw = true;
+				$status->save();
+				ModLogService::boot()
+					->user(Auth::user())
+					->objectUid($status->profile->user_id)
+					->objectId($status->id)
+					->objectType('App\Status::class')
+					->action('admin.status.moderate')
+					->metadata([
+						'action' => 'cw',
+						'message' => 'Success!'
+					])
+					->accessLevel('admin')
+					->save();
+
+
+				if($status->uri == null) {
+					$media = $status->media;
+					$ai = new AccountInterstitial;
+					$ai->user_id = $status->profile->user_id;
+					$ai->type = 'post.cw';
+					$ai->view = 'account.moderation.post.cw';
+					$ai->item_type = 'App\Status';
+					$ai->item_id = $status->id;
+					$ai->has_media = (bool) $media->count();
+					$ai->blurhash = $media->count() ? $media->first()->blurhash : null;
+					$ai->meta = json_encode([
+						'caption' => $status->caption,
+						'created_at' => $status->created_at,
+						'type' => $status->type,
+						'url' => $status->url(),
+						'is_nsfw' => $status->is_nsfw,
+						'scope' => $status->scope,
+						'reblog' => $status->reblog_of_id,
+						'likes_count' => $status->likes_count,
+						'reblogs_count' => $status->reblogs_count,
+					]);
+					$ai->save();
+
+					$u = $status->profile->user;
+					$u->has_interstitial = true;
+					$u->save();
+				}
+			break;
+
+			case 'remcw':
+				$status = Status::findOrFail($item_id);
+				$status->is_nsfw = false;
+				$status->save();
+				ModLogService::boot()
+					->user(Auth::user())
+					->objectUid($status->profile->user_id)
+					->objectId($status->id)
+					->objectType('App\Status::class')
+					->action('admin.status.moderate')
+					->metadata([
+						'action' => 'remove_cw',
+						'message' => 'Success!'
+					])
+					->accessLevel('admin')
+					->save();
+				if($status->uri == null) {
+					$ai = AccountInterstitial::whereUserId($status->profile->user_id)
+						->whereType('post.cw')
+						->whereItemId($status->id)
+						->whereItemType('App\Status')
+						->first();
+					$ai->delete();
+				}
+			break;
+
+			case 'unlist':
+				$status = Status::whereScope('public')->findOrFail($item_id);
+				$status->scope = $status->visibility = 'unlisted';
+				$status->save();
+				PublicTimelineService::del($status->id);
+				ModLogService::boot()
+					->user(Auth::user())
+					->objectUid($status->profile->user_id)
+					->objectId($status->id)
+					->objectType('App\Status::class')
+					->action('admin.status.moderate')
+					->metadata([
+						'action' => 'unlist',
+						'message' => 'Success!'
+					])
+					->accessLevel('admin')
+					->save();
+
+				if($status->uri == null) {
+					$media = $status->media;
+					$ai = new AccountInterstitial;
+					$ai->user_id = $status->profile->user_id;
+					$ai->type = 'post.unlist';
+					$ai->view = 'account.moderation.post.unlist';
+					$ai->item_type = 'App\Status';
+					$ai->item_id = $status->id;
+					$ai->has_media = (bool) $media->count();
+					$ai->blurhash = $media->count() ? $media->first()->blurhash : null;
+					$ai->meta = json_encode([
+						'caption' => $status->caption,
+						'created_at' => $status->created_at,
+						'type' => $status->type,
+						'url' => $status->url(),
+						'is_nsfw' => $status->is_nsfw,
+						'scope' => $status->scope,
+						'reblog' => $status->reblog_of_id,
+						'likes_count' => $status->likes_count,
+						'reblogs_count' => $status->reblogs_count,
+					]);
+					$ai->save();
+
+					$u = $status->profile->user;
+					$u->has_interstitial = true;
+					$u->save();
+				}
+			break;
+
+			case 'spammer':
+				$status = Status::findOrFail($item_id);
+				HandleSpammerPipeline::dispatch($status->profile);
+				ModLogService::boot()
+					->user(Auth::user())
+					->objectUid($status->profile->user_id)
+					->objectId($status->id)
+					->objectType('App\User::class')
+					->action('admin.status.moderate')
+					->metadata([
+						'action' => 'spammer',
+						'message' => 'Success!'
+					])
+					->accessLevel('admin')
+					->save();
+			break;
+		}
+
+		Cache::forget('_api:statuses:recent_9:' . $status->profile_id);
+		Cache::forget('profile:embed:' . $status->profile_id);
+
+		return ['msg' => 200];
+	}
+
+	public function composePost(Request $request)
+	{
+		abort(400, 'Endpoint deprecated');
+	}
+
+	public function bookmarks(Request $request)
+	{
+		$statuses = Auth::user()->profile
+			->bookmarks()
+			->withCount(['likes','comments'])
+			->orderBy('created_at', 'desc')
+			->simplePaginate(10);
+
+		$resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
+		$res = $this->fractal->createData($resource)->toArray();
+
+		return response()->json($res);
+	}
+
+	public function accountStatuses(Request $request, $id)
+	{
+		$this->validate($request, [
+			'only_media' => 'nullable',
+			'pinned' => 'nullable',
+			'exclude_replies' => 'nullable',
+			'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
+			'since_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
+			'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
+			'limit' => 'nullable|integer|min:1|max:24'
+		]);
+
+		$profile = Profile::whereNull('status')->findOrFail($id);
+
+		$limit = $request->limit ?? 9;
+		$max_id = $request->max_id;
+		$min_id = $request->min_id;
+		$scope = $request->only_media == true ?
+			['photo', 'photo:album', 'video', 'video:album'] :
+			['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
+
+		if($profile->is_private) {
+			if(!Auth::check()) {
+				return response()->json([]);
+			}
+			$pid = Auth::user()->profile->id;
+			$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
+				$following = Follower::whereProfileId($pid)->pluck('following_id');
+				return $following->push($pid)->toArray();
+			});
+			$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : [];
+		} else {
+			if(Auth::check()) {
+				$pid = Auth::user()->profile->id;
+				$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
+					$following = Follower::whereProfileId($pid)->pluck('following_id');
+					return $following->push($pid)->toArray();
+				});
+				$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
+			} else {
+				$visibility = ['public', 'unlisted'];
+			}
+		}
+
+		$dir = $min_id ? '>' : '<';
+		$id = $min_id ?? $max_id;
+		$timeline = Status::select(
+			'id',
+			'uri',
+			'caption',
+			'rendered',
+			'profile_id',
+			'type',
+			'in_reply_to_id',
+			'reblog_of_id',
+			'is_nsfw',
+			'likes_count',
+			'reblogs_count',
+			'scope',
+			'local',
+			'created_at',
+			'updated_at'
+		  )->whereProfileId($profile->id)
+		  ->whereIn('type', $scope)
+		  ->where('id', $dir, $id)
+		  ->whereIn('visibility', $visibility)
+		  ->latest()
+		  ->limit($limit)
+		  ->get();
+
+		$resource = new Fractal\Resource\Collection($timeline, new StatusTransformer());
+		$res = $this->fractal->createData($resource)->toArray();
+
+		return response()->json($res);
+	}
+
+	public function remoteProfile(Request $request, $id)
+	{
+		$profile = Profile::whereNull('status')
+			->whereNotNull('domain')
+			->findOrFail($id);
+		$user = Auth::user();
+
+		return view('profile.remote', compact('profile', 'user'));
+	}
+
+	public function remoteStatus(Request $request, $profileId, $statusId)
+	{
+		$user = Profile::whereNull('status')
+			->whereNotNull('domain')
+			->findOrFail($profileId);
+
+		$status = Status::whereProfileId($user->id)
+						->whereNull('reblog_of_id')
+						->whereIn('visibility', ['public', 'unlisted'])
+						->findOrFail($statusId);
+		$template = $status->in_reply_to_id ? 'status.reply' : 'status.remote';
+		return view($template, compact('user', 'status'));
+	}
 }

+ 52 - 0
app/Jobs/ModPipeline/HandleSpammerPipeline.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Jobs\ModPipeline;
+
+use Cache;
+use App\Profile;
+use App\Status;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Services\StatusService;
+
+class HandleSpammerPipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $profile;
+
+	public $deleteWhenMissingModels = true;
+
+	public function __construct(Profile $profile)
+	{
+		$this->profile = $profile;
+	}
+
+	public function handle()
+	{
+		$profile = $this->profile;
+
+		$profile->unlisted = true;
+		$profile->cw = true;
+		$profile->no_autolink = true;
+		$profile->save();
+
+		Status::whereProfileId($profile->id)
+			->chunk(50, function($statuses) {
+				foreach($statuses as $status) {
+					$status->is_nsfw = true;
+					$status->scope = $status->scope === 'public' ? 'unlisted' : $status->scope;
+					$status->visibility = $status->scope;
+					$status->save();
+					StatusService::del($status->id);
+				}
+		});
+
+		Cache::forget('_api:statuses:recent_9:'.$profile->id);
+
+		return 1;
+	}
+}

BIN
public/js/timeline.js


BIN
public/mix-manifest.json


+ 116 - 82
resources/assets/js/components/partials/ContextMenu.vue

@@ -37,6 +37,10 @@
 				<div class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'unlist')">Unlist from Timelines</div>
 				<div v-if="status.sensitive" class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'remcw')">Remove Content Warning</div>
 				<div v-else class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'addcw')">Add Content Warning</div>
+				<div class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'spammer')">
+					Mark as Spammer<br />
+					<span class="small">Unlist + CW existing and future posts</span>
+				</div>
 				<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxModOtherMenuShow()">Other</div> -->
 				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxModMenuClose()">Cancel</div>
 			</div>
@@ -465,99 +469,129 @@
 
 			moderatePost(status, action, $event) {
 				let username = status.account.username;
+				let pid = status.id;
 				let msg = '';
 				let self = this;
 				switch(action) {
 					case 'addcw':
-					msg = 'Are you sure you want to add a content warning to this post?';
-					swal({
-						title: 'Confirm',
-						text: msg,
-						icon: 'warning',
-						buttons: true,
-						dangerMode: true
-					}).then(res =>  {
-						if(res) {
-							axios.post('/api/v2/moderator/action', {
-								action: action,
-								item_id: status.id,
-								item_type: 'status'
-							}).then(res => {
-								swal('Success', 'Successfully added content warning', 'success');
-								status.sensitive = true;
-								self.ctxModMenuClose();
-							}).catch(err => {
-								swal(
-									'Error',
-									'Something went wrong, please try again later.',
-									'error'
-									);
-								self.ctxModMenuClose();
-							});
-						}
-					});
+						msg = 'Are you sure you want to add a content warning to this post?';
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									swal('Success', 'Successfully added content warning', 'success');
+									status.sensitive = true;
+									self.ctxModMenuClose();
+								}).catch(err => {
+									swal(
+										'Error',
+										'Something went wrong, please try again later.',
+										'error'
+										);
+									self.ctxModMenuClose();
+								});
+							}
+						});
 					break;
 
 					case 'remcw':
-					msg = 'Are you sure you want to remove the content warning on this post?';
-					swal({
-						title: 'Confirm',
-						text: msg,
-						icon: 'warning',
-						buttons: true,
-						dangerMode: true
-					}).then(res =>  {
-						if(res) {
-							axios.post('/api/v2/moderator/action', {
-								action: action,
-								item_id: status.id,
-								item_type: 'status'
-							}).then(res => {
-								swal('Success', 'Successfully added content warning', 'success');
-								status.sensitive = false;
-								self.ctxModMenuClose();
-							}).catch(err => {
-								swal(
-									'Error',
-									'Something went wrong, please try again later.',
-									'error'
-									);
-								self.ctxModMenuClose();
-							});
-						}
-					});
+						msg = 'Are you sure you want to remove the content warning on this post?';
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									swal('Success', 'Successfully added content warning', 'success');
+									status.sensitive = false;
+									self.ctxModMenuClose();
+								}).catch(err => {
+									swal(
+										'Error',
+										'Something went wrong, please try again later.',
+										'error'
+										);
+									self.ctxModMenuClose();
+								});
+							}
+						});
 					break;
 
 					case 'unlist':
-					msg = 'Are you sure you want to unlist this post?';
-					swal({
-						title: 'Confirm',
-						text: msg,
-						icon: 'warning',
-						buttons: true,
-						dangerMode: true
-					}).then(res =>  {
-						if(res) {
-							axios.post('/api/v2/moderator/action', {
-								action: action,
-								item_id: status.id,
-								item_type: 'status'
-							}).then(res => {
-								this.feed = this.feed.filter(f => {
-									return f.id != status.id;
+						msg = 'Are you sure you want to unlist this post?';
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									this.feed = this.feed.filter(f => {
+										return f.id != status.id;
+									});
+									swal('Success', 'Successfully unlisted post', 'success');
+									self.ctxModMenuClose();
+								}).catch(err => {
+									self.ctxModMenuClose();
+									swal(
+										'Error',
+										'Something went wrong, please try again later.',
+										'error'
+										);
 								});
-								swal('Success', 'Successfully unlisted post', 'success');
-								self.ctxModMenuClose();
-							}).catch(err => {
-								self.ctxModMenuClose();
-								swal(
-									'Error',
-									'Something went wrong, please try again later.',
-									'error'
-									);
-							});
-						}
-					});
+							}
+						});
+					break;
+
+					case 'spammer':
+						msg = 'Are you sure you want to mark this user as a spammer? All existing and future posts will be unlisted on timelines and a content warning will be applied.';
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									swal('Success', 'Successfully marked account as spammer', 'success');
+									self.ctxModMenuClose();
+								}).catch(err => {
+									self.ctxModMenuClose();
+									swal(
+										'Error',
+										'Something went wrong, please try again later.',
+										'error'
+										);
+								});
+							}
+						});
 					break;
 				}
 			},