diff --git a/CHANGELOG.md b/CHANGELOG.md index 2272b30a5..e30bb2634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,18 @@ ## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev) +### Added +- Implement Admin Domain Blocks API (Mastodon API Compatible) [ThisIsMissEm](https://github.com/ThisIsMissEm) ([#5021](https://github.com/pixelfed/pixelfed/pull/5021)) + ### Updates - Update ApiV1Controller, add support for notification filter types ([f61159a1](https://github.com/pixelfed/pixelfed/commit/f61159a1)) - Update ApiV1Dot1Controller, fix mutual api ([a8bb97b2](https://github.com/pixelfed/pixelfed/commit/a8bb97b2)) - Update ApiV1Controller, fix /api/v1/favourits pagination ([72f68160](https://github.com/pixelfed/pixelfed/commit/72f68160)) - Update RegisterController, update username constraints, require atleast one alpha char ([dd6e3cc2](https://github.com/pixelfed/pixelfed/commit/dd6e3cc2)) - Update AdminUser, fix entity casting ([cb5620d4](https://github.com/pixelfed/pixelfed/commit/cb5620d4)) +- Update instance config, update network cache feed max_hours_old falloff to 90 days instead of 6 hours to allow for less active instances to have more results ([c042d135](https://github.com/pixelfed/pixelfed/commit/c042d135)) +- Update ApiV1Dot1Controller, add new single media status create endpoint ([b03f5cec](https://github.com/pixelfed/pixelfed/commit/b03f5cec)) +- Update AdminSettings component, add link to Custom CSS settings ([958daac4](https://github.com/pixelfed/pixelfed/commit/958daac4)) - ([](https://github.com/pixelfed/pixelfed/commit/)) - ([](https://github.com/pixelfed/pixelfed/commit/)) diff --git a/app/Http/Controllers/Admin/AdminGroupsController.php b/app/Http/Controllers/Admin/AdminGroupsController.php new file mode 100644 index 000000000..45a4fd266 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminGroupsController.php @@ -0,0 +1,49 @@ +groupAdminStats(); + + return view('admin.groups.home', compact('stats')); + } + + protected function groupAdminStats() + { + return Cache::remember('admin:groups:stats', 3, function () { + $res = [ + 'total' => Group::count(), + 'local' => Group::whereLocal(true)->count(), + ]; + + $res['remote'] = $res['total'] - $res['local']; + $res['categories'] = GroupCategory::count(); + $res['posts'] = GroupPost::count(); + $res['members'] = GroupMember::count(); + $res['interactions'] = GroupInteraction::count(); + $res['reports'] = GroupReport::count(); + + $res['local_30d'] = Cache::remember('admin:groups:stats:local_30d', 43200, function () { + return Group::whereLocal(true)->where('created_at', '>', now()->subMonth())->count(); + }); + + $res['remote_30d'] = Cache::remember('admin:groups:stats:remote_30d', 43200, function () { + return Group::whereLocal(false)->where('created_at', '>', now()->subMonth())->count(); + }); + + return $res; + }); + } +} diff --git a/app/Http/Controllers/Admin/AdminSettingsController.php b/app/Http/Controllers/Admin/AdminSettingsController.php index 98e16bbc0..f1c2ca3ab 100644 --- a/app/Http/Controllers/Admin/AdminSettingsController.php +++ b/app/Http/Controllers/Admin/AdminSettingsController.php @@ -531,6 +531,7 @@ trait AdminSettingsController 'registration_status' => 'required|in:open,filtered,closed', 'cloud_storage' => 'required', 'activitypub_enabled' => 'required', + 'authorized_fetch' => 'required', 'account_migration' => 'required', 'mobile_apis' => 'required', 'stories' => 'required', @@ -555,6 +556,7 @@ trait AdminSettingsController } } } + ConfigCacheService::put('federation.activitypub.authorized_fetch', $request->boolean('authorized_fetch')); ConfigCacheService::put('federation.activitypub.enabled', $request->boolean('activitypub_enabled')); ConfigCacheService::put('federation.migration', $request->boolean('account_migration')); ConfigCacheService::put('pixelfed.oauth_enabled', $request->boolean('mobile_apis')); diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php new file mode 100644 index 000000000..d8ba76668 --- /dev/null +++ b/app/Http/Controllers/Api/ApiController.php @@ -0,0 +1,38 @@ +json($res, $code, $this->filterHeaders($headers), JSON_UNESCAPED_SLASHES); + } + + public function linksForCollection($paginator) { + $link = null; + + if ($paginator->onFirstPage()) { + if ($paginator->hasMorePages()) { + $link = '<'.$paginator->nextPageUrl().'>; rel="prev"'; + } + } else { + if ($paginator->previousPageUrl()) { + $link = '<'.$paginator->previousPageUrl().'>; rel="next"'; + } + + if ($paginator->hasMorePages()) { + $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"'; + } + } + + return $link; + } + + private function filterHeaders($headers) { + return array_filter($headers, function($v, $k) { + return $v != null; + }, ARRAY_FILTER_USE_BOTH); + } +} diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index fce3e20ed..80c955fb9 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -60,6 +60,7 @@ use App\Services\SnowflakeService; use App\Services\StatusService; use App\Services\UserFilterService; use App\Services\UserRoleService; +use App\Services\UserStorageService; use App\Status; use App\StatusHashtag; use App\Transformer\Api\Mastodon\v1\AccountTransformer; @@ -1806,12 +1807,16 @@ class ApiV1Controller extends Controller $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; - }); + $accountSize = UserStorageService::get($user->id); + abort_if($accountSize === -1, 403, 'Invalid request.'); + $photo = $request->file('file'); + $fileSize = $photo->getSize(); + $sizeInKbs = (int) ceil($fileSize / 1000); + $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs; + + if ((bool) config_cache('pixelfed.enforce_account_limit') == true) { $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { + if ($updatedAccountSize >= $limit) { abort(403, 'Account size limit reached.'); } } @@ -1819,8 +1824,6 @@ class ApiV1Controller extends Controller $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.'); @@ -1883,6 +1886,10 @@ class ApiV1Controller extends Controller break; } + $user->storage_used = (int) $updatedAccountSize; + $user->storage_used_updated_at = now(); + $user->save(); + Cache::forget($limitKey); $resource = new Fractal\Resource\Item($media, new MediaTransformer()); $res = $this->fractal->createData($resource)->toArray(); @@ -2023,12 +2030,16 @@ class ApiV1Controller extends Controller $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; - }); + $accountSize = UserStorageService::get($user->id); + abort_if($accountSize === -1, 403, 'Invalid request.'); + $photo = $request->file('file'); + $fileSize = $photo->getSize(); + $sizeInKbs = (int) ceil($fileSize / 1000); + $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs; + + if ((bool) config_cache('pixelfed.enforce_account_limit') == true) { $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { + if ($updatedAccountSize >= $limit) { abort(403, 'Account size limit reached.'); } } @@ -2036,8 +2047,6 @@ class ApiV1Controller extends Controller $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.'); @@ -2105,6 +2114,10 @@ class ApiV1Controller extends Controller break; } + $user->storage_used = (int) $updatedAccountSize; + $user->storage_used_updated_at = now(); + $user->save(); + Cache::forget($limitKey); $resource = new Fractal\Resource\Item($media, new MediaTransformer()); $res = $this->fractal->createData($resource)->toArray(); diff --git a/app/Http/Controllers/Api/ApiV1Dot1Controller.php b/app/Http/Controllers/Api/ApiV1Dot1Controller.php index 9a47bb150..efd04c60d 100644 --- a/app/Http/Controllers/Api/ApiV1Dot1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Dot1Controller.php @@ -5,12 +5,17 @@ namespace App\Http\Controllers\Api; use App\AccountLog; use App\EmailVerification; use App\Http\Controllers\Controller; +use App\Http\Controllers\StatusController; use App\Http\Resources\StatusStateless; +use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail; +use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\StatusPipeline\RemoteStatusDelete; use App\Jobs\StatusPipeline\StatusDelete; +use App\Jobs\VideoPipeline\VideoThumbnail; use App\Mail\ConfirmAppEmail; use App\Mail\PasswordChange; +use App\Media; use App\Place; use App\Profile; use App\Report; @@ -18,14 +23,18 @@ use App\Services\AccountService; use App\Services\BouncerService; use App\Services\EmailService; use App\Services\FollowerService; +use App\Services\MediaBlocklistService; +use App\Services\MediaPathService; use App\Services\NetworkTimelineService; use App\Services\ProfileStatusService; use App\Services\PublicTimelineService; use App\Services\StatusService; +use App\Services\UserStorageService; use App\Status; use App\StatusArchived; use App\User; use App\UserSetting; +use App\Util\Lexer\Autolink; use App\Util\Lexer\RestrictedNames; use Cache; use DB; @@ -37,6 +46,7 @@ use Jenssegers\Agent\Agent; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; use Mail; +use NotificationChannels\Expo\ExpoPushToken; class ApiV1Dot1Controller extends Controller { @@ -1008,4 +1018,238 @@ class ApiV1Dot1Controller extends Controller return $this->json($account, 200, $rateLimiting ? $limits : []); } + + public function getExpoPushNotifications(Request $request) + { + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('push'), 403); + abort_unless(config('services.expo.access_token') && strlen(config('services.expo.access_token')) > 10, 404, 'Push notifications are not supported on this server.'); + $user = $request->user(); + $res = [ + 'expo_token' => (bool) $user->expo_token, + 'notify_like' => (bool) $user->notify_like, + 'notify_follow' => (bool) $user->notify_follow, + 'notify_mention' => (bool) $user->notify_mention, + 'notify_comment' => (bool) $user->notify_comment, + ]; + + return $this->json($res); + } + + public function disableExpoPushNotifications(Request $request) + { + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('push'), 403); + abort_unless(config('services.expo.access_token') && strlen(config('services.expo.access_token')) > 10, 404, 'Push notifications are not supported on this server.'); + $request->user()->update([ + 'expo_token' => null, + ]); + + return $this->json(['expo_token' => null]); + } + + public function updateExpoPushNotifications(Request $request) + { + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('push'), 403); + abort_unless(config('services.expo.access_token') && strlen(config('services.expo.access_token')) > 10, 404, 'Push notifications are not supported on this server.'); + $this->validate($request, [ + 'expo_token' => ['required', ExpoPushToken::rule()], + 'notify_like' => 'sometimes', + 'notify_follow' => 'sometimes', + 'notify_mention' => 'sometimes', + 'notify_comment' => 'sometimes', + ]); + + $user = $request->user()->update([ + 'expo_token' => $request->input('expo_token'), + 'notify_like' => $request->has('notify_like') && $request->boolean('notify_like'), + 'notify_follow' => $request->has('notify_follow') && $request->boolean('notify_follow'), + 'notify_mention' => $request->has('notify_mention') && $request->boolean('notify_mention'), + 'notify_comment' => $request->has('notify_comment') && $request->boolean('notify_comment'), + ]); + + $res = [ + 'expo_token' => (bool) $request->user()->expo_token, + 'notify_like' => (bool) $request->user()->notify_like, + 'notify_follow' => (bool) $request->user()->notify_follow, + 'notify_mention' => (bool) $request->user()->notify_mention, + 'notify_comment' => (bool) $request->user()->notify_comment, + ]; + + return $this->json($res); + } + + /** + * POST /api/v1.1/status/create + * + * + * @return StatusTransformer + */ + public function statusCreate(Request $request) + { + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('write'), 403); + + $this->validate($request, [ + 'status' => 'nullable|string|max:'.(int) config_cache('pixelfed.max_caption_length'), + 'file' => [ + 'required', + 'file', + 'mimetypes:'.config_cache('pixelfed.media_types'), + 'max:'.config_cache('pixelfed.max_photo_size'), + function ($attribute, $value, $fail) { + if (is_array($value) && count($value) > 1) { + $fail('Only one file can be uploaded at a time.'); + } + }, + ], + 'sensitive' => 'nullable', + 'visibility' => 'string|in:private,unlisted,public', + 'spoiler_text' => 'sometimes|max:140', + ]); + + if ($request->hasHeader('idempotency-key')) { + $key = 'pf:api:v1:status:idempotency-key:'.$request->user()->id.':'.hash('sha1', $request->header('idempotency-key')); + $exists = Cache::has($key); + abort_if($exists, 400, 'Duplicate idempotency key.'); + Cache::put($key, 1, 3600); + } + + if (config('costar.enabled') == true) { + $blockedKeywords = config('costar.keyword.block'); + if ($blockedKeywords !== null && $request->status) { + $keywords = config('costar.keyword.block'); + foreach ($keywords as $kw) { + if (Str::contains($request->status, $kw) == true) { + abort(400, 'Invalid object. Contains banned keyword.'); + } + } + } + } + $user = $request->user(); + + if ($user->has_roles) { + abort_if(! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); + } + + $profile = $user->profile; + + $limitKey = 'compose:rate-limit:media-upload:'.$user->id; + $photo = $request->file('file'); + $fileSize = $photo->getSize(); + $sizeInKbs = (int) ceil($fileSize / 1000); + $accountSize = UserStorageService::get($user->id); + abort_if($accountSize === -1, 403, 'Invalid request.'); + $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs; + + if ((bool) config_cache('pixelfed.enforce_account_limit') == true) { + $limit = (int) config_cache('pixelfed.max_account_size'); + if ($updatedAccountSize >= $limit) { + abort(403, 'Account size limit reached.'); + } + } + + $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); + + $visibility = $profile->is_private ? 'private' : ( + $profile->unlisted == true && + $request->input('visibility', 'public') == 'public' ? + 'unlisted' : + $request->input('visibility', 'public')); + + if ($user->last_active_at == null) { + return []; + } + + $content = strip_tags($request->input('status')); + $rendered = Autolink::create()->autolink($content); + $cw = $user->profile->cw == true ? true : $request->boolean('sensitive', false); + $spoilerText = $cw && $request->filled('spoiler_text') ? $request->input('spoiler_text') : null; + + $status = new Status; + $status->caption = $content; + $status->rendered = $rendered; + $status->profile_id = $user->profile_id; + $status->is_nsfw = $cw; + $status->cw_summary = $spoilerText; + $status->scope = $visibility; + $status->visibility = $visibility; + $status->type = StatusController::mimeTypeCheck([$mime]); + $status->save(); + + if (! $status) { + abort(500, 'An error occured.'); + } + + $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 = $mime; + $media->order = 1; + $media->caption = $request->input('description'); + 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; + } + + $user->storage_used = (int) $updatedAccountSize; + $user->storage_used_updated_at = now(); + $user->save(); + + NewStatusPipeline::dispatch($status); + + Cache::forget('user:account:id:'.$user->id); + Cache::forget('_api:statuses:recent_9:'.$user->profile_id); + Cache::forget('profile:status_count:'.$user->profile_id); + Cache::forget($user->storageUsedKey()); + Cache::forget('profile:embed:'.$status->profile_id); + Cache::forget($limitKey); + + $res = StatusService::getMastodon($status->id, false); + $res['favourited'] = false; + $res['language'] = 'en'; + $res['bookmarked'] = false; + $res['card'] = null; + + return $this->json($res); + } } diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 9a46791ad..ee193e8af 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -2,45 +2,32 @@ namespace App\Http\Controllers\Api; -use Illuminate\Http\Request; use App\Http\Controllers\Controller; +use App\Jobs\ImageOptimizePipeline\ImageOptimize; +use App\Jobs\MediaPipeline\MediaDeletePipeline; +use App\Jobs\VideoPipeline\VideoThumbnail; use App\Media; -use App\UserSetting; -use App\User; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Storage; use App\Services\AccountService; -use App\Services\BouncerService; use App\Services\InstanceService; use App\Services\MediaBlocklistService; use App\Services\MediaPathService; use App\Services\SearchApiV2Service; +use App\Services\UserRoleService; +use App\Services\UserStorageService; +use App\Transformer\Api\Mastodon\v1\MediaTransformer; +use App\User; +use App\UserSetting; use App\Util\Media\Filter; -use App\Jobs\MediaPipeline\MediaDeletePipeline; -use App\Jobs\VideoPipeline\{ - VideoOptimize, - VideoPostProcess, - VideoThumbnail -}; -use App\Jobs\ImageOptimizePipeline\ImageOptimize; +use App\Util\Site\Nodeinfo; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Storage; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; -use League\Fractal\Pagination\IlluminatePaginatorAdapter; -use App\Transformer\Api\Mastodon\v1\{ - AccountTransformer, - MediaTransformer, - NotificationTransformer, - StatusTransformer, -}; -use App\Transformer\Api\{ - 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 = []) { @@ -50,10 +37,11 @@ class ApiV2Controller extends Controller public function instance(Request $request) { $contact = Cache::remember('api:v1:instance-data:contact', 604800, function () { - if(config_cache('instance.admin.pid')) { + 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; @@ -62,41 +50,42 @@ class ApiV2Controller extends Controller $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() : []; + ->map(function ($rule, $key) { + $id = $key + 1; + + return [ + 'id' => "{$id}", + 'text' => $rule, + ]; + }) + ->toArray() : []; }); - $res = Cache::remember('api:v2:instance-data-response-v2', 1800, function () use($contact, $rules) { + $res = Cache::remember('api:v2:instance-data-response-v2', 1800, function () use ($contact, $rules) { return [ 'domain' => config('pixelfed.domain.app'), 'title' => config_cache('app.name'), - 'version' => '3.5.3 (compatible; Pixelfed ' . config('pixelfed.version') .')', + 'version' => '3.5.3 (compatible; Pixelfed '.config('pixelfed.version').')', 'source_url' => 'https://github.com/pixelfed/pixelfed', 'description' => config_cache('app.short_description'), 'usage' => [ 'users' => [ - 'active_month' => (int) Nodeinfo::activeUsersMonthly() - ] + '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')) - ] + '@2x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')), + ], ], 'languages' => [config('app.locale')], 'configuration' => [ 'urls' => [ 'streaming' => null, - 'status' => null + 'status' => null, ], 'vapid' => [ 'public_key' => config('webpush.vapid.public_key'), @@ -107,7 +96,7 @@ class ApiV2Controller extends Controller 'statuses' => [ 'max_characters' => (int) config_cache('pixelfed.max_caption_length'), 'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'), - 'characters_reserved_per_url' => 23 + 'characters_reserved_per_url' => 23, ], 'media_attachments' => [ 'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')), @@ -115,7 +104,7 @@ class ApiV2Controller extends Controller 'image_matrix_limit' => 3686400, 'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024, 'video_frame_rate_limit' => 240, - 'video_matrix_limit' => 3686400 + 'video_matrix_limit' => 3686400, ], 'polls' => [ 'max_options' => 0, @@ -135,14 +124,15 @@ class ApiV2Controller extends Controller ], 'contact' => [ 'email' => config('instance.email'), - 'account' => $contact + 'account' => $contact, ], - 'rules' => $rules + 'rules' => $rules, ]; }); $res['registrations']['enabled'] = (bool) config_cache('pixelfed.open_registration'); $res['registrations']['approval_required'] = (bool) config_cache('instance.curated_registration.enabled'); + return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES); } @@ -154,7 +144,7 @@ class ApiV2Controller extends Controller */ public function search(Request $request) { - abort_if(!$request->user() || !$request->user()->token(), 403); + abort_if(! $request->user() || ! $request->user()->token(), 403); abort_unless($request->user()->tokenCan('read'), 403); $this->validate($request, [ @@ -167,18 +157,19 @@ class ApiV2Controller extends Controller 'resolve' => 'nullable', 'limit' => 'nullable|integer|max:40', 'offset' => 'nullable|integer', - 'following' => 'nullable' + 'following' => 'nullable', ]); - if($request->user()->has_roles && !UserRoleService::can('can-view-discover', $request->user()->id)) { + if ($request->user()->has_roles && ! UserRoleService::can('can-view-discover', $request->user()->id)) { return [ 'accounts' => [], 'hashtags' => [], - 'statuses' => [] + 'statuses' => [], ]; } - $mastodonMode = !$request->has('_pe'); + $mastodonMode = ! $request->has('_pe'); + return $this->json(SearchApiV2Service::query($request, $mastodonMode)); } @@ -194,7 +185,7 @@ class ApiV2Controller extends Controller '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') + 'cluster' => config('broadcasting.connections.pusher.options.cluster'), ] : []; } @@ -206,39 +197,39 @@ class ApiV2Controller extends Controller */ public function mediaUploadV2(Request $request) { - abort_if(!$request->user() || !$request->user()->token(), 403); + abort_if(! $request->user() || ! $request->user()->token(), 403); abort_unless($request->user()->tokenCan('write'), 403); $this->validate($request, [ 'file.*' => [ 'required_without:file', - 'mimetypes:' . config_cache('pixelfed.media_types'), - 'max:' . config_cache('pixelfed.max_photo_size'), + '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'), + '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' + '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) { + if ($user->last_active_at == null) { return []; } - if(empty($request->file('file'))) { + if (empty($request->file('file'))) { return response('', 422); } - $limitKey = 'compose:rate-limit:media-upload:' . $user->id; + $limitKey = 'compose:rate-limit:media-upload:'.$user->id; $limitTtl = now()->addMinutes(15); - $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { + $limitReached = Cache::remember($limitKey, $limitTtl, function () use ($user) { $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); return $dailyLimit >= 1250; @@ -247,23 +238,25 @@ class ApiV2Controller extends Controller $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; - }); + $accountSize = UserStorageService::get($user->id); + abort_if($accountSize === -1, 403, 'Invalid request.'); + $photo = $request->file('file'); + $fileSize = $photo->getSize(); + $sizeInKbs = (int) ceil($fileSize / 1000); + $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs; + + if ((bool) config_cache('pixelfed.enforce_account_limit') == true) { $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { - abort(403, 'Account size limit reached.'); + if ($updatedAccountSize >= $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) { + if (in_array($photo->getMimeType(), $mimes) == false) { abort(403, 'Invalid or unsupported mime type.'); } @@ -275,24 +268,24 @@ class ApiV2Controller extends Controller $settings = UserSetting::whereUserId($user->id)->first(); - if($settings && !empty($settings->compose_settings)) { + if ($settings && ! empty($settings->compose_settings)) { $compose = $settings->compose_settings; - if(isset($compose['default_license']) && $compose['default_license'] != 1) { + 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')) { + 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) { + if ($removeMedia) { MediaDeletePipeline::dispatch($removeMedia) ->onQueue('mmo') ->delay(now()->addMinutes(15)); @@ -310,7 +303,7 @@ class ApiV2Controller extends Controller $media->caption = $request->input('description'); $media->filter_class = $filterClass; $media->filter_name = $filterName; - if($license) { + if ($license) { $media->license = $license; } $media->save(); @@ -328,13 +321,18 @@ class ApiV2Controller extends Controller break; } + $user->storage_used = (int) $updatedAccountSize; + $user->storage_used_updated_at = now(); + $user->save(); + 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['preview_url'] = $media->url().'?v='.time(); $res['url'] = null; + return $this->json($res, 202); } } diff --git a/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php new file mode 100644 index 000000000..95e399720 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/DomainBlocksController.php @@ -0,0 +1,147 @@ +middleware(['auth:api', 'api.admin', 'scope:admin:read,admin:read:domain_blocks'])->only(['index', 'show']); + $this->middleware(['auth:api', 'api.admin', 'scope:admin:write,admin:write:domain_blocks'])->only(['create', 'update', 'delete']); + } + + public function index(Request $request) { + $this->validate($request, [ + 'limit' => 'sometimes|integer|max:100|min:1', + ]); + + $limit = $request->input('limit', 100); + + $res = Instance::moderated() + ->orderBy('id') + ->cursorPaginate($limit) + ->withQueryString(); + + return $this->json(DomainBlockResource::collection($res), [ + 'Link' => $this->linksForCollection($res) + ]); + } + + public function show(Request $request, $id) { + $domain_block = Instance::moderated()->find($id); + + if (!$domain_block) { + return $this->json([ 'error' => 'Record not found'], [], 404); + } + + return $this->json(new DomainBlockResource($domain_block)); + } + + public function create(Request $request) { + $this->validate($request, [ + 'domain' => 'required|string|min:1|max:120', + 'severity' => [ + 'sometimes', + Rule::in(['noop', 'silence', 'suspend']) + ], + 'reject_media' => 'sometimes|required|boolean', + 'reject_reports' => 'sometimes|required|boolean', + 'private_comment' => 'sometimes|string|min:1|max:1000', + 'public_comment' => 'sometimes|string|min:1|max:1000', + 'obfuscate' => 'sometimes|required|boolean' + ]); + + $domain = $request->input('domain'); + $severity = $request->input('severity', 'silence'); + $private_comment = $request->input('private_comment'); + + abort_if(!strpos($domain, '.'), 400, 'Invalid domain'); + abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain'); + + // This is because Pixelfed can't currently support wildcard domain blocks + // We have to find something that could plausibly be an instance + $parts = explode('.', $domain); + if ($parts[0] == '*') { + // If we only have two parts, e.g., "*", "example", then we want to fail: + abort_if(count($parts) <= 2, 400, 'Invalid domain: This API does not support wildcard domain blocks yet'); + + // Otherwise we convert the *.foo.example to foo.example + $domain = implode('.', array_slice($parts, 1)); + } + + // Double check we definitely haven't let anything through: + abort_if(str_contains($domain, '*'), 400, 'Invalid domain'); + + $existing_domain_block = Instance::moderated()->whereDomain($domain)->first(); + + if ($existing_domain_block) { + return $this->json([ + 'error' => 'A domain block already exists for this domain', + 'existing_domain_block' => new DomainBlockResource($existing_domain_block) + ], [], 422); + } + + $domain_block = Instance::updateOrCreate( + [ 'domain' => $domain ], + [ 'banned' => $severity === 'suspend', 'unlisted' => $severity === 'silence', 'notes' => [$private_comment]] + ); + + InstanceService::refresh(); + + return $this->json(new DomainBlockResource($domain_block)); + } + + public function update(Request $request, $id) { + $this->validate($request, [ + 'severity' => [ + 'sometimes', + Rule::in(['noop', 'silence', 'suspend']) + ], + 'reject_media' => 'sometimes|required|boolean', + 'reject_reports' => 'sometimes|required|boolean', + 'private_comment' => 'sometimes|string|min:1|max:1000', + 'public_comment' => 'sometimes|string|min:1|max:1000', + 'obfuscate' => 'sometimes|required|boolean' + ]); + + $severity = $request->input('severity', 'silence'); + $private_comment = $request->input('private_comment'); + + $domain_block = Instance::moderated()->find($id); + + if (!$domain_block) { + return $this->json([ 'error' => 'Record not found'], [], 404); + } + + $domain_block->banned = $severity === 'suspend'; + $domain_block->unlisted = $severity === 'silence'; + $domain_block->notes = [$private_comment]; + $domain_block->save(); + + InstanceService::refresh(); + + return $this->json(new DomainBlockResource($domain_block)); + } + + public function delete(Request $request, $id) { + $domain_block = Instance::moderated()->find($id); + + if (!$domain_block) { + return $this->json([ 'error' => 'Record not found'], [], 404); + } + + $domain_block->banned = false; + $domain_block->unlisted = false; + $domain_block->save(); + + InstanceService::refresh(); + + return $this->json(null, [], 200); + } +} diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index 4c27aa18e..cfd44969b 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -21,6 +21,7 @@ use App\Services\MediaStorageService; use App\Services\MediaTagService; use App\Services\SnowflakeService; use App\Services\UserRoleService; +use App\Services\UserStorageService; use App\Status; use App\Transformer\Api\MediaTransformer; use App\UserFilter; @@ -70,7 +71,7 @@ class ComposeController extends Controller 'filter_class' => 'nullable|alpha_dash|max:24', ]); - $user = Auth::user(); + $user = $request->user(); $profile = $user->profile; abort_if($user->has_roles && ! UserRoleService::can('can-post', $user->id), 403, 'Invalid permissions for this action'); @@ -84,21 +85,22 @@ class ComposeController extends Controller 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; - }); + $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; + $accountSize = UserStorageService::get($user->id); + abort_if($accountSize === -1, 403, 'Invalid request.'); + $photo = $request->file('file'); + $fileSize = $photo->getSize(); + $sizeInKbs = (int) ceil($fileSize / 1000); + $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs; + + if ((bool) config_cache('pixelfed.enforce_account_limit') == true) { $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { + if ($updatedAccountSize >= $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'); @@ -143,6 +145,10 @@ class ComposeController extends Controller break; } + $user->storage_used = (int) $updatedAccountSize; + $user->storage_used_updated_at = now(); + $user->save(); + Cache::forget($limitKey); $resource = new Fractal\Resource\Item($media, new MediaTransformer()); $res = $this->fractal->createData($resource)->toArray(); @@ -198,6 +204,7 @@ class ComposeController extends Controller ]; ImageOptimize::dispatch($media)->onQueue('mmo'); Cache::forget($limitKey); + UserStorageService::recalculateUpdateStorageUsed($request->user()->id); return $res; } @@ -218,6 +225,8 @@ class ComposeController extends Controller MediaStorageService::delete($media, true); + UserStorageService::recalculateUpdateStorageUsed($request->user()->id); + return response()->json([ 'msg' => 'Successfully deleted', 'code' => 200, @@ -494,17 +503,17 @@ class ComposeController extends Controller $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(); + // $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; - }); + // return $dailyLimit >= 1000; + // }); - abort_if($limitReached == true, 429); + // abort_if($limitReached == true, 429); $license = in_array($request->input('license'), License::keys()) ? $request->input('license') : null; @@ -626,7 +635,6 @@ class ComposeController extends Controller 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); diff --git a/app/Http/Controllers/DirectMessageController.php b/app/Http/Controllers/DirectMessageController.php index 7e66d30fc..1a30032cd 100644 --- a/app/Http/Controllers/DirectMessageController.php +++ b/app/Http/Controllers/DirectMessageController.php @@ -17,11 +17,11 @@ use App\Services\MediaService; use App\Services\StatusService; use App\Services\UserFilterService; use App\Services\UserRoleService; +use App\Services\UserStorageService; use App\Services\WebfingerService; use App\Status; use App\UserFilter; use App\Util\ActivityPub\Helpers; -use Cache; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -602,16 +602,19 @@ class DirectMessageController extends Controller $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; - }); + $accountSize = UserStorageService::get($user->id); + abort_if($accountSize === -1, 403, 'Invalid request.'); + $photo = $request->file('file'); + $fileSize = $photo->getSize(); + $sizeInKbs = (int) ceil($fileSize / 1000); + $updatedAccountSize = (int) $accountSize + (int) $sizeInKbs; + + if ((bool) config_cache('pixelfed.enforce_account_limit') == true) { $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { + if ($updatedAccountSize >= $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) { @@ -667,6 +670,10 @@ class DirectMessageController extends Controller ] ); + $user->storage_used = (int) $updatedAccountSize; + $user->storage_used_updated_at = now(); + $user->save(); + if ($recipient->domain) { $this->remoteDeliver($dm); } diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php new file mode 100644 index 000000000..881d31f01 --- /dev/null +++ b/app/Http/Controllers/GroupController.php @@ -0,0 +1,671 @@ +middleware('auth'); + abort_unless(config('groups.enabled'), 404); + } + + public function index(Request $request) + { + abort_if(! $request->user(), 404); + + return view('layouts.spa'); + } + + public function home(Request $request) + { + abort_if(! $request->user(), 404); + + return view('layouts.spa'); + } + + public function show(Request $request, $id, $path = false) + { + $group = Group::find($id); + + if (! $group || $group->status) { + return response()->view('groups.unavailable')->setStatusCode(404); + } + + if ($request->wantsJson()) { + return $this->showGroupObject($group); + } + + return view('layouts.spa', compact('id', 'path')); + } + + public function showStatus(Request $request, $gid, $sid) + { + $group = Group::find($gid); + $pid = optional($request->user())->profile_id ?? false; + + if (! $group || $group->status) { + return response()->view('groups.unavailable')->setStatusCode(404); + } + + if ($group->is_private) { + abort_if(! $request->user(), 404); + abort_if(! $group->isMember($pid), 404); + } + + $gp = GroupPost::whereGroupId($gid) + ->findOrFail($sid); + + return view('layouts.spa', compact('group', 'gp')); + } + + public function getGroup(Request $request, $id) + { + $group = Group::whereNull('status')->findOrFail($id); + $pid = optional($request->user())->profile_id ?? false; + + $group = $this->toJson($group, $pid); + + return response()->json($group, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + public function showStatusLikes(Request $request, $id, $sid) + { + $group = Group::findOrFail($id); + $user = $request->user(); + $pid = $user->profile_id; + abort_if(! $group->isMember($pid), 404); + $status = GroupPost::whereGroupId($id)->findOrFail($sid); + $likes = GroupLike::whereStatusId($sid) + ->cursorPaginate(10) + ->map(function ($l) use ($group) { + $account = AccountService::get($l->profile_id); + $account['url'] = "/groups/{$group->id}/user/{$account['id']}"; + + return $account; + }) + ->filter(function ($l) { + return $l && isset($l['id']); + }) + ->values(); + + return $likes; + } + + public function groupSettings(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + return view('groups.settings', compact('group')); + } + + public function joinGroup(Request $request, $id) + { + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if($group->isMember($pid), 404); + + if (! $request->user()->is_admin) { + abort_if(GroupService::getRejoinTimeout($group->id, $pid), 422, 'Cannot re-join this group for 24 hours after leaving or cancelling a request to join'); + } + + $member = new GroupMember; + $member->group_id = $group->id; + $member->profile_id = $pid; + $member->role = 'member'; + $member->local_group = true; + $member->local_profile = true; + $member->join_request = $group->is_private; + $member->save(); + + GroupService::delSelf($group->id, $pid); + GroupService::log( + $group->id, + $pid, + 'group:joined', + null, + GroupMember::class, + $member->id + ); + + $group = $this->toJson($group, $pid); + + return $group; + } + + public function updateGroup(Request $request, $id) + { + $this->validate($request, [ + 'description' => 'nullable|max:500', + 'membership' => 'required|in:all,local,private', + 'avatar' => 'nullable', + 'header' => 'nullable', + 'discoverable' => 'required', + 'activitypub' => 'required', + 'is_nsfw' => 'required', + 'category' => 'required|string|in:'.implode(',', GroupService::categories()), + ]); + + $pid = $request->user()->profile_id; + $group = Group::whereProfileId($pid)->findOrFail($id); + $member = GroupMember::whereGroupId($group->id)->whereProfileId($pid)->firstOrFail(); + + abort_if($member->role != 'founder', 403, 'Invalid group permission'); + + $metadata = $group->metadata; + $len = $group->is_private ? 12 : 4; + + if ($request->hasFile('avatar')) { + $avatar = $request->file('avatar'); + + if ($avatar) { + if (isset($metadata['avatar']) && + isset($metadata['avatar']['path']) && + Storage::exists($metadata['avatar']['path']) + ) { + Storage::delete($metadata['avatar']['path']); + } + + $fileName = 'avatar_'.strtolower(str_random($len)).'.'.$avatar->extension(); + $path = $avatar->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName); + $url = url(Storage::url($path)); + $metadata['avatar'] = [ + 'path' => $path, + 'url' => $url, + 'updated_at' => now(), + ]; + } + } + + if ($request->hasFile('header')) { + $header = $request->file('header'); + + if ($header) { + if (isset($metadata['header']) && + isset($metadata['header']['path']) && + Storage::exists($metadata['header']['path']) + ) { + Storage::delete($metadata['header']['path']); + } + + $fileName = 'header_'.strtolower(str_random($len)).'.'.$header->extension(); + $path = $header->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName); + $url = url(Storage::url($path)); + $metadata['header'] = [ + 'path' => $path, + 'url' => $url, + 'updated_at' => now(), + ]; + } + } + + $cat = GroupService::categoryById($group->category_id); + if ($request->category !== $cat['name']) { + $group->category_id = GroupCategory::whereName($request->category)->first()->id; + } + + $changes = null; + $group->description = e($request->input('description', null)); + $group->is_private = $request->input('membership') == 'private'; + $group->local_only = $request->input('membership') == 'local'; + $group->activitypub = $request->input('activitypub') == 'true'; + $group->discoverable = $request->input('discoverable') == 'true'; + $group->is_nsfw = $request->input('is_nsfw') == 'true'; + $group->metadata = $metadata; + if ($group->isDirty()) { + $changes = $group->getDirty(); + } + $group->save(); + + GroupService::log( + $group->id, + $pid, + 'group:settings:updated', + $changes + ); + + GroupService::del($group->id); + + $res = $this->toJson($group, $pid); + + return $res; + } + + protected function toJson($group, $pid = false) + { + return GroupService::get($group->id, $pid); + } + + public function groupLeave(Request $request, $id) + { + abort_if(! $request->user(), 404); + + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + + abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created'); + + abort_if(! $group->isMember($pid), 403, 'Not a member of group.'); + + GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete(); + GroupService::del($group->id); + GroupService::delSelf($group->id, $pid); + GroupService::setRejoinTimeout($group->id, $pid); + + return [200]; + } + + public function cancelJoinRequest(Request $request, $id) + { + abort_if(! $request->user(), 404); + + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + + abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created'); + abort_if($group->isMember($pid), 422, 'Cannot cancel approved join request, please leave group instead.'); + + GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete(); + GroupService::del($group->id); + GroupService::delSelf($group->id, $pid); + GroupService::setRejoinTimeout($group->id, $pid); + + return [200]; + } + + public function metaBlockSearch(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $type = $request->input('type'); + $item = $request->input('item'); + + switch ($type) { + case 'instance': + $res = Instance::whereDomain($item)->first(); + if ($res) { + abort_if(GroupBlock::whereGroupId($group->id)->whereInstanceId($res->id)->exists(), 400); + } + break; + + case 'user': + $res = Profile::whereUsername($item)->first(); + if ($res) { + abort_if(GroupBlock::whereGroupId($group->id)->whereProfileId($res->id)->exists(), 400); + } + if ($res->user_id != null) { + abort_if(User::whereIsAdmin(true)->whereId($res->user_id)->exists(), 400); + } + break; + } + + return response()->json((bool) $res, ($res ? 200 : 404)); + } + + public function reportCreate(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + + $id = $request->input('id'); + $type = $request->input('type'); + $types = [ + // original 3 + 'spam', + 'sensitive', + 'abusive', + + // new + 'underage', + 'violence', + 'copyright', + 'impersonation', + 'scam', + 'terrorism', + ]; + + $gp = GroupPost::whereGroupId($group->id)->find($id); + abort_if(! $gp, 422, 'Cannot report an invalid or deleted post'); + abort_if(! in_array($type, $types), 422, 'Invalid report type'); + abort_if($gp->profile_id === $pid, 422, 'Cannot report your own post'); + abort_if( + GroupReport::whereGroupId($group->id) + ->whereProfileId($pid) + ->whereItemType(GroupPost::class) + ->whereItemId($id) + ->exists(), + 422, + 'You already reported this' + ); + + $report = new GroupReport(); + $report->group_id = $group->id; + $report->profile_id = $pid; + $report->type = $type; + $report->item_type = GroupPost::class; + $report->item_id = $id; + $report->open = true; + $report->save(); + + GroupService::log( + $group->id, + $pid, + 'group:report:create', + [ + 'type' => $type, + 'report_id' => $report->id, + 'status_id' => $gp->status_id, + 'profile_id' => $gp->profile_id, + 'username' => optional(AccountService::get($gp->profile_id))['acct'], + 'gpid' => $gp->id, + 'url' => $gp->url(), + ], + GroupReport::class, + $report->id + ); + + return response([200]); + } + + public function reportAction(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'action' => 'required|in:cw,delete,ignore', + 'id' => 'required|string', + ]); + + $action = $request->input('action'); + $id = $request->input('id'); + + $report = GroupReport::whereGroupId($group->id) + ->findOrFail($id); + $status = Status::findOrFail($report->item_id); + $gp = GroupPost::whereGroupId($group->id) + ->whereStatusId($status->id) + ->firstOrFail(); + + switch ($action) { + case 'cw': + $status->is_nsfw = true; + $status->save(); + StatusService::del($status->id); + + GroupReport::whereGroupId($group->id) + ->whereItemType($report->item_type) + ->whereItemId($report->item_id) + ->update(['open' => false]); + + GroupService::log( + $group->id, + $pid, + 'group:moderation:action', + [ + 'type' => 'cw', + 'report_id' => $report->id, + 'status_id' => $status->id, + 'profile_id' => $status->profile_id, + 'status_url' => $gp->url(), + ], + GroupReport::class, + $report->id + ); + + return response()->json([200]); + break; + + case 'ignore': + GroupReport::whereGroupId($group->id) + ->whereItemType($report->item_type) + ->whereItemId($report->item_id) + ->update(['open' => false]); + + GroupService::log( + $group->id, + $pid, + 'group:moderation:action', + [ + 'type' => 'ignore', + 'report_id' => $report->id, + 'status_id' => $status->id, + 'profile_id' => $status->profile_id, + 'status_url' => $gp->url(), + ], + GroupReport::class, + $report->id + ); + + return response()->json([200]); + break; + } + } + + public function getMemberInteractionLimits(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $profile_id = $request->input('profile_id'); + abort_if(! $group->isMember($profile_id), 404); + $limits = GroupService::getInteractionLimits($group->id, $profile_id); + + return response()->json($limits); + } + + public function updateMemberInteractionLimits(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'profile_id' => 'required|exists:profiles,id', + 'can_post' => 'required', + 'can_comment' => 'required', + 'can_like' => 'required', + ]); + + $member = $request->input('profile_id'); + $can_post = $request->input('can_post'); + $can_comment = $request->input('can_comment'); + $can_like = $request->input('can_like'); + $account = AccountService::get($member); + + abort_if(! $account, 422, 'Invalid profile'); + abort_if(! $group->isMember($member), 422, 'Invalid profile'); + + $limit = GroupLimit::firstOrCreate([ + 'profile_id' => $member, + 'group_id' => $group->id, + ]); + + if ($limit->wasRecentlyCreated) { + abort_if(GroupLimit::whereGroupId($group->id)->count() >= 25, 422, 'limit_reached'); + } + + $previousLimits = $limit->limits; + + $limit->limits = [ + 'can_post' => $can_post, + 'can_comment' => $can_comment, + 'can_like' => $can_like, + ]; + $limit->save(); + + GroupService::clearInteractionLimits($group->id, $member); + + GroupService::log( + $group->id, + $pid, + 'group:member-limits:updated', + [ + 'profile_id' => $account['id'], + 'username' => $account['username'], + 'previousLimits' => $previousLimits, + 'newLimits' => $limit->limits, + ], + GroupLimit::class, + $limit->id + ); + + return $request->all(); + } + + public function showProfile(Request $request, $id, $pid) + { + $group = Group::find($id); + + if (! $group || $group->status) { + return response()->view('groups.unavailable')->setStatusCode(404); + } + + return view('layouts.spa'); + } + + public function showProfileByUsername(Request $request, $id, $pid) + { + abort_if(! $request->user(), 404); + if (! $request->user()) { + return redirect("/{$pid}"); + } + + $group = Group::find($id); + $cid = $request->user()->profile_id; + + if (! $group || $group->status) { + return response()->view('groups.unavailable')->setStatusCode(404); + } + + if (! $group->isMember($cid)) { + return redirect("/{$pid}"); + } + + $profile = Profile::whereUsername($pid)->first(); + + if (! $group->isMember($profile->id)) { + return redirect("/{$pid}"); + } + + if ($profile) { + $url = url("/groups/{$id}/user/{$profile->id}"); + + return redirect($url); + } + + abort(404, 'Invalid username'); + } + + public function groupInviteLanding(Request $request, $id) + { + abort(404, 'Not yet implemented'); + $group = Group::findOrFail($id); + + return view('groups.invite', compact('group')); + } + + public function groupShortLinkRedirect(Request $request, $hid) + { + $gid = HashidService::decode($hid); + $group = Group::findOrFail($gid); + + return redirect($group->url()); + } + + public function groupInviteClaim(Request $request, $id) + { + $group = GroupService::get($id); + abort_if(! $group || empty($group), 404); + + return view('groups.invite-claim', compact('group')); + } + + public function groupMemberInviteCheck(Request $request, $id) + { + abort_if(! $request->user(), 404); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + abort_if($group->isMember($pid), 422, 'Already a member'); + + $exists = GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists(); + + return response()->json([ + 'gid' => $id, + 'can_join' => (bool) $exists, + ]); + } + + public function groupMemberInviteAccept(Request $request, $id) + { + abort_if(! $request->user(), 404); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + abort_if($group->isMember($pid), 422, 'Already a member'); + + abort_if(! GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists(), 422); + + $gm = new GroupMember; + $gm->group_id = $id; + $gm->profile_id = $pid; + $gm->role = 'member'; + $gm->local_group = $group->local; + $gm->local_profile = true; + $gm->join_request = false; + $gm->save(); + + GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->delete(); + GroupService::del($id); + GroupService::delSelf($id, $pid); + + return ['next_url' => $group->url()]; + } + + public function groupMemberInviteDecline(Request $request, $id) + { + abort_if(! $request->user(), 404); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + abort_if($group->isMember($pid), 422, 'Already a member'); + + return ['next_url' => '/']; + } +} diff --git a/app/Http/Controllers/GroupFederationController.php b/app/Http/Controllers/GroupFederationController.php new file mode 100644 index 000000000..7f45f74a4 --- /dev/null +++ b/app/Http/Controllers/GroupFederationController.php @@ -0,0 +1,103 @@ +whereActivitypub(true)->findOrFail($id); + $res = $this->showGroupObject($group); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function showGroupObject($group) + { + return Cache::remember('ap:groups:object:' . $group->id, 3600, function() use($group) { + return [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $group->url(), + 'inbox' => $group->permalink('/inbox'), + 'name' => $group->name, + 'outbox' => $group->permalink('/outbox'), + 'summary' => $group->description, + 'type' => 'Group', + 'attributedTo' => [ + 'type' => 'Person', + 'id' => $group->admin->permalink() + ], + // 'endpoints' => [ + // 'sharedInbox' => config('app.url') . '/f/inbox' + // ], + 'preferredUsername' => 'gid_' . $group->id, + 'publicKey' => [ + 'id' => $group->permalink('#main-key'), + 'owner' => $group->permalink(), + 'publicKeyPem' => InstanceActor::first()->public_key, + ], + 'url' => $group->permalink() + ]; + + if($group->metadata && isset($group->metadata['avatar'])) { + $res['icon'] = [ + 'type' => 'Image', + 'url' => $group->metadata['avatar']['url'] + ]; + } + + if($group->metadata && isset($group->metadata['header'])) { + $res['image'] = [ + 'type' => 'Image', + 'url' => $group->metadata['header']['url'] + ]; + } + ksort($res); + return $res; + }); + } + + public function getStatusObject(Request $request, $gid, $sid) + { + $group = Group::whereLocal(true)->whereActivitypub(true)->findOrFail($gid); + $gp = GroupPost::whereGroupId($gid)->findOrFail($sid); + $status = Status::findOrFail($gp->status_id); + // permission check + + $res = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $gp->url(), + + 'type' => 'Note', + + 'summary' => null, + 'content' => $status->rendered ?? $status->caption, + 'inReplyTo' => null, + + 'published' => $status->created_at->toAtomString(), + 'url' => $gp->url(), + 'attributedTo' => $status->profile->permalink(), + 'to' => [ + 'https://www.w3.org/ns/activitystreams#Public', + $group->permalink('/followers'), + ], + 'cc' => [], + 'sensitive' => (bool) $status->is_nsfw, + 'attachment' => MediaService::activitypub($status->id), + 'target' => [ + 'type' => 'Collection', + 'id' => $group->permalink('/wall'), + 'attributedTo' => $group->permalink() + ] + ]; + // ksort($res); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/GroupPostController.php b/app/Http/Controllers/GroupPostController.php new file mode 100644 index 000000000..909037a00 --- /dev/null +++ b/app/Http/Controllers/GroupPostController.php @@ -0,0 +1,10 @@ +middleware('auth'); + } + + public function checkCreatePermission(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + $config = GroupService::config(); + if($request->user()->is_admin) { + $allowed = true; + } else { + $max = $config['limits']['user']['create']['max']; + $allowed = Group::whereProfileId($pid)->count() <= $max; + } + + return ['permission' => (bool) $allowed]; + } + + public function storeGroup(Request $request) + { + abort_if(!$request->user(), 404); + + $this->validate($request, [ + 'name' => 'required', + 'description' => 'nullable|max:500', + 'membership' => 'required|in:public,private,local' + ]); + + $pid = $request->user()->profile_id; + + $config = GroupService::config(); + abort_if($config['limits']['user']['create']['new'] == false && $request->user()->is_admin == false, 422, 'Invalid operation'); + $max = $config['limits']['user']['create']['max']; + // abort_if(Group::whereProfileId($pid)->count() <= $max, 422, 'Group limit reached'); + + $group = new Group; + $group->profile_id = $pid; + $group->name = $request->input('name'); + $group->description = $request->input('description', null); + $group->is_private = $request->input('membership') == 'private'; + $group->local_only = $request->input('membership') == 'local'; + $group->metadata = $request->input('configuration'); + $group->save(); + + GroupService::log($group->id, $pid, 'group:created'); + + $member = new GroupMember; + $member->group_id = $group->id; + $member->profile_id = $pid; + $member->role = 'founder'; + $member->local_group = true; + $member->local_profile = true; + $member->save(); + + GroupService::log( + $group->id, + $pid, + 'group:joined', + null, + GroupMember::class, + $member->id + ); + + return [ + 'id' => $group->id, + 'url' => $group->url() + ]; + } +} diff --git a/app/Http/Controllers/Groups/GroupsAdminController.php b/app/Http/Controllers/Groups/GroupsAdminController.php new file mode 100644 index 000000000..4bdf0f504 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsAdminController.php @@ -0,0 +1,353 @@ +middleware('auth'); + } + + public function getAdminTabs(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + abort_if($pid !== $group->profile_id, 404); + + $reqs = GroupMember::whereGroupId($group->id)->whereJoinRequest(true)->count(); + $mods = GroupReport::whereGroupId($group->id)->whereOpen(true)->count(); + $tabs = [ + 'moderation_count' => $mods > 99 ? '99+' : $mods, + 'request_count' => $reqs > 99 ? '99+' : $reqs + ]; + + return response()->json($tabs); + } + + public function getInteractionLogs(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $logs = GroupInteraction::whereGroupId($id) + ->latest() + ->paginate(10) + ->map(function($log) use($group) { + return [ + 'id' => $log->id, + 'profile' => GroupAccountService::get($group->id, $log->profile_id), + 'type' => $log->type, + 'metadata' => $log->metadata, + 'created_at' => $log->created_at->format('c') + ]; + }); + + return response()->json($logs, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getBlocks(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $blocks = [ + 'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->take(3)->pluck('name'), + 'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->take(3)->pluck('name'), + 'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->take(3)->pluck('name') + ]; + + return response()->json($blocks, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function exportBlocks(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $blocks = [ + 'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->pluck('name'), + 'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->pluck('name'), + 'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->pluck('name') + ]; + + $blocks['_created_at'] = now()->format('c'); + $blocks['_version'] = '1.0.0'; + ksort($blocks); + + return response()->streamDownload(function() use($blocks) { + echo json_encode($blocks, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + }); + } + + public function addBlock(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'item' => 'required', + 'type' => 'required|in:instance,user,moderate' + ]); + + $item = $request->input('item'); + $type = $request->input('type'); + + switch($type) { + case 'instance': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->instance_id = $instance->id; + $gb->name = $instance->domain; + $gb->is_user = false; + $gb->moderated = false; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:block:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + case 'user': + $profile = Profile::whereUsername($item)->first(); + abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->profile_id = $profile->id; + $gb->name = $profile->username; + $gb->is_user = true; + $gb->moderated = false; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:block:user', + [ + 'username' => $profile->username, + 'domain' => $profile->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + case 'moderate': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->instance_id = $instance->id; + $gb->name = $instance->domain; + $gb->is_user = false; + $gb->moderated = true; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:moderate:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + default: + return response()->json([], 422, []); + break; + } + } + + public function undoBlock(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'item' => 'required', + 'type' => 'required|in:instance,user,moderate' + ]); + + $item = $request->input('item'); + $type = $request->input('type'); + + switch($type) { + case 'instance': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereInstanceId($instance->id) + ->whereModerated(false) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:unblock:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + case 'user': + $profile = Profile::whereUsername($item)->first(); + abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereProfileId($profile->id) + ->whereIsUser(true) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:unblock:user', + [ + 'username' => $profile->username, + 'domain' => $profile->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + case 'moderate': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereInstanceId($instance->id) + ->whereModerated(true) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:moderate:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + default: + return response()->json([], 422, []); + break; + } + } + + public function getReportList(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $scope = $request->input('scope', 'open'); + + $list = GroupReport::selectRaw('id, profile_id, item_type, item_id, type, created_at, count(*) as total') + ->whereGroupId($group->id) + ->groupBy('item_id') + ->when($scope == 'open', function($query, $scope) { + return $query->whereOpen(true); + }) + ->latest() + ->simplePaginate(10) + ->map(function($report) use($group) { + $res = [ + 'id' => (string) $report->id, + 'profile' => GroupAccountService::get($group->id, $report->profile_id), + 'type' => $report->type, + 'created_at' => $report->created_at->format('c'), + 'total_count' => $report->total + ]; + + if($report->item_type === GroupPost::class) { + $res['status'] = GroupPostService::get($group->id, $report->item_id); + } + + return $res; + }); + return response()->json($list, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + +} diff --git a/app/Http/Controllers/Groups/GroupsApiController.php b/app/Http/Controllers/Groups/GroupsApiController.php new file mode 100644 index 000000000..13bbca640 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsApiController.php @@ -0,0 +1,84 @@ +middleware('auth'); + } + + protected function toJson($group, $pid = false) + { + return GroupService::get($group->id, $pid); + } + + public function getConfig(Request $request) + { + return GroupService::config(); + } + + public function getGroupAccount(Request $request, $gid, $pid) + { + $res = GroupAccountService::get($gid, $pid); + + return response()->json($res); + } + + public function getGroupCategories(Request $request) + { + $res = GroupService::categories(); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupsByCategory(Request $request) + { + $name = $request->input('name'); + $category = GroupCategory::whereName($name)->firstOrFail(); + $groups = Group::whereCategoryId($category->id) + ->simplePaginate(6) + ->map(function($group) { + return GroupService::get($group->id); + }) + ->filter(function($group) { + return $group; + }) + ->values(); + return $groups; + } + + public function getRecommendedGroups(Request $request) + { + return []; + } + + public function getSelfGroups(Request $request) + { + $selfOnly = $request->input('self') == true; + $memberOnly = $request->input('member') == true; + $pid = $request->user()->profile_id; + $res = GroupMember::whereProfileId($request->user()->profile_id) + ->when($selfOnly, function($q, $selfOnly) { + return $q->whereRole('founder'); + }) + ->when($memberOnly, function($q, $memberOnly) { + return $q->whereRole('member'); + }) + ->simplePaginate(4) + ->map(function($member) use($pid) { + $group = $member->group; + return $this->toJson($group, $pid); + }); + + return response()->json($res); + } +} diff --git a/app/Http/Controllers/Groups/GroupsCommentController.php b/app/Http/Controllers/Groups/GroupsCommentController.php new file mode 100644 index 000000000..435ed0d78 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsCommentController.php @@ -0,0 +1,361 @@ +validate($request, [ + 'gid' => 'required', + 'sid' => 'required', + 'cid' => 'sometimes', + 'limit' => 'nullable|integer|min:3|max:10' + ]); + + $pid = optional($request->user())->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $cid = $request->has('cid') && $request->input('cid') == 1; + $limit = $request->input('limit', 3); + $maxId = $request->input('max_id', 0); + + $group = Group::findOrFail($gid); + + abort_if($group->is_private && !$group->isMember($pid), 403, 'Not a member of group.'); + + $status = $cid ? GroupComment::findOrFail($sid) : GroupPost::findOrFail($sid); + + abort_if($status->group_id != $group->id, 400, 'Invalid group'); + + $replies = GroupComment::whereGroupId($group->id) + ->whereStatusId($status->id) + ->orderByDesc('id') + ->when($maxId, function($query, $maxId) { + return $query->where('id', '<', $maxId); + }) + ->take($limit) + ->get() + ->map(function($gp) use($pid) { + $status = GroupCommentService::get($gp['group_id'], $gp['id']); + $status['reply_count'] = $gp['reply_count']; + $status['url'] = $gp->url(); + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$gp['profile_id']}"); + return $status; + }); + + return $replies->toArray(); + } + + public function storeComment(Request $request) + { + $this->validate($request, [ + 'gid' => 'required|exists:groups,id', + 'sid' => 'required|exists:group_posts,id', + 'cid' => 'sometimes', + 'content' => 'required|string|min:1|max:1500' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $cid = $request->input('cid'); + $limit = $request->input('limit', 3); + $caption = e($request->input('content')); + + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time'); + + + $parent = $cid == 1 ? + GroupComment::findOrFail($sid) : + GroupPost::whereGroupId($gid)->findOrFail($sid); + // $autolink = Purify::clean(Autolink::create()->autolink($caption)); + // $autolink = str_replace('/discover/tags/', '/groups/' . $gid . '/topics/', $autolink); + + $status = new GroupComment; + $status->group_id = $group->id; + $status->profile_id = $pid; + $status->status_id = $parent->id; + $status->caption = Purify::clean($caption); + $status->visibility = 'public'; + $status->is_nsfw = false; + $status->local = true; + $status->save(); + + NewCommentPipeline::dispatch($parent, $status)->onQueue('groups'); + // todo: perform in job + $parent->reply_count = $parent->reply_count ? $parent->reply_count + $parent->reply_count : 1; + $parent->save(); + GroupPostService::del($parent->group_id, $parent->id); + + GroupService::log( + $group->id, + $pid, + 'group:comment:created', + [ + 'type' => 'group:post:comment', + 'status_id' => $status->id + ], + GroupPost::class, + $status->id + ); + + //GroupCommentPipeline::dispatch($parent, $status, $gp); + //NewStatusPipeline::dispatch($status, $gp); + //GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + + // todo: perform in job + $s = GroupCommentService::get($status->group_id, $status->id); + + $s['pf_type'] = 'text'; + $s['visibility'] = 'public'; + $s['url'] = $status->url(); + + return $s; + } + + public function storeCommentPhoto(Request $request) + { + $this->validate($request, [ + 'gid' => 'required|exists:groups,id', + 'sid' => 'required|exists:group_posts,id', + 'photo' => 'required|image' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $limit = $request->input('limit', 3); + $caption = $request->input('content'); + + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time'); + $parent = GroupPost::whereGroupId($gid)->findOrFail($sid); + + $status = new GroupComment; + $status->status_id = $parent->id; + $status->group_id = $group->id; + $status->profile_id = $pid; + $status->caption = Purify::clean($caption); + $status->visibility = 'draft'; + $status->is_nsfw = false; + $status->save(); + + $photo = $request->file('photo'); + $storagePath = GroupMediaService::path($group->id, $pid, $status->id); + $storagePath = 'public/g/' . $group->id . '/p/' . $parent->id; + $path = $photo->storePublicly($storagePath); + + $media = new GroupMedia(); + $media->group_id = $group->id; + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->media_path = $path; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->save(); + + ImageResizePipeline::dispatchSync($media); + ImageS3UploadPipeline::dispatchSync($media); + + // $gp = new GroupPost; + // $gp->group_id = $group->id; + // $gp->profile_id = $pid; + // $gp->type = 'reply:photo'; + // $gp->status_id = $status->id; + // $gp->in_reply_to_id = $parent->id; + // $gp->save(); + + // GroupService::log( + // $group->id, + // $pid, + // 'group:comment:created', + // [ + // 'type' => $gp->type, + // 'status_id' => $status->id + // ], + // GroupPost::class, + // $gp->id + // ); + + // todo: perform in job + // $parent->reply_count = Status::whereInReplyToId($parent->id)->count(); + // $parent->save(); + // StatusService::del($parent->id); + // GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + + // delay response while background job optimizes media + // sleep(5); + + // todo: perform in job + $s = GroupCommentService::get($status->group_id, $status->id); + + // $s['pf_type'] = 'text'; + // $s['visibility'] = 'public'; + // $s['url'] = $gp->url(); + + return $s; + } + + public function deleteComment(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'id' => 'required|integer|min:1', + 'gid' => 'required|integer|min:1' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $gp = GroupComment::whereGroupId($group->id)->findOrFail($request->input('id')); + abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403); + + $parent = GroupPost::find($gp->status_id); + abort_if(!$parent, 422, 'Invalid parent'); + + DeleteCommentPipeline::dispatch($parent, $gp)->onQueue('groups'); + GroupService::log( + $group->id, + $pid, + 'group:status:deleted', + [ + 'type' => $gp->type, + 'status_id' => $gp->id, + ], + GroupComment::class, + $gp->id + ); + $gp->delete(); + + if($request->wantsJson()) { + return response()->json(['Status successfully deleted.']); + } else { + return redirect('/groups/feed'); + } + } + + public function likePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group || $gid != $group['id'], 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupCommentService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::firstOrCreate([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'comment_id' => $sid, + ]); + + if($like->wasRecentlyCreated) { + // update parent post like count + $parent = GroupComment::find($sid); + abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count + 1; + $parent->save(); + GroupsLikeService::add($pid, $sid); + // invalidate cache + GroupCommentService::del($gid, $sid); + $count++; + GroupService::log( + $gid, + $pid, + 'group:like', + null, + GroupLike::class, + $like->id + ); + } + + $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count]; + + return $response; + } + + public function unlikePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group || $gid != $group['id'], 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupCommentService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::where([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'comment_id' => $sid, + ])->first(); + + if($like) { + $like->delete(); + $parent = GroupComment::find($sid); + abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count - 1; + $parent->save(); + GroupsLikeService::remove($pid, $sid); + // invalidate cache + GroupCommentService::del($gid, $sid); + $count--; + } + + $response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count]; + + return $response; + } +} diff --git a/app/Http/Controllers/Groups/GroupsDiscoverController.php b/app/Http/Controllers/Groups/GroupsDiscoverController.php new file mode 100644 index 000000000..2194807de --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsDiscoverController.php @@ -0,0 +1,57 @@ +middleware('auth'); + } + + public function getDiscoverPopular(Request $request) + { + abort_if(!$request->user(), 404); + $groups = Group::orderByDesc('member_count') + ->take(12) + ->pluck('id') + ->map(function($id) { + return GroupService::get($id); + }) + ->filter(function($id) { + return $id; + }) + ->take(6) + ->values(); + return $groups; + } + + public function getDiscoverNew(Request $request) + { + abort_if(!$request->user(), 404); + $groups = Group::latest() + ->take(12) + ->pluck('id') + ->map(function($id) { + return GroupService::get($id); + }) + ->filter(function($id) { + return $id; + }) + ->take(6) + ->values(); + return $groups; + } +} diff --git a/app/Http/Controllers/Groups/GroupsFeedController.php b/app/Http/Controllers/Groups/GroupsFeedController.php new file mode 100644 index 000000000..bb04e2487 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsFeedController.php @@ -0,0 +1,188 @@ +middleware('auth'); + } + + public function getSelfFeed(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + $limit = $request->input('limit', 5); + $page = $request->input('page'); + $initial = $request->has('initial'); + + if($initial) { + $res = Cache::remember('groups:self:feed:' . $pid, 900, function() use($pid) { + return $this->getSelfFeedV0($pid, 5, null); + }); + } else { + abort_if($page && $page > 5, 422); + $res = $this->getSelfFeedV0($pid, $limit, $page); + } + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + protected function getSelfFeedV0($pid, $limit, $page) + { + return GroupPost::join('group_members', 'group_posts.group_id', 'group_members.group_id') + ->select('group_posts.*', 'group_members.group_id', 'group_members.profile_id') + ->where('group_members.profile_id', $pid) + ->whereIn('group_posts.type', ['text', 'photo', 'video']) + ->orderByDesc('group_posts.id') + ->limit($limit) + // ->pluck('group_posts.status_id') + ->simplePaginate($limit) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + + if(!$status) { + return false; + } + + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = url("/groups/{$gp['group_id']}/p/{$gp['id']}"); + $status['group'] = GroupService::get($gp['group_id']); + $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + + return $status; + }); + } + + public function getGroupProfileFeed(Request $request, $id, $pid) + { + abort_if(!$request->user(), 404); + $cid = $request->user()->profile_id; + + $group = Group::findOrFail($id); + abort_if(!$group->isMember($pid), 404); + + $feed = GroupPost::whereGroupId($id) + ->whereProfileId($pid) + ->latest() + ->paginate(3) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + $status['account']['url'] = "/groups/{$gp['group_id']}/user/{$status['account']['id']}"; + + return $status; + }) + ->filter(function($status) { + return $status; + }); + + return $feed; + } + + public function getGroupFeed(Request $request, $id) + { + $group = Group::findOrFail($id); + $user = $request->user(); + $pid = optional($user)->profile_id ?? false; + abort_if(!$group->isMember($pid), 404); + $max = $request->input('max_id'); + $limit = $request->limit ?? 3; + $filtered = $user ? UserFilterService::filters($user->profile_id) : []; + + // $posts = GroupPost::whereGroupId($group->id) + // ->when($maxId, function($q, $maxId) { + // return $q->where('status_id', '<', $maxId); + // }) + // ->whereNull('in_reply_to_id') + // ->orderByDesc('status_id') + // ->simplePaginate($limit) + // ->map(function($gp) use($pid) { + // $status = StatusService::get($gp['status_id'], false); + // if(!$status) { + // return false; + // } + // $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']); + // $status['favourites_count'] = LikeService::count($gp['status_id']); + // $status['pf_type'] = $gp['type']; + // $status['visibility'] = 'public'; + // $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + // $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + + // return $status; + // })->filter(function($status) { + // return $status; + // }); + // return $posts; + + Cache::remember('api:v1:timelines:public:cache_check', 10368000, function() use($id) { + if(GroupFeedService::count($id) == 0) { + GroupFeedService::warmCache($id, true, 400); + } + }); + + if ($max) { + $feed = GroupFeedService::getRankedMaxId($id, $max, $limit); + } else { + $feed = GroupFeedService::get($id, 0, $limit); + } + + $res = collect($feed) + ->map(function($k) use($user, $id) { + $status = GroupPostService::get($id, $k); + if($status && $user) { + $pid = $user->profile_id; + $sid = $status['account']['id']; + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $status['id']); + $status['favourites_count'] = GroupsLikeService::count($status['id']); + $status['relationship'] = $pid == $sid ? [] : RelationshipService::get($pid, $sid); + } + return $status; + }) + ->filter(function($s) use($filtered) { + return $s && in_array($s['account']['id'], $filtered) == false; + }) + ->values() + ->toArray(); + + return $res; + } +} diff --git a/app/Http/Controllers/Groups/GroupsMemberController.php b/app/Http/Controllers/Groups/GroupsMemberController.php new file mode 100644 index 000000000..3bfe086a2 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsMemberController.php @@ -0,0 +1,214 @@ +validate($request, [ + 'gid' => 'required', + 'limit' => 'nullable|integer|min:3|max:10' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $members = GroupMember::whereGroupId($gid) + ->whereJoinRequest(false) + ->simplePaginate(10) + ->map(function($member) use($pid) { + $account = AccountService::get($member['profile_id']); + $account['role'] = $member['role']; + $account['joined'] = $member['created_at']; + $account['following'] = $pid != $member['profile_id'] ? + FollowerService::follows($pid, $member['profile_id']) : + null; + $account['url'] = url("/groups/{$member->group_id}/user/{$member['profile_id']}"); + return $account; + }); + + return response()->json($members->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupMemberJoinRequests(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + return GroupMember::whereGroupId($group->id) + ->whereJoinRequest(true) + ->whereNull('rejected_at') + ->paginate(10) + ->map(function($member) { + return AccountService::get($member->profile_id); + }); + } + + public function handleGroupMemberJoinRequest(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + $mid = $request->input('pid'); + abort_if($group->isMember($mid), 404); + + $this->validate($request, [ + 'gid' => 'required', + 'pid' => 'required', + 'action' => 'required|in:approve,reject' + ]); + + $action = $request->input('action'); + + $member = GroupMember::whereGroupId($group->id) + ->whereProfileId($mid) + ->firstOrFail(); + + if($action == 'approve') { + MemberJoinApprovedPipeline::dispatch($member)->onQueue('groups'); + } else if ($action == 'reject') { + MemberJoinRejectedPipeline::dispatch($member)->onQueue('groups'); + } + + return $request->all(); + } + + public function getGroupMember(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'pid' => 'required' + ]); + + abort_if(!$request->user(), 404); + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $member_id = $request->input('pid'); + $member = GroupMember::whereGroupId($gid) + ->whereProfileId($member_id) + ->firstOrFail(); + + $account = GroupAccountService::get($group->id, $member['profile_id']); + $account['role'] = $member['role']; + $account['joined'] = $member['created_at']; + $account['following'] = $pid != $member['profile_id'] ? + FollowerService::follows($pid, $member['profile_id']) : + null; + $account['url'] = url("/groups/{$gid}/user/{$member_id}"); + + return response()->json($account, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupMemberCommonIntersections(Request $request) + { + abort_if(!$request->user(), 404); + $cid = $request->user()->profile_id; + + // $this->validate($request, [ + // 'gid' => 'required', + // 'pid' => 'required' + // ]); + + $gid = $request->input('gid'); + $pid = $request->input('pid'); + + if($pid === $cid) { + return []; + } + + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($cid), 404); + abort_if(!$group->isMember($pid), 404); + + $self = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr') + ->whereProfileId($cid) + ->groupBy('hashtag_id') + ->orderByDesc('countr') + ->take(20) + ->pluck('hashtag_id'); + $user = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr') + ->whereProfileId($pid) + ->groupBy('hashtag_id') + ->orderByDesc('countr') + ->take(20) + ->pluck('hashtag_id'); + + $topics = $self->intersect($user) + ->values() + ->shuffle() + ->take(3) + ->map(function($id) use($group) { + $tag = GroupHashtagService::get($id); + $tag['url'] = url("/groups/{$group->id}/topics/{$tag['slug']}?src=upt"); + return $tag; + }); + + // $friends = DB::table('followers as u') + // ->join('followers as s', 'u.following_id', '=', 's.following_id') + // ->where('s.profile_id', $cid) + // ->where('u.profile_id', $pid) + // ->inRandomOrder() + // ->take(10) + // ->pluck('s.following_id') + // ->map(function($id) use($gid) { + // $res = AccountService::get($id); + // $res['url'] = url("/groups/{$gid}/user/{$id}"); + // return $res; + // }); + $mutualGroups = GroupService::mutualGroups($cid, $pid, [$gid]); + + $mutualFriends = collect(FollowerService::mutualIds($cid, $pid)) + ->map(function($id) use($gid) { + $res = AccountService::get($id); + if(GroupService::isMember($gid, $id)) { + $res['url'] = url("/groups/{$gid}/user/{$id}"); + } else if(!$res['local']) { + $res['url'] = url("/i/web/profile/_/{$id}"); + } + return $res; + }); + $mutualFriendsCount = FollowerService::mutualCount($cid, $pid); + + $res = [ + 'groups_count' => $mutualGroups['count'], + 'groups' => $mutualGroups['groups'], + 'topics' => $topics, + 'friends_count' => $mutualFriendsCount, + 'friends' => $mutualFriends, + ]; + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsMetaController.php b/app/Http/Controllers/Groups/GroupsMetaController.php new file mode 100644 index 000000000..bc1e58b33 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsMetaController.php @@ -0,0 +1,31 @@ +middleware('auth'); + } + + public function deleteGroup(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $group->status = "delete"; + $group->save(); + GroupService::del($group->id); + return [200]; + } +} diff --git a/app/Http/Controllers/Groups/GroupsNotificationsController.php b/app/Http/Controllers/Groups/GroupsNotificationsController.php new file mode 100644 index 000000000..dafc6c821 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsNotificationsController.php @@ -0,0 +1,55 @@ +middleware('auth'); + } + + public function selfGlobalNotifications(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + + $res = Notification::whereProfileId($pid) + ->where('action', 'like', 'group%') + ->latest() + ->paginate(10) + ->map(function($n) { + $res = [ + 'id' => $n->id, + 'type' => $n->action, + 'account' => AccountService::get($n->actor_id), + 'object' => [ + 'id' => $n->item_id, + 'type' => last(explode('\\', $n->item_type)), + ], + 'created_at' => $n->created_at->format('c') + ]; + + if($res['object']['type'] == 'Status' || in_array($n->action, ['group:comment'])) { + $res['status'] = StatusService::get($n->item_id, false); + $res['group'] = GroupService::get($res['status']['gid']); + } + + if($res['object']['type'] == 'Group') { + $res['group'] = GroupService::get($n->item_id); + } + + return $res; + }); + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsPostController.php b/app/Http/Controllers/Groups/GroupsPostController.php new file mode 100644 index 000000000..11b4799fe --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsPostController.php @@ -0,0 +1,420 @@ +middleware('auth'); + } + + public function storePost(Request $request) + { + $this->validate($request, [ + 'group_id' => 'required|exists:groups,id', + 'caption' => 'sometimes|string|max:'.config_cache('pixelfed.max_caption_length', 500), + 'pollOptions' => 'sometimes|array|min:1|max:4' + ]); + + $group = Group::findOrFail($request->input('group_id')); + $pid = $request->user()->profile_id; + $caption = $request->input('caption'); + $type = $request->input('type', 'text'); + + abort_if(!GroupService::canPost($group->id, $pid), 422, 'You cannot create new posts at this time'); + + if($type == 'text') { + abort_if(strlen(e($caption)) == 0, 403); + } + + $gp = new GroupPost; + $gp->group_id = $group->id; + $gp->profile_id = $pid; + $gp->caption = e($caption); + $gp->type = $type; + $gp->visibility = 'draft'; + $gp->save(); + + $status = $gp; + + NewPostPipeline::dispatchSync($gp); + + // NewStatusPipeline::dispatch($status, $gp); + + if($type == 'poll') { + // Polls not supported yet + // $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(); + // sleep(5); + } + if($type == 'photo') { + $photo = $request->file('photo'); + $storagePath = GroupMediaService::path($group->id, $pid, $status->id); + // $storagePath = 'public/g/' . $group->id . '/p/' . $status->id; + $path = $photo->storePublicly($storagePath); + // $hash = \hash_file('sha256', $photo); + + $media = new GroupMedia(); + $media->group_id = $group->id; + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->media_path = $path; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->save(); + + // Bus::chain([ + // new ImageResizePipeline($media), + // new ImageS3UploadPipeline($media), + // ])->dispatch($media); + + ImageResizePipeline::dispatchSync($media); + ImageS3UploadPipeline::dispatchSync($media); + // ImageOptimize::dispatch($media); + // delay response while background job optimizes media + // sleep(5); + } + if($type == 'video') { + $video = $request->file('video'); + $storagePath = 'public/g/' . $group->id . '/p/' . $status->id; + $path = $video->storePublicly($storagePath); + $hash = \hash_file('sha256', $video); + + $media = new Media(); + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->user_id = $request->user()->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $video->getSize(); + $media->mime = $video->getMimeType(); + $media->save(); + + VideoThumbnail::dispatch($media); + sleep(15); + } + + GroupService::log( + $group->id, + $pid, + 'group:status:created', + [ + 'type' => $gp->type, + 'status_id' => $status->id + ], + GroupPost::class, + $gp->id + ); + + $s = GroupPostService::get($status->group_id, $status->id); + GroupFeedService::add($group->id, $gp->id); + Cache::forget('groups:self:feed:' . $pid); + + $s['pf_type'] = $type; + $s['visibility'] = 'public'; + $s['url'] = $gp->url(); + + if($type == 'poll') { + $s['poll'] = PollService::get($status->id); + } + + $group->last_active_at = now(); + $group->save(); + + return $s; + } + + public function deletePost(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'id' => 'required|integer|min:1', + 'gid' => 'required|integer|min:1' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $gp = GroupPost::whereGroupId($status->group_id)->findOrFail($request->input('id')); + abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403); + $cached = GroupPostService::get($status->group_id, $status->id); + + if($cached) { + $cached = collect($cached)->filter(function($r, $k) { + return in_array($k, [ + 'id', + 'sensitive', + 'pf_type', + 'media_attachments', + 'content_text', + 'created_at' + ]); + }); + } + + GroupService::log( + $status->group_id, + $request->user()->profile_id, + 'group:status:deleted', + [ + 'type' => $gp->type, + 'status_id' => $status->id, + 'original' => $cached + ], + GroupPost::class, + $gp->id + ); + + $user = $request->user(); + + // if($status->profile_id != $user->profile->id && + // $user->is_admin == true && + // $status->uri == null + // ) { + // $media = $status->media; + + // $ai = new AccountInterstitial; + // $ai->user_id = $status->profile->user_id; + // $ai->type = 'post.removed'; + // $ai->view = 'account.moderation.post.removed'; + // $ai->item_type = 'App\Status'; + // $ai->item_id = $status->id; + // $ai->has_media = (bool) $media->count(); + // $ai->blurhash = $media->count() ? $media->first()->blurhash : null; + // $ai->meta = json_encode([ + // 'caption' => $status->caption, + // 'created_at' => $status->created_at, + // 'type' => $status->type, + // 'url' => $status->url(), + // 'is_nsfw' => $status->is_nsfw, + // 'scope' => $status->scope, + // 'reblog' => $status->reblog_of_id, + // 'likes_count' => $status->likes_count, + // 'reblogs_count' => $status->reblogs_count, + // ]); + // $ai->save(); + + // $u = $status->profile->user; + // $u->has_interstitial = true; + // $u->save(); + // } + + if($status->in_reply_to_id) { + $parent = GroupPost::find($status->in_reply_to_id); + if($parent) { + $parent->reply_count = GroupPost::whereInReplyToId($parent->id)->count(); + $parent->save(); + GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + } + } + + GroupPostService::del($group->id, $gp->id); + GroupFeedService::del($group->id, $gp->id); + if ($status->profile_id == $user->profile->id || $user->is_admin == true) { + // Cache::forget('profile:status_count:'.$status->profile_id); + StatusDelete::dispatch($status); + } + + if($request->wantsJson()) { + return response()->json(['Status successfully deleted.']); + } else { + return redirect($user->url()); + } + } + + public function likePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group, 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupPostService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::firstOrCreate([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'status_id' => $sid, + ]); + + if($like->wasRecentlyCreated) { + // update parent post like count + $parent = GroupPost::whereGroupId($gid)->find($sid); + abort_if(!$parent, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count + 1; + $parent->save(); + GroupsLikeService::add($pid, $sid); + // invalidate cache + GroupPostService::del($gid, $sid); + $count++; + GroupService::log( + $gid, + $pid, + 'group:like', + null, + GroupLike::class, + $like->id + ); + } + // if (GroupLike::whereGroupId($gid)->whereStatusId($sid)->whereProfileId($pid)->exists()) { + // $like = GroupLike::whereProfileId($pid)->whereStatusId($sid)->firstOrFail(); + // // UnlikePipeline::dispatch($like); + // $count = $gp->likes_count - 1; + // $action = 'group:unlike'; + // } else { + // $count = $gp->likes_count; + // $like = GroupLike::firstOrCreate([ + // 'group_id' => $gid, + // 'profile_id' => $pid, + // 'status_id' => $sid + // ]); + // if($like->wasRecentlyCreated == true) { + // $count++; + // $gp->likes_count = $count; + // $like->save(); + // $gp->save(); + // // LikePipeline::dispatch($like); + // $action = 'group:like'; + // } + // } + + + // Cache::forget('status:'.$status->id.':likedby:userid:'.$request->user()->id); + // StatusService::del($status->id); + + $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count]; + + return $response; + } + + public function unlikePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group, 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupPostService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::where([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'status_id' => $sid, + ])->first(); + + if($like) { + $like->delete(); + $parent = GroupPost::whereGroupId($gid)->find($sid); + abort_if(!$parent, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count - 1; + $parent->save(); + GroupsLikeService::remove($pid, $sid); + // invalidate cache + GroupPostService::del($gid, $sid); + $count--; + } + + $response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count]; + + return $response; + } + + public function getGroupMedia(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'type' => 'required|in:photo,video' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $type = $request->input('type'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $media = GroupPost::whereGroupId($gid) + ->whereType($type) + ->latest() + ->simplePaginate(20) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + return $status; + })->filter(function($status) { + return $status; + }); + + return response()->json($media->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsSearchController.php b/app/Http/Controllers/Groups/GroupsSearchController.php new file mode 100644 index 000000000..560436f46 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsSearchController.php @@ -0,0 +1,221 @@ +middleware('auth'); + } + + public function inviteFriendsToGroup(Request $request) + { + abort_if(!$request->user(), 404); + $this->validate($request, [ + 'uids' => 'required', + 'g' => 'required', + ]); + $uid = $request->input('uids'); + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + abort_if( + GroupInvitation::whereGroupId($group->id) + ->whereFromProfileId($pid) + ->count() >= 20, + 422, + 'Invite limit reached' + ); + + $profiles = collect($uid) + ->map(function($u) { + return Profile::find($u); + }) + ->filter(function($u) use($pid) { + return $u && + $u->id != $pid && + isset($u->id) && + Follower::whereFollowingId($pid) + ->whereProfileId($u->id) + ->exists(); + }) + ->filter(function($u) use($group, $pid) { + return GroupInvitation::whereGroupId($group->id) + ->whereFromProfileId($pid) + ->whereToProfileId($u->id) + ->exists() == false; + }) + ->each(function($u) use($gid, $pid) { + $gi = new GroupInvitation; + $gi->group_id = $gid; + $gi->from_profile_id = $pid; + $gi->to_profile_id = $u->id; + $gi->to_local = true; + $gi->from_local = $u->domain == null; + $gi->save(); + // GroupMemberInvite::dispatch($gi); + }); + return [200]; + } + + public function searchFriendsToInvite(Request $request) + { + abort_if(!$request->user(), 404); + $this->validate($request, [ + 'q' => 'required|min:2|max:40', + 'g' => 'required', + 'v' => 'required|in:0.2' + ]); + $q = $request->input('q'); + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + + $res = Profile::where('username', 'like', "%{$q}%") + ->whereNull('profiles.domain') + ->join('followers', 'profiles.id', '=', 'followers.profile_id') + ->where('followers.following_id', $pid) + ->take(10) + ->get() + ->filter(function($p) use($group) { + return $group->isMember($p->profile_id) == false; + }) + ->filter(function($p) use($group, $pid) { + return GroupInvitation::whereGroupId($group->id) + ->whereFromProfileId($pid) + ->whereToProfileId($p->profile_id) + ->exists() == false; + }) + ->map(function($gm) use ($gid) { + $a = AccountService::get($gm->profile_id); + return [ + 'id' => (string) $gm->profile_id, + 'username' => $a['acct'], + 'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search") + ]; + }) + ->values(); + + return $res; + } + + public function searchGlobalResults(Request $request) + { + abort_if(!$request->user(), 404); + $this->validate($request, [ + 'q' => 'required|min:2|max:140', + 'v' => 'required|in:0.2' + ]); + $q = $request->input('q'); + + if(str_starts_with($q, 'https://')) { + $res = Helpers::getSignedFetch($q); + if($res && $res = json_decode($res, true)) { + + } + if($res && isset($res['type']) && in_array($res['type'], ['Group', 'Note', 'Page'])) { + if($res['type'] === 'Group') { + return GroupActivityPubService::fetchGroup($q, true); + } + $resp = GroupActivityPubService::fetchGroupPost($q, true); + $resp['name'] = 'Group Post'; + $resp['url'] = '/groups/' . $resp['group_id'] . '/p/' . $resp['id']; + return [$resp]; + } + } + return Group::whereNull('status') + ->where('name', 'like', '%' . $q . '%') + ->orderBy('id') + ->take(10) + ->pluck('id') + ->map(function($group) { + return GroupService::get($group); + }); + } + + public function searchLocalAutocomplete(Request $request) + { + abort_if(!$request->user(), 404); + $this->validate($request, [ + 'q' => 'required|min:2|max:40', + 'g' => 'required', + 'v' => 'required|in:0.2' + ]); + $q = $request->input('q'); + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + + $res = GroupMember::whereGroupId($gid) + ->join('profiles', 'group_members.profile_id', '=', 'profiles.id') + ->where('profiles.username', 'like', "%{$q}%") + ->take(10) + ->get() + ->map(function($gm) use ($gid) { + $a = AccountService::get($gm->profile_id); + return [ + 'username' => $a['username'], + 'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search") + ]; + }); + return $res; + } + + public function searchAddRecent(Request $request) + { + $this->validate($request, [ + 'q' => 'required|min:2|max:40', + 'g' => 'required', + ]); + $q = $request->input('q'); + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + + $key = 'groups:search:recent:'.$gid.':pid:'.$pid; + $ttl = now()->addDays(14); + $res = Cache::get($key); + if(!$res) { + $val = json_encode([$q]); + } else { + $ex = collect(json_decode($res)) + ->prepend($q) + ->unique('value') + ->slice(0, 3) + ->values() + ->all(); + $val = json_encode($ex); + } + Cache::put($key, $val, $ttl); + return 200; + } + + public function searchGetRecent(Request $request) + { + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + $key = 'groups:search:recent:'.$gid.':pid:'.$pid; + return Cache::get($key); + } +} diff --git a/app/Http/Controllers/Groups/GroupsTopicController.php b/app/Http/Controllers/Groups/GroupsTopicController.php new file mode 100644 index 000000000..c3d8ecda7 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsTopicController.php @@ -0,0 +1,133 @@ +middleware('auth'); + } + + public function groupTopics(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $posts = GroupPostHashtag::join('group_hashtags', 'group_hashtags.id', '=', 'group_post_hashtags.hashtag_id') + ->selectRaw('group_hashtags.*, group_post_hashtags.*, count(group_post_hashtags.hashtag_id) as ht_count') + ->where('group_post_hashtags.group_id', $gid) + ->orderByDesc('ht_count') + ->limit(10) + ->pluck('group_post_hashtags.hashtag_id', 'ht_count') + ->map(function($id, $key) use ($gid) { + $tag = GroupHashtag::find($id); + return [ + 'hid' => $id, + 'name' => $tag->name, + 'url' => url("/groups/{$gid}/topics/{$tag->slug}"), + 'count' => $key + ]; + })->values(); + + return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function groupTopicTag(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'name' => 'required' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $limit = $request->input('limit', 3); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $name = $request->input('name'); + $hashtag = GroupHashtag::whereName($name)->first(); + + if(!$hashtag) { + return []; + } + + // $posts = GroupPost::whereGroupId($gid) + // ->select('status_hashtags.*', 'group_posts.*') + // ->where('status_hashtags.hashtag_id', $hashtag->id) + // ->join('status_hashtags', 'group_posts.status_id', '=', 'status_hashtags.status_id') + // ->orderByDesc('group_posts.status_id') + // ->simplePaginate($limit) + // ->map(function($gp) use($pid) { + // $status = StatusService::get($gp['status_id'], false); + // if(!$status) { + // return false; + // } + // $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']); + // $status['favourites_count'] = LikeService::count($gp['status_id']); + // $status['pf_type'] = $gp['type']; + // $status['visibility'] = 'public'; + // $status['url'] = $gp->url(); + // return $status; + // }); + + $posts = GroupPostHashtag::whereGroupId($gid) + ->whereHashtagId($hashtag->id) + ->orderByDesc('id') + ->simplePaginate($limit) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['status_id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['status_id']); + $status['favourites_count'] = GroupsLikeService::count($gp['status_id']); + $status['pf_type'] = $status['pf_type']; + $status['visibility'] = 'public'; + return $status; + }); + + return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function showTopicFeed(Request $request, $gid, $tag) + { + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + $gid = $group->id; + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + return view('groups.topic-feed', compact('gid', 'tag')); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index df39ef60b..4ec8832e8 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -54,6 +54,7 @@ class Kernel extends HttpKernel * @var array */ protected $routeMiddleware = [ + 'api.admin' => \App\Http\Middleware\Api\Admin::class, 'admin' => \App\Http\Middleware\Admin::class, 'auth' => \Illuminate\Auth\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, @@ -68,6 +69,8 @@ class Kernel extends HttpKernel 'twofactor' => \App\Http\Middleware\TwoFactorAuth::class, 'validemail' => \App\Http\Middleware\EmailVerificationCheck::class, 'interstitial' => \App\Http\Middleware\AccountInterstitial::class, + 'scopes' => \Laravel\Passport\Http\Middleware\CheckScopes::class, + 'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class, // 'restricted' => \App\Http\Middleware\RestrictedAccess::class, ]; } diff --git a/app/Http/Middleware/Api/Admin.php b/app/Http/Middleware/Api/Admin.php new file mode 100644 index 000000000..65d24758d --- /dev/null +++ b/app/Http/Middleware/Api/Admin.php @@ -0,0 +1,26 @@ +is_admin == false) { + return abort(403, "You must be an administrator to do that"); + } + + return $next($request); + } +} diff --git a/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php b/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php new file mode 100644 index 000000000..eeb3ddc09 --- /dev/null +++ b/app/Http/Resources/MastoApi/Admin/DomainBlockResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + $severity = 'noop'; + if ($this->banned) { + $severity = 'suspend'; + } else if ($this->unlisted) { + $severity = 'silence'; + } + + return [ + 'id' => $this->id, + 'domain' => $this->domain, + // This property is coming in Mastodon 4.3, although it'll only be + // useful if Pixelfed supports obfuscating domains: + 'digest' => hash('sha256', $this->domain), + 'severity' => $severity, + // Using the updated_at value as this is going to be the closest to + // when the domain was banned + 'created_at' => $this->updated_at, + // We don't have data for these fields + 'reject_media' => false, + 'reject_reports' => false, + 'private_comment' => $this->notes ? join('; ', $this->notes) : null, + 'public_comment' => $this->limit_reason, + 'obfuscate' => false + ]; + } +} diff --git a/app/Instance.php b/app/Instance.php index 77752d498..a93d9e95e 100644 --- a/app/Instance.php +++ b/app/Instance.php @@ -22,6 +22,13 @@ class Instance extends Model 'notes' ]; + // To get all moderated instances, we need to search where (banned OR unlisted) + public function scopeModerated($query): void { + $query->where(function ($query) { + $query->where('banned', true)->orWhere('unlisted', true); + }); + } + public function profiles() { return $this->hasMany(Profile::class, 'domain', 'domain'); diff --git a/app/Jobs/GroupPipeline/GroupCommentPipeline.php b/app/Jobs/GroupPipeline/GroupCommentPipeline.php new file mode 100644 index 000000000..cdae65d10 --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupCommentPipeline.php @@ -0,0 +1,99 @@ +status = $status; + $this->comment = $comment; + $this->groupPost = $groupPost; + } + + public function handle() + { + if($this->status->group_id == null || $this->comment->group_id == null) { + return; + } + + $this->updateParentReplyCount(); + $this->generateNotification(); + + if($this->groupPost) { + $this->updateChildReplyCount(); + } + } + + protected function updateParentReplyCount() + { + $parent = $this->status; + $parent->reply_count = Status::whereInReplyToId($parent->id)->count(); + $parent->save(); + StatusService::del($parent->id); + } + + protected function updateChildReplyCount() + { + $gp = $this->groupPost; + if($gp->reply_child_id) { + $parent = GroupPost::whereStatusId($gp->reply_child_id)->first(); + if($parent) { + $parent->reply_count++; + $parent->save(); + } + } + } + + protected function generateNotification() + { + $status = $this->status; + $comment = $this->comment; + + $target = $status->profile; + $actor = $comment->profile; + + if ($actor->id == $target->id || $status->comments_disabled == true) { + return; + } + + $notification = DB::transaction(function() use($target, $actor, $comment) { + $actorName = $actor->username; + $actorUrl = $actor->url(); + $text = "{$actorName} commented on your group post."; + $html = "{$actorName} commented on your group post."; + $notification = new Notification(); + $notification->profile_id = $target->id; + $notification->actor_id = $actor->id; + $notification->action = 'group:comment'; + $notification->item_id = $comment->id; + $notification->item_type = "App\Status"; + $notification->save(); + return $notification; + }); + + NotificationService::setNotification($notification); + NotificationService::set($notification->profile_id, $notification->id); + } +} diff --git a/app/Jobs/GroupPipeline/GroupMediaPipeline.php b/app/Jobs/GroupPipeline/GroupMediaPipeline.php new file mode 100644 index 000000000..1155e5465 --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupMediaPipeline.php @@ -0,0 +1,57 @@ +media = $media; + } + + public function handle() + { + MediaStorageService::store($this->media); + } + + protected function localToCloud($media) + { + $path = storage_path('app/'.$media->media_path); + $thumb = storage_path('app/'.$media->thumbnail_path); + + $p = explode('/', $media->media_path); + $name = array_pop($p); + $pt = explode('/', $media->thumbnail_path); + $thumbname = array_pop($pt); + $storagePath = implode('/', $p); + + $disk = Storage::disk(config('filesystems.cloud')); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + $url = $disk->url($file); + $thumbFile = $disk->putFileAs($storagePath, new File($thumb), $thumbname, 'public'); + $thumbUrl = $disk->url($thumbFile); + $media->thumbnail_url = $thumbUrl; + $media->cdn_url = $url; + $media->optimized_url = $url; + $media->replicated_at = now(); + $media->save(); + if($media->status_id) { + Cache::forget('status:transformer:media:attachments:' . $media->status_id); + } + } + +} diff --git a/app/Jobs/GroupPipeline/GroupMemberInvite.php b/app/Jobs/GroupPipeline/GroupMemberInvite.php new file mode 100644 index 000000000..d2c2bf8ef --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupMemberInvite.php @@ -0,0 +1,54 @@ +invite = $invite; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $invite = $this->invite; + $actor = Profile::find($invite->from_profile_id); + $target = Profile::find($invite->to_profile_id); + + if(!$actor || !$target) { + return; + } + + $notification = new Notification; + $notification->profile_id = $target->id; + $notification->actor_id = $actor->id; + $notification->action = 'group:invite'; + $notification->item_id = $invite->group_id; + $notification->item_type = 'App\Models\Group'; + $notification->save(); + } +} diff --git a/app/Jobs/GroupPipeline/JoinApproved.php b/app/Jobs/GroupPipeline/JoinApproved.php new file mode 100644 index 000000000..f41c8f698 --- /dev/null +++ b/app/Jobs/GroupPipeline/JoinApproved.php @@ -0,0 +1,54 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->approved_at = now(); + $member->join_request = false; + $member->role = 'member'; + $member->save(); + + $n = new Notification; + $n->profile_id = $member->profile_id; + $n->actor_id = $member->profile_id; + $n->item_id = $member->group_id; + $n->item_type = 'App\Models\Group'; + $n->save(); + + GroupService::del($member->group_id); + GroupService::delSelf($member->group_id, $member->profile_id); + } +} diff --git a/app/Jobs/GroupPipeline/JoinRejected.php b/app/Jobs/GroupPipeline/JoinRejected.php new file mode 100644 index 000000000..71e1e30c8 --- /dev/null +++ b/app/Jobs/GroupPipeline/JoinRejected.php @@ -0,0 +1,50 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->rejected_at = now(); + $member->save(); + + $n = new Notification; + $n->profile_id = $member->profile_id; + $n->actor_id = $member->profile_id; + $n->item_id = $member->group_id; + $n->item_type = 'App\Models\Group'; + $n->action = 'group.join.rejected'; + $n->save(); + } +} diff --git a/app/Jobs/GroupPipeline/LikePipeline.php b/app/Jobs/GroupPipeline/LikePipeline.php new file mode 100644 index 000000000..bd3e668f7 --- /dev/null +++ b/app/Jobs/GroupPipeline/LikePipeline.php @@ -0,0 +1,107 @@ +like = $like; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $like = $this->like; + + $status = $this->like->status; + $actor = $this->like->actor; + + if (!$status) { + // Ignore notifications to deleted statuses + return; + } + + StatusService::refresh($status->id); + + if($status->url && $actor->domain == null) { + return $this->remoteLikeDeliver(); + } + + $exists = Notification::whereProfileId($status->profile_id) + ->whereActorId($actor->id) + ->whereAction('group:like') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->count(); + + if ($actor->id === $status->profile_id || $exists !== 0) { + return true; + } + + try { + $notification = new Notification(); + $notification->profile_id = $status->profile_id; + $notification->actor_id = $actor->id; + $notification->action = 'group:like'; + $notification->item_id = $status->id; + $notification->item_type = "App\Status"; + $notification->save(); + + } catch (Exception $e) { + } + } + + public function remoteLikeDeliver() + { + $like = $this->like; + $status = $this->like->status; + $actor = $this->like->actor; + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($like, new LikeTransformer()); + $activity = $fractal->createData($resource)->toArray(); + + $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; + + Helpers::sendSignedObject($actor, $url, $activity); + } +} diff --git a/app/Jobs/GroupPipeline/NewStatusPipeline.php b/app/Jobs/GroupPipeline/NewStatusPipeline.php new file mode 100644 index 000000000..4d8eeca5c --- /dev/null +++ b/app/Jobs/GroupPipeline/NewStatusPipeline.php @@ -0,0 +1,130 @@ +status = $status; + $this->gp = $gp; + } + + public function handle() + { + $status = $this->status; + + $autolink = Autolink::create() + ->setAutolinkActiveUsersOnly(true) + ->setBaseHashPath("/groups/{$status->group_id}/topics/") + ->setBaseUserPath("/groups/{$status->group_id}/username/") + ->autolink($status->caption); + + $entities = Extractor::create()->extract($status->caption); + + $autolink = str_replace('/discover/tags/', '/groups/' . $status->group_id . '/topics/', $autolink); + + $status->rendered = nl2br($autolink); + $status->entities = null; + $status->save(); + + $this->tags = array_unique($entities['hashtags']); + $this->mentions = array_unique($entities['mentions']); + + if(count($this->tags)) { + $this->storeHashtags(); + } + + if(count($this->mentions)) { + $this->storeMentions($this->mentions); + } + } + + protected function storeHashtags() + { + $tags = $this->tags; + $status = $this->status; + $gp = $this->gp; + + foreach ($tags as $tag) { + if(mb_strlen($tag) > 124) { + continue; + } + + DB::transaction(function () use ($status, $tag, $gp) { + $slug = str_slug($tag, '-', false); + $hashtag = Hashtag::firstOrCreate( + ['name' => $tag, 'slug' => $slug] + ); + GroupPostHashtag::firstOrCreate( + [ + 'group_id' => $status->group_id, + 'group_post_id' => $gp->id, + 'status_id' => $status->id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + ] + ); + + }); + } + + if(count($this->mentions)) { + $this->storeMentions(); + } + StatusService::del($status->id); + } + + protected function storeMentions() + { + $mentions = $this->mentions; + $status = $this->status; + + foreach ($mentions as $mention) { + $mentioned = Profile::whereUsername($mention)->first(); + + if (empty($mentioned) || !isset($mentioned->id)) { + continue; + } + + DB::transaction(function () use ($status, $mentioned) { + $m = new Mention(); + $m->status_id = $status->id; + $m->profile_id = $mentioned->id; + $m->save(); + + MentionPipeline::dispatch($status, $m); + }); + } + StatusService::del($status->id); + } +} diff --git a/app/Jobs/GroupPipeline/UnlikePipeline.php b/app/Jobs/GroupPipeline/UnlikePipeline.php new file mode 100644 index 000000000..b322d6853 --- /dev/null +++ b/app/Jobs/GroupPipeline/UnlikePipeline.php @@ -0,0 +1,109 @@ +like = $like; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $like = $this->like; + + $status = $this->like->status; + $actor = $this->like->actor; + + if (!$status) { + // Ignore notifications to deleted statuses + return; + } + + $count = $status->likes_count > 1 ? $status->likes_count : $status->likes()->count(); + $status->likes_count = $count - 1; + $status->save(); + + StatusService::del($status->id); + + if($actor->id !== $status->profile_id && $status->url && $actor->domain == null) { + $this->remoteLikeDeliver(); + } + + $exists = Notification::whereProfileId($status->profile_id) + ->whereActorId($actor->id) + ->whereAction('group:like') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->first(); + + if($exists) { + $exists->delete(); + } + + $like = Like::whereProfileId($actor->id)->whereStatusId($status->id)->first(); + + if(!$like) { + return; + } + + $like->forceDelete(); + + return; + } + + public function remoteLikeDeliver() + { + $like = $this->like; + $status = $this->like->status; + $actor = $this->like->actor; + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($like, new LikeTransformer()); + $activity = $fractal->createData($resource)->toArray(); + + $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; + + Helpers::sendSignedObject($actor, $url, $activity); + } +} diff --git a/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php b/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php new file mode 100644 index 000000000..e1d94c5de --- /dev/null +++ b/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php @@ -0,0 +1,58 @@ +parent = $parent; + $this->status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $parent = $this->parent; + $parent->reply_count = GroupComment::whereStatusId($parent->id)->count(); + $parent->save(); + + return; + } +} diff --git a/app/Jobs/GroupsPipeline/ImageResizePipeline.php b/app/Jobs/GroupsPipeline/ImageResizePipeline.php new file mode 100644 index 000000000..fa649efea --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageResizePipeline.php @@ -0,0 +1,89 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media) { + return; + } + + if (!Storage::exists($media->media_path) || $media->skip_optimize) { + return; + } + + $path = $media->media_path; + $file = storage_path('app/' . $path); + $quality = config_cache('pixelfed.image_quality'); + + $orientations = [ + 'square' => [ + 'width' => 1080, + 'height' => 1080, + ], + 'landscape' => [ + 'width' => 1920, + 'height' => 1080, + ], + 'portrait' => [ + 'width' => 1080, + 'height' => 1350, + ], + ]; + + try { + $img = Intervention::make($file); + $img->orientate(); + $width = $img->width(); + $height = $img->height(); + $aspect = $width / $height; + $orientation = $aspect === 1 ? 'square' : ($aspect > 1 ? 'landscape' : 'portrait'); + $ratio = $orientations[$orientation]; + $img->resize($ratio['width'], $ratio['height']); + $img->save($file, $quality); + } catch (Exception $e) { + Log::error($e); + } + } +} diff --git a/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php b/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php new file mode 100644 index 000000000..d59c6d086 --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php @@ -0,0 +1,67 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) { + return; + } + + $fs = Storage::disk(config('filesystems.cloud')); + + if(!$fs) { + return; + } + + if($fs->exists($media->media_path)) { + $fs->delete($media->media_path); + } + } +} diff --git a/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php b/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php new file mode 100644 index 000000000..169c11073 --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php @@ -0,0 +1,107 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) { + return; + } + + $path = storage_path('app/' . $media->media_path); + + $p = explode('/', $media->media_path); + $name = array_pop($p); + $storagePath = implode('/', $p); + + $url = (bool) config_cache('pixelfed.cloud_storage') && (bool) config('media.storage.remote.resilient_mode') ? + self::handleResilientStore($storagePath, $path, $name) : + self::handleStore($storagePath, $path, $name); + + if($url && strlen($url) && str_starts_with($url, 'https://')) { + $media->cdn_url = $url; + $media->processed_at = now(); + $media->version = 11; + $media->save(); + Storage::disk('local')->delete($media->media_path); + } + } + + protected function handleStore($storagePath, $path, $name) + { + return retry(3, function() use($storagePath, $path, $name) { + $baseDisk = (bool) config_cache('pixelfed.cloud_storage') ? config('filesystems.cloud') : 'local'; + $disk = Storage::disk($baseDisk); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + return $disk->url($file); + }, random_int(100, 500)); + } + + protected function handleResilientStore($storagePath, $path, $name) + { + $attempts = 0; + return retry(4, function() use($storagePath, $path, $name, $attempts) { + self::$attempts++; + usleep(100000); + $baseDisk = self::$attempts > 1 ? $this->getAltDriver() : config('filesystems.cloud'); + try { + $disk = Storage::disk($baseDisk); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + } catch (S3Exception | ClientException | ConnectException | UnableToWriteFile | Exception $e) {} + return $disk->url($file); + }, function (int $attempt, Exception $exception) { + return $attempt * 200; + }); + } + + protected function getAltDriver() + { + return config('filesystems.cloud'); + } +} diff --git a/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php b/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php new file mode 100644 index 000000000..a3ec21982 --- /dev/null +++ b/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php @@ -0,0 +1,47 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->approved_at = now(); + $member->join_request = false; + $member->role = 'member'; + $member->save(); + + GroupService::del($member->group_id); + GroupService::delSelf($member->group_id, $member->profile_id); + } +} diff --git a/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php b/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php new file mode 100644 index 000000000..5e8226de0 --- /dev/null +++ b/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php @@ -0,0 +1,42 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->rejected_at = now(); + $member->save(); + } +} diff --git a/app/Jobs/GroupsPipeline/NewCommentPipeline.php b/app/Jobs/GroupsPipeline/NewCommentPipeline.php new file mode 100644 index 000000000..fb618a14d --- /dev/null +++ b/app/Jobs/GroupsPipeline/NewCommentPipeline.php @@ -0,0 +1,115 @@ +parent = $parent; + $this->status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $profile = $this->status->profile; + $status = $this->status; + + $parent = $this->parent; + $parent->reply_count = GroupComment::whereStatusId($parent->id)->count(); + $parent->save(); + + if ($profile->no_autolink == false) { + $this->parseEntities(); + } + } + + public function parseEntities() + { + $this->extractEntities(); + } + + public function extractEntities() + { + $this->entities = Extractor::create()->extract($this->status->caption); + $this->autolinkStatus(); + } + + public function autolinkStatus() + { + $this->autolink = Autolink::create()->autolink($this->status->caption); + $this->storeHashtags(); + } + + public function storeHashtags() + { + $tags = array_unique($this->entities['hashtags']); + $status = $this->status; + + foreach ($tags as $tag) { + if (mb_strlen($tag) > 124) { + continue; + } + DB::transaction(function () use ($status, $tag) { + $hashtag = GroupHashtag::firstOrCreate([ + 'name' => $tag, + ]); + + GroupPostHashtag::firstOrCreate( + [ + 'status_id' => $status->id, + 'group_id' => $status->group_id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + 'status_visibility' => $status->visibility, + ] + ); + }); + } + $this->storeMentions(); + } + + public function storeMentions() + { + // todo + } +} diff --git a/app/Jobs/GroupsPipeline/NewPostPipeline.php b/app/Jobs/GroupsPipeline/NewPostPipeline.php new file mode 100644 index 000000000..1302a0233 --- /dev/null +++ b/app/Jobs/GroupsPipeline/NewPostPipeline.php @@ -0,0 +1,108 @@ +status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $profile = $this->status->profile; + $status = $this->status; + + if ($profile->no_autolink == false) { + $this->parseEntities(); + } + } + + public function parseEntities() + { + $this->extractEntities(); + } + + public function extractEntities() + { + $this->entities = Extractor::create()->extract($this->status->caption); + $this->autolinkStatus(); + } + + public function autolinkStatus() + { + $this->autolink = Autolink::create()->autolink($this->status->caption); + $this->storeHashtags(); + } + + public function storeHashtags() + { + $tags = array_unique($this->entities['hashtags']); + $status = $this->status; + + foreach ($tags as $tag) { + if (mb_strlen($tag) > 124) { + continue; + } + DB::transaction(function () use ($status, $tag) { + $hashtag = GroupHashtag::firstOrCreate([ + 'name' => $tag, + ]); + + GroupPostHashtag::firstOrCreate( + [ + 'status_id' => $status->id, + 'group_id' => $status->group_id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + 'status_visibility' => $status->visibility, + ] + ); + }); + } + $this->storeMentions(); + } + + public function storeMentions() + { + // todo + } +} diff --git a/app/Models/Group.php b/app/Models/Group.php new file mode 100644 index 000000000..508ed98c0 --- /dev/null +++ b/app/Models/Group.php @@ -0,0 +1,67 @@ + 'json' + ]; + + public function url() + { + return url("/groups/{$this->id}"); + } + + public function permalink($suffix = null) + { + if(!$this->local) { + return $this->remote_url; + } + return $this->url() . $suffix; + } + + public function members() + { + return $this->hasMany(GroupMember::class); + } + + public function admin() + { + return $this->belongsTo(Profile::class, 'profile_id'); + } + + public function isMember($id = false) + { + $id = $id ?? request()->user()->profile_id; + // return $this->members()->whereProfileId($id)->whereJoinRequest(false)->exists(); + return GroupService::isMember($this->id, $id); + } + + public function getMembershipType() + { + return $this->is_private ? 'private' : ($this->is_local ? 'local' : 'all'); + } + + public function selfRole($id = false) + { + $id = $id ?? request()->user()->profile_id; + return optional($this->members()->whereProfileId($id)->first())->role ?? null; + } +} diff --git a/app/Models/GroupActivityGraph.php b/app/Models/GroupActivityGraph.php new file mode 100644 index 000000000..55981d20a --- /dev/null +++ b/app/Models/GroupActivityGraph.php @@ -0,0 +1,11 @@ +belongsTo(Profile::class); + } + + public function url() + { + return '/group/' . $this->group_id . '/c/' . $this->id; + } +} diff --git a/app/Models/GroupEvent.php b/app/Models/GroupEvent.php new file mode 100644 index 000000000..ddcd074cc --- /dev/null +++ b/app/Models/GroupEvent.php @@ -0,0 +1,11 @@ + 'array' + ]; +} diff --git a/app/Models/GroupInvitation.php b/app/Models/GroupInvitation.php new file mode 100644 index 000000000..adcd38ea4 --- /dev/null +++ b/app/Models/GroupInvitation.php @@ -0,0 +1,11 @@ + 'json', + 'metadata' => 'json' + ]; + + protected $fillable = [ + 'profile_id', + 'group_id' + ]; +} diff --git a/app/Models/GroupMedia.php b/app/Models/GroupMedia.php new file mode 100644 index 000000000..12f424151 --- /dev/null +++ b/app/Models/GroupMedia.php @@ -0,0 +1,39 @@ + + */ + protected function casts(): array + { + return [ + 'metadata' => 'json', + 'processed_at' => 'datetime', + 'thumbnail_generated' => 'datetime' + ]; + } + + public function url() + { + if($this->cdn_url) { + return $this->cdn_url; + } + return Storage::url($this->media_path); + } + + public function thumbnailUrl() + { + return $this->thumbnail_url; + } +} diff --git a/app/Models/GroupMember.php b/app/Models/GroupMember.php new file mode 100644 index 000000000..4f15e0d3e --- /dev/null +++ b/app/Models/GroupMember.php @@ -0,0 +1,16 @@ +belongsTo(Group::class); + } +} diff --git a/app/Models/GroupPost.php b/app/Models/GroupPost.php new file mode 100644 index 000000000..59693ec6b --- /dev/null +++ b/app/Models/GroupPost.php @@ -0,0 +1,57 @@ +group_id . '/' . $this->id; + } + + public function group() + { + return $this->belongsTo(Group::class); + } + + public function status() + { + return $this->belongsTo(Status::class); + } + + public function profile() + { + return $this->belongsTo(Profile::class); + } + + public function url() + { + return '/groups/' . $this->group_id . '/p/' . $this->id; + } +} diff --git a/app/Models/GroupPostHashtag.php b/app/Models/GroupPostHashtag.php new file mode 100644 index 000000000..46165dd7c --- /dev/null +++ b/app/Models/GroupPostHashtag.php @@ -0,0 +1,22 @@ +user()->is_admin; }); Validator::includeUnvalidatedArrayKeys(); + + // Model::preventLazyLoading(true); } /** diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 43d12b592..dfd4518c3 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -24,7 +24,7 @@ class AuthServiceProvider extends ServiceProvider */ public function boot() { - if (config('app.env') === 'production' && (bool) config_cache('pixelfed.oauth_enabled') == true) { + if(config('pixelfed.oauth_enabled') == true) { Passport::tokensExpireIn(now()->addDays(config('instance.oauth.token_expiration', 356))); Passport::refreshTokensExpireIn(now()->addDays(config('instance.oauth.refresh_expiration', 400))); Passport::enableImplicitGrant(); @@ -37,8 +37,10 @@ class AuthServiceProvider extends ServiceProvider 'write' => 'Full write access to your account', 'follow' => 'Ability to follow other profiles', 'admin:read' => 'Read all data on the server', + 'admin:read:domain_blocks' => 'Read sensitive information of all domain blocks', 'admin:write' => 'Modify all data on the server', - 'push' => 'Receive your push notifications', + 'admin:write:domain_blocks' => 'Perform moderation actions on domain blocks', + 'push' => 'Receive your push notifications' ]); Passport::setDefaultScope([ diff --git a/app/Rules/ValidUrl.php b/app/Rules/ValidUrl.php new file mode 100644 index 000000000..21c36d564 --- /dev/null +++ b/app/Rules/ValidUrl.php @@ -0,0 +1,21 @@ + $regState, 'cloud_storage' => $cloud_ready && $cloud_storage, 'activitypub_enabled' => (bool) config_cache('federation.activitypub.enabled'), + 'authorized_fetch' => (bool) config_cache('federation.activitypub.authorized_fetch'), 'account_migration' => (bool) config_cache('federation.migration'), 'mobile_apis' => (bool) config_cache('pixelfed.oauth_enabled'), 'stories' => (bool) config_cache('instance.stories.enabled'), diff --git a/app/Services/ConfigCacheService.php b/app/Services/ConfigCacheService.php index 4f2b006cc..527c86026 100644 --- a/app/Services/ConfigCacheService.php +++ b/app/Services/ConfigCacheService.php @@ -46,6 +46,7 @@ class ConfigCacheService 'pixelfed.oauth_enabled', 'pixelfed.import.instagram.enabled', 'pixelfed.bouncer.enabled', + 'federation.activitypub.authorized_fetch', 'pixelfed.enforce_email_verification', 'pixelfed.max_account_size', diff --git a/app/Services/GroupFeedService.php b/app/Services/GroupFeedService.php new file mode 100644 index 000000000..bf28b470e --- /dev/null +++ b/app/Services/GroupFeedService.php @@ -0,0 +1,88 @@ + 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_KEY.$gid, $start, $stop); + } + + public static function getRankedMaxId($gid, $start = null, $limit = 10) + { + if (! $start) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY.$gid, $start, '-inf', [ + 'withscores' => true, + 'limit' => [1, $limit], + ])); + } + + public static function getRankedMinId($gid, $end = null, $limit = 10) + { + if (! $end) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY.$gid, '+inf', $end, [ + 'withscores' => true, + 'limit' => [0, $limit], + ])); + } + + public static function add($gid, $val) + { + if (self::count($gid) > self::FEED_LIMIT) { + if (config('database.redis.client') === 'phpredis') { + Redis::zpopmin(self::CACHE_KEY.$gid); + } + } + + return Redis::zadd(self::CACHE_KEY.$gid, $val, $val); + } + + public static function rem($gid, $val) + { + return Redis::zrem(self::CACHE_KEY.$gid, $val); + } + + public static function del($gid, $val) + { + return self::rem($gid, $val); + } + + public static function count($gid) + { + return Redis::zcard(self::CACHE_KEY.$gid); + } + + public static function warmCache($gid, $force = false, $limit = 100) + { + if (self::count($gid) == 0 || $force == true) { + Redis::del(self::CACHE_KEY.$gid); + $ids = GroupPost::whereGroupId($gid) + ->orderByDesc('id') + ->limit($limit) + ->pluck('id'); + foreach ($ids as $id) { + self::add($gid, $id); + } + + return 1; + } + } +} diff --git a/app/Services/GroupPostService.php b/app/Services/GroupPostService.php new file mode 100644 index 000000000..7295bda40 --- /dev/null +++ b/app/Services/GroupPostService.php @@ -0,0 +1,49 @@ +find($pid); + + if (! $gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = $gp['type']; + $res['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } +} diff --git a/app/Services/GroupService.php b/app/Services/GroupService.php new file mode 100644 index 000000000..ac1a1a1c6 --- /dev/null +++ b/app/Services/GroupService.php @@ -0,0 +1,366 @@ +withoutRelations()->whereNull('status')->find($id); + + if(!$group) { + return null; + } + + $admin = $group->profile_id ? AccountService::get($group->profile_id) : null; + + return [ + 'id' => (string) $group->id, + 'name' => $group->name, + 'description' => $group->description, + 'short_description' => str_limit(strip_tags($group->description), 120), + 'category' => self::categoryById($group->category_id), + 'local' => (bool) $group->local, + 'url' => $group->url(), + 'shorturl' => url('/g/'.HashidService::encode($group->id)), + 'membership' => $group->getMembershipType(), + 'member_count' => $group->members()->whereJoinRequest(false)->count(), + 'verified' => false, + 'self' => null, + 'admin' => $admin, + 'config' => [ + 'recommended' => (bool) $group->recommended, + 'discoverable' => (bool) $group->discoverable, + 'activitypub' => (bool) $group->activitypub, + 'is_nsfw' => (bool) $group->is_nsfw, + 'dms' => (bool) $group->dms + ], + 'metadata' => $group->metadata, + 'created_at' => $group->created_at->toAtomString(), + ]; + } + ); + + if($pid) { + $res['self'] = self::getSelf($id, $pid); + } + + return $res; + } + + public static function del($id) + { + Cache::forget('ap:groups:object:' . $id); + return Cache::forget(self::key($id)); + } + + public static function getSelf($gid, $pid) + { + return Cache::remember( + self::key('self:gid-' . $gid . ':pid-' . $pid), + 3600, + function() use($gid, $pid) { + $group = Group::find($gid); + + if(!$gid || !$pid) { + return [ + 'is_member' => false, + 'role' => null, + 'is_requested' => null + ]; + } + + return [ + 'is_member' => $group->isMember($pid), + 'role' => $group->selfRole($pid), + 'is_requested' => optional($group->members()->whereProfileId($pid)->first())->join_request ?? false + ]; + } + ); + } + + public static function delSelf($gid, $pid) + { + Cache::forget(self::key("is_member:{$gid}:{$pid}")); + return Cache::forget(self::key('self:gid-' . $gid . ':pid-' . $pid)); + } + + public static function sidToGid($gid, $pid) + { + return Cache::remember(self::key('s2gid:' . $gid . ':' . $pid), 3600, function() use($gid, $pid) { + return optional(GroupPost::whereGroupId($gid)->whereStatusId($pid)->first())->id; + }); + } + + public static function membershipsByPid($pid) + { + return Cache::remember(self::key("mbpid:{$pid}"), 3600, function() use($pid) { + return GroupMember::whereProfileId($pid)->pluck('group_id'); + }); + } + + public static function config() + { + return [ + 'enabled' => config('exp.gps') ?? false, + 'limits' => [ + 'group' => [ + 'max' => 999, + 'federation' => false, + ], + + 'user' => [ + 'create' => [ + 'new' => true, + 'max' => 10 + ], + 'join' => [ + 'max' => 10 + ], + 'invite' => [ + 'max' => 20 + ] + ] + ], + 'guest' => [ + 'public' => false + ] + ]; + } + + public static function fetchRemote($url) + { + // todo: refactor this demo + $res = Helpers::fetchFromUrl($url); + + if(!$res || !isset($res['type']) || $res['type'] != 'Group') { + return false; + } + + $group = Group::whereRemoteUrl($url)->first(); + + if($group) { + return $group; + } + + $group = new Group; + $group->remote_url = $res['url']; + $group->name = $res['name']; + $group->inbox_url = $res['inbox']; + $group->metadata = [ + 'header' => [ + 'url' => $res['icon']['image']['url'] + ] + ]; + $group->description = Purify::clean($res['summary']); + $group->local = false; + $group->save(); + + return $group->url(); + } + + public static function log( + string $groupId, + string $profileId, + string $type = null, + array $meta = null, + string $itemType = null, + string $itemId = null + ) + { + // todo: truncate (some) metadata after XX days in cron/queue + $log = new GroupInteraction; + $log->group_id = $groupId; + $log->profile_id = $profileId; + $log->type = $type; + $log->item_type = $itemType; + $log->item_id = $itemId; + $log->metadata = $meta; + $log->save(); + } + + public static function getRejoinTimeout($gid, $pid) + { + $key = self::key('rejoin-timeout:gid-' . $gid . ':pid-' . $pid); + return Cache::has($key); + } + + public static function setRejoinTimeout($gid, $pid) + { + // todo: allow group admins to manually remove timeout + $key = self::key('rejoin-timeout:gid-' . $gid . ':pid-' . $pid); + return Cache::put($key, 1, 86400); + } + + public static function getMemberInboxes($id) + { + // todo: cache this, maybe add join/leave methods to this service to handle cache invalidation + $group = (new Group)->withoutRelations()->findOrFail($id); + if(!$group->local) { + return []; + } + $members = GroupMember::whereGroupId($id)->whereLocalProfile(false)->pluck('profile_id'); + return Profile::find($members)->map(function($u) { + return $u->sharedInbox ?? $u->inbox_url; + })->toArray(); + } + + public static function getInteractionLimits($gid, $pid) + { + return Cache::remember(self::key(":il:{$gid}:{$pid}"), 3600, function() use($gid, $pid) { + $limit = GroupLimit::whereGroupId($gid)->whereProfileId($pid)->first(); + if(!$limit) { + return [ + 'limits' => [ + 'can_post' => true, + 'can_comment' => true, + 'can_like' => true + ], + 'updated_at' => null + ]; + } + + return [ + 'limits' => $limit->limits, + 'updated_at' => $limit->updated_at->format('c') + ]; + }); + } + + public static function clearInteractionLimits($gid, $pid) + { + return Cache::forget(self::key(":il:{$gid}:{$pid}")); + } + + public static function canPost($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_post']; + } else { + return true; + } + } + + public static function canComment($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_comment']; + } else { + return true; + } + } + + public static function canLike($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_like']; + } else { + return true; + } + } + + public static function categories($onlyActive = true) + { + return Cache::remember(self::key(':categories'), 2678400, function() use($onlyActive) { + return GroupCategory::when($onlyActive, function($q, $onlyActive) { + return $q->whereActive(true); + }) + ->orderBy('order') + ->pluck('name') + ->toArray(); + }); + } + + public static function categoryById($id) + { + return Cache::remember(self::key(':categorybyid:'.$id), 2678400, function() use($id) { + $category = GroupCategory::find($id); + if($category) { + return [ + 'name' => $category->name, + 'url' => url("/groups/explore/category/{$category->slug}") + ]; + } + return false; + }); + } + + public static function isMember($gid = false, $pid = false) + { + if(!$gid || !$pid) { + return false; + } + + $key = self::key("is_member:{$gid}:{$pid}"); + return Cache::remember($key, 3600, function() use($gid, $pid) { + return GroupMember::whereGroupId($gid) + ->whereProfileId($pid) + ->whereJoinRequest(false) + ->exists(); + }); + } + + public static function mutualGroups($cid = false, $pid = false, $exclude = []) + { + if(!$cid || !$pid) { + return [ + 'count' => 0, + 'groups' => [] + ]; + } + + $self = self::membershipsByPid($cid); + $user = self::membershipsByPid($pid); + + if(!$self->count() || !$user->count()) { + return [ + 'count' => 0, + 'groups' => [] + ]; + } + + $intersect = $self->intersect($user); + $count = $intersect->count(); + $groups = $intersect + ->values() + ->filter(function($id) use($exclude) { + return !in_array($id, $exclude); + }) + ->shuffle() + ->take(1) + ->map(function($id) { + return self::get($id); + }); + + return [ + 'count' => $count, + 'groups' => $groups + ]; + } +} diff --git a/app/Services/Groups/GroupAccountService.php b/app/Services/Groups/GroupAccountService.php new file mode 100644 index 000000000..2d86e4f43 --- /dev/null +++ b/app/Services/Groups/GroupAccountService.php @@ -0,0 +1,51 @@ +whereProfileId($pid)->first(); + if(!$membership) { + return []; + } + + return [ + 'joined' => $membership->created_at->format('c'), + 'role' => $membership->role, + 'local_group' => (bool) $membership->local_group, + 'local_profile' => (bool) $membership->local_profile, + ]; + }); + return $account; + } + + public static function del($gid, $pid) + { + $key = self::CACHE_KEY . $gid . ':' . $pid; + return Cache::forget($key); + } +} diff --git a/app/Services/Groups/GroupActivityPubService.php b/app/Services/Groups/GroupActivityPubService.php new file mode 100644 index 000000000..4c48b22c4 --- /dev/null +++ b/app/Services/Groups/GroupActivityPubService.php @@ -0,0 +1,311 @@ +first(); + if($group) { + return $group; + } + + $res = ActivityPubFetchService::get($url); + if(!$res) { + return $res; + } + $json = json_decode($res, true); + $group = self::validateGroup($json); + if(!$group) { + return false; + } + if($saveOnFetch) { + return self::storeGroup($group); + } + return $group; + } + + public static function fetchGroupPost($url, $saveOnFetch = true) + { + $group = GroupPost::where('remote_url', $url)->first(); + + if($group) { + return $group; + } + + $res = ActivityPubFetchService::get($url); + if(!$res) { + return 'invalid res'; + } + $json = json_decode($res, true); + if(!$json) { + return 'invalid json'; + } + if(isset($json['inReplyTo'])) { + $comment = self::validateGroupComment($json); + return self::storeGroupComment($comment); + } + + $group = self::validateGroupPost($json); + if($saveOnFetch) { + return self::storeGroupPost($group); + } + return $group; + } + + public static function validateGroup($obj) + { + $validator = Validator::make($obj, [ + '@context' => 'required', + 'id' => ['required', 'url', new ValidUrl], + 'type' => 'required|in:Group', + 'preferredUsername' => 'required', + 'name' => 'required', + 'url' => ['sometimes', 'url', new ValidUrl], + 'inbox' => ['required', 'url', new ValidUrl], + 'outbox' => ['required', 'url', new ValidUrl], + 'followers' => ['required', 'url', new ValidUrl], + 'attributedTo' => 'required', + 'summary' => 'sometimes', + 'publicKey' => 'required', + 'publicKey.id' => 'required', + 'publicKey.owner' => ['required', 'url', 'same:id', new ValidUrl], + 'publicKey.publicKeyPem' => 'required', + ]); + + if($validator->fails()) { + return false; + } + + return $validator->validated(); + } + + public static function validateGroupPost($obj) + { + $validator = Validator::make($obj, [ + '@context' => 'required', + 'id' => ['required', 'url', new ValidUrl], + 'type' => 'required|in:Page,Note', + 'to' => 'required|array', + 'to.*' => ['required', 'url', new ValidUrl], + 'cc' => 'sometimes|array', + 'cc.*' => ['sometimes', 'url', new ValidUrl], + 'url' => ['sometimes', 'url', new ValidUrl], + 'attributedTo' => 'required', + 'name' => 'sometimes', + 'target' => 'sometimes', + 'audience' => 'sometimes', + 'inReplyTo' => 'sometimes', + 'content' => 'sometimes', + 'mediaType' => 'sometimes', + 'sensitive' => 'sometimes', + 'attachment' => 'sometimes', + 'published' => 'required', + ]); + + if($validator->fails()) { + return false; + } + + return $validator->validated(); + } + + public static function validateGroupComment($obj) + { + $validator = Validator::make($obj, [ + '@context' => 'required', + 'id' => ['required', 'url', new ValidUrl], + 'type' => 'required|in:Note', + 'to' => 'required|array', + 'to.*' => ['required', 'url', new ValidUrl], + 'cc' => 'sometimes|array', + 'cc.*' => ['sometimes', 'url', new ValidUrl], + 'url' => ['sometimes', 'url', new ValidUrl], + 'attributedTo' => 'required', + 'name' => 'sometimes', + 'target' => 'sometimes', + 'audience' => 'sometimes', + 'inReplyTo' => 'sometimes', + 'content' => 'sometimes', + 'mediaType' => 'sometimes', + 'sensitive' => 'sometimes', + 'published' => 'required', + ]); + + if($validator->fails()) { + return $validator->errors(); + return false; + } + + return $validator->validated(); + } + + public static function getGroupFromPostActivity($groupPost) + { + if(isset($groupPost['audience']) && is_string($groupPost['audience'])) { + return $groupPost['audience']; + } + + if( + isset( + $groupPost['target'], + $groupPost['target']['type'], + $groupPost['target']['attributedTo'] + ) && $groupPost['target']['type'] == 'Collection' + ) { + return $groupPost['target']['attributedTo']; + } + + return false; + } + + public static function getActorFromPostActivity($groupPost) + { + if(!isset($groupPost['attributedTo'])) { + return false; + } + + $field = $groupPost['attributedTo']; + + if(is_string($field)) { + return $field; + } + + if(is_array($field) && count($field) === 1) { + if( + isset( + $field[0]['id'], + $field[0]['type'] + ) && + $field[0]['type'] === 'Person' && + is_string($field[0]['id']) + ) { + return $field[0]['id']; + } + } + + return false; + } + + public static function getCaptionFromPostActivity($groupPost) + { + if(!isset($groupPost['name']) && isset($groupPost['content'])) { + return Purify::clean(strip_tags($groupPost['content'])); + } + + if(isset($groupPost['name'], $groupPost['content'])) { + return Purify::clean(strip_tags($groupPost['name'])) . Purify::clean(strip_tags($groupPost['content'])); + } + } + + public static function getSensitiveFromPostActivity($groupPost) + { + if(!isset($groupPost['sensitive'])) { + return true; + } + + if(isset($groupPost['sensitive']) && !is_bool($groupPost['sensitive'])) { + return true; + } + + return boolval($groupPost['sensitive']); + } + + public static function storeGroup($activity) + { + $group = new Group; + $group->profile_id = null; + $group->category_id = 1; + $group->name = $activity['name'] ?? 'Untitled Group'; + $group->description = isset($activity['summary']) ? Purify::clean($activity['summary']) : null; + $group->is_private = false; + $group->local_only = false; + $group->metadata = []; + $group->local = false; + $group->remote_url = $activity['id']; + $group->inbox_url = $activity['inbox']; + $group->activitypub = true; + $group->save(); + + return $group; + } + + public static function storeGroupPost($groupPost) + { + $groupUrl = self::getGroupFromPostActivity($groupPost); + if(!$groupUrl) { + return; + } + $group = self::fetchGroup($groupUrl, true); + if(!$group) { + return; + } + $actorUrl = self::getActorFromPostActivity($groupPost); + $actor = Helpers::profileFetch($actorUrl); + $caption = self::getCaptionFromPostActivity($groupPost); + $sensitive = self::getSensitiveFromPostActivity($groupPost); + $model = GroupPost::firstOrCreate( + [ + 'remote_url' => $groupPost['id'], + ], [ + 'group_id' => $group->id, + 'profile_id' => $actor->id, + 'type' => 'text', + 'caption' => $caption, + 'visibility' => 'public', + 'is_nsfw' => $sensitive, + ] + ); + return $model; + } + + public static function storeGroupComment($groupPost) + { + $groupUrl = self::getGroupFromPostActivity($groupPost); + if(!$groupUrl) { + return; + } + $group = self::fetchGroup($groupUrl, true); + if(!$group) { + return; + } + $actorUrl = self::getActorFromPostActivity($groupPost); + $actor = Helpers::profileFetch($actorUrl); + $caption = self::getCaptionFromPostActivity($groupPost); + $sensitive = self::getSensitiveFromPostActivity($groupPost); + $parentPost = self::fetchGroupPost($groupPost['inReplyTo']); + $model = GroupComment::firstOrCreate( + [ + 'remote_url' => $groupPost['id'], + ], [ + 'group_id' => $group->id, + 'profile_id' => $actor->id, + 'status_id' => $parentPost->id, + 'type' => 'text', + 'caption' => $caption, + 'visibility' => 'public', + 'is_nsfw' => $sensitive, + 'local' => $actor->private_key != null + ] + ); + return $model; + } +} diff --git a/app/Services/Groups/GroupCommentService.php b/app/Services/Groups/GroupCommentService.php new file mode 100644 index 000000000..52eeee533 --- /dev/null +++ b/app/Services/Groups/GroupCommentService.php @@ -0,0 +1,50 @@ +find($pid); + + if(!$gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = 'group:post:comment'; + $res['url'] = $gp->url(); + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } +} diff --git a/app/Services/Groups/GroupFeedService.php b/app/Services/Groups/GroupFeedService.php new file mode 100644 index 000000000..a2a87be1d --- /dev/null +++ b/app/Services/Groups/GroupFeedService.php @@ -0,0 +1,95 @@ + 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_KEY . $gid, $start, $stop); + } + + public static function getRankedMaxId($gid, $start = null, $limit = 10) + { + if(!$start) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $gid, $start, '-inf', [ + 'withscores' => true, + 'limit' => [1, $limit] + ])); + } + + public static function getRankedMinId($gid, $end = null, $limit = 10) + { + if(!$end) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $gid, '+inf', $end, [ + 'withscores' => true, + 'limit' => [0, $limit] + ])); + } + + public static function add($gid, $val) + { + if(self::count($gid) > self::FEED_LIMIT) { + if(config('database.redis.client') === 'phpredis') { + Redis::zpopmin(self::CACHE_KEY . $gid); + } + } + + return Redis::zadd(self::CACHE_KEY . $gid, $val, $val); + } + + public static function rem($gid, $val) + { + return Redis::zrem(self::CACHE_KEY . $gid, $val); + } + + public static function del($gid, $val) + { + return self::rem($gid, $val); + } + + public static function count($gid) + { + return Redis::zcard(self::CACHE_KEY . $gid); + } + + public static function warmCache($gid, $force = false, $limit = 100) + { + if(self::count($gid) == 0 || $force == true) { + Redis::del(self::CACHE_KEY . $gid); + $ids = GroupPost::whereGroupId($gid) + ->orderByDesc('id') + ->limit($limit) + ->pluck('id'); + foreach($ids as $id) { + self::add($gid, $id); + } + return 1; + } + } +} diff --git a/app/Services/Groups/GroupHashtagService.php b/app/Services/Groups/GroupHashtagService.php new file mode 100644 index 000000000..6553850f0 --- /dev/null +++ b/app/Services/Groups/GroupHashtagService.php @@ -0,0 +1,28 @@ + $tag->name, + 'slug' => Str::slug($tag->name), + ]; + }); + } +} diff --git a/app/Services/Groups/GroupMediaService.php b/app/Services/Groups/GroupMediaService.php new file mode 100644 index 000000000..0200e3a56 --- /dev/null +++ b/app/Services/Groups/GroupMediaService.php @@ -0,0 +1,114 @@ +orderBy('order')->get(); + if(!$media) { + return []; + } + $medias = $media->map(function($media) { + return [ + 'id' => (string) $media->id, + 'type' => 'Document', + 'url' => $media->url(), + 'preview_url' => $media->url(), + 'remote_url' => $media->url, + 'description' => $media->cw_summary, + 'blurhash' => $media->blurhash ?? 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay' + ]; + }); + return $medias->toArray(); + }); + } + + public static function getMastodon($id) + { + $media = self::get($id); + if(!$media) { + return []; + } + $medias = collect($media) + ->map(function($media) { + $mime = $media['mime'] ? explode('/', $media['mime']) : false; + unset( + $media['optimized_url'], + $media['license'], + $media['is_nsfw'], + $media['orientation'], + $media['filter_name'], + $media['filter_class'], + $media['mime'], + $media['hls_manifest'] + ); + + $media['type'] = $mime ? strtolower($mime[0]) : 'unknown'; + return $media; + }) + ->filter(function($m) { + return $m && isset($m['url']); + }) + ->values(); + + return $medias->toArray(); + } + + public static function del($statusId) + { + return Cache::forget(self::CACHE_KEY . $statusId); + } + + public static function activitypub($statusId) + { + $status = self::get($statusId); + if(!$status) { + return []; + } + + return collect($status)->map(function($s) { + $license = isset($s['license']) && $s['license']['title'] ? $s['license']['title'] : null; + return [ + 'type' => 'Document', + 'mediaType' => $s['mime'], + 'url' => $s['url'], + 'name' => $s['description'], + 'summary' => $s['description'], + 'blurhash' => $s['blurhash'], + 'license' => $license + ]; + }); + } +} diff --git a/app/Services/Groups/GroupPostService.php b/app/Services/Groups/GroupPostService.php new file mode 100644 index 000000000..a043be134 --- /dev/null +++ b/app/Services/Groups/GroupPostService.php @@ -0,0 +1,83 @@ +find($pid); + + if(!$gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = $gp['type']; + $res['url'] = $gp->url(); + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } + + public function getStatus(Request $request) + { + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $pid = optional($request->user())->profile_id ?? false; + + $group = Group::findOrFail($gid); + + if($group->is_private) { + abort_if(!$group->isMember($pid), 404); + } + + $gp = GroupPost::whereGroupId($group->id)->whereId($sid)->firstOrFail(); + + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['reply_count'] = $gp['reply_count']; + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + $status['account']['url'] = url("/groups/{$gp->group_id}/user/{$gp->profile_id}"); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + return $status; + } +} diff --git a/app/Services/Groups/GroupsLikeService.php b/app/Services/Groups/GroupsLikeService.php new file mode 100644 index 000000000..e2daa1e71 --- /dev/null +++ b/app/Services/Groups/GroupsLikeService.php @@ -0,0 +1,85 @@ + 400) { + Redis::zpopmin(self::CACHE_SET_KEY . $profileId); + } + + return Redis::zadd(self::CACHE_SET_KEY . $profileId, $statusId, $statusId); + } + + public static function setCount($id) + { + return Redis::zcard(self::CACHE_SET_KEY . $id); + } + + public static function setRem($profileId, $val) + { + return Redis::zrem(self::CACHE_SET_KEY . $profileId, $val); + } + + public static function get($profileId, $start = 0, $stop = 10) + { + if($stop > 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_SET_KEY . $profileId, $start, $stop); + } + + public static function remove($profileId, $statusId) + { + $key = self::CACHE_KEY . $profileId . ':' . $statusId; + Cache::decrement(self::CACHE_POST_KEY . $statusId); + //Cache::forget('pf:services:likes:liked_by:'.$statusId); + self::setRem($profileId, $statusId); + return Cache::put($key, false, 86400); + } + + public static function liked($profileId, $statusId) + { + $key = self::CACHE_KEY . $profileId . ':' . $statusId; + return Cache::remember($key, 900, function() use($profileId, $statusId) { + return GroupLike::whereProfileId($profileId)->whereStatusId($statusId)->exists(); + }); + } + + public static function likedBy($status) + { + $empty = [ + 'username' => null, + 'others' => false + ]; + + return $empty; + } + + public static function count($id) + { + return Cache::get(self::CACHE_POST_KEY . $id, 0); + } + +} diff --git a/app/Services/UserStorageService.php b/app/Services/UserStorageService.php new file mode 100644 index 000000000..0fa5c7e0d --- /dev/null +++ b/app/Services/UserStorageService.php @@ -0,0 +1,48 @@ +status) { + return -1; + } + + if ($user->storage_used_updated_at) { + return (int) $user->storage_used; + } + $updatedVal = self::calculateStorageUsed($id); + $user->storage_used = $updatedVal; + $user->storage_used_updated_at = now(); + $user->save(); + + return $user->storage_used; + } + + public static function calculateStorageUsed($id) + { + return (int) floor(Media::whereUserId($id)->sum('size') / 1000); + } + + public static function recalculateUpdateStorageUsed($id) + { + $user = User::find($id); + if (! $user || $user->status) { + return; + } + $updatedVal = (int) floor(Media::whereUserId($id)->sum('size') / 1000); + $user->storage_used = $updatedVal; + $user->storage_used_updated_at = now(); + $user->save(); + + return $updatedVal; + } +} diff --git a/app/Transformer/Api/GroupPostTransformer.php b/app/Transformer/Api/GroupPostTransformer.php new file mode 100644 index 000000000..0999b3fa4 --- /dev/null +++ b/app/Transformer/Api/GroupPostTransformer.php @@ -0,0 +1,59 @@ + (string) $status->id, + 'gid' => $status->group_id ? (string) $status->group_id : null, + 'url' => '/groups/' . $status->group_id . '/p/' . $status->id, + 'content' => $status->caption, + 'content_text' => $status->caption, + 'created_at' => str_replace('+00:00', 'Z', $status->created_at->format(DATE_RFC3339_EXTENDED)), + 'reblogs_count' => $status->reblogs_count ?? 0, + 'favourites_count' => $status->likes_count ?? 0, + 'reblogged' => null, + 'favourited' => null, + 'muted' => null, + 'sensitive' => (bool) $status->is_nsfw, + 'spoiler_text' => $status->cw_summary ?? '', + 'visibility' => $status->visibility, + 'application' => [ + 'name' => 'web', + 'website' => null + ], + 'language' => null, + 'pf_type' => $status->type, + 'reply_count' => (int) $status->reply_count ?? 0, + 'comments_disabled' => (bool) $status->comments_disabled, + 'thread' => false, + 'media_attachments' => GroupMediaService::get($status->id), + 'replies' => [], + 'parent' => [], + 'place' => null, + 'local' => (bool) !$status->remote_url, + 'account' => AccountService::get($status->profile_id, true), + 'poll' => [], + ]; + } +} diff --git a/app/User.php b/app/User.php index 6ec31e969..30b502308 100644 --- a/app/User.php +++ b/app/User.php @@ -2,32 +2,34 @@ namespace App; -use Laravel\Passport\HasApiTokens; -use Illuminate\Notifications\Notifiable; +use App\Services\AvatarService; +use App\Util\RateLimit\User as UserRateLimit; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; -use App\Util\RateLimit\User as UserRateLimit; -use App\Services\AvatarService; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use NotificationChannels\WebPush\HasPushSubscriptions; +use Illuminate\Notifications\Notifiable; +use Laravel\Passport\HasApiTokens; use NotificationChannels\Expo\ExpoPushToken; +use NotificationChannels\WebPush\HasPushSubscriptions; class User extends Authenticatable { - use Notifiable, SoftDeletes, HasApiTokens, UserRateLimit, HasFactory, HasPushSubscriptions; + use HasApiTokens, HasFactory, HasPushSubscriptions, Notifiable, SoftDeletes, UserRateLimit; /** * The attributes that should be mutated to dates. * * @var array */ - protected $casts = [ - 'deleted_at' => 'datetime', - 'email_verified_at' => 'datetime', - '2fa_setup_at' => 'datetime', - 'last_active_at' => 'datetime', - 'expo_token' => ExpoPushToken::class - ]; + protected function casts(): array + { + return [ + 'deleted_at' => 'datetime', + 'email_verified_at' => 'datetime', + '2fa_setup_at' => 'datetime', + 'last_active_at' => 'datetime', + ]; + } /** * The attributes that are mass assignable. @@ -42,7 +44,12 @@ class User extends Authenticatable 'app_register_ip', 'email_verified_at', 'last_active_at', - 'register_source' + 'register_source', + 'expo_token', + 'notify_like', + 'notify_follow', + 'notify_mention', + 'notify_comment', ]; /** @@ -54,7 +61,7 @@ class User extends Authenticatable 'email', 'password', 'is_admin', 'remember_token', 'email_verified_at', '2fa_enabled', '2fa_secret', '2fa_backup_codes', '2fa_setup_at', 'deleted_at', - 'updated_at' + 'updated_at', ]; public function profile() @@ -97,7 +104,7 @@ class User extends Authenticatable public function storageUsedKey() { - return 'profile:storage:used:' . $this->id; + return 'profile:storage:used:'.$this->id; } public function accountLog() @@ -112,14 +119,14 @@ class User extends Authenticatable public function avatarUrl() { - if(!$this->profile_id || $this->status) { - return config('app.url') . '/storage/avatars/default.jpg'; + if (! $this->profile_id || $this->status) { + return config('app.url').'/storage/avatars/default.jpg'; } return AvatarService::get($this->profile_id); } - public function routeNotificationForExpo(): ?ExpoPushToken + public function routeNotificationForExpo() { return $this->expo_token; } diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index 2002a8967..fe82eb2e8 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -25,6 +25,8 @@ use Cache; use Carbon\Carbon; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use League\Uri\Exceptions\UriException; +use League\Uri\Uri; use Purify; use Validator; @@ -153,61 +155,74 @@ class Helpers return in_array($url, $audience['to']) || in_array($url, $audience['cc']); } - public static function validateUrl($url) + public static function validateUrl($url = null, $disableDNSCheck = false) { - if (is_array($url)) { + if (is_array($url) && ! empty($url)) { $url = $url[0]; } + if (! $url || strlen($url) === 0) { + return false; + } + try { + $uri = Uri::new($url); - $hash = hash('sha256', $url); - $key = "helpers:url:valid:sha256-{$hash}"; + if (! $uri) { + return false; + } + + if ($uri->getScheme() !== 'https') { + return false; + } + + $host = $uri->getHost(); + + if (! $host || $host === '') { + return false; + } + + if (! filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { + return false; + } + + if (! str_contains($host, '.')) { + return false; + } - $valid = Cache::remember($key, 900, function () use ($url) { $localhosts = [ - '127.0.0.1', 'localhost', '::1', + 'localhost', + '127.0.0.1', + '::1', + 'broadcasthost', + 'ip6-localhost', + 'ip6-loopback', ]; - if (strtolower(mb_substr($url, 0, 8)) !== 'https://') { - return false; - } - - if (substr_count($url, '://') !== 1) { - return false; - } - - if (mb_substr($url, 0, 8) !== 'https://') { - $url = 'https://'.substr($url, 8); - } - - $valid = filter_var($url, FILTER_VALIDATE_URL); - - if (! $valid) { - return false; - } - - $host = parse_url($valid, PHP_URL_HOST); - if (in_array($host, $localhosts)) { return false; } - if (config('security.url.verify_dns')) { - if (DomainService::hasValidDns($host) === false) { + if ($disableDNSCheck !== true && app()->environment() === 'production' && (bool) config('security.url.verify_dns')) { + $hash = hash('sha256', $host); + $key = "helpers:url:valid-dns:sha256-{$hash}"; + $domainValidDns = Cache::remember($key, 14440, function () use ($host) { + return DomainService::hasValidDns($host); + }); + if (! $domainValidDns) { return false; } } - if (app()->environment() === 'production') { + if ($disableDNSCheck !== true && app()->environment() === 'production') { $bannedInstances = InstanceService::getBannedDomains(); if (in_array($host, $bannedInstances)) { return false; } } - return $url; - }); - - return $valid; + return $uri->toString(); + } catch (UriException $e) { + return false; + } } public static function validateLocalUrl($url) @@ -215,7 +230,12 @@ class Helpers $url = self::validateUrl($url); if ($url == true) { $domain = config('pixelfed.domain.app'); - $host = parse_url($url, PHP_URL_HOST); + + $uri = Uri::new($url); + $host = $uri->getHost(); + if (! $host || empty($host)) { + return false; + } $url = strtolower($domain) === strtolower($host) ? $url : false; return $url; @@ -858,6 +878,11 @@ class Helpers return self::profileFirstOrNew($url); } + public static function getSignedFetch($url) + { + return ActivityPubFetchService::get($url); + } + public static function sendSignedObject($profile, $url, $body) { if (app()->environment() !== 'production') { diff --git a/app/Util/ActivityPub/HttpSignature.php b/app/Util/ActivityPub/HttpSignature.php index 5bfdcac09..35facb82b 100644 --- a/app/Util/ActivityPub/HttpSignature.php +++ b/app/Util/ActivityPub/HttpSignature.php @@ -2,146 +2,159 @@ namespace App\Util\ActivityPub; -use Cache, Log; use App\Models\InstanceActor; use App\Profile; -use \DateTime; +use Cache; +use DateTime; -class HttpSignature { +class HttpSignature +{ + /* + * source: https://github.com/aaronpk/Nautilus/blob/master/app/ActivityPub/HTTPSignature.php + * thanks aaronpk! + */ - /* - * source: https://github.com/aaronpk/Nautilus/blob/master/app/ActivityPub/HTTPSignature.php - * thanks aaronpk! - */ + public static function sign(Profile $profile, $url, $body = false, $addlHeaders = []) + { + if ($body) { + $digest = self::_digest($body); + } + $user = $profile; + $headers = self::_headersToSign($url, $body ? $digest : false); + $headers = array_merge($headers, $addlHeaders); + $stringToSign = self::_headersToSigningString($headers); + $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); + $key = openssl_pkey_get_private($user->private_key); + openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); + $signature = base64_encode($signature); + $signatureHeader = 'keyId="'.$user->keyId().'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; + unset($headers['(request-target)']); + $headers['Signature'] = $signatureHeader; - public static function sign(Profile $profile, $url, $body = false, $addlHeaders = []) { - if($body) { - $digest = self::_digest($body); - } - $user = $profile; - $headers = self::_headersToSign($url, $body ? $digest : false); - $headers = array_merge($headers, $addlHeaders); - $stringToSign = self::_headersToSigningString($headers); - $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); - $key = openssl_pkey_get_private($user->private_key); - openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); - $signature = base64_encode($signature); - $signatureHeader = 'keyId="'.$user->keyId().'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; - unset($headers['(request-target)']); - $headers['Signature'] = $signatureHeader; - - return self::_headersToCurlArray($headers); - } - - public static function instanceActorSign($url, $body = false, $addlHeaders = [], $method = 'post') - { - $keyId = config('app.url') . '/i/actor#main-key'; - $privateKey = Cache::rememberForever(InstanceActor::PKI_PRIVATE, function() { - return InstanceActor::first()->private_key; - }); - if($body) { - $digest = self::_digest($body); - } - $headers = self::_headersToSign($url, $body ? $digest : false, $method); - $headers = array_merge($headers, $addlHeaders); - $stringToSign = self::_headersToSigningString($headers); - $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); - $key = openssl_pkey_get_private($privateKey); - openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); - $signature = base64_encode($signature); - $signatureHeader = 'keyId="'.$keyId.'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; - unset($headers['(request-target)']); - $headers['Signature'] = $signatureHeader; - - return $headers; - } - - public static function parseSignatureHeader($signature) { - $parts = explode(',', $signature); - $signatureData = []; - - foreach($parts as $part) { - if(preg_match('/(.+)="(.+)"/', $part, $match)) { - $signatureData[$match[1]] = $match[2]; - } + return self::_headersToCurlArray($headers); } - if(!isset($signatureData['keyId'])) { - return [ - 'error' => 'No keyId was found in the signature header. Found: '.implode(', ', array_keys($signatureData)) - ]; + public static function instanceActorSign($url, $body = false, $addlHeaders = [], $method = 'post') + { + $keyId = config('app.url').'/i/actor#main-key'; + $privateKey = Cache::rememberForever(InstanceActor::PKI_PRIVATE, function () { + return InstanceActor::first()->private_key; + }); + if ($body) { + $digest = self::_digest($body); + } + $headers = self::_headersToSign($url, $body ? $digest : false, $method); + $headers = array_merge($headers, $addlHeaders); + $stringToSign = self::_headersToSigningString($headers); + $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); + $key = openssl_pkey_get_private($privateKey); + openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); + $signature = base64_encode($signature); + $signatureHeader = 'keyId="'.$keyId.'",headers="'.$signedHeaders.'",algorithm="rsa-sha256",signature="'.$signature.'"'; + unset($headers['(request-target)']); + $headers['Signature'] = $signatureHeader; + + return $headers; } - if(!filter_var($signatureData['keyId'], FILTER_VALIDATE_URL)) { - return [ - 'error' => 'keyId is not a URL: '.$signatureData['keyId'] - ]; + public static function parseSignatureHeader($signature) + { + $parts = explode(',', $signature); + $signatureData = []; + + foreach ($parts as $part) { + if (preg_match('/(.+)="(.+)"/', $part, $match)) { + $signatureData[$match[1]] = $match[2]; + } + } + + if (! isset($signatureData['keyId'])) { + return [ + 'error' => 'No keyId was found in the signature header. Found: '.implode(', ', array_keys($signatureData)), + ]; + } + + if (! filter_var($signatureData['keyId'], FILTER_VALIDATE_URL)) { + return [ + 'error' => 'keyId is not a URL: '.$signatureData['keyId'], + ]; + } + + if (! Helpers::validateUrl($signatureData['keyId'])) { + return [ + 'error' => 'keyId is not a URL: '.$signatureData['keyId'], + ]; + } + + if (! isset($signatureData['headers']) || ! isset($signatureData['signature'])) { + return [ + 'error' => 'Signature is missing headers or signature parts', + ]; + } + + return $signatureData; } - if(!isset($signatureData['headers']) || !isset($signatureData['signature'])) { - return [ - 'error' => 'Signature is missing headers or signature parts' - ]; + public static function verify($publicKey, $signatureData, $inputHeaders, $path, $body) + { + $digest = 'SHA-256='.base64_encode(hash('sha256', $body, true)); + $headersToSign = []; + foreach (explode(' ', $signatureData['headers']) as $h) { + if ($h == '(request-target)') { + $headersToSign[$h] = 'post '.$path; + } elseif ($h == 'digest') { + $headersToSign[$h] = $digest; + } elseif (isset($inputHeaders[$h][0])) { + $headersToSign[$h] = $inputHeaders[$h][0]; + } + } + $signingString = self::_headersToSigningString($headersToSign); + + $verified = openssl_verify($signingString, base64_decode($signatureData['signature']), $publicKey, OPENSSL_ALGO_SHA256); + + return [$verified, $signingString]; } - return $signatureData; - } - - public static function verify($publicKey, $signatureData, $inputHeaders, $path, $body) { - $digest = 'SHA-256='.base64_encode(hash('sha256', $body, true)); - $headersToSign = []; - foreach(explode(' ',$signatureData['headers']) as $h) { - if($h == '(request-target)') { - $headersToSign[$h] = 'post '.$path; - } elseif($h == 'digest') { - $headersToSign[$h] = $digest; - } elseif(isset($inputHeaders[$h][0])) { - $headersToSign[$h] = $inputHeaders[$h][0]; - } - } - $signingString = self::_headersToSigningString($headersToSign); - - $verified = openssl_verify($signingString, base64_decode($signatureData['signature']), $publicKey, OPENSSL_ALGO_SHA256); - - return [$verified, $signingString]; - } - - private static function _headersToSigningString($headers) { - return implode("\n", array_map(function($k, $v){ - return strtolower($k).': '.$v; - }, array_keys($headers), $headers)); - } - - private static function _headersToCurlArray($headers) { - return array_map(function($k, $v){ - return "$k: $v"; - }, array_keys($headers), $headers); - } - - private static function _digest($body) { - if(is_array($body)) { - $body = json_encode($body); - } - return base64_encode(hash('sha256', $body, true)); - } - - protected static function _headersToSign($url, $digest = false, $method = 'post') { - $date = new DateTime('UTC'); - - if(!in_array($method, ['post', 'get'])) { - throw new \Exception('Invalid method used to sign headers in HttpSignature'); - } - $headers = [ - '(request-target)' => $method . ' '.parse_url($url, PHP_URL_PATH), - 'Host' => parse_url($url, PHP_URL_HOST), - 'Date' => $date->format('D, d M Y H:i:s \G\M\T'), - ]; - - if($digest) { - $headers['Digest'] = 'SHA-256='.$digest; + private static function _headersToSigningString($headers) + { + return implode("\n", array_map(function ($k, $v) { + return strtolower($k).': '.$v; + }, array_keys($headers), $headers)); } - return $headers; - } + private static function _headersToCurlArray($headers) + { + return array_map(function ($k, $v) { + return "$k: $v"; + }, array_keys($headers), $headers); + } + private static function _digest($body) + { + if (is_array($body)) { + $body = json_encode($body); + } + + return base64_encode(hash('sha256', $body, true)); + } + + protected static function _headersToSign($url, $digest = false, $method = 'post') + { + $date = new DateTime('UTC'); + + if (! in_array($method, ['post', 'get'])) { + throw new \Exception('Invalid method used to sign headers in HttpSignature'); + } + $headers = [ + '(request-target)' => $method.' '.parse_url($url, PHP_URL_PATH), + 'Host' => parse_url($url, PHP_URL_HOST), + 'Date' => $date->format('D, d M Y H:i:s \G\M\T'), + ]; + + if ($digest) { + $headers['Digest'] = 'SHA-256='.$digest; + } + + return $headers; + } } diff --git a/app/Util/Site/Config.php b/app/Util/Site/Config.php index e661d82fe..530dc7108 100644 --- a/app/Util/Site/Config.php +++ b/app/Util/Site/Config.php @@ -7,7 +7,7 @@ use Illuminate\Support\Str; class Config { - const CACHE_KEY = 'api:site:configuration:_v0.8'; + const CACHE_KEY = 'api:site:configuration:_v0.9'; public static function get() { @@ -97,6 +97,7 @@ class Config ], ], 'hls' => $hls, + 'groups' => (bool) config('groups.enabled'), ], ]; }); diff --git a/composer.json b/composer.json index de544cb59..29fd22327 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "doctrine/dbal": "^3.0", "intervention/image": "^2.4", "jenssegers/agent": "^2.6", - "laravel-notification-channels/expo": "^1.3.0|^2.0", + "laravel-notification-channels/expo": "~1.3.0|~2.0.0", "laravel-notification-channels/webpush": "^8.0", "laravel/framework": "^11.0", "laravel/helpers": "^1.1", @@ -29,6 +29,7 @@ "laravel/ui": "^4.2", "league/flysystem-aws-s3-v3": "^3.0", "league/iso3166": "^2.1|^4.0", + "league/uri": "^7.4", "pbmedia/laravel-ffmpeg": "^8.0", "phpseclib/phpseclib": "~2.0", "pixelfed/fractal": "^0.18.0", diff --git a/composer.lock b/composer.lock index 1f8509874..370627f1b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fbadeaf1fbbd9e0f64feaa1433ca7dd0", + "content-hash": "fecc0efcc40880a422690feefedef584", "packages": [ { "name": "aws/aws-crt-php", @@ -2249,29 +2249,34 @@ }, { "name": "laravel-notification-channels/expo", - "version": "1.3.1", + "version": "v2.0.0", "source": { "type": "git", "url": "https://github.com/laravel-notification-channels/expo.git", - "reference": "d718a89dfc4997aba69b673f5db416ac833188e9" + "reference": "29d038b6409077ac4c671cc5587a4dc7986260b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel-notification-channels/expo/zipball/d718a89dfc4997aba69b673f5db416ac833188e9", - "reference": "d718a89dfc4997aba69b673f5db416ac833188e9", + "url": "https://api.github.com/repos/laravel-notification-channels/expo/zipball/29d038b6409077ac4c671cc5587a4dc7986260b0", + "reference": "29d038b6409077ac4c671cc5587a4dc7986260b0", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/guzzle": "~6.0 || ~7.0", - "illuminate/notifications": "~6.0 || ~7.0 || ~8.0 || ~9.0 || ~10.0 || ~11.0", - "illuminate/support": "~6.0 || ~7.0 || ~8.0 || ~9.0 || ~10.0 || ~11.0", - "php": ">=7.4 || ^8.0 || ^8.1 || ^8.2 || ^8.3" + "guzzlehttp/guzzle": "^7.1", + "illuminate/contracts": "^11.0", + "illuminate/notifications": "^11.0", + "illuminate/support": "^11.0", + "php": "~8.3" }, "require-dev": { - "mockery/mockery": "^1.0", - "orchestra/testbench": "^6.18", - "phpunit/phpunit": "^8.0" + "larastan/larastan": "^2.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^9.0", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-zlib": "Required for compressing payloads exceeding 1 KiB in size." }, "type": "library", "extra": { @@ -2292,19 +2297,19 @@ ], "authors": [ { - "name": "Nick Pratley", - "email": "nick@npratley.net", - "homepage": "https://npratley.net/", + "name": "Muhammed Sari", + "email": "muhammed@dive.be", + "homepage": "https://dive.be", "role": "Developer" } ], - "description": "Expo Notifications driver for Laravel", + "description": "Expo Notifications Channel for Laravel", "homepage": "https://github.com/laravel-notification-channels/expo", "support": { "issues": "https://github.com/laravel-notification-channels/expo/issues", - "source": "https://github.com/laravel-notification-channels/expo/tree/1.3.1" + "source": "https://github.com/laravel-notification-channels/expo/tree/v2.0.0" }, - "time": "2024-03-15T00:24:58+00:00" + "time": "2024-03-18T07:49:28+00:00" }, { "name": "laravel-notification-channels/webpush", diff --git a/config/federation.php b/config/federation.php index 3d7a7bb30..124935ec8 100644 --- a/config/federation.php +++ b/config/federation.php @@ -30,6 +30,8 @@ return [ 'ingest' => [ 'store_notes_without_followers' => env('AP_INGEST_STORE_NOTES_WITHOUT_FOLLOWERS', false), ], + + 'authorized_fetch' => env('AUTHORIZED_FETCH', false), ], 'atom' => [ diff --git a/config/groups.php b/config/groups.php new file mode 100644 index 000000000..24513e502 --- /dev/null +++ b/config/groups.php @@ -0,0 +1,13 @@ + env('GROUPS_ENABLED', false), + 'federation' => env('GROUPS_FEDERATION', true), + + 'acl' => [ + 'create_group' => [ + 'admins' => env('GROUPS_ACL_CREATE_ADMINS', true), + 'users' => env('GROUPS_ACL_CREATE_USERS', true), + ] + ] +]; diff --git a/database/migrations/2019_03_12_043935_add_snowflakeids_to_users_table.php b/database/migrations/2019_03_12_043935_add_snowflakeids_to_users_table.php index e5d32f2db..8c5dfad93 100644 --- a/database/migrations/2019_03_12_043935_add_snowflakeids_to_users_table.php +++ b/database/migrations/2019_03_12_043935_add_snowflakeids_to_users_table.php @@ -14,8 +14,8 @@ class AddSnowflakeidsToUsersTable extends Migration public function up() { Schema::table('statuses', function (Blueprint $table) { - $table->dropPrimary('id'); $table->bigInteger('id')->unsigned()->primary()->change(); + $table->dropPrimary('id'); }); } diff --git a/database/migrations/2019_04_25_200411_add_snowflake_ids_to_collections_table.php b/database/migrations/2019_04_25_200411_add_snowflake_ids_to_collections_table.php index c7cab9c5b..a0e88ce7e 100644 --- a/database/migrations/2019_04_25_200411_add_snowflake_ids_to_collections_table.php +++ b/database/migrations/2019_04_25_200411_add_snowflake_ids_to_collections_table.php @@ -14,13 +14,13 @@ class AddSnowflakeIdsToCollectionsTable extends Migration public function up() { Schema::table('collections', function (Blueprint $table) { - $table->dropPrimary('id'); $table->bigInteger('id')->unsigned()->primary()->change(); + $table->dropPrimary('id'); }); Schema::table('collection_items', function (Blueprint $table) { - $table->dropPrimary('id'); $table->bigInteger('id')->unsigned()->primary()->change(); + $table->dropPrimary('id'); }); } diff --git a/database/migrations/2021_08_04_100435_create_group_roles_table.php b/database/migrations/2021_08_04_100435_create_group_roles_table.php new file mode 100644 index 000000000..c2b0d0ff4 --- /dev/null +++ b/database/migrations/2021_08_04_100435_create_group_roles_table.php @@ -0,0 +1,36 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->text('abilities')->nullable(); + $table->unique(['group_id', 'slug']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_roles'); + } +} diff --git a/database/migrations/2021_08_16_100034_create_group_interactions_table.php b/database/migrations/2021_08_16_100034_create_group_interactions_table.php new file mode 100644 index 000000000..adc32d1d1 --- /dev/null +++ b/database/migrations/2021_08_16_100034_create_group_interactions_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->string('type')->nullable()->index(); + $table->string('item_type')->nullable()->index(); + $table->string('item_id')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_interactions'); + } +} diff --git a/database/migrations/2021_08_17_073839_create_group_reports_table.php b/database/migrations/2021_08_17_073839_create_group_reports_table.php new file mode 100644 index 000000000..93ed00d63 --- /dev/null +++ b/database/migrations/2021_08_17_073839_create_group_reports_table.php @@ -0,0 +1,39 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->string('type')->nullable()->index(); + $table->string('item_type')->nullable()->index(); + $table->string('item_id')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->boolean('open')->default(true)->index(); + $table->unique(['group_id', 'profile_id', 'item_type', 'item_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_reports'); + } +} diff --git a/database/migrations/2021_09_26_112423_create_group_blocks_table.php b/database/migrations/2021_09_26_112423_create_group_blocks_table.php new file mode 100644 index 000000000..320fcf985 --- /dev/null +++ b/database/migrations/2021_09_26_112423_create_group_blocks_table.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('admin_id')->unsigned()->nullable(); + $table->bigInteger('profile_id')->nullable()->unsigned()->index(); + $table->bigInteger('instance_id')->nullable()->unsigned()->index(); + $table->string('name')->nullable()->index(); + $table->string('reason')->nullable(); + $table->boolean('is_user')->index(); + $table->boolean('moderated')->default(false)->index(); + $table->unique(['group_id', 'profile_id', 'instance_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_blocks'); + } +} diff --git a/database/migrations/2021_09_29_023230_create_group_limits_table.php b/database/migrations/2021_09_29_023230_create_group_limits_table.php new file mode 100644 index 000000000..67ca7bec8 --- /dev/null +++ b/database/migrations/2021_09_29_023230_create_group_limits_table.php @@ -0,0 +1,36 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->json('limits')->nullable(); + $table->json('metadata')->nullable(); + $table->unique(['group_id', 'profile_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_limits'); + } +} diff --git a/database/migrations/2021_10_01_083917_create_group_categories_table.php b/database/migrations/2021_10_01_083917_create_group_categories_table.php new file mode 100644 index 000000000..481ddf5ef --- /dev/null +++ b/database/migrations/2021_10_01_083917_create_group_categories_table.php @@ -0,0 +1,102 @@ +id(); + $table->string('name')->unique()->index(); + $table->string('slug')->unique()->index(); + $table->boolean('active')->default(true)->index(); + $table->tinyInteger('order')->unsigned()->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + + $default = [ + 'General', + 'Photography', + 'Fediverse', + 'CompSci & Programming', + 'Causes & Movements', + 'Humor', + 'Science & Tech', + 'Travel', + 'Buy & Sell', + 'Business', + 'Style', + 'Animals', + 'Sports & Fitness', + 'Education', + 'Arts', + 'Entertainment', + 'Faith & Spirituality', + 'Relationships & Identity', + 'Parenting', + 'Hobbies & Interests', + 'Food & Drink', + 'Vehicles & Commutes', + 'Civics & Community', + ]; + + for ($i=1; $i <= 23; $i++) { + $cat = new GroupCategory; + $cat->name = $default[$i - 1]; + $cat->slug = str_slug($cat->name); + $cat->active = true; + $cat->order = $i; + $cat->save(); + } + + Schema::table('groups', function (Blueprint $table) { + $table->unsignedInteger('category_id')->default(1)->index()->after('id'); + $table->unsignedInteger('member_count')->nullable(); + $table->boolean('recommended')->default(false)->index(); + $table->boolean('discoverable')->default(false)->index(); + $table->boolean('activitypub')->default(false); + $table->boolean('is_nsfw')->default(false); + $table->boolean('dms')->default(false); + $table->boolean('autospam')->default(false); + $table->boolean('verified')->default(false); + $table->timestamp('last_active_at')->nullable(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_categories'); + + Schema::table('groups', function (Blueprint $table) { + $table->dropColumn('category_id'); + $table->dropColumn('member_count'); + $table->dropColumn('recommended'); + $table->dropColumn('activitypub'); + $table->dropColumn('is_nsfw'); + $table->dropColumn('discoverable'); + $table->dropColumn('dms'); + $table->dropColumn('autospam'); + $table->dropColumn('verified'); + $table->dropColumn('last_active_at'); + $table->dropColumn('deleted_at'); + }); + } +} diff --git a/database/migrations/2021_10_09_004230_create_group_hashtags_table.php b/database/migrations/2021_10_09_004230_create_group_hashtags_table.php new file mode 100644 index 000000000..1d05dabb9 --- /dev/null +++ b/database/migrations/2021_10_09_004230_create_group_hashtags_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->string('name')->unique()->index(); + $table->string('formatted')->nullable(); + $table->boolean('recommended')->default(false); + $table->boolean('sensitive')->default(false); + $table->boolean('banned')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_hashtags'); + } +} diff --git a/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php b/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php new file mode 100644 index 000000000..08014e399 --- /dev/null +++ b/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php @@ -0,0 +1,41 @@ +bigIncrements('id'); + $table->bigInteger('hashtag_id')->unsigned()->index(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned(); + $table->bigInteger('status_id')->unsigned()->nullable(); + $table->string('status_visibility')->nullable(); + $table->boolean('nsfw')->default(false); + $table->unique(['hashtag_id', 'group_id', 'profile_id', 'status_id'], 'group_post_hashtags_gda_unique'); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + $table->foreign('profile_id')->references('id')->on('profiles')->onDelete('cascade'); + $table->foreign('hashtag_id')->references('id')->on('group_hashtags')->onDelete('cascade'); + $table->foreign('status_id')->references('id')->on('group_posts')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_post_hashtags'); + } +} diff --git a/database/migrations/2021_10_13_002033_create_group_stores_table.php b/database/migrations/2021_10_13_002033_create_group_stores_table.php new file mode 100644 index 000000000..efdf0a966 --- /dev/null +++ b/database/migrations/2021_10_13_002033_create_group_stores_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->nullable()->index(); + $table->string('store_key')->index(); + $table->json('store_value')->nullable(); + $table->json('metadata')->nullable(); + $table->unique(['group_id', 'store_key']); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_stores'); + } +} diff --git a/database/migrations/2021_10_13_002041_create_group_events_table.php b/database/migrations/2021_10_13_002041_create_group_events_table.php new file mode 100644 index 000000000..166c35cf0 --- /dev/null +++ b/database/migrations/2021_10_13_002041_create_group_events_table.php @@ -0,0 +1,44 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->nullable()->index(); + $table->bigInteger('profile_id')->unsigned()->nullable()->index(); + $table->string('name')->nullable(); + $table->string('type')->index(); + $table->json('tags')->nullable(); + $table->json('location')->nullable(); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + $table->boolean('open')->default(false)->index(); + $table->boolean('comments_open')->default(false); + $table->boolean('show_guest_list')->default(false); + $table->timestamp('start_at')->nullable(); + $table->timestamp('end_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_events'); + } +} diff --git a/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php b/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php new file mode 100644 index 000000000..13fef7240 --- /dev/null +++ b/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->bigInteger('instance_id')->nullable()->index(); + $table->bigInteger('actor_id')->nullable()->index(); + $table->string('verb')->nullable()->index(); + $table->string('id_url')->nullable()->unique()->index(); + $table->json('payload')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_activity_graphs'); + } +} diff --git a/database/migrations/2024_05_20_062706_update_group_posts_table.php b/database/migrations/2024_05_20_062706_update_group_posts_table.php new file mode 100644 index 000000000..99f272be9 --- /dev/null +++ b/database/migrations/2024_05_20_062706_update_group_posts_table.php @@ -0,0 +1,48 @@ +dropColumn('status_id'); + $table->dropColumn('reply_child_id'); + $table->dropColumn('in_reply_to_id'); + $table->dropColumn('reblog_of_id'); + $table->text('caption')->nullable(); + $table->string('visibility')->nullable(); + $table->boolean('is_nsfw')->default(false); + $table->unsignedInteger('likes_count')->default(0); + $table->text('cw_summary')->nullable(); + $table->json('media_ids')->nullable(); + $table->boolean('comments_disabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('group_posts', function (Blueprint $table) { + $table->bigInteger('status_id')->unsigned()->unique()->nullable(); + $table->bigInteger('reply_child_id')->unsigned()->nullable(); + $table->bigInteger('in_reply_to_id')->unsigned()->nullable(); + $table->bigInteger('reblog_of_id')->unsigned()->nullable(); + $table->dropColumn('caption'); + $table->dropColumn('is_nsfw'); + $table->dropColumn('visibility'); + $table->dropColumn('likes_count'); + $table->dropColumn('cw_summary'); + $table->dropColumn('media_ids'); + $table->dropColumn('comments_disabled'); + }); + } +}; diff --git a/database/migrations/2024_05_20_063638_create_group_comments_table.php b/database/migrations/2024_05_20_063638_create_group_comments_table.php new file mode 100644 index 000000000..ad49f58c8 --- /dev/null +++ b/database/migrations/2024_05_20_063638_create_group_comments_table.php @@ -0,0 +1,43 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id')->index(); + $table->unsignedBigInteger('profile_id')->nullable(); + $table->unsignedBigInteger('status_id')->nullable()->index(); + $table->unsignedBigInteger('in_reply_to_id')->nullable()->index(); + $table->string('remote_url')->nullable()->unique()->index(); + $table->text('caption')->nullable(); + $table->boolean('is_nsfw')->default(false); + $table->string('visibility')->nullable(); + $table->unsignedInteger('likes_count')->default(0); + $table->unsignedInteger('replies_count')->default(0); + $table->text('cw_summary')->nullable(); + $table->json('media_ids')->nullable(); + $table->string('status')->nullable(); + $table->string('type')->default('text')->nullable(); + $table->boolean('local')->default(false); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_comments'); + } +}; diff --git a/database/migrations/2024_05_20_073054_create_group_likes_table.php b/database/migrations/2024_05_20_073054_create_group_likes_table.php new file mode 100644 index 000000000..162ef7458 --- /dev/null +++ b/database/migrations/2024_05_20_073054_create_group_likes_table.php @@ -0,0 +1,33 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id'); + $table->unsignedBigInteger('profile_id')->index(); + $table->unsignedBigInteger('status_id')->nullable(); + $table->unsignedBigInteger('comment_id')->nullable(); + $table->boolean('local')->default(true); + $table->unique(['group_id', 'profile_id', 'status_id', 'comment_id'], 'group_likes_gpsc_unique'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_likes'); + } +}; diff --git a/database/migrations/2024_05_20_083159_create_group_media_table.php b/database/migrations/2024_05_20_083159_create_group_media_table.php new file mode 100644 index 000000000..732856097 --- /dev/null +++ b/database/migrations/2024_05_20_083159_create_group_media_table.php @@ -0,0 +1,50 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id'); + $table->unsignedBigInteger('profile_id'); + $table->unsignedBigInteger('status_id')->nullable()->index(); + $table->string('media_path')->unique(); + $table->text('thumbnail_url')->nullable(); + $table->text('cdn_url')->nullable(); + $table->text('url')->nullable(); + $table->string('mime')->nullable(); + $table->unsignedInteger('size')->nullable(); + $table->text('cw_summary')->nullable(); + $table->string('license')->nullable(); + $table->string('blurhash')->nullable(); + $table->tinyInteger('order')->unsigned()->default(1); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->boolean('local_user')->default(true); + $table->boolean('is_cached')->default(false); + $table->boolean('is_comment')->default(false)->index(); + $table->json('metadata')->nullable(); + $table->string('version')->default(1); + $table->boolean('skip_optimize')->default(false); + $table->timestamp('processed_at')->nullable(); + $table->timestamp('thumbnail_generated')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_media'); + } +}; diff --git a/database/migrations/2024_07_29_081002_add_storage_used_to_users_table.php b/database/migrations/2024_07_29_081002_add_storage_used_to_users_table.php new file mode 100644 index 000000000..d794b945e --- /dev/null +++ b/database/migrations/2024_07_29_081002_add_storage_used_to_users_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('storage_used')->default(0); + $table->timestamp('storage_used_updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('storage_used'); + $table->dropColumn('storage_used_updated_at'); + }); + } +}; diff --git a/public/css/spa.css b/public/css/spa.css index b1a22b331..9847ed198 100644 Binary files a/public/css/spa.css and b/public/css/spa.css differ diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 000000000..109858e80 Binary files /dev/null and b/public/js/admin.js differ diff --git a/public/js/changelog.bundle.8b5073534a5f04a4.js b/public/js/changelog.bundle.8b5073534a5f04a4.js new file mode 100644 index 000000000..3bd469ea0 Binary files /dev/null and b/public/js/changelog.bundle.8b5073534a5f04a4.js differ diff --git a/public/js/compose.chunk.85fe3af81758fb89.js b/public/js/compose.chunk.85fe3af81758fb89.js new file mode 100644 index 000000000..35f99d046 Binary files /dev/null and b/public/js/compose.chunk.85fe3af81758fb89.js differ diff --git a/public/js/daci.chunk.b0b7e6318a10b31f.js b/public/js/daci.chunk.b0b7e6318a10b31f.js new file mode 100644 index 000000000..53aa7d934 Binary files /dev/null and b/public/js/daci.chunk.b0b7e6318a10b31f.js differ diff --git a/public/js/discover.chunk.249a98cf3d09bf6b.js b/public/js/discover.chunk.249a98cf3d09bf6b.js new file mode 100644 index 000000000..9083e34bb Binary files /dev/null and b/public/js/discover.chunk.249a98cf3d09bf6b.js differ diff --git a/public/js/discover~findfriends.chunk.4cef4cef03ddc4b0.js b/public/js/discover~findfriends.chunk.4cef4cef03ddc4b0.js new file mode 100644 index 000000000..f927a637e Binary files /dev/null and b/public/js/discover~findfriends.chunk.4cef4cef03ddc4b0.js differ diff --git a/public/js/discover~hashtag.bundle.bf7ca8af8eb870e8.js b/public/js/discover~hashtag.bundle.bf7ca8af8eb870e8.js new file mode 100644 index 000000000..b453b258a Binary files /dev/null and b/public/js/discover~hashtag.bundle.bf7ca8af8eb870e8.js differ diff --git a/public/js/discover~memories.chunk.0c7a7f02f0d3f440.js b/public/js/discover~memories.chunk.0c7a7f02f0d3f440.js new file mode 100644 index 000000000..0e6007c7b Binary files /dev/null and b/public/js/discover~memories.chunk.0c7a7f02f0d3f440.js differ diff --git a/public/js/discover~myhashtags.chunk.8f27f5212a72d5aa.js b/public/js/discover~myhashtags.chunk.8f27f5212a72d5aa.js new file mode 100644 index 000000000..41eb3850a Binary files /dev/null and b/public/js/discover~myhashtags.chunk.8f27f5212a72d5aa.js differ diff --git a/public/js/discover~serverfeed.chunk.a7cf7e32b5cf22f5.js b/public/js/discover~serverfeed.chunk.a7cf7e32b5cf22f5.js new file mode 100644 index 000000000..898f8c652 Binary files /dev/null and b/public/js/discover~serverfeed.chunk.a7cf7e32b5cf22f5.js differ diff --git a/public/js/discover~settings.chunk.4a39509b86a31bed.js b/public/js/discover~settings.chunk.4a39509b86a31bed.js new file mode 100644 index 000000000..e909db822 Binary files /dev/null and b/public/js/discover~settings.chunk.4a39509b86a31bed.js differ diff --git a/public/js/dms.chunk.ee50a6d42af9b051.js b/public/js/dms.chunk.ee50a6d42af9b051.js new file mode 100644 index 000000000..a409df0c2 Binary files /dev/null and b/public/js/dms.chunk.ee50a6d42af9b051.js differ diff --git a/public/js/dms~message.chunk.c3448c8fda82f05a.js b/public/js/dms~message.chunk.c3448c8fda82f05a.js new file mode 100644 index 000000000..4a9c8c330 Binary files /dev/null and b/public/js/dms~message.chunk.c3448c8fda82f05a.js differ diff --git a/public/js/group-status.js b/public/js/group-status.js new file mode 100644 index 000000000..3f0e8cb2c Binary files /dev/null and b/public/js/group-status.js differ diff --git a/public/js/group-topic-feed.js b/public/js/group-topic-feed.js new file mode 100644 index 000000000..c4ed457fc Binary files /dev/null and b/public/js/group-topic-feed.js differ diff --git a/public/js/group.create.8aec8e805eef6c57.js b/public/js/group.create.8aec8e805eef6c57.js new file mode 100644 index 000000000..00d3dba84 Binary files /dev/null and b/public/js/group.create.8aec8e805eef6c57.js differ diff --git a/public/js/groups-page-about.150f2f899988e65c.js b/public/js/groups-page-about.150f2f899988e65c.js new file mode 100644 index 000000000..8941d3183 Binary files /dev/null and b/public/js/groups-page-about.150f2f899988e65c.js differ diff --git a/public/js/groups-page-media.a57186ce36fd8972.js b/public/js/groups-page-media.a57186ce36fd8972.js new file mode 100644 index 000000000..eab30646b Binary files /dev/null and b/public/js/groups-page-media.a57186ce36fd8972.js differ diff --git a/public/js/groups-page-members.20f9217256d06bf3.js b/public/js/groups-page-members.20f9217256d06bf3.js new file mode 100644 index 000000000..e6be9561b Binary files /dev/null and b/public/js/groups-page-members.20f9217256d06bf3.js differ diff --git a/public/js/groups-page-topics.c856bf15dc42b2fb.js b/public/js/groups-page-topics.c856bf15dc42b2fb.js new file mode 100644 index 000000000..fd2375947 Binary files /dev/null and b/public/js/groups-page-topics.c856bf15dc42b2fb.js differ diff --git a/public/js/groups-page.6c5fbadccf05f783.js b/public/js/groups-page.6c5fbadccf05f783.js new file mode 100644 index 000000000..678d0ad5c Binary files /dev/null and b/public/js/groups-page.6c5fbadccf05f783.js differ diff --git a/public/js/groups-post.c9083f5a20000208.js b/public/js/groups-post.c9083f5a20000208.js new file mode 100644 index 000000000..6db341dc2 Binary files /dev/null and b/public/js/groups-post.c9083f5a20000208.js differ diff --git a/public/js/groups-profile.9049d02d06606680.js b/public/js/groups-profile.9049d02d06606680.js new file mode 100644 index 000000000..d8e8563e7 Binary files /dev/null and b/public/js/groups-profile.9049d02d06606680.js differ diff --git a/public/js/groups.js b/public/js/groups.js new file mode 100644 index 000000000..c2a9a04b9 Binary files /dev/null and b/public/js/groups.js differ diff --git a/public/js/home.chunk.6cdfc32fcb0f1ef1.js b/public/js/home.chunk.6cdfc32fcb0f1ef1.js new file mode 100644 index 000000000..31027f9cd Binary files /dev/null and b/public/js/home.chunk.6cdfc32fcb0f1ef1.js differ diff --git a/public/js/home.chunk.6cdfc32fcb0f1ef1.js.LICENSE.txt b/public/js/home.chunk.6cdfc32fcb0f1ef1.js.LICENSE.txt new file mode 100644 index 000000000..ae386fb79 --- /dev/null +++ b/public/js/home.chunk.6cdfc32fcb0f1ef1.js.LICENSE.txt @@ -0,0 +1 @@ +/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ diff --git a/public/js/i18n.bundle.eb796c04a5b36379.js b/public/js/i18n.bundle.eb796c04a5b36379.js new file mode 100644 index 000000000..564d5e4ba Binary files /dev/null and b/public/js/i18n.bundle.eb796c04a5b36379.js differ diff --git a/public/js/landing.js b/public/js/landing.js new file mode 100644 index 000000000..55cb59cc8 Binary files /dev/null and b/public/js/landing.js differ diff --git a/public/js/manifest.js b/public/js/manifest.js new file mode 100644 index 000000000..f8fe581f1 Binary files /dev/null and b/public/js/manifest.js differ diff --git a/public/js/notifications.chunk.080d9f494cc6ddc3.js b/public/js/notifications.chunk.080d9f494cc6ddc3.js new file mode 100644 index 000000000..786abd110 Binary files /dev/null and b/public/js/notifications.chunk.080d9f494cc6ddc3.js differ diff --git a/public/js/post.chunk.aa37cf5357a5233e.js b/public/js/post.chunk.aa37cf5357a5233e.js new file mode 100644 index 000000000..f30ca2307 Binary files /dev/null and b/public/js/post.chunk.aa37cf5357a5233e.js differ diff --git a/public/js/post.chunk.aa37cf5357a5233e.js.LICENSE.txt b/public/js/post.chunk.aa37cf5357a5233e.js.LICENSE.txt new file mode 100644 index 000000000..ae386fb79 --- /dev/null +++ b/public/js/post.chunk.aa37cf5357a5233e.js.LICENSE.txt @@ -0,0 +1 @@ +/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ diff --git a/public/js/profile.chunk.14906ecdcfc5b0b1.js b/public/js/profile.chunk.14906ecdcfc5b0b1.js new file mode 100644 index 000000000..d6f3e984e Binary files /dev/null and b/public/js/profile.chunk.14906ecdcfc5b0b1.js differ diff --git a/public/js/profile.js b/public/js/profile.js new file mode 100644 index 000000000..55d85fc51 Binary files /dev/null and b/public/js/profile.js differ diff --git a/public/js/search.js b/public/js/search.js new file mode 100644 index 000000000..5e33f2250 Binary files /dev/null and b/public/js/search.js differ diff --git a/public/js/spa.js b/public/js/spa.js new file mode 100644 index 000000000..04dc214fc Binary files /dev/null and b/public/js/spa.js differ diff --git a/public/js/status.js b/public/js/status.js new file mode 100644 index 000000000..a6d1aea94 Binary files /dev/null and b/public/js/status.js differ diff --git a/public/js/timeline.js b/public/js/timeline.js new file mode 100644 index 000000000..5e30f3783 Binary files /dev/null and b/public/js/timeline.js differ diff --git a/public/js/vendor.js b/public/js/vendor.js new file mode 100644 index 000000000..2f0dfd79b Binary files /dev/null and b/public/js/vendor.js differ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index a5fa1da5f..7fa5e6ca4 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/components/GroupCreate.vue b/resources/assets/components/GroupCreate.vue new file mode 100644 index 000000000..26c48948b --- /dev/null +++ b/resources/assets/components/GroupCreate.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/resources/assets/components/GroupDiscover.vue b/resources/assets/components/GroupDiscover.vue new file mode 100644 index 000000000..bfdc537d3 --- /dev/null +++ b/resources/assets/components/GroupDiscover.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/resources/assets/components/GroupFeed.vue b/resources/assets/components/GroupFeed.vue new file mode 100644 index 000000000..8d752f271 --- /dev/null +++ b/resources/assets/components/GroupFeed.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/resources/assets/components/GroupJoins.vue b/resources/assets/components/GroupJoins.vue new file mode 100644 index 000000000..81295f56d --- /dev/null +++ b/resources/assets/components/GroupJoins.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/resources/assets/components/GroupNotifications.vue b/resources/assets/components/GroupNotifications.vue new file mode 100644 index 000000000..69b33f355 --- /dev/null +++ b/resources/assets/components/GroupNotifications.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/resources/assets/components/GroupPage.vue b/resources/assets/components/GroupPage.vue new file mode 100644 index 000000000..ea9edfdc8 --- /dev/null +++ b/resources/assets/components/GroupPage.vue @@ -0,0 +1,1190 @@ + + + + + diff --git a/resources/assets/components/GroupPost.vue b/resources/assets/components/GroupPost.vue new file mode 100644 index 000000000..8eb5dc9d4 --- /dev/null +++ b/resources/assets/components/GroupPost.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/assets/components/GroupProfile.vue b/resources/assets/components/GroupProfile.vue new file mode 100644 index 000000000..8affdee26 --- /dev/null +++ b/resources/assets/components/GroupProfile.vue @@ -0,0 +1,443 @@ + + + + + diff --git a/resources/assets/components/GroupSearch.vue b/resources/assets/components/GroupSearch.vue new file mode 100644 index 000000000..e23a75112 --- /dev/null +++ b/resources/assets/components/GroupSearch.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/resources/assets/components/Groups.vue b/resources/assets/components/Groups.vue new file mode 100644 index 000000000..af0dc2e61 --- /dev/null +++ b/resources/assets/components/Groups.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/resources/assets/components/admin/AdminSettings.vue b/resources/assets/components/admin/AdminSettings.vue index 78ffd1b14..88b6192d4 100644 --- a/resources/assets/components/admin/AdminSettings.vue +++ b/resources/assets/components/admin/AdminSettings.vue @@ -63,6 +63,13 @@ @change="handleChange($event, 'features', 'activitypub_enabled')" /> + + + + - -
@@ -185,6 +197,11 @@

+
+

+ Edit Custom CSS +

+
@@ -1268,6 +1285,7 @@ stories: this.features.stories, instagram_import: this.features.instagram_import, autospam_enabled: this.features.autospam_enabled, + authorized_fetch: this.features.authorized_fetch, }).then(res => { this.isSubmitting = false; this.isSubmittingTimeout = true; diff --git a/resources/assets/components/groups/CreateGroup.vue b/resources/assets/components/groups/CreateGroup.vue new file mode 100644 index 000000000..7459275f4 --- /dev/null +++ b/resources/assets/components/groups/CreateGroup.vue @@ -0,0 +1,359 @@ + + + + + diff --git a/resources/assets/components/groups/GroupFeed.vue b/resources/assets/components/groups/GroupFeed.vue new file mode 100644 index 000000000..9a357d4ee --- /dev/null +++ b/resources/assets/components/groups/GroupFeed.vue @@ -0,0 +1,989 @@ + + + + + diff --git a/resources/assets/components/groups/GroupInvite.vue b/resources/assets/components/groups/GroupInvite.vue new file mode 100644 index 000000000..ec11185a5 --- /dev/null +++ b/resources/assets/components/groups/GroupInvite.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/resources/assets/components/groups/GroupProfile.vue b/resources/assets/components/groups/GroupProfile.vue new file mode 100644 index 000000000..67077b84e --- /dev/null +++ b/resources/assets/components/groups/GroupProfile.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/resources/assets/components/groups/GroupSettings.vue b/resources/assets/components/groups/GroupSettings.vue new file mode 100644 index 000000000..099d598f2 --- /dev/null +++ b/resources/assets/components/groups/GroupSettings.vue @@ -0,0 +1,1079 @@ + + + + + diff --git a/resources/assets/components/groups/GroupTopicFeed.vue b/resources/assets/components/groups/GroupTopicFeed.vue new file mode 100644 index 000000000..ee7f57433 --- /dev/null +++ b/resources/assets/components/groups/GroupTopicFeed.vue @@ -0,0 +1,170 @@ + + + diff --git a/resources/assets/components/groups/GroupsHome.vue b/resources/assets/components/groups/GroupsHome.vue new file mode 100644 index 000000000..3a3d6dde8 --- /dev/null +++ b/resources/assets/components/groups/GroupsHome.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/resources/assets/components/groups/Page/GroupAbout.vue b/resources/assets/components/groups/Page/GroupAbout.vue new file mode 100644 index 000000000..8285a3db2 --- /dev/null +++ b/resources/assets/components/groups/Page/GroupAbout.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/resources/assets/components/groups/Page/GroupMedia.vue b/resources/assets/components/groups/Page/GroupMedia.vue new file mode 100644 index 000000000..b2d098ac8 --- /dev/null +++ b/resources/assets/components/groups/Page/GroupMedia.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/resources/assets/components/groups/Page/GroupMembers.vue b/resources/assets/components/groups/Page/GroupMembers.vue new file mode 100644 index 000000000..5b866fc17 --- /dev/null +++ b/resources/assets/components/groups/Page/GroupMembers.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/resources/assets/components/groups/Page/GroupTopics.vue b/resources/assets/components/groups/Page/GroupTopics.vue new file mode 100644 index 000000000..60f0fa496 --- /dev/null +++ b/resources/assets/components/groups/Page/GroupTopics.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/resources/assets/components/groups/partials/CommentDrawer.vue b/resources/assets/components/groups/partials/CommentDrawer.vue new file mode 100644 index 000000000..cd6631df3 --- /dev/null +++ b/resources/assets/components/groups/partials/CommentDrawer.vue @@ -0,0 +1,841 @@ + + + + + diff --git a/resources/assets/components/groups/partials/CommentPost.vue b/resources/assets/components/groups/partials/CommentPost.vue new file mode 100644 index 000000000..4b448f913 --- /dev/null +++ b/resources/assets/components/groups/partials/CommentPost.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/resources/assets/components/groups/partials/ContextMenu.vue b/resources/assets/components/groups/partials/ContextMenu.vue new file mode 100644 index 000000000..52fad0e74 --- /dev/null +++ b/resources/assets/components/groups/partials/ContextMenu.vue @@ -0,0 +1,692 @@ + + + diff --git a/resources/assets/components/groups/partials/CreateForm/CheckboxInput.vue b/resources/assets/components/groups/partials/CreateForm/CheckboxInput.vue new file mode 100644 index 000000000..03fa8727a --- /dev/null +++ b/resources/assets/components/groups/partials/CreateForm/CheckboxInput.vue @@ -0,0 +1,59 @@ + + + diff --git a/resources/assets/components/groups/partials/CreateForm/SelectInput.vue b/resources/assets/components/groups/partials/CreateForm/SelectInput.vue new file mode 100644 index 000000000..304ce0c7d --- /dev/null +++ b/resources/assets/components/groups/partials/CreateForm/SelectInput.vue @@ -0,0 +1,70 @@ + + + diff --git a/resources/assets/components/groups/partials/CreateForm/TextAreaInput.vue b/resources/assets/components/groups/partials/CreateForm/TextAreaInput.vue new file mode 100644 index 000000000..e8977db3f --- /dev/null +++ b/resources/assets/components/groups/partials/CreateForm/TextAreaInput.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupEvents.vue b/resources/assets/components/groups/partials/GroupEvents.vue new file mode 100644 index 000000000..e69de29bb diff --git a/resources/assets/components/groups/partials/GroupInfoCard.vue b/resources/assets/components/groups/partials/GroupInfoCard.vue new file mode 100644 index 000000000..455954b8f --- /dev/null +++ b/resources/assets/components/groups/partials/GroupInfoCard.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupInsights.vue b/resources/assets/components/groups/partials/GroupInsights.vue new file mode 100644 index 000000000..9909508cb --- /dev/null +++ b/resources/assets/components/groups/partials/GroupInsights.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupInviteModal.vue b/resources/assets/components/groups/partials/GroupInviteModal.vue new file mode 100644 index 000000000..75e5f9f68 --- /dev/null +++ b/resources/assets/components/groups/partials/GroupInviteModal.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupListCard.vue b/resources/assets/components/groups/partials/GroupListCard.vue new file mode 100644 index 000000000..64300160e --- /dev/null +++ b/resources/assets/components/groups/partials/GroupListCard.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupMedia.vue b/resources/assets/components/groups/partials/GroupMedia.vue new file mode 100644 index 000000000..65a96001d --- /dev/null +++ b/resources/assets/components/groups/partials/GroupMedia.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupMembers.vue b/resources/assets/components/groups/partials/GroupMembers.vue new file mode 100644 index 000000000..3913aa93d --- /dev/null +++ b/resources/assets/components/groups/partials/GroupMembers.vue @@ -0,0 +1,684 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupModeration.vue b/resources/assets/components/groups/partials/GroupModeration.vue new file mode 100644 index 000000000..54d114391 --- /dev/null +++ b/resources/assets/components/groups/partials/GroupModeration.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupPolls.vue b/resources/assets/components/groups/partials/GroupPolls.vue new file mode 100644 index 000000000..e69de29bb diff --git a/resources/assets/components/groups/partials/GroupPostModal.vue b/resources/assets/components/groups/partials/GroupPostModal.vue new file mode 100644 index 000000000..094d98a26 --- /dev/null +++ b/resources/assets/components/groups/partials/GroupPostModal.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupSearchModal.vue b/resources/assets/components/groups/partials/GroupSearchModal.vue new file mode 100644 index 000000000..8cc70039d --- /dev/null +++ b/resources/assets/components/groups/partials/GroupSearchModal.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupStatus.vue b/resources/assets/components/groups/partials/GroupStatus.vue new file mode 100644 index 000000000..439fd03b4 --- /dev/null +++ b/resources/assets/components/groups/partials/GroupStatus.vue @@ -0,0 +1,873 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupTopics.vue b/resources/assets/components/groups/partials/GroupTopics.vue new file mode 100644 index 000000000..ed4885b1e --- /dev/null +++ b/resources/assets/components/groups/partials/GroupTopics.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/resources/assets/components/groups/partials/LeaveGroup.vue b/resources/assets/components/groups/partials/LeaveGroup.vue new file mode 100644 index 000000000..417c29347 --- /dev/null +++ b/resources/assets/components/groups/partials/LeaveGroup.vue @@ -0,0 +1,9 @@ + + + diff --git a/resources/assets/components/groups/partials/MemberLimitInteractionsModal.vue b/resources/assets/components/groups/partials/MemberLimitInteractionsModal.vue new file mode 100644 index 000000000..143b47575 --- /dev/null +++ b/resources/assets/components/groups/partials/MemberLimitInteractionsModal.vue @@ -0,0 +1,172 @@ + + + diff --git a/resources/assets/components/groups/partials/Membership/MemberOnlyWarning.vue b/resources/assets/components/groups/partials/Membership/MemberOnlyWarning.vue new file mode 100644 index 000000000..cea224a5f --- /dev/null +++ b/resources/assets/components/groups/partials/Membership/MemberOnlyWarning.vue @@ -0,0 +1,38 @@ + + + diff --git a/resources/assets/components/groups/partials/Page/GroupBanner.vue b/resources/assets/components/groups/partials/Page/GroupBanner.vue new file mode 100644 index 000000000..8038cdce5 --- /dev/null +++ b/resources/assets/components/groups/partials/Page/GroupBanner.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/resources/assets/components/groups/partials/Page/GroupHeaderDetails.vue b/resources/assets/components/groups/partials/Page/GroupHeaderDetails.vue new file mode 100644 index 000000000..baeb2dfd5 --- /dev/null +++ b/resources/assets/components/groups/partials/Page/GroupHeaderDetails.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/resources/assets/components/groups/partials/Page/GroupNavTabs.vue b/resources/assets/components/groups/partials/Page/GroupNavTabs.vue new file mode 100644 index 000000000..c0a8827ea --- /dev/null +++ b/resources/assets/components/groups/partials/Page/GroupNavTabs.vue @@ -0,0 +1,167 @@ + + + + diff --git a/resources/assets/components/groups/partials/ReadMore.vue b/resources/assets/components/groups/partials/ReadMore.vue new file mode 100644 index 000000000..9dabf199d --- /dev/null +++ b/resources/assets/components/groups/partials/ReadMore.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/assets/components/groups/partials/SelfDiscover.vue b/resources/assets/components/groups/partials/SelfDiscover.vue new file mode 100644 index 000000000..2fb15a39f --- /dev/null +++ b/resources/assets/components/groups/partials/SelfDiscover.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/resources/assets/components/groups/partials/SelfFeed.vue b/resources/assets/components/groups/partials/SelfFeed.vue new file mode 100644 index 000000000..6663b4dfa --- /dev/null +++ b/resources/assets/components/groups/partials/SelfFeed.vue @@ -0,0 +1,146 @@ + + + diff --git a/resources/assets/components/groups/partials/SelfGroups.vue b/resources/assets/components/groups/partials/SelfGroups.vue new file mode 100644 index 000000000..411ec67e4 --- /dev/null +++ b/resources/assets/components/groups/partials/SelfGroups.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/resources/assets/components/groups/partials/SelfInvitations.vue b/resources/assets/components/groups/partials/SelfInvitations.vue new file mode 100644 index 000000000..f9d4f1e64 --- /dev/null +++ b/resources/assets/components/groups/partials/SelfInvitations.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/assets/components/groups/partials/SelfNotifications.vue b/resources/assets/components/groups/partials/SelfNotifications.vue new file mode 100644 index 000000000..f591cfbd7 --- /dev/null +++ b/resources/assets/components/groups/partials/SelfNotifications.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/resources/assets/components/groups/partials/SelfRemoteSearch.vue b/resources/assets/components/groups/partials/SelfRemoteSearch.vue new file mode 100644 index 000000000..9c3443960 --- /dev/null +++ b/resources/assets/components/groups/partials/SelfRemoteSearch.vue @@ -0,0 +1,47 @@ + + + diff --git a/resources/assets/components/groups/partials/ShareMenu.vue b/resources/assets/components/groups/partials/ShareMenu.vue new file mode 100644 index 000000000..3f4141486 --- /dev/null +++ b/resources/assets/components/groups/partials/ShareMenu.vue @@ -0,0 +1,11 @@ + + + diff --git a/resources/assets/components/groups/partials/Status/GroupHeader.vue b/resources/assets/components/groups/partials/Status/GroupHeader.vue new file mode 100644 index 000000000..1a782302a --- /dev/null +++ b/resources/assets/components/groups/partials/Status/GroupHeader.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/resources/assets/components/groups/partials/Status/ParentUnavailable.vue b/resources/assets/components/groups/partials/Status/ParentUnavailable.vue new file mode 100644 index 000000000..edb0f5062 --- /dev/null +++ b/resources/assets/components/groups/partials/Status/ParentUnavailable.vue @@ -0,0 +1,58 @@ + + + diff --git a/resources/assets/components/groups/sections/Loader.vue b/resources/assets/components/groups/sections/Loader.vue new file mode 100644 index 000000000..e0dc053d0 --- /dev/null +++ b/resources/assets/components/groups/sections/Loader.vue @@ -0,0 +1,23 @@ + + + diff --git a/resources/assets/components/groups/sections/Sidebar.vue b/resources/assets/components/groups/sections/Sidebar.vue new file mode 100644 index 000000000..7b6326b52 --- /dev/null +++ b/resources/assets/components/groups/sections/Sidebar.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/resources/assets/components/partials/sidebar.vue b/resources/assets/components/partials/sidebar.vue index fa99622ee..50fd85faa 100644 --- a/resources/assets/components/partials/sidebar.vue +++ b/resources/assets/components/partials/sidebar.vue @@ -129,12 +129,12 @@ - +