Browse Source

Add groups models, controllers and services

Daniel Supernault 1 year ago
parent
commit
3d6b9badf4
78 changed files with 6848 additions and 0 deletions
  1. 49 0
      app/Http/Controllers/Admin/AdminGroupsController.php
  2. 771 0
      app/Http/Controllers/GroupController.php
  3. 103 0
      app/Http/Controllers/GroupFederationController.php
  4. 10 0
      app/Http/Controllers/GroupPostController.php
  5. 83 0
      app/Http/Controllers/Groups/CreateGroupsController.php
  6. 353 0
      app/Http/Controllers/Groups/GroupsAdminController.php
  7. 84 0
      app/Http/Controllers/Groups/GroupsApiController.php
  8. 361 0
      app/Http/Controllers/Groups/GroupsCommentController.php
  9. 57 0
      app/Http/Controllers/Groups/GroupsDiscoverController.php
  10. 188 0
      app/Http/Controllers/Groups/GroupsFeedController.php
  11. 214 0
      app/Http/Controllers/Groups/GroupsMemberController.php
  12. 31 0
      app/Http/Controllers/Groups/GroupsMetaController.php
  13. 55 0
      app/Http/Controllers/Groups/GroupsNotificationsController.php
  14. 420 0
      app/Http/Controllers/Groups/GroupsPostController.php
  15. 217 0
      app/Http/Controllers/Groups/GroupsSearchController.php
  16. 133 0
      app/Http/Controllers/Groups/GroupsTopicController.php
  17. 99 0
      app/Jobs/GroupPipeline/GroupCommentPipeline.php
  18. 57 0
      app/Jobs/GroupPipeline/GroupMediaPipeline.php
  19. 54 0
      app/Jobs/GroupPipeline/GroupMemberInvite.php
  20. 54 0
      app/Jobs/GroupPipeline/JoinApproved.php
  21. 50 0
      app/Jobs/GroupPipeline/JoinRejected.php
  22. 107 0
      app/Jobs/GroupPipeline/LikePipeline.php
  23. 130 0
      app/Jobs/GroupPipeline/NewStatusPipeline.php
  24. 109 0
      app/Jobs/GroupPipeline/UnlikePipeline.php
  25. 58 0
      app/Jobs/GroupsPipeline/DeleteCommentPipeline.php
  26. 89 0
      app/Jobs/GroupsPipeline/ImageResizePipeline.php
  27. 67 0
      app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php
  28. 107 0
      app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php
  29. 47 0
      app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php
  30. 42 0
      app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php
  31. 115 0
      app/Jobs/GroupsPipeline/NewCommentPipeline.php
  32. 108 0
      app/Jobs/GroupsPipeline/NewPostPipeline.php
  33. 67 0
      app/Models/Group.php
  34. 11 0
      app/Models/GroupActivityGraph.php
  35. 11 0
      app/Models/GroupBlock.php
  36. 11 0
      app/Models/GroupCategory.php
  37. 24 0
      app/Models/GroupComment.php
  38. 11 0
      app/Models/GroupEvent.php
  39. 13 0
      app/Models/GroupHashtag.php
  40. 15 0
      app/Models/GroupInteraction.php
  41. 11 0
      app/Models/GroupInvitation.php
  42. 13 0
      app/Models/GroupLike.php
  43. 21 0
      app/Models/GroupLimit.php
  44. 39 0
      app/Models/GroupMedia.php
  45. 16 0
      app/Models/GroupMember.php
  46. 57 0
      app/Models/GroupPost.php
  47. 22 0
      app/Models/GroupPostHashtag.php
  48. 11 0
      app/Models/GroupReport.php
  49. 11 0
      app/Models/GroupRole.php
  50. 11 0
      app/Models/GroupStore.php
  51. 88 0
      app/Services/GroupFeedService.php
  52. 49 0
      app/Services/GroupPostService.php
  53. 366 0
      app/Services/GroupService.php
  54. 51 0
      app/Services/Groups/GroupAccountService.php
  55. 312 0
      app/Services/Groups/GroupActivityPubService.php
  56. 50 0
      app/Services/Groups/GroupCommentService.php
  57. 95 0
      app/Services/Groups/GroupFeedService.php
  58. 28 0
      app/Services/Groups/GroupHashtagService.php
  59. 114 0
      app/Services/Groups/GroupMediaService.php
  60. 83 0
      app/Services/Groups/GroupPostService.php
  61. 85 0
      app/Services/Groups/GroupsLikeService.php
  62. 59 0
      app/Transformer/Api/GroupPostTransformer.php
  63. 13 0
      config/groups.php
  64. 36 0
      database/migrations/2021_08_04_100435_create_group_roles_table.php
  65. 37 0
      database/migrations/2021_08_16_100034_create_group_interactions_table.php
  66. 39 0
      database/migrations/2021_08_17_073839_create_group_reports_table.php
  67. 40 0
      database/migrations/2021_09_26_112423_create_group_blocks_table.php
  68. 36 0
      database/migrations/2021_09_29_023230_create_group_limits_table.php
  69. 102 0
      database/migrations/2021_10_01_083917_create_group_categories_table.php
  70. 36 0
      database/migrations/2021_10_09_004230_create_group_hashtags_table.php
  71. 41 0
      database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php
  72. 37 0
      database/migrations/2021_10_13_002033_create_group_stores_table.php
  73. 44 0
      database/migrations/2021_10_13_002041_create_group_events_table.php
  74. 36 0
      database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php
  75. 48 0
      database/migrations/2024_05_20_062706_update_group_posts_table.php
  76. 43 0
      database/migrations/2024_05_20_063638_create_group_comments_table.php
  77. 33 0
      database/migrations/2024_05_20_073054_create_group_likes_table.php
  78. 50 0
      database/migrations/2024_05_20_083159_create_group_media_table.php

+ 49 - 0
app/Http/Controllers/Admin/AdminGroupsController.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Models\Group;
+use App\Models\GroupCategory;
+use App\Models\GroupInteraction;
+use App\Models\GroupMember;
+use App\Models\GroupPost;
+use App\Models\GroupReport;
+use Cache;
+use Illuminate\Http\Request;
+
+trait AdminGroupsController
+{
+    public function groupsHome(Request $request)
+    {
+        $stats = $this->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;
+        });
+    }
+}

+ 771 - 0
app/Http/Controllers/GroupController.php

@@ -0,0 +1,771 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use DB;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use App\Models\Group;
+use App\Models\GroupActivityGraph;
+use App\Models\GroupBlock;
+use App\Models\GroupCategory;
+use App\Models\GroupComment;
+use App\Models\GroupEvent;
+use App\Models\GroupInteraction;
+use App\Models\GroupInvitation;
+use App\Models\GroupLimit;
+use App\Models\GroupLike;
+use App\Models\GroupMember;
+use App\Models\GroupPost;
+use App\Models\GroupPostHashtag;
+use App\Models\GroupReport;
+use App\Models\GroupRole;
+use App\Models\GroupStore;
+use App\Models\Poll;
+use App\Follower;
+use App\Instance;
+use App\Hashtag;
+use App\StatusHashtag;
+use App\Like;
+use App\Media;
+use App\Notification;
+use App\Profile;
+use App\Status;
+use App\User;
+use App\Util\Lexer\Autolink;
+use App\Services\AccountService;
+use App\Services\FollowerService;
+use App\Services\HashidService;
+use App\Services\LikeService;
+use App\Services\Groups\GroupCommentService;
+use App\Services\Groups\GroupsLikeService;
+use App\Services\HashtagService;
+use App\Services\GroupService;
+use App\Services\GroupFeedService;
+use App\Services\GroupPostService;
+use App\Services\PollService;
+use App\Services\RelationshipService;
+use App\Services\StatusService;
+use App\Services\UserFilterService;
+use Cache;
+use Storage;
+use Purify;
+use App\Jobs\GroupPipeline\LikePipeline;
+use App\Jobs\GroupPipeline\UnlikePipeline;
+use App\Jobs\ImageOptimizePipeline\ImageOptimize;
+use App\Jobs\VideoPipeline\VideoThumbnail;
+use App\Jobs\StatusPipeline\StatusDelete;
+use App\Jobs\GroupPipeline\GroupCommentPipeline;
+use App\Jobs\GroupPipeline\GroupMemberInvite;
+use App\Jobs\GroupPipeline\NewStatusPipeline;
+use App\Jobs\GroupPipeline\JoinApproved;
+use App\Jobs\GroupPipeline\JoinRejected;
+use Illuminate\Support\Facades\RateLimiter;
+
+class GroupController extends GroupFederationController
+{
+	public function __construct()
+	{
+		// $this->middleware('auth');
+	}
+
+	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 likePost(Request $request)
+	// {
+	// 	$this->validate($request, [
+	// 		'gid' => 'required|exists:groups,id',
+	// 		'sid' => 'required|exists:group_posts,id'
+	// 	]);
+
+	// 	$pid = $request->user()->profile_id;
+	// 	$gid = $request->input('gid');
+	// 	$sid = $request->input('sid');
+
+	// 	$group = Group::findOrFail($gid);
+	// 	abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
+	// 	abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
+	// 	$gp = GroupPost::whereGroupId($group->id)->findOrFail($sid);
+	// 	$action = false;
+
+	// 	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';
+	// 		}
+	// 	}
+
+	// 	if($action) {
+	// 		GroupService::log(
+	// 			$group->id,
+	// 			$pid,
+	// 			$action,
+	// 			[
+	// 				'type' => $gp->type,
+	// 				'status_id' => $gp->id
+	// 			],
+	// 			GroupPost::class,
+	// 			$gp->id
+	// 		);
+	// 	}
+
+	// 	// 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 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);
+		}
+
+		// $gm = GroupMember::whereGroupId($id)
+		// 	->whereProfileId($pid)
+		// 	->firstOrFail();
+
+		// $group = json_encode(GroupService::get($id));
+		// $profile = AccountService::get($pid);
+		// $profile['group'] = [
+		// 	'joined' => $gm->created_at->format('M d, Y'),
+		// 	'role' => $gm->role
+		// ];
+		// $profile['relationship'] = RelationshipService::get($cid, $pid);
+		// $profile = json_encode($profile);
+		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' => '/'];
+	}
+}

+ 103 - 0
app/Http/Controllers/GroupFederationController.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Status;
+use App\Models\InstanceActor;
+use App\Services\MediaService;
+
+class GroupFederationController extends Controller
+{
+	public function getGroupObject(Request $request, $id)
+	{
+		$group = Group::whereLocal(true)->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);
+	}
+}

+ 10 - 0
app/Http/Controllers/GroupPostController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class GroupPostController extends Controller
+{
+    //
+}

+ 83 - 0
app/Http/Controllers/Groups/CreateGroupsController.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Models\Group;
+use App\Models\GroupMember;
+
+class CreateGroupsController extends Controller
+{
+    public function __construct()
+    {
+        $this->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()
+        ];
+    }
+}

+ 353 - 0
app/Http/Controllers/Groups/GroupsAdminController.php

@@ -0,0 +1,353 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Instance;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupBlock;
+use App\Models\GroupCategory;
+use App\Models\GroupInteraction;
+use App\Models\GroupPost;
+use App\Models\GroupMember;
+use App\Models\GroupReport;
+use App\Services\Groups\GroupAccountService;
+use App\Services\Groups\GroupPostService;
+
+class GroupsAdminController extends Controller
+{
+    public function __construct()
+    {
+        $this->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);
+    }
+
+}

+ 84 - 0
app/Http/Controllers/Groups/GroupsApiController.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Models\Group;
+use App\Models\GroupCategory;
+use App\Models\GroupMember;
+use App\Services\Groups\GroupAccountService;
+
+class GroupsApiController extends Controller
+{
+    public function __construct()
+    {
+        $this->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);
+    }
+}

+ 361 - 0
app/Http/Controllers/Groups/GroupsCommentController.php

@@ -0,0 +1,361 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\Groups\GroupCommentService;
+use App\Services\Groups\GroupMediaService;
+use App\Services\Groups\GroupPostService;
+use App\Services\Groups\GroupsLikeService;
+use App\Models\Group;
+use App\Models\GroupLike;
+use App\Models\GroupMedia;
+use App\Models\GroupPost;
+use App\Models\GroupComment;
+use Purify;
+use App\Util\Lexer\Autolink;
+use App\Jobs\GroupsPipeline\ImageResizePipeline;
+use App\Jobs\GroupsPipeline\ImageS3UploadPipeline;
+use App\Jobs\GroupsPipeline\NewPostPipeline;
+use App\Jobs\GroupsPipeline\NewCommentPipeline;
+use App\Jobs\GroupsPipeline\DeleteCommentPipeline;
+
+class GroupsCommentController extends Controller
+{
+    public function getComments(Request $request)
+    {
+        $this->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;
+    }
+}

+ 57 - 0
app/Http/Controllers/Groups/GroupsDiscoverController.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupMember;
+use App\Models\GroupInvitation;
+
+class GroupsDiscoverController extends Controller
+{
+    public function __construct()
+    {
+        $this->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;
+    }
+}

+ 188 - 0
app/Http/Controllers/Groups/GroupsFeedController.php

@@ -0,0 +1,188 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\UserFilterService;
+use App\Services\Groups\GroupFeedService;
+use App\Services\Groups\GroupPostService;
+use App\Services\RelationshipService;
+use App\Services\Groups\GroupsLikeService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Models\GroupInvitation;
+
+class GroupsFeedController extends Controller
+{
+    public function __construct()
+    {
+        $this->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;
+    }
+}

+ 214 - 0
app/Http/Controllers/Groups/GroupsMemberController.php

@@ -0,0 +1,214 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Models\Group;
+use App\Models\GroupCategory;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+use App\Models\GroupMember;
+use App\Services\AccountService;
+use App\Services\FollowerService;
+use App\Services\Groups\GroupAccountService;
+use App\Services\Groups\GroupHashtagService;
+use App\Jobs\GroupsPipeline\MemberJoinApprovedPipeline;
+use App\Jobs\GroupsPipeline\MemberJoinRejectedPipeline;
+
+class GroupsMemberController extends Controller
+{
+    public function getGroupMembers(Request $request)
+    {
+        $this->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);
+    }
+}

+ 31 - 0
app/Http/Controllers/Groups/GroupsMetaController.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\GroupService;
+use App\Models\Group;
+
+class GroupsMetaController extends Controller
+{
+    public function __construct()
+    {
+        $this->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];
+    }
+}

+ 55 - 0
app/Http/Controllers/Groups/GroupsNotificationsController.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\StatusService;
+use App\Services\GroupService;
+use App\Models\Group;
+use App\Notification;
+
+class GroupsNotificationsController extends Controller
+{
+    public function __construct()
+    {
+        $this->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);
+    }
+}

+ 420 - 0
app/Http/Controllers/Groups/GroupsPostController.php

@@ -0,0 +1,420 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\Groups\GroupFeedService;
+use App\Services\Groups\GroupPostService;
+use App\Services\Groups\GroupMediaService;
+use App\Services\Groups\GroupsLikeService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupHashtag;
+use App\Models\GroupPost;
+use App\Models\GroupLike;
+use App\Models\GroupMember;
+use App\Models\GroupInvitation;
+use App\Models\GroupMedia;
+use App\Jobs\GroupsPipeline\ImageResizePipeline;
+use App\Jobs\GroupsPipeline\ImageS3UploadPipeline;
+use App\Jobs\GroupsPipeline\NewPostPipeline;
+
+class GroupsPostController extends Controller
+{
+    public function __construct()
+    {
+        $this->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);
+    }
+}

+ 217 - 0
app/Http/Controllers/Groups/GroupsSearchController.php

@@ -0,0 +1,217 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupMember;
+use App\Models\GroupInvitation;
+
+class GroupsSearchController extends Controller
+{
+    public function __construct()
+    {
+        $this->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:40',
+            'v' => 'required|in:0.2'
+        ]);
+        $q = $request->input('q');
+        $key = 'groups:search:global:by_name:' . hash('sha256', $q);
+
+        if(RateLimiter::tooManyAttempts('groups:search:global:'.$request->user()->id, 25) ) {
+            return response()->json([
+                'error' => [
+                    'message' => 'Too many attempts, please try again later'
+                ]
+            ], 422);
+        }
+
+        RateLimiter::hit('groups:search:global:'.$request->user()->id);
+
+        return Cache::remember($key, 3600, function() use($q) {
+            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);
+    }
+}

+ 133 - 0
app/Http/Controllers/Groups/GroupsTopicController.php

@@ -0,0 +1,133 @@
+<?php
+
+namespace App\Http\Controllers\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\RateLimiter;
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\AccountService;
+use App\Services\GroupService;
+use App\Services\Groups\GroupPostService;
+use App\Services\Groups\GroupsLikeService;
+use App\Follower;
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupHashtag;
+use App\Models\GroupInvitation;
+use App\Models\GroupMember;
+use App\Models\GroupPostHashtag;
+use App\Models\GroupPost;
+
+class GroupsTopicController extends Controller
+{
+    public function __construct()
+    {
+        $this->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'));
+    }
+}

+ 99 - 0
app/Jobs/GroupPipeline/GroupCommentPipeline.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use App\Notification;
+use App\Status;
+use App\Models\GroupPost;
+use Cache;
+use DB;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Redis;
+use App\Services\MediaStorageService;
+use App\Services\NotificationService;
+use App\Services\StatusService;
+
+class GroupCommentPipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $status;
+	protected $comment;
+	protected $groupPost;
+
+	public function __construct(Status $status, Status $comment, $groupPost = null)
+	{
+		$this->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 = "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> 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);
+	}
+}

+ 57 - 0
app/Jobs/GroupPipeline/GroupMediaPipeline.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use App\Media;
+use Cache;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Redis;
+use App\Services\MediaStorageService;
+
+class GroupMediaPipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $media;
+
+	public function __construct(Media $media)
+	{
+		$this->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);
+		}
+	}
+
+}

+ 54 - 0
app/Jobs/GroupPipeline/GroupMemberInvite.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupInvitation;
+use App\Notification;
+use App\Profile;
+
+class GroupMemberInvite implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $invite;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupInvitation $invite)
+    {
+        $this->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();
+    }
+}

+ 54 - 0
app/Jobs/GroupPipeline/JoinApproved.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupMember;
+use App\Notification;
+use App\Services\GroupService;
+
+class JoinApproved implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $member;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMember $member)
+    {
+        $this->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);
+    }
+}

+ 50 - 0
app/Jobs/GroupPipeline/JoinRejected.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupMember;
+use App\Notification;
+use App\Services\GroupService;
+
+class JoinRejected implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $member;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMember $member)
+    {
+        $this->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();
+    }
+}

+ 107 - 0
app/Jobs/GroupPipeline/LikePipeline.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Cache, Log;
+use Illuminate\Support\Facades\Redis;
+use App\{Like, Notification};
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use App\Transformer\ActivityPub\Verb\Like as LikeTransformer;
+use App\Services\StatusService;
+
+class LikePipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $like;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	public $timeout = 5;
+	public $tries = 1;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Like $like)
+	{
+		$this->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);
+	}
+}

+ 130 - 0
app/Jobs/GroupPipeline/NewStatusPipeline.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use App\Notification;
+use App\Hashtag;
+use App\Mention;
+use App\Profile;
+use App\Status;
+use App\StatusHashtag;
+use App\Models\GroupPostHashtag;
+use App\Models\GroupPost;
+use Cache;
+use DB;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Redis;
+use App\Services\MediaStorageService;
+use App\Services\NotificationService;
+use App\Services\StatusService;
+use App\Util\Lexer\Autolink;
+use App\Util\Lexer\Extractor;
+
+class NewStatusPipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $status;
+	protected $gp;
+	protected $tags;
+	protected $mentions;
+
+	public function __construct(Status $status, GroupPost $gp)
+	{
+		$this->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);
+	}
+}

+ 109 - 0
app/Jobs/GroupPipeline/UnlikePipeline.php

@@ -0,0 +1,109 @@
+<?php
+
+namespace App\Jobs\GroupPipeline;
+
+use Cache, Log;
+use Illuminate\Support\Facades\Redis;
+use App\{Like, Notification};
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Util\ActivityPub\Helpers;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use App\Transformer\ActivityPub\Verb\UndoLike as LikeTransformer;
+use App\Services\StatusService;
+
+class UnlikePipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $like;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	public $timeout = 5;
+	public $tries = 1;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Like $like)
+	{
+		$this->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);
+	}
+}

+ 58 - 0
app/Jobs/GroupsPipeline/DeleteCommentPipeline.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\Group;
+use App\Models\GroupComment;
+use App\Models\GroupPost;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+use App\Util\Lexer\Autolink;
+use App\Util\Lexer\Extractor;
+use DB;
+
+class DeleteCommentPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $parent;
+    protected $status;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($parent, $status)
+    {
+        $this->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;
+    }
+}

+ 89 - 0
app/Jobs/GroupsPipeline/ImageResizePipeline.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Models\GroupMedia;
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Log;
+use Storage;
+use Image as Intervention;
+
+class ImageResizePipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $media;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+    
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMedia $media)
+    {
+        $this->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);
+        }
+    }
+}

+ 67 - 0
app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Models\GroupMedia;
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Storage;
+use Illuminate\Http\File;
+use Exception;
+use GuzzleHttp\Exception\ClientException;
+use Aws\S3\Exception\S3Exception;
+use GuzzleHttp\Exception\ConnectException;
+use League\Flysystem\UnableToWriteFile;
+
+class ImageS3DeletePipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $media;
+    static $attempts = 1;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMedia $media)
+    {
+        $this->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);
+        }
+    }
+}

+ 107 - 0
app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Models\GroupMedia;
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Storage;
+use Illuminate\Http\File;
+use Exception;
+use GuzzleHttp\Exception\ClientException;
+use Aws\S3\Exception\S3Exception;
+use GuzzleHttp\Exception\ConnectException;
+use League\Flysystem\UnableToWriteFile;
+
+class ImageS3UploadPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $media;
+    static $attempts = 1;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMedia $media)
+    {
+        $this->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');
+    }
+}

+ 47 - 0
app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupMember;
+use App\Notification;
+use App\Services\GroupService;
+
+class MemberJoinApprovedPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $member;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMember $member)
+    {
+        $this->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);
+    }
+}

+ 42 - 0
app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\GroupMember;
+use App\Notification;
+use App\Services\GroupService;
+
+class MemberJoinRejectedPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $member;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupMember $member)
+    {
+        $this->member = $member;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $member = $this->member;
+        $member->rejected_at = now();
+        $member->save();
+    }
+}

+ 115 - 0
app/Jobs/GroupsPipeline/NewCommentPipeline.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\Group;
+use App\Models\GroupComment;
+use App\Models\GroupPost;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+use App\Util\Lexer\Autolink;
+use App\Util\Lexer\Extractor;
+use DB;
+
+class NewCommentPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $status;
+    protected $parent;
+    protected $entities;
+    protected $autolink;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($parent, GroupComment $status)
+    {
+        $this->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
+    }
+}

+ 108 - 0
app/Jobs/GroupsPipeline/NewPostPipeline.php

@@ -0,0 +1,108 @@
+<?php
+
+namespace App\Jobs\GroupsPipeline;
+
+use App\Util\Media\Image;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+use App\Util\Lexer\Autolink;
+use App\Util\Lexer\Extractor;
+use DB;
+
+class NewPostPipeline implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $status;
+    protected $entities;
+    protected $autolink;
+
+    /**
+     * Delete the job if its models no longer exist.
+     *
+     * @var bool
+     */
+    public $deleteWhenMissingModels = true;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(GroupPost $status)
+    {
+        $this->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
+    }
+}

+ 67 - 0
app/Models/Group.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use App\HasSnowflakePrimary;
+use App\Profile;
+use App\Services\GroupService;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class Group extends Model
+{
+    use HasSnowflakePrimary, HasFactory, SoftDeletes;
+
+	/**
+	 * Indicates if the IDs are auto-incrementing.
+	 *
+	 * @var bool
+	 */
+	public $incrementing = false;
+
+	protected $casts = [
+		'metadata' => '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;
+	}
+}

+ 11 - 0
app/Models/GroupActivityGraph.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupActivityGraph extends Model
+{
+    use HasFactory;
+}

+ 11 - 0
app/Models/GroupBlock.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupBlock extends Model
+{
+    use HasFactory;
+}

+ 11 - 0
app/Models/GroupCategory.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupCategory extends Model
+{
+    use HasFactory;
+}

+ 24 - 0
app/Models/GroupComment.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use App\Profile;
+
+class GroupComment extends Model
+{
+    use HasFactory;
+
+    public $guarded = [];
+
+    public function profile()
+    {
+        return $this->belongsTo(Profile::class);
+    }
+
+    public function url()
+    {
+        return '/group/' . $this->group_id . '/c/' . $this->id;
+    }
+}

+ 11 - 0
app/Models/GroupEvent.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupEvent extends Model
+{
+    use HasFactory;
+}

+ 13 - 0
app/Models/GroupHashtag.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupHashtag extends Model
+{
+    use HasFactory;
+
+    public $fillable = ['name'];
+}

+ 15 - 0
app/Models/GroupInteraction.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupInteraction extends Model
+{
+    use HasFactory;
+
+    protected $casts = [
+    	'metadata' => 'array'
+    ];
+}

+ 11 - 0
app/Models/GroupInvitation.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupInvitation extends Model
+{
+    use HasFactory;
+}

+ 13 - 0
app/Models/GroupLike.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupLike extends Model
+{
+    use HasFactory;
+
+    public $fillable = ['group_id', 'status_id', 'profile_id', 'comment_id'];
+}

+ 21 - 0
app/Models/GroupLimit.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupLimit extends Model
+{
+	use HasFactory;
+
+	protected $casts = [
+		'limits' => 'json',
+		'metadata' => 'json'
+	];
+
+	protected $fillable = [
+		'profile_id',
+		'group_id'
+	];
+}

+ 39 - 0
app/Models/GroupMedia.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Storage;
+
+class GroupMedia extends Model
+{
+    use HasFactory;
+
+    /**
+     * Get the attributes that should be cast.
+     *
+     * @return array<string, string>
+     */
+    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;
+    }
+}

+ 16 - 0
app/Models/GroupMember.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupMember extends Model
+{
+    use HasFactory;
+
+    public function group()
+    {
+    	return $this->belongsTo(Group::class);
+    }
+}

+ 57 - 0
app/Models/GroupPost.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use App\HasSnowflakePrimary;
+use App\Services\HashidService;
+use App\Profile;
+use App\Status;
+
+class GroupPost extends Model
+{
+    use HasSnowflakePrimary, HasFactory;
+
+	/**
+	 * Indicates if the IDs are auto-incrementing.
+	 *
+	 * @var bool
+	 */
+	public $incrementing = false;
+
+    protected $fillable = [
+        'remote_url',
+        'group_id',
+        'profile_id',
+        'type',
+        'caption',
+        'visibility',
+        'is_nsfw'
+    ];
+
+	public function mediaPath()
+	{
+		return 'public/g/_v1/' . $this->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;
+	}
+}

+ 22 - 0
app/Models/GroupPostHashtag.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupPostHashtag extends Model
+{
+    use HasFactory;
+
+    public $fillable = [
+    	'group_id',
+    	'group_post_id',
+    	'status_id',
+    	'hashtag_id',
+    	'profile_id',
+    	'nsfw'
+    ];
+
+    public $timestamps = false;
+}

+ 11 - 0
app/Models/GroupReport.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupReport extends Model
+{
+    use HasFactory;
+}

+ 11 - 0
app/Models/GroupRole.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupRole extends Model
+{
+    use HasFactory;
+}

+ 11 - 0
app/Models/GroupStore.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class GroupStore extends Model
+{
+    use HasFactory;
+}

+ 88 - 0
app/Services/GroupFeedService.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\GroupPost;
+use Illuminate\Support\Facades\Redis;
+
+class GroupFeedService
+{
+    const CACHE_KEY = 'pf:services:groups:feed:';
+
+    const FEED_LIMIT = 400;
+
+    public static function get($gid, $start = 0, $stop = 10)
+    {
+        if ($stop > 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;
+        }
+    }
+}

+ 49 - 0
app/Services/GroupPostService.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\GroupPost;
+use App\Transformer\Api\GroupPostTransformer;
+use Cache;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+
+class GroupPostService
+{
+    const CACHE_KEY = 'pf:services:groups:post:';
+
+    public static function key($gid, $pid)
+    {
+        return self::CACHE_KEY.$gid.':'.$pid;
+    }
+
+    public static function get($gid, $pid)
+    {
+        return Cache::remember(self::key($gid, $pid), 604800, function () use ($gid, $pid) {
+            $gp = GroupPost::whereGroupId($gid)->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));
+    }
+}

+ 366 - 0
app/Services/GroupService.php

@@ -0,0 +1,366 @@
+<?php
+
+namespace App\Services;
+
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupCategory;
+use App\Models\GroupMember;
+use App\Models\GroupPost;
+use App\Models\GroupInteraction;
+use App\Models\GroupLimit;
+use App\Util\ActivityPub\Helpers;
+use Cache;
+use Purify;
+use App\Http\Resources\Groups\GroupResource;
+
+class GroupService
+{
+	const CACHE_KEY = 'pf:services:groups:';
+
+	protected static function key($name)
+	{
+		return self::CACHE_KEY . $name;
+	}
+
+	public static function get($id, $pid = false)
+	{
+		$res = Cache::remember(
+			self::key($id),
+			1209600,
+			function() use($id, $pid) {
+				$group = (new Group)->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
+		];
+	}
+}

+ 51 - 0
app/Services/Groups/GroupAccountService.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Services\Groups;
+
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Models\GroupMember;
+use Cache;
+use Purify;
+use App\Services\AccountService;
+use App\Services\GroupService;
+
+class GroupAccountService
+{
+    const CACHE_KEY = 'pf:services:groups:accounts-v0:';
+
+    public static function get($gid, $pid)
+    {
+        $group = GroupService::get($gid);
+        if(!$group) {
+            return;
+        }
+
+        $account = AccountService::get($pid, true);
+        if(!$account) {
+            return;
+        }
+
+        $key = self::CACHE_KEY . $gid . ':' . $pid;
+        $account['group'] = Cache::remember($key, 3600, function() use($gid, $pid) {
+            $membership = GroupMember::whereGroupId($gid)->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);
+    }
+}

+ 312 - 0
app/Services/Groups/GroupActivityPubService.php

@@ -0,0 +1,312 @@
+<?php
+
+namespace App\Services\Groups;
+
+use App\Models\Group;
+use App\Models\GroupPost;
+use App\Models\GroupComment;
+use Cache;
+use Purify;
+use Illuminate\Support\Facades\Redis;
+use League\Fractal;
+use App\Util\ActivityPub\Helpers;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Transformer\Api\GroupPostTransformer;
+use App\Services\ActivityPubFetchService;
+use Illuminate\Support\Facades\Validator;
+use App\Rules\ValidUrl;
+
+class GroupActivityPubService
+{
+    const CACHE_KEY = 'pf:services:groups:ap:';
+
+    public static function fetchGroup($url, $saveOnFetch = true)
+    {
+        $group = Group::where('remote_url', $url)->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 $validator->errors();
+            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;
+    }
+}

+ 50 - 0
app/Services/Groups/GroupCommentService.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Services\Groups;
+
+use App\Models\GroupComment;
+use Cache;
+use Illuminate\Support\Facades\Redis;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Transformer\Api\GroupPostTransformer;
+
+class GroupCommentService
+{
+    const CACHE_KEY = 'pf:services:groups:comment:';
+
+    public static function key($gid, $pid)
+    {
+        return self::CACHE_KEY . $gid . ':' . $pid;
+    }
+
+    public static function get($gid, $pid)
+    {
+        return Cache::remember(self::key($gid, $pid), 604800, function() use($gid, $pid) {
+            $gp = GroupComment::whereGroupId($gid)->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));
+    }
+}

+ 95 - 0
app/Services/Groups/GroupFeedService.php

@@ -0,0 +1,95 @@
+<?php
+
+namespace App\Services\Groups;
+
+use App\Profile;
+use App\Models\Group;
+use App\Models\GroupCategory;
+use App\Models\GroupMember;
+use App\Models\GroupPost;
+use App\Models\GroupInteraction;
+use App\Models\GroupLimit;
+use App\Util\ActivityPub\Helpers;
+use Cache;
+use Purify;
+use Illuminate\Support\Facades\Redis;
+
+class GroupFeedService
+{
+    const CACHE_KEY = 'pf:services:groups:feed:';
+    const FEED_LIMIT = 400;
+
+    public static function get($gid, $start = 0, $stop = 10)
+    {
+        if($stop > 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;
+        }
+    }
+}

+ 28 - 0
app/Services/Groups/GroupHashtagService.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Services\Groups;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Str;
+use App\Models\GroupHashtag;
+use App\Models\GroupPostHashtag;
+
+class GroupHashtagService
+{
+    const CACHE_KEY = 'pf:services:groups-v1:hashtags:';
+
+    public static function get($id)
+    {
+        return Cache::remember(self::CACHE_KEY . $id, 3600, function() use($id) {
+            $tag = GroupHashtag::find($id);
+            if(!$tag) {
+                return [];
+            }
+            return [
+                'name' => $tag->name,
+                'slug' => Str::slug($tag->name),
+            ];
+        });
+    }
+}

+ 114 - 0
app/Services/Groups/GroupMediaService.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Services\Groups;
+
+use Cache;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Storage;
+use App\Models\GroupMedia;
+use App\Profile;
+use App\Status;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Services\HashidService;
+
+class GroupMediaService
+{
+    const CACHE_KEY = 'groups:media:';
+
+    public static function path($gid, $pid, $sid = false)
+    {
+        if(!$gid || !$pid) {
+            return;
+        }
+        $groupHashid = HashidService::encode($gid);
+        $monthHash = HashidService::encode(date('Y').date('n'));
+        $pid = HashidService::encode($pid);
+        $sid = $sid ? HashidService::encode($sid) : false;
+        $path = $sid ?
+            "public/g1/{$groupHashid}/{$pid}/{$monthHash}/{$sid}" :
+            "public/g1/{$groupHashid}/{$pid}/{$monthHash}";
+        return $path;
+    }
+
+    public static function get($statusId)
+    {
+        return Cache::remember(self::CACHE_KEY.$statusId, 21600, function() use($statusId) {
+            $media = GroupMedia::whereStatusId($statusId)->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
+            ];
+        });
+    }
+}

+ 83 - 0
app/Services/Groups/GroupPostService.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Services\Groups;
+
+use App\Models\GroupPost;
+use Cache;
+use Illuminate\Support\Facades\Redis;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Transformer\Api\GroupPostTransformer;
+
+class GroupPostService
+{
+    const CACHE_KEY = 'pf:services:groups:post:';
+
+    public static function key($gid, $pid)
+    {
+        return self::CACHE_KEY . $gid . ':' . $pid;
+    }
+
+    public static function get($gid, $pid)
+    {
+        return Cache::remember(self::key($gid, $pid), 604800, function() use($gid, $pid) {
+            $gp = GroupPost::whereGroupId($gid)->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;
+    }
+}

+ 85 - 0
app/Services/Groups/GroupsLikeService.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Services\Groups;
+
+use App\Util\ActivityPub\Helpers;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Redis;
+use App\Models\GroupLike;
+
+class GroupsLikeService
+{
+    const CACHE_KEY = 'pf:services:group-likes:ids:';
+    const CACHE_SET_KEY = 'pf:services:group-likes:set:';
+    const CACHE_POST_KEY = 'pf:services:group-likes:count:';
+
+    public static function add($profileId, $statusId)
+    {
+        $key = self::CACHE_KEY . $profileId . ':' . $statusId;
+        Cache::increment(self::CACHE_POST_KEY . $statusId);
+        //Cache::forget('pf:services:likes:liked_by:'.$statusId);
+        self::setAdd($profileId, $statusId);
+        return Cache::put($key, true, 86400);
+    }
+
+    public static function setAdd($profileId, $statusId)
+    {
+        if(self::setCount($profileId) > 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);
+    }
+
+}

+ 59 - 0
app/Transformer/Api/GroupPostTransformer.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Status;
+use League\Fractal;
+use Cache;
+use App\Services\AccountService;
+use App\Services\HashidService;
+use App\Services\LikeService;
+use App\Services\Groups\GroupMediaService;
+use App\Services\MediaTagService;
+use App\Services\StatusService;
+use App\Services\StatusHashtagService;
+use App\Services\StatusLabelService;
+use App\Services\StatusMentionService;
+use App\Services\PollService;
+use App\Models\CustomEmoji;
+use App\Util\Lexer\Autolink;
+use Purify;
+
+class GroupPostTransformer extends Fractal\TransformerAbstract
+{
+    public function transform($status)
+    {
+        return [
+            'id'                        => (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'                      => [],
+        ];
+    }
+}

+ 13 - 0
config/groups.php

@@ -0,0 +1,13 @@
+<?php
+
+return [
+    'enabled' => 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),
+        ]
+    ]
+];

+ 36 - 0
database/migrations/2021_08_04_100435_create_group_roles_table.php

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

+ 37 - 0
database/migrations/2021_08_16_100034_create_group_interactions_table.php

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

+ 39 - 0
database/migrations/2021_08_17_073839_create_group_reports_table.php

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

+ 40 - 0
database/migrations/2021_09_26_112423_create_group_blocks_table.php

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

+ 36 - 0
database/migrations/2021_09_29_023230_create_group_limits_table.php

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

+ 102 - 0
database/migrations/2021_10_01_083917_create_group_categories_table.php

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

+ 36 - 0
database/migrations/2021_10_09_004230_create_group_hashtags_table.php

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

+ 41 - 0
database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php

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

+ 37 - 0
database/migrations/2021_10_13_002033_create_group_stores_table.php

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

+ 44 - 0
database/migrations/2021_10_13_002041_create_group_events_table.php

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

+ 36 - 0
database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php

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

+ 48 - 0
database/migrations/2024_05_20_062706_update_group_posts_table.php

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

+ 43 - 0
database/migrations/2024_05_20_063638_create_group_comments_table.php

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

+ 33 - 0
database/migrations/2024_05_20_073054_create_group_likes_table.php

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

+ 50 - 0
database/migrations/2024_05_20_083159_create_group_media_table.php

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