diff --git a/app/Http/Controllers/Admin/AdminGroupsController.php b/app/Http/Controllers/Admin/AdminGroupsController.php new file mode 100644 index 000000000..45a4fd266 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminGroupsController.php @@ -0,0 +1,49 @@ +groupAdminStats(); + + return view('admin.groups.home', compact('stats')); + } + + protected function groupAdminStats() + { + return Cache::remember('admin:groups:stats', 3, function () { + $res = [ + 'total' => Group::count(), + 'local' => Group::whereLocal(true)->count(), + ]; + + $res['remote'] = $res['total'] - $res['local']; + $res['categories'] = GroupCategory::count(); + $res['posts'] = GroupPost::count(); + $res['members'] = GroupMember::count(); + $res['interactions'] = GroupInteraction::count(); + $res['reports'] = GroupReport::count(); + + $res['local_30d'] = Cache::remember('admin:groups:stats:local_30d', 43200, function () { + return Group::whereLocal(true)->where('created_at', '>', now()->subMonth())->count(); + }); + + $res['remote_30d'] = Cache::remember('admin:groups:stats:remote_30d', 43200, function () { + return Group::whereLocal(false)->where('created_at', '>', now()->subMonth())->count(); + }); + + return $res; + }); + } +} diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php new file mode 100644 index 000000000..d795ba81b --- /dev/null +++ b/app/Http/Controllers/GroupController.php @@ -0,0 +1,771 @@ +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' => '/']; + } +} diff --git a/app/Http/Controllers/GroupFederationController.php b/app/Http/Controllers/GroupFederationController.php new file mode 100644 index 000000000..7f45f74a4 --- /dev/null +++ b/app/Http/Controllers/GroupFederationController.php @@ -0,0 +1,103 @@ +whereActivitypub(true)->findOrFail($id); + $res = $this->showGroupObject($group); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function showGroupObject($group) + { + return Cache::remember('ap:groups:object:' . $group->id, 3600, function() use($group) { + return [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $group->url(), + 'inbox' => $group->permalink('/inbox'), + 'name' => $group->name, + 'outbox' => $group->permalink('/outbox'), + 'summary' => $group->description, + 'type' => 'Group', + 'attributedTo' => [ + 'type' => 'Person', + 'id' => $group->admin->permalink() + ], + // 'endpoints' => [ + // 'sharedInbox' => config('app.url') . '/f/inbox' + // ], + 'preferredUsername' => 'gid_' . $group->id, + 'publicKey' => [ + 'id' => $group->permalink('#main-key'), + 'owner' => $group->permalink(), + 'publicKeyPem' => InstanceActor::first()->public_key, + ], + 'url' => $group->permalink() + ]; + + if($group->metadata && isset($group->metadata['avatar'])) { + $res['icon'] = [ + 'type' => 'Image', + 'url' => $group->metadata['avatar']['url'] + ]; + } + + if($group->metadata && isset($group->metadata['header'])) { + $res['image'] = [ + 'type' => 'Image', + 'url' => $group->metadata['header']['url'] + ]; + } + ksort($res); + return $res; + }); + } + + public function getStatusObject(Request $request, $gid, $sid) + { + $group = Group::whereLocal(true)->whereActivitypub(true)->findOrFail($gid); + $gp = GroupPost::whereGroupId($gid)->findOrFail($sid); + $status = Status::findOrFail($gp->status_id); + // permission check + + $res = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $gp->url(), + + 'type' => 'Note', + + 'summary' => null, + 'content' => $status->rendered ?? $status->caption, + 'inReplyTo' => null, + + 'published' => $status->created_at->toAtomString(), + 'url' => $gp->url(), + 'attributedTo' => $status->profile->permalink(), + 'to' => [ + 'https://www.w3.org/ns/activitystreams#Public', + $group->permalink('/followers'), + ], + 'cc' => [], + 'sensitive' => (bool) $status->is_nsfw, + 'attachment' => MediaService::activitypub($status->id), + 'target' => [ + 'type' => 'Collection', + 'id' => $group->permalink('/wall'), + 'attributedTo' => $group->permalink() + ] + ]; + // ksort($res); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/GroupPostController.php b/app/Http/Controllers/GroupPostController.php new file mode 100644 index 000000000..909037a00 --- /dev/null +++ b/app/Http/Controllers/GroupPostController.php @@ -0,0 +1,10 @@ +middleware('auth'); + } + + public function checkCreatePermission(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + $config = GroupService::config(); + if($request->user()->is_admin) { + $allowed = true; + } else { + $max = $config['limits']['user']['create']['max']; + $allowed = Group::whereProfileId($pid)->count() <= $max; + } + + return ['permission' => (bool) $allowed]; + } + + public function storeGroup(Request $request) + { + abort_if(!$request->user(), 404); + + $this->validate($request, [ + 'name' => 'required', + 'description' => 'nullable|max:500', + 'membership' => 'required|in:public,private,local' + ]); + + $pid = $request->user()->profile_id; + + $config = GroupService::config(); + abort_if($config['limits']['user']['create']['new'] == false && $request->user()->is_admin == false, 422, 'Invalid operation'); + $max = $config['limits']['user']['create']['max']; + // abort_if(Group::whereProfileId($pid)->count() <= $max, 422, 'Group limit reached'); + + $group = new Group; + $group->profile_id = $pid; + $group->name = $request->input('name'); + $group->description = $request->input('description', null); + $group->is_private = $request->input('membership') == 'private'; + $group->local_only = $request->input('membership') == 'local'; + $group->metadata = $request->input('configuration'); + $group->save(); + + GroupService::log($group->id, $pid, 'group:created'); + + $member = new GroupMember; + $member->group_id = $group->id; + $member->profile_id = $pid; + $member->role = 'founder'; + $member->local_group = true; + $member->local_profile = true; + $member->save(); + + GroupService::log( + $group->id, + $pid, + 'group:joined', + null, + GroupMember::class, + $member->id + ); + + return [ + 'id' => $group->id, + 'url' => $group->url() + ]; + } +} diff --git a/app/Http/Controllers/Groups/GroupsAdminController.php b/app/Http/Controllers/Groups/GroupsAdminController.php new file mode 100644 index 000000000..4bdf0f504 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsAdminController.php @@ -0,0 +1,353 @@ +middleware('auth'); + } + + public function getAdminTabs(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + abort_if($pid !== $group->profile_id, 404); + + $reqs = GroupMember::whereGroupId($group->id)->whereJoinRequest(true)->count(); + $mods = GroupReport::whereGroupId($group->id)->whereOpen(true)->count(); + $tabs = [ + 'moderation_count' => $mods > 99 ? '99+' : $mods, + 'request_count' => $reqs > 99 ? '99+' : $reqs + ]; + + return response()->json($tabs); + } + + public function getInteractionLogs(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $logs = GroupInteraction::whereGroupId($id) + ->latest() + ->paginate(10) + ->map(function($log) use($group) { + return [ + 'id' => $log->id, + 'profile' => GroupAccountService::get($group->id, $log->profile_id), + 'type' => $log->type, + 'metadata' => $log->metadata, + 'created_at' => $log->created_at->format('c') + ]; + }); + + return response()->json($logs, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getBlocks(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $blocks = [ + 'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->take(3)->pluck('name'), + 'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->take(3)->pluck('name'), + 'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->take(3)->pluck('name') + ]; + + return response()->json($blocks, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function exportBlocks(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $blocks = [ + 'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->pluck('name'), + 'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->pluck('name'), + 'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->pluck('name') + ]; + + $blocks['_created_at'] = now()->format('c'); + $blocks['_version'] = '1.0.0'; + ksort($blocks); + + return response()->streamDownload(function() use($blocks) { + echo json_encode($blocks, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + }); + } + + public function addBlock(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'item' => 'required', + 'type' => 'required|in:instance,user,moderate' + ]); + + $item = $request->input('item'); + $type = $request->input('type'); + + switch($type) { + case 'instance': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->instance_id = $instance->id; + $gb->name = $instance->domain; + $gb->is_user = false; + $gb->moderated = false; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:block:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + case 'user': + $profile = Profile::whereUsername($item)->first(); + abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->profile_id = $profile->id; + $gb->name = $profile->username; + $gb->is_user = true; + $gb->moderated = false; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:block:user', + [ + 'username' => $profile->username, + 'domain' => $profile->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + case 'moderate': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->instance_id = $instance->id; + $gb->name = $instance->domain; + $gb->is_user = false; + $gb->moderated = true; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:moderate:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + default: + return response()->json([], 422, []); + break; + } + } + + public function undoBlock(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'item' => 'required', + 'type' => 'required|in:instance,user,moderate' + ]); + + $item = $request->input('item'); + $type = $request->input('type'); + + switch($type) { + case 'instance': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereInstanceId($instance->id) + ->whereModerated(false) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:unblock:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + case 'user': + $profile = Profile::whereUsername($item)->first(); + abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereProfileId($profile->id) + ->whereIsUser(true) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:unblock:user', + [ + 'username' => $profile->username, + 'domain' => $profile->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + case 'moderate': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereInstanceId($instance->id) + ->whereModerated(true) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:moderate:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + default: + return response()->json([], 422, []); + break; + } + } + + public function getReportList(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $scope = $request->input('scope', 'open'); + + $list = GroupReport::selectRaw('id, profile_id, item_type, item_id, type, created_at, count(*) as total') + ->whereGroupId($group->id) + ->groupBy('item_id') + ->when($scope == 'open', function($query, $scope) { + return $query->whereOpen(true); + }) + ->latest() + ->simplePaginate(10) + ->map(function($report) use($group) { + $res = [ + 'id' => (string) $report->id, + 'profile' => GroupAccountService::get($group->id, $report->profile_id), + 'type' => $report->type, + 'created_at' => $report->created_at->format('c'), + 'total_count' => $report->total + ]; + + if($report->item_type === GroupPost::class) { + $res['status'] = GroupPostService::get($group->id, $report->item_id); + } + + return $res; + }); + return response()->json($list, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + +} diff --git a/app/Http/Controllers/Groups/GroupsApiController.php b/app/Http/Controllers/Groups/GroupsApiController.php new file mode 100644 index 000000000..13bbca640 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsApiController.php @@ -0,0 +1,84 @@ +middleware('auth'); + } + + protected function toJson($group, $pid = false) + { + return GroupService::get($group->id, $pid); + } + + public function getConfig(Request $request) + { + return GroupService::config(); + } + + public function getGroupAccount(Request $request, $gid, $pid) + { + $res = GroupAccountService::get($gid, $pid); + + return response()->json($res); + } + + public function getGroupCategories(Request $request) + { + $res = GroupService::categories(); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupsByCategory(Request $request) + { + $name = $request->input('name'); + $category = GroupCategory::whereName($name)->firstOrFail(); + $groups = Group::whereCategoryId($category->id) + ->simplePaginate(6) + ->map(function($group) { + return GroupService::get($group->id); + }) + ->filter(function($group) { + return $group; + }) + ->values(); + return $groups; + } + + public function getRecommendedGroups(Request $request) + { + return []; + } + + public function getSelfGroups(Request $request) + { + $selfOnly = $request->input('self') == true; + $memberOnly = $request->input('member') == true; + $pid = $request->user()->profile_id; + $res = GroupMember::whereProfileId($request->user()->profile_id) + ->when($selfOnly, function($q, $selfOnly) { + return $q->whereRole('founder'); + }) + ->when($memberOnly, function($q, $memberOnly) { + return $q->whereRole('member'); + }) + ->simplePaginate(4) + ->map(function($member) use($pid) { + $group = $member->group; + return $this->toJson($group, $pid); + }); + + return response()->json($res); + } +} diff --git a/app/Http/Controllers/Groups/GroupsCommentController.php b/app/Http/Controllers/Groups/GroupsCommentController.php new file mode 100644 index 000000000..435ed0d78 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsCommentController.php @@ -0,0 +1,361 @@ +validate($request, [ + 'gid' => 'required', + 'sid' => 'required', + 'cid' => 'sometimes', + 'limit' => 'nullable|integer|min:3|max:10' + ]); + + $pid = optional($request->user())->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $cid = $request->has('cid') && $request->input('cid') == 1; + $limit = $request->input('limit', 3); + $maxId = $request->input('max_id', 0); + + $group = Group::findOrFail($gid); + + abort_if($group->is_private && !$group->isMember($pid), 403, 'Not a member of group.'); + + $status = $cid ? GroupComment::findOrFail($sid) : GroupPost::findOrFail($sid); + + abort_if($status->group_id != $group->id, 400, 'Invalid group'); + + $replies = GroupComment::whereGroupId($group->id) + ->whereStatusId($status->id) + ->orderByDesc('id') + ->when($maxId, function($query, $maxId) { + return $query->where('id', '<', $maxId); + }) + ->take($limit) + ->get() + ->map(function($gp) use($pid) { + $status = GroupCommentService::get($gp['group_id'], $gp['id']); + $status['reply_count'] = $gp['reply_count']; + $status['url'] = $gp->url(); + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$gp['profile_id']}"); + return $status; + }); + + return $replies->toArray(); + } + + public function storeComment(Request $request) + { + $this->validate($request, [ + 'gid' => 'required|exists:groups,id', + 'sid' => 'required|exists:group_posts,id', + 'cid' => 'sometimes', + 'content' => 'required|string|min:1|max:1500' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $cid = $request->input('cid'); + $limit = $request->input('limit', 3); + $caption = e($request->input('content')); + + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time'); + + + $parent = $cid == 1 ? + GroupComment::findOrFail($sid) : + GroupPost::whereGroupId($gid)->findOrFail($sid); + // $autolink = Purify::clean(Autolink::create()->autolink($caption)); + // $autolink = str_replace('/discover/tags/', '/groups/' . $gid . '/topics/', $autolink); + + $status = new GroupComment; + $status->group_id = $group->id; + $status->profile_id = $pid; + $status->status_id = $parent->id; + $status->caption = Purify::clean($caption); + $status->visibility = 'public'; + $status->is_nsfw = false; + $status->local = true; + $status->save(); + + NewCommentPipeline::dispatch($parent, $status)->onQueue('groups'); + // todo: perform in job + $parent->reply_count = $parent->reply_count ? $parent->reply_count + $parent->reply_count : 1; + $parent->save(); + GroupPostService::del($parent->group_id, $parent->id); + + GroupService::log( + $group->id, + $pid, + 'group:comment:created', + [ + 'type' => 'group:post:comment', + 'status_id' => $status->id + ], + GroupPost::class, + $status->id + ); + + //GroupCommentPipeline::dispatch($parent, $status, $gp); + //NewStatusPipeline::dispatch($status, $gp); + //GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + + // todo: perform in job + $s = GroupCommentService::get($status->group_id, $status->id); + + $s['pf_type'] = 'text'; + $s['visibility'] = 'public'; + $s['url'] = $status->url(); + + return $s; + } + + public function storeCommentPhoto(Request $request) + { + $this->validate($request, [ + 'gid' => 'required|exists:groups,id', + 'sid' => 'required|exists:group_posts,id', + 'photo' => 'required|image' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $limit = $request->input('limit', 3); + $caption = $request->input('content'); + + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time'); + $parent = GroupPost::whereGroupId($gid)->findOrFail($sid); + + $status = new GroupComment; + $status->status_id = $parent->id; + $status->group_id = $group->id; + $status->profile_id = $pid; + $status->caption = Purify::clean($caption); + $status->visibility = 'draft'; + $status->is_nsfw = false; + $status->save(); + + $photo = $request->file('photo'); + $storagePath = GroupMediaService::path($group->id, $pid, $status->id); + $storagePath = 'public/g/' . $group->id . '/p/' . $parent->id; + $path = $photo->storePublicly($storagePath); + + $media = new GroupMedia(); + $media->group_id = $group->id; + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->media_path = $path; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->save(); + + ImageResizePipeline::dispatchSync($media); + ImageS3UploadPipeline::dispatchSync($media); + + // $gp = new GroupPost; + // $gp->group_id = $group->id; + // $gp->profile_id = $pid; + // $gp->type = 'reply:photo'; + // $gp->status_id = $status->id; + // $gp->in_reply_to_id = $parent->id; + // $gp->save(); + + // GroupService::log( + // $group->id, + // $pid, + // 'group:comment:created', + // [ + // 'type' => $gp->type, + // 'status_id' => $status->id + // ], + // GroupPost::class, + // $gp->id + // ); + + // todo: perform in job + // $parent->reply_count = Status::whereInReplyToId($parent->id)->count(); + // $parent->save(); + // StatusService::del($parent->id); + // GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + + // delay response while background job optimizes media + // sleep(5); + + // todo: perform in job + $s = GroupCommentService::get($status->group_id, $status->id); + + // $s['pf_type'] = 'text'; + // $s['visibility'] = 'public'; + // $s['url'] = $gp->url(); + + return $s; + } + + public function deleteComment(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'id' => 'required|integer|min:1', + 'gid' => 'required|integer|min:1' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $gp = GroupComment::whereGroupId($group->id)->findOrFail($request->input('id')); + abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403); + + $parent = GroupPost::find($gp->status_id); + abort_if(!$parent, 422, 'Invalid parent'); + + DeleteCommentPipeline::dispatch($parent, $gp)->onQueue('groups'); + GroupService::log( + $group->id, + $pid, + 'group:status:deleted', + [ + 'type' => $gp->type, + 'status_id' => $gp->id, + ], + GroupComment::class, + $gp->id + ); + $gp->delete(); + + if($request->wantsJson()) { + return response()->json(['Status successfully deleted.']); + } else { + return redirect('/groups/feed'); + } + } + + public function likePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group || $gid != $group['id'], 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupCommentService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::firstOrCreate([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'comment_id' => $sid, + ]); + + if($like->wasRecentlyCreated) { + // update parent post like count + $parent = GroupComment::find($sid); + abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count + 1; + $parent->save(); + GroupsLikeService::add($pid, $sid); + // invalidate cache + GroupCommentService::del($gid, $sid); + $count++; + GroupService::log( + $gid, + $pid, + 'group:like', + null, + GroupLike::class, + $like->id + ); + } + + $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count]; + + return $response; + } + + public function unlikePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group || $gid != $group['id'], 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupCommentService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::where([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'comment_id' => $sid, + ])->first(); + + if($like) { + $like->delete(); + $parent = GroupComment::find($sid); + abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count - 1; + $parent->save(); + GroupsLikeService::remove($pid, $sid); + // invalidate cache + GroupCommentService::del($gid, $sid); + $count--; + } + + $response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count]; + + return $response; + } +} diff --git a/app/Http/Controllers/Groups/GroupsDiscoverController.php b/app/Http/Controllers/Groups/GroupsDiscoverController.php new file mode 100644 index 000000000..2194807de --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsDiscoverController.php @@ -0,0 +1,57 @@ +middleware('auth'); + } + + public function getDiscoverPopular(Request $request) + { + abort_if(!$request->user(), 404); + $groups = Group::orderByDesc('member_count') + ->take(12) + ->pluck('id') + ->map(function($id) { + return GroupService::get($id); + }) + ->filter(function($id) { + return $id; + }) + ->take(6) + ->values(); + return $groups; + } + + public function getDiscoverNew(Request $request) + { + abort_if(!$request->user(), 404); + $groups = Group::latest() + ->take(12) + ->pluck('id') + ->map(function($id) { + return GroupService::get($id); + }) + ->filter(function($id) { + return $id; + }) + ->take(6) + ->values(); + return $groups; + } +} diff --git a/app/Http/Controllers/Groups/GroupsFeedController.php b/app/Http/Controllers/Groups/GroupsFeedController.php new file mode 100644 index 000000000..bb04e2487 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsFeedController.php @@ -0,0 +1,188 @@ +middleware('auth'); + } + + public function getSelfFeed(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + $limit = $request->input('limit', 5); + $page = $request->input('page'); + $initial = $request->has('initial'); + + if($initial) { + $res = Cache::remember('groups:self:feed:' . $pid, 900, function() use($pid) { + return $this->getSelfFeedV0($pid, 5, null); + }); + } else { + abort_if($page && $page > 5, 422); + $res = $this->getSelfFeedV0($pid, $limit, $page); + } + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + protected function getSelfFeedV0($pid, $limit, $page) + { + return GroupPost::join('group_members', 'group_posts.group_id', 'group_members.group_id') + ->select('group_posts.*', 'group_members.group_id', 'group_members.profile_id') + ->where('group_members.profile_id', $pid) + ->whereIn('group_posts.type', ['text', 'photo', 'video']) + ->orderByDesc('group_posts.id') + ->limit($limit) + // ->pluck('group_posts.status_id') + ->simplePaginate($limit) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + + if(!$status) { + return false; + } + + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = url("/groups/{$gp['group_id']}/p/{$gp['id']}"); + $status['group'] = GroupService::get($gp['group_id']); + $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + + return $status; + }); + } + + public function getGroupProfileFeed(Request $request, $id, $pid) + { + abort_if(!$request->user(), 404); + $cid = $request->user()->profile_id; + + $group = Group::findOrFail($id); + abort_if(!$group->isMember($pid), 404); + + $feed = GroupPost::whereGroupId($id) + ->whereProfileId($pid) + ->latest() + ->paginate(3) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + $status['account']['url'] = "/groups/{$gp['group_id']}/user/{$status['account']['id']}"; + + return $status; + }) + ->filter(function($status) { + return $status; + }); + + return $feed; + } + + public function getGroupFeed(Request $request, $id) + { + $group = Group::findOrFail($id); + $user = $request->user(); + $pid = optional($user)->profile_id ?? false; + abort_if(!$group->isMember($pid), 404); + $max = $request->input('max_id'); + $limit = $request->limit ?? 3; + $filtered = $user ? UserFilterService::filters($user->profile_id) : []; + + // $posts = GroupPost::whereGroupId($group->id) + // ->when($maxId, function($q, $maxId) { + // return $q->where('status_id', '<', $maxId); + // }) + // ->whereNull('in_reply_to_id') + // ->orderByDesc('status_id') + // ->simplePaginate($limit) + // ->map(function($gp) use($pid) { + // $status = StatusService::get($gp['status_id'], false); + // if(!$status) { + // return false; + // } + // $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']); + // $status['favourites_count'] = LikeService::count($gp['status_id']); + // $status['pf_type'] = $gp['type']; + // $status['visibility'] = 'public'; + // $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + // $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + + // return $status; + // })->filter(function($status) { + // return $status; + // }); + // return $posts; + + Cache::remember('api:v1:timelines:public:cache_check', 10368000, function() use($id) { + if(GroupFeedService::count($id) == 0) { + GroupFeedService::warmCache($id, true, 400); + } + }); + + if ($max) { + $feed = GroupFeedService::getRankedMaxId($id, $max, $limit); + } else { + $feed = GroupFeedService::get($id, 0, $limit); + } + + $res = collect($feed) + ->map(function($k) use($user, $id) { + $status = GroupPostService::get($id, $k); + if($status && $user) { + $pid = $user->profile_id; + $sid = $status['account']['id']; + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $status['id']); + $status['favourites_count'] = GroupsLikeService::count($status['id']); + $status['relationship'] = $pid == $sid ? [] : RelationshipService::get($pid, $sid); + } + return $status; + }) + ->filter(function($s) use($filtered) { + return $s && in_array($s['account']['id'], $filtered) == false; + }) + ->values() + ->toArray(); + + return $res; + } +} diff --git a/app/Http/Controllers/Groups/GroupsMemberController.php b/app/Http/Controllers/Groups/GroupsMemberController.php new file mode 100644 index 000000000..3bfe086a2 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsMemberController.php @@ -0,0 +1,214 @@ +validate($request, [ + 'gid' => 'required', + 'limit' => 'nullable|integer|min:3|max:10' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $members = GroupMember::whereGroupId($gid) + ->whereJoinRequest(false) + ->simplePaginate(10) + ->map(function($member) use($pid) { + $account = AccountService::get($member['profile_id']); + $account['role'] = $member['role']; + $account['joined'] = $member['created_at']; + $account['following'] = $pid != $member['profile_id'] ? + FollowerService::follows($pid, $member['profile_id']) : + null; + $account['url'] = url("/groups/{$member->group_id}/user/{$member['profile_id']}"); + return $account; + }); + + return response()->json($members->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupMemberJoinRequests(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + return GroupMember::whereGroupId($group->id) + ->whereJoinRequest(true) + ->whereNull('rejected_at') + ->paginate(10) + ->map(function($member) { + return AccountService::get($member->profile_id); + }); + } + + public function handleGroupMemberJoinRequest(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + $mid = $request->input('pid'); + abort_if($group->isMember($mid), 404); + + $this->validate($request, [ + 'gid' => 'required', + 'pid' => 'required', + 'action' => 'required|in:approve,reject' + ]); + + $action = $request->input('action'); + + $member = GroupMember::whereGroupId($group->id) + ->whereProfileId($mid) + ->firstOrFail(); + + if($action == 'approve') { + MemberJoinApprovedPipeline::dispatch($member)->onQueue('groups'); + } else if ($action == 'reject') { + MemberJoinRejectedPipeline::dispatch($member)->onQueue('groups'); + } + + return $request->all(); + } + + public function getGroupMember(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'pid' => 'required' + ]); + + abort_if(!$request->user(), 404); + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $member_id = $request->input('pid'); + $member = GroupMember::whereGroupId($gid) + ->whereProfileId($member_id) + ->firstOrFail(); + + $account = GroupAccountService::get($group->id, $member['profile_id']); + $account['role'] = $member['role']; + $account['joined'] = $member['created_at']; + $account['following'] = $pid != $member['profile_id'] ? + FollowerService::follows($pid, $member['profile_id']) : + null; + $account['url'] = url("/groups/{$gid}/user/{$member_id}"); + + return response()->json($account, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupMemberCommonIntersections(Request $request) + { + abort_if(!$request->user(), 404); + $cid = $request->user()->profile_id; + + // $this->validate($request, [ + // 'gid' => 'required', + // 'pid' => 'required' + // ]); + + $gid = $request->input('gid'); + $pid = $request->input('pid'); + + if($pid === $cid) { + return []; + } + + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($cid), 404); + abort_if(!$group->isMember($pid), 404); + + $self = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr') + ->whereProfileId($cid) + ->groupBy('hashtag_id') + ->orderByDesc('countr') + ->take(20) + ->pluck('hashtag_id'); + $user = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr') + ->whereProfileId($pid) + ->groupBy('hashtag_id') + ->orderByDesc('countr') + ->take(20) + ->pluck('hashtag_id'); + + $topics = $self->intersect($user) + ->values() + ->shuffle() + ->take(3) + ->map(function($id) use($group) { + $tag = GroupHashtagService::get($id); + $tag['url'] = url("/groups/{$group->id}/topics/{$tag['slug']}?src=upt"); + return $tag; + }); + + // $friends = DB::table('followers as u') + // ->join('followers as s', 'u.following_id', '=', 's.following_id') + // ->where('s.profile_id', $cid) + // ->where('u.profile_id', $pid) + // ->inRandomOrder() + // ->take(10) + // ->pluck('s.following_id') + // ->map(function($id) use($gid) { + // $res = AccountService::get($id); + // $res['url'] = url("/groups/{$gid}/user/{$id}"); + // return $res; + // }); + $mutualGroups = GroupService::mutualGroups($cid, $pid, [$gid]); + + $mutualFriends = collect(FollowerService::mutualIds($cid, $pid)) + ->map(function($id) use($gid) { + $res = AccountService::get($id); + if(GroupService::isMember($gid, $id)) { + $res['url'] = url("/groups/{$gid}/user/{$id}"); + } else if(!$res['local']) { + $res['url'] = url("/i/web/profile/_/{$id}"); + } + return $res; + }); + $mutualFriendsCount = FollowerService::mutualCount($cid, $pid); + + $res = [ + 'groups_count' => $mutualGroups['count'], + 'groups' => $mutualGroups['groups'], + 'topics' => $topics, + 'friends_count' => $mutualFriendsCount, + 'friends' => $mutualFriends, + ]; + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsMetaController.php b/app/Http/Controllers/Groups/GroupsMetaController.php new file mode 100644 index 000000000..bc1e58b33 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsMetaController.php @@ -0,0 +1,31 @@ +middleware('auth'); + } + + public function deleteGroup(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $group->status = "delete"; + $group->save(); + GroupService::del($group->id); + return [200]; + } +} diff --git a/app/Http/Controllers/Groups/GroupsNotificationsController.php b/app/Http/Controllers/Groups/GroupsNotificationsController.php new file mode 100644 index 000000000..dafc6c821 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsNotificationsController.php @@ -0,0 +1,55 @@ +middleware('auth'); + } + + public function selfGlobalNotifications(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + + $res = Notification::whereProfileId($pid) + ->where('action', 'like', 'group%') + ->latest() + ->paginate(10) + ->map(function($n) { + $res = [ + 'id' => $n->id, + 'type' => $n->action, + 'account' => AccountService::get($n->actor_id), + 'object' => [ + 'id' => $n->item_id, + 'type' => last(explode('\\', $n->item_type)), + ], + 'created_at' => $n->created_at->format('c') + ]; + + if($res['object']['type'] == 'Status' || in_array($n->action, ['group:comment'])) { + $res['status'] = StatusService::get($n->item_id, false); + $res['group'] = GroupService::get($res['status']['gid']); + } + + if($res['object']['type'] == 'Group') { + $res['group'] = GroupService::get($n->item_id); + } + + return $res; + }); + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsPostController.php b/app/Http/Controllers/Groups/GroupsPostController.php new file mode 100644 index 000000000..11b4799fe --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsPostController.php @@ -0,0 +1,420 @@ +middleware('auth'); + } + + public function storePost(Request $request) + { + $this->validate($request, [ + 'group_id' => 'required|exists:groups,id', + 'caption' => 'sometimes|string|max:'.config_cache('pixelfed.max_caption_length', 500), + 'pollOptions' => 'sometimes|array|min:1|max:4' + ]); + + $group = Group::findOrFail($request->input('group_id')); + $pid = $request->user()->profile_id; + $caption = $request->input('caption'); + $type = $request->input('type', 'text'); + + abort_if(!GroupService::canPost($group->id, $pid), 422, 'You cannot create new posts at this time'); + + if($type == 'text') { + abort_if(strlen(e($caption)) == 0, 403); + } + + $gp = new GroupPost; + $gp->group_id = $group->id; + $gp->profile_id = $pid; + $gp->caption = e($caption); + $gp->type = $type; + $gp->visibility = 'draft'; + $gp->save(); + + $status = $gp; + + NewPostPipeline::dispatchSync($gp); + + // NewStatusPipeline::dispatch($status, $gp); + + if($type == 'poll') { + // Polls not supported yet + // $poll = new Poll; + // $poll->status_id = $status->id; + // $poll->profile_id = $status->profile_id; + // $poll->poll_options = $request->input('pollOptions'); + // $poll->expires_at = now()->addMinutes($request->input('expiry')); + // $poll->cached_tallies = collect($poll->poll_options)->map(function($o) { + // return 0; + // })->toArray(); + // $poll->save(); + // sleep(5); + } + if($type == 'photo') { + $photo = $request->file('photo'); + $storagePath = GroupMediaService::path($group->id, $pid, $status->id); + // $storagePath = 'public/g/' . $group->id . '/p/' . $status->id; + $path = $photo->storePublicly($storagePath); + // $hash = \hash_file('sha256', $photo); + + $media = new GroupMedia(); + $media->group_id = $group->id; + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->media_path = $path; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->save(); + + // Bus::chain([ + // new ImageResizePipeline($media), + // new ImageS3UploadPipeline($media), + // ])->dispatch($media); + + ImageResizePipeline::dispatchSync($media); + ImageS3UploadPipeline::dispatchSync($media); + // ImageOptimize::dispatch($media); + // delay response while background job optimizes media + // sleep(5); + } + if($type == 'video') { + $video = $request->file('video'); + $storagePath = 'public/g/' . $group->id . '/p/' . $status->id; + $path = $video->storePublicly($storagePath); + $hash = \hash_file('sha256', $video); + + $media = new Media(); + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->user_id = $request->user()->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $video->getSize(); + $media->mime = $video->getMimeType(); + $media->save(); + + VideoThumbnail::dispatch($media); + sleep(15); + } + + GroupService::log( + $group->id, + $pid, + 'group:status:created', + [ + 'type' => $gp->type, + 'status_id' => $status->id + ], + GroupPost::class, + $gp->id + ); + + $s = GroupPostService::get($status->group_id, $status->id); + GroupFeedService::add($group->id, $gp->id); + Cache::forget('groups:self:feed:' . $pid); + + $s['pf_type'] = $type; + $s['visibility'] = 'public'; + $s['url'] = $gp->url(); + + if($type == 'poll') { + $s['poll'] = PollService::get($status->id); + } + + $group->last_active_at = now(); + $group->save(); + + return $s; + } + + public function deletePost(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'id' => 'required|integer|min:1', + 'gid' => 'required|integer|min:1' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $gp = GroupPost::whereGroupId($status->group_id)->findOrFail($request->input('id')); + abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403); + $cached = GroupPostService::get($status->group_id, $status->id); + + if($cached) { + $cached = collect($cached)->filter(function($r, $k) { + return in_array($k, [ + 'id', + 'sensitive', + 'pf_type', + 'media_attachments', + 'content_text', + 'created_at' + ]); + }); + } + + GroupService::log( + $status->group_id, + $request->user()->profile_id, + 'group:status:deleted', + [ + 'type' => $gp->type, + 'status_id' => $status->id, + 'original' => $cached + ], + GroupPost::class, + $gp->id + ); + + $user = $request->user(); + + // if($status->profile_id != $user->profile->id && + // $user->is_admin == true && + // $status->uri == null + // ) { + // $media = $status->media; + + // $ai = new AccountInterstitial; + // $ai->user_id = $status->profile->user_id; + // $ai->type = 'post.removed'; + // $ai->view = 'account.moderation.post.removed'; + // $ai->item_type = 'App\Status'; + // $ai->item_id = $status->id; + // $ai->has_media = (bool) $media->count(); + // $ai->blurhash = $media->count() ? $media->first()->blurhash : null; + // $ai->meta = json_encode([ + // 'caption' => $status->caption, + // 'created_at' => $status->created_at, + // 'type' => $status->type, + // 'url' => $status->url(), + // 'is_nsfw' => $status->is_nsfw, + // 'scope' => $status->scope, + // 'reblog' => $status->reblog_of_id, + // 'likes_count' => $status->likes_count, + // 'reblogs_count' => $status->reblogs_count, + // ]); + // $ai->save(); + + // $u = $status->profile->user; + // $u->has_interstitial = true; + // $u->save(); + // } + + if($status->in_reply_to_id) { + $parent = GroupPost::find($status->in_reply_to_id); + if($parent) { + $parent->reply_count = GroupPost::whereInReplyToId($parent->id)->count(); + $parent->save(); + GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + } + } + + GroupPostService::del($group->id, $gp->id); + GroupFeedService::del($group->id, $gp->id); + if ($status->profile_id == $user->profile->id || $user->is_admin == true) { + // Cache::forget('profile:status_count:'.$status->profile_id); + StatusDelete::dispatch($status); + } + + if($request->wantsJson()) { + return response()->json(['Status successfully deleted.']); + } else { + return redirect($user->url()); + } + } + + public function likePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group, 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupPostService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::firstOrCreate([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'status_id' => $sid, + ]); + + if($like->wasRecentlyCreated) { + // update parent post like count + $parent = GroupPost::whereGroupId($gid)->find($sid); + abort_if(!$parent, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count + 1; + $parent->save(); + GroupsLikeService::add($pid, $sid); + // invalidate cache + GroupPostService::del($gid, $sid); + $count++; + GroupService::log( + $gid, + $pid, + 'group:like', + null, + GroupLike::class, + $like->id + ); + } + // if (GroupLike::whereGroupId($gid)->whereStatusId($sid)->whereProfileId($pid)->exists()) { + // $like = GroupLike::whereProfileId($pid)->whereStatusId($sid)->firstOrFail(); + // // UnlikePipeline::dispatch($like); + // $count = $gp->likes_count - 1; + // $action = 'group:unlike'; + // } else { + // $count = $gp->likes_count; + // $like = GroupLike::firstOrCreate([ + // 'group_id' => $gid, + // 'profile_id' => $pid, + // 'status_id' => $sid + // ]); + // if($like->wasRecentlyCreated == true) { + // $count++; + // $gp->likes_count = $count; + // $like->save(); + // $gp->save(); + // // LikePipeline::dispatch($like); + // $action = 'group:like'; + // } + // } + + + // Cache::forget('status:'.$status->id.':likedby:userid:'.$request->user()->id); + // StatusService::del($status->id); + + $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count]; + + return $response; + } + + public function unlikePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group, 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupPostService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::where([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'status_id' => $sid, + ])->first(); + + if($like) { + $like->delete(); + $parent = GroupPost::whereGroupId($gid)->find($sid); + abort_if(!$parent, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count - 1; + $parent->save(); + GroupsLikeService::remove($pid, $sid); + // invalidate cache + GroupPostService::del($gid, $sid); + $count--; + } + + $response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count]; + + return $response; + } + + public function getGroupMedia(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'type' => 'required|in:photo,video' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $type = $request->input('type'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $media = GroupPost::whereGroupId($gid) + ->whereType($type) + ->latest() + ->simplePaginate(20) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + return $status; + })->filter(function($status) { + return $status; + }); + + return response()->json($media->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsSearchController.php b/app/Http/Controllers/Groups/GroupsSearchController.php new file mode 100644 index 000000000..90cc7ccdf --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsSearchController.php @@ -0,0 +1,217 @@ +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); + } +} diff --git a/app/Http/Controllers/Groups/GroupsTopicController.php b/app/Http/Controllers/Groups/GroupsTopicController.php new file mode 100644 index 000000000..c3d8ecda7 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsTopicController.php @@ -0,0 +1,133 @@ +middleware('auth'); + } + + public function groupTopics(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $posts = GroupPostHashtag::join('group_hashtags', 'group_hashtags.id', '=', 'group_post_hashtags.hashtag_id') + ->selectRaw('group_hashtags.*, group_post_hashtags.*, count(group_post_hashtags.hashtag_id) as ht_count') + ->where('group_post_hashtags.group_id', $gid) + ->orderByDesc('ht_count') + ->limit(10) + ->pluck('group_post_hashtags.hashtag_id', 'ht_count') + ->map(function($id, $key) use ($gid) { + $tag = GroupHashtag::find($id); + return [ + 'hid' => $id, + 'name' => $tag->name, + 'url' => url("/groups/{$gid}/topics/{$tag->slug}"), + 'count' => $key + ]; + })->values(); + + return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function groupTopicTag(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'name' => 'required' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $limit = $request->input('limit', 3); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $name = $request->input('name'); + $hashtag = GroupHashtag::whereName($name)->first(); + + if(!$hashtag) { + return []; + } + + // $posts = GroupPost::whereGroupId($gid) + // ->select('status_hashtags.*', 'group_posts.*') + // ->where('status_hashtags.hashtag_id', $hashtag->id) + // ->join('status_hashtags', 'group_posts.status_id', '=', 'status_hashtags.status_id') + // ->orderByDesc('group_posts.status_id') + // ->simplePaginate($limit) + // ->map(function($gp) use($pid) { + // $status = StatusService::get($gp['status_id'], false); + // if(!$status) { + // return false; + // } + // $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']); + // $status['favourites_count'] = LikeService::count($gp['status_id']); + // $status['pf_type'] = $gp['type']; + // $status['visibility'] = 'public'; + // $status['url'] = $gp->url(); + // return $status; + // }); + + $posts = GroupPostHashtag::whereGroupId($gid) + ->whereHashtagId($hashtag->id) + ->orderByDesc('id') + ->simplePaginate($limit) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['status_id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['status_id']); + $status['favourites_count'] = GroupsLikeService::count($gp['status_id']); + $status['pf_type'] = $status['pf_type']; + $status['visibility'] = 'public'; + return $status; + }); + + return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function showTopicFeed(Request $request, $gid, $tag) + { + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + $gid = $group->id; + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + return view('groups.topic-feed', compact('gid', 'tag')); + } +} diff --git a/app/Jobs/GroupPipeline/GroupCommentPipeline.php b/app/Jobs/GroupPipeline/GroupCommentPipeline.php new file mode 100644 index 000000000..cdae65d10 --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupCommentPipeline.php @@ -0,0 +1,99 @@ +status = $status; + $this->comment = $comment; + $this->groupPost = $groupPost; + } + + public function handle() + { + if($this->status->group_id == null || $this->comment->group_id == null) { + return; + } + + $this->updateParentReplyCount(); + $this->generateNotification(); + + if($this->groupPost) { + $this->updateChildReplyCount(); + } + } + + protected function updateParentReplyCount() + { + $parent = $this->status; + $parent->reply_count = Status::whereInReplyToId($parent->id)->count(); + $parent->save(); + StatusService::del($parent->id); + } + + protected function updateChildReplyCount() + { + $gp = $this->groupPost; + if($gp->reply_child_id) { + $parent = GroupPost::whereStatusId($gp->reply_child_id)->first(); + if($parent) { + $parent->reply_count++; + $parent->save(); + } + } + } + + protected function generateNotification() + { + $status = $this->status; + $comment = $this->comment; + + $target = $status->profile; + $actor = $comment->profile; + + if ($actor->id == $target->id || $status->comments_disabled == true) { + return; + } + + $notification = DB::transaction(function() use($target, $actor, $comment) { + $actorName = $actor->username; + $actorUrl = $actor->url(); + $text = "{$actorName} commented on your group post."; + $html = "{$actorName} commented on your group post."; + $notification = new Notification(); + $notification->profile_id = $target->id; + $notification->actor_id = $actor->id; + $notification->action = 'group:comment'; + $notification->item_id = $comment->id; + $notification->item_type = "App\Status"; + $notification->save(); + return $notification; + }); + + NotificationService::setNotification($notification); + NotificationService::set($notification->profile_id, $notification->id); + } +} diff --git a/app/Jobs/GroupPipeline/GroupMediaPipeline.php b/app/Jobs/GroupPipeline/GroupMediaPipeline.php new file mode 100644 index 000000000..1155e5465 --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupMediaPipeline.php @@ -0,0 +1,57 @@ +media = $media; + } + + public function handle() + { + MediaStorageService::store($this->media); + } + + protected function localToCloud($media) + { + $path = storage_path('app/'.$media->media_path); + $thumb = storage_path('app/'.$media->thumbnail_path); + + $p = explode('/', $media->media_path); + $name = array_pop($p); + $pt = explode('/', $media->thumbnail_path); + $thumbname = array_pop($pt); + $storagePath = implode('/', $p); + + $disk = Storage::disk(config('filesystems.cloud')); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + $url = $disk->url($file); + $thumbFile = $disk->putFileAs($storagePath, new File($thumb), $thumbname, 'public'); + $thumbUrl = $disk->url($thumbFile); + $media->thumbnail_url = $thumbUrl; + $media->cdn_url = $url; + $media->optimized_url = $url; + $media->replicated_at = now(); + $media->save(); + if($media->status_id) { + Cache::forget('status:transformer:media:attachments:' . $media->status_id); + } + } + +} diff --git a/app/Jobs/GroupPipeline/GroupMemberInvite.php b/app/Jobs/GroupPipeline/GroupMemberInvite.php new file mode 100644 index 000000000..d2c2bf8ef --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupMemberInvite.php @@ -0,0 +1,54 @@ +invite = $invite; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $invite = $this->invite; + $actor = Profile::find($invite->from_profile_id); + $target = Profile::find($invite->to_profile_id); + + if(!$actor || !$target) { + return; + } + + $notification = new Notification; + $notification->profile_id = $target->id; + $notification->actor_id = $actor->id; + $notification->action = 'group:invite'; + $notification->item_id = $invite->group_id; + $notification->item_type = 'App\Models\Group'; + $notification->save(); + } +} diff --git a/app/Jobs/GroupPipeline/JoinApproved.php b/app/Jobs/GroupPipeline/JoinApproved.php new file mode 100644 index 000000000..f41c8f698 --- /dev/null +++ b/app/Jobs/GroupPipeline/JoinApproved.php @@ -0,0 +1,54 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->approved_at = now(); + $member->join_request = false; + $member->role = 'member'; + $member->save(); + + $n = new Notification; + $n->profile_id = $member->profile_id; + $n->actor_id = $member->profile_id; + $n->item_id = $member->group_id; + $n->item_type = 'App\Models\Group'; + $n->save(); + + GroupService::del($member->group_id); + GroupService::delSelf($member->group_id, $member->profile_id); + } +} diff --git a/app/Jobs/GroupPipeline/JoinRejected.php b/app/Jobs/GroupPipeline/JoinRejected.php new file mode 100644 index 000000000..71e1e30c8 --- /dev/null +++ b/app/Jobs/GroupPipeline/JoinRejected.php @@ -0,0 +1,50 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->rejected_at = now(); + $member->save(); + + $n = new Notification; + $n->profile_id = $member->profile_id; + $n->actor_id = $member->profile_id; + $n->item_id = $member->group_id; + $n->item_type = 'App\Models\Group'; + $n->action = 'group.join.rejected'; + $n->save(); + } +} diff --git a/app/Jobs/GroupPipeline/LikePipeline.php b/app/Jobs/GroupPipeline/LikePipeline.php new file mode 100644 index 000000000..bd3e668f7 --- /dev/null +++ b/app/Jobs/GroupPipeline/LikePipeline.php @@ -0,0 +1,107 @@ +like = $like; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $like = $this->like; + + $status = $this->like->status; + $actor = $this->like->actor; + + if (!$status) { + // Ignore notifications to deleted statuses + return; + } + + StatusService::refresh($status->id); + + if($status->url && $actor->domain == null) { + return $this->remoteLikeDeliver(); + } + + $exists = Notification::whereProfileId($status->profile_id) + ->whereActorId($actor->id) + ->whereAction('group:like') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->count(); + + if ($actor->id === $status->profile_id || $exists !== 0) { + return true; + } + + try { + $notification = new Notification(); + $notification->profile_id = $status->profile_id; + $notification->actor_id = $actor->id; + $notification->action = 'group:like'; + $notification->item_id = $status->id; + $notification->item_type = "App\Status"; + $notification->save(); + + } catch (Exception $e) { + } + } + + public function remoteLikeDeliver() + { + $like = $this->like; + $status = $this->like->status; + $actor = $this->like->actor; + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($like, new LikeTransformer()); + $activity = $fractal->createData($resource)->toArray(); + + $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; + + Helpers::sendSignedObject($actor, $url, $activity); + } +} diff --git a/app/Jobs/GroupPipeline/NewStatusPipeline.php b/app/Jobs/GroupPipeline/NewStatusPipeline.php new file mode 100644 index 000000000..4d8eeca5c --- /dev/null +++ b/app/Jobs/GroupPipeline/NewStatusPipeline.php @@ -0,0 +1,130 @@ +status = $status; + $this->gp = $gp; + } + + public function handle() + { + $status = $this->status; + + $autolink = Autolink::create() + ->setAutolinkActiveUsersOnly(true) + ->setBaseHashPath("/groups/{$status->group_id}/topics/") + ->setBaseUserPath("/groups/{$status->group_id}/username/") + ->autolink($status->caption); + + $entities = Extractor::create()->extract($status->caption); + + $autolink = str_replace('/discover/tags/', '/groups/' . $status->group_id . '/topics/', $autolink); + + $status->rendered = nl2br($autolink); + $status->entities = null; + $status->save(); + + $this->tags = array_unique($entities['hashtags']); + $this->mentions = array_unique($entities['mentions']); + + if(count($this->tags)) { + $this->storeHashtags(); + } + + if(count($this->mentions)) { + $this->storeMentions($this->mentions); + } + } + + protected function storeHashtags() + { + $tags = $this->tags; + $status = $this->status; + $gp = $this->gp; + + foreach ($tags as $tag) { + if(mb_strlen($tag) > 124) { + continue; + } + + DB::transaction(function () use ($status, $tag, $gp) { + $slug = str_slug($tag, '-', false); + $hashtag = Hashtag::firstOrCreate( + ['name' => $tag, 'slug' => $slug] + ); + GroupPostHashtag::firstOrCreate( + [ + 'group_id' => $status->group_id, + 'group_post_id' => $gp->id, + 'status_id' => $status->id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + ] + ); + + }); + } + + if(count($this->mentions)) { + $this->storeMentions(); + } + StatusService::del($status->id); + } + + protected function storeMentions() + { + $mentions = $this->mentions; + $status = $this->status; + + foreach ($mentions as $mention) { + $mentioned = Profile::whereUsername($mention)->first(); + + if (empty($mentioned) || !isset($mentioned->id)) { + continue; + } + + DB::transaction(function () use ($status, $mentioned) { + $m = new Mention(); + $m->status_id = $status->id; + $m->profile_id = $mentioned->id; + $m->save(); + + MentionPipeline::dispatch($status, $m); + }); + } + StatusService::del($status->id); + } +} diff --git a/app/Jobs/GroupPipeline/UnlikePipeline.php b/app/Jobs/GroupPipeline/UnlikePipeline.php new file mode 100644 index 000000000..b322d6853 --- /dev/null +++ b/app/Jobs/GroupPipeline/UnlikePipeline.php @@ -0,0 +1,109 @@ +like = $like; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $like = $this->like; + + $status = $this->like->status; + $actor = $this->like->actor; + + if (!$status) { + // Ignore notifications to deleted statuses + return; + } + + $count = $status->likes_count > 1 ? $status->likes_count : $status->likes()->count(); + $status->likes_count = $count - 1; + $status->save(); + + StatusService::del($status->id); + + if($actor->id !== $status->profile_id && $status->url && $actor->domain == null) { + $this->remoteLikeDeliver(); + } + + $exists = Notification::whereProfileId($status->profile_id) + ->whereActorId($actor->id) + ->whereAction('group:like') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->first(); + + if($exists) { + $exists->delete(); + } + + $like = Like::whereProfileId($actor->id)->whereStatusId($status->id)->first(); + + if(!$like) { + return; + } + + $like->forceDelete(); + + return; + } + + public function remoteLikeDeliver() + { + $like = $this->like; + $status = $this->like->status; + $actor = $this->like->actor; + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($like, new LikeTransformer()); + $activity = $fractal->createData($resource)->toArray(); + + $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; + + Helpers::sendSignedObject($actor, $url, $activity); + } +} diff --git a/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php b/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php new file mode 100644 index 000000000..e1d94c5de --- /dev/null +++ b/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php @@ -0,0 +1,58 @@ +parent = $parent; + $this->status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $parent = $this->parent; + $parent->reply_count = GroupComment::whereStatusId($parent->id)->count(); + $parent->save(); + + return; + } +} diff --git a/app/Jobs/GroupsPipeline/ImageResizePipeline.php b/app/Jobs/GroupsPipeline/ImageResizePipeline.php new file mode 100644 index 000000000..fa649efea --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageResizePipeline.php @@ -0,0 +1,89 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media) { + return; + } + + if (!Storage::exists($media->media_path) || $media->skip_optimize) { + return; + } + + $path = $media->media_path; + $file = storage_path('app/' . $path); + $quality = config_cache('pixelfed.image_quality'); + + $orientations = [ + 'square' => [ + 'width' => 1080, + 'height' => 1080, + ], + 'landscape' => [ + 'width' => 1920, + 'height' => 1080, + ], + 'portrait' => [ + 'width' => 1080, + 'height' => 1350, + ], + ]; + + try { + $img = Intervention::make($file); + $img->orientate(); + $width = $img->width(); + $height = $img->height(); + $aspect = $width / $height; + $orientation = $aspect === 1 ? 'square' : ($aspect > 1 ? 'landscape' : 'portrait'); + $ratio = $orientations[$orientation]; + $img->resize($ratio['width'], $ratio['height']); + $img->save($file, $quality); + } catch (Exception $e) { + Log::error($e); + } + } +} diff --git a/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php b/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php new file mode 100644 index 000000000..d59c6d086 --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php @@ -0,0 +1,67 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) { + return; + } + + $fs = Storage::disk(config('filesystems.cloud')); + + if(!$fs) { + return; + } + + if($fs->exists($media->media_path)) { + $fs->delete($media->media_path); + } + } +} diff --git a/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php b/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php new file mode 100644 index 000000000..169c11073 --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php @@ -0,0 +1,107 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) { + return; + } + + $path = storage_path('app/' . $media->media_path); + + $p = explode('/', $media->media_path); + $name = array_pop($p); + $storagePath = implode('/', $p); + + $url = (bool) config_cache('pixelfed.cloud_storage') && (bool) config('media.storage.remote.resilient_mode') ? + self::handleResilientStore($storagePath, $path, $name) : + self::handleStore($storagePath, $path, $name); + + if($url && strlen($url) && str_starts_with($url, 'https://')) { + $media->cdn_url = $url; + $media->processed_at = now(); + $media->version = 11; + $media->save(); + Storage::disk('local')->delete($media->media_path); + } + } + + protected function handleStore($storagePath, $path, $name) + { + return retry(3, function() use($storagePath, $path, $name) { + $baseDisk = (bool) config_cache('pixelfed.cloud_storage') ? config('filesystems.cloud') : 'local'; + $disk = Storage::disk($baseDisk); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + return $disk->url($file); + }, random_int(100, 500)); + } + + protected function handleResilientStore($storagePath, $path, $name) + { + $attempts = 0; + return retry(4, function() use($storagePath, $path, $name, $attempts) { + self::$attempts++; + usleep(100000); + $baseDisk = self::$attempts > 1 ? $this->getAltDriver() : config('filesystems.cloud'); + try { + $disk = Storage::disk($baseDisk); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + } catch (S3Exception | ClientException | ConnectException | UnableToWriteFile | Exception $e) {} + return $disk->url($file); + }, function (int $attempt, Exception $exception) { + return $attempt * 200; + }); + } + + protected function getAltDriver() + { + return config('filesystems.cloud'); + } +} diff --git a/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php b/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php new file mode 100644 index 000000000..a3ec21982 --- /dev/null +++ b/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php @@ -0,0 +1,47 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->approved_at = now(); + $member->join_request = false; + $member->role = 'member'; + $member->save(); + + GroupService::del($member->group_id); + GroupService::delSelf($member->group_id, $member->profile_id); + } +} diff --git a/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php b/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php new file mode 100644 index 000000000..5e8226de0 --- /dev/null +++ b/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php @@ -0,0 +1,42 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->rejected_at = now(); + $member->save(); + } +} diff --git a/app/Jobs/GroupsPipeline/NewCommentPipeline.php b/app/Jobs/GroupsPipeline/NewCommentPipeline.php new file mode 100644 index 000000000..fb618a14d --- /dev/null +++ b/app/Jobs/GroupsPipeline/NewCommentPipeline.php @@ -0,0 +1,115 @@ +parent = $parent; + $this->status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $profile = $this->status->profile; + $status = $this->status; + + $parent = $this->parent; + $parent->reply_count = GroupComment::whereStatusId($parent->id)->count(); + $parent->save(); + + if ($profile->no_autolink == false) { + $this->parseEntities(); + } + } + + public function parseEntities() + { + $this->extractEntities(); + } + + public function extractEntities() + { + $this->entities = Extractor::create()->extract($this->status->caption); + $this->autolinkStatus(); + } + + public function autolinkStatus() + { + $this->autolink = Autolink::create()->autolink($this->status->caption); + $this->storeHashtags(); + } + + public function storeHashtags() + { + $tags = array_unique($this->entities['hashtags']); + $status = $this->status; + + foreach ($tags as $tag) { + if (mb_strlen($tag) > 124) { + continue; + } + DB::transaction(function () use ($status, $tag) { + $hashtag = GroupHashtag::firstOrCreate([ + 'name' => $tag, + ]); + + GroupPostHashtag::firstOrCreate( + [ + 'status_id' => $status->id, + 'group_id' => $status->group_id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + 'status_visibility' => $status->visibility, + ] + ); + }); + } + $this->storeMentions(); + } + + public function storeMentions() + { + // todo + } +} diff --git a/app/Jobs/GroupsPipeline/NewPostPipeline.php b/app/Jobs/GroupsPipeline/NewPostPipeline.php new file mode 100644 index 000000000..1302a0233 --- /dev/null +++ b/app/Jobs/GroupsPipeline/NewPostPipeline.php @@ -0,0 +1,108 @@ +status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $profile = $this->status->profile; + $status = $this->status; + + if ($profile->no_autolink == false) { + $this->parseEntities(); + } + } + + public function parseEntities() + { + $this->extractEntities(); + } + + public function extractEntities() + { + $this->entities = Extractor::create()->extract($this->status->caption); + $this->autolinkStatus(); + } + + public function autolinkStatus() + { + $this->autolink = Autolink::create()->autolink($this->status->caption); + $this->storeHashtags(); + } + + public function storeHashtags() + { + $tags = array_unique($this->entities['hashtags']); + $status = $this->status; + + foreach ($tags as $tag) { + if (mb_strlen($tag) > 124) { + continue; + } + DB::transaction(function () use ($status, $tag) { + $hashtag = GroupHashtag::firstOrCreate([ + 'name' => $tag, + ]); + + GroupPostHashtag::firstOrCreate( + [ + 'status_id' => $status->id, + 'group_id' => $status->group_id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + 'status_visibility' => $status->visibility, + ] + ); + }); + } + $this->storeMentions(); + } + + public function storeMentions() + { + // todo + } +} diff --git a/app/Models/Group.php b/app/Models/Group.php new file mode 100644 index 000000000..508ed98c0 --- /dev/null +++ b/app/Models/Group.php @@ -0,0 +1,67 @@ + 'json' + ]; + + public function url() + { + return url("/groups/{$this->id}"); + } + + public function permalink($suffix = null) + { + if(!$this->local) { + return $this->remote_url; + } + return $this->url() . $suffix; + } + + public function members() + { + return $this->hasMany(GroupMember::class); + } + + public function admin() + { + return $this->belongsTo(Profile::class, 'profile_id'); + } + + public function isMember($id = false) + { + $id = $id ?? request()->user()->profile_id; + // return $this->members()->whereProfileId($id)->whereJoinRequest(false)->exists(); + return GroupService::isMember($this->id, $id); + } + + public function getMembershipType() + { + return $this->is_private ? 'private' : ($this->is_local ? 'local' : 'all'); + } + + public function selfRole($id = false) + { + $id = $id ?? request()->user()->profile_id; + return optional($this->members()->whereProfileId($id)->first())->role ?? null; + } +} diff --git a/app/Models/GroupActivityGraph.php b/app/Models/GroupActivityGraph.php new file mode 100644 index 000000000..55981d20a --- /dev/null +++ b/app/Models/GroupActivityGraph.php @@ -0,0 +1,11 @@ +belongsTo(Profile::class); + } + + public function url() + { + return '/group/' . $this->group_id . '/c/' . $this->id; + } +} diff --git a/app/Models/GroupEvent.php b/app/Models/GroupEvent.php new file mode 100644 index 000000000..ddcd074cc --- /dev/null +++ b/app/Models/GroupEvent.php @@ -0,0 +1,11 @@ + 'array' + ]; +} diff --git a/app/Models/GroupInvitation.php b/app/Models/GroupInvitation.php new file mode 100644 index 000000000..adcd38ea4 --- /dev/null +++ b/app/Models/GroupInvitation.php @@ -0,0 +1,11 @@ + 'json', + 'metadata' => 'json' + ]; + + protected $fillable = [ + 'profile_id', + 'group_id' + ]; +} diff --git a/app/Models/GroupMedia.php b/app/Models/GroupMedia.php new file mode 100644 index 000000000..12f424151 --- /dev/null +++ b/app/Models/GroupMedia.php @@ -0,0 +1,39 @@ + + */ + protected function casts(): array + { + return [ + 'metadata' => 'json', + 'processed_at' => 'datetime', + 'thumbnail_generated' => 'datetime' + ]; + } + + public function url() + { + if($this->cdn_url) { + return $this->cdn_url; + } + return Storage::url($this->media_path); + } + + public function thumbnailUrl() + { + return $this->thumbnail_url; + } +} diff --git a/app/Models/GroupMember.php b/app/Models/GroupMember.php new file mode 100644 index 000000000..4f15e0d3e --- /dev/null +++ b/app/Models/GroupMember.php @@ -0,0 +1,16 @@ +belongsTo(Group::class); + } +} diff --git a/app/Models/GroupPost.php b/app/Models/GroupPost.php new file mode 100644 index 000000000..59693ec6b --- /dev/null +++ b/app/Models/GroupPost.php @@ -0,0 +1,57 @@ +group_id . '/' . $this->id; + } + + public function group() + { + return $this->belongsTo(Group::class); + } + + public function status() + { + return $this->belongsTo(Status::class); + } + + public function profile() + { + return $this->belongsTo(Profile::class); + } + + public function url() + { + return '/groups/' . $this->group_id . '/p/' . $this->id; + } +} diff --git a/app/Models/GroupPostHashtag.php b/app/Models/GroupPostHashtag.php new file mode 100644 index 000000000..46165dd7c --- /dev/null +++ b/app/Models/GroupPostHashtag.php @@ -0,0 +1,22 @@ + 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_KEY.$gid, $start, $stop); + } + + public static function getRankedMaxId($gid, $start = null, $limit = 10) + { + if (! $start) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY.$gid, $start, '-inf', [ + 'withscores' => true, + 'limit' => [1, $limit], + ])); + } + + public static function getRankedMinId($gid, $end = null, $limit = 10) + { + if (! $end) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY.$gid, '+inf', $end, [ + 'withscores' => true, + 'limit' => [0, $limit], + ])); + } + + public static function add($gid, $val) + { + if (self::count($gid) > self::FEED_LIMIT) { + if (config('database.redis.client') === 'phpredis') { + Redis::zpopmin(self::CACHE_KEY.$gid); + } + } + + return Redis::zadd(self::CACHE_KEY.$gid, $val, $val); + } + + public static function rem($gid, $val) + { + return Redis::zrem(self::CACHE_KEY.$gid, $val); + } + + public static function del($gid, $val) + { + return self::rem($gid, $val); + } + + public static function count($gid) + { + return Redis::zcard(self::CACHE_KEY.$gid); + } + + public static function warmCache($gid, $force = false, $limit = 100) + { + if (self::count($gid) == 0 || $force == true) { + Redis::del(self::CACHE_KEY.$gid); + $ids = GroupPost::whereGroupId($gid) + ->orderByDesc('id') + ->limit($limit) + ->pluck('id'); + foreach ($ids as $id) { + self::add($gid, $id); + } + + return 1; + } + } +} diff --git a/app/Services/GroupPostService.php b/app/Services/GroupPostService.php new file mode 100644 index 000000000..7295bda40 --- /dev/null +++ b/app/Services/GroupPostService.php @@ -0,0 +1,49 @@ +find($pid); + + if (! $gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = $gp['type']; + $res['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } +} diff --git a/app/Services/GroupService.php b/app/Services/GroupService.php new file mode 100644 index 000000000..ac1a1a1c6 --- /dev/null +++ b/app/Services/GroupService.php @@ -0,0 +1,366 @@ +withoutRelations()->whereNull('status')->find($id); + + if(!$group) { + return null; + } + + $admin = $group->profile_id ? AccountService::get($group->profile_id) : null; + + return [ + 'id' => (string) $group->id, + 'name' => $group->name, + 'description' => $group->description, + 'short_description' => str_limit(strip_tags($group->description), 120), + 'category' => self::categoryById($group->category_id), + 'local' => (bool) $group->local, + 'url' => $group->url(), + 'shorturl' => url('/g/'.HashidService::encode($group->id)), + 'membership' => $group->getMembershipType(), + 'member_count' => $group->members()->whereJoinRequest(false)->count(), + 'verified' => false, + 'self' => null, + 'admin' => $admin, + 'config' => [ + 'recommended' => (bool) $group->recommended, + 'discoverable' => (bool) $group->discoverable, + 'activitypub' => (bool) $group->activitypub, + 'is_nsfw' => (bool) $group->is_nsfw, + 'dms' => (bool) $group->dms + ], + 'metadata' => $group->metadata, + 'created_at' => $group->created_at->toAtomString(), + ]; + } + ); + + if($pid) { + $res['self'] = self::getSelf($id, $pid); + } + + return $res; + } + + public static function del($id) + { + Cache::forget('ap:groups:object:' . $id); + return Cache::forget(self::key($id)); + } + + public static function getSelf($gid, $pid) + { + return Cache::remember( + self::key('self:gid-' . $gid . ':pid-' . $pid), + 3600, + function() use($gid, $pid) { + $group = Group::find($gid); + + if(!$gid || !$pid) { + return [ + 'is_member' => false, + 'role' => null, + 'is_requested' => null + ]; + } + + return [ + 'is_member' => $group->isMember($pid), + 'role' => $group->selfRole($pid), + 'is_requested' => optional($group->members()->whereProfileId($pid)->first())->join_request ?? false + ]; + } + ); + } + + public static function delSelf($gid, $pid) + { + Cache::forget(self::key("is_member:{$gid}:{$pid}")); + return Cache::forget(self::key('self:gid-' . $gid . ':pid-' . $pid)); + } + + public static function sidToGid($gid, $pid) + { + return Cache::remember(self::key('s2gid:' . $gid . ':' . $pid), 3600, function() use($gid, $pid) { + return optional(GroupPost::whereGroupId($gid)->whereStatusId($pid)->first())->id; + }); + } + + public static function membershipsByPid($pid) + { + return Cache::remember(self::key("mbpid:{$pid}"), 3600, function() use($pid) { + return GroupMember::whereProfileId($pid)->pluck('group_id'); + }); + } + + public static function config() + { + return [ + 'enabled' => config('exp.gps') ?? false, + 'limits' => [ + 'group' => [ + 'max' => 999, + 'federation' => false, + ], + + 'user' => [ + 'create' => [ + 'new' => true, + 'max' => 10 + ], + 'join' => [ + 'max' => 10 + ], + 'invite' => [ + 'max' => 20 + ] + ] + ], + 'guest' => [ + 'public' => false + ] + ]; + } + + public static function fetchRemote($url) + { + // todo: refactor this demo + $res = Helpers::fetchFromUrl($url); + + if(!$res || !isset($res['type']) || $res['type'] != 'Group') { + return false; + } + + $group = Group::whereRemoteUrl($url)->first(); + + if($group) { + return $group; + } + + $group = new Group; + $group->remote_url = $res['url']; + $group->name = $res['name']; + $group->inbox_url = $res['inbox']; + $group->metadata = [ + 'header' => [ + 'url' => $res['icon']['image']['url'] + ] + ]; + $group->description = Purify::clean($res['summary']); + $group->local = false; + $group->save(); + + return $group->url(); + } + + public static function log( + string $groupId, + string $profileId, + string $type = null, + array $meta = null, + string $itemType = null, + string $itemId = null + ) + { + // todo: truncate (some) metadata after XX days in cron/queue + $log = new GroupInteraction; + $log->group_id = $groupId; + $log->profile_id = $profileId; + $log->type = $type; + $log->item_type = $itemType; + $log->item_id = $itemId; + $log->metadata = $meta; + $log->save(); + } + + public static function getRejoinTimeout($gid, $pid) + { + $key = self::key('rejoin-timeout:gid-' . $gid . ':pid-' . $pid); + return Cache::has($key); + } + + public static function setRejoinTimeout($gid, $pid) + { + // todo: allow group admins to manually remove timeout + $key = self::key('rejoin-timeout:gid-' . $gid . ':pid-' . $pid); + return Cache::put($key, 1, 86400); + } + + public static function getMemberInboxes($id) + { + // todo: cache this, maybe add join/leave methods to this service to handle cache invalidation + $group = (new Group)->withoutRelations()->findOrFail($id); + if(!$group->local) { + return []; + } + $members = GroupMember::whereGroupId($id)->whereLocalProfile(false)->pluck('profile_id'); + return Profile::find($members)->map(function($u) { + return $u->sharedInbox ?? $u->inbox_url; + })->toArray(); + } + + public static function getInteractionLimits($gid, $pid) + { + return Cache::remember(self::key(":il:{$gid}:{$pid}"), 3600, function() use($gid, $pid) { + $limit = GroupLimit::whereGroupId($gid)->whereProfileId($pid)->first(); + if(!$limit) { + return [ + 'limits' => [ + 'can_post' => true, + 'can_comment' => true, + 'can_like' => true + ], + 'updated_at' => null + ]; + } + + return [ + 'limits' => $limit->limits, + 'updated_at' => $limit->updated_at->format('c') + ]; + }); + } + + public static function clearInteractionLimits($gid, $pid) + { + return Cache::forget(self::key(":il:{$gid}:{$pid}")); + } + + public static function canPost($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_post']; + } else { + return true; + } + } + + public static function canComment($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_comment']; + } else { + return true; + } + } + + public static function canLike($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_like']; + } else { + return true; + } + } + + public static function categories($onlyActive = true) + { + return Cache::remember(self::key(':categories'), 2678400, function() use($onlyActive) { + return GroupCategory::when($onlyActive, function($q, $onlyActive) { + return $q->whereActive(true); + }) + ->orderBy('order') + ->pluck('name') + ->toArray(); + }); + } + + public static function categoryById($id) + { + return Cache::remember(self::key(':categorybyid:'.$id), 2678400, function() use($id) { + $category = GroupCategory::find($id); + if($category) { + return [ + 'name' => $category->name, + 'url' => url("/groups/explore/category/{$category->slug}") + ]; + } + return false; + }); + } + + public static function isMember($gid = false, $pid = false) + { + if(!$gid || !$pid) { + return false; + } + + $key = self::key("is_member:{$gid}:{$pid}"); + return Cache::remember($key, 3600, function() use($gid, $pid) { + return GroupMember::whereGroupId($gid) + ->whereProfileId($pid) + ->whereJoinRequest(false) + ->exists(); + }); + } + + public static function mutualGroups($cid = false, $pid = false, $exclude = []) + { + if(!$cid || !$pid) { + return [ + 'count' => 0, + 'groups' => [] + ]; + } + + $self = self::membershipsByPid($cid); + $user = self::membershipsByPid($pid); + + if(!$self->count() || !$user->count()) { + return [ + 'count' => 0, + 'groups' => [] + ]; + } + + $intersect = $self->intersect($user); + $count = $intersect->count(); + $groups = $intersect + ->values() + ->filter(function($id) use($exclude) { + return !in_array($id, $exclude); + }) + ->shuffle() + ->take(1) + ->map(function($id) { + return self::get($id); + }); + + return [ + 'count' => $count, + 'groups' => $groups + ]; + } +} diff --git a/app/Services/Groups/GroupAccountService.php b/app/Services/Groups/GroupAccountService.php new file mode 100644 index 000000000..2d86e4f43 --- /dev/null +++ b/app/Services/Groups/GroupAccountService.php @@ -0,0 +1,51 @@ +whereProfileId($pid)->first(); + if(!$membership) { + return []; + } + + return [ + 'joined' => $membership->created_at->format('c'), + 'role' => $membership->role, + 'local_group' => (bool) $membership->local_group, + 'local_profile' => (bool) $membership->local_profile, + ]; + }); + return $account; + } + + public static function del($gid, $pid) + { + $key = self::CACHE_KEY . $gid . ':' . $pid; + return Cache::forget($key); + } +} diff --git a/app/Services/Groups/GroupActivityPubService.php b/app/Services/Groups/GroupActivityPubService.php new file mode 100644 index 000000000..12403d4bc --- /dev/null +++ b/app/Services/Groups/GroupActivityPubService.php @@ -0,0 +1,312 @@ +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; + } +} diff --git a/app/Services/Groups/GroupCommentService.php b/app/Services/Groups/GroupCommentService.php new file mode 100644 index 000000000..52eeee533 --- /dev/null +++ b/app/Services/Groups/GroupCommentService.php @@ -0,0 +1,50 @@ +find($pid); + + if(!$gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = 'group:post:comment'; + $res['url'] = $gp->url(); + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } +} diff --git a/app/Services/Groups/GroupFeedService.php b/app/Services/Groups/GroupFeedService.php new file mode 100644 index 000000000..a2a87be1d --- /dev/null +++ b/app/Services/Groups/GroupFeedService.php @@ -0,0 +1,95 @@ + 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_KEY . $gid, $start, $stop); + } + + public static function getRankedMaxId($gid, $start = null, $limit = 10) + { + if(!$start) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $gid, $start, '-inf', [ + 'withscores' => true, + 'limit' => [1, $limit] + ])); + } + + public static function getRankedMinId($gid, $end = null, $limit = 10) + { + if(!$end) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $gid, '+inf', $end, [ + 'withscores' => true, + 'limit' => [0, $limit] + ])); + } + + public static function add($gid, $val) + { + if(self::count($gid) > self::FEED_LIMIT) { + if(config('database.redis.client') === 'phpredis') { + Redis::zpopmin(self::CACHE_KEY . $gid); + } + } + + return Redis::zadd(self::CACHE_KEY . $gid, $val, $val); + } + + public static function rem($gid, $val) + { + return Redis::zrem(self::CACHE_KEY . $gid, $val); + } + + public static function del($gid, $val) + { + return self::rem($gid, $val); + } + + public static function count($gid) + { + return Redis::zcard(self::CACHE_KEY . $gid); + } + + public static function warmCache($gid, $force = false, $limit = 100) + { + if(self::count($gid) == 0 || $force == true) { + Redis::del(self::CACHE_KEY . $gid); + $ids = GroupPost::whereGroupId($gid) + ->orderByDesc('id') + ->limit($limit) + ->pluck('id'); + foreach($ids as $id) { + self::add($gid, $id); + } + return 1; + } + } +} diff --git a/app/Services/Groups/GroupHashtagService.php b/app/Services/Groups/GroupHashtagService.php new file mode 100644 index 000000000..6553850f0 --- /dev/null +++ b/app/Services/Groups/GroupHashtagService.php @@ -0,0 +1,28 @@ + $tag->name, + 'slug' => Str::slug($tag->name), + ]; + }); + } +} diff --git a/app/Services/Groups/GroupMediaService.php b/app/Services/Groups/GroupMediaService.php new file mode 100644 index 000000000..0200e3a56 --- /dev/null +++ b/app/Services/Groups/GroupMediaService.php @@ -0,0 +1,114 @@ +orderBy('order')->get(); + if(!$media) { + return []; + } + $medias = $media->map(function($media) { + return [ + 'id' => (string) $media->id, + 'type' => 'Document', + 'url' => $media->url(), + 'preview_url' => $media->url(), + 'remote_url' => $media->url, + 'description' => $media->cw_summary, + 'blurhash' => $media->blurhash ?? 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay' + ]; + }); + return $medias->toArray(); + }); + } + + public static function getMastodon($id) + { + $media = self::get($id); + if(!$media) { + return []; + } + $medias = collect($media) + ->map(function($media) { + $mime = $media['mime'] ? explode('/', $media['mime']) : false; + unset( + $media['optimized_url'], + $media['license'], + $media['is_nsfw'], + $media['orientation'], + $media['filter_name'], + $media['filter_class'], + $media['mime'], + $media['hls_manifest'] + ); + + $media['type'] = $mime ? strtolower($mime[0]) : 'unknown'; + return $media; + }) + ->filter(function($m) { + return $m && isset($m['url']); + }) + ->values(); + + return $medias->toArray(); + } + + public static function del($statusId) + { + return Cache::forget(self::CACHE_KEY . $statusId); + } + + public static function activitypub($statusId) + { + $status = self::get($statusId); + if(!$status) { + return []; + } + + return collect($status)->map(function($s) { + $license = isset($s['license']) && $s['license']['title'] ? $s['license']['title'] : null; + return [ + 'type' => 'Document', + 'mediaType' => $s['mime'], + 'url' => $s['url'], + 'name' => $s['description'], + 'summary' => $s['description'], + 'blurhash' => $s['blurhash'], + 'license' => $license + ]; + }); + } +} diff --git a/app/Services/Groups/GroupPostService.php b/app/Services/Groups/GroupPostService.php new file mode 100644 index 000000000..a043be134 --- /dev/null +++ b/app/Services/Groups/GroupPostService.php @@ -0,0 +1,83 @@ +find($pid); + + if(!$gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = $gp['type']; + $res['url'] = $gp->url(); + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } + + public function getStatus(Request $request) + { + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $pid = optional($request->user())->profile_id ?? false; + + $group = Group::findOrFail($gid); + + if($group->is_private) { + abort_if(!$group->isMember($pid), 404); + } + + $gp = GroupPost::whereGroupId($group->id)->whereId($sid)->firstOrFail(); + + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['reply_count'] = $gp['reply_count']; + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + $status['account']['url'] = url("/groups/{$gp->group_id}/user/{$gp->profile_id}"); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + return $status; + } +} diff --git a/app/Services/Groups/GroupsLikeService.php b/app/Services/Groups/GroupsLikeService.php new file mode 100644 index 000000000..e2daa1e71 --- /dev/null +++ b/app/Services/Groups/GroupsLikeService.php @@ -0,0 +1,85 @@ + 400) { + Redis::zpopmin(self::CACHE_SET_KEY . $profileId); + } + + return Redis::zadd(self::CACHE_SET_KEY . $profileId, $statusId, $statusId); + } + + public static function setCount($id) + { + return Redis::zcard(self::CACHE_SET_KEY . $id); + } + + public static function setRem($profileId, $val) + { + return Redis::zrem(self::CACHE_SET_KEY . $profileId, $val); + } + + public static function get($profileId, $start = 0, $stop = 10) + { + if($stop > 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_SET_KEY . $profileId, $start, $stop); + } + + public static function remove($profileId, $statusId) + { + $key = self::CACHE_KEY . $profileId . ':' . $statusId; + Cache::decrement(self::CACHE_POST_KEY . $statusId); + //Cache::forget('pf:services:likes:liked_by:'.$statusId); + self::setRem($profileId, $statusId); + return Cache::put($key, false, 86400); + } + + public static function liked($profileId, $statusId) + { + $key = self::CACHE_KEY . $profileId . ':' . $statusId; + return Cache::remember($key, 900, function() use($profileId, $statusId) { + return GroupLike::whereProfileId($profileId)->whereStatusId($statusId)->exists(); + }); + } + + public static function likedBy($status) + { + $empty = [ + 'username' => null, + 'others' => false + ]; + + return $empty; + } + + public static function count($id) + { + return Cache::get(self::CACHE_POST_KEY . $id, 0); + } + +} diff --git a/app/Transformer/Api/GroupPostTransformer.php b/app/Transformer/Api/GroupPostTransformer.php new file mode 100644 index 000000000..0999b3fa4 --- /dev/null +++ b/app/Transformer/Api/GroupPostTransformer.php @@ -0,0 +1,59 @@ + (string) $status->id, + 'gid' => $status->group_id ? (string) $status->group_id : null, + 'url' => '/groups/' . $status->group_id . '/p/' . $status->id, + 'content' => $status->caption, + 'content_text' => $status->caption, + 'created_at' => str_replace('+00:00', 'Z', $status->created_at->format(DATE_RFC3339_EXTENDED)), + 'reblogs_count' => $status->reblogs_count ?? 0, + 'favourites_count' => $status->likes_count ?? 0, + 'reblogged' => null, + 'favourited' => null, + 'muted' => null, + 'sensitive' => (bool) $status->is_nsfw, + 'spoiler_text' => $status->cw_summary ?? '', + 'visibility' => $status->visibility, + 'application' => [ + 'name' => 'web', + 'website' => null + ], + 'language' => null, + 'pf_type' => $status->type, + 'reply_count' => (int) $status->reply_count ?? 0, + 'comments_disabled' => (bool) $status->comments_disabled, + 'thread' => false, + 'media_attachments' => GroupMediaService::get($status->id), + 'replies' => [], + 'parent' => [], + 'place' => null, + 'local' => (bool) !$status->remote_url, + 'account' => AccountService::get($status->profile_id, true), + 'poll' => [], + ]; + } +} diff --git a/config/groups.php b/config/groups.php new file mode 100644 index 000000000..24513e502 --- /dev/null +++ b/config/groups.php @@ -0,0 +1,13 @@ + env('GROUPS_ENABLED', false), + 'federation' => env('GROUPS_FEDERATION', true), + + 'acl' => [ + 'create_group' => [ + 'admins' => env('GROUPS_ACL_CREATE_ADMINS', true), + 'users' => env('GROUPS_ACL_CREATE_USERS', true), + ] + ] +]; diff --git a/database/migrations/2021_08_04_100435_create_group_roles_table.php b/database/migrations/2021_08_04_100435_create_group_roles_table.php new file mode 100644 index 000000000..c2b0d0ff4 --- /dev/null +++ b/database/migrations/2021_08_04_100435_create_group_roles_table.php @@ -0,0 +1,36 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->text('abilities')->nullable(); + $table->unique(['group_id', 'slug']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_roles'); + } +} diff --git a/database/migrations/2021_08_16_100034_create_group_interactions_table.php b/database/migrations/2021_08_16_100034_create_group_interactions_table.php new file mode 100644 index 000000000..adc32d1d1 --- /dev/null +++ b/database/migrations/2021_08_16_100034_create_group_interactions_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->string('type')->nullable()->index(); + $table->string('item_type')->nullable()->index(); + $table->string('item_id')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_interactions'); + } +} diff --git a/database/migrations/2021_08_17_073839_create_group_reports_table.php b/database/migrations/2021_08_17_073839_create_group_reports_table.php new file mode 100644 index 000000000..93ed00d63 --- /dev/null +++ b/database/migrations/2021_08_17_073839_create_group_reports_table.php @@ -0,0 +1,39 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->string('type')->nullable()->index(); + $table->string('item_type')->nullable()->index(); + $table->string('item_id')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->boolean('open')->default(true)->index(); + $table->unique(['group_id', 'profile_id', 'item_type', 'item_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_reports'); + } +} diff --git a/database/migrations/2021_09_26_112423_create_group_blocks_table.php b/database/migrations/2021_09_26_112423_create_group_blocks_table.php new file mode 100644 index 000000000..320fcf985 --- /dev/null +++ b/database/migrations/2021_09_26_112423_create_group_blocks_table.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('admin_id')->unsigned()->nullable(); + $table->bigInteger('profile_id')->nullable()->unsigned()->index(); + $table->bigInteger('instance_id')->nullable()->unsigned()->index(); + $table->string('name')->nullable()->index(); + $table->string('reason')->nullable(); + $table->boolean('is_user')->index(); + $table->boolean('moderated')->default(false)->index(); + $table->unique(['group_id', 'profile_id', 'instance_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_blocks'); + } +} diff --git a/database/migrations/2021_09_29_023230_create_group_limits_table.php b/database/migrations/2021_09_29_023230_create_group_limits_table.php new file mode 100644 index 000000000..67ca7bec8 --- /dev/null +++ b/database/migrations/2021_09_29_023230_create_group_limits_table.php @@ -0,0 +1,36 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->json('limits')->nullable(); + $table->json('metadata')->nullable(); + $table->unique(['group_id', 'profile_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_limits'); + } +} diff --git a/database/migrations/2021_10_01_083917_create_group_categories_table.php b/database/migrations/2021_10_01_083917_create_group_categories_table.php new file mode 100644 index 000000000..481ddf5ef --- /dev/null +++ b/database/migrations/2021_10_01_083917_create_group_categories_table.php @@ -0,0 +1,102 @@ +id(); + $table->string('name')->unique()->index(); + $table->string('slug')->unique()->index(); + $table->boolean('active')->default(true)->index(); + $table->tinyInteger('order')->unsigned()->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + + $default = [ + 'General', + 'Photography', + 'Fediverse', + 'CompSci & Programming', + 'Causes & Movements', + 'Humor', + 'Science & Tech', + 'Travel', + 'Buy & Sell', + 'Business', + 'Style', + 'Animals', + 'Sports & Fitness', + 'Education', + 'Arts', + 'Entertainment', + 'Faith & Spirituality', + 'Relationships & Identity', + 'Parenting', + 'Hobbies & Interests', + 'Food & Drink', + 'Vehicles & Commutes', + 'Civics & Community', + ]; + + for ($i=1; $i <= 23; $i++) { + $cat = new GroupCategory; + $cat->name = $default[$i - 1]; + $cat->slug = str_slug($cat->name); + $cat->active = true; + $cat->order = $i; + $cat->save(); + } + + Schema::table('groups', function (Blueprint $table) { + $table->unsignedInteger('category_id')->default(1)->index()->after('id'); + $table->unsignedInteger('member_count')->nullable(); + $table->boolean('recommended')->default(false)->index(); + $table->boolean('discoverable')->default(false)->index(); + $table->boolean('activitypub')->default(false); + $table->boolean('is_nsfw')->default(false); + $table->boolean('dms')->default(false); + $table->boolean('autospam')->default(false); + $table->boolean('verified')->default(false); + $table->timestamp('last_active_at')->nullable(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_categories'); + + Schema::table('groups', function (Blueprint $table) { + $table->dropColumn('category_id'); + $table->dropColumn('member_count'); + $table->dropColumn('recommended'); + $table->dropColumn('activitypub'); + $table->dropColumn('is_nsfw'); + $table->dropColumn('discoverable'); + $table->dropColumn('dms'); + $table->dropColumn('autospam'); + $table->dropColumn('verified'); + $table->dropColumn('last_active_at'); + $table->dropColumn('deleted_at'); + }); + } +} diff --git a/database/migrations/2021_10_09_004230_create_group_hashtags_table.php b/database/migrations/2021_10_09_004230_create_group_hashtags_table.php new file mode 100644 index 000000000..1d05dabb9 --- /dev/null +++ b/database/migrations/2021_10_09_004230_create_group_hashtags_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->string('name')->unique()->index(); + $table->string('formatted')->nullable(); + $table->boolean('recommended')->default(false); + $table->boolean('sensitive')->default(false); + $table->boolean('banned')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_hashtags'); + } +} diff --git a/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php b/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php new file mode 100644 index 000000000..08014e399 --- /dev/null +++ b/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php @@ -0,0 +1,41 @@ +bigIncrements('id'); + $table->bigInteger('hashtag_id')->unsigned()->index(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned(); + $table->bigInteger('status_id')->unsigned()->nullable(); + $table->string('status_visibility')->nullable(); + $table->boolean('nsfw')->default(false); + $table->unique(['hashtag_id', 'group_id', 'profile_id', 'status_id'], 'group_post_hashtags_gda_unique'); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + $table->foreign('profile_id')->references('id')->on('profiles')->onDelete('cascade'); + $table->foreign('hashtag_id')->references('id')->on('group_hashtags')->onDelete('cascade'); + $table->foreign('status_id')->references('id')->on('group_posts')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_post_hashtags'); + } +} diff --git a/database/migrations/2021_10_13_002033_create_group_stores_table.php b/database/migrations/2021_10_13_002033_create_group_stores_table.php new file mode 100644 index 000000000..efdf0a966 --- /dev/null +++ b/database/migrations/2021_10_13_002033_create_group_stores_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->nullable()->index(); + $table->string('store_key')->index(); + $table->json('store_value')->nullable(); + $table->json('metadata')->nullable(); + $table->unique(['group_id', 'store_key']); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_stores'); + } +} diff --git a/database/migrations/2021_10_13_002041_create_group_events_table.php b/database/migrations/2021_10_13_002041_create_group_events_table.php new file mode 100644 index 000000000..166c35cf0 --- /dev/null +++ b/database/migrations/2021_10_13_002041_create_group_events_table.php @@ -0,0 +1,44 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->nullable()->index(); + $table->bigInteger('profile_id')->unsigned()->nullable()->index(); + $table->string('name')->nullable(); + $table->string('type')->index(); + $table->json('tags')->nullable(); + $table->json('location')->nullable(); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + $table->boolean('open')->default(false)->index(); + $table->boolean('comments_open')->default(false); + $table->boolean('show_guest_list')->default(false); + $table->timestamp('start_at')->nullable(); + $table->timestamp('end_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_events'); + } +} diff --git a/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php b/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php new file mode 100644 index 000000000..13fef7240 --- /dev/null +++ b/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->bigInteger('instance_id')->nullable()->index(); + $table->bigInteger('actor_id')->nullable()->index(); + $table->string('verb')->nullable()->index(); + $table->string('id_url')->nullable()->unique()->index(); + $table->json('payload')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_activity_graphs'); + } +} diff --git a/database/migrations/2024_05_20_062706_update_group_posts_table.php b/database/migrations/2024_05_20_062706_update_group_posts_table.php new file mode 100644 index 000000000..99f272be9 --- /dev/null +++ b/database/migrations/2024_05_20_062706_update_group_posts_table.php @@ -0,0 +1,48 @@ +dropColumn('status_id'); + $table->dropColumn('reply_child_id'); + $table->dropColumn('in_reply_to_id'); + $table->dropColumn('reblog_of_id'); + $table->text('caption')->nullable(); + $table->string('visibility')->nullable(); + $table->boolean('is_nsfw')->default(false); + $table->unsignedInteger('likes_count')->default(0); + $table->text('cw_summary')->nullable(); + $table->json('media_ids')->nullable(); + $table->boolean('comments_disabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('group_posts', function (Blueprint $table) { + $table->bigInteger('status_id')->unsigned()->unique()->nullable(); + $table->bigInteger('reply_child_id')->unsigned()->nullable(); + $table->bigInteger('in_reply_to_id')->unsigned()->nullable(); + $table->bigInteger('reblog_of_id')->unsigned()->nullable(); + $table->dropColumn('caption'); + $table->dropColumn('is_nsfw'); + $table->dropColumn('visibility'); + $table->dropColumn('likes_count'); + $table->dropColumn('cw_summary'); + $table->dropColumn('media_ids'); + $table->dropColumn('comments_disabled'); + }); + } +}; diff --git a/database/migrations/2024_05_20_063638_create_group_comments_table.php b/database/migrations/2024_05_20_063638_create_group_comments_table.php new file mode 100644 index 000000000..ad49f58c8 --- /dev/null +++ b/database/migrations/2024_05_20_063638_create_group_comments_table.php @@ -0,0 +1,43 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id')->index(); + $table->unsignedBigInteger('profile_id')->nullable(); + $table->unsignedBigInteger('status_id')->nullable()->index(); + $table->unsignedBigInteger('in_reply_to_id')->nullable()->index(); + $table->string('remote_url')->nullable()->unique()->index(); + $table->text('caption')->nullable(); + $table->boolean('is_nsfw')->default(false); + $table->string('visibility')->nullable(); + $table->unsignedInteger('likes_count')->default(0); + $table->unsignedInteger('replies_count')->default(0); + $table->text('cw_summary')->nullable(); + $table->json('media_ids')->nullable(); + $table->string('status')->nullable(); + $table->string('type')->default('text')->nullable(); + $table->boolean('local')->default(false); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_comments'); + } +}; diff --git a/database/migrations/2024_05_20_073054_create_group_likes_table.php b/database/migrations/2024_05_20_073054_create_group_likes_table.php new file mode 100644 index 000000000..162ef7458 --- /dev/null +++ b/database/migrations/2024_05_20_073054_create_group_likes_table.php @@ -0,0 +1,33 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id'); + $table->unsignedBigInteger('profile_id')->index(); + $table->unsignedBigInteger('status_id')->nullable(); + $table->unsignedBigInteger('comment_id')->nullable(); + $table->boolean('local')->default(true); + $table->unique(['group_id', 'profile_id', 'status_id', 'comment_id'], 'group_likes_gpsc_unique'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_likes'); + } +}; diff --git a/database/migrations/2024_05_20_083159_create_group_media_table.php b/database/migrations/2024_05_20_083159_create_group_media_table.php new file mode 100644 index 000000000..732856097 --- /dev/null +++ b/database/migrations/2024_05_20_083159_create_group_media_table.php @@ -0,0 +1,50 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id'); + $table->unsignedBigInteger('profile_id'); + $table->unsignedBigInteger('status_id')->nullable()->index(); + $table->string('media_path')->unique(); + $table->text('thumbnail_url')->nullable(); + $table->text('cdn_url')->nullable(); + $table->text('url')->nullable(); + $table->string('mime')->nullable(); + $table->unsignedInteger('size')->nullable(); + $table->text('cw_summary')->nullable(); + $table->string('license')->nullable(); + $table->string('blurhash')->nullable(); + $table->tinyInteger('order')->unsigned()->default(1); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->boolean('local_user')->default(true); + $table->boolean('is_cached')->default(false); + $table->boolean('is_comment')->default(false)->index(); + $table->json('metadata')->nullable(); + $table->string('version')->default(1); + $table->boolean('skip_optimize')->default(false); + $table->timestamp('processed_at')->nullable(); + $table->timestamp('thumbnail_generated')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_media'); + } +};