From d63569c1204274df2b7397a48b4b0dac728ea57c Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Wed, 18 Nov 2020 14:19:02 -0700 Subject: [PATCH] Add Direct Messages --- app/DirectMessage.php | 23 +- app/Http/Controllers/AccountController.php | 13 +- .../Controllers/DirectMessageController.php | 631 ++++++++++++++++- app/Http/Controllers/FederationController.php | 6 +- .../Controllers/Settings/PrivacySettings.php | 7 + .../Api/NotificationTransformer.php | 1 + app/Util/ActivityPub/Inbox.php | 138 +++- app/Util/ActivityPub/Validator/Add.php | 41 ++ app/Util/Lexer/PrettyNumber.php | 18 +- app/Util/Media/Image.php | 3 + config/cors.php | 4 +- resources/assets/js/components/Activity.vue | 8 + .../assets/js/components/ComposeModal.vue | 14 +- resources/assets/js/components/Direct.vue | 400 +++++++++++ .../assets/js/components/DirectMessage.vue | 648 ++++++++++++++++++ .../assets/js/components/NotificationCard.vue | 5 + resources/assets/js/components/Profile.vue | 13 +- .../assets/js/components/SearchResults.vue | 2 +- resources/assets/js/direct.js | 5 + resources/views/account/direct.blade.php | 7 +- .../views/account/directmessage.blade.php | 12 + resources/views/layouts/partial/nav.blade.php | 6 + resources/views/settings/privacy.blade.php | 10 +- webpack.mix.js | 2 +- 24 files changed, 1938 insertions(+), 79 deletions(-) create mode 100644 app/Util/ActivityPub/Validator/Add.php create mode 100644 resources/assets/js/components/Direct.vue create mode 100644 resources/assets/js/components/DirectMessage.vue create mode 100644 resources/views/account/directmessage.blade.php diff --git a/app/DirectMessage.php b/app/DirectMessage.php index accc92f7f..1900d326f 100644 --- a/app/DirectMessage.php +++ b/app/DirectMessage.php @@ -14,7 +14,7 @@ class DirectMessage extends Model public function url() { - return url('/i/message/' . $this->to_id . '/' . $this->id); + return config('app.url') . '/account/direct/m/' . $this->status_id; } public function author() @@ -22,8 +22,29 @@ class DirectMessage extends Model return $this->hasOne(Profile::class, 'id', 'from_id'); } + public function recipient() + { + return $this->hasOne(Profile::class, 'id', 'to_id'); + } + public function me() { return Auth::user()->profile->id === $this->from_id; } + + public function toText() + { + $actorName = $this->author->username; + + return "{$actorName} sent a direct message."; + } + + public function toHtml() + { + $actorName = $this->author->username; + $actorUrl = $this->author->url(); + $url = $this->url(); + + return "{$actorName} sent a direct message."; + } } diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 09f4ba1c9..b7af749f3 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -13,6 +13,7 @@ use Illuminate\Http\Request; use PragmaRX\Google2FA\Google2FA; use App\Jobs\FollowPipeline\FollowPipeline; use App\{ + DirectMessage, EmailVerification, Follower, FollowRequest, @@ -114,19 +115,17 @@ class AccountController extends Controller } } - public function messages() - { - return view('account.messages'); - } - public function direct() { return view('account.direct'); } - public function showMessage(Request $request, $id) + public function directMessage(Request $request, $id) { - return view('account.message'); + $profile = Profile::where('id', '!=', $request->user()->profile_id) + // ->whereNull('domain') + ->findOrFail($id); + return view('account.directmessage', compact('id')); } public function mute(Request $request) diff --git a/app/Http/Controllers/DirectMessageController.php b/app/Http/Controllers/DirectMessageController.php index 1bc47d24f..00f780b19 100644 --- a/app/Http/Controllers/DirectMessageController.php +++ b/app/Http/Controllers/DirectMessageController.php @@ -2,57 +2,616 @@ namespace App\Http\Controllers; -use Auth; +use Auth, Cache; use Illuminate\Http\Request; use App\{ DirectMessage, + Media, + Notification, Profile, - Status + Status, + User, + UserFilter, + UserSetting }; +use App\Services\MediaPathService; +use App\Services\MediaBlocklistService; +use App\Jobs\StatusPipeline\NewStatusPipeline; +use Illuminate\Support\Str; +use App\Util\ActivityPub\Helpers; class DirectMessageController extends Controller { - public function __construct() - { - $this->middleware('auth'); - } + public function __construct() + { + $this->middleware('auth'); + } - public function inbox(Request $request) - { - $profile = Auth::user()->profile; - $inbox = DirectMessage::selectRaw('*, max(created_at) as createdAt') - ->whereToId($profile->id) - ->with(['author','status']) - ->orderBy('createdAt', 'desc') - ->groupBy('from_id') - ->paginate(12); - return view('account.messages', compact('inbox')); + public function browse(Request $request) + { + $this->validate($request, [ + 'a' => 'nullable|string|in:inbox,sent,filtered', + 'page' => 'nullable|integer|min:1|max:99' + ]); - } + $profile = $request->user()->profile_id; + $action = $request->input('a', 'inbox'); + $page = $request->input('page'); - public function show(Request $request, int $pid, $mid) - { - $profile = Auth::user()->profile; + if($action == 'inbox') { + $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') + ->whereToId($profile) + ->with(['author','status']) + ->whereIsHidden(false) + ->groupBy('from_id') + ->latest() + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->limit(8) + ->get() + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + }); + } - if($pid !== $profile->id) { - abort(403); - } + if($action == 'sent') { + $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') + ->whereFromId($profile) + ->with(['author','status']) + ->groupBy('to_id') + ->orderBy('createdAt', 'desc') + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->limit(8) + ->get() + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + }); + } - $msg = DirectMessage::whereToId($profile->id) - ->findOrFail($mid); + if($action == 'filtered') { + $dms = DirectMessage::selectRaw('*, max(created_at) as createdAt') + ->whereToId($profile) + ->with(['author','status']) + ->whereIsHidden(true) + ->groupBy('from_id') + ->orderBy('createdAt', 'desc') + ->when($page, function($q, $page) { + if($page > 1) { + return $q->offset($page * 8 - 8); + } + }) + ->limit(8) + ->get() + ->map(function($r) use($profile) { + return $r->from_id !== $profile ? [ + 'id' => (string) $r->from_id, + 'name' => $r->author->name, + 'username' => $r->author->username, + 'avatar' => $r->author->avatarUrl(), + 'url' => $r->author->url(), + 'isLocal' => (bool) !$r->author->domain, + 'domain' => $r->author->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ] : [ + 'id' => (string) $r->to_id, + 'name' => $r->recipient->name, + 'username' => $r->recipient->username, + 'avatar' => $r->recipient->avatarUrl(), + 'url' => $r->recipient->url(), + 'isLocal' => (bool) !$r->recipient->domain, + 'domain' => $r->recipient->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => $r->status->caption, + 'messages' => [] + ]; + }); + } - $thread = DirectMessage::whereIn('to_id', [$profile->id, $msg->from_id]) - ->whereIn('from_id', [$profile->id,$msg->from_id]) - ->orderBy('created_at', 'desc') - ->paginate(30); + return response()->json($dms); + } - $thread = $thread->reverse(); + public function create(Request $request) + { + $this->validate($request, [ + 'to_id' => 'required', + 'message' => 'required|string|min:1|max:500', + 'type' => 'required|in:text,emoji' + ]); - return view('account.message', compact('msg', 'profile', 'thread')); - } + $profile = $request->user()->profile; + $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id')); - public function compose(Request $request) - { - $profile = Auth::user()->profile; - } + abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403); + $msg = $request->input('message'); + + if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) { + if($recipient->follows($profile) == true) { + $hidden = false; + } else { + $hidden = true; + } + } else { + $hidden = false; + } + + $status = new Status; + $status->profile_id = $profile->id; + $status->caption = $msg; + $status->rendered = $msg; + $status->visibility = 'direct'; + $status->scope = 'direct'; + $status->in_reply_to_profile_id = $recipient->id; + $status->save(); + + $dm = new DirectMessage; + $dm->to_id = $recipient->id; + $dm->from_id = $profile->id; + $dm->status_id = $status->id; + $dm->is_hidden = $hidden; + $dm->type = $request->input('type'); + $dm->save(); + + if(filter_var($msg, FILTER_VALIDATE_URL)) { + if(Helpers::validateUrl($msg)) { + $dm->type = 'link'; + $dm->meta = [ + 'domain' => parse_url($msg, PHP_URL_HOST), + 'local' => parse_url($msg, PHP_URL_HOST) == + parse_url(config('app.url'), PHP_URL_HOST) + ]; + $dm->save(); + } + } + + $nf = UserFilter::whereUserId($recipient->id) + ->whereFilterableId($profile->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('dm.mute') + ->exists(); + + if($recipient->domain == null && $hidden == false && !$nf) { + $notification = new Notification(); + $notification->profile_id = $recipient->id; + $notification->actor_id = $profile->id; + $notification->action = 'dm'; + $notification->message = $dm->toText(); + $notification->rendered = $dm->toHtml(); + $notification->item_id = $dm->id; + $notification->item_type = "App\DirectMessage"; + $notification->save(); + } + + if($recipient->domain) { + $this->remoteDeliver($dm); + } + + $res = [ + 'id' => (string) $dm->id, + 'isAuthor' => $profile->id == $dm->from_id, + 'hidden' => (bool) $dm->is_hidden, + 'type' => $dm->type, + 'text' => $dm->status->caption, + 'media' => null, + 'timeAgo' => $dm->created_at->diffForHumans(null,null,true), + 'seen' => $dm->read_at != null, + 'meta' => $dm->meta + ]; + + return response()->json($res); + } + + public function thread(Request $request) + { + $this->validate($request, [ + 'pid' => 'required' + ]); + $uid = $request->user()->profile_id; + $pid = $request->input('pid'); + $max_id = $request->input('max_id'); + $min_id = $request->input('min_id'); + + $r = Profile::findOrFail($pid); + // $r = Profile::whereNull('domain')->findOrFail($pid); + + if($min_id) { + $res = DirectMessage::select('*') + ->where('id', '>', $min_id) + ->where(function($q) use($pid,$uid) { + return $q->where([['from_id',$pid],['to_id',$uid] + ])->orWhere([['from_id',$uid],['to_id',$pid]]); + }) + ->latest() + ->take(8) + ->get(); + } else if ($max_id) { + $res = DirectMessage::select('*') + ->where('id', '<', $max_id) + ->where(function($q) use($pid,$uid) { + return $q->where([['from_id',$pid],['to_id',$uid] + ])->orWhere([['from_id',$uid],['to_id',$pid]]); + }) + ->latest() + ->take(8) + ->get(); + } else { + $res = DirectMessage::where(function($q) use($pid,$uid) { + return $q->where([['from_id',$pid],['to_id',$uid] + ])->orWhere([['from_id',$uid],['to_id',$pid]]); + }) + ->latest() + ->take(8) + ->get(); + } + + + $res = $res->map(function($s) use ($uid){ + return [ + 'id' => (string) $s->id, + 'hidden' => (bool) $s->is_hidden, + 'isAuthor' => $uid == $s->from_id, + 'type' => $s->type, + 'text' => $s->status->caption, + 'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null, + 'timeAgo' => $s->created_at->diffForHumans(null,null,true), + 'seen' => $s->read_at != null, + 'meta' => json_decode($s->meta,true) + ]; + }); + + $w = [ + 'id' => (string) $r->id, + 'name' => $r->name, + 'username' => $r->username, + 'avatar' => $r->avatarUrl(), + 'url' => $r->url(), + 'muted' => UserFilter::whereUserId($uid) + ->whereFilterableId($r->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('dm.mute') + ->first() ? true : false, + 'isLocal' => (bool) !$r->domain, + 'domain' => $r->domain, + 'timeAgo' => $r->created_at->diffForHumans(null, true, true), + 'lastMessage' => '', + 'messages' => $res + ]; + + return response()->json($w, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function delete(Request $request) + { + $this->validate($request, [ + 'id' => 'required' + ]); + + $sid = $request->input('id'); + $pid = $request->user()->profile_id; + + $dm = DirectMessage::whereFromId($pid) + ->findOrFail($sid); + + $status = Status::whereProfileId($pid) + ->findOrFail($dm->status_id); + + if($dm->recipient->domain) { + $dmc = $dm; + $this->remoteDelete($dmc); + } + + $status->delete(); + $dm->delete(); + + return [200]; + } + + public function get(Request $request, $id) + { + $pid = $request->user()->profile_id; + $dm = DirectMessage::whereStatusId($id)->firstOrFail(); + abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404); + return response()->json($dm, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function mediaUpload(Request $request) + { + $this->validate($request, [ + 'file' => function() { + return [ + 'required', + 'mimes:' . config('pixelfed.media_types'), + 'max:' . config('pixelfed.max_photo_size'), + ]; + }, + 'to_id' => 'required' + ]); + + $user = $request->user(); + $profile = $user->profile; + $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id')); + abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403); + + if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) { + if($recipient->follows($profile) == true) { + $hidden = false; + } else { + $hidden = true; + } + } else { + $hidden = false; + } + + if(config('pixelfed.enforce_account_limit') == true) { + $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { + return Media::whereUserId($user->id)->sum('size') / 1000; + }); + $limit = (int) config('pixelfed.max_account_size'); + if ($size >= $limit) { + abort(403, 'Account size limit reached.'); + } + } + $photo = $request->file('file'); + + $mimes = explode(',', config('pixelfed.media_types')); + if(in_array($photo->getMimeType(), $mimes) == false) { + abort(403, 'Invalid or unsupported mime type.'); + } + + $storagePath = MediaPathService::get($user, 2) . Str::random(8); + $path = $photo->store($storagePath); + $hash = \hash_file('sha256', $photo); + + abort_if(MediaBlocklistService::exists($hash) == true, 451); + + $status = new Status; + $status->profile_id = $profile->id; + $status->caption = null; + $status->rendered = null; + $status->visibility = 'direct'; + $status->scope = 'direct'; + $status->in_reply_to_profile_id = $recipient->id; + $status->save(); + + $media = new Media(); + $media->status_id = $status->id; + $media->profile_id = $profile->id; + $media->user_id = $user->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->caption = null; + $media->filter_class = null; + $media->filter_name = null; + $media->save(); + + $dm = new DirectMessage; + $dm->to_id = $recipient->id; + $dm->from_id = $profile->id; + $dm->status_id = $status->id; + $dm->type = array_first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo'; + $dm->is_hidden = $hidden; + $dm->save(); + + if($recipient->domain) { + $this->remoteDeliver($dm); + } + + return [ + 'type' => $dm->type, + 'url' => $media->url() + ]; + } + + public function composeLookup(Request $request) + { + $this->validate($request, [ + 'username' => 'required' + ]); + $username = $request->input('username'); + $profile = Profile::whereUsername($username)->firstOrFail(); + + return ['id' => (string)$profile->id]; + } + + public function read(Request $request) + { + $this->validate($request, [ + 'pid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->input('pid'); + $sid = $request->input('sid'); + + $dms = DirectMessage::whereToId($request->user()->profile_id) + ->whereFromId($pid) + ->where('status_id', '>=', $sid) + ->get(); + + $now = now(); + foreach($dms as $dm) { + $dm->read_at = $now; + $dm->save(); + } + + return response()->json($dms->pluck('id')); + } + + public function mute(Request $request) + { + $this->validate($request, [ + 'id' => 'required' + ]); + + $fid = $request->input('id'); + $pid = $request->user()->profile_id; + + UserFilter::firstOrCreate( + [ + 'user_id' => $pid, + 'filterable_id' => $fid, + 'filterable_type' => 'App\Profile', + 'filter_type' => 'dm.mute' + ] + ); + + return [200]; + } + + public function unmute(Request $request) + { + $this->validate($request, [ + 'id' => 'required' + ]); + + $fid = $request->input('id'); + $pid = $request->user()->profile_id; + + $f = UserFilter::whereUserId($pid) + ->whereFilterableId($fid) + ->whereFilterableType('App\Profile') + ->whereFilterType('dm.mute') + ->firstOrFail(); + + $f->delete(); + return [200]; + } + + public function remoteDeliver($dm) + { + $profile = $dm->author; + $url = $dm->recipient->inbox_url; + $tags = [ + [ + 'type' => 'Mention', + 'href' => $dm->recipient->permalink(), + 'name' => $dm->recipient->emailUrl(), + ] + ]; + $body = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + [ + 'sc' => 'http://schema.org#', + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + ] + ], + 'id' => $dm->status->permalink(), + 'type' => 'Create', + 'actor' => $dm->status->profile->permalink(), + 'published' => $dm->status->created_at->toAtomString(), + 'to' => [$dm->recipient->permalink()], + 'cc' => [], + 'object' => [ + 'id' => $dm->status->url(), + 'type' => 'Note', + 'summary' => null, + 'content' => $dm->status->rendered ?? $dm->status->caption, + 'inReplyTo' => null, + 'published' => $dm->status->created_at->toAtomString(), + 'url' => $dm->status->url(), + 'attributedTo' => $dm->status->profile->permalink(), + 'to' => [$dm->recipient->permalink()], + 'cc' => [], + 'sensitive' => (bool) $dm->status->is_nsfw, + 'attachment' => $dm->status->media()->orderBy('order')->get()->map(function ($media) { + return [ + 'type' => $media->activityVerb(), + 'mediaType' => $media->mime, + 'url' => $media->url(), + 'name' => $media->caption, + ]; + })->toArray(), + 'tag' => $tags, + ] + ]; + + Helpers::sendSignedObject($profile, $url, $body); + } + + public function remoteDelete($dm) + { + $profile = $dm->author; + $url = $dm->recipient->inbox_url; + + $body = [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + [ + 'sc' => 'http://schema.org#', + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + ] + ], + 'id' => $dm->status->permalink('#delete'), + 'to' => [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'type' => 'Delete', + 'actor' => $dm->status->profile->permalink(), + 'object' => [ + 'id' => $dm->status->url(), + 'type' => 'Tombstone' + ] + ]; + + Helpers::sendSignedObject($profile, $url, $body); + } } diff --git a/app/Http/Controllers/FederationController.php b/app/Http/Controllers/FederationController.php index 09116f1f4..b26bf1a1c 100644 --- a/app/Http/Controllers/FederationController.php +++ b/app/Http/Controllers/FederationController.php @@ -34,7 +34,8 @@ class FederationController extends Controller public function nodeinfoWellKnown() { abort_if(!config('federation.nodeinfo.enabled'), 404); - return response()->json(Nodeinfo::wellKnown()); + return response()->json(Nodeinfo::wellKnown()) + ->header('Access-Control-Allow-Origin','*'); } public function nodeinfo() @@ -62,7 +63,8 @@ class FederationController extends Controller } $webfinger = (new Webfinger($profile))->generate(); - return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT); + return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT) + ->header('Access-Control-Allow-Origin','*'); } public function hostMeta(Request $request) diff --git a/app/Http/Controllers/Settings/PrivacySettings.php b/app/Http/Controllers/Settings/PrivacySettings.php index dff6636eb..91f1ac43c 100644 --- a/app/Http/Controllers/Settings/PrivacySettings.php +++ b/app/Http/Controllers/Settings/PrivacySettings.php @@ -34,6 +34,7 @@ trait PrivacySettings $fields = [ 'is_private', 'crawlable', + 'public_dm', 'show_profile_follower_count', 'show_profile_following_count', ]; @@ -56,6 +57,12 @@ trait PrivacySettings } else { $settings->{$field} = true; } + } elseif ($field == 'public_dm') { + if ($form == 'on') { + $settings->{$field} = true; + } else { + $settings->{$field} = false; + } } else { if ($form == 'on') { $settings->{$field} = true; diff --git a/app/Transformer/Api/NotificationTransformer.php b/app/Transformer/Api/NotificationTransformer.php index 812b250e6..cc75bfd83 100644 --- a/app/Transformer/Api/NotificationTransformer.php +++ b/app/Transformer/Api/NotificationTransformer.php @@ -50,6 +50,7 @@ class NotificationTransformer extends Fractal\TransformerAbstract public function replaceTypeVerb($verb) { $verbs = [ + 'dm' => 'direct', 'follow' => 'follow', 'mention' => 'mention', 'reblog' => 'share', diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 6ccbd508b..ccc8d74f2 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -19,6 +19,7 @@ use App\Jobs\LikePipeline\LikePipeline; use App\Jobs\FollowPipeline\FollowPipeline; use App\Util\ActivityPub\Validator\Accept as AcceptValidator; +use App\Util\ActivityPub\Validator\Add as AddValidator; use App\Util\ActivityPub\Validator\Announce as AnnounceValidator; use App\Util\ActivityPub\Validator\Follow as FollowValidator; use App\Util\ActivityPub\Validator\Like as LikeValidator; @@ -57,6 +58,12 @@ class Inbox { $verb = (string) $this->payload['type']; switch ($verb) { + + case 'Add': + if(AddValidator::validate($this->payload) == false) { return; } + $this->handleAddActivity(); + break; + case 'Create': $this->handleCreateActivity(); break; @@ -121,6 +128,11 @@ class Inbox return Helpers::profileFetch($actorUrl); } + public function handleAddActivity() + { + // stories ;) + } + public function handleCreateActivity() { $activity = $this->payload['object']; @@ -157,6 +169,15 @@ class Inbox if(!$actor || $actor->domain == null) { return; } + $to = $activity['to']; + $cc = $activity['cc']; + if(count($to) == 1 && + count($cc) == 0 && + parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app') + ) { + $this->handleDirectMessage(); + return; + } if($actor->followers()->count() == 0) { return; @@ -170,6 +191,103 @@ class Inbox return; } + public function handleDirectMessage() + { + $activity = $this->payload['object']; + $actor = $this->actorFirstOrCreate($this->payload['actor']); + $profile = Profile::whereNull('domain') + ->whereUsername(array_last(explode('/', $activity['to'][0]))) + ->firstOrFail(); + + if(in_array($actor->id, $profile->blockedIds()->toArray())) { + return; + } + + $msg = $activity['content']; + $msgText = strip_tags($activity['content']); + + if($profile->user->settings->public_dm == false || $profile->is_private) { + if($profile->follows($actor) == true) { + $hidden = false; + } else { + $hidden = true; + } + } else { + $hidden = false; + } + + $status = new Status; + $status->profile_id = $actor->id; + $status->caption = $msgText; + $status->rendered = $msg; + $status->visibility = 'direct'; + $status->scope = 'direct'; + $status->in_reply_to_profile_id = $profile->id; + $status->save(); + + $dm = new DirectMessage; + $dm->to_id = $profile->id; + $dm->from_id = $actor->id; + $dm->status_id = $status->id; + $dm->is_hidden = $hidden; + $dm->type = $request->input('type'); + $dm->save(); + + if(count($activity['attachment'])) { + $allowed = explode(',', config('pixelfed.media_types')); + foreach($activity['attachment'] as $a) { + $type = $a['mediaType']; + $url = $a['url']; + $valid = self::validateUrl($url); + if(in_array($type, $allowed) == false || $valid == false) { + continue; + } + + $media = new Media(); + $media->remote_media = true; + $media->status_id = $status->id; + $media->profile_id = $status->profile_id; + $media->user_id = null; + $media->media_path = $url; + $media->remote_url = $url; + $media->mime = $type; + $media->save(); + } + } + + if(filter_var($msg, FILTER_VALIDATE_URL)) { + if(Helpers::validateUrl($msg)) { + $dm->type = 'link'; + $dm->meta = [ + 'domain' => parse_url($msg, PHP_URL_HOST), + 'local' => parse_url($msg, PHP_URL_HOST) == + parse_url(config('app.url'), PHP_URL_HOST) + ]; + $dm->save(); + } + } + + $nf = UserFilter::whereUserId($profile->id) + ->whereFilterableId($actor->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('dm.mute') + ->exists(); + + if($profile->domain == null && $hidden == false && !$nf) { + $notification = new Notification(); + $notification->profile_id = $profile->id; + $notification->actor_id = $actor->id; + $notification->action = 'dm'; + $notification->message = $dm->toText(); + $notification->rendered = $dm->toHtml(); + $notification->item_id = $dm->id; + $notification->item_type = "App\DirectMessage"; + $notification->save(); + } + + return; + } + public function handleFollowActivity() { $actor = $this->actorFirstOrCreate($this->payload['actor']); @@ -305,7 +423,20 @@ class Inbox } $actor = $this->payload['actor']; $obj = $this->payload['object']; - if(is_string($obj) == true) { + if(is_string($obj) == true && $actor == $obj && Helpers::validateUrl($obj)) { + $profile = Profile::whereRemoteUrl($obj)->first(); + if(!$profile || $profile->private_key != null) { + return; + } + Notification::whereActorId($profile->id)->delete(); + $profile->avatar()->delete(); + $profile->followers()->delete(); + $profile->following()->delete(); + $profile->likes()->delete(); + $profile->media()->delete(); + $profile->hashtags()->delete(); + $profile->statuses()->delete(); + $profile->delete(); return; } $type = $this->payload['object']['type']; @@ -319,9 +450,7 @@ class Inbox $id = $this->payload['object']['id']; switch ($type) { case 'Person': - // todo: fix race condition - return; - $profile = Helpers::profileFetch($actor); + $profile = Profile::whereRemoteUrl($actor)->first(); if(!$profile || $profile->private_key != null) { return; } @@ -331,6 +460,7 @@ class Inbox $profile->following()->delete(); $profile->likes()->delete(); $profile->media()->delete(); + $profile->hashtags()->delete(); $profile->statuses()->delete(); $profile->delete(); return; diff --git a/app/Util/ActivityPub/Validator/Add.php b/app/Util/ActivityPub/Validator/Add.php new file mode 100644 index 000000000..89a7ec729 --- /dev/null +++ b/app/Util/ActivityPub/Validator/Add.php @@ -0,0 +1,41 @@ + 'required', + 'id' => 'required|string', + 'type' => [ + 'required', + Rule::in(['Add']) + ], + 'actor' => 'required|url', + 'object' => 'required', + 'object.id' => 'required|url', + 'object.type' => [ + 'required', + Rule::in(['Story']) + ], + 'object.attributedTo' => 'required|url|same:actor', + 'object.attachment' => 'required', + 'object.attachment.type' => [ + 'required', + Rule::in(['Image']) + ], + 'object.attachment.url' => 'required|url', + 'object.attachment.mediaType' => [ + 'required', + Rule::in(['image/jpeg', 'image/png']) + ] + ])->passes(); + + return $valid; + } +} \ No newline at end of file diff --git a/app/Util/Lexer/PrettyNumber.php b/app/Util/Lexer/PrettyNumber.php index 2dfa86e6c..4e6a4eba6 100644 --- a/app/Util/Lexer/PrettyNumber.php +++ b/app/Util/Lexer/PrettyNumber.php @@ -4,19 +4,23 @@ namespace App\Util\Lexer; class PrettyNumber { - public static function convert($expression) + public static function convert($number) { + if(!is_integer($number)) { + return $number; + } + $abbrevs = [12 => 'T', 9 => 'B', 6 => 'M', 3 => 'K', 0 => '']; foreach ($abbrevs as $exponent => $abbrev) { - if ($expression >= pow(10, $exponent)) { - $display_num = $expression / pow(10, $exponent); - $num = number_format($display_num, 0).$abbrev; - - return $num; + if(abs($number) >= pow(10, $exponent)) { + $display = $number / pow(10, $exponent); + $decimals = ($exponent >= 3 && round($display) < 100) ? 1 : 0; + $number = number_format($display, $decimals).$abbrev; + break; } } - return $expression; + return $number; } public static function size($expression, $kb = false) diff --git a/app/Util/Media/Image.php b/app/Util/Media/Image.php index 4cbfaca16..bbc877ffa 100644 --- a/app/Util/Media/Image.php +++ b/app/Util/Media/Image.php @@ -165,6 +165,8 @@ class Image $quality = config('pixelfed.image_quality'); $img->save($newPath, $quality); + $media->width = $img->width(); + $media->height = $img->height(); $img->destroy(); if (!$thumbnail) { $media->orientation = $orientation; @@ -178,6 +180,7 @@ class Image $media->mime = $img->mime; } + $media->save(); Cache::forget('status:transformer:media:attachments:'.$media->status_id); Cache::forget('status:thumb:'.$media->status_id); diff --git a/config/cors.php b/config/cors.php index e33f4c445..92b4b8e8c 100644 --- a/config/cors.php +++ b/config/cors.php @@ -21,7 +21,9 @@ return [ * You can enable CORS for 1 or multiple paths. * Example: ['api/*'] */ - 'paths' => [], + 'paths' => [ + '.well-known/*' + ], /* * Matches the request method. `[*]` allows all methods. diff --git a/resources/assets/js/components/Activity.vue b/resources/assets/js/components/Activity.vue index d2e7e0012..ff4d6ccac 100644 --- a/resources/assets/js/components/Activity.vue +++ b/resources/assets/js/components/Activity.vue @@ -52,6 +52,11 @@ {{truncate(n.account.username)}} tagged you in a post.

+
+

+ {{truncate(n.account.username)}} sent a dm. +

+
{{timeAgo(n.created_at)}}
@@ -249,6 +254,9 @@ export default { case 'tagged': return n.tagged.post_url; break; + case 'direct': + return '/account/direct/t/'+n.account.id; + break } return '/'; }, diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 8f36f5ec4..f934527c6 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -237,7 +237,7 @@
- +

{{composeTextLength}}/{{config.uploader.max_caption_length}}

@@ -271,7 +271,7 @@

Audience - {{visibilityTag}} + {{visibilityTag}}

@@ -632,12 +632,13 @@ export default { methods: { fetchProfile() { + let self = this; axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => { - this.profile = res.data; + self.profile = res.data; window.pixelfed.currentUser = res.data; if(res.data.locked == true) { - this.visibility = 'private'; - this.visibilityTag = 'Followers Only'; + self.visibility = 'private'; + self.visibilityTag = 'Followers Only'; } }).catch(err => { }); @@ -663,6 +664,9 @@ export default { let self = this; self.uploading = true; let io = document.querySelector('#pf-dz'); + if(!io.files.length) { + self.uploading = false; + } Array.prototype.forEach.call(io.files, function(io, i) { if(self.media && self.media.length + i >= self.config.uploader.album_limit) { swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error'); diff --git a/resources/assets/js/components/Direct.vue b/resources/assets/js/components/Direct.vue new file mode 100644 index 000000000..f56f395bd --- /dev/null +++ b/resources/assets/js/components/Direct.vue @@ -0,0 +1,400 @@ + + + + + \ No newline at end of file diff --git a/resources/assets/js/components/DirectMessage.vue b/resources/assets/js/components/DirectMessage.vue new file mode 100644 index 000000000..23826b561 --- /dev/null +++ b/resources/assets/js/components/DirectMessage.vue @@ -0,0 +1,648 @@ + + + + + \ No newline at end of file diff --git a/resources/assets/js/components/NotificationCard.vue b/resources/assets/js/components/NotificationCard.vue index 2eb3034c6..f44205317 100644 --- a/resources/assets/js/components/NotificationCard.vue +++ b/resources/assets/js/components/NotificationCard.vue @@ -62,6 +62,11 @@ {{truncate(n.account.username)}} tagged you in a post.

+
+

+ {{truncate(n.account.username)}} sent a dm. +

+

We cannot display this notification at this time. diff --git a/resources/assets/js/components/Profile.vue b/resources/assets/js/components/Profile.vue index f002b5c0d..9a86874f4 100644 --- a/resources/assets/js/components/Profile.vue +++ b/resources/assets/js/components/Profile.vue @@ -102,17 +102,18 @@ - + Message + - + Edit Profile - +

@@ -614,6 +615,12 @@ .modal-tab-active { border-bottom: 1px solid #08d; } + .btn-sec-alt:hover { + color: #ccc; + opacity: .7; + background-color: transparent; + border-color: #6c757d; + } - + @endpush diff --git a/resources/views/account/directmessage.blade.php b/resources/views/account/directmessage.blade.php new file mode 100644 index 000000000..7d316cbe0 --- /dev/null +++ b/resources/views/account/directmessage.blade.php @@ -0,0 +1,12 @@ +@extends('layouts.app') + +@section('content') +
+ +
+@endsection + +@push('scripts') + + +@endpush diff --git a/resources/views/layouts/partial/nav.blade.php b/resources/views/layouts/partial/nav.blade.php index 06771af5b..216852526 100644 --- a/resources/views/layouts/partial/nav.blade.php +++ b/resources/views/layouts/partial/nav.blade.php @@ -38,6 +38,12 @@ Discover +
--}} - {{--
- -
{{--