Add groups models, controllers and services

This commit is contained in:
Daniel Supernault 2024-07-09 23:16:05 -06:00
parent dd6e3cc290
commit 3d6b9badf4
No known key found for this signature in database
GPG key ID: 23740873EE6F76A1
78 changed files with 6848 additions and 0 deletions

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Models\Group;
use App\Models\GroupCategory;
use App\Models\GroupInteraction;
use App\Models\GroupMember;
use App\Models\GroupPost;
use App\Models\GroupReport;
use Cache;
use Illuminate\Http\Request;
trait AdminGroupsController
{
public function groupsHome(Request $request)
{
$stats = $this->groupAdminStats();
return view('admin.groups.home', compact('stats'));
}
protected function groupAdminStats()
{
return Cache::remember('admin:groups:stats', 3, function () {
$res = [
'total' => Group::count(),
'local' => Group::whereLocal(true)->count(),
];
$res['remote'] = $res['total'] - $res['local'];
$res['categories'] = GroupCategory::count();
$res['posts'] = GroupPost::count();
$res['members'] = GroupMember::count();
$res['interactions'] = GroupInteraction::count();
$res['reports'] = GroupReport::count();
$res['local_30d'] = Cache::remember('admin:groups:stats:local_30d', 43200, function () {
return Group::whereLocal(true)->where('created_at', '>', now()->subMonth())->count();
});
$res['remote_30d'] = Cache::remember('admin:groups:stats:remote_30d', 43200, function () {
return Group::whereLocal(false)->where('created_at', '>', now()->subMonth())->count();
});
return $res;
});
}
}

View file

@ -0,0 +1,771 @@
<?php
namespace App\Http\Controllers;
use DB;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Models\Group;
use App\Models\GroupActivityGraph;
use App\Models\GroupBlock;
use App\Models\GroupCategory;
use App\Models\GroupComment;
use App\Models\GroupEvent;
use App\Models\GroupInteraction;
use App\Models\GroupInvitation;
use App\Models\GroupLimit;
use App\Models\GroupLike;
use App\Models\GroupMember;
use App\Models\GroupPost;
use App\Models\GroupPostHashtag;
use App\Models\GroupReport;
use App\Models\GroupRole;
use App\Models\GroupStore;
use App\Models\Poll;
use App\Follower;
use App\Instance;
use App\Hashtag;
use App\StatusHashtag;
use App\Like;
use App\Media;
use App\Notification;
use App\Profile;
use App\Status;
use App\User;
use App\Util\Lexer\Autolink;
use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\HashidService;
use App\Services\LikeService;
use App\Services\Groups\GroupCommentService;
use App\Services\Groups\GroupsLikeService;
use App\Services\HashtagService;
use App\Services\GroupService;
use App\Services\GroupFeedService;
use App\Services\GroupPostService;
use App\Services\PollService;
use App\Services\RelationshipService;
use App\Services\StatusService;
use App\Services\UserFilterService;
use Cache;
use Storage;
use Purify;
use App\Jobs\GroupPipeline\LikePipeline;
use App\Jobs\GroupPipeline\UnlikePipeline;
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Jobs\VideoPipeline\VideoThumbnail;
use App\Jobs\StatusPipeline\StatusDelete;
use App\Jobs\GroupPipeline\GroupCommentPipeline;
use App\Jobs\GroupPipeline\GroupMemberInvite;
use App\Jobs\GroupPipeline\NewStatusPipeline;
use App\Jobs\GroupPipeline\JoinApproved;
use App\Jobs\GroupPipeline\JoinRejected;
use Illuminate\Support\Facades\RateLimiter;
class GroupController extends GroupFederationController
{
public function __construct()
{
// $this->middleware('auth');
}
public function index(Request $request)
{
abort_if(!$request->user(), 404);
return view('layouts.spa');
}
public function home(Request $request)
{
abort_if(!$request->user(), 404);
return view('layouts.spa');
}
public function show(Request $request, $id, $path = false)
{
$group = Group::find($id);
if(!$group || $group->status) {
return response()->view('groups.unavailable')->setStatusCode(404);
}
if($request->wantsJson()) {
return $this->showGroupObject($group);
}
return view('layouts.spa', compact('id', 'path'));
}
public function showStatus(Request $request, $gid, $sid)
{
$group = Group::find($gid);
$pid = optional($request->user())->profile_id ?? false;
if(!$group || $group->status) {
return response()->view('groups.unavailable')->setStatusCode(404);
}
if($group->is_private) {
abort_if(!$request->user(), 404);
abort_if(!$group->isMember($pid), 404);
}
$gp = GroupPost::whereGroupId($gid)
->findOrFail($sid);
return view('layouts.spa', compact('group', 'gp'));
}
public function getGroup(Request $request, $id)
{
$group = Group::whereNull('status')->findOrFail($id);
$pid = optional($request->user())->profile_id ?? false;
$group = $this->toJson($group, $pid);
return response()->json($group, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function showStatusLikes(Request $request, $id, $sid)
{
$group = Group::findOrFail($id);
$user = $request->user();
$pid = $user->profile_id;
abort_if(!$group->isMember($pid), 404);
$status = GroupPost::whereGroupId($id)->findOrFail($sid);
$likes = GroupLike::whereStatusId($sid)
->cursorPaginate(10)
->map(function($l) use($group) {
$account = AccountService::get($l->profile_id);
$account['url'] = "/groups/{$group->id}/user/{$account['id']}";
return $account;
})
->filter(function($l) {
return $l && isset($l['id']);
})
->values();
return $likes;
}
public function groupSettings(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
return view('groups.settings', compact('group'));
}
public function joinGroup(Request $request, $id)
{
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if($group->isMember($pid), 404);
if(!$request->user()->is_admin) {
abort_if(GroupService::getRejoinTimeout($group->id, $pid), 422, 'Cannot re-join this group for 24 hours after leaving or cancelling a request to join');
}
$member = new GroupMember;
$member->group_id = $group->id;
$member->profile_id = $pid;
$member->role = 'member';
$member->local_group = true;
$member->local_profile = true;
$member->join_request = $group->is_private;
$member->save();
GroupService::delSelf($group->id, $pid);
GroupService::log(
$group->id,
$pid,
'group:joined',
null,
GroupMember::class,
$member->id
);
$group = $this->toJson($group, $pid);
return $group;
}
public function updateGroup(Request $request, $id)
{
$this->validate($request, [
'description' => 'nullable|max:500',
'membership' => 'required|in:all,local,private',
'avatar' => 'nullable',
'header' => 'nullable',
'discoverable' => 'required',
'activitypub' => 'required',
'is_nsfw' => 'required',
'category' => 'required|string|in:' . implode(',',GroupService::categories())
]);
$pid = $request->user()->profile_id;
$group = Group::whereProfileId($pid)->findOrFail($id);
$member = GroupMember::whereGroupId($group->id)->whereProfileId($pid)->firstOrFail();
abort_if($member->role != 'founder', 403, 'Invalid group permission');
$metadata = $group->metadata;
$len = $group->is_private ? 12 : 4;
if($request->hasFile('avatar')) {
$avatar = $request->file('avatar');
if($avatar) {
if( isset($metadata['avatar']) &&
isset($metadata['avatar']['path']) &&
Storage::exists($metadata['avatar']['path'])
) {
Storage::delete($metadata['avatar']['path']);
}
$fileName = 'avatar_' . strtolower(str_random($len)) . '.' . $avatar->extension();
$path = $avatar->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName);
$url = url(Storage::url($path));
$metadata['avatar'] = [
'path' => $path,
'url' => $url,
'updated_at' => now()
];
}
}
if($request->hasFile('header')) {
$header = $request->file('header');
if($header) {
if( isset($metadata['header']) &&
isset($metadata['header']['path']) &&
Storage::exists($metadata['header']['path'])
) {
Storage::delete($metadata['header']['path']);
}
$fileName = 'header_' . strtolower(str_random($len)) . '.' . $header->extension();
$path = $header->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName);
$url = url(Storage::url($path));
$metadata['header'] = [
'path' => $path,
'url' => $url,
'updated_at' => now()
];
}
}
$cat = GroupService::categoryById($group->category_id);
if($request->category !== $cat['name']) {
$group->category_id = GroupCategory::whereName($request->category)->first()->id;
}
$changes = null;
$group->description = e($request->input('description', null));
$group->is_private = $request->input('membership') == 'private';
$group->local_only = $request->input('membership') == 'local';
$group->activitypub = $request->input('activitypub') == "true";
$group->discoverable = $request->input('discoverable') == "true";
$group->is_nsfw = $request->input('is_nsfw') == "true";
$group->metadata = $metadata;
if($group->isDirty()) {
$changes = $group->getDirty();
}
$group->save();
GroupService::log(
$group->id,
$pid,
'group:settings:updated',
$changes
);
GroupService::del($group->id);
$res = $this->toJson($group, $pid);
return $res;
}
protected function toJson($group, $pid = false)
{
return GroupService::get($group->id, $pid);
}
// public function likePost(Request $request)
// {
// $this->validate($request, [
// 'gid' => 'required|exists:groups,id',
// 'sid' => 'required|exists:group_posts,id'
// ]);
// $pid = $request->user()->profile_id;
// $gid = $request->input('gid');
// $sid = $request->input('sid');
// $group = Group::findOrFail($gid);
// abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
// abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
// $gp = GroupPost::whereGroupId($group->id)->findOrFail($sid);
// $action = false;
// if (GroupLike::whereGroupId($gid)->whereStatusId($sid)->whereProfileId($pid)->exists()) {
// $like = GroupLike::whereProfileId($pid)->whereStatusId($sid)->firstOrFail();
// // UnlikePipeline::dispatch($like);
// $count = $gp->likes_count - 1;
// $action = 'group:unlike';
// } else {
// $count = $gp->likes_count;
// $like = GroupLike::firstOrCreate([
// 'group_id' => $gid,
// 'profile_id' => $pid,
// 'status_id' => $sid
// ]);
// if($like->wasRecentlyCreated == true) {
// $count++;
// $gp->likes_count = $count;
// $like->save();
// $gp->save();
// // LikePipeline::dispatch($like);
// $action = 'group:like';
// }
// }
// if($action) {
// GroupService::log(
// $group->id,
// $pid,
// $action,
// [
// 'type' => $gp->type,
// 'status_id' => $gp->id
// ],
// GroupPost::class,
// $gp->id
// );
// }
// // Cache::forget('status:'.$status->id.':likedby:userid:'.$request->user()->id);
// // StatusService::del($status->id);
// $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];
// return $response;
// }
public function groupLeave(Request $request, $id)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created');
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete();
GroupService::del($group->id);
GroupService::delSelf($group->id, $pid);
GroupService::setRejoinTimeout($group->id, $pid);
return [200];
}
public function cancelJoinRequest(Request $request, $id)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created');
abort_if($group->isMember($pid), 422, 'Cannot cancel approved join request, please leave group instead.');
GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete();
GroupService::del($group->id);
GroupService::delSelf($group->id, $pid);
GroupService::setRejoinTimeout($group->id, $pid);
return [200];
}
public function metaBlockSearch(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$type = $request->input('type');
$item = $request->input('item');
switch($type) {
case 'instance':
$res = Instance::whereDomain($item)->first();
if($res) {
abort_if(GroupBlock::whereGroupId($group->id)->whereInstanceId($res->id)->exists(), 400);
}
break;
case 'user':
$res = Profile::whereUsername($item)->first();
if($res) {
abort_if(GroupBlock::whereGroupId($group->id)->whereProfileId($res->id)->exists(), 400);
}
if($res->user_id != null) {
abort_if(User::whereIsAdmin(true)->whereId($res->user_id)->exists(), 400);
}
break;
}
return response()->json((bool) $res, ($res ? 200 : 404));
}
public function reportCreate(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
$id = $request->input('id');
$type = $request->input('type');
$types = [
// original 3
'spam',
'sensitive',
'abusive',
// new
'underage',
'violence',
'copyright',
'impersonation',
'scam',
'terrorism'
];
$gp = GroupPost::whereGroupId($group->id)->find($id);
abort_if(!$gp, 422, 'Cannot report an invalid or deleted post');
abort_if(!in_array($type, $types), 422, 'Invalid report type');
abort_if($gp->profile_id === $pid, 422, 'Cannot report your own post');
abort_if(
GroupReport::whereGroupId($group->id)
->whereProfileId($pid)
->whereItemType(GroupPost::class)
->whereItemId($id)
->exists(),
422,
'You already reported this'
);
$report = new GroupReport();
$report->group_id = $group->id;
$report->profile_id = $pid;
$report->type = $type;
$report->item_type = GroupPost::class;
$report->item_id = $id;
$report->open = true;
$report->save();
GroupService::log(
$group->id,
$pid,
'group:report:create',
[
'type' => $type,
'report_id' => $report->id,
'status_id' => $gp->status_id,
'profile_id' => $gp->profile_id,
'username' => optional(AccountService::get($gp->profile_id))['acct'],
'gpid' => $gp->id,
'url' => $gp->url()
],
GroupReport::class,
$report->id
);
return response([200]);
}
public function reportAction(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$this->validate($request, [
'action' => 'required|in:cw,delete,ignore',
'id' => 'required|string'
]);
$action = $request->input('action');
$id = $request->input('id');
$report = GroupReport::whereGroupId($group->id)
->findOrFail($id);
$status = Status::findOrFail($report->item_id);
$gp = GroupPost::whereGroupId($group->id)
->whereStatusId($status->id)
->firstOrFail();
switch ($action) {
case 'cw':
$status->is_nsfw = true;
$status->save();
StatusService::del($status->id);
GroupReport::whereGroupId($group->id)
->whereItemType($report->item_type)
->whereItemId($report->item_id)
->update(['open' => false]);
GroupService::log(
$group->id,
$pid,
'group:moderation:action',
[
'type' => 'cw',
'report_id' => $report->id,
'status_id' => $status->id,
'profile_id' => $status->profile_id,
'status_url' => $gp->url()
],
GroupReport::class,
$report->id
);
return response()->json([200]);
break;
case 'ignore':
GroupReport::whereGroupId($group->id)
->whereItemType($report->item_type)
->whereItemId($report->item_id)
->update(['open' => false]);
GroupService::log(
$group->id,
$pid,
'group:moderation:action',
[
'type' => 'ignore',
'report_id' => $report->id,
'status_id' => $status->id,
'profile_id' => $status->profile_id,
'status_url' => $gp->url()
],
GroupReport::class,
$report->id
);
return response()->json([200]);
break;
}
}
public function getMemberInteractionLimits(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$profile_id = $request->input('profile_id');
abort_if(!$group->isMember($profile_id), 404);
$limits = GroupService::getInteractionLimits($group->id, $profile_id);
return response()->json($limits);
}
public function updateMemberInteractionLimits(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$this->validate($request, [
'profile_id' => 'required|exists:profiles,id',
'can_post' => 'required',
'can_comment' => 'required',
'can_like' => 'required'
]);
$member = $request->input('profile_id');
$can_post = $request->input('can_post');
$can_comment = $request->input('can_comment');
$can_like = $request->input('can_like');
$account = AccountService::get($member);
abort_if(!$account, 422, 'Invalid profile');
abort_if(!$group->isMember($member), 422, 'Invalid profile');
$limit = GroupLimit::firstOrCreate([
'profile_id' => $member,
'group_id' => $group->id
]);
if($limit->wasRecentlyCreated) {
abort_if(GroupLimit::whereGroupId($group->id)->count() >= 25, 422, 'limit_reached');
}
$previousLimits = $limit->limits;
$limit->limits = [
'can_post' => $can_post,
'can_comment' => $can_comment,
'can_like' => $can_like
];
$limit->save();
GroupService::clearInteractionLimits($group->id, $member);
GroupService::log(
$group->id,
$pid,
'group:member-limits:updated',
[
'profile_id' => $account['id'],
'username' => $account['username'],
'previousLimits' => $previousLimits,
'newLimits' => $limit->limits
],
GroupLimit::class,
$limit->id
);
return $request->all();
}
public function showProfile(Request $request, $id, $pid)
{
$group = Group::find($id);
if(!$group || $group->status) {
return response()->view('groups.unavailable')->setStatusCode(404);
}
// $gm = GroupMember::whereGroupId($id)
// ->whereProfileId($pid)
// ->firstOrFail();
// $group = json_encode(GroupService::get($id));
// $profile = AccountService::get($pid);
// $profile['group'] = [
// 'joined' => $gm->created_at->format('M d, Y'),
// 'role' => $gm->role
// ];
// $profile['relationship'] = RelationshipService::get($cid, $pid);
// $profile = json_encode($profile);
return view('layouts.spa');
}
public function showProfileByUsername(Request $request, $id, $pid)
{
// abort_if(!$request->user(), 404);
if(!$request->user()) {
return redirect("/{$pid}");
}
$group = Group::find($id);
$cid = $request->user()->profile_id;
if(!$group || $group->status) {
return response()->view('groups.unavailable')->setStatusCode(404);
}
if(!$group->isMember($cid)) {
return redirect("/{$pid}");
}
$profile = Profile::whereUsername($pid)->first();
if(!$group->isMember($profile->id)) {
return redirect("/{$pid}");
}
if($profile) {
$url = url("/groups/{$id}/user/{$profile->id}");
return redirect($url);
}
abort(404, 'Invalid username');
}
public function groupInviteLanding(Request $request, $id)
{
abort(404, 'Not yet implemented');
$group = Group::findOrFail($id);
return view('groups.invite', compact('group'));
}
public function groupShortLinkRedirect(Request $request, $hid)
{
$gid = HashidService::decode($hid);
$group = Group::findOrFail($gid);
return redirect($group->url());
}
public function groupInviteClaim(Request $request, $id)
{
$group = GroupService::get($id);
abort_if(!$group || empty($group), 404);
return view('groups.invite-claim', compact('group'));
}
public function groupMemberInviteCheck(Request $request, $id)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($group->isMember($pid), 422, 'Already a member');
$exists = GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists();
return response()->json([
'gid' => $id,
'can_join' => (bool) $exists
]);
}
public function groupMemberInviteAccept(Request $request, $id)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($group->isMember($pid), 422, 'Already a member');
abort_if(!GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists(), 422);
$gm = new GroupMember;
$gm->group_id = $id;
$gm->profile_id = $pid;
$gm->role = 'member';
$gm->local_group = $group->local;
$gm->local_profile = true;
$gm->join_request = false;
$gm->save();
GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->delete();
GroupService::del($id);
GroupService::delSelf($id, $pid);
return ['next_url' => $group->url()];
}
public function groupMemberInviteDecline(Request $request, $id)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($group->isMember($pid), 422, 'Already a member');
return ['next_url' => '/'];
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use App\Models\Group;
use App\Models\GroupPost;
use App\Status;
use App\Models\InstanceActor;
use App\Services\MediaService;
class GroupFederationController extends Controller
{
public function getGroupObject(Request $request, $id)
{
$group = Group::whereLocal(true)->whereActivitypub(true)->findOrFail($id);
$res = $this->showGroupObject($group);
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function showGroupObject($group)
{
return Cache::remember('ap:groups:object:' . $group->id, 3600, function() use($group) {
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $group->url(),
'inbox' => $group->permalink('/inbox'),
'name' => $group->name,
'outbox' => $group->permalink('/outbox'),
'summary' => $group->description,
'type' => 'Group',
'attributedTo' => [
'type' => 'Person',
'id' => $group->admin->permalink()
],
// 'endpoints' => [
// 'sharedInbox' => config('app.url') . '/f/inbox'
// ],
'preferredUsername' => 'gid_' . $group->id,
'publicKey' => [
'id' => $group->permalink('#main-key'),
'owner' => $group->permalink(),
'publicKeyPem' => InstanceActor::first()->public_key,
],
'url' => $group->permalink()
];
if($group->metadata && isset($group->metadata['avatar'])) {
$res['icon'] = [
'type' => 'Image',
'url' => $group->metadata['avatar']['url']
];
}
if($group->metadata && isset($group->metadata['header'])) {
$res['image'] = [
'type' => 'Image',
'url' => $group->metadata['header']['url']
];
}
ksort($res);
return $res;
});
}
public function getStatusObject(Request $request, $gid, $sid)
{
$group = Group::whereLocal(true)->whereActivitypub(true)->findOrFail($gid);
$gp = GroupPost::whereGroupId($gid)->findOrFail($sid);
$status = Status::findOrFail($gp->status_id);
// permission check
$res = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $gp->url(),
'type' => 'Note',
'summary' => null,
'content' => $status->rendered ?? $status->caption,
'inReplyTo' => null,
'published' => $status->created_at->toAtomString(),
'url' => $gp->url(),
'attributedTo' => $status->profile->permalink(),
'to' => [
'https://www.w3.org/ns/activitystreams#Public',
$group->permalink('/followers'),
],
'cc' => [],
'sensitive' => (bool) $status->is_nsfw,
'attachment' => MediaService::activitypub($status->id),
'target' => [
'type' => 'Collection',
'id' => $group->permalink('/wall'),
'attributedTo' => $group->permalink()
]
];
// ksort($res);
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
}

View file

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

View file

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Groups;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\GroupService;
use App\Models\Group;
use App\Models\GroupMember;
class CreateGroupsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function checkCreatePermission(Request $request)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$config = GroupService::config();
if($request->user()->is_admin) {
$allowed = true;
} else {
$max = $config['limits']['user']['create']['max'];
$allowed = Group::whereProfileId($pid)->count() <= $max;
}
return ['permission' => (bool) $allowed];
}
public function storeGroup(Request $request)
{
abort_if(!$request->user(), 404);
$this->validate($request, [
'name' => 'required',
'description' => 'nullable|max:500',
'membership' => 'required|in:public,private,local'
]);
$pid = $request->user()->profile_id;
$config = GroupService::config();
abort_if($config['limits']['user']['create']['new'] == false && $request->user()->is_admin == false, 422, 'Invalid operation');
$max = $config['limits']['user']['create']['max'];
// abort_if(Group::whereProfileId($pid)->count() <= $max, 422, 'Group limit reached');
$group = new Group;
$group->profile_id = $pid;
$group->name = $request->input('name');
$group->description = $request->input('description', null);
$group->is_private = $request->input('membership') == 'private';
$group->local_only = $request->input('membership') == 'local';
$group->metadata = $request->input('configuration');
$group->save();
GroupService::log($group->id, $pid, 'group:created');
$member = new GroupMember;
$member->group_id = $group->id;
$member->profile_id = $pid;
$member->role = 'founder';
$member->local_group = true;
$member->local_profile = true;
$member->save();
GroupService::log(
$group->id,
$pid,
'group:joined',
null,
GroupMember::class,
$member->id
);
return [
'id' => $group->id,
'url' => $group->url()
];
}
}

View file

@ -0,0 +1,353 @@
<?php
namespace App\Http\Controllers\Groups;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\GroupService;
use App\Instance;
use App\Profile;
use App\Models\Group;
use App\Models\GroupBlock;
use App\Models\GroupCategory;
use App\Models\GroupInteraction;
use App\Models\GroupPost;
use App\Models\GroupMember;
use App\Models\GroupReport;
use App\Services\Groups\GroupAccountService;
use App\Services\Groups\GroupPostService;
class GroupsAdminController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function getAdminTabs(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
abort_if($pid !== $group->profile_id, 404);
$reqs = GroupMember::whereGroupId($group->id)->whereJoinRequest(true)->count();
$mods = GroupReport::whereGroupId($group->id)->whereOpen(true)->count();
$tabs = [
'moderation_count' => $mods > 99 ? '99+' : $mods,
'request_count' => $reqs > 99 ? '99+' : $reqs
];
return response()->json($tabs);
}
public function getInteractionLogs(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$logs = GroupInteraction::whereGroupId($id)
->latest()
->paginate(10)
->map(function($log) use($group) {
return [
'id' => $log->id,
'profile' => GroupAccountService::get($group->id, $log->profile_id),
'type' => $log->type,
'metadata' => $log->metadata,
'created_at' => $log->created_at->format('c')
];
});
return response()->json($logs, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function getBlocks(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$blocks = [
'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->take(3)->pluck('name'),
'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->take(3)->pluck('name'),
'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->take(3)->pluck('name')
];
return response()->json($blocks, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function exportBlocks(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$blocks = [
'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->pluck('name'),
'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->pluck('name'),
'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->pluck('name')
];
$blocks['_created_at'] = now()->format('c');
$blocks['_version'] = '1.0.0';
ksort($blocks);
return response()->streamDownload(function() use($blocks) {
echo json_encode($blocks, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
});
}
public function addBlock(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$this->validate($request, [
'item' => 'required',
'type' => 'required|in:instance,user,moderate'
]);
$item = $request->input('item');
$type = $request->input('type');
switch($type) {
case 'instance':
$instance = Instance::whereDomain($item)->first();
abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid');
$gb = new GroupBlock;
$gb->group_id = $group->id;
$gb->admin_id = $pid;
$gb->instance_id = $instance->id;
$gb->name = $instance->domain;
$gb->is_user = false;
$gb->moderated = false;
$gb->save();
GroupService::log(
$group->id,
$pid,
'group:admin:block:instance',
[
'domain' => $instance->domain
],
GroupBlock::class,
$gb->id
);
return [200];
break;
case 'user':
$profile = Profile::whereUsername($item)->first();
abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid');
$gb = new GroupBlock;
$gb->group_id = $group->id;
$gb->admin_id = $pid;
$gb->profile_id = $profile->id;
$gb->name = $profile->username;
$gb->is_user = true;
$gb->moderated = false;
$gb->save();
GroupService::log(
$group->id,
$pid,
'group:admin:block:user',
[
'username' => $profile->username,
'domain' => $profile->domain
],
GroupBlock::class,
$gb->id
);
return [200];
break;
case 'moderate':
$instance = Instance::whereDomain($item)->first();
abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid');
$gb = new GroupBlock;
$gb->group_id = $group->id;
$gb->admin_id = $pid;
$gb->instance_id = $instance->id;
$gb->name = $instance->domain;
$gb->is_user = false;
$gb->moderated = true;
$gb->save();
GroupService::log(
$group->id,
$pid,
'group:admin:moderate:instance',
[
'domain' => $instance->domain
],
GroupBlock::class,
$gb->id
);
return [200];
break;
default:
return response()->json([], 422, []);
break;
}
}
public function undoBlock(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$this->validate($request, [
'item' => 'required',
'type' => 'required|in:instance,user,moderate'
]);
$item = $request->input('item');
$type = $request->input('type');
switch($type) {
case 'instance':
$instance = Instance::whereDomain($item)->first();
abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid');
$gb = GroupBlock::whereGroupId($group->id)
->whereInstanceId($instance->id)
->whereModerated(false)
->first();
abort_if(!$gb, 422, 'Invalid group block');
GroupService::log(
$group->id,
$pid,
'group:admin:unblock:instance',
[
'domain' => $instance->domain
],
GroupBlock::class,
$gb->id
);
$gb->delete();
return [200];
break;
case 'user':
$profile = Profile::whereUsername($item)->first();
abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid');
$gb = GroupBlock::whereGroupId($group->id)
->whereProfileId($profile->id)
->whereIsUser(true)
->first();
abort_if(!$gb, 422, 'Invalid group block');
GroupService::log(
$group->id,
$pid,
'group:admin:unblock:user',
[
'username' => $profile->username,
'domain' => $profile->domain
],
GroupBlock::class,
$gb->id
);
$gb->delete();
return [200];
break;
case 'moderate':
$instance = Instance::whereDomain($item)->first();
abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid');
$gb = GroupBlock::whereGroupId($group->id)
->whereInstanceId($instance->id)
->whereModerated(true)
->first();
abort_if(!$gb, 422, 'Invalid group block');
GroupService::log(
$group->id,
$pid,
'group:admin:moderate:instance',
[
'domain' => $instance->domain
],
GroupBlock::class,
$gb->id
);
$gb->delete();
return [200];
break;
default:
return response()->json([], 422, []);
break;
}
}
public function getReportList(Request $request, $id)
{
abort_if(!$request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$scope = $request->input('scope', 'open');
$list = GroupReport::selectRaw('id, profile_id, item_type, item_id, type, created_at, count(*) as total')
->whereGroupId($group->id)
->groupBy('item_id')
->when($scope == 'open', function($query, $scope) {
return $query->whereOpen(true);
})
->latest()
->simplePaginate(10)
->map(function($report) use($group) {
$res = [
'id' => (string) $report->id,
'profile' => GroupAccountService::get($group->id, $report->profile_id),
'type' => $report->type,
'created_at' => $report->created_at->format('c'),
'total_count' => $report->total
];
if($report->item_type === GroupPost::class) {
$res['status'] = GroupPostService::get($group->id, $report->item_id);
}
return $res;
});
return response()->json($list, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Groups;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\GroupService;
use App\Models\Group;
use App\Models\GroupCategory;
use App\Models\GroupMember;
use App\Services\Groups\GroupAccountService;
class GroupsApiController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
protected function toJson($group, $pid = false)
{
return GroupService::get($group->id, $pid);
}
public function getConfig(Request $request)
{
return GroupService::config();
}
public function getGroupAccount(Request $request, $gid, $pid)
{
$res = GroupAccountService::get($gid, $pid);
return response()->json($res);
}
public function getGroupCategories(Request $request)
{
$res = GroupService::categories();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function getGroupsByCategory(Request $request)
{
$name = $request->input('name');
$category = GroupCategory::whereName($name)->firstOrFail();
$groups = Group::whereCategoryId($category->id)
->simplePaginate(6)
->map(function($group) {
return GroupService::get($group->id);
})
->filter(function($group) {
return $group;
})
->values();
return $groups;
}
public function getRecommendedGroups(Request $request)
{
return [];
}
public function getSelfGroups(Request $request)
{
$selfOnly = $request->input('self') == true;
$memberOnly = $request->input('member') == true;
$pid = $request->user()->profile_id;
$res = GroupMember::whereProfileId($request->user()->profile_id)
->when($selfOnly, function($q, $selfOnly) {
return $q->whereRole('founder');
})
->when($memberOnly, function($q, $memberOnly) {
return $q->whereRole('member');
})
->simplePaginate(4)
->map(function($member) use($pid) {
$group = $member->group;
return $this->toJson($group, $pid);
});
return response()->json($res);
}
}

View file

@ -0,0 +1,361 @@
<?php
namespace App\Http\Controllers\Groups;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\AccountService;
use App\Services\GroupService;
use App\Services\Groups\GroupCommentService;
use App\Services\Groups\GroupMediaService;
use App\Services\Groups\GroupPostService;
use App\Services\Groups\GroupsLikeService;
use App\Models\Group;
use App\Models\GroupLike;
use App\Models\GroupMedia;
use App\Models\GroupPost;
use App\Models\GroupComment;
use Purify;
use App\Util\Lexer\Autolink;
use App\Jobs\GroupsPipeline\ImageResizePipeline;
use App\Jobs\GroupsPipeline\ImageS3UploadPipeline;
use App\Jobs\GroupsPipeline\NewPostPipeline;
use App\Jobs\GroupsPipeline\NewCommentPipeline;
use App\Jobs\GroupsPipeline\DeleteCommentPipeline;
class GroupsCommentController extends Controller
{
public function getComments(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'sid' => 'required',
'cid' => 'sometimes',
'limit' => 'nullable|integer|min:3|max:10'
]);
$pid = optional($request->user())->profile_id;
$gid = $request->input('gid');
$sid = $request->input('sid');
$cid = $request->has('cid') && $request->input('cid') == 1;
$limit = $request->input('limit', 3);
$maxId = $request->input('max_id', 0);
$group = Group::findOrFail($gid);
abort_if($group->is_private && !$group->isMember($pid), 403, 'Not a member of group.');
$status = $cid ? GroupComment::findOrFail($sid) : GroupPost::findOrFail($sid);
abort_if($status->group_id != $group->id, 400, 'Invalid group');
$replies = GroupComment::whereGroupId($group->id)
->whereStatusId($status->id)
->orderByDesc('id')
->when($maxId, function($query, $maxId) {
return $query->where('id', '<', $maxId);
})
->take($limit)
->get()
->map(function($gp) use($pid) {
$status = GroupCommentService::get($gp['group_id'], $gp['id']);
$status['reply_count'] = $gp['reply_count'];
$status['url'] = $gp->url();
$status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$gp['profile_id']}");
return $status;
});
return $replies->toArray();
}
public function storeComment(Request $request)
{
$this->validate($request, [
'gid' => 'required|exists:groups,id',
'sid' => 'required|exists:group_posts,id',
'cid' => 'sometimes',
'content' => 'required|string|min:1|max:1500'
]);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$sid = $request->input('sid');
$cid = $request->input('cid');
$limit = $request->input('limit', 3);
$caption = e($request->input('content'));
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time');
$parent = $cid == 1 ?
GroupComment::findOrFail($sid) :
GroupPost::whereGroupId($gid)->findOrFail($sid);
// $autolink = Purify::clean(Autolink::create()->autolink($caption));
// $autolink = str_replace('/discover/tags/', '/groups/' . $gid . '/topics/', $autolink);
$status = new GroupComment;
$status->group_id = $group->id;
$status->profile_id = $pid;
$status->status_id = $parent->id;
$status->caption = Purify::clean($caption);
$status->visibility = 'public';
$status->is_nsfw = false;
$status->local = true;
$status->save();
NewCommentPipeline::dispatch($parent, $status)->onQueue('groups');
// todo: perform in job
$parent->reply_count = $parent->reply_count ? $parent->reply_count + $parent->reply_count : 1;
$parent->save();
GroupPostService::del($parent->group_id, $parent->id);
GroupService::log(
$group->id,
$pid,
'group:comment:created',
[
'type' => 'group:post:comment',
'status_id' => $status->id
],
GroupPost::class,
$status->id
);
//GroupCommentPipeline::dispatch($parent, $status, $gp);
//NewStatusPipeline::dispatch($status, $gp);
//GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id));
// todo: perform in job
$s = GroupCommentService::get($status->group_id, $status->id);
$s['pf_type'] = 'text';
$s['visibility'] = 'public';
$s['url'] = $status->url();
return $s;
}
public function storeCommentPhoto(Request $request)
{
$this->validate($request, [
'gid' => 'required|exists:groups,id',
'sid' => 'required|exists:group_posts,id',
'photo' => 'required|image'
]);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$sid = $request->input('sid');
$limit = $request->input('limit', 3);
$caption = $request->input('content');
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time');
$parent = GroupPost::whereGroupId($gid)->findOrFail($sid);
$status = new GroupComment;
$status->status_id = $parent->id;
$status->group_id = $group->id;
$status->profile_id = $pid;
$status->caption = Purify::clean($caption);
$status->visibility = 'draft';
$status->is_nsfw = false;
$status->save();
$photo = $request->file('photo');
$storagePath = GroupMediaService::path($group->id, $pid, $status->id);
$storagePath = 'public/g/' . $group->id . '/p/' . $parent->id;
$path = $photo->storePublicly($storagePath);
$media = new GroupMedia();
$media->group_id = $group->id;
$media->status_id = $status->id;
$media->profile_id = $request->user()->profile_id;
$media->media_path = $path;
$media->size = $photo->getSize();
$media->mime = $photo->getMimeType();
$media->save();
ImageResizePipeline::dispatchSync($media);
ImageS3UploadPipeline::dispatchSync($media);
// $gp = new GroupPost;
// $gp->group_id = $group->id;
// $gp->profile_id = $pid;
// $gp->type = 'reply:photo';
// $gp->status_id = $status->id;
// $gp->in_reply_to_id = $parent->id;
// $gp->save();
// GroupService::log(
// $group->id,
// $pid,
// 'group:comment:created',
// [
// 'type' => $gp->type,
// 'status_id' => $status->id
// ],
// GroupPost::class,
// $gp->id
// );
// todo: perform in job
// $parent->reply_count = Status::whereInReplyToId($parent->id)->count();
// $parent->save();
// StatusService::del($parent->id);
// GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id));
// delay response while background job optimizes media
// sleep(5);
// todo: perform in job
$s = GroupCommentService::get($status->group_id, $status->id);
// $s['pf_type'] = 'text';
// $s['visibility'] = 'public';
// $s['url'] = $gp->url();
return $s;
}
public function deleteComment(Request $request)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'id' => 'required|integer|min:1',
'gid' => 'required|integer|min:1'
]);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
$gp = GroupComment::whereGroupId($group->id)->findOrFail($request->input('id'));
abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403);
$parent = GroupPost::find($gp->status_id);
abort_if(!$parent, 422, 'Invalid parent');
DeleteCommentPipeline::dispatch($parent, $gp)->onQueue('groups');
GroupService::log(
$group->id,
$pid,
'group:status:deleted',
[
'type' => $gp->type,
'status_id' => $gp->id,
],
GroupComment::class,
$gp->id
);
$gp->delete();
if($request->wantsJson()) {
return response()->json(['Status successfully deleted.']);
} else {
return redirect('/groups/feed');
}
}
public function likePost(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'sid' => 'required'
]);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$sid = $request->input('sid');
$group = GroupService::get($gid);
abort_if(!$group || $gid != $group['id'], 422, 'Invalid group');
abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group');
$gp = GroupCommentService::get($gid, $sid);
abort_if(!$gp, 422, 'Invalid status');
$count = $gp['favourites_count'] ?? 0;
$like = GroupLike::firstOrCreate([
'group_id' => $gid,
'profile_id' => $pid,
'comment_id' => $sid,
]);
if($like->wasRecentlyCreated) {
// update parent post like count
$parent = GroupComment::find($sid);
abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status');
$parent->likes_count = $parent->likes_count + 1;
$parent->save();
GroupsLikeService::add($pid, $sid);
// invalidate cache
GroupCommentService::del($gid, $sid);
$count++;
GroupService::log(
$gid,
$pid,
'group:like',
null,
GroupLike::class,
$like->id
);
}
$response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];
return $response;
}
public function unlikePost(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'sid' => 'required'
]);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$sid = $request->input('sid');
$group = GroupService::get($gid);
abort_if(!$group || $gid != $group['id'], 422, 'Invalid group');
abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group');
$gp = GroupCommentService::get($gid, $sid);
abort_if(!$gp, 422, 'Invalid status');
$count = $gp['favourites_count'] ?? 0;
$like = GroupLike::where([
'group_id' => $gid,
'profile_id' => $pid,
'comment_id' => $sid,
])->first();
if($like) {
$like->delete();
$parent = GroupComment::find($sid);
abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status');
$parent->likes_count = $parent->likes_count - 1;
$parent->save();
GroupsLikeService::remove($pid, $sid);
// invalidate cache
GroupCommentService::del($gid, $sid);
$count--;
}
$response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count];
return $response;
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Groups;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\AccountService;
use App\Services\GroupService;
use App\Follower;
use App\Profile;
use App\Models\Group;
use App\Models\GroupMember;
use App\Models\GroupInvitation;
class GroupsDiscoverController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function getDiscoverPopular(Request $request)
{
abort_if(!$request->user(), 404);
$groups = Group::orderByDesc('member_count')
->take(12)
->pluck('id')
->map(function($id) {
return GroupService::get($id);
})
->filter(function($id) {
return $id;
})
->take(6)
->values();
return $groups;
}
public function getDiscoverNew(Request $request)
{
abort_if(!$request->user(), 404);
$groups = Group::latest()
->take(12)
->pluck('id')
->map(function($id) {
return GroupService::get($id);
})
->filter(function($id) {
return $id;
})
->take(6)
->values();
return $groups;
}
}

View file

@ -0,0 +1,188 @@
<?php
namespace App\Http\Controllers\Groups;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\AccountService;
use App\Services\GroupService;
use App\Services\UserFilterService;
use App\Services\Groups\GroupFeedService;
use App\Services\Groups\GroupPostService;
use App\Services\RelationshipService;
use App\Services\Groups\GroupsLikeService;
use App\Follower;
use App\Profile;
use App\Models\Group;
use App\Models\GroupPost;
use App\Models\GroupInvitation;
class GroupsFeedController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function getSelfFeed(Request $request)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$limit = $request->input('limit', 5);
$page = $request->input('page');
$initial = $request->has('initial');
if($initial) {
$res = Cache::remember('groups:self:feed:' . $pid, 900, function() use($pid) {
return $this->getSelfFeedV0($pid, 5, null);
});
} else {
abort_if($page && $page > 5, 422);
$res = $this->getSelfFeedV0($pid, $limit, $page);
}
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
protected function getSelfFeedV0($pid, $limit, $page)
{
return GroupPost::join('group_members', 'group_posts.group_id', 'group_members.group_id')
->select('group_posts.*', 'group_members.group_id', 'group_members.profile_id')
->where('group_members.profile_id', $pid)
->whereIn('group_posts.type', ['text', 'photo', 'video'])
->orderByDesc('group_posts.id')
->limit($limit)
// ->pluck('group_posts.status_id')
->simplePaginate($limit)
->map(function($gp) use($pid) {
$status = GroupPostService::get($gp['group_id'], $gp['id']);
if(!$status) {
return false;
}
$status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
$status['favourites_count'] = GroupsLikeService::count($gp['id']);
$status['pf_type'] = $gp['type'];
$status['visibility'] = 'public';
$status['url'] = url("/groups/{$gp['group_id']}/p/{$gp['id']}");
$status['group'] = GroupService::get($gp['group_id']);
$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}");
return $status;
});
}
public function getGroupProfileFeed(Request $request, $id, $pid)
{
abort_if(!$request->user(), 404);
$cid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if(!$group->isMember($pid), 404);
$feed = GroupPost::whereGroupId($id)
->whereProfileId($pid)
->latest()
->paginate(3)
->map(function($gp) use($pid) {
$status = GroupPostService::get($gp['group_id'], $gp['id']);
if(!$status) {
return false;
}
$status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
$status['favourites_count'] = GroupsLikeService::count($gp['id']);
$status['pf_type'] = $gp['type'];
$status['visibility'] = 'public';
$status['url'] = $gp->url();
// if($gp['type'] == 'poll') {
// $status['poll'] = PollService::get($status['id']);
// }
$status['account']['url'] = "/groups/{$gp['group_id']}/user/{$status['account']['id']}";
return $status;
})
->filter(function($status) {
return $status;
});
return $feed;
}
public function getGroupFeed(Request $request, $id)
{
$group = Group::findOrFail($id);
$user = $request->user();
$pid = optional($user)->profile_id ?? false;
abort_if(!$group->isMember($pid), 404);
$max = $request->input('max_id');
$limit = $request->limit ?? 3;
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
// $posts = GroupPost::whereGroupId($group->id)
// ->when($maxId, function($q, $maxId) {
// return $q->where('status_id', '<', $maxId);
// })
// ->whereNull('in_reply_to_id')
// ->orderByDesc('status_id')
// ->simplePaginate($limit)
// ->map(function($gp) use($pid) {
// $status = StatusService::get($gp['status_id'], false);
// if(!$status) {
// return false;
// }
// $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']);
// $status['favourites_count'] = LikeService::count($gp['status_id']);
// $status['pf_type'] = $gp['type'];
// $status['visibility'] = 'public';
// $status['url'] = $gp->url();
// if($gp['type'] == 'poll') {
// $status['poll'] = PollService::get($status['id']);
// }
// $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}");
// return $status;
// })->filter(function($status) {
// return $status;
// });
// return $posts;
Cache::remember('api:v1:timelines:public:cache_check', 10368000, function() use($id) {
if(GroupFeedService::count($id) == 0) {
GroupFeedService::warmCache($id, true, 400);
}
});
if ($max) {
$feed = GroupFeedService::getRankedMaxId($id, $max, $limit);
} else {
$feed = GroupFeedService::get($id, 0, $limit);
}
$res = collect($feed)
->map(function($k) use($user, $id) {
$status = GroupPostService::get($id, $k);
if($status && $user) {
$pid = $user->profile_id;
$sid = $status['account']['id'];
$status['favourited'] = (bool) GroupsLikeService::liked($pid, $status['id']);
$status['favourites_count'] = GroupsLikeService::count($status['id']);
$status['relationship'] = $pid == $sid ? [] : RelationshipService::get($pid, $sid);
}
return $status;
})
->filter(function($s) use($filtered) {
return $s && in_array($s['account']['id'], $filtered) == false;
})
->values()
->toArray();
return $res;
}
}

View file

@ -0,0 +1,214 @@
<?php
namespace App\Http\Controllers\Groups;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\GroupService;
use App\Models\Group;
use App\Models\GroupCategory;
use App\Models\GroupHashtag;
use App\Models\GroupPostHashtag;
use App\Models\GroupMember;
use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\Groups\GroupAccountService;
use App\Services\Groups\GroupHashtagService;
use App\Jobs\GroupsPipeline\MemberJoinApprovedPipeline;
use App\Jobs\GroupsPipeline\MemberJoinRejectedPipeline;
class GroupsMemberController extends Controller
{
public function getGroupMembers(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'limit' => 'nullable|integer|min:3|max:10'
]);
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
$members = GroupMember::whereGroupId($gid)
->whereJoinRequest(false)
->simplePaginate(10)
->map(function($member) use($pid) {
$account = AccountService::get($member['profile_id']);
$account['role'] = $member['role'];
$account['joined'] = $member['created_at'];
$account['following'] = $pid != $member['profile_id'] ?
FollowerService::follows($pid, $member['profile_id']) :
null;
$account['url'] = url("/groups/{$member->group_id}/user/{$member['profile_id']}");
return $account;
});
return response()->json($members->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function getGroupMemberJoinRequests(Request $request)
{
abort_if(!$request->user(), 404);
$id = $request->input('gid');
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
return GroupMember::whereGroupId($group->id)
->whereJoinRequest(true)
->whereNull('rejected_at')
->paginate(10)
->map(function($member) {
return AccountService::get($member->profile_id);
});
}
public function handleGroupMemberJoinRequest(Request $request)
{
abort_if(!$request->user(), 404);
$id = $request->input('gid');
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$mid = $request->input('pid');
abort_if($group->isMember($mid), 404);
$this->validate($request, [
'gid' => 'required',
'pid' => 'required',
'action' => 'required|in:approve,reject'
]);
$action = $request->input('action');
$member = GroupMember::whereGroupId($group->id)
->whereProfileId($mid)
->firstOrFail();
if($action == 'approve') {
MemberJoinApprovedPipeline::dispatch($member)->onQueue('groups');
} else if ($action == 'reject') {
MemberJoinRejectedPipeline::dispatch($member)->onQueue('groups');
}
return $request->all();
}
public function getGroupMember(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'pid' => 'required'
]);
abort_if(!$request->user(), 404);
$gid = $request->input('gid');
$group = Group::findOrFail($gid);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$member_id = $request->input('pid');
$member = GroupMember::whereGroupId($gid)
->whereProfileId($member_id)
->firstOrFail();
$account = GroupAccountService::get($group->id, $member['profile_id']);
$account['role'] = $member['role'];
$account['joined'] = $member['created_at'];
$account['following'] = $pid != $member['profile_id'] ?
FollowerService::follows($pid, $member['profile_id']) :
null;
$account['url'] = url("/groups/{$gid}/user/{$member_id}");
return response()->json($account, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function getGroupMemberCommonIntersections(Request $request)
{
abort_if(!$request->user(), 404);
$cid = $request->user()->profile_id;
// $this->validate($request, [
// 'gid' => 'required',
// 'pid' => 'required'
// ]);
$gid = $request->input('gid');
$pid = $request->input('pid');
if($pid === $cid) {
return [];
}
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($cid), 404);
abort_if(!$group->isMember($pid), 404);
$self = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr')
->whereProfileId($cid)
->groupBy('hashtag_id')
->orderByDesc('countr')
->take(20)
->pluck('hashtag_id');
$user = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr')
->whereProfileId($pid)
->groupBy('hashtag_id')
->orderByDesc('countr')
->take(20)
->pluck('hashtag_id');
$topics = $self->intersect($user)
->values()
->shuffle()
->take(3)
->map(function($id) use($group) {
$tag = GroupHashtagService::get($id);
$tag['url'] = url("/groups/{$group->id}/topics/{$tag['slug']}?src=upt");
return $tag;
});
// $friends = DB::table('followers as u')
// ->join('followers as s', 'u.following_id', '=', 's.following_id')
// ->where('s.profile_id', $cid)
// ->where('u.profile_id', $pid)
// ->inRandomOrder()
// ->take(10)
// ->pluck('s.following_id')
// ->map(function($id) use($gid) {
// $res = AccountService::get($id);
// $res['url'] = url("/groups/{$gid}/user/{$id}");
// return $res;
// });
$mutualGroups = GroupService::mutualGroups($cid, $pid, [$gid]);
$mutualFriends = collect(FollowerService::mutualIds($cid, $pid))
->map(function($id) use($gid) {
$res = AccountService::get($id);
if(GroupService::isMember($gid, $id)) {
$res['url'] = url("/groups/{$gid}/user/{$id}");
} else if(!$res['local']) {
$res['url'] = url("/i/web/profile/_/{$id}");
}
return $res;
});
$mutualFriendsCount = FollowerService::mutualCount($cid, $pid);
$res = [
'groups_count' => $mutualGroups['count'],
'groups' => $mutualGroups['groups'],
'topics' => $topics,
'friends_count' => $mutualFriendsCount,
'friends' => $mutualFriends,
];
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Groups;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\GroupService;
use App\Models\Group;
class GroupsMetaController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function deleteGroup(Request $request)
{
abort_if(!$request->user(), 404);
$id = $request->input('gid');
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!$group->isMember($pid), 404);
abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$group->status = "delete";
$group->save();
GroupService::del($group->id);
return [200];
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Groups;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\AccountService;
use App\Services\StatusService;
use App\Services\GroupService;
use App\Models\Group;
use App\Notification;
class GroupsNotificationsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function selfGlobalNotifications(Request $request)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$res = Notification::whereProfileId($pid)
->where('action', 'like', 'group%')
->latest()
->paginate(10)
->map(function($n) {
$res = [
'id' => $n->id,
'type' => $n->action,
'account' => AccountService::get($n->actor_id),
'object' => [
'id' => $n->item_id,
'type' => last(explode('\\', $n->item_type)),
],
'created_at' => $n->created_at->format('c')
];
if($res['object']['type'] == 'Status' || in_array($n->action, ['group:comment'])) {
$res['status'] = StatusService::get($n->item_id, false);
$res['group'] = GroupService::get($res['status']['gid']);
}
if($res['object']['type'] == 'Group') {
$res['group'] = GroupService::get($n->item_id);
}
return $res;
});
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
}

View file

@ -0,0 +1,420 @@
<?php
namespace App\Http\Controllers\Groups;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\RateLimiter;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\AccountService;
use App\Services\GroupService;
use App\Services\Groups\GroupFeedService;
use App\Services\Groups\GroupPostService;
use App\Services\Groups\GroupMediaService;
use App\Services\Groups\GroupsLikeService;
use App\Follower;
use App\Profile;
use App\Models\Group;
use App\Models\GroupHashtag;
use App\Models\GroupPost;
use App\Models\GroupLike;
use App\Models\GroupMember;
use App\Models\GroupInvitation;
use App\Models\GroupMedia;
use App\Jobs\GroupsPipeline\ImageResizePipeline;
use App\Jobs\GroupsPipeline\ImageS3UploadPipeline;
use App\Jobs\GroupsPipeline\NewPostPipeline;
class GroupsPostController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function storePost(Request $request)
{
$this->validate($request, [
'group_id' => 'required|exists:groups,id',
'caption' => 'sometimes|string|max:'.config_cache('pixelfed.max_caption_length', 500),
'pollOptions' => 'sometimes|array|min:1|max:4'
]);
$group = Group::findOrFail($request->input('group_id'));
$pid = $request->user()->profile_id;
$caption = $request->input('caption');
$type = $request->input('type', 'text');
abort_if(!GroupService::canPost($group->id, $pid), 422, 'You cannot create new posts at this time');
if($type == 'text') {
abort_if(strlen(e($caption)) == 0, 403);
}
$gp = new GroupPost;
$gp->group_id = $group->id;
$gp->profile_id = $pid;
$gp->caption = e($caption);
$gp->type = $type;
$gp->visibility = 'draft';
$gp->save();
$status = $gp;
NewPostPipeline::dispatchSync($gp);
// NewStatusPipeline::dispatch($status, $gp);
if($type == 'poll') {
// Polls not supported yet
// $poll = new Poll;
// $poll->status_id = $status->id;
// $poll->profile_id = $status->profile_id;
// $poll->poll_options = $request->input('pollOptions');
// $poll->expires_at = now()->addMinutes($request->input('expiry'));
// $poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
// return 0;
// })->toArray();
// $poll->save();
// sleep(5);
}
if($type == 'photo') {
$photo = $request->file('photo');
$storagePath = GroupMediaService::path($group->id, $pid, $status->id);
// $storagePath = 'public/g/' . $group->id . '/p/' . $status->id;
$path = $photo->storePublicly($storagePath);
// $hash = \hash_file('sha256', $photo);
$media = new GroupMedia();
$media->group_id = $group->id;
$media->status_id = $status->id;
$media->profile_id = $request->user()->profile_id;
$media->media_path = $path;
$media->size = $photo->getSize();
$media->mime = $photo->getMimeType();
$media->save();
// Bus::chain([
// new ImageResizePipeline($media),
// new ImageS3UploadPipeline($media),
// ])->dispatch($media);
ImageResizePipeline::dispatchSync($media);
ImageS3UploadPipeline::dispatchSync($media);
// ImageOptimize::dispatch($media);
// delay response while background job optimizes media
// sleep(5);
}
if($type == 'video') {
$video = $request->file('video');
$storagePath = 'public/g/' . $group->id . '/p/' . $status->id;
$path = $video->storePublicly($storagePath);
$hash = \hash_file('sha256', $video);
$media = new Media();
$media->status_id = $status->id;
$media->profile_id = $request->user()->profile_id;
$media->user_id = $request->user()->id;
$media->media_path = $path;
$media->original_sha256 = $hash;
$media->size = $video->getSize();
$media->mime = $video->getMimeType();
$media->save();
VideoThumbnail::dispatch($media);
sleep(15);
}
GroupService::log(
$group->id,
$pid,
'group:status:created',
[
'type' => $gp->type,
'status_id' => $status->id
],
GroupPost::class,
$gp->id
);
$s = GroupPostService::get($status->group_id, $status->id);
GroupFeedService::add($group->id, $gp->id);
Cache::forget('groups:self:feed:' . $pid);
$s['pf_type'] = $type;
$s['visibility'] = 'public';
$s['url'] = $gp->url();
if($type == 'poll') {
$s['poll'] = PollService::get($status->id);
}
$group->last_active_at = now();
$group->save();
return $s;
}
public function deletePost(Request $request)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'id' => 'required|integer|min:1',
'gid' => 'required|integer|min:1'
]);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
$gp = GroupPost::whereGroupId($status->group_id)->findOrFail($request->input('id'));
abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403);
$cached = GroupPostService::get($status->group_id, $status->id);
if($cached) {
$cached = collect($cached)->filter(function($r, $k) {
return in_array($k, [
'id',
'sensitive',
'pf_type',
'media_attachments',
'content_text',
'created_at'
]);
});
}
GroupService::log(
$status->group_id,
$request->user()->profile_id,
'group:status:deleted',
[
'type' => $gp->type,
'status_id' => $status->id,
'original' => $cached
],
GroupPost::class,
$gp->id
);
$user = $request->user();
// if($status->profile_id != $user->profile->id &&
// $user->is_admin == true &&
// $status->uri == null
// ) {
// $media = $status->media;
// $ai = new AccountInterstitial;
// $ai->user_id = $status->profile->user_id;
// $ai->type = 'post.removed';
// $ai->view = 'account.moderation.post.removed';
// $ai->item_type = 'App\Status';
// $ai->item_id = $status->id;
// $ai->has_media = (bool) $media->count();
// $ai->blurhash = $media->count() ? $media->first()->blurhash : null;
// $ai->meta = json_encode([
// 'caption' => $status->caption,
// 'created_at' => $status->created_at,
// 'type' => $status->type,
// 'url' => $status->url(),
// 'is_nsfw' => $status->is_nsfw,
// 'scope' => $status->scope,
// 'reblog' => $status->reblog_of_id,
// 'likes_count' => $status->likes_count,
// 'reblogs_count' => $status->reblogs_count,
// ]);
// $ai->save();
// $u = $status->profile->user;
// $u->has_interstitial = true;
// $u->save();
// }
if($status->in_reply_to_id) {
$parent = GroupPost::find($status->in_reply_to_id);
if($parent) {
$parent->reply_count = GroupPost::whereInReplyToId($parent->id)->count();
$parent->save();
GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id));
}
}
GroupPostService::del($group->id, $gp->id);
GroupFeedService::del($group->id, $gp->id);
if ($status->profile_id == $user->profile->id || $user->is_admin == true) {
// Cache::forget('profile:status_count:'.$status->profile_id);
StatusDelete::dispatch($status);
}
if($request->wantsJson()) {
return response()->json(['Status successfully deleted.']);
} else {
return redirect($user->url());
}
}
public function likePost(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'sid' => 'required'
]);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$sid = $request->input('sid');
$group = GroupService::get($gid);
abort_if(!$group, 422, 'Invalid group');
abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group');
$gp = GroupPostService::get($gid, $sid);
abort_if(!$gp, 422, 'Invalid status');
$count = $gp['favourites_count'] ?? 0;
$like = GroupLike::firstOrCreate([
'group_id' => $gid,
'profile_id' => $pid,
'status_id' => $sid,
]);
if($like->wasRecentlyCreated) {
// update parent post like count
$parent = GroupPost::whereGroupId($gid)->find($sid);
abort_if(!$parent, 422, 'Invalid status');
$parent->likes_count = $parent->likes_count + 1;
$parent->save();
GroupsLikeService::add($pid, $sid);
// invalidate cache
GroupPostService::del($gid, $sid);
$count++;
GroupService::log(
$gid,
$pid,
'group:like',
null,
GroupLike::class,
$like->id
);
}
// if (GroupLike::whereGroupId($gid)->whereStatusId($sid)->whereProfileId($pid)->exists()) {
// $like = GroupLike::whereProfileId($pid)->whereStatusId($sid)->firstOrFail();
// // UnlikePipeline::dispatch($like);
// $count = $gp->likes_count - 1;
// $action = 'group:unlike';
// } else {
// $count = $gp->likes_count;
// $like = GroupLike::firstOrCreate([
// 'group_id' => $gid,
// 'profile_id' => $pid,
// 'status_id' => $sid
// ]);
// if($like->wasRecentlyCreated == true) {
// $count++;
// $gp->likes_count = $count;
// $like->save();
// $gp->save();
// // LikePipeline::dispatch($like);
// $action = 'group:like';
// }
// }
// Cache::forget('status:'.$status->id.':likedby:userid:'.$request->user()->id);
// StatusService::del($status->id);
$response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];
return $response;
}
public function unlikePost(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'sid' => 'required'
]);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$sid = $request->input('sid');
$group = GroupService::get($gid);
abort_if(!$group, 422, 'Invalid group');
abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time');
abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group');
$gp = GroupPostService::get($gid, $sid);
abort_if(!$gp, 422, 'Invalid status');
$count = $gp['favourites_count'] ?? 0;
$like = GroupLike::where([
'group_id' => $gid,
'profile_id' => $pid,
'status_id' => $sid,
])->first();
if($like) {
$like->delete();
$parent = GroupPost::whereGroupId($gid)->find($sid);
abort_if(!$parent, 422, 'Invalid status');
$parent->likes_count = $parent->likes_count - 1;
$parent->save();
GroupsLikeService::remove($pid, $sid);
// invalidate cache
GroupPostService::del($gid, $sid);
$count--;
}
$response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count];
return $response;
}
public function getGroupMedia(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'type' => 'required|in:photo,video'
]);
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$type = $request->input('type');
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
$media = GroupPost::whereGroupId($gid)
->whereType($type)
->latest()
->simplePaginate(20)
->map(function($gp) use($pid) {
$status = GroupPostService::get($gp['group_id'], $gp['id']);
if(!$status) {
return false;
}
$status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
$status['favourites_count'] = GroupsLikeService::count($gp['id']);
$status['pf_type'] = $gp['type'];
$status['visibility'] = 'public';
$status['url'] = $gp->url();
// if($gp['type'] == 'poll') {
// $status['poll'] = PollService::get($status['id']);
// }
return $status;
})->filter(function($status) {
return $status;
});
return response()->json($media->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
}

View file

@ -0,0 +1,217 @@
<?php
namespace App\Http\Controllers\Groups;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\AccountService;
use App\Services\GroupService;
use App\Follower;
use App\Profile;
use App\Models\Group;
use App\Models\GroupMember;
use App\Models\GroupInvitation;
class GroupsSearchController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function inviteFriendsToGroup(Request $request)
{
abort_if(!$request->user(), 404);
$this->validate($request, [
'uids' => 'required',
'g' => 'required',
]);
$uid = $request->input('uids');
$gid = $request->input('g');
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 404);
abort_if(
GroupInvitation::whereGroupId($group->id)
->whereFromProfileId($pid)
->count() >= 20,
422,
'Invite limit reached'
);
$profiles = collect($uid)
->map(function($u) {
return Profile::find($u);
})
->filter(function($u) use($pid) {
return $u &&
$u->id != $pid &&
isset($u->id) &&
Follower::whereFollowingId($pid)
->whereProfileId($u->id)
->exists();
})
->filter(function($u) use($group, $pid) {
return GroupInvitation::whereGroupId($group->id)
->whereFromProfileId($pid)
->whereToProfileId($u->id)
->exists() == false;
})
->each(function($u) use($gid, $pid) {
$gi = new GroupInvitation;
$gi->group_id = $gid;
$gi->from_profile_id = $pid;
$gi->to_profile_id = $u->id;
$gi->to_local = true;
$gi->from_local = $u->domain == null;
$gi->save();
// GroupMemberInvite::dispatch($gi);
});
return [200];
}
public function searchFriendsToInvite(Request $request)
{
abort_if(!$request->user(), 404);
$this->validate($request, [
'q' => 'required|min:2|max:40',
'g' => 'required',
'v' => 'required|in:0.2'
]);
$q = $request->input('q');
$gid = $request->input('g');
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 404);
$res = Profile::where('username', 'like', "%{$q}%")
->whereNull('profiles.domain')
->join('followers', 'profiles.id', '=', 'followers.profile_id')
->where('followers.following_id', $pid)
->take(10)
->get()
->filter(function($p) use($group) {
return $group->isMember($p->profile_id) == false;
})
->filter(function($p) use($group, $pid) {
return GroupInvitation::whereGroupId($group->id)
->whereFromProfileId($pid)
->whereToProfileId($p->profile_id)
->exists() == false;
})
->map(function($gm) use ($gid) {
$a = AccountService::get($gm->profile_id);
return [
'id' => (string) $gm->profile_id,
'username' => $a['acct'],
'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search")
];
})
->values();
return $res;
}
public function searchGlobalResults(Request $request)
{
abort_if(!$request->user(), 404);
$this->validate($request, [
'q' => 'required|min:2|max:40',
'v' => 'required|in:0.2'
]);
$q = $request->input('q');
$key = 'groups:search:global:by_name:' . hash('sha256', $q);
if(RateLimiter::tooManyAttempts('groups:search:global:'.$request->user()->id, 25) ) {
return response()->json([
'error' => [
'message' => 'Too many attempts, please try again later'
]
], 422);
}
RateLimiter::hit('groups:search:global:'.$request->user()->id);
return Cache::remember($key, 3600, function() use($q) {
return Group::whereNull('status')
->where('name', 'like', '%' . $q . '%')
->orderBy('id')
->take(10)
->pluck('id')
->map(function($group) {
return GroupService::get($group);
});
});
}
public function searchLocalAutocomplete(Request $request)
{
abort_if(!$request->user(), 404);
$this->validate($request, [
'q' => 'required|min:2|max:40',
'g' => 'required',
'v' => 'required|in:0.2'
]);
$q = $request->input('q');
$gid = $request->input('g');
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 404);
$res = GroupMember::whereGroupId($gid)
->join('profiles', 'group_members.profile_id', '=', 'profiles.id')
->where('profiles.username', 'like', "%{$q}%")
->take(10)
->get()
->map(function($gm) use ($gid) {
$a = AccountService::get($gm->profile_id);
return [
'username' => $a['username'],
'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search")
];
});
return $res;
}
public function searchAddRecent(Request $request)
{
$this->validate($request, [
'q' => 'required|min:2|max:40',
'g' => 'required',
]);
$q = $request->input('q');
$gid = $request->input('g');
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 404);
$key = 'groups:search:recent:'.$gid.':pid:'.$pid;
$ttl = now()->addDays(14);
$res = Cache::get($key);
if(!$res) {
$val = json_encode([$q]);
} else {
$ex = collect(json_decode($res))
->prepend($q)
->unique('value')
->slice(0, 3)
->values()
->all();
$val = json_encode($ex);
}
Cache::put($key, $val, $ttl);
return 200;
}
public function searchGetRecent(Request $request)
{
$gid = $request->input('g');
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 404);
$key = 'groups:search:recent:'.$gid.':pid:'.$pid;
return Cache::get($key);
}
}

View file

@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Groups;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\AccountService;
use App\Services\GroupService;
use App\Services\Groups\GroupPostService;
use App\Services\Groups\GroupsLikeService;
use App\Follower;
use App\Profile;
use App\Models\Group;
use App\Models\GroupHashtag;
use App\Models\GroupInvitation;
use App\Models\GroupMember;
use App\Models\GroupPostHashtag;
use App\Models\GroupPost;
class GroupsTopicController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function groupTopics(Request $request)
{
$this->validate($request, [
'gid' => 'required',
]);
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
$posts = GroupPostHashtag::join('group_hashtags', 'group_hashtags.id', '=', 'group_post_hashtags.hashtag_id')
->selectRaw('group_hashtags.*, group_post_hashtags.*, count(group_post_hashtags.hashtag_id) as ht_count')
->where('group_post_hashtags.group_id', $gid)
->orderByDesc('ht_count')
->limit(10)
->pluck('group_post_hashtags.hashtag_id', 'ht_count')
->map(function($id, $key) use ($gid) {
$tag = GroupHashtag::find($id);
return [
'hid' => $id,
'name' => $tag->name,
'url' => url("/groups/{$gid}/topics/{$tag->slug}"),
'count' => $key
];
})->values();
return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function groupTopicTag(Request $request)
{
$this->validate($request, [
'gid' => 'required',
'name' => 'required'
]);
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$gid = $request->input('gid');
$limit = $request->input('limit', 3);
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
$name = $request->input('name');
$hashtag = GroupHashtag::whereName($name)->first();
if(!$hashtag) {
return [];
}
// $posts = GroupPost::whereGroupId($gid)
// ->select('status_hashtags.*', 'group_posts.*')
// ->where('status_hashtags.hashtag_id', $hashtag->id)
// ->join('status_hashtags', 'group_posts.status_id', '=', 'status_hashtags.status_id')
// ->orderByDesc('group_posts.status_id')
// ->simplePaginate($limit)
// ->map(function($gp) use($pid) {
// $status = StatusService::get($gp['status_id'], false);
// if(!$status) {
// return false;
// }
// $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']);
// $status['favourites_count'] = LikeService::count($gp['status_id']);
// $status['pf_type'] = $gp['type'];
// $status['visibility'] = 'public';
// $status['url'] = $gp->url();
// return $status;
// });
$posts = GroupPostHashtag::whereGroupId($gid)
->whereHashtagId($hashtag->id)
->orderByDesc('id')
->simplePaginate($limit)
->map(function($gp) use($pid) {
$status = GroupPostService::get($gp['group_id'], $gp['status_id']);
if(!$status) {
return false;
}
$status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['status_id']);
$status['favourites_count'] = GroupsLikeService::count($gp['status_id']);
$status['pf_type'] = $status['pf_type'];
$status['visibility'] = 'public';
return $status;
});
return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function showTopicFeed(Request $request, $gid, $tag)
{
abort_if(!$request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
$gid = $group->id;
abort_if(!$group->isMember($pid), 403, 'Not a member of group.');
return view('groups.topic-feed', compact('gid', 'tag'));
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace App\Jobs\GroupPipeline;
use App\Notification;
use App\Status;
use App\Models\GroupPost;
use Cache;
use DB;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
use App\Services\MediaStorageService;
use App\Services\NotificationService;
use App\Services\StatusService;
class GroupCommentPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
protected $comment;
protected $groupPost;
public function __construct(Status $status, Status $comment, $groupPost = null)
{
$this->status = $status;
$this->comment = $comment;
$this->groupPost = $groupPost;
}
public function handle()
{
if($this->status->group_id == null || $this->comment->group_id == null) {
return;
}
$this->updateParentReplyCount();
$this->generateNotification();
if($this->groupPost) {
$this->updateChildReplyCount();
}
}
protected function updateParentReplyCount()
{
$parent = $this->status;
$parent->reply_count = Status::whereInReplyToId($parent->id)->count();
$parent->save();
StatusService::del($parent->id);
}
protected function updateChildReplyCount()
{
$gp = $this->groupPost;
if($gp->reply_child_id) {
$parent = GroupPost::whereStatusId($gp->reply_child_id)->first();
if($parent) {
$parent->reply_count++;
$parent->save();
}
}
}
protected function generateNotification()
{
$status = $this->status;
$comment = $this->comment;
$target = $status->profile;
$actor = $comment->profile;
if ($actor->id == $target->id || $status->comments_disabled == true) {
return;
}
$notification = DB::transaction(function() use($target, $actor, $comment) {
$actorName = $actor->username;
$actorUrl = $actor->url();
$text = "{$actorName} commented on your group post.";
$html = "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> commented on your group post.";
$notification = new Notification();
$notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'group:comment';
$notification->item_id = $comment->id;
$notification->item_type = "App\Status";
$notification->save();
return $notification;
});
NotificationService::setNotification($notification);
NotificationService::set($notification->profile_id, $notification->id);
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Jobs\GroupPipeline;
use App\Media;
use Cache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
use App\Services\MediaStorageService;
class GroupMediaPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
public function __construct(Media $media)
{
$this->media = $media;
}
public function handle()
{
MediaStorageService::store($this->media);
}
protected function localToCloud($media)
{
$path = storage_path('app/'.$media->media_path);
$thumb = storage_path('app/'.$media->thumbnail_path);
$p = explode('/', $media->media_path);
$name = array_pop($p);
$pt = explode('/', $media->thumbnail_path);
$thumbname = array_pop($pt);
$storagePath = implode('/', $p);
$disk = Storage::disk(config('filesystems.cloud'));
$file = $disk->putFileAs($storagePath, new File($path), $name, 'public');
$url = $disk->url($file);
$thumbFile = $disk->putFileAs($storagePath, new File($thumb), $thumbname, 'public');
$thumbUrl = $disk->url($thumbFile);
$media->thumbnail_url = $thumbUrl;
$media->cdn_url = $url;
$media->optimized_url = $url;
$media->replicated_at = now();
$media->save();
if($media->status_id) {
Cache::forget('status:transformer:media:attachments:' . $media->status_id);
}
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Jobs\GroupPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\GroupInvitation;
use App\Notification;
use App\Profile;
class GroupMemberInvite implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $invite;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(GroupInvitation $invite)
{
$this->invite = $invite;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$invite = $this->invite;
$actor = Profile::find($invite->from_profile_id);
$target = Profile::find($invite->to_profile_id);
if(!$actor || !$target) {
return;
}
$notification = new Notification;
$notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'group:invite';
$notification->item_id = $invite->group_id;
$notification->item_type = 'App\Models\Group';
$notification->save();
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Jobs\GroupPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\GroupMember;
use App\Notification;
use App\Services\GroupService;
class JoinApproved implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $member;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(GroupMember $member)
{
$this->member = $member;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$member = $this->member;
$member->approved_at = now();
$member->join_request = false;
$member->role = 'member';
$member->save();
$n = new Notification;
$n->profile_id = $member->profile_id;
$n->actor_id = $member->profile_id;
$n->item_id = $member->group_id;
$n->item_type = 'App\Models\Group';
$n->save();
GroupService::del($member->group_id);
GroupService::delSelf($member->group_id, $member->profile_id);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Jobs\GroupPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\GroupMember;
use App\Notification;
use App\Services\GroupService;
class JoinRejected implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $member;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(GroupMember $member)
{
$this->member = $member;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$member = $this->member;
$member->rejected_at = now();
$member->save();
$n = new Notification;
$n->profile_id = $member->profile_id;
$n->actor_id = $member->profile_id;
$n->item_id = $member->group_id;
$n->item_type = 'App\Models\Group';
$n->action = 'group.join.rejected';
$n->save();
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Jobs\GroupPipeline;
use Cache, Log;
use Illuminate\Support\Facades\Redis;
use App\{Like, Notification};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Util\ActivityPub\Helpers;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\Like as LikeTransformer;
use App\Services\StatusService;
class LikePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $like;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
public $timeout = 5;
public $tries = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Like $like)
{
$this->like = $like;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$like = $this->like;
$status = $this->like->status;
$actor = $this->like->actor;
if (!$status) {
// Ignore notifications to deleted statuses
return;
}
StatusService::refresh($status->id);
if($status->url && $actor->domain == null) {
return $this->remoteLikeDeliver();
}
$exists = Notification::whereProfileId($status->profile_id)
->whereActorId($actor->id)
->whereAction('group:like')
->whereItemId($status->id)
->whereItemType('App\Status')
->count();
if ($actor->id === $status->profile_id || $exists !== 0) {
return true;
}
try {
$notification = new Notification();
$notification->profile_id = $status->profile_id;
$notification->actor_id = $actor->id;
$notification->action = 'group:like';
$notification->item_id = $status->id;
$notification->item_type = "App\Status";
$notification->save();
} catch (Exception $e) {
}
}
public function remoteLikeDeliver()
{
$like = $this->like;
$status = $this->like->status;
$actor = $this->like->actor;
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($like, new LikeTransformer());
$activity = $fractal->createData($resource)->toArray();
$url = $status->profile->sharedInbox ?? $status->profile->inbox_url;
Helpers::sendSignedObject($actor, $url, $activity);
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace App\Jobs\GroupPipeline;
use App\Notification;
use App\Hashtag;
use App\Mention;
use App\Profile;
use App\Status;
use App\StatusHashtag;
use App\Models\GroupPostHashtag;
use App\Models\GroupPost;
use Cache;
use DB;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
use App\Services\MediaStorageService;
use App\Services\NotificationService;
use App\Services\StatusService;
use App\Util\Lexer\Autolink;
use App\Util\Lexer\Extractor;
class NewStatusPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
protected $gp;
protected $tags;
protected $mentions;
public function __construct(Status $status, GroupPost $gp)
{
$this->status = $status;
$this->gp = $gp;
}
public function handle()
{
$status = $this->status;
$autolink = Autolink::create()
->setAutolinkActiveUsersOnly(true)
->setBaseHashPath("/groups/{$status->group_id}/topics/")
->setBaseUserPath("/groups/{$status->group_id}/username/")
->autolink($status->caption);
$entities = Extractor::create()->extract($status->caption);
$autolink = str_replace('/discover/tags/', '/groups/' . $status->group_id . '/topics/', $autolink);
$status->rendered = nl2br($autolink);
$status->entities = null;
$status->save();
$this->tags = array_unique($entities['hashtags']);
$this->mentions = array_unique($entities['mentions']);
if(count($this->tags)) {
$this->storeHashtags();
}
if(count($this->mentions)) {
$this->storeMentions($this->mentions);
}
}
protected function storeHashtags()
{
$tags = $this->tags;
$status = $this->status;
$gp = $this->gp;
foreach ($tags as $tag) {
if(mb_strlen($tag) > 124) {
continue;
}
DB::transaction(function () use ($status, $tag, $gp) {
$slug = str_slug($tag, '-', false);
$hashtag = Hashtag::firstOrCreate(
['name' => $tag, 'slug' => $slug]
);
GroupPostHashtag::firstOrCreate(
[
'group_id' => $status->group_id,
'group_post_id' => $gp->id,
'status_id' => $status->id,
'hashtag_id' => $hashtag->id,
'profile_id' => $status->profile_id,
]
);
});
}
if(count($this->mentions)) {
$this->storeMentions();
}
StatusService::del($status->id);
}
protected function storeMentions()
{
$mentions = $this->mentions;
$status = $this->status;
foreach ($mentions as $mention) {
$mentioned = Profile::whereUsername($mention)->first();
if (empty($mentioned) || !isset($mentioned->id)) {
continue;
}
DB::transaction(function () use ($status, $mentioned) {
$m = new Mention();
$m->status_id = $status->id;
$m->profile_id = $mentioned->id;
$m->save();
MentionPipeline::dispatch($status, $m);
});
}
StatusService::del($status->id);
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace App\Jobs\GroupPipeline;
use Cache, Log;
use Illuminate\Support\Facades\Redis;
use App\{Like, Notification};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Util\ActivityPub\Helpers;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\UndoLike as LikeTransformer;
use App\Services\StatusService;
class UnlikePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $like;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
public $timeout = 5;
public $tries = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Like $like)
{
$this->like = $like;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$like = $this->like;
$status = $this->like->status;
$actor = $this->like->actor;
if (!$status) {
// Ignore notifications to deleted statuses
return;
}
$count = $status->likes_count > 1 ? $status->likes_count : $status->likes()->count();
$status->likes_count = $count - 1;
$status->save();
StatusService::del($status->id);
if($actor->id !== $status->profile_id && $status->url && $actor->domain == null) {
$this->remoteLikeDeliver();
}
$exists = Notification::whereProfileId($status->profile_id)
->whereActorId($actor->id)
->whereAction('group:like')
->whereItemId($status->id)
->whereItemType('App\Status')
->first();
if($exists) {
$exists->delete();
}
$like = Like::whereProfileId($actor->id)->whereStatusId($status->id)->first();
if(!$like) {
return;
}
$like->forceDelete();
return;
}
public function remoteLikeDeliver()
{
$like = $this->like;
$status = $this->like->status;
$actor = $this->like->actor;
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($like, new LikeTransformer());
$activity = $fractal->createData($resource)->toArray();
$url = $status->profile->sharedInbox ?? $status->profile->inbox_url;
Helpers::sendSignedObject($actor, $url, $activity);
}
}

View file

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

View file

@ -0,0 +1,89 @@
<?php
namespace App\Jobs\GroupsPipeline;
use App\Models\GroupMedia;
use App\Util\Media\Image;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Log;
use Storage;
use Image as Intervention;
class ImageResizePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(GroupMedia $media)
{
$this->media = $media;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$media = $this->media;
if(!$media) {
return;
}
if (!Storage::exists($media->media_path) || $media->skip_optimize) {
return;
}
$path = $media->media_path;
$file = storage_path('app/' . $path);
$quality = config_cache('pixelfed.image_quality');
$orientations = [
'square' => [
'width' => 1080,
'height' => 1080,
],
'landscape' => [
'width' => 1920,
'height' => 1080,
],
'portrait' => [
'width' => 1080,
'height' => 1350,
],
];
try {
$img = Intervention::make($file);
$img->orientate();
$width = $img->width();
$height = $img->height();
$aspect = $width / $height;
$orientation = $aspect === 1 ? 'square' : ($aspect > 1 ? 'landscape' : 'portrait');
$ratio = $orientations[$orientation];
$img->resize($ratio['width'], $ratio['height']);
$img->save($file, $quality);
} catch (Exception $e) {
Log::error($e);
}
}
}

View file

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

View file

@ -0,0 +1,107 @@
<?php
namespace App\Jobs\GroupsPipeline;
use App\Models\GroupMedia;
use App\Util\Media\Image;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Storage;
use Illuminate\Http\File;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Aws\S3\Exception\S3Exception;
use GuzzleHttp\Exception\ConnectException;
use League\Flysystem\UnableToWriteFile;
class ImageS3UploadPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
static $attempts = 1;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(GroupMedia $media)
{
$this->media = $media;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$media = $this->media;
if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) {
return;
}
$path = storage_path('app/' . $media->media_path);
$p = explode('/', $media->media_path);
$name = array_pop($p);
$storagePath = implode('/', $p);
$url = (bool) config_cache('pixelfed.cloud_storage') && (bool) config('media.storage.remote.resilient_mode') ?
self::handleResilientStore($storagePath, $path, $name) :
self::handleStore($storagePath, $path, $name);
if($url && strlen($url) && str_starts_with($url, 'https://')) {
$media->cdn_url = $url;
$media->processed_at = now();
$media->version = 11;
$media->save();
Storage::disk('local')->delete($media->media_path);
}
}
protected function handleStore($storagePath, $path, $name)
{
return retry(3, function() use($storagePath, $path, $name) {
$baseDisk = (bool) config_cache('pixelfed.cloud_storage') ? config('filesystems.cloud') : 'local';
$disk = Storage::disk($baseDisk);
$file = $disk->putFileAs($storagePath, new File($path), $name, 'public');
return $disk->url($file);
}, random_int(100, 500));
}
protected function handleResilientStore($storagePath, $path, $name)
{
$attempts = 0;
return retry(4, function() use($storagePath, $path, $name, $attempts) {
self::$attempts++;
usleep(100000);
$baseDisk = self::$attempts > 1 ? $this->getAltDriver() : config('filesystems.cloud');
try {
$disk = Storage::disk($baseDisk);
$file = $disk->putFileAs($storagePath, new File($path), $name, 'public');
} catch (S3Exception | ClientException | ConnectException | UnableToWriteFile | Exception $e) {}
return $disk->url($file);
}, function (int $attempt, Exception $exception) {
return $attempt * 200;
});
}
protected function getAltDriver()
{
return config('filesystems.cloud');
}
}

View file

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

View file

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

View file

@ -0,0 +1,115 @@
<?php
namespace App\Jobs\GroupsPipeline;
use App\Util\Media\Image;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\Group;
use App\Models\GroupComment;
use App\Models\GroupPost;
use App\Models\GroupHashtag;
use App\Models\GroupPostHashtag;
use App\Util\Lexer\Autolink;
use App\Util\Lexer\Extractor;
use DB;
class NewCommentPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
protected $parent;
protected $entities;
protected $autolink;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($parent, GroupComment $status)
{
$this->parent = $parent;
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$profile = $this->status->profile;
$status = $this->status;
$parent = $this->parent;
$parent->reply_count = GroupComment::whereStatusId($parent->id)->count();
$parent->save();
if ($profile->no_autolink == false) {
$this->parseEntities();
}
}
public function parseEntities()
{
$this->extractEntities();
}
public function extractEntities()
{
$this->entities = Extractor::create()->extract($this->status->caption);
$this->autolinkStatus();
}
public function autolinkStatus()
{
$this->autolink = Autolink::create()->autolink($this->status->caption);
$this->storeHashtags();
}
public function storeHashtags()
{
$tags = array_unique($this->entities['hashtags']);
$status = $this->status;
foreach ($tags as $tag) {
if (mb_strlen($tag) > 124) {
continue;
}
DB::transaction(function () use ($status, $tag) {
$hashtag = GroupHashtag::firstOrCreate([
'name' => $tag,
]);
GroupPostHashtag::firstOrCreate(
[
'status_id' => $status->id,
'group_id' => $status->group_id,
'hashtag_id' => $hashtag->id,
'profile_id' => $status->profile_id,
'status_visibility' => $status->visibility,
]
);
});
}
$this->storeMentions();
}
public function storeMentions()
{
// todo
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace App\Jobs\GroupsPipeline;
use App\Util\Media\Image;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\Group;
use App\Models\GroupPost;
use App\Models\GroupHashtag;
use App\Models\GroupPostHashtag;
use App\Util\Lexer\Autolink;
use App\Util\Lexer\Extractor;
use DB;
class NewPostPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
protected $entities;
protected $autolink;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(GroupPost $status)
{
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$profile = $this->status->profile;
$status = $this->status;
if ($profile->no_autolink == false) {
$this->parseEntities();
}
}
public function parseEntities()
{
$this->extractEntities();
}
public function extractEntities()
{
$this->entities = Extractor::create()->extract($this->status->caption);
$this->autolinkStatus();
}
public function autolinkStatus()
{
$this->autolink = Autolink::create()->autolink($this->status->caption);
$this->storeHashtags();
}
public function storeHashtags()
{
$tags = array_unique($this->entities['hashtags']);
$status = $this->status;
foreach ($tags as $tag) {
if (mb_strlen($tag) > 124) {
continue;
}
DB::transaction(function () use ($status, $tag) {
$hashtag = GroupHashtag::firstOrCreate([
'name' => $tag,
]);
GroupPostHashtag::firstOrCreate(
[
'status_id' => $status->id,
'group_id' => $status->group_id,
'hashtag_id' => $hashtag->id,
'profile_id' => $status->profile_id,
'status_visibility' => $status->visibility,
]
);
});
}
$this->storeMentions();
}
public function storeMentions()
{
// todo
}
}

67
app/Models/Group.php Normal file
View file

@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\HasSnowflakePrimary;
use App\Profile;
use App\Services\GroupService;
use Illuminate\Database\Eloquent\SoftDeletes;
class Group extends Model
{
use HasSnowflakePrimary, HasFactory, SoftDeletes;
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
protected $casts = [
'metadata' => 'json'
];
public function url()
{
return url("/groups/{$this->id}");
}
public function permalink($suffix = null)
{
if(!$this->local) {
return $this->remote_url;
}
return $this->url() . $suffix;
}
public function members()
{
return $this->hasMany(GroupMember::class);
}
public function admin()
{
return $this->belongsTo(Profile::class, 'profile_id');
}
public function isMember($id = false)
{
$id = $id ?? request()->user()->profile_id;
// return $this->members()->whereProfileId($id)->whereJoinRequest(false)->exists();
return GroupService::isMember($this->id, $id);
}
public function getMembershipType()
{
return $this->is_private ? 'private' : ($this->is_local ? 'local' : 'all');
}
public function selfRole($id = false)
{
$id = $id ?? request()->user()->profile_id;
return optional($this->members()->whereProfileId($id)->first())->role ?? null;
}
}

View file

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

11
app/Models/GroupBlock.php Normal file
View file

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

View file

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

View file

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

11
app/Models/GroupEvent.php Normal file
View file

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

View file

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

View file

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

View file

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

13
app/Models/GroupLike.php Normal file
View file

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

21
app/Models/GroupLimit.php Normal file
View file

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

39
app/Models/GroupMedia.php Normal file
View file

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Storage;
class GroupMedia extends Model
{
use HasFactory;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'metadata' => 'json',
'processed_at' => 'datetime',
'thumbnail_generated' => 'datetime'
];
}
public function url()
{
if($this->cdn_url) {
return $this->cdn_url;
}
return Storage::url($this->media_path);
}
public function thumbnailUrl()
{
return $this->thumbnail_url;
}
}

View file

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

57
app/Models/GroupPost.php Normal file
View file

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

View file

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

View file

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

11
app/Models/GroupRole.php Normal file
View file

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

11
app/Models/GroupStore.php Normal file
View file

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

View file

@ -0,0 +1,88 @@
<?php
namespace App\Services;
use App\Models\GroupPost;
use Illuminate\Support\Facades\Redis;
class GroupFeedService
{
const CACHE_KEY = 'pf:services:groups:feed:';
const FEED_LIMIT = 400;
public static function get($gid, $start = 0, $stop = 10)
{
if ($stop > 100) {
$stop = 100;
}
return Redis::zrevrange(self::CACHE_KEY.$gid, $start, $stop);
}
public static function getRankedMaxId($gid, $start = null, $limit = 10)
{
if (! $start) {
return [];
}
return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY.$gid, $start, '-inf', [
'withscores' => true,
'limit' => [1, $limit],
]));
}
public static function getRankedMinId($gid, $end = null, $limit = 10)
{
if (! $end) {
return [];
}
return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY.$gid, '+inf', $end, [
'withscores' => true,
'limit' => [0, $limit],
]));
}
public static function add($gid, $val)
{
if (self::count($gid) > self::FEED_LIMIT) {
if (config('database.redis.client') === 'phpredis') {
Redis::zpopmin(self::CACHE_KEY.$gid);
}
}
return Redis::zadd(self::CACHE_KEY.$gid, $val, $val);
}
public static function rem($gid, $val)
{
return Redis::zrem(self::CACHE_KEY.$gid, $val);
}
public static function del($gid, $val)
{
return self::rem($gid, $val);
}
public static function count($gid)
{
return Redis::zcard(self::CACHE_KEY.$gid);
}
public static function warmCache($gid, $force = false, $limit = 100)
{
if (self::count($gid) == 0 || $force == true) {
Redis::del(self::CACHE_KEY.$gid);
$ids = GroupPost::whereGroupId($gid)
->orderByDesc('id')
->limit($limit)
->pluck('id');
foreach ($ids as $id) {
self::add($gid, $id);
}
return 1;
}
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Models\GroupPost;
use App\Transformer\Api\GroupPostTransformer;
use Cache;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
class GroupPostService
{
const CACHE_KEY = 'pf:services:groups:post:';
public static function key($gid, $pid)
{
return self::CACHE_KEY.$gid.':'.$pid;
}
public static function get($gid, $pid)
{
return Cache::remember(self::key($gid, $pid), 604800, function () use ($gid, $pid) {
$gp = GroupPost::whereGroupId($gid)->find($pid);
if (! $gp) {
return null;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($gp, new GroupPostTransformer());
$res = $fractal->createData($resource)->toArray();
$res['pf_type'] = $gp['type'];
$res['url'] = $gp->url();
// if($gp['type'] == 'poll') {
// $status['poll'] = PollService::get($status['id']);
// }
//$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}");
return $res;
});
}
public static function del($gid, $pid)
{
return Cache::forget(self::key($gid, $pid));
}
}

View file

@ -0,0 +1,366 @@
<?php
namespace App\Services;
use App\Profile;
use App\Models\Group;
use App\Models\GroupCategory;
use App\Models\GroupMember;
use App\Models\GroupPost;
use App\Models\GroupInteraction;
use App\Models\GroupLimit;
use App\Util\ActivityPub\Helpers;
use Cache;
use Purify;
use App\Http\Resources\Groups\GroupResource;
class GroupService
{
const CACHE_KEY = 'pf:services:groups:';
protected static function key($name)
{
return self::CACHE_KEY . $name;
}
public static function get($id, $pid = false)
{
$res = Cache::remember(
self::key($id),
1209600,
function() use($id, $pid) {
$group = (new Group)->withoutRelations()->whereNull('status')->find($id);
if(!$group) {
return null;
}
$admin = $group->profile_id ? AccountService::get($group->profile_id) : null;
return [
'id' => (string) $group->id,
'name' => $group->name,
'description' => $group->description,
'short_description' => str_limit(strip_tags($group->description), 120),
'category' => self::categoryById($group->category_id),
'local' => (bool) $group->local,
'url' => $group->url(),
'shorturl' => url('/g/'.HashidService::encode($group->id)),
'membership' => $group->getMembershipType(),
'member_count' => $group->members()->whereJoinRequest(false)->count(),
'verified' => false,
'self' => null,
'admin' => $admin,
'config' => [
'recommended' => (bool) $group->recommended,
'discoverable' => (bool) $group->discoverable,
'activitypub' => (bool) $group->activitypub,
'is_nsfw' => (bool) $group->is_nsfw,
'dms' => (bool) $group->dms
],
'metadata' => $group->metadata,
'created_at' => $group->created_at->toAtomString(),
];
}
);
if($pid) {
$res['self'] = self::getSelf($id, $pid);
}
return $res;
}
public static function del($id)
{
Cache::forget('ap:groups:object:' . $id);
return Cache::forget(self::key($id));
}
public static function getSelf($gid, $pid)
{
return Cache::remember(
self::key('self:gid-' . $gid . ':pid-' . $pid),
3600,
function() use($gid, $pid) {
$group = Group::find($gid);
if(!$gid || !$pid) {
return [
'is_member' => false,
'role' => null,
'is_requested' => null
];
}
return [
'is_member' => $group->isMember($pid),
'role' => $group->selfRole($pid),
'is_requested' => optional($group->members()->whereProfileId($pid)->first())->join_request ?? false
];
}
);
}
public static function delSelf($gid, $pid)
{
Cache::forget(self::key("is_member:{$gid}:{$pid}"));
return Cache::forget(self::key('self:gid-' . $gid . ':pid-' . $pid));
}
public static function sidToGid($gid, $pid)
{
return Cache::remember(self::key('s2gid:' . $gid . ':' . $pid), 3600, function() use($gid, $pid) {
return optional(GroupPost::whereGroupId($gid)->whereStatusId($pid)->first())->id;
});
}
public static function membershipsByPid($pid)
{
return Cache::remember(self::key("mbpid:{$pid}"), 3600, function() use($pid) {
return GroupMember::whereProfileId($pid)->pluck('group_id');
});
}
public static function config()
{
return [
'enabled' => config('exp.gps') ?? false,
'limits' => [
'group' => [
'max' => 999,
'federation' => false,
],
'user' => [
'create' => [
'new' => true,
'max' => 10
],
'join' => [
'max' => 10
],
'invite' => [
'max' => 20
]
]
],
'guest' => [
'public' => false
]
];
}
public static function fetchRemote($url)
{
// todo: refactor this demo
$res = Helpers::fetchFromUrl($url);
if(!$res || !isset($res['type']) || $res['type'] != 'Group') {
return false;
}
$group = Group::whereRemoteUrl($url)->first();
if($group) {
return $group;
}
$group = new Group;
$group->remote_url = $res['url'];
$group->name = $res['name'];
$group->inbox_url = $res['inbox'];
$group->metadata = [
'header' => [
'url' => $res['icon']['image']['url']
]
];
$group->description = Purify::clean($res['summary']);
$group->local = false;
$group->save();
return $group->url();
}
public static function log(
string $groupId,
string $profileId,
string $type = null,
array $meta = null,
string $itemType = null,
string $itemId = null
)
{
// todo: truncate (some) metadata after XX days in cron/queue
$log = new GroupInteraction;
$log->group_id = $groupId;
$log->profile_id = $profileId;
$log->type = $type;
$log->item_type = $itemType;
$log->item_id = $itemId;
$log->metadata = $meta;
$log->save();
}
public static function getRejoinTimeout($gid, $pid)
{
$key = self::key('rejoin-timeout:gid-' . $gid . ':pid-' . $pid);
return Cache::has($key);
}
public static function setRejoinTimeout($gid, $pid)
{
// todo: allow group admins to manually remove timeout
$key = self::key('rejoin-timeout:gid-' . $gid . ':pid-' . $pid);
return Cache::put($key, 1, 86400);
}
public static function getMemberInboxes($id)
{
// todo: cache this, maybe add join/leave methods to this service to handle cache invalidation
$group = (new Group)->withoutRelations()->findOrFail($id);
if(!$group->local) {
return [];
}
$members = GroupMember::whereGroupId($id)->whereLocalProfile(false)->pluck('profile_id');
return Profile::find($members)->map(function($u) {
return $u->sharedInbox ?? $u->inbox_url;
})->toArray();
}
public static function getInteractionLimits($gid, $pid)
{
return Cache::remember(self::key(":il:{$gid}:{$pid}"), 3600, function() use($gid, $pid) {
$limit = GroupLimit::whereGroupId($gid)->whereProfileId($pid)->first();
if(!$limit) {
return [
'limits' => [
'can_post' => true,
'can_comment' => true,
'can_like' => true
],
'updated_at' => null
];
}
return [
'limits' => $limit->limits,
'updated_at' => $limit->updated_at->format('c')
];
});
}
public static function clearInteractionLimits($gid, $pid)
{
return Cache::forget(self::key(":il:{$gid}:{$pid}"));
}
public static function canPost($gid, $pid)
{
$limits = self::getInteractionLimits($gid, $pid);
if($limits) {
return (bool) $limits['limits']['can_post'];
} else {
return true;
}
}
public static function canComment($gid, $pid)
{
$limits = self::getInteractionLimits($gid, $pid);
if($limits) {
return (bool) $limits['limits']['can_comment'];
} else {
return true;
}
}
public static function canLike($gid, $pid)
{
$limits = self::getInteractionLimits($gid, $pid);
if($limits) {
return (bool) $limits['limits']['can_like'];
} else {
return true;
}
}
public static function categories($onlyActive = true)
{
return Cache::remember(self::key(':categories'), 2678400, function() use($onlyActive) {
return GroupCategory::when($onlyActive, function($q, $onlyActive) {
return $q->whereActive(true);
})
->orderBy('order')
->pluck('name')
->toArray();
});
}
public static function categoryById($id)
{
return Cache::remember(self::key(':categorybyid:'.$id), 2678400, function() use($id) {
$category = GroupCategory::find($id);
if($category) {
return [
'name' => $category->name,
'url' => url("/groups/explore/category/{$category->slug}")
];
}
return false;
});
}
public static function isMember($gid = false, $pid = false)
{
if(!$gid || !$pid) {
return false;
}
$key = self::key("is_member:{$gid}:{$pid}");
return Cache::remember($key, 3600, function() use($gid, $pid) {
return GroupMember::whereGroupId($gid)
->whereProfileId($pid)
->whereJoinRequest(false)
->exists();
});
}
public static function mutualGroups($cid = false, $pid = false, $exclude = [])
{
if(!$cid || !$pid) {
return [
'count' => 0,
'groups' => []
];
}
$self = self::membershipsByPid($cid);
$user = self::membershipsByPid($pid);
if(!$self->count() || !$user->count()) {
return [
'count' => 0,
'groups' => []
];
}
$intersect = $self->intersect($user);
$count = $intersect->count();
$groups = $intersect
->values()
->filter(function($id) use($exclude) {
return !in_array($id, $exclude);
})
->shuffle()
->take(1)
->map(function($id) {
return self::get($id);
});
return [
'count' => $count,
'groups' => $groups
];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Services\Groups;
use App\Models\Group;
use App\Models\GroupPost;
use App\Models\GroupMember;
use Cache;
use Purify;
use App\Services\AccountService;
use App\Services\GroupService;
class GroupAccountService
{
const CACHE_KEY = 'pf:services:groups:accounts-v0:';
public static function get($gid, $pid)
{
$group = GroupService::get($gid);
if(!$group) {
return;
}
$account = AccountService::get($pid, true);
if(!$account) {
return;
}
$key = self::CACHE_KEY . $gid . ':' . $pid;
$account['group'] = Cache::remember($key, 3600, function() use($gid, $pid) {
$membership = GroupMember::whereGroupId($gid)->whereProfileId($pid)->first();
if(!$membership) {
return [];
}
return [
'joined' => $membership->created_at->format('c'),
'role' => $membership->role,
'local_group' => (bool) $membership->local_group,
'local_profile' => (bool) $membership->local_profile,
];
});
return $account;
}
public static function del($gid, $pid)
{
$key = self::CACHE_KEY . $gid . ':' . $pid;
return Cache::forget($key);
}
}

View file

@ -0,0 +1,312 @@
<?php
namespace App\Services\Groups;
use App\Models\Group;
use App\Models\GroupPost;
use App\Models\GroupComment;
use Cache;
use Purify;
use Illuminate\Support\Facades\Redis;
use League\Fractal;
use App\Util\ActivityPub\Helpers;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Transformer\Api\GroupPostTransformer;
use App\Services\ActivityPubFetchService;
use Illuminate\Support\Facades\Validator;
use App\Rules\ValidUrl;
class GroupActivityPubService
{
const CACHE_KEY = 'pf:services:groups:ap:';
public static function fetchGroup($url, $saveOnFetch = true)
{
$group = Group::where('remote_url', $url)->first();
if($group) {
return $group;
}
$res = ActivityPubFetchService::get($url);
if(!$res) {
return $res;
}
$json = json_decode($res, true);
$group = self::validateGroup($json);
if(!$group) {
return false;
}
if($saveOnFetch) {
return self::storeGroup($group);
}
return $group;
}
public static function fetchGroupPost($url, $saveOnFetch = true)
{
$group = GroupPost::where('remote_url', $url)->first();
if($group) {
return $group;
}
$res = ActivityPubFetchService::get($url);
if(!$res) {
return 'invalid res';
}
$json = json_decode($res, true);
if(!$json) {
return 'invalid json';
}
if(isset($json['inReplyTo'])) {
$comment = self::validateGroupComment($json);
return self::storeGroupComment($comment);
}
$group = self::validateGroupPost($json);
if($saveOnFetch) {
return self::storeGroupPost($group);
}
return $group;
}
public static function validateGroup($obj)
{
$validator = Validator::make($obj, [
'@context' => 'required',
'id' => ['required', 'url', new ValidUrl],
'type' => 'required|in:Group',
'preferredUsername' => 'required',
'name' => 'required',
'url' => ['sometimes', 'url', new ValidUrl],
'inbox' => ['required', 'url', new ValidUrl],
'outbox' => ['required', 'url', new ValidUrl],
'followers' => ['required', 'url', new ValidUrl],
'attributedTo' => 'required',
'summary' => 'sometimes',
'publicKey' => 'required',
'publicKey.id' => 'required',
'publicKey.owner' => ['required', 'url', 'same:id', new ValidUrl],
'publicKey.publicKeyPem' => 'required',
]);
if($validator->fails()) {
return false;
}
return $validator->validated();
}
public static function validateGroupPost($obj)
{
$validator = Validator::make($obj, [
'@context' => 'required',
'id' => ['required', 'url', new ValidUrl],
'type' => 'required|in:Page,Note',
'to' => 'required|array',
'to.*' => ['required', 'url', new ValidUrl],
'cc' => 'sometimes|array',
'cc.*' => ['sometimes', 'url', new ValidUrl],
'url' => ['sometimes', 'url', new ValidUrl],
'attributedTo' => 'required',
'name' => 'sometimes',
'target' => 'sometimes',
'audience' => 'sometimes',
'inReplyTo' => 'sometimes',
'content' => 'sometimes',
'mediaType' => 'sometimes',
'sensitive' => 'sometimes',
'attachment' => 'sometimes',
'published' => 'required',
]);
if($validator->fails()) {
//return $validator->errors();
return false;
}
return $validator->validated();
}
public static function validateGroupComment($obj)
{
$validator = Validator::make($obj, [
'@context' => 'required',
'id' => ['required', 'url', new ValidUrl],
'type' => 'required|in:Note',
'to' => 'required|array',
'to.*' => ['required', 'url', new ValidUrl],
'cc' => 'sometimes|array',
'cc.*' => ['sometimes', 'url', new ValidUrl],
'url' => ['sometimes', 'url', new ValidUrl],
'attributedTo' => 'required',
'name' => 'sometimes',
'target' => 'sometimes',
'audience' => 'sometimes',
'inReplyTo' => 'sometimes',
'content' => 'sometimes',
'mediaType' => 'sometimes',
'sensitive' => 'sometimes',
'published' => 'required',
]);
if($validator->fails()) {
return $validator->errors();
return false;
}
return $validator->validated();
}
public static function getGroupFromPostActivity($groupPost)
{
if(isset($groupPost['audience']) && is_string($groupPost['audience'])) {
return $groupPost['audience'];
}
if(
isset(
$groupPost['target'],
$groupPost['target']['type'],
$groupPost['target']['attributedTo']
) && $groupPost['target']['type'] == 'Collection'
) {
return $groupPost['target']['attributedTo'];
}
return false;
}
public static function getActorFromPostActivity($groupPost)
{
if(!isset($groupPost['attributedTo'])) {
return false;
}
$field = $groupPost['attributedTo'];
if(is_string($field)) {
return $field;
}
if(is_array($field) && count($field) === 1) {
if(
isset(
$field[0]['id'],
$field[0]['type']
) &&
$field[0]['type'] === 'Person' &&
is_string($field[0]['id'])
) {
return $field[0]['id'];
}
}
return false;
}
public static function getCaptionFromPostActivity($groupPost)
{
if(!isset($groupPost['name']) && isset($groupPost['content'])) {
return Purify::clean(strip_tags($groupPost['content']));
}
if(isset($groupPost['name'], $groupPost['content'])) {
return Purify::clean(strip_tags($groupPost['name'])) . Purify::clean(strip_tags($groupPost['content']));
}
}
public static function getSensitiveFromPostActivity($groupPost)
{
if(!isset($groupPost['sensitive'])) {
return true;
}
if(isset($groupPost['sensitive']) && !is_bool($groupPost['sensitive'])) {
return true;
}
return boolval($groupPost['sensitive']);
}
public static function storeGroup($activity)
{
$group = new Group;
$group->profile_id = null;
$group->category_id = 1;
$group->name = $activity['name'] ?? 'Untitled Group';
$group->description = isset($activity['summary']) ? Purify::clean($activity['summary']) : null;
$group->is_private = false;
$group->local_only = false;
$group->metadata = [];
$group->local = false;
$group->remote_url = $activity['id'];
$group->inbox_url = $activity['inbox'];
$group->activitypub = true;
$group->save();
return $group;
}
public static function storeGroupPost($groupPost)
{
$groupUrl = self::getGroupFromPostActivity($groupPost);
if(!$groupUrl) {
return;
}
$group = self::fetchGroup($groupUrl, true);
if(!$group) {
return;
}
$actorUrl = self::getActorFromPostActivity($groupPost);
$actor = Helpers::profileFetch($actorUrl);
$caption = self::getCaptionFromPostActivity($groupPost);
$sensitive = self::getSensitiveFromPostActivity($groupPost);
$model = GroupPost::firstOrCreate(
[
'remote_url' => $groupPost['id'],
], [
'group_id' => $group->id,
'profile_id' => $actor->id,
'type' => 'text',
'caption' => $caption,
'visibility' => 'public',
'is_nsfw' => $sensitive,
]
);
return $model;
}
public static function storeGroupComment($groupPost)
{
$groupUrl = self::getGroupFromPostActivity($groupPost);
if(!$groupUrl) {
return;
}
$group = self::fetchGroup($groupUrl, true);
if(!$group) {
return;
}
$actorUrl = self::getActorFromPostActivity($groupPost);
$actor = Helpers::profileFetch($actorUrl);
$caption = self::getCaptionFromPostActivity($groupPost);
$sensitive = self::getSensitiveFromPostActivity($groupPost);
$parentPost = self::fetchGroupPost($groupPost['inReplyTo']);
$model = GroupComment::firstOrCreate(
[
'remote_url' => $groupPost['id'],
], [
'group_id' => $group->id,
'profile_id' => $actor->id,
'status_id' => $parentPost->id,
'type' => 'text',
'caption' => $caption,
'visibility' => 'public',
'is_nsfw' => $sensitive,
'local' => $actor->private_key != null
]
);
return $model;
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Services\Groups;
use App\Models\GroupComment;
use Cache;
use Illuminate\Support\Facades\Redis;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Transformer\Api\GroupPostTransformer;
class GroupCommentService
{
const CACHE_KEY = 'pf:services:groups:comment:';
public static function key($gid, $pid)
{
return self::CACHE_KEY . $gid . ':' . $pid;
}
public static function get($gid, $pid)
{
return Cache::remember(self::key($gid, $pid), 604800, function() use($gid, $pid) {
$gp = GroupComment::whereGroupId($gid)->find($pid);
if(!$gp) {
return null;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($gp, new GroupPostTransformer());
$res = $fractal->createData($resource)->toArray();
$res['pf_type'] = 'group:post:comment';
$res['url'] = $gp->url();
// if($gp['type'] == 'poll') {
// $status['poll'] = PollService::get($status['id']);
// }
//$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}");
return $res;
});
}
public static function del($gid, $pid)
{
return Cache::forget(self::key($gid, $pid));
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace App\Services\Groups;
use App\Profile;
use App\Models\Group;
use App\Models\GroupCategory;
use App\Models\GroupMember;
use App\Models\GroupPost;
use App\Models\GroupInteraction;
use App\Models\GroupLimit;
use App\Util\ActivityPub\Helpers;
use Cache;
use Purify;
use Illuminate\Support\Facades\Redis;
class GroupFeedService
{
const CACHE_KEY = 'pf:services:groups:feed:';
const FEED_LIMIT = 400;
public static function get($gid, $start = 0, $stop = 10)
{
if($stop > 100) {
$stop = 100;
}
return Redis::zrevrange(self::CACHE_KEY . $gid, $start, $stop);
}
public static function getRankedMaxId($gid, $start = null, $limit = 10)
{
if(!$start) {
return [];
}
return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $gid, $start, '-inf', [
'withscores' => true,
'limit' => [1, $limit]
]));
}
public static function getRankedMinId($gid, $end = null, $limit = 10)
{
if(!$end) {
return [];
}
return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $gid, '+inf', $end, [
'withscores' => true,
'limit' => [0, $limit]
]));
}
public static function add($gid, $val)
{
if(self::count($gid) > self::FEED_LIMIT) {
if(config('database.redis.client') === 'phpredis') {
Redis::zpopmin(self::CACHE_KEY . $gid);
}
}
return Redis::zadd(self::CACHE_KEY . $gid, $val, $val);
}
public static function rem($gid, $val)
{
return Redis::zrem(self::CACHE_KEY . $gid, $val);
}
public static function del($gid, $val)
{
return self::rem($gid, $val);
}
public static function count($gid)
{
return Redis::zcard(self::CACHE_KEY . $gid);
}
public static function warmCache($gid, $force = false, $limit = 100)
{
if(self::count($gid) == 0 || $force == true) {
Redis::del(self::CACHE_KEY . $gid);
$ids = GroupPost::whereGroupId($gid)
->orderByDesc('id')
->limit($limit)
->pluck('id');
foreach($ids as $id) {
self::add($gid, $id);
}
return 1;
}
}
}

View file

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

View file

@ -0,0 +1,114 @@
<?php
namespace App\Services\Groups;
use Cache;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use App\Models\GroupMedia;
use App\Profile;
use App\Status;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Services\HashidService;
class GroupMediaService
{
const CACHE_KEY = 'groups:media:';
public static function path($gid, $pid, $sid = false)
{
if(!$gid || !$pid) {
return;
}
$groupHashid = HashidService::encode($gid);
$monthHash = HashidService::encode(date('Y').date('n'));
$pid = HashidService::encode($pid);
$sid = $sid ? HashidService::encode($sid) : false;
$path = $sid ?
"public/g1/{$groupHashid}/{$pid}/{$monthHash}/{$sid}" :
"public/g1/{$groupHashid}/{$pid}/{$monthHash}";
return $path;
}
public static function get($statusId)
{
return Cache::remember(self::CACHE_KEY.$statusId, 21600, function() use($statusId) {
$media = GroupMedia::whereStatusId($statusId)->orderBy('order')->get();
if(!$media) {
return [];
}
$medias = $media->map(function($media) {
return [
'id' => (string) $media->id,
'type' => 'Document',
'url' => $media->url(),
'preview_url' => $media->url(),
'remote_url' => $media->url,
'description' => $media->cw_summary,
'blurhash' => $media->blurhash ?? 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay'
];
});
return $medias->toArray();
});
}
public static function getMastodon($id)
{
$media = self::get($id);
if(!$media) {
return [];
}
$medias = collect($media)
->map(function($media) {
$mime = $media['mime'] ? explode('/', $media['mime']) : false;
unset(
$media['optimized_url'],
$media['license'],
$media['is_nsfw'],
$media['orientation'],
$media['filter_name'],
$media['filter_class'],
$media['mime'],
$media['hls_manifest']
);
$media['type'] = $mime ? strtolower($mime[0]) : 'unknown';
return $media;
})
->filter(function($m) {
return $m && isset($m['url']);
})
->values();
return $medias->toArray();
}
public static function del($statusId)
{
return Cache::forget(self::CACHE_KEY . $statusId);
}
public static function activitypub($statusId)
{
$status = self::get($statusId);
if(!$status) {
return [];
}
return collect($status)->map(function($s) {
$license = isset($s['license']) && $s['license']['title'] ? $s['license']['title'] : null;
return [
'type' => 'Document',
'mediaType' => $s['mime'],
'url' => $s['url'],
'name' => $s['description'],
'summary' => $s['description'],
'blurhash' => $s['blurhash'],
'license' => $license
];
});
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Services\Groups;
use App\Models\GroupPost;
use Cache;
use Illuminate\Support\Facades\Redis;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Transformer\Api\GroupPostTransformer;
class GroupPostService
{
const CACHE_KEY = 'pf:services:groups:post:';
public static function key($gid, $pid)
{
return self::CACHE_KEY . $gid . ':' . $pid;
}
public static function get($gid, $pid)
{
return Cache::remember(self::key($gid, $pid), 604800, function() use($gid, $pid) {
$gp = GroupPost::whereGroupId($gid)->find($pid);
if(!$gp) {
return null;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($gp, new GroupPostTransformer());
$res = $fractal->createData($resource)->toArray();
$res['pf_type'] = $gp['type'];
$res['url'] = $gp->url();
// if($gp['type'] == 'poll') {
// $status['poll'] = PollService::get($status['id']);
// }
//$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}");
return $res;
});
}
public static function del($gid, $pid)
{
return Cache::forget(self::key($gid, $pid));
}
public function getStatus(Request $request)
{
$gid = $request->input('gid');
$sid = $request->input('sid');
$pid = optional($request->user())->profile_id ?? false;
$group = Group::findOrFail($gid);
if($group->is_private) {
abort_if(!$group->isMember($pid), 404);
}
$gp = GroupPost::whereGroupId($group->id)->whereId($sid)->firstOrFail();
$status = GroupPostService::get($gp['group_id'], $gp['id']);
if(!$status) {
return false;
}
$status['reply_count'] = $gp['reply_count'];
$status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']);
$status['favourites_count'] = GroupsLikeService::count($gp['id']);
$status['pf_type'] = $gp['type'];
$status['visibility'] = 'public';
$status['url'] = $gp->url();
$status['account']['url'] = url("/groups/{$gp->group_id}/user/{$gp->profile_id}");
// if($gp['type'] == 'poll') {
// $status['poll'] = PollService::get($status['id']);
// }
return $status;
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Services\Groups;
use App\Util\ActivityPub\Helpers;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use App\Models\GroupLike;
class GroupsLikeService
{
const CACHE_KEY = 'pf:services:group-likes:ids:';
const CACHE_SET_KEY = 'pf:services:group-likes:set:';
const CACHE_POST_KEY = 'pf:services:group-likes:count:';
public static function add($profileId, $statusId)
{
$key = self::CACHE_KEY . $profileId . ':' . $statusId;
Cache::increment(self::CACHE_POST_KEY . $statusId);
//Cache::forget('pf:services:likes:liked_by:'.$statusId);
self::setAdd($profileId, $statusId);
return Cache::put($key, true, 86400);
}
public static function setAdd($profileId, $statusId)
{
if(self::setCount($profileId) > 400) {
Redis::zpopmin(self::CACHE_SET_KEY . $profileId);
}
return Redis::zadd(self::CACHE_SET_KEY . $profileId, $statusId, $statusId);
}
public static function setCount($id)
{
return Redis::zcard(self::CACHE_SET_KEY . $id);
}
public static function setRem($profileId, $val)
{
return Redis::zrem(self::CACHE_SET_KEY . $profileId, $val);
}
public static function get($profileId, $start = 0, $stop = 10)
{
if($stop > 100) {
$stop = 100;
}
return Redis::zrevrange(self::CACHE_SET_KEY . $profileId, $start, $stop);
}
public static function remove($profileId, $statusId)
{
$key = self::CACHE_KEY . $profileId . ':' . $statusId;
Cache::decrement(self::CACHE_POST_KEY . $statusId);
//Cache::forget('pf:services:likes:liked_by:'.$statusId);
self::setRem($profileId, $statusId);
return Cache::put($key, false, 86400);
}
public static function liked($profileId, $statusId)
{
$key = self::CACHE_KEY . $profileId . ':' . $statusId;
return Cache::remember($key, 900, function() use($profileId, $statusId) {
return GroupLike::whereProfileId($profileId)->whereStatusId($statusId)->exists();
});
}
public static function likedBy($status)
{
$empty = [
'username' => null,
'others' => false
];
return $empty;
}
public static function count($id)
{
return Cache::get(self::CACHE_POST_KEY . $id, 0);
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Transformer\Api;
use App\Status;
use League\Fractal;
use Cache;
use App\Services\AccountService;
use App\Services\HashidService;
use App\Services\LikeService;
use App\Services\Groups\GroupMediaService;
use App\Services\MediaTagService;
use App\Services\StatusService;
use App\Services\StatusHashtagService;
use App\Services\StatusLabelService;
use App\Services\StatusMentionService;
use App\Services\PollService;
use App\Models\CustomEmoji;
use App\Util\Lexer\Autolink;
use Purify;
class GroupPostTransformer extends Fractal\TransformerAbstract
{
public function transform($status)
{
return [
'id' => (string) $status->id,
'gid' => $status->group_id ? (string) $status->group_id : null,
'url' => '/groups/' . $status->group_id . '/p/' . $status->id,
'content' => $status->caption,
'content_text' => $status->caption,
'created_at' => str_replace('+00:00', 'Z', $status->created_at->format(DATE_RFC3339_EXTENDED)),
'reblogs_count' => $status->reblogs_count ?? 0,
'favourites_count' => $status->likes_count ?? 0,
'reblogged' => null,
'favourited' => null,
'muted' => null,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary ?? '',
'visibility' => $status->visibility,
'application' => [
'name' => 'web',
'website' => null
],
'language' => null,
'pf_type' => $status->type,
'reply_count' => (int) $status->reply_count ?? 0,
'comments_disabled' => (bool) $status->comments_disabled,
'thread' => false,
'media_attachments' => GroupMediaService::get($status->id),
'replies' => [],
'parent' => [],
'place' => null,
'local' => (bool) !$status->remote_url,
'account' => AccountService::get($status->profile_id, true),
'poll' => [],
];
}
}

13
config/groups.php Normal file
View file

@ -0,0 +1,13 @@
<?php
return [
'enabled' => env('GROUPS_ENABLED', false),
'federation' => env('GROUPS_FEDERATION', true),
'acl' => [
'create_group' => [
'admins' => env('GROUPS_ACL_CREATE_ADMINS', true),
'users' => env('GROUPS_ACL_CREATE_USERS', true),
]
]
];

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupRolesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_roles', function (Blueprint $table) {
$table->id();
$table->bigInteger('group_id')->unsigned()->index();
$table->string('name');
$table->string('slug')->nullable();
$table->text('abilities')->nullable();
$table->unique(['group_id', 'slug']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_roles');
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupInteractionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_interactions', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('group_id')->unsigned()->index();
$table->bigInteger('profile_id')->unsigned()->index();
$table->string('type')->nullable()->index();
$table->string('item_type')->nullable()->index();
$table->string('item_id')->nullable()->index();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_interactions');
}
}

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupReportsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_reports', function (Blueprint $table) {
$table->id();
$table->bigInteger('group_id')->unsigned()->index();
$table->bigInteger('profile_id')->unsigned()->index();
$table->string('type')->nullable()->index();
$table->string('item_type')->nullable()->index();
$table->string('item_id')->nullable()->index();
$table->json('metadata')->nullable();
$table->boolean('open')->default(true)->index();
$table->unique(['group_id', 'profile_id', 'item_type', 'item_id']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_reports');
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupBlocksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_blocks', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('group_id')->unsigned()->index();
$table->bigInteger('admin_id')->unsigned()->nullable();
$table->bigInteger('profile_id')->nullable()->unsigned()->index();
$table->bigInteger('instance_id')->nullable()->unsigned()->index();
$table->string('name')->nullable()->index();
$table->string('reason')->nullable();
$table->boolean('is_user')->index();
$table->boolean('moderated')->default(false)->index();
$table->unique(['group_id', 'profile_id', 'instance_id']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_blocks');
}
}

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupLimitsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_limits', function (Blueprint $table) {
$table->id();
$table->bigInteger('group_id')->unsigned()->index();
$table->bigInteger('profile_id')->unsigned()->index();
$table->json('limits')->nullable();
$table->json('metadata')->nullable();
$table->unique(['group_id', 'profile_id']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_limits');
}
}

View file

@ -0,0 +1,102 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use App\Models\GroupCategory;
class CreateGroupCategoriesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::dropIfExists('group_categories');
Schema::create('group_categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique()->index();
$table->string('slug')->unique()->index();
$table->boolean('active')->default(true)->index();
$table->tinyInteger('order')->unsigned()->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
$default = [
'General',
'Photography',
'Fediverse',
'CompSci & Programming',
'Causes & Movements',
'Humor',
'Science & Tech',
'Travel',
'Buy & Sell',
'Business',
'Style',
'Animals',
'Sports & Fitness',
'Education',
'Arts',
'Entertainment',
'Faith & Spirituality',
'Relationships & Identity',
'Parenting',
'Hobbies & Interests',
'Food & Drink',
'Vehicles & Commutes',
'Civics & Community',
];
for ($i=1; $i <= 23; $i++) {
$cat = new GroupCategory;
$cat->name = $default[$i - 1];
$cat->slug = str_slug($cat->name);
$cat->active = true;
$cat->order = $i;
$cat->save();
}
Schema::table('groups', function (Blueprint $table) {
$table->unsignedInteger('category_id')->default(1)->index()->after('id');
$table->unsignedInteger('member_count')->nullable();
$table->boolean('recommended')->default(false)->index();
$table->boolean('discoverable')->default(false)->index();
$table->boolean('activitypub')->default(false);
$table->boolean('is_nsfw')->default(false);
$table->boolean('dms')->default(false);
$table->boolean('autospam')->default(false);
$table->boolean('verified')->default(false);
$table->timestamp('last_active_at')->nullable();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_categories');
Schema::table('groups', function (Blueprint $table) {
$table->dropColumn('category_id');
$table->dropColumn('member_count');
$table->dropColumn('recommended');
$table->dropColumn('activitypub');
$table->dropColumn('is_nsfw');
$table->dropColumn('discoverable');
$table->dropColumn('dms');
$table->dropColumn('autospam');
$table->dropColumn('verified');
$table->dropColumn('last_active_at');
$table->dropColumn('deleted_at');
});
}
}

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupHashtagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_hashtags', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name')->unique()->index();
$table->string('formatted')->nullable();
$table->boolean('recommended')->default(false);
$table->boolean('sensitive')->default(false);
$table->boolean('banned')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_hashtags');
}
}

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupPostHashtagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_post_hashtags', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('hashtag_id')->unsigned()->index();
$table->bigInteger('group_id')->unsigned()->index();
$table->bigInteger('profile_id')->unsigned();
$table->bigInteger('status_id')->unsigned()->nullable();
$table->string('status_visibility')->nullable();
$table->boolean('nsfw')->default(false);
$table->unique(['hashtag_id', 'group_id', 'profile_id', 'status_id'], 'group_post_hashtags_gda_unique');
$table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade');
$table->foreign('profile_id')->references('id')->on('profiles')->onDelete('cascade');
$table->foreign('hashtag_id')->references('id')->on('group_hashtags')->onDelete('cascade');
$table->foreign('status_id')->references('id')->on('group_posts')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_post_hashtags');
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupStoresTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_stores', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('group_id')->unsigned()->nullable()->index();
$table->string('store_key')->index();
$table->json('store_value')->nullable();
$table->json('metadata')->nullable();
$table->unique(['group_id', 'store_key']);
$table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_stores');
}
}

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupEventsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_events', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('group_id')->unsigned()->nullable()->index();
$table->bigInteger('profile_id')->unsigned()->nullable()->index();
$table->string('name')->nullable();
$table->string('type')->index();
$table->json('tags')->nullable();
$table->json('location')->nullable();
$table->text('description')->nullable();
$table->json('metadata')->nullable();
$table->boolean('open')->default(false)->index();
$table->boolean('comments_open')->default(false);
$table->boolean('show_guest_list')->default(false);
$table->timestamp('start_at')->nullable();
$table->timestamp('end_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_events');
}
}

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateGroupActivityGraphsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('group_activity_graphs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('instance_id')->nullable()->index();
$table->bigInteger('actor_id')->nullable()->index();
$table->string('verb')->nullable()->index();
$table->string('id_url')->nullable()->unique()->index();
$table->json('payload')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('group_activity_graphs');
}
}

View file

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('group_posts', function (Blueprint $table) {
$table->dropColumn('status_id');
$table->dropColumn('reply_child_id');
$table->dropColumn('in_reply_to_id');
$table->dropColumn('reblog_of_id');
$table->text('caption')->nullable();
$table->string('visibility')->nullable();
$table->boolean('is_nsfw')->default(false);
$table->unsignedInteger('likes_count')->default(0);
$table->text('cw_summary')->nullable();
$table->json('media_ids')->nullable();
$table->boolean('comments_disabled')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('group_posts', function (Blueprint $table) {
$table->bigInteger('status_id')->unsigned()->unique()->nullable();
$table->bigInteger('reply_child_id')->unsigned()->nullable();
$table->bigInteger('in_reply_to_id')->unsigned()->nullable();
$table->bigInteger('reblog_of_id')->unsigned()->nullable();
$table->dropColumn('caption');
$table->dropColumn('is_nsfw');
$table->dropColumn('visibility');
$table->dropColumn('likes_count');
$table->dropColumn('cw_summary');
$table->dropColumn('media_ids');
$table->dropColumn('comments_disabled');
});
}
};

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('group_comments', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('group_id')->index();
$table->unsignedBigInteger('profile_id')->nullable();
$table->unsignedBigInteger('status_id')->nullable()->index();
$table->unsignedBigInteger('in_reply_to_id')->nullable()->index();
$table->string('remote_url')->nullable()->unique()->index();
$table->text('caption')->nullable();
$table->boolean('is_nsfw')->default(false);
$table->string('visibility')->nullable();
$table->unsignedInteger('likes_count')->default(0);
$table->unsignedInteger('replies_count')->default(0);
$table->text('cw_summary')->nullable();
$table->json('media_ids')->nullable();
$table->string('status')->nullable();
$table->string('type')->default('text')->nullable();
$table->boolean('local')->default(false);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('group_comments');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('group_likes', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('group_id');
$table->unsignedBigInteger('profile_id')->index();
$table->unsignedBigInteger('status_id')->nullable();
$table->unsignedBigInteger('comment_id')->nullable();
$table->boolean('local')->default(true);
$table->unique(['group_id', 'profile_id', 'status_id', 'comment_id'], 'group_likes_gpsc_unique');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('group_likes');
}
};

View file

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('group_media', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('group_id');
$table->unsignedBigInteger('profile_id');
$table->unsignedBigInteger('status_id')->nullable()->index();
$table->string('media_path')->unique();
$table->text('thumbnail_url')->nullable();
$table->text('cdn_url')->nullable();
$table->text('url')->nullable();
$table->string('mime')->nullable();
$table->unsignedInteger('size')->nullable();
$table->text('cw_summary')->nullable();
$table->string('license')->nullable();
$table->string('blurhash')->nullable();
$table->tinyInteger('order')->unsigned()->default(1);
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->boolean('local_user')->default(true);
$table->boolean('is_cached')->default(false);
$table->boolean('is_comment')->default(false)->index();
$table->json('metadata')->nullable();
$table->string('version')->default(1);
$table->boolean('skip_optimize')->default(false);
$table->timestamp('processed_at')->nullable();
$table->timestamp('thumbnail_generated')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('group_media');
}
};