Merge pull request #5215 from pixelfed/groups

Add Groups
This commit is contained in:
daniel 2024-07-23 02:02:31 -06:00 committed by GitHub
commit c13826e44b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
204 changed files with 22435 additions and 16 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,671 @@
<?php
namespace App\Http\Controllers;
use App\Instance;
use App\Models\Group;
use App\Models\GroupBlock;
use App\Models\GroupCategory;
use App\Models\GroupInvitation;
use App\Models\GroupLike;
use App\Models\GroupLimit;
use App\Models\GroupMember;
use App\Models\GroupPost;
use App\Models\GroupReport;
use App\Profile;
use App\Services\AccountService;
use App\Services\GroupService;
use App\Services\HashidService;
use App\Services\StatusService;
use App\Status;
use App\User;
use Illuminate\Http\Request;
use Storage;
class GroupController extends GroupFederationController
{
public function __construct()
{
$this->middleware('auth');
abort_unless(config('groups.enabled'), 404);
}
public function index(Request $request)
{
abort_if(! $request->user(), 404);
return view('layouts.spa');
}
public function home(Request $request)
{
abort_if(! $request->user(), 404);
return view('layouts.spa');
}
public function show(Request $request, $id, $path = false)
{
$group = Group::find($id);
if (! $group || $group->status) {
return response()->view('groups.unavailable')->setStatusCode(404);
}
if ($request->wantsJson()) {
return $this->showGroupObject($group);
}
return view('layouts.spa', compact('id', 'path'));
}
public function showStatus(Request $request, $gid, $sid)
{
$group = Group::find($gid);
$pid = optional($request->user())->profile_id ?? false;
if (! $group || $group->status) {
return response()->view('groups.unavailable')->setStatusCode(404);
}
if ($group->is_private) {
abort_if(! $request->user(), 404);
abort_if(! $group->isMember($pid), 404);
}
$gp = GroupPost::whereGroupId($gid)
->findOrFail($sid);
return view('layouts.spa', compact('group', 'gp'));
}
public function getGroup(Request $request, $id)
{
$group = Group::whereNull('status')->findOrFail($id);
$pid = optional($request->user())->profile_id ?? false;
$group = $this->toJson($group, $pid);
return response()->json($group, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
public function showStatusLikes(Request $request, $id, $sid)
{
$group = Group::findOrFail($id);
$user = $request->user();
$pid = $user->profile_id;
abort_if(! $group->isMember($pid), 404);
$status = GroupPost::whereGroupId($id)->findOrFail($sid);
$likes = GroupLike::whereStatusId($sid)
->cursorPaginate(10)
->map(function ($l) use ($group) {
$account = AccountService::get($l->profile_id);
$account['url'] = "/groups/{$group->id}/user/{$account['id']}";
return $account;
})
->filter(function ($l) {
return $l && isset($l['id']);
})
->values();
return $likes;
}
public function groupSettings(Request $request, $id)
{
abort_if(! $request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(! $group->isMember($pid), 404);
abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
return view('groups.settings', compact('group'));
}
public function joinGroup(Request $request, $id)
{
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if($group->isMember($pid), 404);
if (! $request->user()->is_admin) {
abort_if(GroupService::getRejoinTimeout($group->id, $pid), 422, 'Cannot re-join this group for 24 hours after leaving or cancelling a request to join');
}
$member = new GroupMember;
$member->group_id = $group->id;
$member->profile_id = $pid;
$member->role = 'member';
$member->local_group = true;
$member->local_profile = true;
$member->join_request = $group->is_private;
$member->save();
GroupService::delSelf($group->id, $pid);
GroupService::log(
$group->id,
$pid,
'group:joined',
null,
GroupMember::class,
$member->id
);
$group = $this->toJson($group, $pid);
return $group;
}
public function updateGroup(Request $request, $id)
{
$this->validate($request, [
'description' => 'nullable|max:500',
'membership' => 'required|in:all,local,private',
'avatar' => 'nullable',
'header' => 'nullable',
'discoverable' => 'required',
'activitypub' => 'required',
'is_nsfw' => 'required',
'category' => 'required|string|in:'.implode(',', GroupService::categories()),
]);
$pid = $request->user()->profile_id;
$group = Group::whereProfileId($pid)->findOrFail($id);
$member = GroupMember::whereGroupId($group->id)->whereProfileId($pid)->firstOrFail();
abort_if($member->role != 'founder', 403, 'Invalid group permission');
$metadata = $group->metadata;
$len = $group->is_private ? 12 : 4;
if ($request->hasFile('avatar')) {
$avatar = $request->file('avatar');
if ($avatar) {
if (isset($metadata['avatar']) &&
isset($metadata['avatar']['path']) &&
Storage::exists($metadata['avatar']['path'])
) {
Storage::delete($metadata['avatar']['path']);
}
$fileName = 'avatar_'.strtolower(str_random($len)).'.'.$avatar->extension();
$path = $avatar->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName);
$url = url(Storage::url($path));
$metadata['avatar'] = [
'path' => $path,
'url' => $url,
'updated_at' => now(),
];
}
}
if ($request->hasFile('header')) {
$header = $request->file('header');
if ($header) {
if (isset($metadata['header']) &&
isset($metadata['header']['path']) &&
Storage::exists($metadata['header']['path'])
) {
Storage::delete($metadata['header']['path']);
}
$fileName = 'header_'.strtolower(str_random($len)).'.'.$header->extension();
$path = $header->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName);
$url = url(Storage::url($path));
$metadata['header'] = [
'path' => $path,
'url' => $url,
'updated_at' => now(),
];
}
}
$cat = GroupService::categoryById($group->category_id);
if ($request->category !== $cat['name']) {
$group->category_id = GroupCategory::whereName($request->category)->first()->id;
}
$changes = null;
$group->description = e($request->input('description', null));
$group->is_private = $request->input('membership') == 'private';
$group->local_only = $request->input('membership') == 'local';
$group->activitypub = $request->input('activitypub') == 'true';
$group->discoverable = $request->input('discoverable') == 'true';
$group->is_nsfw = $request->input('is_nsfw') == 'true';
$group->metadata = $metadata;
if ($group->isDirty()) {
$changes = $group->getDirty();
}
$group->save();
GroupService::log(
$group->id,
$pid,
'group:settings:updated',
$changes
);
GroupService::del($group->id);
$res = $this->toJson($group, $pid);
return $res;
}
protected function toJson($group, $pid = false)
{
return GroupService::get($group->id, $pid);
}
public function groupLeave(Request $request, $id)
{
abort_if(! $request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created');
abort_if(! $group->isMember($pid), 403, 'Not a member of group.');
GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete();
GroupService::del($group->id);
GroupService::delSelf($group->id, $pid);
GroupService::setRejoinTimeout($group->id, $pid);
return [200];
}
public function cancelJoinRequest(Request $request, $id)
{
abort_if(! $request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created');
abort_if($group->isMember($pid), 422, 'Cannot cancel approved join request, please leave group instead.');
GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete();
GroupService::del($group->id);
GroupService::delSelf($group->id, $pid);
GroupService::setRejoinTimeout($group->id, $pid);
return [200];
}
public function metaBlockSearch(Request $request, $id)
{
abort_if(! $request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(! $group->isMember($pid), 404);
abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$type = $request->input('type');
$item = $request->input('item');
switch ($type) {
case 'instance':
$res = Instance::whereDomain($item)->first();
if ($res) {
abort_if(GroupBlock::whereGroupId($group->id)->whereInstanceId($res->id)->exists(), 400);
}
break;
case 'user':
$res = Profile::whereUsername($item)->first();
if ($res) {
abort_if(GroupBlock::whereGroupId($group->id)->whereProfileId($res->id)->exists(), 400);
}
if ($res->user_id != null) {
abort_if(User::whereIsAdmin(true)->whereId($res->user_id)->exists(), 400);
}
break;
}
return response()->json((bool) $res, ($res ? 200 : 404));
}
public function reportCreate(Request $request, $id)
{
abort_if(! $request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(! $group->isMember($pid), 404);
$id = $request->input('id');
$type = $request->input('type');
$types = [
// original 3
'spam',
'sensitive',
'abusive',
// new
'underage',
'violence',
'copyright',
'impersonation',
'scam',
'terrorism',
];
$gp = GroupPost::whereGroupId($group->id)->find($id);
abort_if(! $gp, 422, 'Cannot report an invalid or deleted post');
abort_if(! in_array($type, $types), 422, 'Invalid report type');
abort_if($gp->profile_id === $pid, 422, 'Cannot report your own post');
abort_if(
GroupReport::whereGroupId($group->id)
->whereProfileId($pid)
->whereItemType(GroupPost::class)
->whereItemId($id)
->exists(),
422,
'You already reported this'
);
$report = new GroupReport();
$report->group_id = $group->id;
$report->profile_id = $pid;
$report->type = $type;
$report->item_type = GroupPost::class;
$report->item_id = $id;
$report->open = true;
$report->save();
GroupService::log(
$group->id,
$pid,
'group:report:create',
[
'type' => $type,
'report_id' => $report->id,
'status_id' => $gp->status_id,
'profile_id' => $gp->profile_id,
'username' => optional(AccountService::get($gp->profile_id))['acct'],
'gpid' => $gp->id,
'url' => $gp->url(),
],
GroupReport::class,
$report->id
);
return response([200]);
}
public function reportAction(Request $request, $id)
{
abort_if(! $request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(! $group->isMember($pid), 404);
abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$this->validate($request, [
'action' => 'required|in:cw,delete,ignore',
'id' => 'required|string',
]);
$action = $request->input('action');
$id = $request->input('id');
$report = GroupReport::whereGroupId($group->id)
->findOrFail($id);
$status = Status::findOrFail($report->item_id);
$gp = GroupPost::whereGroupId($group->id)
->whereStatusId($status->id)
->firstOrFail();
switch ($action) {
case 'cw':
$status->is_nsfw = true;
$status->save();
StatusService::del($status->id);
GroupReport::whereGroupId($group->id)
->whereItemType($report->item_type)
->whereItemId($report->item_id)
->update(['open' => false]);
GroupService::log(
$group->id,
$pid,
'group:moderation:action',
[
'type' => 'cw',
'report_id' => $report->id,
'status_id' => $status->id,
'profile_id' => $status->profile_id,
'status_url' => $gp->url(),
],
GroupReport::class,
$report->id
);
return response()->json([200]);
break;
case 'ignore':
GroupReport::whereGroupId($group->id)
->whereItemType($report->item_type)
->whereItemId($report->item_id)
->update(['open' => false]);
GroupService::log(
$group->id,
$pid,
'group:moderation:action',
[
'type' => 'ignore',
'report_id' => $report->id,
'status_id' => $status->id,
'profile_id' => $status->profile_id,
'status_url' => $gp->url(),
],
GroupReport::class,
$report->id
);
return response()->json([200]);
break;
}
}
public function getMemberInteractionLimits(Request $request, $id)
{
abort_if(! $request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(! $group->isMember($pid), 404);
abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$profile_id = $request->input('profile_id');
abort_if(! $group->isMember($profile_id), 404);
$limits = GroupService::getInteractionLimits($group->id, $profile_id);
return response()->json($limits);
}
public function updateMemberInteractionLimits(Request $request, $id)
{
abort_if(! $request->user(), 404);
$group = Group::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(! $group->isMember($pid), 404);
abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404);
$this->validate($request, [
'profile_id' => 'required|exists:profiles,id',
'can_post' => 'required',
'can_comment' => 'required',
'can_like' => 'required',
]);
$member = $request->input('profile_id');
$can_post = $request->input('can_post');
$can_comment = $request->input('can_comment');
$can_like = $request->input('can_like');
$account = AccountService::get($member);
abort_if(! $account, 422, 'Invalid profile');
abort_if(! $group->isMember($member), 422, 'Invalid profile');
$limit = GroupLimit::firstOrCreate([
'profile_id' => $member,
'group_id' => $group->id,
]);
if ($limit->wasRecentlyCreated) {
abort_if(GroupLimit::whereGroupId($group->id)->count() >= 25, 422, 'limit_reached');
}
$previousLimits = $limit->limits;
$limit->limits = [
'can_post' => $can_post,
'can_comment' => $can_comment,
'can_like' => $can_like,
];
$limit->save();
GroupService::clearInteractionLimits($group->id, $member);
GroupService::log(
$group->id,
$pid,
'group:member-limits:updated',
[
'profile_id' => $account['id'],
'username' => $account['username'],
'previousLimits' => $previousLimits,
'newLimits' => $limit->limits,
],
GroupLimit::class,
$limit->id
);
return $request->all();
}
public function showProfile(Request $request, $id, $pid)
{
$group = Group::find($id);
if (! $group || $group->status) {
return response()->view('groups.unavailable')->setStatusCode(404);
}
return view('layouts.spa');
}
public function showProfileByUsername(Request $request, $id, $pid)
{
abort_if(! $request->user(), 404);
if (! $request->user()) {
return redirect("/{$pid}");
}
$group = Group::find($id);
$cid = $request->user()->profile_id;
if (! $group || $group->status) {
return response()->view('groups.unavailable')->setStatusCode(404);
}
if (! $group->isMember($cid)) {
return redirect("/{$pid}");
}
$profile = Profile::whereUsername($pid)->first();
if (! $group->isMember($profile->id)) {
return redirect("/{$pid}");
}
if ($profile) {
$url = url("/groups/{$id}/user/{$profile->id}");
return redirect($url);
}
abort(404, 'Invalid username');
}
public function groupInviteLanding(Request $request, $id)
{
abort(404, 'Not yet implemented');
$group = Group::findOrFail($id);
return view('groups.invite', compact('group'));
}
public function groupShortLinkRedirect(Request $request, $hid)
{
$gid = HashidService::decode($hid);
$group = Group::findOrFail($gid);
return redirect($group->url());
}
public function groupInviteClaim(Request $request, $id)
{
$group = GroupService::get($id);
abort_if(! $group || empty($group), 404);
return view('groups.invite-claim', compact('group'));
}
public function groupMemberInviteCheck(Request $request, $id)
{
abort_if(! $request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($group->isMember($pid), 422, 'Already a member');
$exists = GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists();
return response()->json([
'gid' => $id,
'can_join' => (bool) $exists,
]);
}
public function groupMemberInviteAccept(Request $request, $id)
{
abort_if(! $request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($group->isMember($pid), 422, 'Already a member');
abort_if(! GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists(), 422);
$gm = new GroupMember;
$gm->group_id = $id;
$gm->profile_id = $pid;
$gm->role = 'member';
$gm->local_group = $group->local;
$gm->local_profile = true;
$gm->join_request = false;
$gm->save();
GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->delete();
GroupService::del($id);
GroupService::delSelf($id, $pid);
return ['next_url' => $group->url()];
}
public function groupMemberInviteDecline(Request $request, $id)
{
abort_if(! $request->user(), 404);
$pid = $request->user()->profile_id;
$group = Group::findOrFail($id);
abort_if($group->isMember($pid), 422, 'Already a member');
return ['next_url' => '/'];
}
}

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,221 @@
<?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;
use App\Util\ActivityPub\Helpers;
use App\Services\Groups\GroupActivityPubService;
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:140',
'v' => 'required|in:0.2'
]);
$q = $request->input('q');
if(str_starts_with($q, 'https://')) {
$res = Helpers::getSignedFetch($q);
if($res && $res = json_decode($res, true)) {
}
if($res && isset($res['type']) && in_array($res['type'], ['Group', 'Note', 'Page'])) {
if($res['type'] === 'Group') {
return GroupActivityPubService::fetchGroup($q, true);
}
$resp = GroupActivityPubService::fetchGroupPost($q, true);
$resp['name'] = 'Group Post';
$resp['url'] = '/groups/' . $resp['group_id'] . '/p/' . $resp['id'];
return [$resp];
}
}
return Group::whereNull('status')
->where('name', 'like', '%' . $q . '%')
->orderBy('id')
->take(10)
->pluck('id')
->map(function($group) {
return GroupService::get($group);
});
}
public function searchLocalAutocomplete(Request $request)
{
abort_if(!$request->user(), 404);
$this->validate($request, [
'q' => 'required|min:2|max:40',
'g' => 'required',
'v' => 'required|in:0.2'
]);
$q = $request->input('q');
$gid = $request->input('g');
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 404);
$res = GroupMember::whereGroupId($gid)
->join('profiles', 'group_members.profile_id', '=', 'profiles.id')
->where('profiles.username', 'like', "%{$q}%")
->take(10)
->get()
->map(function($gm) use ($gid) {
$a = AccountService::get($gm->profile_id);
return [
'username' => $a['username'],
'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search")
];
});
return $res;
}
public function searchAddRecent(Request $request)
{
$this->validate($request, [
'q' => 'required|min:2|max:40',
'g' => 'required',
]);
$q = $request->input('q');
$gid = $request->input('g');
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 404);
$key = 'groups:search:recent:'.$gid.':pid:'.$pid;
$ttl = now()->addDays(14);
$res = Cache::get($key);
if(!$res) {
$val = json_encode([$q]);
} else {
$ex = collect(json_decode($res))
->prepend($q)
->unique('value')
->slice(0, 3)
->values()
->all();
$val = json_encode($ex);
}
Cache::put($key, $val, $ttl);
return 200;
}
public function searchGetRecent(Request $request)
{
$gid = $request->input('g');
$pid = $request->user()->profile_id;
$group = Group::findOrFail($gid);
abort_if(!$group->isMember($pid), 404);
$key = 'groups:search:recent:'.$gid.':pid:'.$pid;
return Cache::get($key);
}
}

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;
}

21
app/Rules/ValidUrl.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidUrl implements ValidationRule
{
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!str_starts_with(strtolower($value), 'https://')) {
$fail('The :attribute must start with https://.');
}
}
}

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,311 @@
<?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 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' => [],
];
}
}

View file

@ -858,6 +858,11 @@ class Helpers
return self::profileFirstOrNew($url); return self::profileFirstOrNew($url);
} }
public static function getSignedFetch($url)
{
return ActivityPubFetchService::get($url);
}
public static function sendSignedObject($profile, $url, $body) public static function sendSignedObject($profile, $url, $body)
{ {
if (app()->environment() !== 'production') { if (app()->environment() !== 'production') {

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

Binary file not shown.

Binary file not shown.

BIN
public/js/group-status.js vendored Normal file

Binary file not shown.

BIN
public/js/group-topic-feed.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more