Explorar el Código

Update StoryController, add parental controls support

Daniel Supernault hace 1 año
padre
commit
71c148c61e
Se han modificado 2 ficheros con 757 adiciones y 726 borrados
  1. 456 445
      app/Http/Controllers/StoryComposeController.php
  2. 301 281
      app/Http/Controllers/StoryController.php

+ 456 - 445
app/Http/Controllers/StoryComposeController.php

@@ -29,306 +29,315 @@ use App\Jobs\StoryPipeline\StoryFanout;
 use App\Jobs\StoryPipeline\StoryDelete;
 use ImageOptimizer;
 use App\Models\Conversation;
+use App\Services\UserRoleService;
 
 class StoryComposeController extends Controller
 {
     public function apiV1Add(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'file' => function() {
-				return [
-					'required',
-					'mimetypes:image/jpeg,image/png,video/mp4',
-					'max:' . config_cache('pixelfed.max_photo_size'),
-				];
-			},
-		]);
-
-		$user = $request->user();
-
-		$count = Story::whereProfileId($user->profile_id)
-			->whereActive(true)
-			->where('expires_at', '>', now())
-			->count();
-
-		if($count >= Story::MAX_PER_DAY) {
-			abort(418, 'You have reached your limit for new Stories today.');
-		}
-
-		$photo = $request->file('file');
-		$path = $this->storePhoto($photo, $user);
-
-		$story = new Story();
-		$story->duration = 3;
-		$story->profile_id = $user->profile_id;
-		$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
-		$story->mime = $photo->getMimeType();
-		$story->path = $path;
-		$story->local = true;
-		$story->size = $photo->getSize();
-		$story->bearcap_token = str_random(64);
-		$story->expires_at = now()->addMinutes(1440);
-		$story->save();
-
-		$url = $story->path;
-
-		$res = [
-			'code' => 200,
-			'msg'  => 'Successfully added',
-			'media_id' => (string) $story->id,
-			'media_url' => url(Storage::url($url)) . '?v=' . time(),
-			'media_type' => $story->type
-		];
-
-		if($story->type === 'video') {
-			$video = FFMpeg::open($path);
-			$duration = $video->getDurationInSeconds();
-			$res['media_duration'] = $duration;
-			if($duration > 500) {
-				Storage::delete($story->path);
-				$story->delete();
-				return response()->json([
-					'message' => 'Video duration cannot exceed 60 seconds'
-				], 422);
-			}
-		}
-
-		return $res;
-	}
-
-	protected function storePhoto($photo, $user)
-	{
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-		if(in_array($photo->getMimeType(), [
-			'image/jpeg',
-			'image/png',
-			'video/mp4'
-		]) == false) {
-			abort(400, 'Invalid media type');
-			return;
-		}
-
-		$storagePath = MediaPathService::story($user->profile);
-		$path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
-		if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
-			$fpath = storage_path('app/' . $path);
-			$img = Intervention::make($fpath);
-			$img->orientate();
-			$img->save($fpath, config_cache('pixelfed.image_quality'));
-			$img->destroy();
-		}
-		return $path;
-	}
-
-	public function cropPhoto(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'media_id' => 'required|integer|min:1',
-			'width' => 'required',
-			'height' => 'required',
-			'x' => 'required',
-			'y' => 'required'
-		]);
-
-		$user = $request->user();
-		$id = $request->input('media_id');
-		$width = round($request->input('width'));
-		$height = round($request->input('height'));
-		$x = round($request->input('x'));
-		$y = round($request->input('y'));
-
-		$story = Story::whereProfileId($user->profile_id)->findOrFail($id);
-
-		$path = storage_path('app/' . $story->path);
-
-		if(!is_file($path)) {
-			abort(400, 'Invalid or missing media.');
-		}
-
-		if($story->type === 'photo') {
-			$img = Intervention::make($path);
-			$img->crop($width, $height, $x, $y);
-			$img->resize(1080, 1920, function ($constraint) {
-				$constraint->aspectRatio();
-			});
-			$img->save($path, config_cache('pixelfed.image_quality'));
-		}
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully cropped',
-		];
-	}
-
-	public function publishStory(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'media_id' => 'required',
-			'duration' => 'required|integer|min:3|max:120',
-			'can_reply' => 'required|boolean',
-			'can_react' => 'required|boolean'
-		]);
-
-		$id = $request->input('media_id');
-		$user = $request->user();
-		$story = Story::whereProfileId($user->profile_id)
-			->findOrFail($id);
-
-		$story->active = true;
-		$story->duration = $request->input('duration', 10);
-		$story->can_reply = $request->input('can_reply');
-		$story->can_react = $request->input('can_react');
-		$story->save();
-
-		StoryService::delLatest($story->profile_id);
-		StoryFanout::dispatch($story)->onQueue('story');
-		StoryService::addRotateQueue($story->id);
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully published',
-		];
-	}
-
-	public function apiV1Delete(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$user = $request->user();
-
-		$story = Story::whereProfileId($user->profile_id)
-			->findOrFail($id);
-		$story->active = false;
-		$story->save();
-
-		StoryDelete::dispatch($story)->onQueue('story');
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully deleted'
-		];
-	}
-
-	public function compose(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		return view('stories.compose');
-	}
-
-	public function createPoll(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-		abort_if(!config_cache('instance.polls.enabled'), 404);
-
-		return $request->all();
-	}
-
-	public function publishStoryPoll(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'question' => 'required|string|min:6|max:140',
-			'options' => 'required|array|min:2|max:4',
-			'can_reply' => 'required|boolean',
-			'can_react' => 'required|boolean'
-		]);
-
-		$pid = $request->user()->profile_id;
-
-		$count = Story::whereProfileId($pid)
-			->whereActive(true)
-			->where('expires_at', '>', now())
-			->count();
-
-		if($count >= Story::MAX_PER_DAY) {
-			abort(418, 'You have reached your limit for new Stories today.');
-		}
-
-		$story = new Story;
-		$story->type = 'poll';
-		$story->story = json_encode([
-			'question' => $request->input('question'),
-			'options' => $request->input('options')
-		]);
-		$story->public = false;
-		$story->local = true;
-		$story->profile_id = $pid;
-		$story->expires_at = now()->addMinutes(1440);
-		$story->duration = 30;
-		$story->can_reply = false;
-		$story->can_react = false;
-		$story->save();
-
-		$poll = new Poll;
-		$poll->story_id = $story->id;
-		$poll->profile_id = $pid;
-		$poll->poll_options = $request->input('options');
-		$poll->expires_at = $story->expires_at;
-		$poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
-			return 0;
-		})->toArray();
-		$poll->save();
-
-		$story->active = true;
-		$story->save();
-
-		StoryService::delLatest($story->profile_id);
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully published',
-		];
-	}
-
-	public function storyPollVote(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'sid' => 'required',
-			'ci' => 'required|integer|min:0|max:3'
-		]);
-
-		$pid = $request->user()->profile_id;
-		$ci = $request->input('ci');
-		$story = Story::findOrFail($request->input('sid'));
-		abort_if(!FollowerService::follows($pid, $story->profile_id), 403);
-		$poll = Poll::whereStoryId($story->id)->firstOrFail();
-
-		$vote = new PollVote;
-		$vote->profile_id = $pid;
-		$vote->poll_id = $poll->id;
-		$vote->story_id = $story->id;
-		$vote->status_id = null;
-		$vote->choice = $ci;
-		$vote->save();
-
-		$poll->votes_count = $poll->votes_count + 1;
-    	$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($ci) {
-    		return $ci == $key ? $tally + 1 : $tally;
-    	})->toArray();
-    	$poll->save();
-
-		return 200;
-	}
-
-	public function storeReport(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
+            'file' => function() {
+                return [
+                    'required',
+                    'mimetypes:image/jpeg,image/png,video/mp4',
+                    'max:' . config_cache('pixelfed.max_photo_size'),
+                ];
+            },
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        $count = Story::whereProfileId($user->profile_id)
+            ->whereActive(true)
+            ->where('expires_at', '>', now())
+            ->count();
+
+        if($count >= Story::MAX_PER_DAY) {
+            abort(418, 'You have reached your limit for new Stories today.');
+        }
+
+        $photo = $request->file('file');
+        $path = $this->storePhoto($photo, $user);
+
+        $story = new Story();
+        $story->duration = 3;
+        $story->profile_id = $user->profile_id;
+        $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
+        $story->mime = $photo->getMimeType();
+        $story->path = $path;
+        $story->local = true;
+        $story->size = $photo->getSize();
+        $story->bearcap_token = str_random(64);
+        $story->expires_at = now()->addMinutes(1440);
+        $story->save();
+
+        $url = $story->path;
+
+        $res = [
+            'code' => 200,
+            'msg'  => 'Successfully added',
+            'media_id' => (string) $story->id,
+            'media_url' => url(Storage::url($url)) . '?v=' . time(),
+            'media_type' => $story->type
+        ];
+
+        if($story->type === 'video') {
+            $video = FFMpeg::open($path);
+            $duration = $video->getDurationInSeconds();
+            $res['media_duration'] = $duration;
+            if($duration > 500) {
+                Storage::delete($story->path);
+                $story->delete();
+                return response()->json([
+                    'message' => 'Video duration cannot exceed 60 seconds'
+                ], 422);
+            }
+        }
+
+        return $res;
+    }
+
+    protected function storePhoto($photo, $user)
+    {
+        $mimes = explode(',', config_cache('pixelfed.media_types'));
+        if(in_array($photo->getMimeType(), [
+            'image/jpeg',
+            'image/png',
+            'video/mp4'
+        ]) == false) {
+            abort(400, 'Invalid media type');
+            return;
+        }
+
+        $storagePath = MediaPathService::story($user->profile);
+        $path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
+        if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
+            $fpath = storage_path('app/' . $path);
+            $img = Intervention::make($fpath);
+            $img->orientate();
+            $img->save($fpath, config_cache('pixelfed.image_quality'));
+            $img->destroy();
+        }
+        return $path;
+    }
+
+    public function cropPhoto(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
+            'media_id' => 'required|integer|min:1',
+            'width' => 'required',
+            'height' => 'required',
+            'x' => 'required',
+            'y' => 'required'
+        ]);
+
+        $user = $request->user();
+        $id = $request->input('media_id');
+        $width = round($request->input('width'));
+        $height = round($request->input('height'));
+        $x = round($request->input('x'));
+        $y = round($request->input('y'));
+
+        $story = Story::whereProfileId($user->profile_id)->findOrFail($id);
+
+        $path = storage_path('app/' . $story->path);
+
+        if(!is_file($path)) {
+            abort(400, 'Invalid or missing media.');
+        }
+
+        if($story->type === 'photo') {
+            $img = Intervention::make($path);
+            $img->crop($width, $height, $x, $y);
+            $img->resize(1080, 1920, function ($constraint) {
+                $constraint->aspectRatio();
+            });
+            $img->save($path, config_cache('pixelfed.image_quality'));
+        }
+
+        return [
+            'code' => 200,
+            'msg'  => 'Successfully cropped',
+        ];
+    }
+
+    public function publishStory(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
+            'media_id' => 'required',
+            'duration' => 'required|integer|min:3|max:120',
+            'can_reply' => 'required|boolean',
+            'can_react' => 'required|boolean'
+        ]);
+
+        $id = $request->input('media_id');
+        $user = $request->user();
+        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        $story = Story::whereProfileId($user->profile_id)
+            ->findOrFail($id);
+
+        $story->active = true;
+        $story->duration = $request->input('duration', 10);
+        $story->can_reply = $request->input('can_reply');
+        $story->can_react = $request->input('can_react');
+        $story->save();
+
+        StoryService::delLatest($story->profile_id);
+        StoryFanout::dispatch($story)->onQueue('story');
+        StoryService::addRotateQueue($story->id);
+
+        return [
+            'code' => 200,
+            'msg'  => 'Successfully published',
+        ];
+    }
+
+    public function apiV1Delete(Request $request, $id)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $user = $request->user();
+
+        $story = Story::whereProfileId($user->profile_id)
+            ->findOrFail($id);
+        $story->active = false;
+        $story->save();
+
+        StoryDelete::dispatch($story)->onQueue('story');
+
+        return [
+            'code' => 200,
+            'msg'  => 'Successfully deleted'
+        ];
+    }
+
+    public function compose(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        $user = $request->user();
+        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+
+        return view('stories.compose');
+    }
+
+    public function createPoll(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        abort_if(!config_cache('instance.polls.enabled'), 404);
+
+        return $request->all();
+    }
+
+    public function publishStoryPoll(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
+            'question' => 'required|string|min:6|max:140',
+            'options' => 'required|array|min:2|max:4',
+            'can_reply' => 'required|boolean',
+            'can_react' => 'required|boolean'
+        ]);
+
+        $user = $request->user();
+        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        $pid = $request->user()->profile_id;
+
+        $count = Story::whereProfileId($pid)
+            ->whereActive(true)
+            ->where('expires_at', '>', now())
+            ->count();
+
+        if($count >= Story::MAX_PER_DAY) {
+            abort(418, 'You have reached your limit for new Stories today.');
+        }
+
+        $story = new Story;
+        $story->type = 'poll';
+        $story->story = json_encode([
+            'question' => $request->input('question'),
+            'options' => $request->input('options')
+        ]);
+        $story->public = false;
+        $story->local = true;
+        $story->profile_id = $pid;
+        $story->expires_at = now()->addMinutes(1440);
+        $story->duration = 30;
+        $story->can_reply = false;
+        $story->can_react = false;
+        $story->save();
+
+        $poll = new Poll;
+        $poll->story_id = $story->id;
+        $poll->profile_id = $pid;
+        $poll->poll_options = $request->input('options');
+        $poll->expires_at = $story->expires_at;
+        $poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
+            return 0;
+        })->toArray();
+        $poll->save();
+
+        $story->active = true;
+        $story->save();
+
+        StoryService::delLatest($story->profile_id);
+
+        return [
+            'code' => 200,
+            'msg'  => 'Successfully published',
+        ];
+    }
+
+    public function storyPollVote(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
+            'sid' => 'required',
+            'ci' => 'required|integer|min:0|max:3'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $ci = $request->input('ci');
+        $story = Story::findOrFail($request->input('sid'));
+        abort_if(!FollowerService::follows($pid, $story->profile_id), 403);
+        $poll = Poll::whereStoryId($story->id)->firstOrFail();
+
+        $vote = new PollVote;
+        $vote->profile_id = $pid;
+        $vote->poll_id = $poll->id;
+        $vote->story_id = $story->id;
+        $vote->status_id = null;
+        $vote->choice = $ci;
+        $vote->save();
+
+        $poll->votes_count = $poll->votes_count + 1;
+        $poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($ci) {
+            return $ci == $key ? $tally + 1 : $tally;
+        })->toArray();
+        $poll->save();
+
+        return 200;
+    }
+
+    public function storeReport(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
             'type'  => 'required|alpha_dash',
             'id'    => 'required|integer|min:1',
         ]);
 
+        $user = $request->user();
+        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+
         $pid = $request->user()->profile_id;
         $sid = $request->input('id');
         $type = $request->input('type');
@@ -355,17 +364,17 @@ class StoryComposeController extends Controller
         abort_if(!FollowerService::follows($pid, $story->profile_id), 422, 'Cannot report a story from an account you do not follow');
 
         if( Report::whereProfileId($pid)
-        	->whereObjectType('App\Story')
-        	->whereObjectId($story->id)
-        	->exists()
+            ->whereObjectType('App\Story')
+            ->whereObjectId($story->id)
+            ->exists()
         ) {
-        	return response()->json(['error' => [
-        		'code' => 409,
-        		'message' => 'Cannot report the same story again'
-        	]], 409);
+            return response()->json(['error' => [
+                'code' => 409,
+                'message' => 'Cannot report the same story again'
+            ]], 409);
         }
 
-		$report = new Report;
+        $report = new Report;
         $report->profile_id = $pid;
         $report->user_id = $request->user()->id;
         $report->object_id = $story->id;
@@ -376,149 +385,151 @@ class StoryComposeController extends Controller
         $report->save();
 
         return [200];
-	}
-
-	public function react(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-		$this->validate($request, [
-			'sid' => 'required',
-			'reaction' => 'required|string'
-		]);
-		$pid = $request->user()->profile_id;
-		$text = $request->input('reaction');
-
-		$story = Story::findOrFail($request->input('sid'));
-
-		abort_if(!$story->can_react, 422);
-		abort_if(StoryService::reactCounter($story->id, $pid) >= 5, 422, 'You have already reacted to this story');
-
-		$status = new Status;
-		$status->profile_id = $pid;
-		$status->type = 'story:reaction';
-		$status->caption = $text;
-		$status->rendered = $text;
-		$status->scope = 'direct';
-		$status->visibility = 'direct';
-		$status->in_reply_to_profile_id = $story->profile_id;
-		$status->entities = json_encode([
-			'story_id' => $story->id,
-			'reaction' => $text
-		]);
-		$status->save();
-
-		$dm = new DirectMessage;
-		$dm->to_id = $story->profile_id;
-		$dm->from_id = $pid;
-		$dm->type = 'story:react';
-		$dm->status_id = $status->id;
-		$dm->meta = json_encode([
-			'story_username' => $story->profile->username,
-			'story_actor_username' => $request->user()->username,
-			'story_id' => $story->id,
-			'story_media_url' => url(Storage::url($story->path)),
-			'reaction' => $text
-		]);
-		$dm->save();
-
-		Conversation::updateOrInsert(
-			[
-				'to_id' => $story->profile_id,
-				'from_id' => $pid
-			],
-			[
-				'type' => 'story:react',
-				'status_id' => $status->id,
-				'dm_id' => $dm->id,
-				'is_hidden' => false
-			]
-		);
-
-		if($story->local) {
-			// generate notification
-			$n = new Notification;
-			$n->profile_id = $dm->to_id;
-			$n->actor_id = $dm->from_id;
-			$n->item_id = $dm->id;
-			$n->item_type = 'App\DirectMessage';
-			$n->action = 'story:react';
-			$n->save();
-		} else {
-			StoryReactionDeliver::dispatch($story, $status)->onQueue('story');
-		}
-
-		StoryService::reactIncrement($story->id, $pid);
-
-		return 200;
-	}
-
-	public function comment(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-		$this->validate($request, [
-			'sid' => 'required',
-			'caption' => 'required|string'
-		]);
-		$pid = $request->user()->profile_id;
-		$text = $request->input('caption');
-
-		$story = Story::findOrFail($request->input('sid'));
-
-		abort_if(!$story->can_reply, 422);
-
-		$status = new Status;
-		$status->type = 'story:reply';
-		$status->profile_id = $pid;
-		$status->caption = $text;
-		$status->rendered = $text;
-		$status->scope = 'direct';
-		$status->visibility = 'direct';
-		$status->in_reply_to_profile_id = $story->profile_id;
-		$status->entities = json_encode([
-			'story_id' => $story->id
-		]);
-		$status->save();
-
-		$dm = new DirectMessage;
-		$dm->to_id = $story->profile_id;
-		$dm->from_id = $pid;
-		$dm->type = 'story:comment';
-		$dm->status_id = $status->id;
-		$dm->meta = json_encode([
-			'story_username' => $story->profile->username,
-			'story_actor_username' => $request->user()->username,
-			'story_id' => $story->id,
-			'story_media_url' => url(Storage::url($story->path)),
-			'caption' => $text
-		]);
-		$dm->save();
-
-		Conversation::updateOrInsert(
-			[
-				'to_id' => $story->profile_id,
-				'from_id' => $pid
-			],
-			[
-				'type' => 'story:comment',
-				'status_id' => $status->id,
-				'dm_id' => $dm->id,
-				'is_hidden' => false
-			]
-		);
-
-		if($story->local) {
-			// generate notification
-			$n = new Notification;
-			$n->profile_id = $dm->to_id;
-			$n->actor_id = $dm->from_id;
-			$n->item_id = $dm->id;
-			$n->item_type = 'App\DirectMessage';
-			$n->action = 'story:comment';
-			$n->save();
-		} else {
-			StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
-		}
-
-		return 200;
-	}
+    }
+
+    public function react(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        $this->validate($request, [
+            'sid' => 'required',
+            'reaction' => 'required|string'
+        ]);
+        $pid = $request->user()->profile_id;
+        $text = $request->input('reaction');
+        $user = $request->user();
+        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        $story = Story::findOrFail($request->input('sid'));
+
+        abort_if(!$story->can_react, 422);
+        abort_if(StoryService::reactCounter($story->id, $pid) >= 5, 422, 'You have already reacted to this story');
+
+        $status = new Status;
+        $status->profile_id = $pid;
+        $status->type = 'story:reaction';
+        $status->caption = $text;
+        $status->rendered = $text;
+        $status->scope = 'direct';
+        $status->visibility = 'direct';
+        $status->in_reply_to_profile_id = $story->profile_id;
+        $status->entities = json_encode([
+            'story_id' => $story->id,
+            'reaction' => $text
+        ]);
+        $status->save();
+
+        $dm = new DirectMessage;
+        $dm->to_id = $story->profile_id;
+        $dm->from_id = $pid;
+        $dm->type = 'story:react';
+        $dm->status_id = $status->id;
+        $dm->meta = json_encode([
+            'story_username' => $story->profile->username,
+            'story_actor_username' => $request->user()->username,
+            'story_id' => $story->id,
+            'story_media_url' => url(Storage::url($story->path)),
+            'reaction' => $text
+        ]);
+        $dm->save();
+
+        Conversation::updateOrInsert(
+            [
+                'to_id' => $story->profile_id,
+                'from_id' => $pid
+            ],
+            [
+                'type' => 'story:react',
+                'status_id' => $status->id,
+                'dm_id' => $dm->id,
+                'is_hidden' => false
+            ]
+        );
+
+        if($story->local) {
+            // generate notification
+            $n = new Notification;
+            $n->profile_id = $dm->to_id;
+            $n->actor_id = $dm->from_id;
+            $n->item_id = $dm->id;
+            $n->item_type = 'App\DirectMessage';
+            $n->action = 'story:react';
+            $n->save();
+        } else {
+            StoryReactionDeliver::dispatch($story, $status)->onQueue('story');
+        }
+
+        StoryService::reactIncrement($story->id, $pid);
+
+        return 200;
+    }
+
+    public function comment(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        $this->validate($request, [
+            'sid' => 'required',
+            'caption' => 'required|string'
+        ]);
+        $pid = $request->user()->profile_id;
+        $text = $request->input('caption');
+        $user = $request->user();
+        abort_if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id), 403, 'Invalid permissions for this action');
+        $story = Story::findOrFail($request->input('sid'));
+
+        abort_if(!$story->can_reply, 422);
+
+        $status = new Status;
+        $status->type = 'story:reply';
+        $status->profile_id = $pid;
+        $status->caption = $text;
+        $status->rendered = $text;
+        $status->scope = 'direct';
+        $status->visibility = 'direct';
+        $status->in_reply_to_profile_id = $story->profile_id;
+        $status->entities = json_encode([
+            'story_id' => $story->id
+        ]);
+        $status->save();
+
+        $dm = new DirectMessage;
+        $dm->to_id = $story->profile_id;
+        $dm->from_id = $pid;
+        $dm->type = 'story:comment';
+        $dm->status_id = $status->id;
+        $dm->meta = json_encode([
+            'story_username' => $story->profile->username,
+            'story_actor_username' => $request->user()->username,
+            'story_id' => $story->id,
+            'story_media_url' => url(Storage::url($story->path)),
+            'caption' => $text
+        ]);
+        $dm->save();
+
+        Conversation::updateOrInsert(
+            [
+                'to_id' => $story->profile_id,
+                'from_id' => $pid
+            ],
+            [
+                'type' => 'story:comment',
+                'status_id' => $status->id,
+                'dm_id' => $dm->id,
+                'is_hidden' => false
+            ]
+        );
+
+        if($story->local) {
+            // generate notification
+            $n = new Notification;
+            $n->profile_id = $dm->to_id;
+            $n->actor_id = $dm->from_id;
+            $n->item_id = $dm->id;
+            $n->item_type = 'App\DirectMessage';
+            $n->action = 'story:comment';
+            $n->save();
+        } else {
+            StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
+        }
+
+        return 200;
+    }
 }

+ 301 - 281
app/Http/Controllers/StoryController.php

@@ -28,288 +28,308 @@ use League\Fractal\Serializer\ArraySerializer;
 use League\Fractal\Resource\Item;
 use App\Transformer\ActivityPub\Verb\StoryVerb;
 use App\Jobs\StoryPipeline\StoryViewDeliver;
+use App\Services\UserRoleService;
 
 class StoryController extends StoryComposeController
 {
-	public function recent(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-		$pid = $request->user()->profile_id;
-
-		if(config('database.default') == 'pgsql') {
-			$s = Cache::remember('pf:stories:recent-by-id:' . $pid, 900, function() use($pid) {
-				return Story::select('stories.*', 'followers.following_id')
-					->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
-					->where('followers.profile_id', $pid)
-					->where('stories.active', true)
-					->get()
-					->map(function($s) {
-						$r  = new \StdClass;
-						$r->id = $s->id;
-						$r->profile_id = $s->profile_id;
-						$r->type = $s->type;
-						$r->path = $s->path;
-						return $r;
-					})
-					->unique('profile_id');
-			});
-
-		} else {
-			$s = Cache::remember('pf:stories:recent-by-id:' . $pid, 900, function() use($pid) {
-				return Story::select('stories.*', 'followers.following_id')
-					->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
-					->where('followers.profile_id', $pid)
-					->where('stories.active', true)
-					->groupBy('followers.following_id')
-					->orderByDesc('id')
-					->get();
-			});
-		}
-
-		$self = Cache::remember('pf:stories:recent-self:' . $pid, 21600, function() use($pid) {
-			return Story::whereProfileId($pid)
-				->whereActive(true)
-				->orderByDesc('id')
-				->limit(1)
-				->get()
-				->map(function($s) use($pid) {
-					$r  = new \StdClass;
-					$r->id = $s->id;
-					$r->profile_id = $pid;
-					$r->type = $s->type;
-					$r->path = $s->path;
-					return $r;
-				});
-		});
-
-		if($self->count()) {
-			$s->prepend($self->first());
-		}
-
-		$res = $s->map(function($s) use($pid) {
-			$profile = AccountService::get($s->profile_id);
-			$url = $profile['local'] ? url("/stories/{$profile['username']}") :
-				url("/i/rs/{$profile['id']}");
-			return [
-				'pid' => $profile['id'],
-				'avatar' => $profile['avatar'],
-				'local' => $profile['local'],
-				'username'	=> $profile['acct'],
-				'latest' => [
-					'id' => $s->id,
-					'type' => $s->type,
-					'preview_url' => url(Storage::url($s->path))
-				],
-				'url' => $url,
-				'seen' => StoryService::hasSeen($pid, StoryService::latest($s->profile_id)),
-				'sid' => $s->id
-			];
-		})
-		->sortBy('seen')
-		->values();
-		return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function profile(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$authed = $request->user()->profile_id;
-		$profile = Profile::findOrFail($id);
-
-		if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) {
-			return abort([], 403);
-		}
-
-		$stories = Story::whereProfileId($profile->id)
-		->whereActive(true)
-		->orderBy('expires_at')
-		->get()
-		->map(function($s, $k) use($authed) {
-			$seen = StoryService::hasSeen($authed, $s->id);
-			$res = [
-				'id' => (string) $s->id,
-				'type' => $s->type,
-				'duration' => $s->duration,
-				'src' => url(Storage::url($s->path)),
-				'created_at' => $s->created_at->toAtomString(),
-				'expires_at' => $s->expires_at->toAtomString(),
-				'view_count' => ($authed == $s->profile_id) ? ($s->view_count ?? 0) : null,
-				'seen' => $seen,
-				'progress' => $seen ? 100 : 0,
-				'can_reply' => (bool) $s->can_reply,
-				'can_react' => (bool) $s->can_react
-			];
-
-			if($s->type == 'poll') {
-				$res['question'] = json_decode($s->story, true)['question'];
-				$res['options'] = json_decode($s->story, true)['options'];
-				$res['voted'] = PollService::votedStory($s->id, $authed);
-				if($res['voted']) {
-					$res['voted_index'] = PollService::storyChoice($s->id, $authed);
-				}
-			}
-
-			return $res;
-		})->toArray();
-		if(count($stories) == 0) {
-			return [];
-		}
-		$cursor = count($stories) - 1;
-		$stories = [[
-			'id' => (string) $stories[$cursor]['id'],
-			'nodes' => $stories,
-			'account' => AccountService::get($profile->id),
-			'pid' => (string) $profile->id
-		]];
-		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function viewed(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'id'	=> 'required|min:1',
-		]);
-		$id = $request->input('id');
-
-		$authed = $request->user()->profile;
-
-		$story = Story::with('profile')
-			->findOrFail($id);
-		$exp = $story->expires_at;
-
-		$profile = $story->profile;
-
-		if($story->profile_id == $authed->id) {
-			return [];
-		}
-
-		$publicOnly = (bool) $profile->followedBy($authed);
-		abort_if(!$publicOnly, 403);
-
-		$v = StoryView::firstOrCreate([
-			'story_id' => $id,
-			'profile_id' => $authed->id
-		]);
-
-		if($v->wasRecentlyCreated) {
-			Story::findOrFail($story->id)->increment('view_count');
-
-			if($story->local == false) {
-				StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
-			}
-		}
-
-		Cache::forget('stories:recent:by_id:' . $authed->id);
-		StoryService::addSeen($authed->id, $story->id);
-		return ['code' => 200];
-	}
-
-	public function exists(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		return response()->json(Story::whereProfileId($id)
-		->whereActive(true)
-		->exists());
-	}
-
-	public function iRedirect(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$user = $request->user();
-		abort_if(!$user, 404);
-		$username = $user->username;
-		return redirect("/stories/{$username}");
-	}
-
-	public function viewers(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'sid' => 'required|string'
-		]);
-
-		$pid = $request->user()->profile_id;
-		$sid = $request->input('sid');
-
-		$story = Story::whereProfileId($pid)
-			->whereActive(true)
-			->findOrFail($sid);
-
-		$viewers = StoryView::whereStoryId($story->id)
-			->latest()
-			->simplePaginate(10)
-			->map(function($view) {
-				return AccountService::get($view->profile_id);
-			})
-			->values();
-
-		return response()->json($viewers, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function remoteStory(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$profile = Profile::findOrFail($id);
-		if($profile->user_id != null || $profile->domain == null) {
-			return redirect('/stories/' . $profile->username);
-		}
-		$pid = $profile->id;
-		return view('stories.show_remote', compact('pid'));
-	}
-
-	public function pollResults(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'sid' => 'required|string'
-		]);
-
-		$pid = $request->user()->profile_id;
-		$sid = $request->input('sid');
-
-		$story = Story::whereProfileId($pid)
-			->whereActive(true)
-			->findOrFail($sid);
-
-		return PollService::storyResults($sid);
-	}
-
-	public function getActivityObject(Request $request, $username, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled'), 404);
-
-		if(!$request->wantsJson()) {
-			return redirect('/stories/' . $username);
-		}
-
-		abort_if(!$request->hasHeader('Authorization'), 404);
-
-		$profile = Profile::whereUsername($username)->whereNull('domain')->firstOrFail();
-		$story = Story::whereActive(true)->whereProfileId($profile->id)->findOrFail($id);
-
-		abort_if($story->bearcap_token == null, 404);
-		abort_if(now()->gt($story->expires_at), 404);
-		$token = substr($request->header('Authorization'), 7);
-		abort_if(hash_equals($story->bearcap_token, $token) === false, 404);
-		abort_if($story->created_at->lt(now()->subMinutes(20)), 404);
-
-		$fractal = new Manager();
-		$fractal->setSerializer(new ArraySerializer());
-		$resource = new Item($story, new StoryVerb());
-		$res = $fractal->createData($resource)->toArray();
-		return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function showSystemStory()
-	{
-		// return view('stories.system');
-	}
+    public function recent(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        $user = $request->user();
+        if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id)) {
+            return [];
+        }
+        $pid = $user->profile_id;
+
+        if(config('database.default') == 'pgsql') {
+            $s = Cache::remember('pf:stories:recent-by-id:' . $pid, 900, function() use($pid) {
+                return Story::select('stories.*', 'followers.following_id')
+                    ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
+                    ->where('followers.profile_id', $pid)
+                    ->where('stories.active', true)
+                    ->get()
+                    ->map(function($s) {
+                        $r  = new \StdClass;
+                        $r->id = $s->id;
+                        $r->profile_id = $s->profile_id;
+                        $r->type = $s->type;
+                        $r->path = $s->path;
+                        return $r;
+                    })
+                    ->unique('profile_id');
+            });
+
+        } else {
+            $s = Cache::remember('pf:stories:recent-by-id:' . $pid, 900, function() use($pid) {
+                return Story::select('stories.*', 'followers.following_id')
+                    ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
+                    ->where('followers.profile_id', $pid)
+                    ->where('stories.active', true)
+                    ->groupBy('followers.following_id')
+                    ->orderByDesc('id')
+                    ->get();
+            });
+        }
+
+        $self = Cache::remember('pf:stories:recent-self:' . $pid, 21600, function() use($pid) {
+            return Story::whereProfileId($pid)
+                ->whereActive(true)
+                ->orderByDesc('id')
+                ->limit(1)
+                ->get()
+                ->map(function($s) use($pid) {
+                    $r  = new \StdClass;
+                    $r->id = $s->id;
+                    $r->profile_id = $pid;
+                    $r->type = $s->type;
+                    $r->path = $s->path;
+                    return $r;
+                });
+        });
+
+        if($self->count()) {
+            $s->prepend($self->first());
+        }
+
+        $res = $s->map(function($s) use($pid) {
+            $profile = AccountService::get($s->profile_id);
+            $url = $profile['local'] ? url("/stories/{$profile['username']}") :
+                url("/i/rs/{$profile['id']}");
+            return [
+                'pid' => $profile['id'],
+                'avatar' => $profile['avatar'],
+                'local' => $profile['local'],
+                'username'  => $profile['acct'],
+                'latest' => [
+                    'id' => $s->id,
+                    'type' => $s->type,
+                    'preview_url' => url(Storage::url($s->path))
+                ],
+                'url' => $url,
+                'seen' => StoryService::hasSeen($pid, StoryService::latest($s->profile_id)),
+                'sid' => $s->id
+            ];
+        })
+        ->sortBy('seen')
+        ->values();
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function profile(Request $request, $id)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $user = $request->user();
+        if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id)) {
+            return [];
+        }
+        $authed = $user->profile_id;
+        $profile = Profile::findOrFail($id);
+
+        if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) {
+            return abort([], 403);
+        }
+
+        $stories = Story::whereProfileId($profile->id)
+        ->whereActive(true)
+        ->orderBy('expires_at')
+        ->get()
+        ->map(function($s, $k) use($authed) {
+            $seen = StoryService::hasSeen($authed, $s->id);
+            $res = [
+                'id' => (string) $s->id,
+                'type' => $s->type,
+                'duration' => $s->duration,
+                'src' => url(Storage::url($s->path)),
+                'created_at' => $s->created_at->toAtomString(),
+                'expires_at' => $s->expires_at->toAtomString(),
+                'view_count' => ($authed == $s->profile_id) ? ($s->view_count ?? 0) : null,
+                'seen' => $seen,
+                'progress' => $seen ? 100 : 0,
+                'can_reply' => (bool) $s->can_reply,
+                'can_react' => (bool) $s->can_react
+            ];
+
+            if($s->type == 'poll') {
+                $res['question'] = json_decode($s->story, true)['question'];
+                $res['options'] = json_decode($s->story, true)['options'];
+                $res['voted'] = PollService::votedStory($s->id, $authed);
+                if($res['voted']) {
+                    $res['voted_index'] = PollService::storyChoice($s->id, $authed);
+                }
+            }
+
+            return $res;
+        })->toArray();
+        if(count($stories) == 0) {
+            return [];
+        }
+        $cursor = count($stories) - 1;
+        $stories = [[
+            'id' => (string) $stories[$cursor]['id'],
+            'nodes' => $stories,
+            'account' => AccountService::get($profile->id),
+            'pid' => (string) $profile->id
+        ]];
+        return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function viewed(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
+            'id'    => 'required|min:1',
+        ]);
+        $id = $request->input('id');
+        $user = $request->user();
+        if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id)) {
+            return [];
+        }
+        $authed = $user->profile;
+
+        $story = Story::with('profile')
+            ->findOrFail($id);
+        $exp = $story->expires_at;
+
+        $profile = $story->profile;
+
+        if($story->profile_id == $authed->id) {
+            return [];
+        }
+
+        $publicOnly = (bool) $profile->followedBy($authed);
+        abort_if(!$publicOnly, 403);
+
+        $v = StoryView::firstOrCreate([
+            'story_id' => $id,
+            'profile_id' => $authed->id
+        ]);
+
+        if($v->wasRecentlyCreated) {
+            Story::findOrFail($story->id)->increment('view_count');
+
+            if($story->local == false) {
+                StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
+            }
+        }
+
+        Cache::forget('stories:recent:by_id:' . $authed->id);
+        StoryService::addSeen($authed->id, $story->id);
+        return ['code' => 200];
+    }
+
+    public function exists(Request $request, $id)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+        $user = $request->user();
+        if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id)) {
+            return response()->json(false);
+        }
+        return response()->json(Story::whereProfileId($id)
+        ->whereActive(true)
+        ->exists());
+    }
+
+    public function iRedirect(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $user = $request->user();
+        abort_if(!$user, 404);
+        $username = $user->username;
+        return redirect("/stories/{$username}");
+    }
+
+    public function viewers(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
+            'sid' => 'required|string'
+        ]);
+
+        $user = $request->user();
+        if($user->has_roles && !UserRoleService::can('can-use-stories', $user->id)) {
+            return response()->json([]);
+        }
+
+        $pid = $request->user()->profile_id;
+        $sid = $request->input('sid');
+
+        $story = Story::whereProfileId($pid)
+            ->whereActive(true)
+            ->findOrFail($sid);
+
+        $viewers = StoryView::whereStoryId($story->id)
+            ->latest()
+            ->simplePaginate(10)
+            ->map(function($view) {
+                return AccountService::get($view->profile_id);
+            })
+            ->values();
+
+        return response()->json($viewers, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function remoteStory(Request $request, $id)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $profile = Profile::findOrFail($id);
+        if($profile->user_id != null || $profile->domain == null) {
+            return redirect('/stories/' . $profile->username);
+        }
+        $pid = $profile->id;
+        return view('stories.show_remote', compact('pid'));
+    }
+
+    public function pollResults(Request $request)
+    {
+        abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+        $this->validate($request, [
+            'sid' => 'required|string'
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $sid = $request->input('sid');
+
+        $story = Story::whereProfileId($pid)
+            ->whereActive(true)
+            ->findOrFail($sid);
+
+        return PollService::storyResults($sid);
+    }
+
+    public function getActivityObject(Request $request, $username, $id)
+    {
+        abort_if(!config_cache('instance.stories.enabled'), 404);
+
+        if(!$request->wantsJson()) {
+            return redirect('/stories/' . $username);
+        }
+
+        abort_if(!$request->hasHeader('Authorization'), 404);
+
+        $profile = Profile::whereUsername($username)->whereNull('domain')->firstOrFail();
+        $story = Story::whereActive(true)->whereProfileId($profile->id)->findOrFail($id);
+
+        abort_if($story->bearcap_token == null, 404);
+        abort_if(now()->gt($story->expires_at), 404);
+        $token = substr($request->header('Authorization'), 7);
+        abort_if(hash_equals($story->bearcap_token, $token) === false, 404);
+        abort_if($story->created_at->lt(now()->subMinutes(20)), 404);
+
+        $fractal = new Manager();
+        $fractal->setSerializer(new ArraySerializer());
+        $resource = new Item($story, new StoryVerb());
+        $res = $fractal->createData($resource)->toArray();
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+    }
+
+    public function showSystemStory()
+    {
+        // return view('stories.system');
+    }
 }