diff --git a/CHANGELOG.md b/CHANGELOG.md index 13baca035..06c1a9720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Added `app:hashtag-related-generate` command to generate related hashtags ([176b4ed7](https://github.com/pixelfed/pixelfed/commit/176b4ed7)) - Added Mutual Followers API endpoint ([33dbbe46](https://github.com/pixelfed/pixelfed/commit/33dbbe46)) - Added User Domain Blocks ([#4834](https://github.com/pixelfed/pixelfed/pull/4834)) ([fa0380ac](https://github.com/pixelfed/pixelfed/commit/fa0380ac)) +- Added Parental Controls ([#4862](https://github.com/pixelfed/pixelfed/pull/4862)) ([c91f1c59](https://github.com/pixelfed/pixelfed/commit/c91f1c59)) ### Federation - Update Privacy Settings, add support for Mastodon `indexable` search flag ([fc24630e](https://github.com/pixelfed/pixelfed/commit/fc24630e)) diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 993df3555..b342d68ce 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -758,6 +758,8 @@ class ApiV1Controller extends Controller abort_if(!$request->user(), 403); $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-follow', $user->id), 403, 'Invalid permissions for this action'); + AccountService::setLastActive($user->id); $target = Profile::where('id', '!=', $user->profile_id) @@ -843,6 +845,7 @@ class ApiV1Controller extends Controller abort_if(!$request->user(), 403); $user = $request->user(); + AccountService::setLastActive($user->id); $target = Profile::where('id', '!=', $user->profile_id) @@ -947,6 +950,8 @@ class ApiV1Controller extends Controller ]); $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-view-discover', $user->id), 403, 'Invalid permissions for this action'); + AccountService::setLastActive($user->id); $query = $request->input('q'); $limit = $request->input('limit') ?? 20; @@ -1750,6 +1755,8 @@ class ApiV1Controller extends Controller ]); $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); + AccountService::setLastActive($user->id); $media = Media::whereUserId($user->id) @@ -2568,7 +2575,11 @@ class ApiV1Controller extends Controller $limit = $request->input('limit', 20); $scope = $request->input('scope', 'inbox'); - $pid = $request->user()->profile_id; + $user = $request->user(); + if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id)) { + return []; + } + $pid = $user->profile_id; if(config('database.default') == 'pgsql') { $dms = DirectMessage::when($scope === 'inbox', function($q, $scope) use($pid) { @@ -2983,6 +2994,15 @@ class ApiV1Controller extends Controller $in_reply_to_id = $request->input('in_reply_to_id'); $user = $request->user(); + + if($user->has_roles) { + if($in_reply_to_id != null) { + abort_if(!UserRoleService::can('can-comment', $user->id), 403, 'Invalid permissions for this action'); + } else { + abort_if(!UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); + } + } + $profile = $user->profile; $limitKey = 'compose:rate-limit:store:' . $user->id; @@ -3438,6 +3458,7 @@ class ApiV1Controller extends Controller $status = Status::findOrFail($id); $pid = $request->user()->profile_id; + abort_if($user->has_roles && !UserRoleService::can('can-bookmark', $user->id), 403, 'Invalid permissions for this action'); abort_if($status->in_reply_to_id || $status->reblog_of_id, 404); abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404); abort_if(!in_array($status->type, ['photo','photo:album', 'video', 'video:album', 'photo:video:album']), 404); @@ -3477,6 +3498,7 @@ class ApiV1Controller extends Controller $status = Status::findOrFail($id); $pid = $request->user()->profile_id; + abort_if($user->has_roles && !UserRoleService::can('can-bookmark', $user->id), 403, 'Invalid permissions for this action'); abort_if($status->in_reply_to_id || $status->reblog_of_id, 404); abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404); abort_if(!in_array($status->type, ['photo','photo:album', 'video', 'video:album', 'photo:video:album']), 404); diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 2ca5b96c5..93f930cd5 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -17,304 +17,313 @@ use App\Services\SearchApiV2Service; use App\Util\Media\Filter; use App\Jobs\MediaPipeline\MediaDeletePipeline; use App\Jobs\VideoPipeline\{ - VideoOptimize, - VideoPostProcess, - VideoThumbnail + VideoOptimize, + VideoPostProcess, + VideoThumbnail }; use App\Jobs\ImageOptimizePipeline\ImageOptimize; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Pagination\IlluminatePaginatorAdapter; use App\Transformer\Api\Mastodon\v1\{ - AccountTransformer, - MediaTransformer, - NotificationTransformer, - StatusTransformer, + AccountTransformer, + MediaTransformer, + NotificationTransformer, + StatusTransformer, }; use App\Transformer\Api\{ - RelationshipTransformer, + RelationshipTransformer, }; use App\Util\Site\Nodeinfo; +use App\Services\UserRoleService; class ApiV2Controller extends Controller { - const PF_API_ENTITY_KEY = "_pe"; + const PF_API_ENTITY_KEY = "_pe"; - public function json($res, $code = 200, $headers = []) - { - return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES); - } + public function json($res, $code = 200, $headers = []) + { + return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES); + } public function instance(Request $request) { - $contact = Cache::remember('api:v1:instance-data:contact', 604800, function () { - if(config_cache('instance.admin.pid')) { - return AccountService::getMastodon(config_cache('instance.admin.pid'), true); - } - $admin = User::whereIsAdmin(true)->first(); - return $admin && isset($admin->profile_id) ? - AccountService::getMastodon($admin->profile_id, true) : - null; - }); + $contact = Cache::remember('api:v1:instance-data:contact', 604800, function () { + if(config_cache('instance.admin.pid')) { + return AccountService::getMastodon(config_cache('instance.admin.pid'), true); + } + $admin = User::whereIsAdmin(true)->first(); + return $admin && isset($admin->profile_id) ? + AccountService::getMastodon($admin->profile_id, true) : + null; + }); - $rules = Cache::remember('api:v1:instance-data:rules', 604800, function () { - return config_cache('app.rules') ? - collect(json_decode(config_cache('app.rules'), true)) - ->map(function($rule, $key) { - $id = $key + 1; - return [ - 'id' => "{$id}", - 'text' => $rule - ]; - }) - ->toArray() : []; - }); + $rules = Cache::remember('api:v1:instance-data:rules', 604800, function () { + return config_cache('app.rules') ? + collect(json_decode(config_cache('app.rules'), true)) + ->map(function($rule, $key) { + $id = $key + 1; + return [ + 'id' => "{$id}", + 'text' => $rule + ]; + }) + ->toArray() : []; + }); - $res = [ - 'domain' => config('pixelfed.domain.app'), - 'title' => config_cache('app.name'), - 'version' => config('pixelfed.version'), - 'source_url' => 'https://github.com/pixelfed/pixelfed', - 'description' => config_cache('app.short_description'), - 'usage' => [ - 'users' => [ - 'active_month' => (int) Nodeinfo::activeUsersMonthly() - ] - ], - 'thumbnail' => [ - 'url' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')), - 'blurhash' => InstanceService::headerBlurhash(), - 'versions' => [ - '@1x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')), - '@2x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')) - ] - ], - 'languages' => [config('app.locale')], - 'configuration' => [ - 'urls' => [ - 'streaming' => 'wss://' . config('pixelfed.domain.app'), - 'status' => null - ], - 'accounts' => [ - 'max_featured_tags' => 0, - ], - 'statuses' => [ - 'max_characters' => (int) config('pixelfed.max_caption_length'), - 'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'), - 'characters_reserved_per_url' => 23 - ], - 'media_attachments' => [ - 'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')), - 'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, - 'image_matrix_limit' => 3686400, - 'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, - 'video_frame_rate_limit' => 240, - 'video_matrix_limit' => 3686400 - ], - 'polls' => [ - 'max_options' => 4, - 'max_characters_per_option' => 50, - 'min_expiration' => 300, - 'max_expiration' => 2629746, - ], - 'translation' => [ - 'enabled' => false, - ], - ], - 'registrations' => [ - 'enabled' => (bool) config_cache('pixelfed.open_registration'), - 'approval_required' => false, - 'message' => null - ], - 'contact' => [ - 'email' => config('instance.email'), - 'account' => $contact - ], - 'rules' => $rules - ]; + $res = [ + 'domain' => config('pixelfed.domain.app'), + 'title' => config_cache('app.name'), + 'version' => config('pixelfed.version'), + 'source_url' => 'https://github.com/pixelfed/pixelfed', + 'description' => config_cache('app.short_description'), + 'usage' => [ + 'users' => [ + 'active_month' => (int) Nodeinfo::activeUsersMonthly() + ] + ], + 'thumbnail' => [ + 'url' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')), + 'blurhash' => InstanceService::headerBlurhash(), + 'versions' => [ + '@1x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')), + '@2x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')) + ] + ], + 'languages' => [config('app.locale')], + 'configuration' => [ + 'urls' => [ + 'streaming' => 'wss://' . config('pixelfed.domain.app'), + 'status' => null + ], + 'accounts' => [ + 'max_featured_tags' => 0, + ], + 'statuses' => [ + 'max_characters' => (int) config('pixelfed.max_caption_length'), + 'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'), + 'characters_reserved_per_url' => 23 + ], + 'media_attachments' => [ + 'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')), + 'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, + 'image_matrix_limit' => 3686400, + 'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, + 'video_frame_rate_limit' => 240, + 'video_matrix_limit' => 3686400 + ], + 'polls' => [ + 'max_options' => 4, + 'max_characters_per_option' => 50, + 'min_expiration' => 300, + 'max_expiration' => 2629746, + ], + 'translation' => [ + 'enabled' => false, + ], + ], + 'registrations' => [ + 'enabled' => (bool) config_cache('pixelfed.open_registration'), + 'approval_required' => false, + 'message' => null + ], + 'contact' => [ + 'email' => config('instance.email'), + 'account' => $contact + ], + 'rules' => $rules + ]; - return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES); + return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES); } - /** - * GET /api/v2/search - * - * - * @return array - */ - public function search(Request $request) - { - abort_if(!$request->user(), 403); + /** + * GET /api/v2/search + * + * + * @return array + */ + public function search(Request $request) + { + abort_if(!$request->user(), 403); - $this->validate($request, [ - 'q' => 'required|string|min:1|max:100', - 'account_id' => 'nullable|string', - 'max_id' => 'nullable|string', - 'min_id' => 'nullable|string', - 'type' => 'nullable|in:accounts,hashtags,statuses', - 'exclude_unreviewed' => 'nullable', - 'resolve' => 'nullable', - 'limit' => 'nullable|integer|max:40', - 'offset' => 'nullable|integer', - 'following' => 'nullable' - ]); + $this->validate($request, [ + 'q' => 'required|string|min:1|max:100', + 'account_id' => 'nullable|string', + 'max_id' => 'nullable|string', + 'min_id' => 'nullable|string', + 'type' => 'nullable|in:accounts,hashtags,statuses', + 'exclude_unreviewed' => 'nullable', + 'resolve' => 'nullable', + 'limit' => 'nullable|integer|max:40', + 'offset' => 'nullable|integer', + 'following' => 'nullable' + ]); - $mastodonMode = !$request->has('_pe'); - return $this->json(SearchApiV2Service::query($request, $mastodonMode)); - } + if($request->user()->has_roles && !UserRoleService::can('can-view-discover', $request->user()->id)) { + return [ + 'accounts' => [], + 'hashtags' => [], + 'statuses' => [] + ]; + } - /** - * GET /api/v2/streaming/config - * - * - * @return object - */ - public function getWebsocketConfig() - { - return config('broadcasting.default') === 'pusher' ? [ - 'host' => config('broadcasting.connections.pusher.options.host'), - 'port' => config('broadcasting.connections.pusher.options.port'), - 'key' => config('broadcasting.connections.pusher.key'), - 'cluster' => config('broadcasting.connections.pusher.options.cluster') - ] : []; - } + $mastodonMode = !$request->has('_pe'); + return $this->json(SearchApiV2Service::query($request, $mastodonMode)); + } - /** - * POST /api/v2/media - * - * - * @return MediaTransformer - */ - public function mediaUploadV2(Request $request) - { - abort_if(!$request->user(), 403); + /** + * GET /api/v2/streaming/config + * + * + * @return object + */ + public function getWebsocketConfig() + { + return config('broadcasting.default') === 'pusher' ? [ + 'host' => config('broadcasting.connections.pusher.options.host'), + 'port' => config('broadcasting.connections.pusher.options.port'), + 'key' => config('broadcasting.connections.pusher.key'), + 'cluster' => config('broadcasting.connections.pusher.options.cluster') + ] : []; + } - $this->validate($request, [ - 'file.*' => [ - 'required_without:file', - 'mimetypes:' . config_cache('pixelfed.media_types'), - 'max:' . config_cache('pixelfed.max_photo_size'), - ], - 'file' => [ - 'required_without:file.*', - 'mimetypes:' . config_cache('pixelfed.media_types'), - 'max:' . config_cache('pixelfed.max_photo_size'), - ], - 'filter_name' => 'nullable|string|max:24', - 'filter_class' => 'nullable|alpha_dash|max:24', - 'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length'), - 'replace_id' => 'sometimes' - ]); + /** + * POST /api/v2/media + * + * + * @return MediaTransformer + */ + public function mediaUploadV2(Request $request) + { + abort_if(!$request->user(), 403); - $user = $request->user(); + $this->validate($request, [ + 'file.*' => [ + 'required_without:file', + 'mimetypes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ], + 'file' => [ + 'required_without:file.*', + 'mimetypes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ], + 'filter_name' => 'nullable|string|max:24', + 'filter_class' => 'nullable|alpha_dash|max:24', + 'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length'), + 'replace_id' => 'sometimes' + ]); - if($user->last_active_at == null) { - return []; - } + $user = $request->user(); - if(empty($request->file('file'))) { - return response('', 422); - } + if($user->last_active_at == null) { + return []; + } - $limitKey = 'compose:rate-limit:media-upload:' . $user->id; - $limitTtl = now()->addMinutes(15); - $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { - $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); + if(empty($request->file('file'))) { + return response('', 422); + } - return $dailyLimit >= 1250; - }); - abort_if($limitReached == true, 429); + $limitKey = 'compose:rate-limit:media-upload:' . $user->id; + $limitTtl = now()->addMinutes(15); + $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { + $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - $profile = $user->profile; + return $dailyLimit >= 1250; + }); + abort_if($limitReached == true, 429); - if(config_cache('pixelfed.enforce_account_limit') == true) { - $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { - return Media::whereUserId($user->id)->sum('size') / 1000; - }); - $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { - abort(403, 'Account size limit reached.'); - } - } + $profile = $user->profile; - $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null; - $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null; + if(config_cache('pixelfed.enforce_account_limit') == true) { + $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { + return Media::whereUserId($user->id)->sum('size') / 1000; + }); + $limit = (int) config_cache('pixelfed.max_account_size'); + if ($size >= $limit) { + abort(403, 'Account size limit reached.'); + } + } - $photo = $request->file('file'); + $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null; + $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null; - $mimes = explode(',', config_cache('pixelfed.media_types')); - if(in_array($photo->getMimeType(), $mimes) == false) { - abort(403, 'Invalid or unsupported mime type.'); - } + $photo = $request->file('file'); - $storagePath = MediaPathService::get($user, 2); - $path = $photo->storePublicly($storagePath); - $hash = \hash_file('sha256', $photo); - $license = null; - $mime = $photo->getMimeType(); + $mimes = explode(',', config_cache('pixelfed.media_types')); + if(in_array($photo->getMimeType(), $mimes) == false) { + abort(403, 'Invalid or unsupported mime type.'); + } - $settings = UserSetting::whereUserId($user->id)->first(); + $storagePath = MediaPathService::get($user, 2); + $path = $photo->storePublicly($storagePath); + $hash = \hash_file('sha256', $photo); + $license = null; + $mime = $photo->getMimeType(); - if($settings && !empty($settings->compose_settings)) { - $compose = $settings->compose_settings; + $settings = UserSetting::whereUserId($user->id)->first(); - if(isset($compose['default_license']) && $compose['default_license'] != 1) { - $license = $compose['default_license']; - } - } + if($settings && !empty($settings->compose_settings)) { + $compose = $settings->compose_settings; - abort_if(MediaBlocklistService::exists($hash) == true, 451); + if(isset($compose['default_license']) && $compose['default_license'] != 1) { + $license = $compose['default_license']; + } + } - if($request->has('replace_id')) { - $rpid = $request->input('replace_id'); - $removeMedia = Media::whereNull('status_id') - ->whereUserId($user->id) - ->whereProfileId($profile->id) - ->where('created_at', '>', now()->subHours(2)) - ->find($rpid); - if($removeMedia) { - MediaDeletePipeline::dispatch($removeMedia) - ->onQueue('mmo') - ->delay(now()->addMinutes(15)); - } - } + abort_if(MediaBlocklistService::exists($hash) == true, 451); - $media = new Media(); - $media->status_id = null; - $media->profile_id = $profile->id; - $media->user_id = $user->id; - $media->media_path = $path; - $media->original_sha256 = $hash; - $media->size = $photo->getSize(); - $media->mime = $mime; - $media->caption = $request->input('description'); - $media->filter_class = $filterClass; - $media->filter_name = $filterName; - if($license) { - $media->license = $license; - } - $media->save(); + if($request->has('replace_id')) { + $rpid = $request->input('replace_id'); + $removeMedia = Media::whereNull('status_id') + ->whereUserId($user->id) + ->whereProfileId($profile->id) + ->where('created_at', '>', now()->subHours(2)) + ->find($rpid); + if($removeMedia) { + MediaDeletePipeline::dispatch($removeMedia) + ->onQueue('mmo') + ->delay(now()->addMinutes(15)); + } + } - switch ($media->mime) { - case 'image/jpeg': - case 'image/png': - ImageOptimize::dispatch($media)->onQueue('mmo'); - break; + $media = new Media(); + $media->status_id = null; + $media->profile_id = $profile->id; + $media->user_id = $user->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $photo->getSize(); + $media->mime = $mime; + $media->caption = $request->input('description'); + $media->filter_class = $filterClass; + $media->filter_name = $filterName; + if($license) { + $media->license = $license; + } + $media->save(); - case 'video/mp4': - VideoThumbnail::dispatch($media)->onQueue('mmo'); - $preview_url = '/storage/no-preview.png'; - $url = '/storage/no-preview.png'; - break; - } + switch ($media->mime) { + case 'image/jpeg': + case 'image/png': + ImageOptimize::dispatch($media)->onQueue('mmo'); + break; - Cache::forget($limitKey); - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($media, new MediaTransformer()); - $res = $fractal->createData($resource)->toArray(); - $res['preview_url'] = $media->url(). '?v=' . time(); - $res['url'] = null; - return $this->json($res, 202); - } + case 'video/mp4': + VideoThumbnail::dispatch($media)->onQueue('mmo'); + $preview_url = '/storage/no-preview.png'; + $url = '/storage/no-preview.png'; + break; + } + + Cache::forget($limitKey); + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($media, new MediaTransformer()); + $res = $fractal->createData($resource)->toArray(); + $res['preview_url'] = $media->url(). '?v=' . time(); + $res['url'] = null; + return $this->json($res, 202); + } } diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 5eb1159fe..8c10e5d0c 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -60,7 +60,7 @@ class RegisterController extends Controller * * @return \Illuminate\Contracts\Validation\Validator */ - protected function validator(array $data) + public function validator(array $data) { if(config('database.default') == 'pgsql') { $data['username'] = strtolower($data['username']); @@ -151,7 +151,7 @@ class RegisterController extends Controller * * @return \App\User */ - protected function create(array $data) + public function create(array $data) { if(config('database.default') == 'pgsql') { $data['username'] = strtolower($data['username']); diff --git a/app/Http/Controllers/BookmarkController.php b/app/Http/Controllers/BookmarkController.php index a24520d64..d1d793dd2 100644 --- a/app/Http/Controllers/BookmarkController.php +++ b/app/Http/Controllers/BookmarkController.php @@ -8,60 +8,56 @@ use Auth; use Illuminate\Http\Request; use App\Services\BookmarkService; use App\Services\FollowerService; +use App\Services\UserRoleService; class BookmarkController extends Controller { - public function __construct() - { - $this->middleware('auth'); - } + public function __construct() + { + $this->middleware('auth'); + } - public function store(Request $request) - { - $this->validate($request, [ - 'item' => 'required|integer|min:1', - ]); + public function store(Request $request) + { + $this->validate($request, [ + 'item' => 'required|integer|min:1', + ]); - $profile = Auth::user()->profile; - $status = Status::findOrFail($request->input('item')); + $user = $request->user(); + $status = Status::findOrFail($request->input('item')); - abort_if($status->in_reply_to_id || $status->reblog_of_id, 404); - abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404); - abort_if(!in_array($status->type, ['photo','photo:album', 'video', 'video:album', 'photo:video:album']), 404); + abort_if($user->has_roles && !UserRoleService::can('can-bookmark', $user->id), 403, 'Invalid permissions for this action'); + abort_if($status->in_reply_to_id || $status->reblog_of_id, 404); + abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404); + abort_if(!in_array($status->type, ['photo','photo:album', 'video', 'video:album', 'photo:video:album']), 404); - if($status->scope == 'private') { - if($profile->id !== $status->profile_id && !FollowerService::follows($profile->id, $status->profile_id)) { - if($exists = Bookmark::whereStatusId($status->id)->whereProfileId($profile->id)->first()) { - BookmarkService::del($profile->id, $status->id); - $exists->delete(); + if($status->scope == 'private') { + if($user->profile_id !== $status->profile_id && !FollowerService::follows($user->profile_id, $status->profile_id)) { + if($exists = Bookmark::whereStatusId($status->id)->whereProfileId($user->profile_id)->first()) { + BookmarkService::del($user->profile_id, $status->id); + $exists->delete(); - if ($request->ajax()) { - return ['code' => 200, 'msg' => 'Bookmark removed!']; - } else { - return redirect()->back(); - } - } - abort(404, 'Error: You cannot bookmark private posts from accounts you do not follow.'); - } - } + if ($request->ajax()) { + return ['code' => 200, 'msg' => 'Bookmark removed!']; + } else { + return redirect()->back(); + } + } + abort(404, 'Error: You cannot bookmark private posts from accounts you do not follow.'); + } + } - $bookmark = Bookmark::firstOrCreate( - ['status_id' => $status->id], ['profile_id' => $profile->id] - ); + $bookmark = Bookmark::firstOrCreate( + ['status_id' => $status->id], ['profile_id' => $user->profile_id] + ); - if (!$bookmark->wasRecentlyCreated) { - BookmarkService::del($profile->id, $status->id); - $bookmark->delete(); - } else { - BookmarkService::add($profile->id, $status->id); - } + if (!$bookmark->wasRecentlyCreated) { + BookmarkService::del($user->profile_id, $status->id); + $bookmark->delete(); + } else { + BookmarkService::add($user->profile_id, $status->id); + } - if ($request->ajax()) { - $response = ['code' => 200, 'msg' => 'Bookmark saved!']; - } else { - $response = redirect()->back(); - } - - return $response; - } + return $request->expectsJson() ? ['code' => 200, 'msg' => 'Bookmark saved!'] : redirect()->back(); + } } diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index e79625861..e17a37fd7 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -6,26 +6,26 @@ use Illuminate\Http\Request; use Auth, Cache, DB, Storage, URL; use Carbon\Carbon; use App\{ - Avatar, - Collection, - CollectionItem, - Hashtag, - Like, - Media, - MediaTag, - Notification, - Profile, - Place, - Status, - UserFilter, - UserSetting + Avatar, + Collection, + CollectionItem, + Hashtag, + Like, + Media, + MediaTag, + Notification, + Profile, + Place, + Status, + UserFilter, + UserSetting }; use App\Models\Poll; use App\Transformer\Api\{ - MediaTransformer, - MediaDraftTransformer, - StatusTransformer, - StatusStatelessTransformer + MediaTransformer, + MediaDraftTransformer, + StatusTransformer, + StatusStatelessTransformer }; use League\Fractal; use App\Util\Media\Filter; @@ -36,9 +36,9 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\ImageOptimizePipeline\ImageThumbnail; use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\VideoPipeline\{ - VideoOptimize, - VideoPostProcess, - VideoThumbnail + VideoOptimize, + VideoPostProcess, + VideoThumbnail }; use App\Services\AccountService; use App\Services\CollectionService; @@ -58,230 +58,234 @@ use App\Services\UserRoleService; class ComposeController extends Controller { - protected $fractal; + protected $fractal; - public function __construct() - { - $this->middleware('auth'); - $this->fractal = new Fractal\Manager(); - $this->fractal->setSerializer(new ArraySerializer()); - } + public function __construct() + { + $this->middleware('auth'); + $this->fractal = new Fractal\Manager(); + $this->fractal->setSerializer(new ArraySerializer()); + } - public function show(Request $request) - { - return view('status.compose'); - } + public function show(Request $request) + { + return view('status.compose'); + } - public function mediaUpload(Request $request) - { - abort_if(!$request->user(), 403); + public function mediaUpload(Request $request) + { + abort_if(!$request->user(), 403); - $this->validate($request, [ - 'file.*' => [ - 'required_without:file', - 'mimetypes:' . config_cache('pixelfed.media_types'), - 'max:' . config_cache('pixelfed.max_photo_size'), - ], - 'file' => [ - 'required_without:file.*', - 'mimetypes:' . config_cache('pixelfed.media_types'), - 'max:' . config_cache('pixelfed.max_photo_size'), - ], - 'filter_name' => 'nullable|string|max:24', - 'filter_class' => 'nullable|alpha_dash|max:24' - ]); + $this->validate($request, [ + 'file.*' => [ + 'required_without:file', + 'mimetypes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ], + 'file' => [ + 'required_without:file.*', + 'mimetypes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ], + 'filter_name' => 'nullable|string|max:24', + 'filter_class' => 'nullable|alpha_dash|max:24' + ]); - $user = Auth::user(); - $profile = $user->profile; - abort_if($user->has_roles && !UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); + $user = Auth::user(); + $profile = $user->profile; + abort_if($user->has_roles && !UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); - $limitKey = 'compose:rate-limit:media-upload:' . $user->id; - $limitTtl = now()->addMinutes(15); - $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { - $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); + $limitKey = 'compose:rate-limit:media-upload:' . $user->id; + $limitTtl = now()->addMinutes(15); + $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { + $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - return $dailyLimit >= 1250; - }); + return $dailyLimit >= 1250; + }); - abort_if($limitReached == true, 429); + abort_if($limitReached == true, 429); - if(config_cache('pixelfed.enforce_account_limit') == true) { - $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { - return Media::whereUserId($user->id)->sum('size') / 1000; - }); - $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { - abort(403, 'Account size limit reached.'); - } - } + if(config_cache('pixelfed.enforce_account_limit') == true) { + $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { + return Media::whereUserId($user->id)->sum('size') / 1000; + }); + $limit = (int) config_cache('pixelfed.max_account_size'); + if ($size >= $limit) { + abort(403, 'Account size limit reached.'); + } + } - $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null; - $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null; + $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null; + $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null; - $photo = $request->file('file'); + $photo = $request->file('file'); - $mimes = explode(',', config_cache('pixelfed.media_types')); + $mimes = explode(',', config_cache('pixelfed.media_types')); - abort_if(in_array($photo->getMimeType(), $mimes) == false, 400, 'Invalid media format'); + abort_if(in_array($photo->getMimeType(), $mimes) == false, 400, 'Invalid media format'); - $storagePath = MediaPathService::get($user, 2); - $path = $photo->storePublicly($storagePath); - $hash = \hash_file('sha256', $photo); - $mime = $photo->getMimeType(); + $storagePath = MediaPathService::get($user, 2); + $path = $photo->storePublicly($storagePath); + $hash = \hash_file('sha256', $photo); + $mime = $photo->getMimeType(); - abort_if(MediaBlocklistService::exists($hash) == true, 451); + abort_if(MediaBlocklistService::exists($hash) == true, 451); - $media = new Media(); - $media->status_id = null; - $media->profile_id = $profile->id; - $media->user_id = $user->id; - $media->media_path = $path; - $media->original_sha256 = $hash; - $media->size = $photo->getSize(); - $media->mime = $mime; - $media->filter_class = $filterClass; - $media->filter_name = $filterName; - $media->version = 3; - $media->save(); + $media = new Media(); + $media->status_id = null; + $media->profile_id = $profile->id; + $media->user_id = $user->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $photo->getSize(); + $media->mime = $mime; + $media->filter_class = $filterClass; + $media->filter_name = $filterName; + $media->version = 3; + $media->save(); - $preview_url = $media->url() . '?v=' . time(); - $url = $media->url() . '?v=' . time(); + $preview_url = $media->url() . '?v=' . time(); + $url = $media->url() . '?v=' . time(); - switch ($media->mime) { - case 'image/jpeg': - case 'image/png': - case 'image/webp': - ImageOptimize::dispatch($media)->onQueue('mmo'); - break; + switch ($media->mime) { + case 'image/jpeg': + case 'image/png': + case 'image/webp': + ImageOptimize::dispatch($media)->onQueue('mmo'); + break; - case 'video/mp4': - VideoThumbnail::dispatch($media)->onQueue('mmo'); - $preview_url = '/storage/no-preview.png'; - $url = '/storage/no-preview.png'; - break; + case 'video/mp4': + VideoThumbnail::dispatch($media)->onQueue('mmo'); + $preview_url = '/storage/no-preview.png'; + $url = '/storage/no-preview.png'; + break; - default: - break; - } + default: + break; + } - Cache::forget($limitKey); - $resource = new Fractal\Resource\Item($media, new MediaTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - $res['preview_url'] = $preview_url; - $res['url'] = $url; - return response()->json($res); - } + Cache::forget($limitKey); + $resource = new Fractal\Resource\Item($media, new MediaTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + $res['preview_url'] = $preview_url; + $res['url'] = $url; + return response()->json($res); + } - public function mediaUpdate(Request $request) - { - $this->validate($request, [ - 'id' => 'required', - 'file' => function() { - return [ - 'required', - 'mimetypes:' . config_cache('pixelfed.media_types'), - 'max:' . config_cache('pixelfed.max_photo_size'), - ]; - }, - ]); + public function mediaUpdate(Request $request) + { + $this->validate($request, [ + 'id' => 'required', + 'file' => function() { + return [ + 'required', + 'mimetypes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ]; + }, + ]); - $user = Auth::user(); - abort_if($user->has_roles && !UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); + $user = Auth::user(); + abort_if($user->has_roles && !UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); - $limitKey = 'compose:rate-limit:media-updates:' . $user->id; - $limitTtl = now()->addMinutes(15); - $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { - $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); + $limitKey = 'compose:rate-limit:media-updates:' . $user->id; + $limitTtl = now()->addMinutes(15); + $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { + $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - return $dailyLimit >= 1500; - }); + return $dailyLimit >= 1500; + }); - abort_if($limitReached == true, 429); + abort_if($limitReached == true, 429); - $photo = $request->file('file'); - $id = $request->input('id'); + $photo = $request->file('file'); + $id = $request->input('id'); - $media = Media::whereUserId($user->id) - ->whereProfileId($user->profile_id) - ->whereNull('status_id') - ->findOrFail($id); + $media = Media::whereUserId($user->id) + ->whereProfileId($user->profile_id) + ->whereNull('status_id') + ->findOrFail($id); - $media->save(); + $media->save(); - $fragments = explode('/', $media->media_path); - $name = last($fragments); - array_pop($fragments); - $dir = implode('/', $fragments); - $path = $photo->storePubliclyAs($dir, $name); - $res = [ - 'url' => $media->url() . '?v=' . time() - ]; - ImageOptimize::dispatch($media)->onQueue('mmo'); - Cache::forget($limitKey); - return $res; - } + $fragments = explode('/', $media->media_path); + $name = last($fragments); + array_pop($fragments); + $dir = implode('/', $fragments); + $path = $photo->storePubliclyAs($dir, $name); + $res = [ + 'url' => $media->url() . '?v=' . time() + ]; + ImageOptimize::dispatch($media)->onQueue('mmo'); + Cache::forget($limitKey); + return $res; + } - public function mediaDelete(Request $request) - { - abort_if(!$request->user(), 403); + public function mediaDelete(Request $request) + { + abort_if(!$request->user(), 403); - $this->validate($request, [ - 'id' => 'required|integer|min:1|exists:media,id' - ]); + $this->validate($request, [ + 'id' => 'required|integer|min:1|exists:media,id' + ]); - $media = Media::whereNull('status_id') - ->whereUserId(Auth::id()) - ->findOrFail($request->input('id')); + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); - MediaStorageService::delete($media, true); + $media = Media::whereNull('status_id') + ->whereUserId(Auth::id()) + ->findOrFail($request->input('id')); - return response()->json([ - 'msg' => 'Successfully deleted', - 'code' => 200 - ]); - } + MediaStorageService::delete($media, true); - public function searchTag(Request $request) - { - abort_if(!$request->user(), 403); + return response()->json([ + 'msg' => 'Successfully deleted', + 'code' => 200 + ]); + } - $this->validate($request, [ - 'q' => 'required|string|min:1|max:50' - ]); + public function searchTag(Request $request) + { + abort_if(!$request->user(), 403); - $q = $request->input('q'); + $this->validate($request, [ + 'q' => 'required|string|min:1|max:50' + ]); - if(Str::of($q)->startsWith('@')) { - if(strlen($q) < 3) { - return []; - } - $q = mb_substr($q, 1); - } + $q = $request->input('q'); - $blocked = UserFilter::whereFilterableType('App\Profile') - ->whereFilterType('block') - ->whereFilterableId($request->user()->profile_id) - ->pluck('user_id'); + if(Str::of($q)->startsWith('@')) { + if(strlen($q) < 3) { + return []; + } + $q = mb_substr($q, 1); + } - $blocked->push($request->user()->profile_id); + abort_if($user->has_roles && !UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); - $results = Profile::select('id','domain','username') - ->whereNotIn('id', $blocked) - ->whereNull('domain') - ->where('username','like','%'.$q.'%') - ->limit(15) - ->get() - ->map(function($r) { - return [ - 'id' => (string) $r->id, - 'name' => $r->username, - 'privacy' => true, - 'avatar' => $r->avatarUrl() - ]; - }); + $blocked = UserFilter::whereFilterableType('App\Profile') + ->whereFilterType('block') + ->whereFilterableId($request->user()->profile_id) + ->pluck('user_id'); - return $results; - } + $blocked->push($request->user()->profile_id); + + $results = Profile::select('id','domain','username') + ->whereNotIn('id', $blocked) + ->whereNull('domain') + ->where('username','like','%'.$q.'%') + ->limit(15) + ->get() + ->map(function($r) { + return [ + 'id' => (string) $r->id, + 'name' => $r->username, + 'privacy' => true, + 'avatar' => $r->avatarUrl() + ]; + }); + + return $results; + } public function searchUntag(Request $request) { @@ -292,6 +296,8 @@ class ComposeController extends Controller 'profile_id' => 'required' ]); + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); + $user = $request->user(); $status_id = $request->input('status_id'); $profile_id = (int) $request->input('profile_id'); @@ -316,506 +322,520 @@ class ComposeController extends Controller return [200]; } - public function searchLocation(Request $request) - { - abort_if(!$request->user(), 403); - $this->validate($request, [ - 'q' => 'required|string|max:100' - ]); - $pid = $request->user()->profile_id; - abort_if(!$pid, 400); - $q = e($request->input('q')); + public function searchLocation(Request $request) + { + abort_if(!$request->user(), 403); + $this->validate($request, [ + 'q' => 'required|string|max:100' + ]); + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); + $pid = $request->user()->profile_id; + abort_if(!$pid, 400); + $q = e($request->input('q')); - $popular = Cache::remember('pf:search:location:v1:popular', 1209600, function() { - $minId = SnowflakeService::byDate(now()->subDays(290)); - if(config('database.default') == 'pgsql') { - return Status::selectRaw('id, place_id, count(place_id) as pc') - ->whereNotNull('place_id') - ->where('id', '>', $minId) - ->orderByDesc('pc') - ->groupBy(['place_id', 'id']) - ->limit(400) - ->get() - ->filter(function($post) { - return $post; - }) - ->map(function($place) { - return [ - 'id' => $place->place_id, - 'count' => $place->pc - ]; - }) - ->unique('id') - ->values(); - } - return Status::selectRaw('id, place_id, count(place_id) as pc') - ->whereNotNull('place_id') - ->where('id', '>', $minId) - ->groupBy('place_id') - ->orderByDesc('pc') - ->limit(400) - ->get() - ->filter(function($post) { - return $post; - }) - ->map(function($place) { - return [ - 'id' => $place->place_id, - 'count' => $place->pc - ]; - }); - }); - $q = '%' . $q . '%'; - $wildcard = config('database.default') === 'pgsql' ? 'ilike' : 'like'; + $popular = Cache::remember('pf:search:location:v1:popular', 1209600, function() { + $minId = SnowflakeService::byDate(now()->subDays(290)); + if(config('database.default') == 'pgsql') { + return Status::selectRaw('id, place_id, count(place_id) as pc') + ->whereNotNull('place_id') + ->where('id', '>', $minId) + ->orderByDesc('pc') + ->groupBy(['place_id', 'id']) + ->limit(400) + ->get() + ->filter(function($post) { + return $post; + }) + ->map(function($place) { + return [ + 'id' => $place->place_id, + 'count' => $place->pc + ]; + }) + ->unique('id') + ->values(); + } + return Status::selectRaw('id, place_id, count(place_id) as pc') + ->whereNotNull('place_id') + ->where('id', '>', $minId) + ->groupBy('place_id') + ->orderByDesc('pc') + ->limit(400) + ->get() + ->filter(function($post) { + return $post; + }) + ->map(function($place) { + return [ + 'id' => $place->place_id, + 'count' => $place->pc + ]; + }); + }); + $q = '%' . $q . '%'; + $wildcard = config('database.default') === 'pgsql' ? 'ilike' : 'like'; - $places = DB::table('places') - ->where('name', $wildcard, $q) - ->limit((strlen($q) > 5 ? 360 : 30)) - ->get() - ->sortByDesc(function($place, $key) use($popular) { - return $popular->filter(function($p) use($place) { - return $p['id'] == $place->id; - })->map(function($p) use($place) { - return in_array($place->country, ['Canada', 'USA', 'France', 'Germany', 'United Kingdom']) ? $p['count'] : 1; - })->values(); - }) - ->map(function($r) { - return [ - 'id' => $r->id, - 'name' => $r->name, - 'country' => $r->country, - 'url' => url('/discover/places/' . $r->id . '/' . $r->slug) - ]; - }) - ->values() - ->all(); - return $places; - } + $places = DB::table('places') + ->where('name', $wildcard, $q) + ->limit((strlen($q) > 5 ? 360 : 30)) + ->get() + ->sortByDesc(function($place, $key) use($popular) { + return $popular->filter(function($p) use($place) { + return $p['id'] == $place->id; + })->map(function($p) use($place) { + return in_array($place->country, ['Canada', 'USA', 'France', 'Germany', 'United Kingdom']) ? $p['count'] : 1; + })->values(); + }) + ->map(function($r) { + return [ + 'id' => $r->id, + 'name' => $r->name, + 'country' => $r->country, + 'url' => url('/discover/places/' . $r->id . '/' . $r->slug) + ]; + }) + ->values() + ->all(); + return $places; + } - public function searchMentionAutocomplete(Request $request) - { - abort_if(!$request->user(), 403); + public function searchMentionAutocomplete(Request $request) + { + abort_if(!$request->user(), 403); - $this->validate($request, [ - 'q' => 'required|string|min:2|max:50' - ]); + $this->validate($request, [ + 'q' => 'required|string|min:2|max:50' + ]); - $q = $request->input('q'); + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); - if(Str::of($q)->startsWith('@')) { - if(strlen($q) < 3) { - return []; - } - } + $q = $request->input('q'); - $blocked = UserFilter::whereFilterableType('App\Profile') - ->whereFilterType('block') - ->whereFilterableId($request->user()->profile_id) - ->pluck('user_id'); + if(Str::of($q)->startsWith('@')) { + if(strlen($q) < 3) { + return []; + } + } - $blocked->push($request->user()->profile_id); + $blocked = UserFilter::whereFilterableType('App\Profile') + ->whereFilterType('block') + ->whereFilterableId($request->user()->profile_id) + ->pluck('user_id'); - $results = Profile::select('id','domain','username') - ->whereNotIn('id', $blocked) - ->where('username','like','%'.$q.'%') - ->groupBy('id', 'domain') - ->limit(15) - ->get() - ->map(function($profile) { - $username = $profile->domain ? substr($profile->username, 1) : $profile->username; + $blocked->push($request->user()->profile_id); + + $results = Profile::select('id','domain','username') + ->whereNotIn('id', $blocked) + ->where('username','like','%'.$q.'%') + ->groupBy('id', 'domain') + ->limit(15) + ->get() + ->map(function($profile) { + $username = $profile->domain ? substr($profile->username, 1) : $profile->username; return [ 'key' => '@' . str_limit($username, 30), 'value' => $username, ]; - }); + }); - return $results; - } + return $results; + } - public function searchHashtagAutocomplete(Request $request) - { - abort_if(!$request->user(), 403); + public function searchHashtagAutocomplete(Request $request) + { + abort_if(!$request->user(), 403); - $this->validate($request, [ - 'q' => 'required|string|min:2|max:50' - ]); + $this->validate($request, [ + 'q' => 'required|string|min:2|max:50' + ]); - $q = $request->input('q'); + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); - $results = Hashtag::select('slug') - ->where('slug', 'like', '%'.$q.'%') - ->whereIsNsfw(false) - ->whereIsBanned(false) - ->limit(5) - ->get() - ->map(function($tag) { - return [ - 'key' => '#' . $tag->slug, - 'value' => $tag->slug - ]; - }); + $q = $request->input('q'); - return $results; - } + $results = Hashtag::select('slug') + ->where('slug', 'like', '%'.$q.'%') + ->whereIsNsfw(false) + ->whereIsBanned(false) + ->limit(5) + ->get() + ->map(function($tag) { + return [ + 'key' => '#' . $tag->slug, + 'value' => $tag->slug + ]; + }); - public function store(Request $request) - { - $this->validate($request, [ - 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500), - 'media.*' => 'required', - 'media.*.id' => 'required|integer|min:1', - 'media.*.filter_class' => 'nullable|alpha_dash|max:30', - 'media.*.license' => 'nullable|string|max:140', - 'media.*.alt' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'), - 'cw' => 'nullable|boolean', - 'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10', - 'place' => 'nullable', - 'comments_disabled' => 'nullable', - 'tagged' => 'nullable', - 'license' => 'nullable|integer|min:1|max:16', - 'collections' => 'sometimes|array|min:1|max:5', - 'spoiler_text' => 'nullable|string|max:140', - // 'optimize_media' => 'nullable' - ]); + return $results; + } - if(config('costar.enabled') == true) { - $blockedKeywords = config('costar.keyword.block'); - if($blockedKeywords !== null && $request->caption) { - $keywords = config('costar.keyword.block'); - foreach($keywords as $kw) { - if(Str::contains($request->caption, $kw) == true) { - abort(400, 'Invalid object'); - } - } - } - } + public function store(Request $request) + { + $this->validate($request, [ + 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500), + 'media.*' => 'required', + 'media.*.id' => 'required|integer|min:1', + 'media.*.filter_class' => 'nullable|alpha_dash|max:30', + 'media.*.license' => 'nullable|string|max:140', + 'media.*.alt' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'), + 'cw' => 'nullable|boolean', + 'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10', + 'place' => 'nullable', + 'comments_disabled' => 'nullable', + 'tagged' => 'nullable', + 'license' => 'nullable|integer|min:1|max:16', + 'collections' => 'sometimes|array|min:1|max:5', + 'spoiler_text' => 'nullable|string|max:140', + // 'optimize_media' => 'nullable' + ]); - $user = Auth::user(); - $profile = $user->profile; + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); - $limitKey = 'compose:rate-limit:store:' . $user->id; - $limitTtl = now()->addMinutes(15); - $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { - $dailyLimit = Status::whereProfileId($user->profile_id) - ->whereNull('in_reply_to_id') - ->whereNull('reblog_of_id') - ->where('created_at', '>', now()->subDays(1)) - ->count(); + if(config('costar.enabled') == true) { + $blockedKeywords = config('costar.keyword.block'); + if($blockedKeywords !== null && $request->caption) { + $keywords = config('costar.keyword.block'); + foreach($keywords as $kw) { + if(Str::contains($request->caption, $kw) == true) { + abort(400, 'Invalid object'); + } + } + } + } - return $dailyLimit >= 1000; - }); + $user = $request->user(); + $profile = $user->profile; - abort_if($limitReached == true, 429); + $limitKey = 'compose:rate-limit:store:' . $user->id; + $limitTtl = now()->addMinutes(15); + $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { + $dailyLimit = Status::whereProfileId($user->profile_id) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->where('created_at', '>', now()->subDays(1)) + ->count(); - $license = in_array($request->input('license'), License::keys()) ? $request->input('license') : null; + return $dailyLimit >= 1000; + }); - $visibility = $request->input('visibility'); - $medias = $request->input('media'); - $attachments = []; - $status = new Status; - $mimes = []; - $place = $request->input('place'); - $cw = $request->input('cw'); - $tagged = $request->input('tagged'); - $optimize_media = (bool) $request->input('optimize_media'); + abort_if($limitReached == true, 429); - foreach($medias as $k => $media) { - if($k + 1 > config_cache('pixelfed.max_album_length')) { - continue; - } - $m = Media::findOrFail($media['id']); - if($m->profile_id !== $profile->id || $m->status_id) { - abort(403, 'Invalid media id'); - } - $m->filter_class = in_array($media['filter_class'], Filter::classes()) ? $media['filter_class'] : null; - $m->license = $license; - $m->caption = isset($media['alt']) ? strip_tags($media['alt']) : null; - $m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k; + $license = in_array($request->input('license'), License::keys()) ? $request->input('license') : null; - if($cw == true || $profile->cw == true) { - $m->is_nsfw = $cw; - $status->is_nsfw = $cw; - } - $m->save(); - $attachments[] = $m; - array_push($mimes, $m->mime); - } + $visibility = $request->input('visibility'); + $medias = $request->input('media'); + $attachments = []; + $status = new Status; + $mimes = []; + $place = $request->input('place'); + $cw = $request->input('cw'); + $tagged = $request->input('tagged'); + $optimize_media = (bool) $request->input('optimize_media'); - abort_if(empty($attachments), 422); + foreach($medias as $k => $media) { + if($k + 1 > config_cache('pixelfed.max_album_length')) { + continue; + } + $m = Media::findOrFail($media['id']); + if($m->profile_id !== $profile->id || $m->status_id) { + abort(403, 'Invalid media id'); + } + $m->filter_class = in_array($media['filter_class'], Filter::classes()) ? $media['filter_class'] : null; + $m->license = $license; + $m->caption = isset($media['alt']) ? strip_tags($media['alt']) : null; + $m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k; - $mediaType = StatusController::mimeTypeCheck($mimes); + if($cw == true || $profile->cw == true) { + $m->is_nsfw = $cw; + $status->is_nsfw = $cw; + } + $m->save(); + $attachments[] = $m; + array_push($mimes, $m->mime); + } - if(in_array($mediaType, ['photo', 'video', 'photo:album']) == false) { - abort(400, __('exception.compose.invalid.album')); - } + abort_if(empty($attachments), 422); - if($place && is_array($place)) { - $status->place_id = $place['id']; - } + $mediaType = StatusController::mimeTypeCheck($mimes); - if($request->filled('comments_disabled')) { - $status->comments_disabled = (bool) $request->input('comments_disabled'); - } + if(in_array($mediaType, ['photo', 'video', 'photo:album']) == false) { + abort(400, __('exception.compose.invalid.album')); + } - if($request->filled('spoiler_text') && $cw) { - $status->cw_summary = $request->input('spoiler_text'); - } + if($place && is_array($place)) { + $status->place_id = $place['id']; + } - $status->caption = strip_tags($request->caption); - $status->rendered = Autolink::create()->autolink($status->caption); - $status->scope = 'draft'; - $status->visibility = 'draft'; - $status->profile_id = $profile->id; - $status->save(); + if($request->filled('comments_disabled')) { + $status->comments_disabled = (bool) $request->input('comments_disabled'); + } - foreach($attachments as $media) { - $media->status_id = $status->id; - $media->save(); - } + if($request->filled('spoiler_text') && $cw) { + $status->cw_summary = $request->input('spoiler_text'); + } - $visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility; - $visibility = $profile->is_private ? 'private' : $visibility; - $cw = $profile->cw == true ? true : $cw; - $status->is_nsfw = $cw; - $status->visibility = $visibility; - $status->scope = $visibility; - $status->type = $mediaType; - $status->save(); + $status->caption = strip_tags($request->caption); + $status->rendered = Autolink::create()->autolink($status->caption); + $status->scope = 'draft'; + $status->visibility = 'draft'; + $status->profile_id = $profile->id; + $status->save(); - foreach($tagged as $tg) { - $mt = new MediaTag; - $mt->status_id = $status->id; - $mt->media_id = $status->media->first()->id; - $mt->profile_id = $tg['id']; - $mt->tagged_username = $tg['name']; - $mt->is_public = true; - $mt->metadata = json_encode([ - '_v' => 1, - ]); - $mt->save(); - MediaTagService::set($mt->status_id, $mt->profile_id); - MediaTagService::sendNotification($mt); - } + foreach($attachments as $media) { + $media->status_id = $status->id; + $media->save(); + } - if($request->filled('collections')) { - $collections = Collection::whereProfileId($profile->id) - ->find($request->input('collections')) - ->each(function($collection) use($status) { - $count = $collection->items()->count(); - CollectionItem::firstOrCreate([ - 'collection_id' => $collection->id, - 'object_type' => 'App\Status', - 'object_id' => $status->id - ], [ - 'order' => $count - ]); + $visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility; + $visibility = $profile->is_private ? 'private' : $visibility; + $cw = $profile->cw == true ? true : $cw; + $status->is_nsfw = $cw; + $status->visibility = $visibility; + $status->scope = $visibility; + $status->type = $mediaType; + $status->save(); - CollectionService::addItem( - $collection->id, - $status->id, - $count - ); + foreach($tagged as $tg) { + $mt = new MediaTag; + $mt->status_id = $status->id; + $mt->media_id = $status->media->first()->id; + $mt->profile_id = $tg['id']; + $mt->tagged_username = $tg['name']; + $mt->is_public = true; + $mt->metadata = json_encode([ + '_v' => 1, + ]); + $mt->save(); + MediaTagService::set($mt->status_id, $mt->profile_id); + MediaTagService::sendNotification($mt); + } - $collection->updated_at = now(); + if($request->filled('collections')) { + $collections = Collection::whereProfileId($profile->id) + ->find($request->input('collections')) + ->each(function($collection) use($status) { + $count = $collection->items()->count(); + CollectionItem::firstOrCreate([ + 'collection_id' => $collection->id, + 'object_type' => 'App\Status', + 'object_id' => $status->id + ], [ + 'order' => $count + ]); + + CollectionService::addItem( + $collection->id, + $status->id, + $count + ); + + $collection->updated_at = now(); $collection->save(); CollectionService::setCollection($collection->id, $collection); - }); - } + }); + } - NewStatusPipeline::dispatch($status); - Cache::forget('user:account:id:'.$profile->user_id); - Cache::forget('_api:statuses:recent_9:'.$profile->id); - Cache::forget('profile:status_count:'.$profile->id); - Cache::forget('status:transformer:media:attachments:'.$status->id); - Cache::forget($user->storageUsedKey()); - Cache::forget('profile:embed:' . $status->profile_id); - Cache::forget($limitKey); + NewStatusPipeline::dispatch($status); + Cache::forget('user:account:id:'.$profile->user_id); + Cache::forget('_api:statuses:recent_9:'.$profile->id); + Cache::forget('profile:status_count:'.$profile->id); + Cache::forget('status:transformer:media:attachments:'.$status->id); + Cache::forget($user->storageUsedKey()); + Cache::forget('profile:embed:' . $status->profile_id); + Cache::forget($limitKey); - return $status->url(); - } + return $status->url(); + } - public function storeText(Request $request) - { - abort_unless(config('exp.top'), 404); - $this->validate($request, [ - 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500), - 'cw' => 'nullable|boolean', - 'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10', - 'place' => 'nullable', - 'comments_disabled' => 'nullable', - 'tagged' => 'nullable', - ]); + public function storeText(Request $request) + { + abort_unless(config('exp.top'), 404); + $this->validate($request, [ + 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500), + 'cw' => 'nullable|boolean', + 'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10', + 'place' => 'nullable', + 'comments_disabled' => 'nullable', + 'tagged' => 'nullable', + ]); - if(config('costar.enabled') == true) { - $blockedKeywords = config('costar.keyword.block'); - if($blockedKeywords !== null && $request->caption) { - $keywords = config('costar.keyword.block'); - foreach($keywords as $kw) { - if(Str::contains($request->caption, $kw) == true) { - abort(400, 'Invalid object'); - } - } - } - } + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); - $user = Auth::user(); - $profile = $user->profile; - $visibility = $request->input('visibility'); - $status = new Status; - $place = $request->input('place'); - $cw = $request->input('cw'); - $tagged = $request->input('tagged'); + if(config('costar.enabled') == true) { + $blockedKeywords = config('costar.keyword.block'); + if($blockedKeywords !== null && $request->caption) { + $keywords = config('costar.keyword.block'); + foreach($keywords as $kw) { + if(Str::contains($request->caption, $kw) == true) { + abort(400, 'Invalid object'); + } + } + } + } - if($place && is_array($place)) { - $status->place_id = $place['id']; - } + $user = $request->user(); + $profile = $user->profile; + $visibility = $request->input('visibility'); + $status = new Status; + $place = $request->input('place'); + $cw = $request->input('cw'); + $tagged = $request->input('tagged'); - if($request->filled('comments_disabled')) { - $status->comments_disabled = (bool) $request->input('comments_disabled'); - } + if($place && is_array($place)) { + $status->place_id = $place['id']; + } - $status->caption = strip_tags($request->caption); - $status->profile_id = $profile->id; - $entities = []; - $visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility; - $cw = $profile->cw == true ? true : $cw; - $status->is_nsfw = $cw; - $status->visibility = $visibility; - $status->scope = $visibility; - $status->type = 'text'; - $status->rendered = Autolink::create()->autolink($status->caption); - $status->entities = json_encode(array_merge([ - 'timg' => [ - 'version' => 0, - 'bg_id' => 1, - 'font_size' => strlen($status->caption) <= 140 ? 'h1' : 'h3', - 'length' => strlen($status->caption), - ] - ], $entities), JSON_UNESCAPED_SLASHES); - $status->save(); + if($request->filled('comments_disabled')) { + $status->comments_disabled = (bool) $request->input('comments_disabled'); + } - foreach($tagged as $tg) { - $mt = new MediaTag; - $mt->status_id = $status->id; - $mt->media_id = $status->media->first()->id; - $mt->profile_id = $tg['id']; - $mt->tagged_username = $tg['name']; - $mt->is_public = true; - $mt->metadata = json_encode([ - '_v' => 1, - ]); - $mt->save(); - MediaTagService::set($mt->status_id, $mt->profile_id); - MediaTagService::sendNotification($mt); - } + $status->caption = strip_tags($request->caption); + $status->profile_id = $profile->id; + $entities = []; + $visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility; + $cw = $profile->cw == true ? true : $cw; + $status->is_nsfw = $cw; + $status->visibility = $visibility; + $status->scope = $visibility; + $status->type = 'text'; + $status->rendered = Autolink::create()->autolink($status->caption); + $status->entities = json_encode(array_merge([ + 'timg' => [ + 'version' => 0, + 'bg_id' => 1, + 'font_size' => strlen($status->caption) <= 140 ? 'h1' : 'h3', + 'length' => strlen($status->caption), + ] + ], $entities), JSON_UNESCAPED_SLASHES); + $status->save(); + + foreach($tagged as $tg) { + $mt = new MediaTag; + $mt->status_id = $status->id; + $mt->media_id = $status->media->first()->id; + $mt->profile_id = $tg['id']; + $mt->tagged_username = $tg['name']; + $mt->is_public = true; + $mt->metadata = json_encode([ + '_v' => 1, + ]); + $mt->save(); + MediaTagService::set($mt->status_id, $mt->profile_id); + MediaTagService::sendNotification($mt); + } - Cache::forget('user:account:id:'.$profile->user_id); - Cache::forget('_api:statuses:recent_9:'.$profile->id); - Cache::forget('profile:status_count:'.$profile->id); + Cache::forget('user:account:id:'.$profile->user_id); + Cache::forget('_api:statuses:recent_9:'.$profile->id); + Cache::forget('profile:status_count:'.$profile->id); - return $status->url(); - } + return $status->url(); + } - public function mediaProcessingCheck(Request $request) - { - $this->validate($request, [ - 'id' => 'required|integer|min:1' - ]); + public function mediaProcessingCheck(Request $request) + { + $this->validate($request, [ + 'id' => 'required|integer|min:1' + ]); - $media = Media::whereUserId($request->user()->id) - ->whereNull('status_id') - ->findOrFail($request->input('id')); + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); - if(config('pixelfed.media_fast_process')) { - return [ - 'finished' => true - ]; - } + $media = Media::whereUserId($request->user()->id) + ->whereNull('status_id') + ->findOrFail($request->input('id')); - $finished = false; + if(config('pixelfed.media_fast_process')) { + return [ + 'finished' => true + ]; + } - switch ($media->mime) { - case 'image/jpeg': - case 'image/png': - case 'video/mp4': - $finished = config_cache('pixelfed.cloud_storage') ? (bool) $media->cdn_url : (bool) $media->processed_at; - break; + $finished = false; - default: - # code... - break; - } + switch ($media->mime) { + case 'image/jpeg': + case 'image/png': + case 'video/mp4': + $finished = config_cache('pixelfed.cloud_storage') ? (bool) $media->cdn_url : (bool) $media->processed_at; + break; - return [ - 'finished' => $finished - ]; - } + default: + # code... + break; + } - public function composeSettings(Request $request) - { - $uid = $request->user()->id; - $default = [ - 'default_license' => 1, - 'media_descriptions' => false, - 'max_altext_length' => config_cache('pixelfed.max_altext_length') - ]; - $settings = AccountService::settings($uid); - if(isset($settings['other']) && isset($settings['other']['scope'])) { - $s = $settings['compose_settings']; - $s['default_scope'] = $settings['other']['scope']; - $settings['compose_settings'] = $s; - } + return [ + 'finished' => $finished + ]; + } - return array_merge($default, $settings['compose_settings']); - } + public function composeSettings(Request $request) + { + $uid = $request->user()->id; + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); - public function createPoll(Request $request) - { - $this->validate($request, [ - 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500), - 'cw' => 'nullable|boolean', - 'visibility' => 'required|string|in:public,private', - 'comments_disabled' => 'nullable', - 'expiry' => 'required|in:60,360,1440,10080', - 'pollOptions' => 'required|array|min:1|max:4' - ]); + $default = [ + 'default_license' => 1, + 'media_descriptions' => false, + 'max_altext_length' => config_cache('pixelfed.max_altext_length') + ]; + $settings = AccountService::settings($uid); + if(isset($settings['other']) && isset($settings['other']['scope'])) { + $s = $settings['compose_settings']; + $s['default_scope'] = $settings['other']['scope']; + $settings['compose_settings'] = $s; + } - abort_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled'); + return array_merge($default, $settings['compose_settings']); + } - abort_if(Status::whereType('poll') - ->whereProfileId($request->user()->profile_id) - ->whereCaption($request->input('caption')) - ->where('created_at', '>', now()->subDays(2)) - ->exists() - , 422, 'Duplicate detected.'); + public function createPoll(Request $request) + { + $this->validate($request, [ + 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500), + 'cw' => 'nullable|boolean', + 'visibility' => 'required|string|in:public,private', + 'comments_disabled' => 'nullable', + 'expiry' => 'required|in:60,360,1440,10080', + 'pollOptions' => 'required|array|min:1|max:4' + ]); + abort(404); + abort_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled'); + abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action'); - $status = new Status; - $status->profile_id = $request->user()->profile_id; - $status->caption = $request->input('caption'); - $status->rendered = Autolink::create()->autolink($status->caption); - $status->visibility = 'draft'; - $status->scope = 'draft'; - $status->type = 'poll'; - $status->local = true; - $status->save(); + abort_if(Status::whereType('poll') + ->whereProfileId($request->user()->profile_id) + ->whereCaption($request->input('caption')) + ->where('created_at', '>', now()->subDays(2)) + ->exists() + , 422, 'Duplicate detected.'); - $poll = new Poll; - $poll->status_id = $status->id; - $poll->profile_id = $status->profile_id; - $poll->poll_options = $request->input('pollOptions'); - $poll->expires_at = now()->addMinutes($request->input('expiry')); - $poll->cached_tallies = collect($poll->poll_options)->map(function($o) { - return 0; - })->toArray(); - $poll->save(); + $status = new Status; + $status->profile_id = $request->user()->profile_id; + $status->caption = $request->input('caption'); + $status->rendered = Autolink::create()->autolink($status->caption); + $status->visibility = 'draft'; + $status->scope = 'draft'; + $status->type = 'poll'; + $status->local = true; + $status->save(); - $status->visibility = $request->input('visibility'); - $status->scope = $request->input('visibility'); - $status->save(); + $poll = new Poll; + $poll->status_id = $status->id; + $poll->profile_id = $status->profile_id; + $poll->poll_options = $request->input('pollOptions'); + $poll->expires_at = now()->addMinutes($request->input('expiry')); + $poll->cached_tallies = collect($poll->poll_options)->map(function($o) { + return 0; + })->toArray(); + $poll->save(); - NewStatusPipeline::dispatch($status); + $status->visibility = $request->input('visibility'); + $status->scope = $request->input('visibility'); + $status->save(); - return ['url' => $status->url()]; - } + NewStatusPipeline::dispatch($status); + + return ['url' => $status->url()]; + } } diff --git a/app/Http/Controllers/DirectMessageController.php b/app/Http/Controllers/DirectMessageController.php index df76d2ab9..0d91d4f17 100644 --- a/app/Http/Controllers/DirectMessageController.php +++ b/app/Http/Controllers/DirectMessageController.php @@ -5,14 +5,14 @@ namespace App\Http\Controllers; use Auth, Cache; use Illuminate\Http\Request; use App\{ - DirectMessage, - Media, - Notification, - Profile, - Status, - User, - UserFilter, - UserSetting + DirectMessage, + Media, + Notification, + Profile, + Status, + User, + UserFilter, + UserSetting }; use App\Services\MediaPathService; use App\Services\MediaBlocklistService; @@ -26,835 +26,861 @@ use App\Services\WebfingerService; use App\Models\Conversation; use App\Jobs\DirectPipeline\DirectDeletePipeline; use App\Jobs\DirectPipeline\DirectDeliverPipeline; +use App\Services\UserRoleService; class DirectMessageController extends Controller { - public function __construct() - { - $this->middleware('auth'); - } - - public function browse(Request $request) - { - $this->validate($request, [ - 'a' => 'nullable|string|in:inbox,sent,filtered', - 'page' => 'nullable|integer|min:1|max:99' - ]); - - $profile = $request->user()->profile_id; - $action = $request->input('a', 'inbox'); - $page = $request->input('page'); - - if(config('database.default') == 'pgsql') { - if($action == 'inbox') { - $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at') - ->whereToId($profile) - ->with(['author','status']) - ->whereIsHidden(false) - ->when($page, function($q, $page) { - if($page > 1) { - return $q->offset($page * 8 - 8); - } - }) - ->latest() - ->get() - ->unique('from_id') - ->take(8) - ->map(function($r) use($profile) { - return $r->from_id !== $profile ? [ - 'id' => (string) $r->from_id, - 'name' => $r->author->name, - 'username' => $r->author->username, - 'avatar' => $r->author->avatarUrl(), - 'url' => $r->author->url(), - 'isLocal' => (bool) !$r->author->domain, - 'domain' => $r->author->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ] : [ - 'id' => (string) $r->to_id, - 'name' => $r->recipient->name, - 'username' => $r->recipient->username, - 'avatar' => $r->recipient->avatarUrl(), - 'url' => $r->recipient->url(), - 'isLocal' => (bool) !$r->recipient->domain, - 'domain' => $r->recipient->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ]; - })->values(); - } - - if($action == 'sent') { - $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at') - ->whereFromId($profile) - ->with(['author','status']) - ->orderBy('id', 'desc') - ->when($page, function($q, $page) { - if($page > 1) { - return $q->offset($page * 8 - 8); - } - }) - ->get() - ->unique('to_id') - ->take(8) - ->map(function($r) use($profile) { - return $r->from_id !== $profile ? [ - 'id' => (string) $r->from_id, - 'name' => $r->author->name, - 'username' => $r->author->username, - 'avatar' => $r->author->avatarUrl(), - 'url' => $r->author->url(), - 'isLocal' => (bool) !$r->author->domain, - 'domain' => $r->author->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ] : [ - 'id' => (string) $r->to_id, - 'name' => $r->recipient->name, - 'username' => $r->recipient->username, - 'avatar' => $r->recipient->avatarUrl(), - 'url' => $r->recipient->url(), - 'isLocal' => (bool) !$r->recipient->domain, - 'domain' => $r->recipient->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ]; - }); - } - - if($action == 'filtered') { - $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at') - ->whereToId($profile) - ->with(['author','status']) - ->whereIsHidden(true) - ->orderBy('id', 'desc') - ->when($page, function($q, $page) { - if($page > 1) { - return $q->offset($page * 8 - 8); - } - }) - ->get() - ->unique('from_id') - ->take(8) - ->map(function($r) use($profile) { - return $r->from_id !== $profile ? [ - 'id' => (string) $r->from_id, - 'name' => $r->author->name, - 'username' => $r->author->username, - 'avatar' => $r->author->avatarUrl(), - 'url' => $r->author->url(), - 'isLocal' => (bool) !$r->author->domain, - 'domain' => $r->author->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ] : [ - 'id' => (string) $r->to_id, - 'name' => $r->recipient->name, - 'username' => $r->recipient->username, - 'avatar' => $r->recipient->avatarUrl(), - 'url' => $r->recipient->url(), - 'isLocal' => (bool) !$r->recipient->domain, - 'domain' => $r->recipient->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ]; - }); - } - } elseif(config('database.default') == 'mysql') { - if($action == 'inbox') { - $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') - ->whereToId($profile) - ->with(['author','status']) - ->whereIsHidden(false) - ->groupBy('from_id') - ->latest() - ->when($page, function($q, $page) { - if($page > 1) { - return $q->offset($page * 8 - 8); - } - }) - ->limit(8) - ->get() - ->map(function($r) use($profile) { - return $r->from_id !== $profile ? [ - 'id' => (string) $r->from_id, - 'name' => $r->author->name, - 'username' => $r->author->username, - 'avatar' => $r->author->avatarUrl(), - 'url' => $r->author->url(), - 'isLocal' => (bool) !$r->author->domain, - 'domain' => $r->author->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ] : [ - 'id' => (string) $r->to_id, - 'name' => $r->recipient->name, - 'username' => $r->recipient->username, - 'avatar' => $r->recipient->avatarUrl(), - 'url' => $r->recipient->url(), - 'isLocal' => (bool) !$r->recipient->domain, - 'domain' => $r->recipient->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ]; - }); - } - - if($action == 'sent') { - $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') - ->whereFromId($profile) - ->with(['author','status']) - ->groupBy('to_id') - ->orderBy('createdAt', 'desc') - ->when($page, function($q, $page) { - if($page > 1) { - return $q->offset($page * 8 - 8); - } - }) - ->limit(8) - ->get() - ->map(function($r) use($profile) { - return $r->from_id !== $profile ? [ - 'id' => (string) $r->from_id, - 'name' => $r->author->name, - 'username' => $r->author->username, - 'avatar' => $r->author->avatarUrl(), - 'url' => $r->author->url(), - 'isLocal' => (bool) !$r->author->domain, - 'domain' => $r->author->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ] : [ - 'id' => (string) $r->to_id, - 'name' => $r->recipient->name, - 'username' => $r->recipient->username, - 'avatar' => $r->recipient->avatarUrl(), - 'url' => $r->recipient->url(), - 'isLocal' => (bool) !$r->recipient->domain, - 'domain' => $r->recipient->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ]; - }); - } - - if($action == 'filtered') { - $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') - ->whereToId($profile) - ->with(['author','status']) - ->whereIsHidden(true) - ->groupBy('from_id') - ->orderBy('createdAt', 'desc') - ->when($page, function($q, $page) { - if($page > 1) { - return $q->offset($page * 8 - 8); - } - }) - ->limit(8) - ->get() - ->map(function($r) use($profile) { - return $r->from_id !== $profile ? [ - 'id' => (string) $r->from_id, - 'name' => $r->author->name, - 'username' => $r->author->username, - 'avatar' => $r->author->avatarUrl(), - 'url' => $r->author->url(), - 'isLocal' => (bool) !$r->author->domain, - 'domain' => $r->author->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ] : [ - 'id' => (string) $r->to_id, - 'name' => $r->recipient->name, - 'username' => $r->recipient->username, - 'avatar' => $r->recipient->avatarUrl(), - 'url' => $r->recipient->url(), - 'isLocal' => (bool) !$r->recipient->domain, - 'domain' => $r->recipient->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [] - ]; - }); - } - } - - return response()->json($dms->all()); - } - - public function create(Request $request) - { - $this->validate($request, [ - 'to_id' => 'required', - 'message' => 'required|string|min:1|max:500', - 'type' => 'required|in:text,emoji' - ]); - - $profile = $request->user()->profile; - $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id')); - - abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403); - $msg = $request->input('message'); - - if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) { - if($recipient->follows($profile) == true) { - $hidden = false; - } else { - $hidden = true; - } - } else { - $hidden = false; - } - - $status = new Status; - $status->profile_id = $profile->id; - $status->caption = $msg; - $status->rendered = $msg; - $status->visibility = 'direct'; - $status->scope = 'direct'; - $status->in_reply_to_profile_id = $recipient->id; - $status->save(); - - $dm = new DirectMessage; - $dm->to_id = $recipient->id; - $dm->from_id = $profile->id; - $dm->status_id = $status->id; - $dm->is_hidden = $hidden; - $dm->type = $request->input('type'); - $dm->save(); - - Conversation::updateOrInsert( - [ - 'to_id' => $recipient->id, - 'from_id' => $profile->id - ], - [ - 'type' => $dm->type, - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => $hidden - ] - ); - - if(filter_var($msg, FILTER_VALIDATE_URL)) { - if(Helpers::validateUrl($msg)) { - $dm->type = 'link'; - $dm->meta = [ - 'domain' => parse_url($msg, PHP_URL_HOST), - 'local' => parse_url($msg, PHP_URL_HOST) == - parse_url(config('app.url'), PHP_URL_HOST) - ]; - $dm->save(); - } - } - - $nf = UserFilter::whereUserId($recipient->id) - ->whereFilterableId($profile->id) - ->whereFilterableType('App\Profile') - ->whereFilterType('dm.mute') - ->exists(); - - if($recipient->domain == null && $hidden == false && !$nf) { - $notification = new Notification(); - $notification->profile_id = $recipient->id; - $notification->actor_id = $profile->id; - $notification->action = 'dm'; - $notification->item_id = $dm->id; - $notification->item_type = "App\DirectMessage"; - $notification->save(); - } - - if($recipient->domain) { - $this->remoteDeliver($dm); - } - - $res = [ - 'id' => (string) $dm->id, - 'isAuthor' => $profile->id == $dm->from_id, - 'reportId' => (string) $dm->status_id, - 'hidden' => (bool) $dm->is_hidden, - 'type' => $dm->type, - 'text' => $dm->status->caption, - 'media' => null, - 'timeAgo' => $dm->created_at->diffForHumans(null,null,true), - 'seen' => $dm->read_at != null, - 'meta' => $dm->meta - ]; - - return response()->json($res); - } - - public function thread(Request $request) - { - $this->validate($request, [ - 'pid' => 'required' - ]); - $uid = $request->user()->profile_id; - $pid = $request->input('pid'); - $max_id = $request->input('max_id'); - $min_id = $request->input('min_id'); - - $r = Profile::findOrFail($pid); - - if($min_id) { - $res = DirectMessage::select('*') - ->where('id', '>', $min_id) - ->where(function($q) use($pid,$uid) { - return $q->where([['from_id',$pid],['to_id',$uid] - ])->orWhere([['from_id',$uid],['to_id',$pid]]); - }) - ->latest() - ->take(8) - ->get(); - } else if ($max_id) { - $res = DirectMessage::select('*') - ->where('id', '<', $max_id) - ->where(function($q) use($pid,$uid) { - return $q->where([['from_id',$pid],['to_id',$uid] - ])->orWhere([['from_id',$uid],['to_id',$pid]]); - }) - ->latest() - ->take(8) - ->get(); - } else { - $res = DirectMessage::where(function($q) use($pid,$uid) { - return $q->where([['from_id',$pid],['to_id',$uid] - ])->orWhere([['from_id',$uid],['to_id',$pid]]); - }) - ->latest() - ->take(8) - ->get(); - } - - $res = $res->filter(function($s) { - return $s && $s->status; - }) - ->map(function($s) use ($uid) { - return [ - 'id' => (string) $s->id, - 'hidden' => (bool) $s->is_hidden, - 'isAuthor' => $uid == $s->from_id, - 'type' => $s->type, - 'text' => $s->status->caption, - 'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null, - 'timeAgo' => $s->created_at->diffForHumans(null,null,true), - 'seen' => $s->read_at != null, - 'reportId' => (string) $s->status_id, - 'meta' => json_decode($s->meta,true) - ]; - }) - ->values(); - - $w = [ - 'id' => (string) $r->id, - 'name' => $r->name, - 'username' => $r->username, - 'avatar' => $r->avatarUrl(), - 'url' => $r->url(), - 'muted' => UserFilter::whereUserId($uid) - ->whereFilterableId($r->id) - ->whereFilterableType('App\Profile') - ->whereFilterType('dm.mute') - ->first() ? true : false, - 'isLocal' => (bool) !$r->domain, - 'domain' => $r->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => '', - 'messages' => $res - ]; - - return response()->json($w, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); - } - - public function delete(Request $request) - { - $this->validate($request, [ - 'id' => 'required' - ]); - - $sid = $request->input('id'); - $pid = $request->user()->profile_id; - - $dm = DirectMessage::whereFromId($pid) - ->whereStatusId($sid) - ->firstOrFail(); - - $status = Status::whereProfileId($pid) - ->findOrFail($dm->status_id); - - $recipient = AccountService::get($dm->to_id); - - if(!$recipient) { - return response('', 422); - } - - if($recipient['local'] == false) { - $dmc = $dm; - $this->remoteDelete($dmc); - } else { - StatusDelete::dispatch($status)->onQueue('high'); - } - - if(Conversation::whereStatusId($sid)->count()) { - $latest = DirectMessage::where(['from_id' => $dm->from_id, 'to_id' => $dm->to_id]) - ->orWhere(['to_id' => $dm->from_id, 'from_id' => $dm->to_id]) - ->latest() - ->first(); - - if($latest->status_id == $sid) { - Conversation::where(['to_id' => $dm->from_id, 'from_id' => $dm->to_id]) - ->update([ - 'updated_at' => $latest->updated_at, - 'status_id' => $latest->status_id, - 'type' => $latest->type, - 'is_hidden' => false - ]); - - Conversation::where(['to_id' => $dm->to_id, 'from_id' => $dm->from_id]) - ->update([ - 'updated_at' => $latest->updated_at, - 'status_id' => $latest->status_id, - 'type' => $latest->type, - 'is_hidden' => false - ]); - } else { - Conversation::where([ - 'status_id' => $sid, - 'to_id' => $dm->from_id, - 'from_id' => $dm->to_id - ])->delete(); - - Conversation::where([ - 'status_id' => $sid, - 'from_id' => $dm->from_id, - 'to_id' => $dm->to_id - ])->delete(); - } - } - - StatusService::del($status->id, true); - - $status->forceDeleteQuietly(); - return [200]; - } - - public function get(Request $request, $id) - { - $pid = $request->user()->profile_id; - $dm = DirectMessage::whereStatusId($id)->firstOrFail(); - abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404); - return response()->json($dm, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); - } - - public function mediaUpload(Request $request) - { - $this->validate($request, [ - 'file' => function() { - return [ - 'required', - 'mimetypes:' . config_cache('pixelfed.media_types'), - 'max:' . config_cache('pixelfed.max_photo_size'), - ]; - }, - 'to_id' => 'required' - ]); - - $user = $request->user(); - $profile = $user->profile; - $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id')); - abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403); - - if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) { - if($recipient->follows($profile) == true) { - $hidden = false; - } else { - $hidden = true; - } - } else { - $hidden = false; - } - - if(config_cache('pixelfed.enforce_account_limit') == true) { - $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { - return Media::whereUserId($user->id)->sum('size') / 1000; - }); - $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { - abort(403, 'Account size limit reached.'); - } - } - $photo = $request->file('file'); - - $mimes = explode(',', config_cache('pixelfed.media_types')); - if(in_array($photo->getMimeType(), $mimes) == false) { - abort(403, 'Invalid or unsupported mime type.'); - } - - $storagePath = MediaPathService::get($user, 2) . Str::random(8); - $path = $photo->storePublicly($storagePath); - $hash = \hash_file('sha256', $photo); - - abort_if(MediaBlocklistService::exists($hash) == true, 451); - - $status = new Status; - $status->profile_id = $profile->id; - $status->caption = null; - $status->rendered = null; - $status->visibility = 'direct'; - $status->scope = 'direct'; - $status->in_reply_to_profile_id = $recipient->id; - $status->save(); - - $media = new Media(); - $media->status_id = $status->id; - $media->profile_id = $profile->id; - $media->user_id = $user->id; - $media->media_path = $path; - $media->original_sha256 = $hash; - $media->size = $photo->getSize(); - $media->mime = $photo->getMimeType(); - $media->caption = null; - $media->filter_class = null; - $media->filter_name = null; - $media->save(); - - $dm = new DirectMessage; - $dm->to_id = $recipient->id; - $dm->from_id = $profile->id; - $dm->status_id = $status->id; - $dm->type = array_first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo'; - $dm->is_hidden = $hidden; - $dm->save(); - - Conversation::updateOrInsert( - [ - 'to_id' => $recipient->id, - 'from_id' => $profile->id - ], - [ - 'type' => $dm->type, - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => $hidden - ] - ); - - if($recipient->domain) { - $this->remoteDeliver($dm); - } - - return [ - 'id' => $dm->id, - 'reportId' => (string) $dm->status_id, - 'type' => $dm->type, - 'url' => $media->url() - ]; - } - - public function composeLookup(Request $request) - { - $this->validate($request, [ - 'q' => 'required|string|min:2|max:50', - 'remote' => 'nullable', - ]); - - $q = $request->input('q'); - $r = $request->input('remote', false); - - if($r && !Str::of($q)->contains('.')) { - return []; - } - - if($r && Helpers::validateUrl($q)) { - Helpers::profileFetch($q); - } - - if(Str::of($q)->startsWith('@')) { - if(strlen($q) < 3) { - return []; - } - if(substr_count($q, '@') == 2) { - WebfingerService::lookup($q); - } - $q = mb_substr($q, 1); - } - - $blocked = UserFilter::whereFilterableType('App\Profile') - ->whereFilterType('block') - ->whereFilterableId($request->user()->profile_id) - ->pluck('user_id'); - - $blocked->push($request->user()->profile_id); - - $results = Profile::select('id','domain','username') - ->whereNotIn('id', $blocked) - ->where('username','like','%'.$q.'%') - ->orderBy('domain') - ->limit(8) - ->get() - ->map(function($r) { - $acct = AccountService::get($r->id); - return [ - 'local' => (bool) !$r->domain, - 'id' => (string) $r->id, - 'name' => $r->username, - 'privacy' => true, - 'avatar' => $r->avatarUrl(), - 'account' => $acct - ]; - }); - - return $results; - } - - public function read(Request $request) - { - $this->validate($request, [ - 'pid' => 'required', - 'sid' => 'required' - ]); - - $pid = $request->input('pid'); - $sid = $request->input('sid'); - - $dms = DirectMessage::whereToId($request->user()->profile_id) - ->whereFromId($pid) - ->where('status_id', '>=', $sid) - ->get(); - - $now = now(); - foreach($dms as $dm) { - $dm->read_at = $now; - $dm->save(); - } - - return response()->json($dms->pluck('id')); - } - - public function mute(Request $request) - { - $this->validate($request, [ - 'id' => 'required' - ]); - - $fid = $request->input('id'); - $pid = $request->user()->profile_id; - - UserFilter::firstOrCreate( - [ - 'user_id' => $pid, - 'filterable_id' => $fid, - 'filterable_type' => 'App\Profile', - 'filter_type' => 'dm.mute' - ] - ); - - return [200]; - } - - public function unmute(Request $request) - { - $this->validate($request, [ - 'id' => 'required' - ]); - - $fid = $request->input('id'); - $pid = $request->user()->profile_id; - - $f = UserFilter::whereUserId($pid) - ->whereFilterableId($fid) - ->whereFilterableType('App\Profile') - ->whereFilterType('dm.mute') - ->firstOrFail(); - - $f->delete(); - - return [200]; - } - - public function remoteDeliver($dm) - { - $profile = $dm->author; - $url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url; - - $tags = [ - [ - 'type' => 'Mention', - 'href' => $dm->recipient->permalink(), - 'name' => $dm->recipient->emailUrl(), - ] - ]; - - $body = [ - '@context' => [ - 'https://w3id.org/security/v1', - 'https://www.w3.org/ns/activitystreams', - ], - 'id' => $dm->status->permalink(), - 'type' => 'Create', - 'actor' => $dm->status->profile->permalink(), - 'published' => $dm->status->created_at->toAtomString(), - 'to' => [$dm->recipient->permalink()], - 'cc' => [], - 'object' => [ - 'id' => $dm->status->url(), - 'type' => 'Note', - 'summary' => null, - 'content' => $dm->status->rendered ?? $dm->status->caption, - 'inReplyTo' => null, - 'published' => $dm->status->created_at->toAtomString(), - 'url' => $dm->status->url(), - 'attributedTo' => $dm->status->profile->permalink(), - 'to' => [$dm->recipient->permalink()], - 'cc' => [], - 'sensitive' => (bool) $dm->status->is_nsfw, - 'attachment' => $dm->status->media()->orderBy('order')->get()->map(function ($media) { - return [ - 'type' => $media->activityVerb(), - 'mediaType' => $media->mime, - 'url' => $media->url(), - 'name' => $media->caption, - ]; - })->toArray(), - 'tag' => $tags, - ] - ]; - - DirectDeliverPipeline::dispatch($profile, $url, $body)->onQueue('high'); - } - - public function remoteDelete($dm) - { - $profile = $dm->author; - $url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url; - - $body = [ - '@context' => [ - 'https://www.w3.org/ns/activitystreams', - ], - 'id' => $dm->status->permalink('#delete'), - 'to' => [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'type' => 'Delete', - 'actor' => $dm->status->profile->permalink(), - 'object' => [ - 'id' => $dm->status->url(), - 'type' => 'Tombstone' - ] - ]; - DirectDeletePipeline::dispatch($profile, $url, $body)->onQueue('high'); - } + public function __construct() + { + $this->middleware('auth'); + } + + public function browse(Request $request) + { + $this->validate($request, [ + 'a' => 'nullable|string|in:inbox,sent,filtered', + 'page' => 'nullable|integer|min:1|max:99' + ]); + + $user = $request->user(); + if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id)) { + return []; + } + $profile = $user->profile_id; + $action = $request->input('a', 'inbox'); + $page = $request->input('page'); + + if(config('database.default') == 'pgsql') { + if($action == 'inbox') { + $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at') + ->whereToId($profile) + ->with(['author','status']) + ->whereIsHidden(false) + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->latest() + ->get() + ->unique('from_id') + ->take(8) + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + })->values(); + } + + if($action == 'sent') { + $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at') + ->whereFromId($profile) + ->with(['author','status']) + ->orderBy('id', 'desc') + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->get() + ->unique('to_id') + ->take(8) + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + }); + } + + if($action == 'filtered') { + $dms = DirectMessage::select('id', 'type', 'to_id', 'from_id', 'id', 'status_id', 'is_hidden', 'meta', 'created_at', 'read_at') + ->whereToId($profile) + ->with(['author','status']) + ->whereIsHidden(true) + ->orderBy('id', 'desc') + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->get() + ->unique('from_id') + ->take(8) + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + }); + } + } elseif(config('database.default') == 'mysql') { + if($action == 'inbox') { + $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') + ->whereToId($profile) + ->with(['author','status']) + ->whereIsHidden(false) + ->groupBy('from_id') + ->latest() + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->limit(8) + ->get() + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + }); + } + + if($action == 'sent') { + $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') + ->whereFromId($profile) + ->with(['author','status']) + ->groupBy('to_id') + ->orderBy('createdAt', 'desc') + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->limit(8) + ->get() + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + }); + } + + if($action == 'filtered') { + $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') + ->whereToId($profile) + ->with(['author','status']) + ->whereIsHidden(true) + ->groupBy('from_id') + ->orderBy('createdAt', 'desc') + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->limit(8) + ->get() + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + }); + } + } + + return response()->json($dms->all()); + } + + public function create(Request $request) + { + $this->validate($request, [ + 'to_id' => 'required', + 'message' => 'required|string|min:1|max:500', + 'type' => 'required|in:text,emoji' + ]); + + $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + $profile = $user->profile; + $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id')); + + abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403); + $msg = $request->input('message'); + + if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) { + if($recipient->follows($profile) == true) { + $hidden = false; + } else { + $hidden = true; + } + } else { + $hidden = false; + } + + $status = new Status; + $status->profile_id = $profile->id; + $status->caption = $msg; + $status->rendered = $msg; + $status->visibility = 'direct'; + $status->scope = 'direct'; + $status->in_reply_to_profile_id = $recipient->id; + $status->save(); + + $dm = new DirectMessage; + $dm->to_id = $recipient->id; + $dm->from_id = $profile->id; + $dm->status_id = $status->id; + $dm->is_hidden = $hidden; + $dm->type = $request->input('type'); + $dm->save(); + + Conversation::updateOrInsert( + [ + 'to_id' => $recipient->id, + 'from_id' => $profile->id + ], + [ + 'type' => $dm->type, + 'status_id' => $status->id, + 'dm_id' => $dm->id, + 'is_hidden' => $hidden + ] + ); + + if(filter_var($msg, FILTER_VALIDATE_URL)) { + if(Helpers::validateUrl($msg)) { + $dm->type = 'link'; + $dm->meta = [ + 'domain' => parse_url($msg, PHP_URL_HOST), + 'local' => parse_url($msg, PHP_URL_HOST) == + parse_url(config('app.url'), PHP_URL_HOST) + ]; + $dm->save(); + } + } + + $nf = UserFilter::whereUserId($recipient->id) + ->whereFilterableId($profile->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('dm.mute') + ->exists(); + + if($recipient->domain == null && $hidden == false && !$nf) { + $notification = new Notification(); + $notification->profile_id = $recipient->id; + $notification->actor_id = $profile->id; + $notification->action = 'dm'; + $notification->item_id = $dm->id; + $notification->item_type = "App\DirectMessage"; + $notification->save(); + } + + if($recipient->domain) { + $this->remoteDeliver($dm); + } + + $res = [ + 'id' => (string) $dm->id, + 'isAuthor' => $profile->id == $dm->from_id, + 'reportId' => (string) $dm->status_id, + 'hidden' => (bool) $dm->is_hidden, + 'type' => $dm->type, + 'text' => $dm->status->caption, + 'media' => null, + 'timeAgo' => $dm->created_at->diffForHumans(null,null,true), + 'seen' => $dm->read_at != null, + 'meta' => $dm->meta + ]; + + return response()->json($res); + } + + public function thread(Request $request) + { + $this->validate($request, [ + 'pid' => 'required' + ]); + $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + + $uid = $user->profile_id; + $pid = $request->input('pid'); + $max_id = $request->input('max_id'); + $min_id = $request->input('min_id'); + + $r = Profile::findOrFail($pid); + + if($min_id) { + $res = DirectMessage::select('*') + ->where('id', '>', $min_id) + ->where(function($q) use($pid,$uid) { + return $q->where([['from_id',$pid],['to_id',$uid] + ])->orWhere([['from_id',$uid],['to_id',$pid]]); + }) + ->latest() + ->take(8) + ->get(); + } else if ($max_id) { + $res = DirectMessage::select('*') + ->where('id', '<', $max_id) + ->where(function($q) use($pid,$uid) { + return $q->where([['from_id',$pid],['to_id',$uid] + ])->orWhere([['from_id',$uid],['to_id',$pid]]); + }) + ->latest() + ->take(8) + ->get(); + } else { + $res = DirectMessage::where(function($q) use($pid,$uid) { + return $q->where([['from_id',$pid],['to_id',$uid] + ])->orWhere([['from_id',$uid],['to_id',$pid]]); + }) + ->latest() + ->take(8) + ->get(); + } + + $res = $res->filter(function($s) { + return $s && $s->status; + }) + ->map(function($s) use ($uid) { + return [ + 'id' => (string) $s->id, + 'hidden' => (bool) $s->is_hidden, + 'isAuthor' => $uid == $s->from_id, + 'type' => $s->type, + 'text' => $s->status->caption, + 'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null, + 'timeAgo' => $s->created_at->diffForHumans(null,null,true), + 'seen' => $s->read_at != null, + 'reportId' => (string) $s->status_id, + 'meta' => json_decode($s->meta,true) + ]; + }) + ->values(); + + $w = [ + 'id' => (string) $r->id, + 'name' => $r->name, + 'username' => $r->username, + 'avatar' => $r->avatarUrl(), + 'url' => $r->url(), + 'muted' => UserFilter::whereUserId($uid) + ->whereFilterableId($r->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('dm.mute') + ->first() ? true : false, + 'isLocal' => (bool) !$r->domain, + 'domain' => $r->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => '', + 'messages' => $res + ]; + + return response()->json($w, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function delete(Request $request) + { + $this->validate($request, [ + 'id' => 'required' + ]); + + $sid = $request->input('id'); + $pid = $request->user()->profile_id; + + $dm = DirectMessage::whereFromId($pid) + ->whereStatusId($sid) + ->firstOrFail(); + + $status = Status::whereProfileId($pid) + ->findOrFail($dm->status_id); + + $recipient = AccountService::get($dm->to_id); + + if(!$recipient) { + return response('', 422); + } + + if($recipient['local'] == false) { + $dmc = $dm; + $this->remoteDelete($dmc); + } else { + StatusDelete::dispatch($status)->onQueue('high'); + } + + if(Conversation::whereStatusId($sid)->count()) { + $latest = DirectMessage::where(['from_id' => $dm->from_id, 'to_id' => $dm->to_id]) + ->orWhere(['to_id' => $dm->from_id, 'from_id' => $dm->to_id]) + ->latest() + ->first(); + + if($latest->status_id == $sid) { + Conversation::where(['to_id' => $dm->from_id, 'from_id' => $dm->to_id]) + ->update([ + 'updated_at' => $latest->updated_at, + 'status_id' => $latest->status_id, + 'type' => $latest->type, + 'is_hidden' => false + ]); + + Conversation::where(['to_id' => $dm->to_id, 'from_id' => $dm->from_id]) + ->update([ + 'updated_at' => $latest->updated_at, + 'status_id' => $latest->status_id, + 'type' => $latest->type, + 'is_hidden' => false + ]); + } else { + Conversation::where([ + 'status_id' => $sid, + 'to_id' => $dm->from_id, + 'from_id' => $dm->to_id + ])->delete(); + + Conversation::where([ + 'status_id' => $sid, + 'from_id' => $dm->from_id, + 'to_id' => $dm->to_id + ])->delete(); + } + } + + StatusService::del($status->id, true); + + $status->forceDeleteQuietly(); + return [200]; + } + + public function get(Request $request, $id) + { + $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + + $pid = $request->user()->profile_id; + $dm = DirectMessage::whereStatusId($id)->firstOrFail(); + abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404); + return response()->json($dm, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function mediaUpload(Request $request) + { + $this->validate($request, [ + 'file' => function() { + return [ + 'required', + 'mimetypes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ]; + }, + 'to_id' => 'required' + ]); + + $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + $profile = $user->profile; + $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id')); + abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403); + + if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) { + if($recipient->follows($profile) == true) { + $hidden = false; + } else { + $hidden = true; + } + } else { + $hidden = false; + } + + if(config_cache('pixelfed.enforce_account_limit') == true) { + $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { + return Media::whereUserId($user->id)->sum('size') / 1000; + }); + $limit = (int) config_cache('pixelfed.max_account_size'); + if ($size >= $limit) { + abort(403, 'Account size limit reached.'); + } + } + $photo = $request->file('file'); + + $mimes = explode(',', config_cache('pixelfed.media_types')); + if(in_array($photo->getMimeType(), $mimes) == false) { + abort(403, 'Invalid or unsupported mime type.'); + } + + $storagePath = MediaPathService::get($user, 2) . Str::random(8); + $path = $photo->storePublicly($storagePath); + $hash = \hash_file('sha256', $photo); + + abort_if(MediaBlocklistService::exists($hash) == true, 451); + + $status = new Status; + $status->profile_id = $profile->id; + $status->caption = null; + $status->rendered = null; + $status->visibility = 'direct'; + $status->scope = 'direct'; + $status->in_reply_to_profile_id = $recipient->id; + $status->save(); + + $media = new Media(); + $media->status_id = $status->id; + $media->profile_id = $profile->id; + $media->user_id = $user->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->caption = null; + $media->filter_class = null; + $media->filter_name = null; + $media->save(); + + $dm = new DirectMessage; + $dm->to_id = $recipient->id; + $dm->from_id = $profile->id; + $dm->status_id = $status->id; + $dm->type = array_first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo'; + $dm->is_hidden = $hidden; + $dm->save(); + + Conversation::updateOrInsert( + [ + 'to_id' => $recipient->id, + 'from_id' => $profile->id + ], + [ + 'type' => $dm->type, + 'status_id' => $status->id, + 'dm_id' => $dm->id, + 'is_hidden' => $hidden + ] + ); + + if($recipient->domain) { + $this->remoteDeliver($dm); + } + + return [ + 'id' => $dm->id, + 'reportId' => (string) $dm->status_id, + 'type' => $dm->type, + 'url' => $media->url() + ]; + } + + public function composeLookup(Request $request) + { + $this->validate($request, [ + 'q' => 'required|string|min:2|max:50', + 'remote' => 'nullable', + ]); + + $user = $request->user(); + if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id)) { + return []; + } + + $q = $request->input('q'); + $r = $request->input('remote', false); + + if($r && !Str::of($q)->contains('.')) { + return []; + } + + if($r && Helpers::validateUrl($q)) { + Helpers::profileFetch($q); + } + + if(Str::of($q)->startsWith('@')) { + if(strlen($q) < 3) { + return []; + } + if(substr_count($q, '@') == 2) { + WebfingerService::lookup($q); + } + $q = mb_substr($q, 1); + } + + $blocked = UserFilter::whereFilterableType('App\Profile') + ->whereFilterType('block') + ->whereFilterableId($request->user()->profile_id) + ->pluck('user_id'); + + $blocked->push($request->user()->profile_id); + + $results = Profile::select('id','domain','username') + ->whereNotIn('id', $blocked) + ->where('username','like','%'.$q.'%') + ->orderBy('domain') + ->limit(8) + ->get() + ->map(function($r) { + $acct = AccountService::get($r->id); + return [ + 'local' => (bool) !$r->domain, + 'id' => (string) $r->id, + 'name' => $r->username, + 'privacy' => true, + 'avatar' => $r->avatarUrl(), + 'account' => $acct + ]; + }); + + return $results; + } + + public function read(Request $request) + { + $this->validate($request, [ + 'pid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->input('pid'); + $sid = $request->input('sid'); + $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + + $dms = DirectMessage::whereToId($request->user()->profile_id) + ->whereFromId($pid) + ->where('status_id', '>=', $sid) + ->get(); + + $now = now(); + foreach($dms as $dm) { + $dm->read_at = $now; + $dm->save(); + } + + return response()->json($dms->pluck('id')); + } + + public function mute(Request $request) + { + $this->validate($request, [ + 'id' => 'required' + ]); + + $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + $fid = $request->input('id'); + $pid = $request->user()->profile_id; + + UserFilter::firstOrCreate( + [ + 'user_id' => $pid, + 'filterable_id' => $fid, + 'filterable_type' => 'App\Profile', + 'filter_type' => 'dm.mute' + ] + ); + + return [200]; + } + + public function unmute(Request $request) + { + $this->validate($request, [ + 'id' => 'required' + ]); + + $user = $request->user(); + abort_if($user->has_roles && !UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + + $fid = $request->input('id'); + $pid = $request->user()->profile_id; + + $f = UserFilter::whereUserId($pid) + ->whereFilterableId($fid) + ->whereFilterableType('App\Profile') + ->whereFilterType('dm.mute') + ->firstOrFail(); + + $f->delete(); + + return [200]; + } + + public function remoteDeliver($dm) + { + $profile = $dm->author; + $url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url; + + $tags = [ + [ + 'type' => 'Mention', + 'href' => $dm->recipient->permalink(), + 'name' => $dm->recipient->emailUrl(), + ] + ]; + + $body = [ + '@context' => [ + 'https://w3id.org/security/v1', + 'https://www.w3.org/ns/activitystreams', + ], + 'id' => $dm->status->permalink(), + 'type' => 'Create', + 'actor' => $dm->status->profile->permalink(), + 'published' => $dm->status->created_at->toAtomString(), + 'to' => [$dm->recipient->permalink()], + 'cc' => [], + 'object' => [ + 'id' => $dm->status->url(), + 'type' => 'Note', + 'summary' => null, + 'content' => $dm->status->rendered ?? $dm->status->caption, + 'inReplyTo' => null, + 'published' => $dm->status->created_at->toAtomString(), + 'url' => $dm->status->url(), + 'attributedTo' => $dm->status->profile->permalink(), + 'to' => [$dm->recipient->permalink()], + 'cc' => [], + 'sensitive' => (bool) $dm->status->is_nsfw, + 'attachment' => $dm->status->media()->orderBy('order')->get()->map(function ($media) { + return [ + 'type' => $media->activityVerb(), + 'mediaType' => $media->mime, + 'url' => $media->url(), + 'name' => $media->caption, + ]; + })->toArray(), + 'tag' => $tags, + ] + ]; + + DirectDeliverPipeline::dispatch($profile, $url, $body)->onQueue('high'); + } + + public function remoteDelete($dm) + { + $profile = $dm->author; + $url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url; + + $body = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + ], + 'id' => $dm->status->permalink('#delete'), + 'to' => [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'type' => 'Delete', + 'actor' => $dm->status->profile->permalink(), + 'object' => [ + 'id' => $dm->status->url(), + 'type' => 'Tombstone' + ] + ]; + DirectDeletePipeline::dispatch($profile, $url, $body)->onQueue('high'); + } } diff --git a/app/Http/Controllers/ParentalControlsController.php b/app/Http/Controllers/ParentalControlsController.php new file mode 100644 index 000000000..7d8625863 --- /dev/null +++ b/app/Http/Controllers/ParentalControlsController.php @@ -0,0 +1,231 @@ +user(), 404); + abort_unless($request->user()->has_roles === 0, 404); + } + abort_unless(config('instance.parental_controls.enabled'), 404); + if(config_cache('pixelfed.open_registration') == false) { + abort_if(config('instance.parental_controls.limits.respect_open_registration'), 404); + } + if($maxUserCheck == true) { + $hasLimit = config('pixelfed.enforce_max_users'); + if($hasLimit) { + $count = User::where(function($q){ return $q->whereNull('status')->orWhereNotIn('status', ['deleted','delete']); })->count(); + $limit = (int) config('pixelfed.max_users'); + + abort_if($limit && $limit <= $count, 404); + } + } + } + + public function index(Request $request) + { + $this->authPreflight($request); + $children = ParentalControls::whereParentId($request->user()->id)->latest()->paginate(5); + return view('settings.parental-controls.index', compact('children')); + } + + public function add(Request $request) + { + $this->authPreflight($request, true); + return view('settings.parental-controls.add'); + } + + public function view(Request $request, $id) + { + $this->authPreflight($request); + $uid = $request->user()->id; + $pc = ParentalControls::whereParentId($uid)->findOrFail($id); + return view('settings.parental-controls.manage', compact('pc')); + } + + public function update(Request $request, $id) + { + $this->authPreflight($request); + $uid = $request->user()->id; + $ff = $this->requestFormFields($request); + $pc = ParentalControls::whereParentId($uid)->findOrFail($id); + $pc->permissions = $ff; + $pc->save(); + + $roles = UserRoleService::mapActions($pc->child_id, $ff); + if(isset($roles['account-force-private'])) { + $c = Profile::whereUserId($pc->child_id)->first(); + $c->is_private = $roles['account-force-private']; + $c->save(); + } + UserRoles::whereUserId($pc->child_id)->update(['roles' => $roles]); + return redirect($pc->manageUrl() . '?permissions'); + } + + public function store(Request $request) + { + $this->authPreflight($request, true); + $this->validate($request, [ + 'email' => 'required|email|unique:parental_controls,email|unique:users,email', + ]); + + $state = $this->requestFormFields($request); + + $pc = new ParentalControls; + $pc->parent_id = $request->user()->id; + $pc->email = $request->input('email'); + $pc->verify_code = str_random(32); + $pc->permissions = $state; + $pc->save(); + + DispatchChildInvitePipeline::dispatch($pc); + return redirect($pc->manageUrl()); + } + + public function inviteRegister(Request $request, $id, $code) + { + if($request->user()) { + $title = 'You cannot complete this action on this device.'; + $body = 'Please log out or use a different device or browser to complete the invitation registration.'; + return view('errors.custom', compact('title', 'body')); + } + + $this->authPreflight($request, true, false); + + $pc = ParentalControls::whereRaw('verify_code = BINARY ?', $code)->whereNull(['email_verified_at', 'child_id'])->findOrFail($id); + abort_unless(User::whereId($pc->parent_id)->exists(), 404); + return view('settings.parental-controls.invite-register-form', compact('pc')); + } + + public function inviteRegisterStore(Request $request, $id, $code) + { + if($request->user()) { + $title = 'You cannot complete this action on this device.'; + $body = 'Please log out or use a different device or browser to complete the invitation registration.'; + return view('errors.custom', compact('title', 'body')); + } + + $this->authPreflight($request, true, false); + + $pc = ParentalControls::whereRaw('verify_code = BINARY ?', $code)->whereNull('email_verified_at')->findOrFail($id); + + $fields = $request->all(); + $fields['email'] = $pc->email; + $defaults = UserRoleService::defaultRoles(); + $validator = (new RegisterController)->validator($fields); + $valid = $validator->validate(); + abort_if(!$valid, 404); + event(new Registered($user = (new RegisterController)->create($fields))); + sleep(5); + $user->has_roles = true; + $user->parent_id = $pc->parent_id; + if(config('instance.parental_controls.limits.auto_verify_email')) { + $user->email_verified_at = now(); + $user->save(); + sleep(3); + } else { + $user->save(); + sleep(3); + } + $ur = UserRoles::updateOrCreate([ + 'user_id' => $user->id, + ],[ + 'roles' => UserRoleService::mapInvite($user->id, $pc->permissions) + ]); + $pc->email_verified_at = now(); + $pc->child_id = $user->id; + $pc->save(); + sleep(2); + Auth::guard()->login($user); + + return redirect('/i/web'); + } + + public function cancelInvite(Request $request, $id) + { + $this->authPreflight($request); + $pc = ParentalControls::whereParentId($request->user()->id) + ->whereNull(['email_verified_at', 'child_id']) + ->findOrFail($id); + + return view('settings.parental-controls.delete-invite', compact('pc')); + } + + public function cancelInviteHandle(Request $request, $id) + { + $this->authPreflight($request); + $pc = ParentalControls::whereParentId($request->user()->id) + ->whereNull(['email_verified_at', 'child_id']) + ->findOrFail($id); + + $pc->delete(); + + return redirect('/settings/parental-controls'); + } + + public function stopManaging(Request $request, $id) + { + $this->authPreflight($request); + $pc = ParentalControls::whereParentId($request->user()->id) + ->whereNotNull(['email_verified_at', 'child_id']) + ->findOrFail($id); + + return view('settings.parental-controls.stop-managing', compact('pc')); + } + + public function stopManagingHandle(Request $request, $id) + { + $this->authPreflight($request); + $pc = ParentalControls::whereParentId($request->user()->id) + ->whereNotNull(['email_verified_at', 'child_id']) + ->findOrFail($id); + $pc->child()->update([ + 'has_roles' => false, + 'parent_id' => null, + ]); + $pc->delete(); + + return redirect('/settings/parental-controls'); + } + + protected function requestFormFields($request) + { + $state = []; + $fields = [ + 'post', + 'comment', + 'like', + 'share', + 'follow', + 'bookmark', + 'story', + 'collection', + 'discovery_feeds', + 'dms', + 'federation', + 'hide_network', + 'private', + 'hide_cw' + ]; + + foreach ($fields as $field) { + $state[$field] = $request->input($field) == 'on'; + } + + return $state; + } +} diff --git a/app/Http/Controllers/Settings/HomeSettings.php b/app/Http/Controllers/Settings/HomeSettings.php index 082a72af0..99326c097 100644 --- a/app/Http/Controllers/Settings/HomeSettings.php +++ b/app/Http/Controllers/Settings/HomeSettings.php @@ -22,189 +22,189 @@ use App\Services\PronounService; trait HomeSettings { - public function home() - { - $id = Auth::user()->profile->id; - $storage = []; - $used = Media::whereProfileId($id)->sum('size'); - $storage['limit'] = config_cache('pixelfed.max_account_size') * 1024; - $storage['used'] = $used; - $storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100); - $storage['limitPretty'] = PrettyNumber::size($storage['limit']); - $storage['usedPretty'] = PrettyNumber::size($storage['used']); - $pronouns = PronounService::get($id); + public function home() + { + $id = Auth::user()->profile->id; + $storage = []; + $used = Media::whereProfileId($id)->sum('size'); + $storage['limit'] = config_cache('pixelfed.max_account_size') * 1024; + $storage['used'] = $used; + $storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100); + $storage['limitPretty'] = PrettyNumber::size($storage['limit']); + $storage['usedPretty'] = PrettyNumber::size($storage['used']); + $pronouns = PronounService::get($id); - return view('settings.home', compact('storage', 'pronouns')); - } + return view('settings.home', compact('storage', 'pronouns')); + } - public function homeUpdate(Request $request) - { - $this->validate($request, [ - 'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'), - 'bio' => 'nullable|string|max:'.config('pixelfed.max_bio_length'), - 'website' => 'nullable|url', - 'language' => 'nullable|string|min:2|max:5', - 'pronouns' => 'nullable|array|max:4' - ]); + public function homeUpdate(Request $request) + { + $this->validate($request, [ + 'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'), + 'bio' => 'nullable|string|max:'.config('pixelfed.max_bio_length'), + 'website' => 'nullable|url', + 'language' => 'nullable|string|min:2|max:5', + 'pronouns' => 'nullable|array|max:4' + ]); - $changes = false; - $name = strip_tags(Purify::clean($request->input('name'))); - $bio = $request->filled('bio') ? strip_tags(Purify::clean($request->input('bio'))) : null; - $website = $request->input('website'); - $language = $request->input('language'); - $user = Auth::user(); - $profile = $user->profile; - $pronouns = $request->input('pronouns'); - $existingPronouns = PronounService::get($profile->id); - $layout = $request->input('profile_layout'); - if($layout) { - $layout = !in_array($layout, ['metro', 'moment']) ? 'metro' : $layout; - } + $changes = false; + $name = strip_tags(Purify::clean($request->input('name'))); + $bio = $request->filled('bio') ? strip_tags(Purify::clean($request->input('bio'))) : null; + $website = $request->input('website'); + $language = $request->input('language'); + $user = Auth::user(); + $profile = $user->profile; + $pronouns = $request->input('pronouns'); + $existingPronouns = PronounService::get($profile->id); + $layout = $request->input('profile_layout'); + if($layout) { + $layout = !in_array($layout, ['metro', 'moment']) ? 'metro' : $layout; + } - $enforceEmailVerification = config_cache('pixelfed.enforce_email_verification'); + $enforceEmailVerification = config_cache('pixelfed.enforce_email_verification'); - // Only allow email to be updated if not yet verified - if (!$enforceEmailVerification || !$changes && $user->email_verified_at) { - if ($profile->name != $name) { - $changes = true; - $user->name = $name; - $profile->name = $name; - } + // Only allow email to be updated if not yet verified + if (!$enforceEmailVerification || !$changes && $user->email_verified_at) { + if ($profile->name != $name) { + $changes = true; + $user->name = $name; + $profile->name = $name; + } - if ($profile->website != $website) { - $changes = true; - $profile->website = $website; - } + if ($profile->website != $website) { + $changes = true; + $profile->website = $website; + } - if (strip_tags($profile->bio) != $bio) { - $changes = true; - $profile->bio = Autolink::create()->autolink($bio); - } + if (strip_tags($profile->bio) != $bio) { + $changes = true; + $profile->bio = Autolink::create()->autolink($bio); + } - if($user->language != $language && - in_array($language, \App\Util\Localization\Localization::languages()) - ) { - $changes = true; - $user->language = $language; - session()->put('locale', $language); - } + if($user->language != $language && + in_array($language, \App\Util\Localization\Localization::languages()) + ) { + $changes = true; + $user->language = $language; + session()->put('locale', $language); + } - if($existingPronouns != $pronouns) { - if($pronouns && in_array('Select Pronoun(s)', $pronouns)) { - PronounService::clear($profile->id); - } else { - PronounService::put($profile->id, $pronouns); - } - } - } + if($existingPronouns != $pronouns) { + if($pronouns && in_array('Select Pronoun(s)', $pronouns)) { + PronounService::clear($profile->id); + } else { + PronounService::put($profile->id, $pronouns); + } + } + } - if ($changes === true) { - $user->save(); - $profile->save(); - Cache::forget('user:account:id:'.$user->id); - AccountService::del($profile->id); - return redirect('/settings/home')->with('status', 'Profile successfully updated!'); - } + if ($changes === true) { + $user->save(); + $profile->save(); + Cache::forget('user:account:id:'.$user->id); + AccountService::del($profile->id); + return redirect('/settings/home')->with('status', 'Profile successfully updated!'); + } - return redirect('/settings/home'); - } + return redirect('/settings/home'); + } - public function password() - { - return view('settings.password'); - } + public function password() + { + return view('settings.password'); + } - public function passwordUpdate(Request $request) - { - $this->validate($request, [ - 'current' => 'required|string', - 'password' => 'required|string', - 'password_confirmation' => 'required|string', - ]); + public function passwordUpdate(Request $request) + { + $this->validate($request, [ + 'current' => 'required|string', + 'password' => 'required|string', + 'password_confirmation' => 'required|string', + ]); - $current = $request->input('current'); - $new = $request->input('password'); - $confirm = $request->input('password_confirmation'); + $current = $request->input('current'); + $new = $request->input('password'); + $confirm = $request->input('password_confirmation'); - $user = Auth::user(); + $user = Auth::user(); - if (password_verify($current, $user->password) && $new === $confirm) { - $user->password = bcrypt($new); - $user->save(); + if (password_verify($current, $user->password) && $new === $confirm) { + $user->password = bcrypt($new); + $user->save(); - $log = new AccountLog(); - $log->user_id = $user->id; - $log->item_id = $user->id; - $log->item_type = 'App\User'; - $log->action = 'account.edit.password'; - $log->message = 'Password changed'; - $log->link = null; - $log->ip_address = $request->ip(); - $log->user_agent = $request->userAgent(); - $log->save(); + $log = new AccountLog(); + $log->user_id = $user->id; + $log->item_id = $user->id; + $log->item_type = 'App\User'; + $log->action = 'account.edit.password'; + $log->message = 'Password changed'; + $log->link = null; + $log->ip_address = $request->ip(); + $log->user_agent = $request->userAgent(); + $log->save(); - Mail::to($request->user())->send(new PasswordChange($user)); - return redirect('/settings/home')->with('status', 'Password successfully updated!'); - } else { - return redirect()->back()->with('error', 'There was an error with your request! Please try again.'); - } + Mail::to($request->user())->send(new PasswordChange($user)); + return redirect('/settings/home')->with('status', 'Password successfully updated!'); + } else { + return redirect()->back()->with('error', 'There was an error with your request! Please try again.'); + } - } + } - public function email() - { - return view('settings.email'); - } + public function email() + { + return view('settings.email'); + } - public function emailUpdate(Request $request) - { - $this->validate($request, [ - 'email' => 'required|email|unique:users,email', - ]); - $changes = false; - $email = $request->input('email'); - $user = Auth::user(); - $profile = $user->profile; + public function emailUpdate(Request $request) + { + $this->validate($request, [ + 'email' => 'required|email|unique:users,email', + ]); + $changes = false; + $email = $request->input('email'); + $user = Auth::user(); + $profile = $user->profile; - $validate = config_cache('pixelfed.enforce_email_verification'); + $validate = config_cache('pixelfed.enforce_email_verification'); - if ($user->email != $email) { - $changes = true; - $user->email = $email; + if ($user->email != $email) { + $changes = true; + $user->email = $email; - if ($validate) { - $user->email_verified_at = null; - // Prevent old verifications from working - EmailVerification::whereUserId($user->id)->delete(); - } + if ($validate) { + // auto verify admin email addresses + $user->email_verified_at = $user->is_admin == true ? now() : null; + // Prevent old verifications from working + EmailVerification::whereUserId($user->id)->delete(); + } - $log = new AccountLog(); - $log->user_id = $user->id; - $log->item_id = $user->id; - $log->item_type = 'App\User'; - $log->action = 'account.edit.email'; - $log->message = 'Email changed'; - $log->link = null; - $log->ip_address = $request->ip(); - $log->user_agent = $request->userAgent(); - $log->save(); - } + $log = new AccountLog(); + $log->user_id = $user->id; + $log->item_id = $user->id; + $log->item_type = 'App\User'; + $log->action = 'account.edit.email'; + $log->message = 'Email changed'; + $log->link = null; + $log->ip_address = $request->ip(); + $log->user_agent = $request->userAgent(); + $log->save(); + } - if ($changes === true) { - Cache::forget('user:account:id:'.$user->id); - $user->save(); - $profile->save(); + if ($changes === true) { + Cache::forget('user:account:id:'.$user->id); + $user->save(); + $profile->save(); - return redirect('/settings/home')->with('status', 'Email successfully updated!'); - } else { - return redirect('/settings/email'); - } + return redirect('/settings/email')->with('status', 'Email successfully updated!'); + } else { + return redirect('/settings/email'); + } - } - - public function avatar() - { - return view('settings.avatar'); - } + } + public function avatar() + { + return view('settings.avatar'); + } } diff --git a/app/Http/Controllers/StoryComposeController.php b/app/Http/Controllers/StoryComposeController.php index 8f9358b74..eb2d859c0 100644 --- a/app/Http/Controllers/StoryComposeController.php +++ b/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); + { + 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'), - ]; - }, - ]); + $this->validate($request, [ + 'file' => function() { + return [ + 'required', + 'mimetypes:image/jpeg,image/png,video/mp4', + 'max:' . config_cache('pixelfed.max_photo_size'), + ]; + }, + ]); - $user = $request->user(); + $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(); - $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.'); + } - 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); - $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(); - $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; - $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 + ]; - $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); + } + } - 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; + } - 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; + } - 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; + } - $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); - 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' + ]); - $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')); - $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); - $story = Story::whereProfileId($user->profile_id)->findOrFail($id); + $path = storage_path('app/' . $story->path); - $path = storage_path('app/' . $story->path); + if(!is_file($path)) { + abort(400, 'Invalid or missing media.'); + } - 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')); + } - 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', + ]; + } - return [ - 'code' => 200, - 'msg' => 'Successfully cropped', - ]; - } + public function publishStory(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - 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' + ]); - $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); - $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(); - $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); - StoryService::delLatest($story->profile_id); - StoryFanout::dispatch($story)->onQueue('story'); - StoryService::addRotateQueue($story->id); + return [ + 'code' => 200, + 'msg' => 'Successfully published', + ]; + } - return [ - 'code' => 200, - 'msg' => 'Successfully published', - ]; - } + public function apiV1Delete(Request $request, $id) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - public function apiV1Delete(Request $request, $id) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + $user = $request->user(); - $user = $request->user(); + $story = Story::whereProfileId($user->profile_id) + ->findOrFail($id); + $story->active = false; + $story->save(); - $story = Story::whereProfileId($user->profile_id) - ->findOrFail($id); - $story->active = false; - $story->save(); + StoryDelete::dispatch($story)->onQueue('story'); - StoryDelete::dispatch($story)->onQueue('story'); + return [ + 'code' => 200, + 'msg' => 'Successfully deleted' + ]; + } - 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'); - public function compose(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + return view('stories.compose'); + } - 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); - 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(); + } - return $request->all(); - } + public function publishStoryPoll(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - 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' + ]); - $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; - $pid = $request->user()->profile_id; + $count = Story::whereProfileId($pid) + ->whereActive(true) + ->where('expires_at', '>', now()) + ->count(); - $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.'); + } - 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(); - $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(); - $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(); - $story->active = true; - $story->save(); + StoryService::delLatest($story->profile_id); - StoryService::delLatest($story->profile_id); + return [ + 'code' => 200, + 'msg' => 'Successfully published', + ]; + } - return [ - 'code' => 200, - 'msg' => 'Successfully published', - ]; - } + public function storyPollVote(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - 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' + ]); - $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(); - $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(); - $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(); - $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; + } - return 200; - } + public function storeReport(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - public function storeReport(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - $this->validate($request, [ + $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'); + 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')); - $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'); - 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(); - $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(); - $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 + ] + ); - 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'); + } - 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); - StoryService::reactIncrement($story->id, $pid); + return 200; + } - 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')); - 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'); + abort_if(!$story->can_reply, 422); - $story = Story::findOrFail($request->input('sid')); + $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(); - abort_if(!$story->can_reply, 422); + $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(); - $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(); + Conversation::updateOrInsert( + [ + 'to_id' => $story->profile_id, + 'from_id' => $pid + ], + [ + 'type' => 'story:comment', + 'status_id' => $status->id, + 'dm_id' => $dm->id, + 'is_hidden' => false + ] + ); - $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(); + 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'); + } - 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; - } + return 200; + } } diff --git a/app/Http/Controllers/StoryController.php b/app/Http/Controllers/StoryController.php index 5a9fb5530..692e27961 100644 --- a/app/Http/Controllers/StoryController.php +++ b/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; + 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'); - }); + 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(); - }); - } + } 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; - }); - }); + $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()); - } + 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); - } + $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); + 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); + $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); - } + 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 - ]; + $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); - } - } + 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); - } + 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); + 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'); + $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; - $authed = $request->user()->profile; + $story = Story::with('profile') + ->findOrFail($id); + $exp = $story->expires_at; - $story = Story::with('profile') - ->findOrFail($id); - $exp = $story->expires_at; + $profile = $story->profile; - $profile = $story->profile; + if($story->profile_id == $authed->id) { + return []; + } - if($story->profile_id == $authed->id) { - return []; - } + $publicOnly = (bool) $profile->followedBy($authed); + abort_if(!$publicOnly, 403); - $publicOnly = (bool) $profile->followedBy($authed); - abort_if(!$publicOnly, 403); + $v = StoryView::firstOrCreate([ + 'story_id' => $id, + 'profile_id' => $authed->id + ]); - $v = StoryView::firstOrCreate([ - 'story_id' => $id, - 'profile_id' => $authed->id - ]); + if($v->wasRecentlyCreated) { + Story::findOrFail($story->id)->increment('view_count'); - if($v->wasRecentlyCreated) { - Story::findOrFail($story->id)->increment('view_count'); + if($story->local == false) { + StoryViewDeliver::dispatch($story, $authed)->onQueue('story'); + } + } - 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]; + } - 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 exists(Request $request, $id) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + public function iRedirect(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - return response()->json(Story::whereProfileId($id) - ->whereActive(true) - ->exists()); - } + $user = $request->user(); + abort_if(!$user, 404); + $username = $user->username; + return redirect("/stories/{$username}"); + } - public function iRedirect(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + public function viewers(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}"); - } + $this->validate($request, [ + 'sid' => 'required|string' + ]); - public function viewers(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 response()->json([]); + } - $this->validate($request, [ - 'sid' => 'required|string' - ]); + $pid = $request->user()->profile_id; + $sid = $request->input('sid'); - $pid = $request->user()->profile_id; - $sid = $request->input('sid'); + $story = Story::whereProfileId($pid) + ->whereActive(true) + ->findOrFail($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(); - $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); + } - 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); - 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')); + } - $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); - public function pollResults(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + $this->validate($request, [ + 'sid' => 'required|string' + ]); - $this->validate($request, [ - 'sid' => 'required|string' - ]); + $pid = $request->user()->profile_id; + $sid = $request->input('sid'); - $pid = $request->user()->profile_id; - $sid = $request->input('sid'); + $story = Story::whereProfileId($pid) + ->whereActive(true) + ->findOrFail($sid); - $story = Story::whereProfileId($pid) - ->whereActive(true) - ->findOrFail($sid); + return PollService::storyResults($sid); + } - return PollService::storyResults($sid); - } + public function getActivityObject(Request $request, $username, $id) + { + abort_if(!config_cache('instance.stories.enabled'), 404); - public function getActivityObject(Request $request, $username, $id) - { - abort_if(!config_cache('instance.stories.enabled'), 404); + if(!$request->wantsJson()) { + return redirect('/stories/' . $username); + } - if(!$request->wantsJson()) { - return redirect('/stories/' . $username); - } + abort_if(!$request->hasHeader('Authorization'), 404); - abort_if(!$request->hasHeader('Authorization'), 404); + $profile = Profile::whereUsername($username)->whereNull('domain')->firstOrFail(); + $story = Story::whereActive(true)->whereProfileId($profile->id)->findOrFail($id); - $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); - 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); + } - $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 showSystemStory() + { + // return view('stories.system'); + } } diff --git a/app/Jobs/ParentalControlsPipeline/DispatchChildInvitePipeline.php b/app/Jobs/ParentalControlsPipeline/DispatchChildInvitePipeline.php new file mode 100644 index 000000000..a67f4e444 --- /dev/null +++ b/app/Jobs/ParentalControlsPipeline/DispatchChildInvitePipeline.php @@ -0,0 +1,38 @@ +pc = $pc; + } + + /** + * Execute the job. + */ + public function handle(): void + { + $pc = $this->pc; + + Mail::to($pc->email)->send(new ParentChildInvite($pc)); + } +} diff --git a/app/Mail/ParentChildInvite.php b/app/Mail/ParentChildInvite.php new file mode 100644 index 000000000..843ea472d --- /dev/null +++ b/app/Mail/ParentChildInvite.php @@ -0,0 +1,49 @@ + + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/ParentalControls.php b/app/Models/ParentalControls.php new file mode 100644 index 000000000..83d47c18a --- /dev/null +++ b/app/Models/ParentalControls.php @@ -0,0 +1,55 @@ + 'array', + 'email_sent_at' => 'datetime', + 'email_verified_at' => 'datetime' + ]; + + protected $guarded = []; + + public function parent() + { + return $this->belongsTo(User::class, 'parent_id'); + } + + public function child() + { + return $this->belongsTo(User::class, 'child_id'); + } + + public function childAccount() + { + if($u = $this->child) { + if($u->profile_id) { + return AccountService::get($u->profile_id, true); + } else { + return []; + } + } else { + return []; + } + } + + public function manageUrl() + { + return url('/settings/parental-controls/manage/' . $this->id); + } + + public function inviteUrl() + { + return url('/auth/pci/' . $this->id . '/' . $this->verify_code); + } +} diff --git a/app/Services/UserRoleService.php b/app/Services/UserRoleService.php index 500a4666e..ed765a930 100644 --- a/app/Services/UserRoleService.php +++ b/app/Services/UserRoleService.php @@ -52,6 +52,13 @@ class UserRoleService 'can-follow' => false, 'can-make-public' => false, + + 'can-direct-message' => false, + 'can-use-stories' => false, + 'can-view-sensitive' => false, + 'can-bookmark' => false, + 'can-collections' => false, + 'can-federation' => false, ]; } @@ -114,6 +121,110 @@ class UserRoleService 'title' => 'Can make account public', 'action' => 'Allows the ability to make account public' ], + + 'can-direct-message' => [ + 'title' => '', + 'action' => '' + ], + 'can-use-stories' => [ + 'title' => '', + 'action' => '' + ], + 'can-view-sensitive' => [ + 'title' => '', + 'action' => '' + ], + 'can-bookmark' => [ + 'title' => '', + 'action' => '' + ], + 'can-collections' => [ + 'title' => '', + 'action' => '' + ], + 'can-federation' => [ + 'title' => '', + 'action' => '' + ], ]; } + + public static function mapInvite($id, $data = []) + { + $roles = self::get($id); + + $map = [ + 'account-force-private' => 'private', + 'account-ignore-follow-requests' => 'private', + + 'can-view-public-feed' => 'discovery_feeds', + 'can-view-network-feed' => 'discovery_feeds', + 'can-view-discover' => 'discovery_feeds', + 'can-view-hashtag-feed' => 'discovery_feeds', + + 'can-post' => 'post', + 'can-comment' => 'comment', + 'can-like' => 'like', + 'can-share' => 'share', + + 'can-follow' => 'follow', + 'can-make-public' => '!private', + + 'can-direct-message' => 'dms', + 'can-use-stories' => 'story', + 'can-view-sensitive' => '!hide_cw', + 'can-bookmark' => 'bookmark', + 'can-collections' => 'collection', + 'can-federation' => 'federation', + ]; + + foreach ($map as $key => $value) { + if(!isset($data[$value]) && !isset($data[substr($value, 1)])) { + $map[$key] = false; + continue; + } + $map[$key] = str_starts_with($value, '!') ? !$data[substr($value, 1)] : $data[$value]; + } + + return $map; + } + + public static function mapActions($id, $data = []) + { + $res = []; + $map = [ + 'account-force-private' => 'private', + 'account-ignore-follow-requests' => 'private', + + 'can-view-public-feed' => 'discovery_feeds', + 'can-view-network-feed' => 'discovery_feeds', + 'can-view-discover' => 'discovery_feeds', + 'can-view-hashtag-feed' => 'discovery_feeds', + + 'can-post' => 'post', + 'can-comment' => 'comment', + 'can-like' => 'like', + 'can-share' => 'share', + + 'can-follow' => 'follow', + 'can-make-public' => '!private', + + 'can-direct-message' => 'dms', + 'can-use-stories' => 'story', + 'can-view-sensitive' => '!hide_cw', + 'can-bookmark' => 'bookmark', + 'can-collections' => 'collection', + 'can-federation' => 'federation', + ]; + + foreach ($map as $key => $value) { + if(!isset($data[$value]) && !isset($data[substr($value, 1)])) { + $res[$key] = false; + continue; + } + $res[$key] = str_starts_with($value, '!') ? !$data[substr($value, 1)] : $data[$value]; + } + + return $res; + } } diff --git a/config/instance.php b/config/instance.php index 6357afe63..03f666a79 100644 --- a/config/instance.php +++ b/config/instance.php @@ -129,5 +129,15 @@ return [ 'banner' => [ 'blurhash' => env('INSTANCE_BANNER_BLURHASH', 'UzJR]l{wHZRjM}R%XRkCH?X9xaWEjZj]kAjt') - ] + ], + + 'parental_controls' => [ + 'enabled' => env('INSTANCE_PARENTAL_CONTROLS', false), + + 'limits' => [ + 'respect_open_registration' => env('INSTANCE_PARENTAL_CONTROLS_RESPECT_OPENREG', true), + 'max_children' => env('INSTANCE_PARENTAL_CONTROLS_MAX_CHILDREN', 1), + 'auto_verify_email' => true, + ], + ] ]; diff --git a/database/migrations/2023_12_27_082024_add_has_roles_to_users_table.php b/database/migrations/2023_12_27_082024_add_has_roles_to_users_table.php index 09246e37b..f32fe599c 100644 --- a/database/migrations/2023_12_27_082024_add_has_roles_to_users_table.php +++ b/database/migrations/2023_12_27_082024_add_has_roles_to_users_table.php @@ -24,9 +24,17 @@ return new class extends Migration public function down(): void { Schema::table('users', function (Blueprint $table) { - $table->dropColumn('has_roles'); - $table->dropColumn('parent_id'); - $table->dropColumn('role_id'); + if (Schema::hasColumn('users', 'has_roles')) { + $table->dropColumn('has_roles'); + } + + if (Schema::hasColumn('users', 'role_id')) { + $table->dropColumn('role_id'); + } + + if (Schema::hasColumn('users', 'parent_id')) { + $table->dropColumn('parent_id'); + } }); } }; diff --git a/database/migrations/2024_01_09_052419_create_parental_controls_table.php b/database/migrations/2024_01_09_052419_create_parental_controls_table.php new file mode 100644 index 000000000..4ef7fd2c7 --- /dev/null +++ b/database/migrations/2024_01_09_052419_create_parental_controls_table.php @@ -0,0 +1,45 @@ +id(); + $table->unsignedInteger('parent_id')->index(); + $table->unsignedInteger('child_id')->unique()->index()->nullable(); + $table->string('email')->unique()->nullable(); + $table->string('verify_code')->nullable(); + $table->timestamp('email_sent_at')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->json('permissions')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::table('user_roles', function (Blueprint $table) { + $table->dropIndex('user_roles_profile_id_unique'); + $table->unsignedBigInteger('profile_id')->unique()->nullable()->index()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('parental_controls'); + + Schema::table('user_roles', function (Blueprint $table) { + $table->dropIndex('user_roles_profile_id_unique'); + $table->unsignedBigInteger('profile_id')->unique()->index()->change(); + }); + } +}; diff --git a/resources/views/components/collapse.blade.php b/resources/views/components/collapse.blade.php new file mode 100644 index 000000000..144579366 --- /dev/null +++ b/resources/views/components/collapse.blade.php @@ -0,0 +1,12 @@ +@php +$cid = 'col' . str_random(6); +@endphp +

+ +

+ {{ $slot }} +
+

diff --git a/resources/views/emails/parental-controls/invite.blade.php b/resources/views/emails/parental-controls/invite.blade.php new file mode 100644 index 000000000..bece5b1bb --- /dev/null +++ b/resources/views/emails/parental-controls/invite.blade.php @@ -0,0 +1,18 @@ + +# You've been invited to join Pixelfed! + + +A parent account with the username **{{ $verify->parent->username }}** has invited you to join Pixelfed with a special youth account managed by them. + +If you do not recognize this account as your parents or a trusted guardian, please check with them first. + + + +Accept Invite + + +Thanks,
+Pixelfed + +This email is automatically generated. Please do not reply to this message. +
diff --git a/resources/views/errors/custom.blade.php b/resources/views/errors/custom.blade.php new file mode 100644 index 000000000..932292b6b --- /dev/null +++ b/resources/views/errors/custom.blade.php @@ -0,0 +1,10 @@ +@extends('layouts.app') + +@section('content') +
+
+

{!! $title ?? config('instance.page.404.header')!!}

+

{!! $body ?? config('instance.page.404.body')!!}

+
+
+@endsection diff --git a/resources/views/settings/email.blade.php b/resources/views/settings/email.blade.php index 7286896b1..4b7ddd677 100644 --- a/resources/views/settings/email.blade.php +++ b/resources/views/settings/email.blade.php @@ -1,63 +1,36 @@ -@extends('layouts.app') +@extends('settings.template') -@section('content') -@if (session('status')) -
- {{ session('status') }} -
-@endif -@if ($errors->any()) -
- @foreach($errors->all() as $error) -

{{ $error }}

- @endforeach -
-@endif -@if (session('error')) -
- {{ session('error') }} -
-@endif +@section('section') -
-
-
-
-
-
-
-

Email Settings

-
-
-
- @csrf - - - - -
- - -

- @if(Auth::user()->email_verified_at) - Verified {{Auth::user()->email_verified_at->diffForHumans()}} - @else - Unverified You need to verify your email. - @endif -

-
-
-
- -
-
-
-
-
-
+
+
+

+

Email Settings

-
+
+
+ @csrf + + + +
+ + +

+ @if(Auth::user()->email_verified_at) + Verified {{Auth::user()->email_verified_at->diffForHumans()}} + @else + Unverified You need to verify your email. + @endif +

+
+
+
+ +
+
+
@endsection diff --git a/resources/views/settings/parental-controls/add.blade.php b/resources/views/settings/parental-controls/add.blade.php new file mode 100644 index 000000000..b7ca4c7ab --- /dev/null +++ b/resources/views/settings/parental-controls/add.blade.php @@ -0,0 +1,59 @@ +@extends('settings.template-vue') + +@section('section') +
+ @csrf +
+
+
+

+

Add child

+
+
+ +
+ +
+

Choose your child's policies

+ +
+

Allowed Actions

+ + @include('settings.parental-controls.checkbox', ['name' => 'post', 'title' => 'Post', 'checked' => true]) + @include('settings.parental-controls.checkbox', ['name' => 'comment', 'title' => 'Comment', 'checked' => true]) + @include('settings.parental-controls.checkbox', ['name' => 'like', 'title' => 'Like', 'checked' => true]) + @include('settings.parental-controls.checkbox', ['name' => 'share', 'title' => 'Share', 'checked' => true]) + @include('settings.parental-controls.checkbox', ['name' => 'follow', 'title' => 'Follow']) + @include('settings.parental-controls.checkbox', ['name' => 'bookmark', 'title' => 'Bookmark']) + @include('settings.parental-controls.checkbox', ['name' => 'story', 'title' => 'Add to story']) + @include('settings.parental-controls.checkbox', ['name' => 'collection', 'title' => 'Add to collection']) +
+
+

Enabled features

+ + @include('settings.parental-controls.checkbox', ['name' => 'discovery_feeds', 'title' => 'Discovery Feeds']) + @include('settings.parental-controls.checkbox', ['name' => 'dms', 'title' => 'Direct Messages']) + @include('settings.parental-controls.checkbox', ['name' => 'federation', 'title' => 'Federation']) +
+
+

Preferences

+ + @include('settings.parental-controls.checkbox', ['name' => 'hide_network', 'title' => 'Hide my child\'s connections']) + @include('settings.parental-controls.checkbox', ['name' => 'private', 'title' => 'Make my child\'s account private']) + @include('settings.parental-controls.checkbox', ['name' => 'hide_cw', 'title' => 'Hide sensitive media']) +
+
+ +
+
+ +

Where should we send this invite?

+ +
+ + +
+
+
+@endsection + diff --git a/resources/views/settings/parental-controls/checkbox.blade.php b/resources/views/settings/parental-controls/checkbox.blade.php new file mode 100644 index 000000000..b6cedbe92 --- /dev/null +++ b/resources/views/settings/parental-controls/checkbox.blade.php @@ -0,0 +1,7 @@ +@php +$id = str_random(6) . '_' . str_slug($name); +$defaultChecked = isset($checked) && $checked ? 'checked=""' : ''; +@endphp
+ + +
diff --git a/resources/views/settings/parental-controls/child-status.blade.php b/resources/views/settings/parental-controls/child-status.blade.php new file mode 100644 index 000000000..9852dacd7 --- /dev/null +++ b/resources/views/settings/parental-controls/child-status.blade.php @@ -0,0 +1,44 @@ +@if($state) +
+ @if($state === 'sent_invite') +
+ +

Child Invite Sent!

+ +
+
Created child invite
+
Sent invite email to child
+
Child joined via invite
+
Child account is active
+
+
+ @elseif($state === 'awaiting_email_confirmation') +
+ +

Child Invite Sent!

+ +
+
Created child invite
+
Sent invite email to child
+
Child joined via invite
+
Child account is active
+
+
+ @elseif($state === 'active') +
+ +

Child Account Active

+ +
+
Created child invite
+
Sent invite email to child
+
Child joined via invite
+
Child account is active
+
+ + View Account +
+ @endif +
+@else +@endif diff --git a/resources/views/settings/parental-controls/delete-invite.blade.php b/resources/views/settings/parental-controls/delete-invite.blade.php new file mode 100644 index 000000000..36b66ab15 --- /dev/null +++ b/resources/views/settings/parental-controls/delete-invite.blade.php @@ -0,0 +1,32 @@ +@extends('settings.template-vue') + +@section('section') +
+ @csrf +
+
+
+

+
+

Cancel child invite

+

Last updated: {{ $pc->updated_at->diffForHumans() }}

+
+
+
+
+
+
+ +
+

+ +

+

Are you sure you want to cancel this invite?

+

The child you invited will not be able to join if you cancel the invite.

+
+ + +
+
+ +@endsection diff --git a/resources/views/settings/parental-controls/index.blade.php b/resources/views/settings/parental-controls/index.blade.php new file mode 100644 index 000000000..a99093f33 --- /dev/null +++ b/resources/views/settings/parental-controls/index.blade.php @@ -0,0 +1,62 @@ +@extends('settings.template-vue') + +@section('section') +
+
+
+

+

Parental Controls

+
+
+ +
+ + @if($children->count()) + + @else +
+

You are not managing any children accounts.

+
+ @endif + +
+ + Add Child + + +
+ {{ $children->total() }}/{{ config('instance.parental_controls.limits.max_children') }} + children added +
+
+ +
+@endsection + diff --git a/resources/views/settings/parental-controls/invite-register-form.blade.php b/resources/views/settings/parental-controls/invite-register-form.blade.php new file mode 100644 index 000000000..5b894e8d2 --- /dev/null +++ b/resources/views/settings/parental-controls/invite-register-form.blade.php @@ -0,0 +1,115 @@ +@extends('layouts.app') + +@section('content') +
+
+
+ +
+
Create your Account
+ +
+
+ @csrf + + +
+
+ + + + @if ($errors->has('name')) + + {{ $errors->first('name') }} + + @endif +
+
+ +
+
+ + + + @if ($errors->has('username')) + + {{ $errors->first('username') }} + + @endif +
+
+ +
+
+ + + + @if ($errors->has('password')) + + {{ $errors->first('password') }} + + @endif +
+
+ +
+
+ + +
+
+ +
+
+
+ + +
+
+
+ + @if(config('captcha.enabled') || config('captcha.active.register')) +
+ {!! Captcha::display() !!} +
+ @endif + +

By signing up, you agree to our Terms of Use and Privacy Policy, in addition, you understand that your account is managed by {{ $pc->parent->username }} and they can limit your account without your permission. For more details, view the Parental Controls help center page.

+ +
+
+ +
+
+
+
+
+
+
+
+@endsection diff --git a/resources/views/settings/parental-controls/manage.blade.php b/resources/views/settings/parental-controls/manage.blade.php new file mode 100644 index 000000000..a6cc62119 --- /dev/null +++ b/resources/views/settings/parental-controls/manage.blade.php @@ -0,0 +1,119 @@ +@extends('settings.template-vue') + +@section('section') +
+ @csrf +
+
+
+

+
+

Manage child

+

Last updated: {{ $pc->updated_at->diffForHumans() }}

+
+
+ + +
+ +
+ +
+ +
+
+
+
+
+ @if(!$pc->child_id && !$pc->email_verified_at) + @include('settings.parental-controls.child-status', ['state' => 'sent_invite']) + @elseif($pc->child_id && !$pc->email_verified_at) + @include('settings.parental-controls.child-status', ['state' => 'awaiting_email_confirmation']) + @elseif($pc->child_id && $pc->email_verified_at) + @include('settings.parental-controls.child-status', ['state' => 'active']) + @else + @include('settings.parental-controls.child-status', ['state' => 'sent_invite']) + @endif +
+
+
+

Allowed Actions

+ + @include('settings.parental-controls.checkbox', ['name' => 'post', 'title' => 'Post', 'checked' => $pc->permissions['post']]) + @include('settings.parental-controls.checkbox', ['name' => 'comment', 'title' => 'Comment', 'checked' => $pc->permissions['comment']]) + @include('settings.parental-controls.checkbox', ['name' => 'like', 'title' => 'Like', 'checked' => $pc->permissions['like']]) + @include('settings.parental-controls.checkbox', ['name' => 'share', 'title' => 'Share', 'checked' => $pc->permissions['share']]) + @include('settings.parental-controls.checkbox', ['name' => 'follow', 'title' => 'Follow', 'checked' => $pc->permissions['follow']]) + @include('settings.parental-controls.checkbox', ['name' => 'bookmark', 'title' => 'Bookmark', 'checked' => $pc->permissions['bookmark']]) + @include('settings.parental-controls.checkbox', ['name' => 'story', 'title' => 'Add to story', 'checked' => $pc->permissions['story']]) + @include('settings.parental-controls.checkbox', ['name' => 'collection', 'title' => 'Add to collection', 'checked' => $pc->permissions['collection']]) +
+
+

Enabled features

+ + @include('settings.parental-controls.checkbox', ['name' => 'discovery_feeds', 'title' => 'Discovery Feeds', 'checked' => $pc->permissions['discovery_feeds']]) + @include('settings.parental-controls.checkbox', ['name' => 'dms', 'title' => 'Direct Messages', 'checked' => $pc->permissions['dms']]) + @include('settings.parental-controls.checkbox', ['name' => 'federation', 'title' => 'Federation', 'checked' => $pc->permissions['federation']]) +
+
+

Preferences

+ + @include('settings.parental-controls.checkbox', ['name' => 'hide_network', 'title' => 'Hide my child\'s connections', 'checked' => $pc->permissions['hide_network']]) + @include('settings.parental-controls.checkbox', ['name' => 'private', 'title' => 'Make my child\'s account private', 'checked' => $pc->permissions['private']]) + @include('settings.parental-controls.checkbox', ['name' => 'hide_cw', 'title' => 'Hide sensitive media', 'checked' => $pc->permissions['hide_cw']]) +
+
+
+
+
+ + +
+
+
+
+
+ @if(!$pc->child_id && !$pc->email_verified_at) +
+

Cancel Invite

+

Cancel the child invite and prevent it from being used.

+ Cancel Invite +
+ @else +
+

Stop Managing

+

Transition account to a regular account without parental controls.

+ Stop Managing Child +
+ @endif +
+
+
+
+ +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/settings/parental-controls/stop-managing.blade.php b/resources/views/settings/parental-controls/stop-managing.blade.php new file mode 100644 index 000000000..747339fd8 --- /dev/null +++ b/resources/views/settings/parental-controls/stop-managing.blade.php @@ -0,0 +1,32 @@ +@extends('settings.template-vue') + +@section('section') +
+ @csrf +
+
+
+

+
+

Stop Managing Child

+

Last updated: {{ $pc->updated_at->diffForHumans() }}

+
+
+
+
+
+
+ +
+

+ +

+

Confirm Stop Managing this Account?

+

This child account will be transitioned to a regular account without any limitations.

+
+ + +
+
+ +@endsection diff --git a/resources/views/settings/partial/sidebar.blade.php b/resources/views/settings/partial/sidebar.blade.php index a3837066a..b971e1f5d 100644 --- a/resources/views/settings/partial/sidebar.blade.php +++ b/resources/views/settings/partial/sidebar.blade.php @@ -1,79 +1,80 @@ -
- +
+ + @push('styles') + + @endpush diff --git a/resources/views/site/help/parental-controls.blade.php b/resources/views/site/help/parental-controls.blade.php new file mode 100644 index 000000000..d7b9710dd --- /dev/null +++ b/resources/views/site/help/parental-controls.blade.php @@ -0,0 +1,47 @@ +@extends('site.help.partial.template', ['breadcrumb'=>'Parental Controls']) + +@section('section') +
+

Parental Controls

+
+
+ +

In the digital age, ensuring your children's online safety is paramount. Designed with both fun and safety in mind, this feature allows parents to create child accounts, tailor-made for a worry-free social media experience.

+ +

Key Features:

+ + +
+ + +
+ @if(config('instance.parental_controls.enabled')) +
    +
  1. Click here and tap on the Add Child button in the bottom left corner
  2. +
  3. Select the Allowed Actions, Enabled features and Preferences
  4. +
  5. Enter your childs email address
  6. +
  7. Press the Add Child buttton
  8. +
  9. Open your childs email and tap on the Accept Invite button in the email, ensure your parent username is present in the email
  10. +
  11. Fill out the child display name, username and password
  12. +
  13. Press Register and your child account will be active!
  14. +
+ @else +

This feature has been disabled by server admins.

+ @endif +
+
+ +@if(config('instance.parental_controls.enabled')) + +
+ You can create and manage up to {{ config('instance.parental_controls.limits.max_children') }} child accounts. +
+
+@endif +@endsection diff --git a/routes/web.php b/routes/web.php index b8149a605..e71e6fd9e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -200,6 +200,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::post('auth/raw/mastodon/s/account-to-id', 'RemoteAuthController@accountToId'); Route::post('auth/raw/mastodon/s/finish-up', 'RemoteAuthController@finishUp'); Route::post('auth/raw/mastodon/s/login', 'RemoteAuthController@handleLogin'); + Route::get('auth/pci/{id}/{code}', 'ParentalControlsController@inviteRegister'); + Route::post('auth/pci/{id}/{code}', 'ParentalControlsController@inviteRegisterStore'); Route::get('discover', 'DiscoverController@home')->name('discover'); @@ -534,6 +536,16 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact }); + Route::get('parental-controls', 'ParentalControlsController@index')->name('settings.parental-controls')->middleware('dangerzone'); + Route::get('parental-controls/add', 'ParentalControlsController@add')->name('settings.pc.add')->middleware('dangerzone'); + Route::post('parental-controls/add', 'ParentalControlsController@store')->middleware('dangerzone'); + Route::get('parental-controls/manage/{id}', 'ParentalControlsController@view')->middleware('dangerzone'); + Route::post('parental-controls/manage/{id}', 'ParentalControlsController@update')->middleware('dangerzone'); + Route::get('parental-controls/manage/{id}/cancel-invite', 'ParentalControlsController@cancelInvite')->name('settings.pc.cancel-invite')->middleware('dangerzone'); + Route::post('parental-controls/manage/{id}/cancel-invite', 'ParentalControlsController@cancelInviteHandle')->middleware('dangerzone'); + Route::get('parental-controls/manage/{id}/stop-managing', 'ParentalControlsController@stopManaging')->name('settings.pc.stop-managing')->middleware('dangerzone'); + Route::post('parental-controls/manage/{id}/stop-managing', 'ParentalControlsController@stopManagingHandle')->middleware('dangerzone'); + Route::get('applications', 'SettingsController@applications')->name('settings.applications')->middleware('dangerzone'); Route::get('data-export', 'SettingsController@dataExport')->name('settings.dataexport')->middleware('dangerzone'); Route::post('data-export/following', 'SettingsController@exportFollowing')->middleware('dangerzone'); @@ -618,6 +630,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::view('licenses', 'site.help.licenses')->name('help.licenses'); Route::view('instance-max-users-limit', 'site.help.instance-max-users')->name('help.instance-max-users-limit'); Route::view('import', 'site.help.import')->name('help.import'); + Route::view('parental-controls', 'site.help.parental-controls'); }); Route::get('newsroom/{year}/{month}/{slug}', 'NewsroomController@show'); Route::get('newsroom/archive', 'NewsroomController@archive');