Browse Source

Merge pull request #4862 from pixelfed/staging

Add Parental Controls feature
daniel 1 year ago
parent
commit
187d1e1af9
33 changed files with 4156 additions and 2960 deletions
  1. 1 0
      CHANGELOG.md
  2. 23 1
      app/Http/Controllers/Api/ApiV1Controller.php
  3. 290 281
      app/Http/Controllers/Api/ApiV2Controller.php
  4. 2 2
      app/Http/Controllers/Auth/RegisterController.php
  5. 49 53
      app/Http/Controllers/BookmarkController.php
  6. 760 740
      app/Http/Controllers/ComposeController.php
  7. 862 836
      app/Http/Controllers/DirectMessageController.php
  8. 231 0
      app/Http/Controllers/ParentalControlsController.php
  9. 185 185
      app/Http/Controllers/Settings/HomeSettings.php
  10. 456 445
      app/Http/Controllers/StoryComposeController.php
  11. 301 281
      app/Http/Controllers/StoryController.php
  12. 38 0
      app/Jobs/ParentalControlsPipeline/DispatchChildInvitePipeline.php
  13. 49 0
      app/Mail/ParentChildInvite.php
  14. 55 0
      app/Models/ParentalControls.php
  15. 111 0
      app/Services/UserRoleService.php
  16. 11 1
      config/instance.php
  17. 11 3
      database/migrations/2023_12_27_082024_add_has_roles_to_users_table.php
  18. 45 0
      database/migrations/2024_01_09_052419_create_parental_controls_table.php
  19. 12 0
      resources/views/components/collapse.blade.php
  20. 18 0
      resources/views/emails/parental-controls/invite.blade.php
  21. 10 0
      resources/views/errors/custom.blade.php
  22. 29 56
      resources/views/settings/email.blade.php
  23. 59 0
      resources/views/settings/parental-controls/add.blade.php
  24. 7 0
      resources/views/settings/parental-controls/checkbox.blade.php
  25. 44 0
      resources/views/settings/parental-controls/child-status.blade.php
  26. 32 0
      resources/views/settings/parental-controls/delete-invite.blade.php
  27. 62 0
      resources/views/settings/parental-controls/index.blade.php
  28. 115 0
      resources/views/settings/parental-controls/invite-register-form.blade.php
  29. 119 0
      resources/views/settings/parental-controls/manage.blade.php
  30. 32 0
      resources/views/settings/parental-controls/stop-managing.blade.php
  31. 77 76
      resources/views/settings/partial/sidebar.blade.php
  32. 47 0
      resources/views/site/help/parental-controls.blade.php
  33. 13 0
      routes/web.php

+ 1 - 0
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))

+ 23 - 1
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);

+ 290 - 281
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;
-		});
-
-		$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
-		];
-
-    	return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
+        $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() : [];
+        });
+
+        $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);
+    }
+
+    /**
+     * 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'
+        ]);
+
+        if($request->user()->has_roles && !UserRoleService::can('can-view-discover', $request->user()->id)) {
+            return [
+                'accounts' => [],
+                'hashtags' => [],
+                'statuses' => []
+            ];
+        }
+
+        $mastodonMode = !$request->has('_pe');
+        return $this->json(SearchApiV2Service::query($request, $mastodonMode));
     }
 
-	/**
-	 * 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'
-		]);
-
-		$mastodonMode = !$request->has('_pe');
-		return $this->json(SearchApiV2Service::query($request, $mastodonMode));
-	}
-
-	/**
-	 * 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')
-		] : [];
-	}
-
-	/**
-	 * POST /api/v2/media
-	 *
-	 *
-	 * @return MediaTransformer
-	 */
-	public function mediaUploadV2(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',
-		  'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length'),
-		  'replace_id' => 'sometimes'
-		]);
-
-		$user = $request->user();
-
-		if($user->last_active_at == null) {
-			return [];
-		}
-
-		if(empty($request->file('file'))) {
-			return response('', 422);
-		}
-
-		$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;
-		});
-		abort_if($limitReached == true, 429);
-
-		$profile = $user->profile;
-
-		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;
-
-		$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);
-		$path = $photo->storePublicly($storagePath);
-		$hash = \hash_file('sha256', $photo);
-		$license = null;
-		$mime = $photo->getMimeType();
-
-		$settings = UserSetting::whereUserId($user->id)->first();
-
-		if($settings && !empty($settings->compose_settings)) {
-			$compose = $settings->compose_settings;
-
-			if(isset($compose['default_license']) && $compose['default_license'] != 1) {
-				$license = $compose['default_license'];
-			}
-		}
-
-		abort_if(MediaBlocklistService::exists($hash) == true, 451);
-
-		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));
-			}
-		}
-
-		$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();
-
-		switch ($media->mime) {
-			case 'image/jpeg':
-			case 'image/png':
-				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;
-		}
-
-		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);
-	}
+    /**
+     * 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')
+        ] : [];
+    }
+
+    /**
+     * POST /api/v2/media
+     *
+     *
+     * @return MediaTransformer
+     */
+    public function mediaUploadV2(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',
+          'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length'),
+          'replace_id' => 'sometimes'
+        ]);
+
+        $user = $request->user();
+
+        if($user->last_active_at == null) {
+            return [];
+        }
+
+        if(empty($request->file('file'))) {
+            return response('', 422);
+        }
+
+        $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;
+        });
+        abort_if($limitReached == true, 429);
+
+        $profile = $user->profile;
+
+        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;
+
+        $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);
+        $path = $photo->storePublicly($storagePath);
+        $hash = \hash_file('sha256', $photo);
+        $license = null;
+        $mime = $photo->getMimeType();
+
+        $settings = UserSetting::whereUserId($user->id)->first();
+
+        if($settings && !empty($settings->compose_settings)) {
+            $compose = $settings->compose_settings;
+
+            if(isset($compose['default_license']) && $compose['default_license'] != 1) {
+                $license = $compose['default_license'];
+            }
+        }
+
+        abort_if(MediaBlocklistService::exists($hash) == true, 451);
+
+        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));
+            }
+        }
+
+        $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();
+
+        switch ($media->mime) {
+            case 'image/jpeg':
+            case 'image/png':
+                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;
+        }
+
+        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);
+    }
 }

+ 2 - 2
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']);

+ 49 - 53
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 store(Request $request)
-	{
-		$this->validate($request, [
-			'item' => 'required|integer|min:1',
-		]);
-
-		$profile = Auth::user()->profile;
-		$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);
-
-		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 ($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]
-		);
-
-		if (!$bookmark->wasRecentlyCreated) {
-			BookmarkService::del($profile->id, $status->id);
-			$bookmark->delete();
-		} else {
-			BookmarkService::add($profile->id, $status->id);
-		}
-
-		if ($request->ajax()) {
-			$response = ['code' => 200, 'msg' => 'Bookmark saved!'];
-		} else {
-			$response = redirect()->back();
-		}
-
-		return $response;
-	}
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    public function store(Request $request)
+    {
+        $this->validate($request, [
+            'item' => 'required|integer|min:1',
+        ]);
+
+        $user = $request->user();
+        $status = Status::findOrFail($request->input('item'));
+
+        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($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.');
+            }
+        }
+
+        $bookmark = Bookmark::firstOrCreate(
+            ['status_id' => $status->id], ['profile_id' => $user->profile_id]
+        );
+
+        if (!$bookmark->wasRecentlyCreated) {
+            BookmarkService::del($user->profile_id, $status->id);
+            $bookmark->delete();
+        } else {
+            BookmarkService::add($user->profile_id, $status->id);
+        }
+
+        return $request->expectsJson() ? ['code' => 200, 'msg' => 'Bookmark saved!'] : redirect()->back();
+    }
 }

+ 760 - 740
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;
-
-	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 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'
-		]);
-
-		$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();
-
-			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.');
-			}
-		}
-
-		$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');
-
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-
-		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();
-
-		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();
-
-		$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;
-
-			case 'video/mp4':
-			VideoThumbnail::dispatch($media)->onQueue('mmo');
-			$preview_url = '/storage/no-preview.png';
-			$url = '/storage/no-preview.png';
-			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);
-	}
-
-	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');
-
-		$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;
-		});
-
-		abort_if($limitReached == true, 429);
-
-		$photo = $request->file('file');
-		$id = $request->input('id');
-
-		$media = Media::whereUserId($user->id)
-		->whereProfileId($user->profile_id)
-		->whereNull('status_id')
-		->findOrFail($id);
-
-		$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;
-	}
-
-	public function mediaDelete(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'id' => 'required|integer|min:1|exists:media,id'
-		]);
-
-		$media = Media::whereNull('status_id')
-		->whereUserId(Auth::id())
-		->findOrFail($request->input('id'));
-
-		MediaStorageService::delete($media, true);
-
-		return response()->json([
-			'msg' => 'Successfully deleted',
-			'code' => 200
-		]);
-	}
-
-	public function searchTag(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'q' => 'required|string|min:1|max:50'
-		]);
-
-		$q = $request->input('q');
-
-		if(Str::of($q)->startsWith('@')) {
-			if(strlen($q) < 3) {
-				return [];
-			}
-			$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)
-			->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;
-	}
+    protected $fractal;
+
+    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 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'
+        ]);
+
+        $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();
+
+            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.');
+            }
+        }
+
+        $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');
+
+        $mimes = explode(',', config_cache('pixelfed.media_types'));
+
+        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();
+
+        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();
+
+        $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;
+
+            case 'video/mp4':
+            VideoThumbnail::dispatch($media)->onQueue('mmo');
+            $preview_url = '/storage/no-preview.png';
+            $url = '/storage/no-preview.png';
+            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);
+    }
+
+    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');
+
+        $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;
+        });
+
+        abort_if($limitReached == true, 429);
+
+        $photo = $request->file('file');
+        $id = $request->input('id');
+
+        $media = Media::whereUserId($user->id)
+        ->whereProfileId($user->profile_id)
+        ->whereNull('status_id')
+        ->findOrFail($id);
+
+        $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;
+    }
+
+    public function mediaDelete(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+            'id' => 'required|integer|min:1|exists:media,id'
+        ]);
+
+        abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $media = Media::whereNull('status_id')
+        ->whereUserId(Auth::id())
+        ->findOrFail($request->input('id'));
+
+        MediaStorageService::delete($media, true);
+
+        return response()->json([
+            'msg' => 'Successfully deleted',
+            'code' => 200
+        ]);
+    }
+
+    public function searchTag(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+            'q' => 'required|string|min:1|max:50'
+        ]);
+
+        $q = $request->input('q');
+
+        if(Str::of($q)->startsWith('@')) {
+            if(strlen($q) < 3) {
+                return [];
+            }
+            $q = mb_substr($q, 1);
+        }
+
+        abort_if($user->has_roles && !UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action');
+
+        $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)
+            ->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'));
-
-		$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;
-	}
-
-	public function searchMentionAutocomplete(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'q' => 'required|string|min:2|max:50'
-		]);
-
-		$q = $request->input('q');
-
-		if(Str::of($q)->startsWith('@')) {
-			if(strlen($q) < 3) {
-				return [];
-			}
-		}
-
-		$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.'%')
-			->groupBy('id', 'domain')
-			->limit(15)
-			->get()
-			->map(function($profile) {
-				$username = $profile->domain ? substr($profile->username, 1) : $profile->username;
+    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';
+
+        $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);
+
+        $this->validate($request, [
+            'q' => 'required|string|min:2|max:50'
+        ]);
+
+        abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $q = $request->input('q');
+
+        if(Str::of($q)->startsWith('@')) {
+            if(strlen($q) < 3) {
+                return [];
+            }
+        }
+
+        $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.'%')
+            ->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;
-	}
-
-	public function searchHashtagAutocomplete(Request $request)
-	{
-		abort_if(!$request->user(), 403);
-
-		$this->validate($request, [
-			'q' => 'required|string|min:2|max:50'
-		]);
-
-		$q = $request->input('q');
-
-		$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
-				];
-		});
-
-		return $results;
-	}
-
-	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'
-		]);
-
-		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');
-					}
-				}
-			}
-		}
-
-		$user = Auth::user();
-		$profile = $user->profile;
-
-		$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();
-
-			return $dailyLimit >= 1000;
-		});
-
-		abort_if($limitReached == true, 429);
-
-		$license = in_array($request->input('license'), License::keys()) ? $request->input('license') : null;
-
-		$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');
-
-		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;
-
-			if($cw == true || $profile->cw == true) {
-				$m->is_nsfw = $cw;
-				$status->is_nsfw = $cw;
-			}
-			$m->save();
-			$attachments[] = $m;
-			array_push($mimes, $m->mime);
-		}
-
-		abort_if(empty($attachments), 422);
-
-		$mediaType = StatusController::mimeTypeCheck($mimes);
-
-		if(in_array($mediaType, ['photo', 'video', 'photo:album']) == false) {
-			abort(400, __('exception.compose.invalid.album'));
-		}
-
-		if($place && is_array($place)) {
-			$status->place_id = $place['id'];
-		}
-
-		if($request->filled('comments_disabled')) {
-			$status->comments_disabled = (bool) $request->input('comments_disabled');
-		}
-
-		if($request->filled('spoiler_text') && $cw) {
-			$status->cw_summary = $request->input('spoiler_text');
-		}
-
-		$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($attachments as $media) {
-			$media->status_id = $status->id;
-			$media->save();
-		}
-
-		$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();
-
-		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);
-		}
-
-		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();
+        });
+
+        return $results;
+    }
+
+    public function searchHashtagAutocomplete(Request $request)
+    {
+        abort_if(!$request->user(), 403);
+
+        $this->validate($request, [
+            'q' => 'required|string|min:2|max:50'
+        ]);
+
+        abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $q = $request->input('q');
+
+        $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
+                ];
+        });
+
+        return $results;
+    }
+
+    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'
+        ]);
+
+        abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        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');
+                    }
+                }
+            }
+        }
+
+        $user = $request->user();
+        $profile = $user->profile;
+
+        $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();
+
+            return $dailyLimit >= 1000;
+        });
+
+        abort_if($limitReached == true, 429);
+
+        $license = in_array($request->input('license'), License::keys()) ? $request->input('license') : null;
+
+        $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');
+
+        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;
+
+            if($cw == true || $profile->cw == true) {
+                $m->is_nsfw = $cw;
+                $status->is_nsfw = $cw;
+            }
+            $m->save();
+            $attachments[] = $m;
+            array_push($mimes, $m->mime);
+        }
+
+        abort_if(empty($attachments), 422);
+
+        $mediaType = StatusController::mimeTypeCheck($mimes);
+
+        if(in_array($mediaType, ['photo', 'video', 'photo:album']) == false) {
+            abort(400, __('exception.compose.invalid.album'));
+        }
+
+        if($place && is_array($place)) {
+            $status->place_id = $place['id'];
+        }
+
+        if($request->filled('comments_disabled')) {
+            $status->comments_disabled = (bool) $request->input('comments_disabled');
+        }
+
+        if($request->filled('spoiler_text') && $cw) {
+            $status->cw_summary = $request->input('spoiler_text');
+        }
+
+        $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($attachments as $media) {
+            $media->status_id = $status->id;
+            $media->save();
+        }
+
+        $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();
+
+        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);
+        }
+
+        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);
-
-		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',
-		]);
-
-		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');
-					}
-				}
-			}
-		}
-
-		$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($place && is_array($place)) {
-			$status->place_id = $place['id'];
-		}
-
-		if($request->filled('comments_disabled')) {
-			$status->comments_disabled = (bool) $request->input('comments_disabled');
-		}
-
-		$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);
-
-		return $status->url();
-	}
-
-	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'));
-
-		if(config('pixelfed.media_fast_process')) {
-			return [
-				'finished' => true
-			];
-		}
-
-		$finished = false;
-
-		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;
-
-			default:
-				# code...
-				break;
-		}
-
-		return [
-			'finished' => $finished
-		];
-	}
-
-	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 array_merge($default, $settings['compose_settings']);
-	}
-
-	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_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled');
-
-		abort_if(Status::whereType('poll')
-			->whereProfileId($request->user()->profile_id)
-			->whereCaption($request->input('caption'))
-			->where('created_at', '>', now()->subDays(2))
-			->exists()
-		, 422, 'Duplicate detected.');
-
-		$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();
-
-		$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->visibility = $request->input('visibility');
-		$status->scope = $request->input('visibility');
-		$status->save();
-
-		NewStatusPipeline::dispatch($status);
-
-		return ['url' => $status->url()];
-	}
+                });
+        }
+
+        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();
+    }
+
+    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',
+        ]);
+
+        abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        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');
+                    }
+                }
+            }
+        }
+
+        $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($place && is_array($place)) {
+            $status->place_id = $place['id'];
+        }
+
+        if($request->filled('comments_disabled')) {
+            $status->comments_disabled = (bool) $request->input('comments_disabled');
+        }
+
+        $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);
+
+        return $status->url();
+    }
+
+    public function mediaProcessingCheck(Request $request)
+    {
+        $this->validate($request, [
+            'id' => 'required|integer|min:1'
+        ]);
+
+        abort_if($request->user()->has_roles && !UserRoleService::can('can-post', $request->user()->id), 403, 'Invalid permissions for this action');
+
+        $media = Media::whereUserId($request->user()->id)
+            ->whereNull('status_id')
+            ->findOrFail($request->input('id'));
+
+        if(config('pixelfed.media_fast_process')) {
+            return [
+                'finished' => true
+            ];
+        }
+
+        $finished = false;
+
+        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;
+
+            default:
+                # code...
+                break;
+        }
+
+        return [
+            'finished' => $finished
+        ];
+    }
+
+    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');
+
+        $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 array_merge($default, $settings['compose_settings']);
+    }
+
+    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');
+
+        abort_if(Status::whereType('poll')
+            ->whereProfileId($request->user()->profile_id)
+            ->whereCaption($request->input('caption'))
+            ->where('created_at', '>', now()->subDays(2))
+            ->exists()
+        , 422, 'Duplicate detected.');
+
+        $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();
+
+        $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->visibility = $request->input('visibility');
+        $status->scope = $request->input('visibility');
+        $status->save();
+
+        NewStatusPipeline::dispatch($status);
+
+        return ['url' => $status->url()];
+    }
 }

+ 862 - 836
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');
+    }
 }

+ 231 - 0
app/Http/Controllers/ParentalControlsController.php

@@ -0,0 +1,231 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\ParentalControls;
+use App\Models\UserRoles;
+use App\Profile;
+use App\User;
+use App\Http\Controllers\Auth\RegisterController;
+use Illuminate\Auth\Events\Registered;
+use Illuminate\Support\Facades\Auth;
+use App\Services\UserRoleService;
+use App\Jobs\ParentalControlsPipeline\DispatchChildInvitePipeline;
+
+class ParentalControlsController extends Controller
+{
+    public function authPreflight($request, $maxUserCheck = false, $authCheck = true)
+    {
+        if($authCheck) {
+            abort_unless($request->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;
+    }
+}

+ 185 - 185
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);
-
-		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'
-		]);
-
-		$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');
-
-		// 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 (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($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!');
-		}
-
-		return redirect('/settings/home');
-	}
-
-	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',
-	  ]);
-
-		$current = $request->input('current');
-		$new = $request->input('password');
-		$confirm = $request->input('password_confirmation');
-
-		$user = Auth::user();
-
-		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();
-
-			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 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');
-
-		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();
-			}
-
-			$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();
-
-			return redirect('/settings/home')->with('status', 'Email successfully updated!');
-		} else {
-			return redirect('/settings/email');
-		}
-
-	}
-
-	public function avatar()
-	{
-		return view('settings.avatar');
-	}
-
+    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'));
+    }
+
+    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;
+        }
+
+        $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;
+            }
+
+            if ($profile->website != $website) {
+                $changes = true;
+                $profile->website = $website;
+            }
+
+            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($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!');
+        }
+
+        return redirect('/settings/home');
+    }
+
+    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',
+      ]);
+
+        $current = $request->input('current');
+        $new = $request->input('password');
+        $confirm = $request->input('password_confirmation');
+
+        $user = Auth::user();
+
+        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();
+
+            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 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');
+
+        if ($user->email != $email) {
+            $changes = true;
+            $user->email = $email;
+
+            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();
+        }
+
+        if ($changes === true) {
+            Cache::forget('user:account:id:'.$user->id);
+            $user->save();
+            $profile->save();
+
+            return redirect('/settings/email')->with('status', 'Email successfully updated!');
+        } else {
+            return redirect('/settings/email');
+        }
+
+    }
+
+    public function avatar()
+    {
+        return view('settings.avatar');
+    }
 }

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

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

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

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

+ 38 - 0
app/Jobs/ParentalControlsPipeline/DispatchChildInvitePipeline.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Jobs\ParentalControlsPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\ParentalControls;
+use App\Mail\ParentChildInvite;
+use Illuminate\Support\Facades\Mail;
+
+class DispatchChildInvitePipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public $pc;
+
+    /**
+     * Create a new job instance.
+     */
+    public function __construct(ParentalControls $pc)
+    {
+        $this->pc = $pc;
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle(): void
+    {
+        $pc = $this->pc;
+
+        Mail::to($pc->email)->send(new ParentChildInvite($pc));
+    }
+}

+ 49 - 0
app/Mail/ParentChildInvite.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Mail;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Mail\Mailable;
+use Illuminate\Mail\Mailables\Content;
+use Illuminate\Mail\Mailables\Envelope;
+use Illuminate\Queue\SerializesModels;
+
+class ParentChildInvite extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    public function __construct(
+        public $verify,
+    ) {}
+
+    /**
+     * Get the message envelope.
+     */
+    public function envelope(): Envelope
+    {
+        return new Envelope(
+            subject: 'You\'ve been invited to join Pixelfed!',
+        );
+    }
+
+    /**
+     * Get the message content definition.
+     */
+    public function content(): Content
+    {
+        return new Content(
+            markdown: 'emails.parental-controls.invite',
+        );
+    }
+
+    /**
+     * Get the attachments for the message.
+     *
+     * @return array<int, \Illuminate\Mail\Mailables\Attachment>
+     */
+    public function attachments(): array
+    {
+        return [];
+    }
+}

+ 55 - 0
app/Models/ParentalControls.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+use App\User;
+use App\Services\AccountService;
+
+class ParentalControls extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    protected $casts = [
+        'permissions' => '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);
+    }
+}

+ 111 - 0
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;
     }
 }

+ 11 - 1
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,
+        ],
+    ]
 ];

+ 11 - 3
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');
+            }
         });
     }
 };

+ 45 - 0
database/migrations/2024_01_09_052419_create_parental_controls_table.php

@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('parental_controls', function (Blueprint $table) {
+            $table->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();
+        });
+    }
+};

+ 12 - 0
resources/views/components/collapse.blade.php

@@ -0,0 +1,12 @@
+@php
+$cid = 'col' . str_random(6);
+@endphp
+<p>
+  <a class="text-dark font-weight-bold" data-toggle="collapse" href="#{{$cid}}" role="button" aria-expanded="false" aria-controls="{{$cid}}">
+    <i class="fas fa-chevron-down mr-2"></i>
+    {{ $title }}
+  </a>
+  <div class="collapse" id="{{$cid}}">
+    {{ $slot }}
+  </div>
+</p>

+ 18 - 0
resources/views/emails/parental-controls/invite.blade.php

@@ -0,0 +1,18 @@
+<x-mail::message>
+# You've been invited to join Pixelfed!
+
+<x-mail::panel>
+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.
+</x-mail::panel>
+
+<x-mail::button :url="$verify->inviteUrl()">
+Accept Invite
+</x-mail::button>
+
+Thanks,<br>
+Pixelfed
+
+<small>This email is automatically generated. Please do not reply to this message.</small>
+</x-mail::message>

+ 10 - 0
resources/views/errors/custom.blade.php

@@ -0,0 +1,10 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container">
+  <div class="error-page py-5 my-5 text-center">
+    <h3 class="font-weight-bold">{!! $title ?? config('instance.page.404.header')!!}</h3>
+    <p class="lead">{!! $body ?? config('instance.page.404.body')!!}</p>
+  </div>
+</div>
+@endsection

+ 29 - 56
resources/views/settings/email.blade.php

@@ -1,63 +1,36 @@
-@extends('layouts.app')
+@extends('settings.template')
 
-@section('content')
-@if (session('status'))
-    <div class="alert alert-primary px-3 h6 text-center">
-        {{ session('status') }}
-    </div>
-@endif
-@if ($errors->any())
-    <div class="alert alert-danger px-3 h6 text-center">
-            @foreach($errors->all() as $error)
-                <p class="font-weight-bold mb-1">{{ $error }}</p>
-            @endforeach
-    </div>
-@endif
-@if (session('error'))
-    <div class="alert alert-danger px-3 h6 text-center">
-        {{ session('error') }}
-    </div>
-@endif
-
-<div class="container">
-  <div class="col-12">
-    <div class="card shadow-none border mt-5">
-      <div class="card-body">
-        <div class="row">
-          <div class="col-12 p-3 p-md-5">
-			  <div class="title">
-			    <h3 class="font-weight-bold">Email Settings</h3>
-			  </div>
-			  <hr>
-			  <form method="post" action="{{route('settings.email')}}">
-			    @csrf
-			    <input type="hidden" class="form-control" name="name" value="{{Auth::user()->profile->name}}">
-			    <input type="hidden" class="form-control" name="username" value="{{Auth::user()->profile->username}}">
-			    <input type="hidden" class="form-control" name="website" value="{{Auth::user()->profile->website}}">
+@section('section')
 
-			    <div class="form-group">
-			      <label for="email" class="font-weight-bold">Email Address</label>
-			        <input type="email" class="form-control" id="email" name="email" placeholder="Email Address" value="{{Auth::user()->email}}">
-			        <p class="help-text small text-muted font-weight-bold">
-			          @if(Auth::user()->email_verified_at)
-			          <span class="text-success">Verified</span> {{Auth::user()->email_verified_at->diffForHumans()}}
-			          @else
-			          <span class="text-danger">Unverified</span> You need to <a href="/i/verify-email">verify your email</a>.
-			          @endif
-			        </p>
-			    </div>
-			    <div class="form-group row">
-			      <div class="col-12 text-right">
-			        <button type="submit" class="btn btn-primary font-weight-bold py-0 px-5">Submit</button>
-			      </div>
-			    </div>
-			  </form>
-          </div>
-        </div>
-      </div>
+<div class="d-flex justify-content-between align-items-center">
+    <div class="title d-flex align-items-center" style="gap: 1rem;">
+        <p class="mb-0"><a href="/settings/home"><i class="far fa-chevron-left fa-lg"></i></a></p>
+        <h3 class="font-weight-bold mb-0">Email Settings</h3>
     </div>
-  </div>
 </div>
 
+<hr>
+<form method="post" action="{{route('settings.email')}}">
+    @csrf
+    <input type="hidden" class="form-control" name="name" value="{{Auth::user()->profile->name}}">
+    <input type="hidden" class="form-control" name="username" value="{{Auth::user()->profile->username}}">
+    <input type="hidden" class="form-control" name="website" value="{{Auth::user()->profile->website}}">
 
+    <div class="form-group">
+        <label for="email" class="font-weight-bold">Email Address</label>
+        <input type="email" class="form-control" id="email" name="email" placeholder="Email Address" value="{{Auth::user()->email}}">
+        <p class="help-text small text-muted font-weight-bold">
+            @if(Auth::user()->email_verified_at)
+            <span class="text-success">Verified</span> {{Auth::user()->email_verified_at->diffForHumans()}}
+            @else
+            <span class="text-danger">Unverified</span> You need to <a href="/i/verify-email">verify your email</a>.
+            @endif
+        </p>
+    </div>
+    <div class="form-group row">
+        <div class="col-12 text-right">
+            <button type="submit" class="btn btn-primary font-weight-bold py-0 px-5">Submit</button>
+        </div>
+    </div>
+</form>
 @endsection

+ 59 - 0
resources/views/settings/parental-controls/add.blade.php

@@ -0,0 +1,59 @@
+@extends('settings.template-vue')
+
+@section('section')
+<form class="d-flex h-100 flex-column" method="post">
+    @csrf
+<div class="d-flex h-100 flex-column">
+    <div class="d-flex justify-content-between align-items-center">
+        <div class="title d-flex align-items-center" style="gap: 1rem;">
+            <p class="mb-0"><a href="/settings/parental-controls"><i class="far fa-chevron-left fa-lg"></i></a></p>
+            <h3 class="font-weight-bold mb-0">Add child</h3>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="d-flex flex-column flex-grow-1">
+        <h4>Choose your child's policies</h4>
+
+        <div class="mb-4">
+            <p class="font-weight-bold mb-1">Allowed Actions</p>
+
+            @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'])
+        </div>
+        <div class="mb-4">
+            <p class="font-weight-bold mb-1">Enabled features</p>
+
+            @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'])
+        </div>
+        <div class="mb-4">
+            <p class="font-weight-bold mb-1">Preferences</p>
+
+            @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'])
+        </div>
+    </div>
+
+    <div>
+        <div class="form-group">
+            <label class="font-weight-bold mb-0">Email address</label>
+            <p class="help-text lh-1 small">Where should we send this invite?</p>
+            <input class="form-control" placeholder="Enter your childs email address" name="email" required>
+        </div>
+
+        <button class="btn btn-dark btn-block font-weight-bold">Add Child</button>
+    </div>
+</div>
+</form>
+@endsection
+

+ 7 - 0
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<div class="custom-control custom-checkbox">
+                <input type="checkbox" class="custom-control-input" id="{{$id}}" name="{{$name}}" {!!$defaultChecked!!}>
+                <label class="custom-control-label pl-2" for="{{$id}}">{{ $title }}</label>
+            </div>

+ 44 - 0
resources/views/settings/parental-controls/child-status.blade.php

@@ -0,0 +1,44 @@
+@if($state)
+<div class="card shadow-none border">
+    @if($state === 'sent_invite')
+    <div class="card-body d-flex justify-content-center flex-column align-items-center py-5" style="gap:1rem">
+        <i class="far fa-envelope fa-3x"></i>
+        <p class="lead mb-0 font-weight-bold">Child Invite Sent!</p>
+
+        <div class="list-group">
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Created child invite</div>
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Sent invite email to child</div>
+            <div class="list-group-item py-1"><i class="far fa-times-circle text-dark mr-2"></i> Child joined via invite</div>
+            <div class="list-group-item py-1"><i class="far fa-times-circle text-dark mr-2"></i> Child account is active</div>
+        </div>
+    </div>
+    @elseif($state === 'awaiting_email_confirmation')
+    <div class="card-body d-flex justify-content-center flex-column align-items-center py-5" style="gap:1rem">
+        <i class="far fa-envelope fa-3x"></i>
+        <p class="lead mb-0 font-weight-bold">Child Invite Sent!</p>
+
+        <div class="list-group">
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Created child invite</div>
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Sent invite email to child</div>
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Child joined via invite</div>
+            <div class="list-group-item py-1"><i class="far fa-times-circle text-dark mr-2"></i> Child account is active</div>
+        </div>
+    </div>
+    @elseif($state === 'active')
+    <div class="card-body d-flex justify-content-center flex-column align-items-center py-5" style="gap:1rem">
+        <i class="far fa-check-circle fa-3x text-success"></i>
+        <p class="lead mb-0 font-weight-bold">Child Account Active</p>
+
+        <div class="list-group">
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Created child invite</div>
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Sent invite email to child</div>
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Child joined via invite</div>
+            <div class="list-group-item py-1"><i class="far fa-check-circle text-success mr-2"></i> Child account is active</div>
+        </div>
+
+        <a class="btn btn-dark font-weight-bold px-5" href="{{ $pc->childAccount()['url'] }}">View Account</a>
+    </div>
+    @endif
+</div>
+@else
+@endif

+ 32 - 0
resources/views/settings/parental-controls/delete-invite.blade.php

@@ -0,0 +1,32 @@
+@extends('settings.template-vue')
+
+@section('section')
+<form class="d-flex h-100 flex-column" method="post">
+    @csrf
+    <div class="d-flex h-100 flex-column" style="gap: 1rem;">
+        <div class="d-flex justify-content-between align-items-center">
+            <div class="title d-flex align-items-center" style="gap: 1rem;">
+                <p class="mb-0"><a href="{{ $pc->manageUrl() }}?actions"><i class="far fa-chevron-left fa-lg"></i></a></p>
+                <div>
+                    <h3 class="font-weight-bold mb-0">Cancel child invite</h3>
+                    <p class="small mb-0">Last updated: {{ $pc->updated_at->diffForHumans() }}</p>
+                </div>
+            </div>
+        </div>
+        <div>
+            <hr />
+        </div>
+
+        <div class="d-flex bg-light align-items-center justify-content-center flex-grow-1 flex-column">
+            <p>
+                <i class="far fa-exclamation-triangle fa-3x"></i>
+            </p>
+            <h4>Are you sure you want to cancel this invite?</h4>
+            <p>The child you invited will not be able to join if you cancel the invite.</p>
+        </div>
+
+        <button type="submit" class="btn btn-danger btn-block font-weight-bold">Cancel invite</button>
+    </div>
+</form>
+
+@endsection

+ 62 - 0
resources/views/settings/parental-controls/index.blade.php

@@ -0,0 +1,62 @@
+@extends('settings.template-vue')
+
+@section('section')
+<div class="d-flex h-100 flex-column">
+    <div class="d-flex justify-content-between align-items-center">
+        <div class="title d-flex align-items-center" style="gap: 1rem;">
+            <p class="mb-0"><a href="/settings/home"><i class="far fa-chevron-left fa-lg"></i></a></p>
+            <h3 class="font-weight-bold mb-0">Parental Controls</h3>
+        </div>
+    </div>
+
+    <hr />
+
+    @if($children->count())
+    <div class="d-flex flex-column flex-grow-1 w-100">
+        <div class="list-group w-100">
+            @foreach($children as $child)
+            <a class="list-group-item d-flex align-items-center text-decoration-none text-dark" href="{{ $child->manageUrl() }}" style="gap: 1rem;">
+                <img src="/storage/avatars/default.png" width="40" height="40" class="rounded-circle" />
+
+                <div class="flex-grow-1">
+                    @if($child->child_id && $child->email_verified_at)
+                    <p class="font-weight-bold mb-0" style="line-height: 1.5;">&commat;{{ $child->childAccount()['username'] }}</p>
+                    <p class="small text-muted mb-0" style="line-height: 1;">{{ $child->childAccount()['display_name'] }}</p>
+                    @else
+                    <p class="font-weight-light mb-0 text-danger" style="line-height: 1.5;">Invite Pending</p>
+                    <p class="mb-0 small" style="line-height: 1;">{{ $child->email }}</p>
+                    @endif
+                </div>
+
+                <div class="font-weight-bold small text-lighter" style="line-height:1;">
+                    <i class="far fa-clock mr-1"></i>
+                    {{ $child->updated_at->diffForHumans() }}
+                </div>
+            </a>
+            @endforeach
+        </div>
+
+        <div class="mt-3">
+            {{ $children->links() }}
+        </div>
+    </div>
+    @else
+    <div class="d-flex flex-grow-1 bg-light mb-3 rounded p-4">
+        <p>You are not managing any children accounts.</p>
+    </div>
+    @endif
+
+    <div class="d-flex justify-content-between align-items-center">
+        <a class="btn btn-outline-dark font-weight-bold py-2 px-4" href="{{ route('settings.pc.add') }}">
+            <i class="far fa-plus mr-2"></i> Add Child
+        </a>
+
+        <div class="font-weight-bold">
+            <span>{{ $children->total() }}/{{ config('instance.parental_controls.limits.max_children') }}</span>
+            <span>children added</span>
+        </div>
+    </div>
+
+</div>
+@endsection
+

+ 115 - 0
resources/views/settings/parental-controls/invite-register-form.blade.php

@@ -0,0 +1,115 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container mt-4">
+    <div class="row justify-content-center">
+        <div class="col-lg-5">
+            <div class="card shadow-none border mb-3">
+                <a
+                    class="card-body d-flex flex-column justify-content-center align-items-center text-decoration-none"
+                    href="{{ $pc->parent->url() }}"
+                    target="_blank">
+                    <p class="text-center font-weight-bold text-muted">You've been invited by:</p>
+
+                    <div class="media align-items-center">
+                        <img
+                            src="{{ $pc->parent->avatarUrl() }}"
+                            width="30"
+                            height="30"
+                            class="rounded-circle mr-2"
+                            draggable="false"
+                            onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+
+                        <div class="media-body">
+                            <p class="lead font-weight-bold mb-0 text-dark" style="line-height: 1;">&commat;{{ $pc->parent->username }}</p>
+                        </div>
+                    </div>
+                </a>
+            </div>
+            <div class="card shadow-none border">
+                <div class="card-header bg-white p-3 text-center font-weight-bold">Create your Account</div>
+
+                <div class="card-body">
+                    <form method="POST" class="px-md-3">
+                        @csrf
+
+                        <input type="hidden" name="rt" value="{{ (new \App\Http\Controllers\Auth\RegisterController())->getRegisterToken() }}">
+                        <div class="form-group row">
+                            <div class="col-md-12">
+                                <label class="small font-weight-bold text-lighter">Name</label>
+                                <input id="name" type="text" class="form-control{{ $errors->has('name') ? ' is-invalid' : '' }}" name="name" value="{{ old('name') }}" placeholder="{{ __('Name') }}" required autofocus>
+
+                                @if ($errors->has('name'))
+                                    <span class="invalid-feedback">
+                                        <strong>{{ $errors->first('name') }}</strong>
+                                    </span>
+                                @endif
+                            </div>
+                        </div>
+
+                        <div class="form-group row">
+                            <div class="col-md-12">
+                                <label class="small font-weight-bold text-lighter">Username</label>
+                                <input id="username" type="text" class="form-control{{ $errors->has('username') ? ' is-invalid' : '' }}" name="username" value="{{ old('username') }}" placeholder="{{ __('Username') }}" required>
+
+                                @if ($errors->has('username'))
+                                    <span class="invalid-feedback">
+                                        <strong>{{ $errors->first('username') }}</strong>
+                                    </span>
+                                @endif
+                            </div>
+                        </div>
+
+                        <div class="form-group row">
+                            <div class="col-md-12">
+                                <label class="small font-weight-bold text-lighter">Password</label>
+                                <input id="password" type="password" class="form-control{{ $errors->has('password') ? ' is-invalid' : '' }}" name="password" placeholder="{{ __('Password') }}" required>
+
+                                @if ($errors->has('password'))
+                                    <span class="invalid-feedback">
+                                        <strong>{{ $errors->first('password') }}</strong>
+                                    </span>
+                                @endif
+                            </div>
+                        </div>
+
+                        <div class="form-group row">
+                            <div class="col-md-12">
+                                <label class="small font-weight-bold text-lighter">Confirm Password</label>
+                                <input id="password-confirm" type="password" class="form-control" name="password_confirmation" placeholder="{{ __('Confirm Password') }}" required>
+                            </div>
+                        </div>
+
+                        <div class="form-group row">
+                            <div class="col-md-12">
+                                <div class="form-check">
+                                  <input class="form-check-input" name="agecheck" type="checkbox" value="true" id="ageCheck" required>
+                                  <label class="form-check-label" for="ageCheck">
+                                    I am at least 16 years old
+                                  </label>
+                                </div>
+                            </div>
+                        </div>
+
+                        @if(config('captcha.enabled') || config('captcha.active.register'))
+                        <div class="d-flex justify-content-center my-3">
+                            {!! Captcha::display() !!}
+                        </div>
+                        @endif
+
+                        <p class="small">By signing up, you agree to our <a href="{{route('site.terms')}}" class="font-weight-bold text-dark">Terms of Use</a> and <a href="{{route('site.privacy')}}" class="font-weight-bold text-dark">Privacy Policy</a>, in addition, you understand that your account is managed by <span class="font-weight-bold">{{ $pc->parent->username }}</span> and they can limit your account without your permission. For more details, view the <a href="/site/kb/parental-controls" class="text-dark font-weight-bold">Parental Controls</a> help center page.</p>
+
+                        <div class="form-group row">
+                            <div class="col-md-12">
+                                <button type="submit" class="btn btn-primary btn-block py-0 font-weight-bold">
+                                    {{ __('Register') }}
+                                </button>
+                            </div>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+@endsection

+ 119 - 0
resources/views/settings/parental-controls/manage.blade.php

@@ -0,0 +1,119 @@
+@extends('settings.template-vue')
+
+@section('section')
+<form class="d-flex h-100 flex-column" method="post">
+    @csrf
+<div class="d-flex h-100 flex-column">
+    <div class="d-flex justify-content-between align-items-center">
+        <div class="title d-flex align-items-center" style="gap: 1rem;">
+            <p class="mb-0"><a href="/settings/parental-controls"><i class="far fa-chevron-left fa-lg"></i></a></p>
+            <div>
+                <h3 class="font-weight-bold mb-0">Manage child</h3>
+                <p class="small mb-0">Last updated: {{ $pc->updated_at->diffForHumans() }}</p>
+            </div>
+        </div>
+
+        <button class="btn btn-dark font-weight-bold">Update</button>
+    </div>
+
+    <hr />
+
+    <div class="d-flex flex-column flex-grow-1">
+        <ul class="nav nav-pills mb-0" id="pills-tab" role="tablist">
+            <li class="nav-item" role="presentation">
+                <button class="nav-link active font-weight-bold" id="pills-status-tab" data-toggle="pill" data-target="#pills-status" type="button" role="tab" aria-controls="pills-status" aria-selected="true">Status</button>
+            </li>
+            <li class="nav-item" role="presentation">
+                <button class="nav-link font-weight-bold" id="pills-permissions-tab" data-toggle="pill" data-target="#pills-permissions" type="button" role="tab" aria-controls="pills-permissions" aria-selected="false">Permissions</button>
+            </li>
+            <li class="nav-item" role="presentation">
+                <button class="nav-link font-weight-bold" id="pills-details-tab" data-toggle="pill" data-target="#pills-details" type="button" role="tab" aria-controls="pills-details" aria-selected="false">Account Details</button>
+            </li>
+            <li class="nav-item" role="presentation">
+                <button class="nav-link font-weight-bold" id="pills-actions-tab" data-toggle="pill" data-target="#pills-actions" type="button" role="tab" aria-controls="pills-actions" aria-selected="false">Actions</button>
+            </li>
+        </ul>
+        <div>
+            <hr>
+        </div>
+        <div class="tab-content" id="pills-tabContent">
+            <div class="tab-pane fade show active" id="pills-status" role="tabpanel" aria-labelledby="pills-status-tab">
+                @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
+            </div>
+            <div class="tab-pane fade" id="pills-permissions" role="tabpanel" aria-labelledby="pills-permissions-tab">
+                <div class="mb-4">
+                    <p class="font-weight-bold mb-1">Allowed Actions</p>
+
+                    @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']])
+                </div>
+                <div class="mb-4">
+                    <p class="font-weight-bold mb-1">Enabled features</p>
+
+                    @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']])
+                </div>
+                <div class="mb-4">
+                    <p class="font-weight-bold mb-1">Preferences</p>
+
+                    @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']])
+                </div>
+            </div>
+            <div class="tab-pane fade" id="pills-details" role="tabpanel" aria-labelledby="pills-details-tab">
+                <div>
+                    <div class="form-group">
+                        <label class="font-weight-bold mb-0">Email address</label>
+                        <input class="form-control" name="email" value="{{ $pc->email }}" disabled>
+                    </div>
+                </div>
+            </div>
+            <div class="tab-pane fade" id="pills-actions" role="tabpanel" aria-labelledby="pills-actions-tab">
+                <div class="d-flex flex-column" style="gap: 2rem;">
+                    @if(!$pc->child_id && !$pc->email_verified_at)
+                    <div>
+                        <p class="lead font-weight-bold mb-0">Cancel Invite</p>
+                        <p class="small text-muted">Cancel the child invite and prevent it from being used.</p>
+                        <a class="btn btn-outline-dark px-5" href="{{ route('settings.pc.cancel-invite', ['id' => $pc->id]) }}"><i class="fas fa-user-minus mr-1"></i> Cancel Invite</a>
+                    </div>
+                    @else
+                    <div>
+                        <p class="lead font-weight-bold mb-0">Stop Managing</p>
+                        <p class="small text-muted">Transition account to a regular account without parental controls.</p>
+                        <a class="btn btn-outline-dark px-5" href="{{ route('settings.pc.stop-managing', ['id' => $pc->id]) }}"><i class="fas fa-user-minus mr-1"></i> Stop Managing Child</a>
+                    </div>
+                    @endif
+                </div>
+            </div>
+        </div>
+    </div>
+
+</div>
+</form>
+@endsection
+
+@push('scripts')
+<script type="text/javascript">
+    @if(request()->has('permissions'))
+    $('#pills-tab button[data-target="#pills-permissions"]').tab('show')
+    @elseif(request()->has('actions'))
+    $('#pills-tab button[data-target="#pills-actions"]').tab('show')
+    @endif
+</script>
+@endpush

+ 32 - 0
resources/views/settings/parental-controls/stop-managing.blade.php

@@ -0,0 +1,32 @@
+@extends('settings.template-vue')
+
+@section('section')
+<form class="d-flex h-100 flex-column" method="post">
+    @csrf
+    <div class="d-flex h-100 flex-column" style="gap: 1rem;">
+        <div class="d-flex justify-content-between align-items-center">
+            <div class="title d-flex align-items-center" style="gap: 1rem;">
+                <p class="mb-0"><a href="{{ $pc->manageUrl() }}?actions"><i class="far fa-chevron-left fa-lg"></i></a></p>
+                <div>
+                    <h3 class="font-weight-bold mb-0">Stop Managing Child</h3>
+                    <p class="small mb-0">Last updated: {{ $pc->updated_at->diffForHumans() }}</p>
+                </div>
+            </div>
+        </div>
+        <div>
+            <hr />
+        </div>
+
+        <div class="d-flex bg-light align-items-center justify-content-center flex-grow-1 flex-column">
+            <p>
+                <i class="far fa-exclamation-triangle fa-3x"></i>
+            </p>
+            <h4>Confirm Stop Managing this Account?</h4>
+            <p>This child account will be transitioned to a regular account without any limitations.</p>
+        </div>
+
+        <button type="submit" class="btn btn-danger btn-block font-weight-bold">Stop Managing</button>
+    </div>
+</form>
+
+@endsection

+ 77 - 76
resources/views/settings/partial/sidebar.blade.php

@@ -1,79 +1,80 @@
-	<div class="col-12 col-md-3">
-		<ul class="nav flex-column settings-nav py-3">
-			<li class="nav-item pl-3 {{request()->is('settings/home')?'active':''}}">
-				<a class="nav-link font-weight-light  text-muted" href="{{route('settings')}}">Account</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/accessibility')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.accessibility')}}">Accessibility</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/email')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.email')}}">Email</a>
-			</li>
-			@if(config('pixelfed.user_invites.enabled'))
-			<li class="nav-item pl-3 {{request()->is('settings/invites*')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.invites')}}">Invites</a>
-			</li>
-			@endif
-			<li class="nav-item pl-3 {{request()->is('settings/media*')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.media')}}">Media</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/notifications')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.notifications')}}">Notifications</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/password')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.password')}}">Password</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/privacy*')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.privacy')}}">Privacy</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/relationships*')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.relationships')}}">Relationships</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/security*')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.security')}}">Security</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/timeline*')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.timeline')}}">Timelines</a>
-			</li>
-			<li class="nav-item">
-				<hr>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('*import*')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.import')}}">Import</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/data-export')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.dataexport')}}">Export</a>
-			</li>
+    <div class="col-12 col-md-3">
+        <ul class="nav flex-column settings-nav py-3">
+            <li class="nav-item pl-3 {{request()->is('settings/home')?'active':''}}">
+                <a class="nav-link font-weight-light  text-muted" href="{{route('settings')}}">Account</a>
+            </li>
+            <li class="nav-item pl-3 {{request()->is('settings/accessibility')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.accessibility')}}">Accessibility</a>
+            </li>
+            <li class="nav-item pl-3 {{request()->is('settings/email')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.email')}}">Email</a>
+            </li>
+            {{-- @if(config('pixelfed.user_invites.enabled'))
+            <li class="nav-item pl-3 {{request()->is('settings/invites*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.invites')}}">Invites</a>
+            </li>
+            @endif --}}
+            <li class="nav-item pl-3 {{request()->is('settings/media*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.media')}}">Media</a>
+            </li>
+            {{-- <li class="nav-item pl-3 {{request()->is('settings/notifications')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.notifications')}}">Notifications</a>
+            </li> --}}
+            <li class="nav-item pl-3 {{request()->is('settings/password')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.password')}}">Password</a>
+            </li>
+            <li class="nav-item pl-3 {{request()->is('settings/privacy*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.privacy')}}">Privacy</a>
+            </li>
+            <li class="nav-item pl-3 {{request()->is('settings/relationships*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.relationships')}}">Relationships</a>
+            </li>
+            <li class="nav-item pl-3 {{request()->is('settings/security*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.security')}}">Security</a>
+            </li>
+            <li class="nav-item pl-3 {{request()->is('settings/timeline*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.timeline')}}">Timelines</a>
+            </li>
+            <li class="nav-item">
+                <hr>
+            </li>
 
-			@if(config_cache('pixelfed.oauth_enabled') == true)
-			<li class="nav-item">
-			<hr>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/applications')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.applications')}}">Applications</a>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/developers')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.developers')}}">Developers</a>
-			</li>
-			@endif
+            @if(config_cache('pixelfed.oauth_enabled') == true)
+            <li class="nav-item pl-3 {{request()->is('settings/applications')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.applications')}}">Applications</a>
+            </li>
+            <li class="nav-item pl-3 {{request()->is('settings/developers')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.developers')}}">Developers</a>
+            </li>
+            @endif
 
-			<li class="nav-item">
-			<hr>
-			</li>
-			<li class="nav-item pl-3 {{request()->is('settings/labs*')?'active':''}}">
-				<a class="nav-link font-weight-light text-muted" href="{{route('settings.labs')}}">Labs</a>
-			</li>
-		</ul>
-	</div>
+            <li class="nav-item pl-3 {{request()->is('*import*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.import')}}">Import</a>
+            </li>
+            <li class="nav-item pl-3 {{request()->is('settings/data-export')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.dataexport')}}">Export</a>
+            </li>
 
-	@push('styles')
-	<style type="text/css">
-		.settings-nav {
-			@media only screen and (min-width: 768px) {
-				border-right: 1px solid #dee2e6 !important
-			}
-			height: 100%;
-			flex-grow: 1;
-		}
-	</style>
-	@endpush
+            <li class="nav-item pl-3 {{request()->is('settings/labs*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.labs')}}">Labs</a>
+            </li>
+
+            @if(config('instance.parental_controls.enabled'))
+            <li class="nav-item pl-3 {{request()->is('settings/parental-controls*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.parental-controls')}}">Parental Controls</a>
+            </li>
+            @endif
+        </ul>
+    </div>
+
+    @push('styles')
+    <style type="text/css">
+        .settings-nav {
+            @media only screen and (min-width: 768px) {
+                border-right: 1px solid #dee2e6 !important
+            }
+            height: 100%;
+            flex-grow: 1;
+        }
+    </style>
+    @endpush

+ 47 - 0
resources/views/site/help/parental-controls.blade.php

@@ -0,0 +1,47 @@
+@extends('site.help.partial.template', ['breadcrumb'=>'Parental Controls'])
+
+@section('section')
+  <div class="title">
+    <h3 class="font-weight-bold">Parental Controls</h3>
+  </div>
+  <hr>
+
+  <p>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.</p>
+
+  <p class="font-weight-bold text-center">Key Features:</p>
+
+  <ul>
+    <li><strong>Child Account Creation</strong>: Easily set up a child account with just a few clicks. This account is linked to your own, giving you complete oversight.</li>
+    <li><strong>Post Control</strong>: Decide if your child can post content. This allows you to ensure they're only sharing what's appropriate and safe.</li>
+    <li><strong>Comment Management</strong>: Control whether your child can comment on posts. This helps in safeguarding them from unwanted interactions and maintaining a positive online environment.</li>
+    <li><strong>Like & Share Restrictions</strong>: You have the power to enable or disable the ability to like and share posts. This feature helps in controlling the extent of your child's social media engagement.</li>
+    <li><strong>Disable Federation</strong>: For added safety, you can choose to disable federation for your child's account, limiting their interaction to a more controlled environment.</li>
+  </ul>
+  <hr>
+
+  <x-collapse title="How do I create a child account?">
+    <div>
+      @if(config('instance.parental_controls.enabled'))
+      <ol>
+        <li>Click <a href="/settings/parental-controls">here</a> and tap on the <strong>Add Child</strong> button in the bottom left corner</li>
+        <li>Select the Allowed Actions, Enabled features and Preferences</li>
+        <li>Enter your childs email address</li>
+        <li>Press the <strong>Add Child</strong> buttton</li>
+        <li>Open your childs email and tap on the <strong>Accept Invite</strong> button in the email, ensure your parent username is present in the email</li>
+        <li>Fill out the child display name, username and password</li>
+        <li>Press <strong>Register</strong> and your child account will be active!</li>
+      </ol>
+      @else
+      <p>This feature has been disabled by server admins.</p>
+      @endif
+    </div>
+  </x-collapse>
+
+@if(config('instance.parental_controls.enabled'))
+  <x-collapse title="How many child accounts can I create/manage?">
+    <div>
+      You can create and manage up to <strong>{{ config('instance.parental_controls.limits.max_children') }}</strong> child accounts.
+    </div>
+  </x-collapse>
+@endif
+@endsection

+ 13 - 0
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');