user(), 404); $this->validate($request, [ 'file' => function() { return [ 'required', 'mimes:image/jpeg,image/png,video/mp4', 'max:' . config('pixelfed.max_photo_size'), ]; }, ]); $user = $request->user(); if(Story::whereProfileId($user->profile_id)->where('expires_at', '>', now())->count() >= Story::MAX_PER_DAY) { abort(400, '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->save(); $url = $story->path; if($story->type === 'video') { $video = FFMpeg::open($path); $width = $video->getVideoStream()->get('width'); $height = $video->getVideoStream()->get('height'); if($width !== 1080 || $height !== 1920) { Storage::delete($story->path); $story->delete(); abort(422, 'Invalid video dimensions, must be 1080x1920'); } } return [ 'code' => 200, 'msg' => 'Successfully added', 'media_id' => (string) $story->id, 'media_url' => url(Storage::url($url)) . '?v=' . time(), 'media_type' => $story->type ]; } protected function storePhoto($photo, $user) { $mimes = explode(',', config('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->store($storagePath); if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) { $fpath = storage_path('app/' . $path); $img = Intervention::make($fpath); $img->orientate(); $img->save($fpath, config('pixelfed.image_quality')); $img->destroy(); } return $path; } public function cropPhoto(Request $request) { abort_if(!config('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->save($path, config('pixelfed.image_quality')); } return [ 'code' => 200, 'msg' => 'Successfully cropped', ]; } public function publishStory(Request $request) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $this->validate($request, [ 'media_id' => 'required', 'duration' => 'required|integer|min:3|max:10' ]); $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->expires_at = now()->addHours(24); $story->save(); return [ 'code' => 200, 'msg' => 'Successfully published', ]; } public function apiV1Delete(Request $request, $id) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $user = $request->user(); $story = Story::whereProfileId($user->profile_id) ->findOrFail($id); if(Storage::exists($story->path) == true) { Storage::delete($story->path); } $story->delete(); return [ 'code' => 200, 'msg' => 'Successfully deleted' ]; } public function apiV1Recent(Request $request) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $profile = $request->user()->profile; $following = $profile->following->pluck('id')->toArray(); if(config('database.default') == 'pgsql') { $db = Story::with('profile') ->whereActive(true) ->whereIn('profile_id', $following) ->where('expires_at', '>', now()) ->distinct('profile_id') ->take(9) ->get(); } else { $db = Story::with('profile') ->whereActive(true) ->whereIn('profile_id', $following) ->where('created_at', '>', now()->subDay()) ->orderByDesc('expires_at') ->groupBy('profile_id') ->take(9) ->get(); } $stories = $db->map(function($s, $k) { return [ 'id' => (string) $s->id, 'photo' => $s->profile->avatarUrl(), 'name' => $s->profile->username, 'link' => $s->profile->url(), 'lastUpdated' => (int) $s->created_at->format('U'), 'seen' => $s->seen(), 'items' => [], 'pid' => (string) $s->profile->id ]; }); return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } public function apiV1Fetch(Request $request, $id) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $authed = $request->user()->profile; $profile = Profile::findOrFail($id); if($id == $authed->id) { $publicOnly = true; } else { $publicOnly = (bool) $profile->followedBy($authed); } $stories = Story::whereProfileId($profile->id) ->whereActive(true) ->orderBy('expires_at', 'desc') ->where('expires_at', '>', now()) ->when(!$publicOnly, function($query, $publicOnly) { return $query->wherePublic(true); }) ->get() ->map(function($s, $k) { return [ 'id' => (string) $s->id, 'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo', 'length' => 3, 'src' => url(Storage::url($s->path)), 'preview' => null, 'link' => null, 'linkText' => null, 'time' => $s->created_at->format('U'), 'expires_at' => (int) $s->expires_at->format('U'), 'created_ago' => $s->created_at->diffForHumans(null, true, true), 'seen' => $s->seen() ]; })->toArray(); return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } public function apiV1Item(Request $request, $id) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $authed = $request->user()->profile; $story = Story::with('profile') ->whereActive(true) ->where('expires_at', '>', now()) ->findOrFail($id); $profile = $story->profile; if($story->profile_id == $authed->id) { $publicOnly = true; } else { $publicOnly = (bool) $profile->followedBy($authed); } abort_if(!$publicOnly, 403); $res = [ 'id' => (string) $story->id, 'type' => Str::endsWith($story->path, '.mp4') ? 'video' :'photo', 'length' => 10, 'src' => url(Storage::url($story->path)), 'preview' => null, 'link' => null, 'linkText' => null, 'time' => $story->created_at->format('U'), 'expires_at' => (int) $story->expires_at->format('U'), 'seen' => $story->seen() ]; return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } public function apiV1Profile(Request $request, $id) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $authed = $request->user()->profile; $profile = Profile::findOrFail($id); if($id == $authed->id) { $publicOnly = true; } else { $publicOnly = (bool) $profile->followedBy($authed); } $stories = Story::whereProfileId($profile->id) ->whereActive(true) ->orderBy('expires_at') ->where('expires_at', '>', now()) ->when(!$publicOnly, function($query, $publicOnly) { return $query->wherePublic(true); }) ->get() ->map(function($s, $k) { return [ 'id' => $s->id, 'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo', 'length' => 10, 'src' => url(Storage::url($s->path)), 'preview' => null, 'link' => null, 'linkText' => null, 'time' => $s->created_at->format('U'), 'expires_at' => (int) $s->expires_at->format('U'), 'seen' => $s->seen() ]; })->toArray(); if(count($stories) == 0) { return []; } $cursor = count($stories) - 1; $stories = [[ 'id' => (string) $stories[$cursor]['id'], 'photo' => $profile->avatarUrl(), 'name' => $profile->username, 'link' => $profile->url(), 'lastUpdated' => (int) now()->format('U'), 'seen' => null, 'items' => $stories, 'pid' => (string) $profile->id ]]; return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } public function apiV1Viewed(Request $request) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $this->validate($request, [ 'id' => 'required|integer|min:1|exists:stories', ]); $id = $request->input('id'); $authed = $request->user()->profile; $story = Story::with('profile') ->where('expires_at', '>', now()) ->orderByDesc('expires_at') ->findOrFail($id); $profile = $story->profile; if($story->profile_id == $authed->id) { return []; } $publicOnly = (bool) $profile->followedBy($authed); abort_if(!$publicOnly, 403); StoryView::firstOrCreate([ 'story_id' => $id, 'profile_id' => $authed->id ]); $story->view_count = $story->view_count + 1; $story->save(); return ['code' => 200]; } public function apiV1Exists(Request $request, $id) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $res = (bool) Story::whereProfileId($id) ->whereActive(true) ->where('expires_at', '>', now()) ->count(); return response()->json($res); } public function apiV1Me(Request $request) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $profile = $request->user()->profile; $stories = Story::whereProfileId($profile->id) ->whereActive(true) ->orderBy('expires_at') ->where('expires_at', '>', now()) ->get() ->map(function($s, $k) { return [ 'id' => $s->id, 'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo', 'length' => 3, 'src' => url(Storage::url($s->path)), 'preview' => null, 'link' => null, 'linkText' => null, 'time' => $s->created_at->format('U'), 'expires_at' => (int) $s->expires_at->format('U'), 'seen' => true ]; })->toArray(); $ts = count($stories) ? last($stories)['time'] : null; $res = [ 'id' => (string) $profile->id, 'photo' => $profile->avatarUrl(), 'name' => $profile->username, 'link' => $profile->url(), 'lastUpdated' => $ts, 'seen' => true, 'items' => $stories ]; return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } public function compose(Request $request) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); return view('stories.compose'); } public function iRedirect(Request $request) { abort_if(!config('instance.stories.enabled') || !$request->user(), 404); $user = $request->user(); abort_if(!$user, 404); $username = $user->username; return redirect("/stories/{$username}"); } }