From 67e3f6048f52583f0db3a0d61dfd4fc0ef4c670c Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 23 Jul 2021 09:47:14 -0600 Subject: [PATCH 01/92] Update Settings, add default license and enforced media descriptions --- app/Http/Controllers/ComposeController.php | 24 ++++- app/Http/Controllers/SettingsController.php | 50 +++++++++- ...ompose_settings_to_user_settings_table.php | 46 +++++++++ resources/assets/js/components/Activity.vue | 15 +-- .../assets/js/components/ComposeModal.vue | 93 +++++++++++++++---- .../assets/js/components/NotificationCard.vue | 4 +- resources/views/settings/media.blade.php | 49 ++++++++++ .../views/settings/partial/sidebar.blade.php | 3 + routes/web.php | 3 + 9 files changed, 252 insertions(+), 35 deletions(-) create mode 100644 database/migrations/2021_07_23_062326_add_compose_settings_to_user_settings_table.php create mode 100644 resources/views/settings/media.blade.php diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index b20afab99..56e137441 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -15,7 +15,8 @@ use App\{ Profile, Place, Status, - UserFilter + UserFilter, + UserSetting }; use App\Transformer\Api\{ MediaTransformer, @@ -661,4 +662,25 @@ class ComposeController extends Controller 'finished' => $finished ]; } + + public function composeSettings(Request $request) + { + $uid = $request->user()->id; + + return Cache::remember('profile:compose:settings:' . $uid, now()->addHours(12), function() use($uid) { + $res = UserSetting::whereUserId($uid)->first(); + + if(!$res) { + return [ + 'default_license' => null, + 'media_descriptions' => false + ]; + } + + return json_decode($res->compose_settings, true) ?? [ + 'default_license' => null, + 'media_descriptions' => false + ]; + }); + } } diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index 65dfca5d0..3f8ef9879 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -7,6 +7,7 @@ use App\Following; use App\ProfileSponsor; use App\Report; use App\UserFilter; +use App\UserSetting; use Auth, Cookie, DB, Cache, Purify; use Illuminate\Support\Facades\Redis; use Carbon\Carbon; @@ -221,7 +222,7 @@ class SettingsController extends Controller $sponsors->sponsors = json_encode($res); $sponsors->save(); $sponsors = $res; - return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');; + return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!'); } public function timelineSettings(Request $request) @@ -249,7 +250,52 @@ class SettingsController extends Controller } else { Redis::zrem('pf:tl:replies', $pid); } - return redirect(route('settings.timeline')); + return redirect(route('settings'))->with('status', 'Timeline settings successfully updated!');; + } + + public function mediaSettings(Request $request) + { + $setting = UserSetting::whereUserId($request->user()->id)->firstOrFail(); + $compose = $setting->compose_settings ? json_decode($setting->compose_settings, true) : [ + 'default_license' => null, + 'media_descriptions' => false + ]; + return view('settings.media', compact('compose')); + } + + public function updateMediaSettings(Request $request) + { + $this->validate($request, [ + 'default' => 'required|int|min:1|max:16', + 'sync' => 'nullable', + 'media_descriptions' => 'nullable' + ]); + + $license = $request->input('default'); + $sync = $request->input('sync') == 'on'; + $media_descriptions = $request->input('media_descriptions') == 'on'; + + $setting = UserSetting::whereUserId($request->user()->id)->firstOrFail(); + $compose = json_decode($setting->compose_settings, true); + $changed = false; + + if(!isset($compose['default_license']) || $compose['default_license'] !== $license) { + $compose['default_license'] = (int) $license; + $changed = true; + } + + if(!isset($compose['media_descriptions']) || $compose['media_descriptions'] !== $media_descriptions) { + $compose['media_descriptions'] = $media_descriptions; + $changed = true; + } + + if($changed) { + $setting->compose_settings = json_encode($compose); + $setting->save(); + Cache::forget('profile:compose:settings:' . $request->user()->id); + } + + return redirect(route('settings'))->with('status', 'Media settings successfully updated!'); } } diff --git a/database/migrations/2021_07_23_062326_add_compose_settings_to_user_settings_table.php b/database/migrations/2021_07_23_062326_add_compose_settings_to_user_settings_table.php new file mode 100644 index 000000000..58837cab3 --- /dev/null +++ b/database/migrations/2021_07_23_062326_add_compose_settings_to_user_settings_table.php @@ -0,0 +1,46 @@ +json('compose_settings')->nullable(); + }); + + Schema::table('media', function (Blueprint $table) { + $table->text('caption')->change(); + $table->index('profile_id'); + $table->index('mime'); + $table->index('license'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('user_settings', function (Blueprint $table) { + $table->dropColumn('compose_settings'); + }); + + Schema::table('media', function (Blueprint $table) { + $table->string('caption')->change(); + $table->dropIndex('profile_id'); + $table->dropIndex('mime'); + $table->dropIndex('license'); + }); + } +} diff --git a/resources/assets/js/components/Activity.vue b/resources/assets/js/components/Activity.vue index a5ef1525b..751cab0ae 100644 --- a/resources/assets/js/components/Activity.vue +++ b/resources/assets/js/components/Activity.vue @@ -134,20 +134,16 @@ export default { window._sharedData.curUser = res.data; window.App.util.navatar(); }); - axios.get('/api/pixelfed/v1/notifications', { - params: { - pg: true - } - }) + axios.get('/api/pixelfed/v1/notifications?pg=true') .then(res => { let data = res.data.filter(n => { - if(n.type == 'share' && !status) { + if(n.type == 'share' && !n.status) { return false; } - if(n.type == 'comment' && !status) { + if(n.type == 'comment' && !n.status) { return false; } - if(n.type == 'mention' && !status) { + if(n.type == 'mention' && !n.status) { return false; } return true; @@ -167,8 +163,7 @@ export default { } axios.get('/api/pixelfed/v1/notifications', { params: { - pg: true, - page: this.notificationCursor + max_id: this.notificationMaxId } }).then(res => { if(res.data.length) { diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 0aa4f8b63..893bacf92 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -100,10 +100,10 @@ v-for="(item, index) in availableLicenses" class="list-group-item cursor-pointer" :class="{ - 'text-primary': licenseIndex === index, - 'font-weight-bold': licenseIndex === index + 'text-primary': licenseId === item.id, + 'font-weight-bold': licenseId === item.id }" - @click="toggleLicense(index)"> + @click="toggleLicense(item)"> {{item.name}} @@ -336,7 +336,13 @@

Tag people

-

Add license NEW

+

+ Add license NEW + + {{licenseTitle}} + + +

Add location

@@ -591,11 +597,11 @@ {{media[carouselCursor].license ? media[carouselCursor].license.length : 0}}/140

--> - @@ -845,52 +851,79 @@ export default { availableLicenses: [ { id: 1, - name: "All Rights Reserved" + name: "All Rights Reserved", + title: "" }, { id: 5, - name: "Public Domain Work" + name: "Public Domain Work", + title: "" }, { id: 6, - name: "Public Domain Dedication (CC0)" + name: "Public Domain Dedication (CC0)", + title: "CC0" }, { id: 11, - name: "Attribution" + name: "Attribution", + title: "CC BY" }, { id: 12, - name: "Attribution-ShareAlike" + name: "Attribution-ShareAlike", + title: "CC BY-SA" }, { id: 13, - name: "Attribution-NonCommercial" + name: "Attribution-NonCommercial", + title: "CC BY-NC" }, { id: 14, - name: "Attribution-NonCommercial-ShareAlike" + name: "Attribution-NonCommercial-ShareAlike", + title: "CC BY-NC-SA" }, { id: 15, - name: "Attribution-NoDerivs" + name: "Attribution-NoDerivs", + title: "CC BY-ND" }, { id: 16, - name: "Attribution-NonCommercial-NoDerivs" + name: "Attribution-NonCommercial-NoDerivs", + title: "CC BY-NC-ND" } ], licenseIndex: 0, video: { title: '', description: '' - } + }, + composeSettings: { + default_license: null, + media_descriptions: false + }, + licenseId: null, + licenseTitle: null } }, beforeMount() { this.fetchProfile(); this.filters = window.App.util.filters; + axios.get('/api/compose/v0/settings') + .then(res => { + this.composeSettings = res.data; + this.licenseId = this.composeSettings.default_license; + if(this.licenseId > 10) { + this.licenseTitle = this.availableLicenses.filter(l => { + return l.id == this.licenseId; + }).map(l => { + return l.title; + })[0]; + } + }); }, mounted() { @@ -1064,6 +1097,16 @@ export default { switch(state) { case 'publish' : + if(this.composeSettings.media_descriptions === true) { + let count = this.media.filter(m => { + return !m.hasOwnProperty('alt') || m.alt.length < 2; + }); + + if(count.length) { + swal('Missing media descriptions', 'You have enabled mandatory media descriptions. Please add media descriptions under Advanced settings to proceed. For more information, please see the media settings page.', 'warning'); + return; + } + } if(this.media.length == 0) { swal('Whoops!', 'You need to add media before you can save this!', 'warning'); return; @@ -1080,7 +1123,7 @@ export default { place: this.place, tagged: this.taggedUsernames, optimize_media: this.optimizeMedia, - license: this.availableLicenses[this.licenseIndex].id, + license: this.licenseId, video: this.video }; axios.post('/api/compose/v0/publish', data) @@ -1515,8 +1558,18 @@ export default { this.page = 'licensePicker'; }, - toggleLicense(index) { - this.licenseIndex = index; + toggleLicense(license) { + this.licenseId = license.id; + + if(this.licenseId > 10) { + this.licenseTitle = this.availableLicenses.filter(l => { + return l.id == this.licenseId; + }).map(l => { + return l.title; + })[0]; + } else { + this.licenseTitle = null; + } switch(this.mode) { case 'photo': diff --git a/resources/assets/js/components/NotificationCard.vue b/resources/assets/js/components/NotificationCard.vue index a52bde62f..db4139cc6 100644 --- a/resources/assets/js/components/NotificationCard.vue +++ b/resources/assets/js/components/NotificationCard.vue @@ -120,7 +120,7 @@ setTimeout(function() { self.profile = window._sharedData.curUser; self.fetchFollowRequests(); - }, 500); + }, 1500); }, updated() { @@ -157,7 +157,7 @@ } axios.get('/api/pixelfed/v1/notifications', { params: { - page: this.notificationCursor + max_id: this.notificationMaxId } }).then(res => { if(res.data.length) { diff --git a/resources/views/settings/media.blade.php b/resources/views/settings/media.blade.php new file mode 100644 index 000000000..4375ecd9c --- /dev/null +++ b/resources/views/settings/media.blade.php @@ -0,0 +1,49 @@ +@extends('settings.template') + +@section('section') + +
+

Media

+
+
+
+ @csrf +
+ + +

Set a default license for new posts.

+
+ +
+ + +

Update existing posts with your new default license. You can sync once every 24 hours.

+
+ +
+ + +

+ Briefly describe your media to improve accessibility for vision impaired people.
+ Not available for mobile or 3rd party apps at this time. +

+
+ +
+
+
+ +
+
+
+ +@endsection diff --git a/resources/views/settings/partial/sidebar.blade.php b/resources/views/settings/partial/sidebar.blade.php index 2be95ca2a..34f12971b 100644 --- a/resources/views/settings/partial/sidebar.blade.php +++ b/resources/views/settings/partial/sidebar.blade.php @@ -14,6 +14,9 @@ Invites @endif + diff --git a/routes/web.php b/routes/web.php index 2300a5ca8..77e834b2f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -119,6 +119,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::post('/publish', 'ComposeController@store'); Route::post('/publish/text', 'ComposeController@storeText'); Route::get('/media/processing', 'ComposeController@mediaProcessingCheck'); + Route::get('/settings', 'ComposeController@composeSettings'); }); }); @@ -429,6 +430,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('timeline', 'SettingsController@timelineSettings')->name('settings.timeline'); Route::post('timeline', 'SettingsController@updateTimelineSettings'); + Route::get('media', 'SettingsController@mediaSettings')->name('settings.media'); + Route::post('media', 'SettingsController@updateMediaSettings'); }); Route::group(['prefix' => 'site'], function () { From 072d55d1a897cb6b5f96c89d5cc3bb4265653b74 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 21:15:15 -0600 Subject: [PATCH 02/92] Update Compose Apis, make media descriptions/alt text length limit configurable. Default length: 1000 --- app/Http/Controllers/Api/ApiV1Controller.php | 4 ++-- app/Http/Controllers/ComposeController.php | 23 +++++++++---------- config/pixelfed.php | 2 ++ .../assets/js/components/ComposeModal.vue | 10 ++++---- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 5c9f95442..801f96f9c 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -1048,7 +1048,7 @@ class ApiV1Controller extends Controller }, 'filter_name' => 'nullable|string|max:24', 'filter_class' => 'nullable|alpha_dash|max:24', - 'description' => 'nullable|string|max:420' + 'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length') ]); $user = $request->user(); @@ -1140,7 +1140,7 @@ class ApiV1Controller extends Controller abort_if(!$request->user(), 403); $this->validate($request, [ - 'description' => 'nullable|string|max:420' + 'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length') ]); $user = $request->user(); diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index 56e137441..50e8bf5fa 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -404,7 +404,7 @@ class ComposeController extends Controller 'media.*.id' => 'required|integer|min:1', 'media.*.filter_class' => 'nullable|alpha_dash|max:30', 'media.*.license' => 'nullable|string|max:140', - 'media.*.alt' => 'nullable|string|max:140', + 'media.*.alt' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'), 'cw' => 'nullable|boolean', 'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10', 'place' => 'nullable', @@ -666,21 +666,20 @@ class ComposeController extends Controller public function composeSettings(Request $request) { $uid = $request->user()->id; + $default = [ + 'default_license' => 1, + 'media_descriptions' => false, + 'max_altext_length' => config_cache('pixelfed.max_altext_length') + ]; - return Cache::remember('profile:compose:settings:' . $uid, now()->addHours(12), function() use($uid) { + return array_merge($default, Cache::remember('profile:compose:settings:' . $uid, now()->addHours(12), function() use($uid) { $res = UserSetting::whereUserId($uid)->first(); - if(!$res) { - return [ - 'default_license' => null, - 'media_descriptions' => false - ]; + if(!$res || empty($res->compose_settings)) { + return []; } - return json_decode($res->compose_settings, true) ?? [ - 'default_license' => null, - 'media_descriptions' => false - ]; - }); + return json_decode($res->compose_settings, true); + })); } } diff --git a/config/pixelfed.php b/config/pixelfed.php index 0fd26b334..e3cfe2495 100644 --- a/config/pixelfed.php +++ b/config/pixelfed.php @@ -278,4 +278,6 @@ return [ | */ 'media_fast_process' => env('PF_MEDIA_FAST_PROCESS', true), + + 'max_altext_length' => env('PF_MEDIA_MAX_ALTTEXT_LENGTH', 1000), ]; diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 893bacf92..0171d236c 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -536,8 +536,8 @@
- -

{{m.alt ? m.alt.length : 0}}/140

+ +

{{m.alt ? m.alt.length : 0}}/{{maxAltTextLength}}


@@ -904,8 +904,9 @@ export default { default_license: null, media_descriptions: false }, - licenseId: null, - licenseTitle: null + licenseId: 1, + licenseTitle: null, + maxAltTextLength: 140 } }, @@ -916,6 +917,7 @@ export default { .then(res => { this.composeSettings = res.data; this.licenseId = this.composeSettings.default_license; + this.maxAltTextLength = res.data.max_altext_length; if(this.licenseId > 10) { this.licenseTitle = this.availableLicenses.filter(l => { return l.id == this.licenseId; From ea0fc90c92f6e493ada8e466ad51ee089ddf0674 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 22:13:14 -0600 Subject: [PATCH 03/92] Add default licenses and license sync --- app/Http/Controllers/SettingsController.php | 20 +++++++- .../MediaSyncLicensePipeline.php | 47 +++++++++++++++++++ resources/views/settings/media.blade.php | 2 +- 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 app/Jobs/MediaPipeline/MediaSyncLicensePipeline.php diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index 3f8ef9879..bb7677373 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -22,6 +22,7 @@ use App\Http\Controllers\Settings\{ SecuritySettings }; use App\Jobs\DeletePipeline\DeleteAccountPipeline; +use App\Jobs\MediaPipeline\MediaSyncLicensePipeline; class SettingsController extends Controller { @@ -274,11 +275,21 @@ class SettingsController extends Controller $license = $request->input('default'); $sync = $request->input('sync') == 'on'; $media_descriptions = $request->input('media_descriptions') == 'on'; + $uid = $request->user()->id; - $setting = UserSetting::whereUserId($request->user()->id)->firstOrFail(); + $setting = UserSetting::whereUserId($uid)->firstOrFail(); $compose = json_decode($setting->compose_settings, true); $changed = false; + if($sync) { + $key = 'pf:settings:mls_recently:'.$uid; + if(Cache::get($key) == 2) { + $msg = 'You can only sync licenses twice per 24 hours. Try again later.'; + return redirect(route('settings')) + ->with('error', $msg); + } + } + if(!isset($compose['default_license']) || $compose['default_license'] !== $license) { $compose['default_license'] = (int) $license; $changed = true; @@ -295,6 +306,13 @@ class SettingsController extends Controller Cache::forget('profile:compose:settings:' . $request->user()->id); } + if($sync) { + $val = Cache::has($key) ? 2 : 1; + Cache::put($key, $val, 86400); + MediaSyncLicensePipeline::dispatch($uid, $license); + return redirect(route('settings'))->with('status', 'Media licenses successfully synced! It may take a few minutes to take effect for every post.'); + } + return redirect(route('settings'))->with('status', 'Media settings successfully updated!'); } diff --git a/app/Jobs/MediaPipeline/MediaSyncLicensePipeline.php b/app/Jobs/MediaPipeline/MediaSyncLicensePipeline.php new file mode 100644 index 000000000..1884bd780 --- /dev/null +++ b/app/Jobs/MediaPipeline/MediaSyncLicensePipeline.php @@ -0,0 +1,47 @@ +userId = $userId; + $this->licenseId = $licenseId; + } + + public function handle() + { + $licenseId = $this->licenseId; + + if(!$licenseId || !$this->userId) { + return 1; + } + + Media::whereUserId($this->userId) + ->chunk(100, function($medias) use($licenseId) { + foreach($medias as $media) { + $media->license = $licenseId; + $media->save(); + Cache::forget('status:transformer:media:attachments:'. $media->status_id); + StatusService::del($media->status_id); + } + }); + } + +} diff --git a/resources/views/settings/media.blade.php b/resources/views/settings/media.blade.php index 4375ecd9c..a37c05bf3 100644 --- a/resources/views/settings/media.blade.php +++ b/resources/views/settings/media.blade.php @@ -26,7 +26,7 @@
-

Update existing posts with your new default license. You can sync once every 24 hours.

+

Update existing posts with your new default license. You can sync twice every 24 hours.
License changes may not be reflected on remote servers.

From 833d110c9ee9f95453c89a4be65143477eef3782 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 22:14:58 -0600 Subject: [PATCH 04/92] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a05796a79..66d6b841b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Auto Following support for admins ([68aa2540](https://github.com/pixelfed/pixelfed/commit/68aa2540)) - Mark as spammer mod tool, unlists and applies content warning to existing and future post ([6d956a86](https://github.com/pixelfed/pixelfed/commit/6d956a86)) - Diagnostics for error page and admin dashboard ([64725ecc](https://github.com/pixelfed/pixelfed/commit/64725ecc)) +- Default media licenses and media license sync ([ea0fc90c](https://github.com/pixelfed/pixelfed/commit/ea0fc90c)) +- Customize media description/alt-text length limit ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1)) ### Updated - Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b)) @@ -73,6 +75,8 @@ - Updated RemotePost.vue, improve text only post UI. ([b0257be2](https://github.com/pixelfed/pixelfed/commit/b0257be2)) - Updated Timeline, make text-only posts opt-in by default. ([0153ed6d](https://github.com/pixelfed/pixelfed/commit/0153ed6d)) - Updated LikeController, add UndoLikePipeline and federate Undo Like activities. ([8ac8fcad](https://github.com/pixelfed/pixelfed/commit/8ac8fcad)) +- Updated Settings, add default license and enforced media descriptions. ([67e3f604](https://github.com/pixelfed/pixelfed/commit/67e3f604)) +- Updated Compose Apis, make media descriptions/alt text length limit configurable. Default length: 1000. ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0) From 2a791f1991ab765c362e154780099286ed66686b Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 22:20:05 -0600 Subject: [PATCH 05/92] Update ApiV1Controller, add default license support --- app/Http/Controllers/Api/ApiV1Controller.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 801f96f9c..6a4593823 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -1091,6 +1091,17 @@ class ApiV1Controller extends Controller $storagePath = MediaPathService::get($user, 2); $path = $photo->store($storagePath); $hash = \hash_file('sha256', $photo); + $license = null; + + $settings = UserSetting::whereUserId($user->id)->first(); + + if($settings && !empty($settings->compose_settings)) { + $compose = json_decode($settings->compose_settings, true); + + if(isset($compose['default_license']) && $compose['default_license'] != 1) { + $license = $compose['default_license']; + } + } abort_if(MediaBlocklistService::exists($hash) == true, 451); @@ -1105,6 +1116,9 @@ class ApiV1Controller extends Controller $media->caption = $request->input('description'); $media->filter_class = $filterClass; $media->filter_name = $filterName; + if($license) { + $media->license = $license; + } $media->save(); switch ($media->mime) { From 7314065a2ab29b6ef263430a739a77ea0f2ba172 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 22:20:44 -0600 Subject: [PATCH 06/92] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d6b841b..122eaec72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ - Updated LikeController, add UndoLikePipeline and federate Undo Like activities. ([8ac8fcad](https://github.com/pixelfed/pixelfed/commit/8ac8fcad)) - Updated Settings, add default license and enforced media descriptions. ([67e3f604](https://github.com/pixelfed/pixelfed/commit/67e3f604)) - Updated Compose Apis, make media descriptions/alt text length limit configurable. Default length: 1000. ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1)) +- Updated ApiV1Controller, add default license support. ([2a791f19](https://github.com/pixelfed/pixelfed/commit/2a791f19)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0) From 8001ad998c7c1168bc47541bde3be1bd57ea772e Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 22:21:27 -0600 Subject: [PATCH 07/92] Update compiled assets --- public/js/activity.js | Bin 9648 -> 9640 bytes public/js/compose.js | Bin 148285 -> 150064 bytes public/js/timeline.js | Bin 193402 -> 193404 bytes public/mix-manifest.json | Bin 2125 -> 2125 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/public/js/activity.js b/public/js/activity.js index 27da599a11a1efbcc47e65734bc2de246f07adbf..aae83d388864c24fd8924160c602dfb3a5b96ac9 100644 GIT binary patch delta 78 zcmdnsy~2CL3Jy;Df^^%GqS92Q$-6lGnM(8~+i)v@C{0dzAhp?pGnY{|H?blBr`wHH?hJqW%G5en^FK5IU9Wd delta 83 zcmZ4Cy}^6K3JxJ9o$7+bqQu-{tLlPuD@B9a+Q}O^{3n}mYfg^imfh^bnZ~FDme46k kOi#5c$;d3$%gZmxOv_A8EXmBzb1p3^&M(@0p6jL*0M?csP5=M^ diff --git a/public/js/compose.js b/public/js/compose.js index e176e9f5e3690187152ac705aa8eb9cb8f04652f..9262eb7df5cb78c73919cd8d0600c8d2c5886e3d 100644 GIT binary patch delta 1987 zcmZWqOKcNI7}h_wNkXBmlQ<#fkXY7jXjY+lNwH=!{r=4%+ ze|-P{e=~dcT+PCTnuR9a(-X;Rvc&i!k;qV|no*SS5Z|2>bjH=)=?`}=4(J~v=vl1G zgdFtq>csSB*q!VAedFT0bF($h1F2q-^qz&5H3Ol+6D+sbb~)>C+Us0rq@g+@`&cO$ zl5#`0-denJlenFEbmiLX)qzngiM!&7+>>jAWiT{|^~o|An)l`&UEf*W zXuC052D3LJW$?`nr3?<-JmQptW5;cBi?7{!*bO}-2*A?D6umcoGmuZk&Z9yLu}9?h7trm|%-X75olG7W7_E4FqFM3>!zWc>^< z?v*Y(db`Mn)9^ywO0V87>PQ|=L+kSu2m6R~27;^2d-suDGtfT8$F)>iGw=X5O<5f? z!Y3u15HpGyQ{*^S4IH+`!>6z$i!lisaa~T^l^Efcz$9O^*-xuoUJvGbRB#d}qKY|) zC(MIb9W#^RK*&Ei$&ccMrsG3eMm58^tKT-9zvLp*lqB$^^BiohDiC4G|x@a%WRL7Gx>Fw( zWn)zlt6R>=318kcesWE9?i(@Pa%qyDCq^M9B zS*3bOiMHx^CS8bYV9c0FT#UW36d)~*VS&;e7j>1^!g718e=fi~P`x}A>w`H!O}Rf3 zE)rwF_0k?=TsGs$LSL7SBsxY};`6;O?NK#!KD|>=135DX4OQh?G}hWGz^hcyx>5z< z)sgHgLq3^@`{eBfC_KT|dkb*4zM2N45oL)SyANB+sO1OnkYnG$S~nG#Ha1q$qYo4J zTC|1y+k_ah#f#QiPwqoU8+pTlYRS$V{s#;xAo}~ zT=1ZPpUf^ny_IsHHyscl{a-+n1yzW3c~@UfUf0HrFdHIm>(JL;c6*US=b= zS&wEBw3Gg=sE(ZZm8Oh&(P^;uyOFo5QR4ALTE|q`ni^(H!z5=mpwB@f{Y@y@Z1Q5u>&r4Jf^%y-=F9kw@Bs~;9v{|`)I(*JKzu)ruEMlj*6kyF7{3;1dFTLO1BlNQQ=?sBxdAvfE#0 zQOvUj)!8;jrBX9Ho_I4guSv|!gfn(h6Ezh|e2G!p9Tkp%(_gMUU2021;qv7)Of5&! zpra#cC|ouu59!f#VuN;T{{84xW>y#_yi%Ah$5*5@Xjj`+=UM{oDV2#YvsZEwJ*&TX z;PE_wEg50}40v!3zzH7u0igK=@RW0d&X&bnMYfOi*;pPu0Vqzg{Fmu)0JaSoyws#z zn=xy5_Fx-fhozIwWWq$_qtKa&+hbtHk|>m6R}>1=BkWbt?{btQ#6XwT+UE-jwUT&A z!1X8`#-CAeVr>i#{p+ht^_g)d1{!=E0|$D?!GPDt;TYYz0o7W1Hx4(U#24|2lCN75 z&WJsN91=pE;XX0MNq?wO_6tK^PNY@SK=Sd(eb8$EFQMliyujCspivDc-OzW7u$HS% zJxbnzjCWo_QJQ=|z?2i_!u`FyyW^}fHfNI}JjDSS?NU?G|kL1{HW=6I66#3nug6I65{V7aQzEe|r{S9eK>6*eMXrL36jtg7W^(b={vku(k~A1?p^ diff --git a/public/js/timeline.js b/public/js/timeline.js index 23b0783e3a99bdff7b38351c9c73fb94cf8f295f..943473953e8c44079743f3ca0120523725fe9213 100644 GIT binary patch delta 80 zcmezMjQh_s?uHh|Ele{@SPV@K45s&UGl^~ARKmo}B%7O95ucf2Rg#fete2Nxl9`s7 koLG{XpXZxc;hD1iVi{A6B8Ne8N}{ouY0`Gn8BDIx0MR5JBme*a delta 83 zcmV-Z0IdJ~<_r4f3xI?Hv;vuQ12r%(myZhq7Pqr>0s{gZaA9XOvqMO!(|9!&rM diff --git a/public/mix-manifest.json b/public/mix-manifest.json index fdf5fbc077d6d65af9cc63dde7f0238b620529bd..5b393095357ec65cc7e73d52a262d6b10478389c 100644 GIT binary patch delta 82 zcmX>ra8_W#QxP*0v&6I{bF(yaQwyUMOT$zPW5bOfPCMP9Y7@1fa8l@N*TNs(9 mrWhn`zRaA&B9W42W|3x@mTYR4YG#sXV3CrNYM{hb%LM>gIT=R) delta 82 zcmX>ra8_W#QxTIy3lk#?19M|z!!!%?L_^b5^R$gWk1>fDSfr#S8k?J&nkJc=Bqt>q mn5HFezRaA&B4K83VPTk(W@2e#YG#p|Y+{_6Vxh!U%LM>EU>K7C From 09d5198c5546ee224263350ef7eef70d77a1adab Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 23:10:44 -0600 Subject: [PATCH 08/92] Update StatusTransformers, remove includes and use cached services --- app/Services/MediaService.php | 40 +++++++++++++++++++ app/Services/StatusHashtagService.php | 18 +++++++++ .../Api/StatusStatelessTransformer.php | 37 +++-------------- app/Transformer/Api/StatusTransformer.php | 37 +++-------------- 4 files changed, 70 insertions(+), 62 deletions(-) create mode 100644 app/Services/MediaService.php diff --git a/app/Services/MediaService.php b/app/Services/MediaService.php new file mode 100644 index 000000000..f1230c7d8 --- /dev/null +++ b/app/Services/MediaService.php @@ -0,0 +1,40 @@ +type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) { + $media = Media::whereStatusId($status->id)->orderBy('order')->get(); + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Collection($media, new MediaTransformer()); + return $fractal->createData($resource)->toArray(); + } + return []; + }); + } + + public static function del($statusId) + { + return Cache::forget(self::CACHE_KEY . $statusId); + } +} diff --git a/app/Services/StatusHashtagService.php b/app/Services/StatusHashtagService.php index 6863c1d05..d0d8b2550 100644 --- a/app/Services/StatusHashtagService.php +++ b/app/Services/StatusHashtagService.php @@ -6,6 +6,7 @@ use Cache; use Illuminate\Support\Facades\Redis; use App\{Status, StatusHashtag}; use App\Transformer\Api\StatusHashtagTransformer; +use App\Transformer\Api\HashtagTransformer; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Pagination\IlluminatePaginatorAdapter; @@ -78,4 +79,21 @@ class StatusHashtagService { { return ['status' => StatusService::get($statusId)]; } + + public static function statusTags($statusId) + { + $key = 'pf:services:sh:id:' . $statusId; + + return Cache::remember($key, 604800, function() use($statusId) { + $status = Status::find($statusId); + if(!$status) { + return []; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Collection($status->hashtags, new HashtagTransformer()); + return $fractal->createData($resource)->toArray(); + }); + } } diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index b3ba463da..fe433d988 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -7,18 +7,14 @@ use League\Fractal; use Cache; use App\Services\HashidService; use App\Services\LikeService; +use App\Services\MediaService; use App\Services\MediaTagService; +use App\Services\StatusHashtagService; use App\Services\StatusLabelService; use App\Services\ProfileService; class StatusStatelessTransformer extends Fractal\TransformerAbstract { - protected $defaultIncludes = [ - 'account', - 'tags', - 'media_attachments', - ]; - public function transform(Status $status) { $taggedPeople = MediaTagService::get($status->id); @@ -62,31 +58,10 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'local' => (bool) $status->local, 'taggedPeople' => $taggedPeople, 'label' => StatusLabelService::get($status), - 'liked_by' => LikeService::likedBy($status) + 'liked_by' => LikeService::likedBy($status), + 'media_attachments' => MediaService::get($status->id), + 'account' => ProfileService::get($status->profile_id), + 'tags' => StatusHashtagService::statusTags($status->id) ]; } - - public function includeAccount(Status $status) - { - $account = $status->profile; - - return $this->item($account, new AccountTransformer()); - } - - public function includeTags(Status $status) - { - $tags = $status->hashtags; - - return $this->collection($tags, new HashtagTransformer()); - } - - public function includeMediaAttachments(Status $status) - { - return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(3), function() use($status) { - if(in_array($status->type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) { - $media = $status->media()->orderBy('order')->get(); - return $this->collection($media, new MediaTransformer()); - } - }); - } } diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index 4ff621872..481d55a62 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -8,19 +8,15 @@ use League\Fractal; use Cache; use App\Services\HashidService; use App\Services\LikeService; +use App\Services\MediaService; use App\Services\MediaTagService; +use App\Services\StatusHashtagService; use App\Services\StatusLabelService; use App\Services\ProfileService; use Illuminate\Support\Str; class StatusTransformer extends Fractal\TransformerAbstract { - protected $defaultIncludes = [ - 'account', - 'tags', - 'media_attachments', - ]; - public function transform(Status $status) { $taggedPeople = MediaTagService::get($status->id); @@ -64,31 +60,10 @@ class StatusTransformer extends Fractal\TransformerAbstract 'local' => (bool) $status->local, 'taggedPeople' => $taggedPeople, 'label' => StatusLabelService::get($status), - 'liked_by' => LikeService::likedBy($status) + 'liked_by' => LikeService::likedBy($status), + 'media_attachments' => MediaService::get($status->id), + 'account' => ProfileService::get($status->profile_id), + 'tags' => StatusHashtagService::statusTags($status->id) ]; } - - public function includeAccount(Status $status) - { - $account = $status->profile; - - return $this->item($account, new AccountTransformer()); - } - - public function includeTags(Status $status) - { - $tags = $status->hashtags; - - return $this->collection($tags, new HashtagTransformer()); - } - - public function includeMediaAttachments(Status $status) - { - return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(14), function() use($status) { - if(in_array($status->type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) { - $media = $status->media()->orderBy('order')->get(); - return $this->collection($media, new MediaTransformer()); - } - }); - } } From 1054b025b1b34df9caf0072e63b0d0d8f498dc6a Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 23:16:01 -0600 Subject: [PATCH 09/92] Update StatusTransformer --- .../Api/Mastodon/v1/StatusTransformer.php | 121 +++++++----------- 1 file changed, 49 insertions(+), 72 deletions(-) diff --git a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php index deadd5859..43ebccc5d 100644 --- a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php +++ b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php @@ -5,81 +5,58 @@ namespace App\Transformer\Api\Mastodon\v1; use App\Status; use League\Fractal; use Cache; +use App\Services\MediaService; +use App\Services\ProfileService; +use App\Services\StatusHashtagService; class StatusTransformer extends Fractal\TransformerAbstract { - protected $defaultIncludes = [ - 'account', - 'media_attachments', - 'mentions', - 'tags', - ]; + protected $defaultIncludes = [ + 'mentions', + ]; - public function transform(Status $status) - { - return [ - 'id' => (string) $status->id, - 'created_at' => $status->created_at->toJSON(), - 'in_reply_to_id' => $status->in_reply_to_id ? (string) $status->in_reply_to_id : null, - 'in_reply_to_account_id' => $status->in_reply_to_profile_id, - 'sensitive' => (bool) $status->is_nsfw, - 'spoiler_text' => $status->cw_summary ?? '', - 'visibility' => $status->visibility ?? $status->scope, - 'language' => 'en', - 'uri' => $status->url(), - 'url' => $status->url(), - 'replies_count' => 0, - 'reblogs_count' => $status->reblogs_count ?? 0, - 'favourites_count' => $status->likes_count ?? 0, - 'reblogged' => $status->shared(), - 'favourited' => $status->liked(), - 'muted' => false, - 'bookmarked' => false, - 'pinned' => false, - 'content' => $status->rendered ?? $status->caption ?? '', - 'reblog' => null, - 'application' => [ - 'name' => 'web', - 'website' => null - ], - 'mentions' => [], - 'tags' => [], - 'emojis' => [], - 'card' => null, - 'poll' => null, - ]; - } + public function transform(Status $status) + { + return [ + 'id' => (string) $status->id, + 'created_at' => $status->created_at->toJSON(), + 'in_reply_to_id' => $status->in_reply_to_id ? (string) $status->in_reply_to_id : null, + 'in_reply_to_account_id' => $status->in_reply_to_profile_id, + 'sensitive' => (bool) $status->is_nsfw, + 'spoiler_text' => $status->cw_summary ?? '', + 'visibility' => $status->visibility ?? $status->scope, + 'language' => 'en', + 'uri' => $status->url(), + 'url' => $status->url(), + 'replies_count' => 0, + 'reblogs_count' => $status->reblogs_count ?? 0, + 'favourites_count' => $status->likes_count ?? 0, + 'reblogged' => $status->shared(), + 'favourited' => $status->liked(), + 'muted' => false, + 'bookmarked' => false, + 'pinned' => false, + 'content' => $status->rendered ?? $status->caption ?? '', + 'reblog' => null, + 'application' => [ + 'name' => 'web', + 'website' => null + ], + 'mentions' => [], + 'tags' => [], + 'emojis' => [], + 'card' => null, + 'poll' => null, + 'media_attachments' => MediaService::get($status->id), + 'account' => ProfileService::get($status->profile_id), + 'tags' => StatusHashtagService::statusTags($status->id) + ]; + } - public function includeAccount(Status $status) - { - $account = $status->profile; + public function includeMentions(Status $status) + { + $mentions = $status->mentions; - return $this->item($account, new AccountTransformer()); - } - - public function includeMediaAttachments(Status $status) - { - return Cache::remember('mastoapi:status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) { - if(in_array($status->type, ['photo', 'video', 'photo:album', 'loop', 'photo:video:album'])) { - $media = $status->media()->orderBy('order')->get(); - return $this->collection($media, new MediaTransformer()); - } else { - return $this->collection([], new MediaTransformer()); - } - }); - } - - public function includeMentions(Status $status) - { - $mentions = $status->mentions; - - return $this->collection($mentions, new MentionTransformer()); - } - - public function includeTags(Status $status) - { - $hashtags = $status->hashtags; - - return $this->collection($hashtags, new HashtagTransformer()); - } -} \ No newline at end of file + return $this->collection($mentions, new MentionTransformer()); + } +} From 7c6cff31034bbb68ca95b84d8a47cdfe1cbe0045 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 23:24:56 -0600 Subject: [PATCH 10/92] Update StatusTransformer --- .../Api/Mastodon/v1/StatusTransformer.php | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php index 43ebccc5d..6b4177384 100644 --- a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php +++ b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php @@ -11,10 +11,6 @@ use App\Services\StatusHashtagService; class StatusTransformer extends Fractal\TransformerAbstract { - protected $defaultIncludes = [ - 'mentions', - ]; - public function transform(Status $status) { return [ @@ -49,14 +45,7 @@ class StatusTransformer extends Fractal\TransformerAbstract 'poll' => null, 'media_attachments' => MediaService::get($status->id), 'account' => ProfileService::get($status->profile_id), - 'tags' => StatusHashtagService::statusTags($status->id) + 'tags' => StatusHashtagService::statusTags($status->id), ]; } - - public function includeMentions(Status $status) - { - $mentions = $status->mentions; - - return $this->collection($mentions, new MentionTransformer()); - } } From 1060dd23d5988b6f38467723380d4b37a36ebe7a Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 24 Jul 2021 23:37:44 -0600 Subject: [PATCH 11/92] Update RemotePost component, update likes reaction bar --- .../assets/js/components/PostComponent.vue | 14 +++++++------- resources/assets/js/components/RemotePost.vue | 19 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index c401d7857..6395318af 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -222,13 +222,13 @@
+ +
diff --git a/resources/assets/js/components/RemotePost.vue b/resources/assets/js/components/RemotePost.vue index 5acefefcd..00b01960b 100644 --- a/resources/assets/js/components/RemotePost.vue +++ b/resources/assets/js/components/RemotePost.vue @@ -224,13 +224,15 @@

-
- - likes - - - shares - +
+
@@ -692,9 +694,6 @@ export default { window.location.href = '/login?next=' + encodeURIComponent('/p/' + this.status.shortcode); return; } - if(this.status.favourites_count == 0) { - return; - } if(this.likes.length) { this.$refs.likesModal.show(); return; From c1f14f89f65ce4e39ec5b26739a3dffddccc4915 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 01:36:57 -0600 Subject: [PATCH 12/92] Update FollowPipeline, fix cache invalidation bug --- app/Jobs/FollowPipeline/FollowPipeline.php | 99 +++++++++++----------- 1 file changed, 51 insertions(+), 48 deletions(-) diff --git a/app/Jobs/FollowPipeline/FollowPipeline.php b/app/Jobs/FollowPipeline/FollowPipeline.php index 0441531c3..986f22425 100644 --- a/app/Jobs/FollowPipeline/FollowPipeline.php +++ b/app/Jobs/FollowPipeline/FollowPipeline.php @@ -14,59 +14,62 @@ use Illuminate\Support\Facades\Redis; class FollowPipeline implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - protected $follower; + protected $follower; - /** - * Delete the job if its models no longer exist. - * - * @var bool - */ - public $deleteWhenMissingModels = true; - - /** - * Create a new job instance. - * - * @return void - */ - public function __construct($follower) - { - $this->follower = $follower; - } + /** + * Delete the job if its models no longer exist. + * + * @var bool + */ + public $deleteWhenMissingModels = true; + + /** + * Create a new job instance. + * + * @return void + */ + public function __construct($follower) + { + $this->follower = $follower; + } - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - $follower = $this->follower; - $actor = $follower->actor; - $target = $follower->target; + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $follower = $this->follower; + $actor = $follower->actor; + $target = $follower->target; - if($target->domain || !$target->private_key) { - return; - } + Cache::forget('profile:following:' . $actor->id); + Cache::forget('profile:following:' . $target->id); - try { - $notification = new Notification(); - $notification->profile_id = $target->id; - $notification->actor_id = $actor->id; - $notification->action = 'follow'; - $notification->message = $follower->toText(); - $notification->rendered = $follower->toHtml(); - $notification->item_id = $target->id; - $notification->item_type = "App\Profile"; - $notification->save(); + if($target->domain || !$target->private_key) { + return; + } - $redis = Redis::connection(); + try { + $notification = new Notification(); + $notification->profile_id = $target->id; + $notification->actor_id = $actor->id; + $notification->action = 'follow'; + $notification->message = $follower->toText(); + $notification->rendered = $follower->toHtml(); + $notification->item_id = $target->id; + $notification->item_type = "App\Profile"; + $notification->save(); - $nkey = config('cache.prefix').':user.'.$target->id.'.notifications'; - $redis->lpush($nkey, $notification->id); - } catch (Exception $e) { - Log::error($e); - } - } + $redis = Redis::connection(); + + $nkey = config('cache.prefix').':user.'.$target->id.'.notifications'; + $redis->lpush($nkey, $notification->id); + } catch (Exception $e) { + Log::error($e); + } + } } From bce8edd994d5e89c248c88f753457104effea068 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 01:39:03 -0600 Subject: [PATCH 13/92] Update PublicApiController, improve accountStatuses api perf --- app/Http/Controllers/PublicApiController.php | 100 +++++-------------- 1 file changed, 24 insertions(+), 76 deletions(-) diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index fd8dae9ce..24b4ff740 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -656,6 +656,7 @@ class PublicApiController extends Controller 'limit' => 'nullable|integer|min:1|max:24' ]); + $user = $request->user(); $profile = Profile::whereNull('status')->findOrFail($id); $limit = $request->limit ?? 9; @@ -663,21 +664,21 @@ class PublicApiController extends Controller $min_id = $request->min_id; $scope = $request->only_media == true ? ['photo', 'photo:album', 'video', 'video:album'] : - ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply']; + ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply', 'text']; if($profile->is_private) { - if(!Auth::check()) { + if(!$user) { return response()->json([]); } - $pid = Auth::user()->profile->id; + $pid = $user->profile_id; $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Follower::whereProfileId($pid)->pluck('following_id'); return $following->push($pid)->toArray(); }); $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : []; } else { - if(Auth::check()) { - $pid = Auth::user()->profile->id; + if($user) { + $pid = $user->profile_id; $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Follower::whereProfileId($pid)->pluck('following_id'); return $following->push($pid)->toArray(); @@ -688,84 +689,31 @@ class PublicApiController extends Controller } } - $tag = in_array('private', $visibility) ? 'private' : 'public'; - if($min_id == 1 && $limit == 9 && $tag == 'public') { - $limit = 9; - $scope = ['photo', 'photo:album', 'video', 'video:album']; - $key = '_api:statuses:recent_9:'.$profile->id; - $res = Cache::remember($key, now()->addHours(24), function() use($profile, $scope, $visibility, $limit) { - $dir = '>'; - $id = 1; - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'likes_count', - 'reblogs_count', - 'scope', - 'visibility', - 'local', - 'place_id', - 'comments_disabled', - 'cw_summary', - 'created_at', - 'updated_at' - )->whereProfileId($profile->id) - ->whereIn('type', $scope) - ->where('id', $dir, $id) - ->whereIn('visibility', $visibility) - ->limit($limit) - ->orderByDesc('id') - ->get(); - - $resource = new Fractal\Resource\Collection($timeline, new StatusStatelessTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); - }); - return $res; - } - $dir = $min_id ? '>' : '<'; $id = $min_id ?? $max_id; - $timeline = Status::select( + $res = Status::select( 'id', - 'uri', - 'caption', - 'rendered', 'profile_id', 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'likes_count', - 'reblogs_count', 'scope', - 'visibility', 'local', - 'place_id', - 'comments_disabled', - 'cw_summary', - 'created_at', - 'updated_at' - )->whereProfileId($profile->id) - ->whereIn('type', $scope) - ->where('id', $dir, $id) - ->whereIn('visibility', $visibility) - ->limit($limit) - ->orderByDesc('id') - ->get(); - - $resource = new Fractal\Resource\Collection($timeline, new StatusStatelessTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + 'created_at' + ) + ->whereProfileId($profile->id) + ->whereIn('type', $scope) + ->where('id', $dir, $id) + ->whereIn('scope', $visibility) + ->limit($limit) + ->orderByDesc('id') + ->get() + ->map(function($s) use($user) { + $status = StatusService::get($s->id, false); + if($user) { + $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); + } + return $status; + }); + return response()->json($res); } } From f9516ac316824f5829d3c5799b1011b3cc570f7d Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 02:12:30 -0600 Subject: [PATCH 14/92] Update ApiControllers, use NotificationService --- app/Http/Controllers/Api/ApiV1Controller.php | 5 ++ .../Controllers/Api/BaseApiController.php | 54 ++++++++++++------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 6a4593823..b2ce2f85a 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -1316,6 +1316,11 @@ class ApiV1Controller extends Controller } } + if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) { + Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600); + NotificationService::warmCache($pid, 400, true); + } + $baseUrl = config('app.url') . '/api/v1/notifications?'; if($minId == $maxId) { diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php index 5e02a2880..0e62013f4 100644 --- a/app/Http/Controllers/Api/BaseApiController.php +++ b/app/Http/Controllers/Api/BaseApiController.php @@ -54,26 +54,40 @@ class BaseApiController extends Controller public function notifications(Request $request) { abort_if(!$request->user(), 403); - $pid = $request->user()->profile_id; - $pg = $request->input('pg'); - if($pg == true) { - $timeago = Carbon::now()->subMonths(6); - $notifications = Notification::whereProfileId($pid) - ->whereDate('created_at', '>', $timeago) - ->latest() - ->simplePaginate(10); - $resource = new Fractal\Resource\Collection($notifications, new NotificationTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - } else { - $this->validate($request, [ - 'page' => 'nullable|integer|min:1|max:10', - 'limit' => 'nullable|integer|min:1|max:40' - ]); - $limit = $request->input('limit') ?? 10; - $page = $request->input('page') ?? 1; - $end = (int) $page * $limit; - $start = (int) $end - $limit; - $res = NotificationService::get($pid, $start, $end); + + $pid = $request->user()->profile_id; + $limit = $request->input('limit', 20); + + $since = $request->input('since_id'); + $min = $request->input('min_id'); + $max = $request->input('max_id'); + + if(!$since && !$min && !$max) { + $min = 1; + } + + $maxId = null; + $minId = null; + + if($max) { + $res = NotificationService::getMax($pid, $max, $limit); + $ids = NotificationService::getRankedMaxId($pid, $max, $limit); + if(!empty($ids)) { + $maxId = max($ids); + $minId = min($ids); + } + } else { + $res = NotificationService::getMin($pid, $min ?? $since, $limit); + $ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit); + if(!empty($ids)) { + $maxId = max($ids); + $minId = min($ids); + } + } + + if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) { + Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600); + NotificationService::warmCache($pid, 400, true); } return response()->json($res); From b6e226aef98365855379f2a35b7f12a1968d265a Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 02:14:41 -0600 Subject: [PATCH 15/92] Update Notification components, fix old notifications with missing attributes --- resources/assets/js/components/Activity.vue | 18 ++++++++++++--- .../assets/js/components/NotificationCard.vue | 22 ++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/resources/assets/js/components/Activity.vue b/resources/assets/js/components/Activity.vue index 751cab0ae..6e03b1779 100644 --- a/resources/assets/js/components/Activity.vue +++ b/resources/assets/js/components/Activity.vue @@ -146,6 +146,12 @@ export default { if(n.type == 'mention' && !n.status) { return false; } + if(n.type == 'favourite' && !n.status) { + return false; + } + if(n.type == 'follow' && !n.account) { + return false; + } return true; }); let ids = res.data.map(n => n.id); @@ -168,13 +174,19 @@ export default { }).then(res => { if(res.data.length) { let data = res.data.filter(n => { - if(n.type == 'share' && !status) { + if(n.type == 'share' && !n.status) { return false; } - if(n.type == 'comment' && !status) { + if(n.type == 'comment' && !n.status) { return false; } - if(n.type == 'mention' && !status) { + if(n.type == 'mention' && !n.status) { + return false; + } + if(n.type == 'favourite' && !n.status) { + return false; + } + if(n.type == 'follow' && !n.account) { return false; } if(_.find(this.notifications, {id: n.id})) { diff --git a/resources/assets/js/components/NotificationCard.vue b/resources/assets/js/components/NotificationCard.vue index db4139cc6..3648df8d6 100644 --- a/resources/assets/js/components/NotificationCard.vue +++ b/resources/assets/js/components/NotificationCard.vue @@ -21,7 +21,7 @@

{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}} liked your - + post. @@ -140,6 +140,12 @@ if(n.type == 'mention' && !n.status) { return false; } + if(n.type == 'favourite' && !n.status) { + return false; + } + if(n.type == 'follow' && !n.account) { + return false; + } return true; }); let ids = data.map(n => n.id); @@ -171,6 +177,12 @@ if(n.type == 'mention' && !n.status) { return false; } + if(n.type == 'favourite' && !n.status) { + return false; + } + if(n.type == 'follow' && !n.account) { + return false; + } if(_.find(this.notifications, {id: n.id})) { return false; } @@ -271,7 +283,7 @@ }, notificationPreview(n) { - if(!n.status.hasOwnProperty('media_attachments') || !n.status.media_attachments.length) { + if(!n.status || !n.status.hasOwnProperty('media_attachments') || !n.status.media_attachments.length) { return '/storage/no-preview.png'; } return n.status.media_attachments[0].preview_url; @@ -286,7 +298,11 @@ }, getPostUrl(status) { - if(status.local == true) { + if(!status) { + return; + } + + if(!status.hasOwnProperty('local') || status.local == true) { return status.url; } From 14a1367a8fd8fea8569cf5010138c9e0290a9e9f Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 03:17:49 -0600 Subject: [PATCH 16/92] Federate Media Licenses --- app/Services/MediaService.php | 21 +++++++++++++++++++ .../ActivityPub/StatusTransformer.php | 11 ++-------- app/Util/ActivityPub/Helpers.php | 5 +++++ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/Services/MediaService.php b/app/Services/MediaService.php index f1230c7d8..5945e27cb 100644 --- a/app/Services/MediaService.php +++ b/app/Services/MediaService.php @@ -10,6 +10,7 @@ use League\Fractal; use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Pagination\IlluminatePaginatorAdapter; use App\Transformer\Api\MediaTransformer; +use App\Util\Media\License; class MediaService { @@ -37,4 +38,24 @@ class MediaService { 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) use($license) { + $license = isset($s['license']) && $s['license']['title'] ? $s['license']['title'] : null; + return [ + 'type' => 'Document', + 'mediaType' => $s['mime'], + 'url' => $s['url'], + 'name' => $s['description'], + 'blurhash' => $s['blurhash'], + 'license' => $license + ]; + }); + } } diff --git a/app/Transformer/ActivityPub/StatusTransformer.php b/app/Transformer/ActivityPub/StatusTransformer.php index 8368b64a8..f5d5ea531 100644 --- a/app/Transformer/ActivityPub/StatusTransformer.php +++ b/app/Transformer/ActivityPub/StatusTransformer.php @@ -4,6 +4,7 @@ namespace App\Transformer\ActivityPub; use App\Status; use League\Fractal; +use App\Services\MediaService; class StatusTransformer extends Fractal\TransformerAbstract { @@ -45,15 +46,7 @@ class StatusTransformer extends Fractal\TransformerAbstract 'sensitive' => (bool) $status->is_nsfw, 'atomUri' => $status->url(), 'inReplyToAtomUri' => null, - 'attachment' => $status->media->map(function ($media) { - return [ - 'type' => 'Document', - 'mediaType' => $media->mime, - 'url' => $media->url(), - 'name' => $media->caption, - 'blurhash' => $media->blurhash - ]; - }), + 'attachment' => MediaService::activitypub($status->id), 'tag' => [], 'location' => $status->place_id ? [ 'type' => 'Place', diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index f2e1adec5..bc2dd57b2 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -32,6 +32,7 @@ use App\Services\MediaPathService; use App\Services\MediaStorageService; use App\Jobs\MediaPipeline\MediaStoragePipeline; use App\Jobs\AvatarPipeline\RemoteAvatarFetch; +use App\Util\Media\License; class Helpers { @@ -428,6 +429,7 @@ class Helpers { $type = $media['mediaType']; $url = $media['url']; $blurhash = isset($media['blurhash']) ? $media['blurhash'] : null; + $license = isset($media['license']) ? License::nameToId($media['license']) : null; $valid = self::validateUrl($url); if(in_array($type, $allowed) == false || $valid == false) { continue; @@ -441,6 +443,9 @@ class Helpers { $media->user_id = null; $media->media_path = $url; $media->remote_url = $url; + if($license) { + $media->license = $license; + } $media->mime = $type; $media->version = 3; $media->save(); From f3d6023ef858e381feff59eda843ab7aad7a33d4 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 03:19:48 -0600 Subject: [PATCH 17/92] Update LikeController, improve query perf --- app/Http/Controllers/LikeController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/Http/Controllers/LikeController.php b/app/Http/Controllers/LikeController.php index 9597465ba..725e2eb2a 100644 --- a/app/Http/Controllers/LikeController.php +++ b/app/Http/Controllers/LikeController.php @@ -29,8 +29,7 @@ class LikeController extends Controller $profile = $user->profile; $status = Status::findOrFail($request->input('item')); - - if ($status->likes()->whereProfileId($profile->id)->count() !== 0) { + if (Like::whereStatusId($status->id)->whereProfileId($profile->id)->exists()) { $like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail(); UnlikePipeline::dispatch($like); } else { From f6131ed7642817d5804d0b12e249cd5bfd2455b0 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 03:29:22 -0600 Subject: [PATCH 18/92] Update License util, add nameToId method --- app/Util/Media/License.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/Util/Media/License.php b/app/Util/Media/License.php index 653013a4d..835a0acc1 100644 --- a/app/Util/Media/License.php +++ b/app/Util/Media/License.php @@ -120,4 +120,19 @@ class License { ->values() ->toArray(); } + + public static function nameToId($name) + { + $license = collect(self::get()) + ->filter(function($l) use($name) { + return $l['title'] == $name; + }) + ->first(); + + if(!$license || $license['id'] < 2) { + return null; + } + + return $license['id']; + } } From 7274574c680ab0869c0ac48de552e9373f19a02a Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 03:31:47 -0600 Subject: [PATCH 19/92] Update RemoteProfile, add warning about potentially out of date information --- resources/assets/js/components/RemoteProfile.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/assets/js/components/RemoteProfile.vue b/resources/assets/js/components/RemoteProfile.vue index d9f2bd350..a281ced4e 100644 --- a/resources/assets/js/components/RemoteProfile.vue +++ b/resources/assets/js/components/RemoteProfile.vue @@ -57,6 +57,7 @@

Last updated:

+

You are viewing a profile from a remote server, it may not contain up-to-date information.

From 0e178a3371a4cc912066d4cb0390bfa01ac3a79c Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 03:33:47 -0600 Subject: [PATCH 20/92] Update NotifcationCard.vue component, add refresh button for cold notification cache --- resources/assets/js/components/NotificationCard.vue | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/resources/assets/js/components/NotificationCard.vue b/resources/assets/js/components/NotificationCard.vue index 3648df8d6..1d2e7f559 100644 --- a/resources/assets/js/components/NotificationCard.vue +++ b/resources/assets/js/components/NotificationCard.vue @@ -90,6 +90,7 @@

No notifications yet

+

Refresh

@@ -110,7 +111,9 @@ profile: { locked: false }, - followRequests: null + followRequests: null, + showRefresh: false, + attemptedRefresh: false }; }, @@ -152,6 +155,9 @@ this.notificationMaxId = Math.min(...ids); this.notifications = data; this.loading = false; + if(data.length == 0 && !this.attemptedRefresh) { + this.showRefresh = true; + } //this.notificationPoll(); }); }, @@ -307,6 +313,11 @@ } return '/i/web/post/_/' + status.account.id + '/' + status.id; + }, + + refreshNotifications() { + this.attemptedRefresh = true; + this.fetchNotifications(); } } } From c4146a3040c38bcb7901e96ba914e0925dcc496d Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 05:06:38 -0600 Subject: [PATCH 21/92] Update RemoteProfile component, add follower modals --- app/Http/Controllers/PublicApiController.php | 13 +- .../assets/js/components/RemoteProfile.vue | 243 +++++++++++++++++- 2 files changed, 248 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 24b4ff740..fdfae931c 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -591,11 +591,14 @@ class PublicApiController extends Controller public function accountFollowers(Request $request, $id) { abort_unless(Auth::check(), 403); - $profile = Profile::with('user')->whereNull('status')->whereNull('domain')->findOrFail($id); + $profile = Profile::with('user')->whereNull('status')->findOrFail($id); $owner = Auth::id() == $profile->user_id; - if(Auth::id() != $profile->user_id && $profile->is_private || !$profile->user->settings->show_profile_followers) { + if(Auth::id() != $profile->user_id && $profile->is_private) { return response()->json([]); } + if(!$profile->domain && !$profile->user->settings->show_profile_followers) { + return response()->json([]); + } if(!$owner && $request->page > 5) { return []; } @@ -612,7 +615,6 @@ class PublicApiController extends Controller $profile = Profile::with('user') ->whereNull('status') - ->whereNull('domain') ->findOrFail($id); // filter by username @@ -621,7 +623,10 @@ class PublicApiController extends Controller $filter = ($owner == true) && ($search != null); abort_if($owner == false && $profile->is_private == true && !$profile->followedBy(Auth::user()->profile), 404); - abort_if($profile->user->settings->show_profile_following == false && $owner == false, 404); + + if(!$profile->domain) { + abort_if($profile->user->settings->show_profile_following == false && $owner == false, 404); + } if(!$owner && $request->page > 5) { return []; diff --git a/resources/assets/js/components/RemoteProfile.vue b/resources/assets/js/components/RemoteProfile.vue index a281ced4e..28791c5a2 100644 --- a/resources/assets/js/components/RemoteProfile.vue +++ b/resources/assets/js/components/RemoteProfile.vue @@ -44,11 +44,11 @@ {{profile.statuses_count}} Posts - + {{profile.following_count}} Following - + {{profile.followers_count}} Followers @@ -90,6 +90,116 @@ + + +
+
+

+ {{profileUsername}} is not following yet

+
+
+
+ + + + +
+
+
+ + + +
+

+ + {{user.username}} + +

+

+ {{user.acct.split('@')[0]}}@{{user.acct.split('@')[1]}} +

+

+ {{user.display_name ? user.display_name : user.username}} +

+
+
+ Following +
+
+
+
+
+

No Results Found

+
+
+
+

Load more

+
+
+
+
+
+ Loading... +
+
+
+ +
+
+

+ {{profileUsername}} has no followers yet

+
+ +
+
+
+ + + +
+

+ + {{user.username}} + +

+

+ {{user.acct.split('@')[0]}}@{{user.acct.split('@')[1]}} +

+

+ {{user.display_name ? user.display_name : user.username}} +

+
+ +
+
+
+

Load more

+
+
+
+
+
+ Loading... +
+
+
1) { + this.$refs.followingModal.show(); + return; + } else { + axios.get('/api/pixelfed/v1/accounts/'+this.profileId+'/following', { + params: { + page: this.followingCursor + } + }) + .then(res => { + this.following = res.data; + this.followingModalSearchCache = res.data; + this.followingCursor++; + if(res.data.length < 10) { + this.followingMore = false; + } + this.followingLoading = false; + }); + this.$refs.followingModal.show(); + return; + } + }, + + followersModal() { + if(this.followerCursor > 1) { + this.$refs.followerModal.show(); + return; + } else { + axios.get('/api/pixelfed/v1/accounts/'+this.profileId+'/followers', { + params: { + page: this.followerCursor + } + }) + .then(res => { + this.followers.push(...res.data); + this.followerCursor++; + if(res.data.length < 10) { + this.followerMore = false; + } + this.followerLoading = false; + }) + this.$refs.followerModal.show(); + return; + } + }, + + followingLoadMore() { + axios.get('/api/pixelfed/v1/accounts/'+this.profile.id+'/following', { + params: { + page: this.followingCursor, + fbu: this.followingModalSearch + } + }) + .then(res => { + if(res.data.length > 0) { + this.following.push(...res.data); + this.followingCursor++; + this.followingModalSearchCache = this.following; + } + if(res.data.length < 10) { + this.followingModalSearchCache = this.following; + this.followingMore = false; + } + }); + }, + + followersLoadMore() { + axios.get('/api/pixelfed/v1/accounts/'+this.profile.id+'/followers', { + params: { + page: this.followerCursor + } + }) + .then(res => { + if(res.data.length > 0) { + this.followers.push(...res.data); + this.followerCursor++; + } + if(res.data.length < 10) { + this.followerMore = false; + } + }); + }, + + profileUrlRedirect(profile) { + if(profile.local == true) { + return profile.url; + } + + return '/i/web/profile/_/' + profile.id; + }, + + followingModalSearchHandler() { + let self = this; + let q = this.followingModalSearch; + + if(q.length == 0) { + this.following = this.followingModalSearchCache; + this.followingModalSearch = null; + } + if(q.length > 0) { + let url = '/api/pixelfed/v1/accounts/' + + self.profileId + '/following?page=1&fbu=' + + q; + + axios.get(url).then(res => { + this.following = res.data; + }).catch(err => { + self.following = self.followingModalSearchCache; + self.followingModalSearch = null; + }); + } + }, } } From 85d5639a52d33d3649c207d9c4b774f3a2bb39ac Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 05:32:42 -0600 Subject: [PATCH 22/92] Update Hashtag component --- resources/assets/js/components/Hashtag.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/assets/js/components/Hashtag.vue b/resources/assets/js/components/Hashtag.vue index a319d6d8b..7259b5d39 100644 --- a/resources/assets/js/components/Hashtag.vue +++ b/resources/assets/js/components/Hashtag.vue @@ -33,7 +33,7 @@

Top Posts

-
+
@@ -59,7 +59,7 @@

Most Recent

-
+
-
+
@@ -110,7 +110,7 @@
-
+
Loading...
From 38e5fc43ebc59986a598f46757d6e7e6c751073c Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 05:46:42 -0600 Subject: [PATCH 23/92] Add FollowObserver --- app/Observers/FollowerObserver.php | 64 ++++++++++++++++++++++++++++ app/Providers/AppServiceProvider.php | 3 ++ 2 files changed, 67 insertions(+) create mode 100644 app/Observers/FollowerObserver.php diff --git a/app/Observers/FollowerObserver.php b/app/Observers/FollowerObserver.php new file mode 100644 index 000000000..afc476eeb --- /dev/null +++ b/app/Observers/FollowerObserver.php @@ -0,0 +1,64 @@ +profile_id, $follower->following_id); + } + + /** + * Handle the Follower "updated" event. + * + * @param \App\Models\Follower $follower + * @return void + */ + public function updated(Follower $follower) + { + FollowerService::add($follower->profile_id, $follower->following_id); + } + + /** + * Handle the Follower "deleted" event. + * + * @param \App\Models\Follower $follower + * @return void + */ + public function deleted(Follower $follower) + { + FollowerService::remove($follower->profile_id, $follower->following_id); + } + + /** + * Handle the Follower "restored" event. + * + * @param \App\Models\Follower $follower + * @return void + */ + public function restored(Follower $follower) + { + FollowerService::add($follower->profile_id, $follower->following_id); + } + + /** + * Handle the Follower "force deleted" event. + * + * @param \App\Models\Follower $follower + * @return void + */ + public function forceDeleted(Follower $follower) + { + FollowerService::remove($follower->profile_id, $follower->following_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a4dfbe27b..f2baf4c88 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Observers\{ AvatarObserver, + FollowerObserver, LikeObserver, NotificationObserver, ModLogObserver, @@ -14,6 +15,7 @@ use App\Observers\{ }; use App\{ Avatar, + Follower, Like, Notification, ModLog, @@ -48,6 +50,7 @@ class AppServiceProvider extends ServiceProvider StatusHashtag::observe(StatusHashtagObserver::class); User::observe(UserObserver::class); UserFilter::observe(UserFilterObserver::class); + Follower::observe(FollowerObserver::class); Horizon::auth(function ($request) { return Auth::check() && $request->user()->is_admin; }); From 22257cc2a71e261d07764064071b28be3f53e187 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 05:56:35 -0600 Subject: [PATCH 24/92] Update FollowerService, cache audience --- app/Profile.php | 11 ++------ app/Services/FollowerService.php | 47 +++++++------------------------- 2 files changed, 12 insertions(+), 46 deletions(-) diff --git a/app/Profile.php b/app/Profile.php index d4532fcb1..2d9ef3fdc 100644 --- a/app/Profile.php +++ b/app/Profile.php @@ -6,6 +6,7 @@ use Auth, Cache, Storage; use App\Util\Lexer\PrettyNumber; use Pixelfed\Snowflake\HasSnowflakePrimary; use Illuminate\Database\Eloquent\{Model, SoftDeletes}; +use App\Services\FollowerService; class Profile extends Model { @@ -276,15 +277,7 @@ class Profile extends Model public function getAudienceInbox($scope = 'public') { - return $this - ->followers() - ->whereLocalProfile(false) - ->get() - ->map(function($follow) { - return $follow->sharedInbox ?? $follow->inbox_url; - }) - ->unique() - ->toArray(); + return FollowerService::audience($this->id, $scope); } public function circles() diff --git a/app/Services/FollowerService.php b/app/Services/FollowerService.php index 68ecb118e..931d15303 100644 --- a/app/Services/FollowerService.php +++ b/app/Services/FollowerService.php @@ -3,7 +3,7 @@ namespace App\Services; use Illuminate\Support\Facades\Redis; - +use Cache; use App\{ Follower, Profile, @@ -25,6 +25,8 @@ class FollowerService { Redis::zrem(self::FOLLOWING_KEY . $actor, $target); Redis::zrem(self::FOLLOWERS_KEY . $target, $actor); + Cache::forget('pf:services:follow:audience:' . $actor); + Cache::forget('pf:services:follow:audience:' . $target); } public static function followers($id, $start = 0, $stop = 10) @@ -42,28 +44,19 @@ class FollowerService return Follower::whereProfileId($actor)->whereFollowingId($target)->exists(); } - public static function audience($profile) + public static function audience($profile, $scope = null) { return (new self)->getAudienceInboxes($profile); } - protected function getAudienceInboxes($profile) + protected function getAudienceInboxes($profile, $scope = null) { - if($profile instanceOf User) { - return $profile - ->profile - ->followers() - ->whereLocalProfile(false) - ->get() - ->map(function($follow) { - return $follow->sharedInbox ?? $follow->inbox_url; - }) - ->unique() - ->values() - ->toArray(); + if(!$profile instanceOf Profile) { + return []; } - if($profile instanceOf Profile) { + $key = 'pf:services:follow:audience:' . $profile->id; + return Cache::remember($key, 86400, function() use($profile) { return $profile ->followers() ->whereLocalProfile(false) @@ -74,27 +67,7 @@ class FollowerService ->unique() ->values() ->toArray(); - } - - if(is_string($profile) || is_integer($profile)) { - $profile = Profile::whereNull('domain')->find($profile); - if(!$profile) { - return []; - } - - return $profile - ->followers() - ->whereLocalProfile(false) - ->get() - ->map(function($follow) { - return $follow->sharedInbox ?? $follow->inbox_url; - }) - ->unique() - ->values() - ->toArray(); - } - - return []; + }); } } From ecbc46458770c4661975f1c59515740146fb9e8f Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 06:02:28 -0600 Subject: [PATCH 25/92] Update changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 122eaec72..906127996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Diagnostics for error page and admin dashboard ([64725ecc](https://github.com/pixelfed/pixelfed/commit/64725ecc)) - Default media licenses and media license sync ([ea0fc90c](https://github.com/pixelfed/pixelfed/commit/ea0fc90c)) - Customize media description/alt-text length limit ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1)) +- Federate Media Licenses ([14a1367a](https://github.com/pixelfed/pixelfed/commit/14a1367a)) ### Updated - Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b)) @@ -78,6 +79,18 @@ - Updated Settings, add default license and enforced media descriptions. ([67e3f604](https://github.com/pixelfed/pixelfed/commit/67e3f604)) - Updated Compose Apis, make media descriptions/alt text length limit configurable. Default length: 1000. ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1)) - Updated ApiV1Controller, add default license support. ([2a791f19](https://github.com/pixelfed/pixelfed/commit/2a791f19)) +- Updated StatusTransformers, remove includes and use cached services. ([09d5198c](https://github.com/pixelfed/pixelfed/commit/09d5198c)) +- Updated RemotePost component, update likes reaction bar. ([1060dd23](https://github.com/pixelfed/pixelfed/commit/1060dd23)) +- Updated FollowPipeline, fix cache invalidation bug. ([c1f14f89](https://github.com/pixelfed/pixelfed/commit/c1f14f89)) +- Updated PublicApiController, improve accountStatuses api perf. ([bce8edd9](https://github.com/pixelfed/pixelfed/commit/bce8edd9)) +- Updated ApiControllers, use NotificationService. ([f9516ac3](https://github.com/pixelfed/pixelfed/commit/f9516ac3)) +- Updated Notification components, fix old notifications with missing attributes. ([b6e226ae](https://github.com/pixelfed/pixelfed/commit/b6e226ae)) +- Updated LikeController, improve query perf. ([f3d6023e](https://github.com/pixelfed/pixelfed/commit/f3d6023e)) +- Updated License util, add nameToId method. ([f6131ed7](https://github.com/pixelfed/pixelfed/commit/f6131ed7)) +- Updated RemoteProfile, add warning about potentially out of date information. ([7274574c](https://github.com/pixelfed/pixelfed/commit/7274574c)) +- Updated NotifcationCard.vue component, add refresh button for cold notification cache. ([0e178a33](https://github.com/pixelfed/pixelfed/commit/0e178a33)) +- Updated RemoteProfile component, add follower modals. ([c4146a30](https://github.com/pixelfed/pixelfed/commit/c4146a30)) +- Updated FollowerService, cache audience. ([22257cc2](https://github.com/pixelfed/pixelfed/commit/22257cc2)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0) From 2376580eb7bac8004df1e6d97c7a781a38ea4c95 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 25 Jul 2021 06:15:10 -0600 Subject: [PATCH 26/92] Update NotificationCard component --- resources/assets/js/components/NotificationCard.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/assets/js/components/NotificationCard.vue b/resources/assets/js/components/NotificationCard.vue index 1d2e7f559..89a6ec7ac 100644 --- a/resources/assets/js/components/NotificationCard.vue +++ b/resources/assets/js/components/NotificationCard.vue @@ -316,6 +316,7 @@ }, refreshNotifications() { + this.loading = true; this.attemptedRefresh = true; this.fetchNotifications(); } From ee0028bc5734db07ff6adca4e5fc29b709c00bb1 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 26 Jul 2021 18:47:40 -0600 Subject: [PATCH 27/92] Update PublicApiController, use account service --- app/Http/Controllers/PublicApiController.php | 14 +++++++-- app/Services/AccountService.php | 8 +++--- app/Services/ProfileService.php | 30 +++++--------------- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index fdfae931c..5059f8161 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -29,6 +29,7 @@ use App\Services\{ AccountService, LikeService, PublicTimelineService, + ProfileService, StatusService, SnowflakeService, UserFilterService @@ -593,6 +594,7 @@ class PublicApiController extends Controller abort_unless(Auth::check(), 403); $profile = Profile::with('user')->whereNull('status')->findOrFail($id); $owner = Auth::id() == $profile->user_id; + if(Auth::id() != $profile->user_id && $profile->is_private) { return response()->json([]); } @@ -602,9 +604,15 @@ class PublicApiController extends Controller if(!$owner && $request->page > 5) { return []; } - $followers = $profile->followers()->orderByDesc('followers.created_at')->paginate(10); - $resource = new Fractal\Resource\Collection($followers, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); + + $res = Follower::select('id', 'profile_id', 'following_id') + ->whereFollowingId($profile->id) + ->orderByDesc('id') + ->simplePaginate(10) + ->map(function($follower) { + return ProfileService::get($follower['profile_id']); + }) + ->toArray(); return response()->json($res); } diff --git a/app/Services/AccountService.php b/app/Services/AccountService.php index 38e29169d..8f9f5c3b9 100644 --- a/app/Services/AccountService.php +++ b/app/Services/AccountService.php @@ -8,8 +8,8 @@ use App\Transformer\Api\AccountTransformer; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; -class AccountService { - +class AccountService +{ const CACHE_KEY = 'pf:services:account:'; public static function get($id) @@ -19,7 +19,7 @@ class AccountService { } $key = self::CACHE_KEY . $id; - $ttl = now()->addMinutes(15); + $ttl = now()->addHours(12); return Cache::remember($key, $ttl, function() use($id) { $fractal = new Fractal\Manager(); @@ -35,4 +35,4 @@ class AccountService { return Cache::forget(self::CACHE_KEY . $id); } -} \ No newline at end of file +} diff --git a/app/Services/ProfileService.php b/app/Services/ProfileService.php index 67a0cb4c8..43f2ff0e4 100644 --- a/app/Services/ProfileService.php +++ b/app/Services/ProfileService.php @@ -2,31 +2,15 @@ namespace App\Services; -use Cache; -use Illuminate\Support\Facades\Redis; -use App\Transformer\Api\AccountTransformer; -use League\Fractal; -use League\Fractal\Serializer\ArraySerializer; -use League\Fractal\Pagination\IlluminatePaginatorAdapter; -use App\Profile; - -class ProfileService { - +class ProfileService +{ public static function get($id) { - $key = 'profile:model:' . $id; - $ttl = now()->addHours(4); - $res = Cache::remember($key, $ttl, function() use($id) { - $profile = Profile::find($id); - if(!$profile) { - return false; - } - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); - return $fractal->createData($resource)->toArray(); - }); - return $res; + return AccountService::get($id); } + public static function del($id) + { + return AccountService::del($id); + } } From 15c4fdd90cfa159b0c92836ff45408f6e2e3982e Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 26 Jul 2021 19:02:11 -0600 Subject: [PATCH 28/92] Update StatusService, add non-public option and improve cache invalidation --- app/Services/StatusService.php | 12 +- app/Status.php | 692 ++++++++++++++++----------------- 2 files changed, 355 insertions(+), 349 deletions(-) diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index 8807e37b1..892005f7e 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -21,10 +21,14 @@ class StatusService { return self::CACHE_KEY . $id; } - public static function get($id) + public static function get($id, $publicOnly = true) { - return Cache::remember(self::key($id), now()->addDays(7), function() use($id) { - $status = Status::whereScope('public')->find($id); + return Cache::remember(self::key($id), now()->addDays(7), function() use($id, $publicOnly) { + if($publicOnly) { + $status = Status::whereScope('public')->find($id); + } else { + $status = Status::whereIn('scope', ['public', 'private', 'unlisted'])->find($id); + } if(!$status) { return null; } @@ -37,6 +41,8 @@ class StatusService { public static function del($id) { + Cache::forget('pf:services:sh:id:' . $id); + Cache::forget('status:transformer:media:attachments:' . $id); PublicTimelineService::rem($id); return Cache::forget(self::key($id)); } diff --git a/app/Status.php b/app/Status.php index d9adae0ac..e14802406 100644 --- a/app/Status.php +++ b/app/Status.php @@ -10,408 +10,408 @@ use Illuminate\Database\Eloquent\SoftDeletes; class Status extends Model { - use HasSnowflakePrimary, SoftDeletes; + use HasSnowflakePrimary, SoftDeletes; - /** - * Indicates if the IDs are auto-incrementing. - * - * @var bool - */ - public $incrementing = false; + /** + * Indicates if the IDs are auto-incrementing. + * + * @var bool + */ + public $incrementing = false; - /** - * The attributes that should be mutated to dates. - * - * @var array - */ - protected $dates = ['deleted_at']; + /** + * The attributes that should be mutated to dates. + * + * @var array + */ + protected $dates = ['deleted_at']; - protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id', 'reblog_of_id', 'type']; + protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id', 'reblog_of_id', 'type']; - const STATUS_TYPES = [ - 'text', - 'photo', - 'photo:album', - 'video', - 'video:album', - 'photo:video:album', - 'share', - 'reply', - 'story', - 'story:reply', - 'story:reaction', - 'story:live', - 'loop' - ]; + const STATUS_TYPES = [ + 'text', + 'photo', + 'photo:album', + 'video', + 'video:album', + 'photo:video:album', + 'share', + 'reply', + 'story', + 'story:reply', + 'story:reaction', + 'story:live', + 'loop' + ]; - const MAX_MENTIONS = 5; + const MAX_MENTIONS = 5; - const MAX_HASHTAGS = 30; + const MAX_HASHTAGS = 30; - const MAX_LINKS = 0; + const MAX_LINKS = 0; - public function profile() - { - return $this->belongsTo(Profile::class); - } + public function profile() + { + return $this->belongsTo(Profile::class); + } - public function media() - { - return $this->hasMany(Media::class); - } + public function media() + { + return $this->hasMany(Media::class); + } - public function firstMedia() - { - return $this->hasMany(Media::class)->orderBy('order', 'asc')->first(); - } + public function firstMedia() + { + return $this->hasMany(Media::class)->orderBy('order', 'asc')->first(); + } - public function viewType() - { - if($this->type) { - return $this->type; - } - return $this->setType(); - } + public function viewType() + { + if($this->type) { + return $this->type; + } + return $this->setType(); + } - public function setType() - { - if(in_array($this->type, self::STATUS_TYPES)) { - return $this->type; - } - $mimes = $this->media->pluck('mime')->toArray(); - $type = StatusController::mimeTypeCheck($mimes); - if($type) { - $this->type = $type; - $this->save(); - return $type; - } - } + public function setType() + { + if(in_array($this->type, self::STATUS_TYPES)) { + return $this->type; + } + $mimes = $this->media->pluck('mime')->toArray(); + $type = StatusController::mimeTypeCheck($mimes); + if($type) { + $this->type = $type; + $this->save(); + return $type; + } + } - public function thumb($showNsfw = false) - { - $key = $showNsfw ? 'status:thumb:nsfw1'.$this->id : 'status:thumb:nsfw0'.$this->id; - return Cache::remember($key, now()->addMinutes(15), function() use ($showNsfw) { - $type = $this->type ?? $this->setType(); - $is_nsfw = !$showNsfw ? $this->is_nsfw : false; - if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album', 'video'])) { - return url(Storage::url('public/no-preview.png')); - } + public function thumb($showNsfw = false) + { + $key = $showNsfw ? 'status:thumb:nsfw1'.$this->id : 'status:thumb:nsfw0'.$this->id; + return Cache::remember($key, now()->addMinutes(15), function() use ($showNsfw) { + $type = $this->type ?? $this->setType(); + $is_nsfw = !$showNsfw ? $this->is_nsfw : false; + if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album', 'video'])) { + return url(Storage::url('public/no-preview.png')); + } - return url(Storage::url($this->firstMedia()->thumbnail_path)); - }); - } + return url(Storage::url($this->firstMedia()->thumbnail_path)); + }); + } - public function url() - { - if($this->uri) { - return $this->uri; - } else { - $id = $this->id; - $username = $this->profile->username; - $path = url(config('app.url')."/p/{$username}/{$id}"); - return $path; - } - } + public function url($forceLocal = false) + { + if($this->uri) { + return $forceLocal ? "/i/web/post/_/{$this->profile_id}/{$this->id}" : $this->uri; + } else { + $id = $this->id; + $username = $this->profile->username; + $path = url(config('app.url')."/p/{$username}/{$id}"); + return $path; + } + } - public function permalink($suffix = '/activity') - { - $id = $this->id; - $username = $this->profile->username; - $path = config('app.url')."/p/{$username}/{$id}{$suffix}"; + public function permalink($suffix = '/activity') + { + $id = $this->id; + $username = $this->profile->username; + $path = config('app.url')."/p/{$username}/{$id}{$suffix}"; - return url($path); - } + return url($path); + } - public function editUrl() - { - return $this->url().'/edit'; - } + public function editUrl() + { + return $this->url().'/edit'; + } - public function mediaUrl() - { - $media = $this->firstMedia(); - $path = $media->media_path; - $hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at); - $url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}"); + public function mediaUrl() + { + $media = $this->firstMedia(); + $path = $media->media_path; + $hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at); + $url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}"); - return $url; - } + return $url; + } - public function likes() - { - return $this->hasMany(Like::class); - } + public function likes() + { + return $this->hasMany(Like::class); + } - public function liked() : bool - { - if(!Auth::check()) { - return false; - } + public function liked() : bool + { + if(!Auth::check()) { + return false; + } - $pid = Auth::user()->profile_id; + $pid = Auth::user()->profile_id; - return Like::select('status_id', 'profile_id') - ->whereStatusId($this->id) - ->whereProfileId($pid) - ->exists(); - } + return Like::select('status_id', 'profile_id') + ->whereStatusId($this->id) + ->whereProfileId($pid) + ->exists(); + } - public function likedBy() - { - return $this->hasManyThrough( - Profile::class, - Like::class, - 'status_id', - 'id', - 'id', - 'profile_id' - ); - } + public function likedBy() + { + return $this->hasManyThrough( + Profile::class, + Like::class, + 'status_id', + 'id', + 'id', + 'profile_id' + ); + } - public function comments() - { - return $this->hasMany(self::class, 'in_reply_to_id'); - } + public function comments() + { + return $this->hasMany(self::class, 'in_reply_to_id'); + } - public function bookmarked() - { - if (!Auth::check()) { - return false; - } - $profile = Auth::user()->profile; + public function bookmarked() + { + if (!Auth::check()) { + return false; + } + $profile = Auth::user()->profile; - return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count(); - } + return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count(); + } - public function shares() - { - return $this->hasMany(self::class, 'reblog_of_id'); - } + public function shares() + { + return $this->hasMany(self::class, 'reblog_of_id'); + } - public function shared() : bool - { - if(!Auth::check()) { - return false; - } - $pid = Auth::user()->profile_id; + public function shared() : bool + { + if(!Auth::check()) { + return false; + } + $pid = Auth::user()->profile_id; - return $this->select('profile_id', 'reblog_of_id') - ->whereProfileId($pid) - ->whereReblogOfId($this->id) - ->exists(); - } + return $this->select('profile_id', 'reblog_of_id') + ->whereProfileId($pid) + ->whereReblogOfId($this->id) + ->exists(); + } - public function sharedBy() - { - return $this->hasManyThrough( - Profile::class, - Status::class, - 'reblog_of_id', - 'id', - 'id', - 'profile_id' - ); - } + public function sharedBy() + { + return $this->hasManyThrough( + Profile::class, + Status::class, + 'reblog_of_id', + 'id', + 'id', + 'profile_id' + ); + } - public function parent() - { - $parent = $this->in_reply_to_id ?? $this->reblog_of_id; - if (!empty($parent)) { - return $this->findOrFail($parent); - } else { - return false; - } - } + public function parent() + { + $parent = $this->in_reply_to_id ?? $this->reblog_of_id; + if (!empty($parent)) { + return $this->findOrFail($parent); + } else { + return false; + } + } - public function conversation() - { - return $this->hasOne(Conversation::class); - } + public function conversation() + { + return $this->hasOne(Conversation::class); + } - public function hashtags() - { - return $this->hasManyThrough( - Hashtag::class, - StatusHashtag::class, - 'status_id', - 'id', - 'id', - 'hashtag_id' - ); - } + public function hashtags() + { + return $this->hasManyThrough( + Hashtag::class, + StatusHashtag::class, + 'status_id', + 'id', + 'id', + 'hashtag_id' + ); + } - public function mentions() - { - return $this->hasManyThrough( - Profile::class, - Mention::class, - 'status_id', - 'id', - 'id', - 'profile_id' - ); - } + public function mentions() + { + return $this->hasManyThrough( + Profile::class, + Mention::class, + 'status_id', + 'id', + 'id', + 'profile_id' + ); + } - public function reportUrl() - { - return route('report.form')."?type=post&id={$this->id}"; - } + public function reportUrl() + { + return route('report.form')."?type=post&id={$this->id}"; + } - public function toActivityStream() - { - $media = $this->media; - $mediaCollection = []; - foreach ($media as $image) { - $mediaCollection[] = [ - 'type' => 'Link', - 'href' => $image->url(), - 'mediaType' => $image->mime, - ]; - } - $obj = [ - '@context' => 'https://www.w3.org/ns/activitystreams', - 'type' => 'Image', - 'name' => null, - 'url' => $mediaCollection, - ]; + public function toActivityStream() + { + $media = $this->media; + $mediaCollection = []; + foreach ($media as $image) { + $mediaCollection[] = [ + 'type' => 'Link', + 'href' => $image->url(), + 'mediaType' => $image->mime, + ]; + } + $obj = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Image', + 'name' => null, + 'url' => $mediaCollection, + ]; - return $obj; - } + return $obj; + } - public function replyToText() - { - $actorName = $this->profile->username; + public function replyToText() + { + $actorName = $this->profile->username; - return "{$actorName} ".__('notification.commented'); - } + return "{$actorName} ".__('notification.commented'); + } - public function replyToHtml() - { - $actorName = $this->profile->username; - $actorUrl = $this->profile->url(); + public function replyToHtml() + { + $actorName = $this->profile->username; + $actorUrl = $this->profile->url(); - return "{$actorName} ". - __('notification.commented'); - } + return "{$actorName} ". + __('notification.commented'); + } - public function shareToText() - { - $actorName = $this->profile->username; + public function shareToText() + { + $actorName = $this->profile->username; - return "{$actorName} ".__('notification.shared'); - } + return "{$actorName} ".__('notification.shared'); + } - public function shareToHtml() - { - $actorName = $this->profile->username; - $actorUrl = $this->profile->url(); + public function shareToHtml() + { + $actorName = $this->profile->username; + $actorUrl = $this->profile->url(); - return "{$actorName} ". - __('notification.shared'); - } + return "{$actorName} ". + __('notification.shared'); + } - public function recentComments() - { - return $this->comments()->orderBy('created_at', 'desc')->take(3); - } + public function recentComments() + { + return $this->comments()->orderBy('created_at', 'desc')->take(3); + } - public function toActivityPubObject() - { - if($this->local == false) { - return; - } - $profile = $this->profile; - $to = $this->scopeToAudience('to'); - $cc = $this->scopeToAudience('cc'); - return [ - '@context' => 'https://www.w3.org/ns/activitystreams', - 'id' => $this->permalink(), - 'type' => 'Create', - 'actor' => $profile->permalink(), - 'published' => $this->created_at->format('c'), - 'to' => $to, - 'cc' => $cc, - 'object' => [ - 'id' => $this->url(), - 'type' => 'Note', - 'summary' => null, - 'inReplyTo' => null, - 'published' => $this->created_at->format('c'), - 'url' => $this->url(), - 'attributedTo' => $this->profile->url(), - 'to' => $to, - 'cc' => $cc, - 'sensitive' => (bool) $this->is_nsfw, - 'content' => $this->rendered, - 'attachment' => $this->media->map(function($media) { - return [ - 'type' => 'Document', - 'mediaType' => $media->mime, - 'url' => $media->url(), - 'name' => null - ]; - })->toArray() - ] - ]; - } + public function toActivityPubObject() + { + if($this->local == false) { + return; + } + $profile = $this->profile; + $to = $this->scopeToAudience('to'); + $cc = $this->scopeToAudience('cc'); + return [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $this->permalink(), + 'type' => 'Create', + 'actor' => $profile->permalink(), + 'published' => $this->created_at->format('c'), + 'to' => $to, + 'cc' => $cc, + 'object' => [ + 'id' => $this->url(), + 'type' => 'Note', + 'summary' => null, + 'inReplyTo' => null, + 'published' => $this->created_at->format('c'), + 'url' => $this->url(), + 'attributedTo' => $this->profile->url(), + 'to' => $to, + 'cc' => $cc, + 'sensitive' => (bool) $this->is_nsfw, + 'content' => $this->rendered, + 'attachment' => $this->media->map(function($media) { + return [ + 'type' => 'Document', + 'mediaType' => $media->mime, + 'url' => $media->url(), + 'name' => null + ]; + })->toArray() + ] + ]; + } - public function scopeToAudience($audience) - { - if(!in_array($audience, ['to', 'cc']) || $this->local == false) { - return; - } - $res = []; - $res['to'] = []; - $res['cc'] = []; - $scope = $this->scope; - $mentions = $this->mentions->map(function ($mention) { - return $mention->permalink(); - })->toArray(); + public function scopeToAudience($audience) + { + if(!in_array($audience, ['to', 'cc']) || $this->local == false) { + return; + } + $res = []; + $res['to'] = []; + $res['cc'] = []; + $scope = $this->scope; + $mentions = $this->mentions->map(function ($mention) { + return $mention->permalink(); + })->toArray(); - if($this->in_reply_to_id != null) { - $parent = $this->parent(); - if($parent) { - $mentions = array_merge([$parent->profile->permalink()], $mentions); - } - } + if($this->in_reply_to_id != null) { + $parent = $this->parent(); + if($parent) { + $mentions = array_merge([$parent->profile->permalink()], $mentions); + } + } - switch ($scope) { - case 'public': - $res['to'] = [ - "https://www.w3.org/ns/activitystreams#Public" - ]; - $res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions); - break; + switch ($scope) { + case 'public': + $res['to'] = [ + "https://www.w3.org/ns/activitystreams#Public" + ]; + $res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions); + break; - case 'unlisted': - $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions); - $res['cc'] = [ - "https://www.w3.org/ns/activitystreams#Public" - ]; - break; + case 'unlisted': + $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions); + $res['cc'] = [ + "https://www.w3.org/ns/activitystreams#Public" + ]; + break; - case 'private': - $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions); - $res['cc'] = []; - break; + case 'private': + $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions); + $res['cc'] = []; + break; - // TODO: Update scope when DMs are supported - case 'direct': - $res['to'] = []; - $res['cc'] = []; - break; - } - return $res[$audience]; - } + // TODO: Update scope when DMs are supported + case 'direct': + $res['to'] = []; + $res['cc'] = []; + break; + } + return $res[$audience]; + } - public function place() - { - return $this->belongsTo(Place::class); - } + public function place() + { + return $this->belongsTo(Place::class); + } - public function directMessage() - { - return $this->hasOne(DirectMessage::class); - } + public function directMessage() + { + return $this->hasOne(DirectMessage::class); + } } From bc3add052575592c760e113131a0c053e24a87ab Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 26 Jul 2021 19:23:55 -0600 Subject: [PATCH 29/92] Update ContactAdmin mail, set New Support Message subject --- app/Mail/ContactAdmin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Mail/ContactAdmin.php b/app/Mail/ContactAdmin.php index 97294aaec..9a4863950 100644 --- a/app/Mail/ContactAdmin.php +++ b/app/Mail/ContactAdmin.php @@ -32,6 +32,6 @@ class ContactAdmin extends Mailable public function build() { $contact = $this->contact; - return $this->markdown('emails.contact.admin')->with(compact('contact')); + return $this->subject('New Support Message')->markdown('emails.contact.admin')->with(compact('contact')); } } From 6e45021fc2d742bbb0293ae4b7c8d5fb7df58b83 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 26 Jul 2021 22:21:03 -0600 Subject: [PATCH 30/92] Update StatusTransformer, prioritize scope over deprecated visibility attribute --- app/Transformer/Api/StatusStatelessTransformer.php | 2 +- app/Transformer/Api/StatusTransformer.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index fe433d988..e3352bcaa 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -39,7 +39,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'muted' => null, 'sensitive' => (bool) $status->is_nsfw, 'spoiler_text' => $status->cw_summary ?? '', - 'visibility' => $status->visibility ?? $status->scope, + 'visibility' => $status->scope ?? $status->visibility, 'application' => [ 'name' => 'web', 'website' => null diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index 481d55a62..f2fd4a2cb 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -41,7 +41,7 @@ class StatusTransformer extends Fractal\TransformerAbstract 'muted' => null, 'sensitive' => (bool) $status->is_nsfw, 'spoiler_text' => $status->cw_summary ?? '', - 'visibility' => $status->visibility ?? $status->scope, + 'visibility' => $status->scope ?? $status->visibility, 'application' => [ 'name' => 'web', 'website' => null From e9ef0c887afe9afdbd6f5c63ee00c28304b1b0bd Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 26 Jul 2021 22:49:46 -0600 Subject: [PATCH 31/92] Add Archive Posts --- .../Controllers/Api/BaseApiController.php | 75 ++++++- .../assets/js/components/PostComponent.vue | 25 +++ resources/assets/js/components/Profile.vue | 200 ++++++++++++------ .../js/components/partials/ContextMenu.vue | 35 ++- .../js/components/partials/StatusCard.vue | 11 +- .../views/site/help/sharing-media.blade.php | 77 +++++-- routes/web.php | 3 + 7 files changed, 346 insertions(+), 80 deletions(-) diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php index 0e62013f4..70401eba5 100644 --- a/app/Http/Controllers/Api/BaseApiController.php +++ b/app/Http/Controllers/Api/BaseApiController.php @@ -15,7 +15,8 @@ use App\{ Media, Notification, Profile, - Status + Status, + StatusArchived }; use App\Transformer\Api\{ AccountTransformer, @@ -39,6 +40,7 @@ use App\Jobs\VideoPipeline\{ use App\Services\NotificationService; use App\Services\MediaPathService; use App\Services\MediaBlocklistService; +use App\Services\StatusService; class BaseApiController extends Controller { @@ -286,4 +288,75 @@ class BaseApiController extends Controller return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } + + public function archive(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $status = Status::whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->whereProfileId($request->user()->profile_id) + ->findOrFail($id); + + if($status->scope === 'archived') { + return [200]; + } + + $archive = new StatusArchived; + $archive->status_id = $status->id; + $archive->profile_id = $status->profile_id; + $archive->original_scope = $status->scope; + $archive->save(); + + $status->scope = 'archived'; + $status->visibility = 'draft'; + $status->save(); + + StatusService::del($status->id); + + // invalidate caches + + return [200]; + } + + public function unarchive(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $status = Status::whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->whereProfileId($request->user()->profile_id) + ->findOrFail($id); + + if($status->scope !== 'archived') { + return [200]; + } + + $archive = StatusArchived::whereStatusId($status->id) + ->whereProfileId($status->profile_id) + ->firstOrFail(); + + $status->scope = $archive->original_scope; + $status->visibility = $archive->original_scope; + $status->save(); + + $archive->delete(); + + return [200]; + } + + public function archivedPosts(Request $request) + { + abort_if(!$request->user(), 403); + + $statuses = Status::whereProfileId($request->user()->profile_id) + ->whereScope('archived') + ->orderByDesc('id') + ->simplePaginate(10); + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer()); + return $fractal->createData($resource)->toArray(); + } } diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index 6395318af..0ca16f10e 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -616,6 +616,8 @@
Block
Unblock
Report +
Archive
+
Unarchive
Delete
Cancel
@@ -1757,6 +1759,29 @@ export default { }); }, + archivePost(status) { + if(window.confirm('Are you sure you want to archive this post?') == false) { + return; + } + + axios.post('/api/pixelfed/v2/status/' + status.id + '/archive') + .then(res => { + this.$refs.ctxModal.hide(); + window.location.href = '/'; + }); + }, + + unarchivePost(status) { + if(window.confirm('Are you sure you want to unarchive this post?') == false) { + return; + } + + axios.post('/api/pixelfed/v2/status/' + status.id + '/unarchive') + .then(res => { + this.$refs.ctxModal.hide(); + }); + } + }, } diff --git a/resources/assets/js/components/Profile.vue b/resources/assets/js/components/Profile.vue index 6545a8eae..83d524369 100644 --- a/resources/assets/js/components/Profile.vue +++ b/resources/assets/js/components/Profile.vue @@ -181,64 +181,68 @@ +
-
-
- -
-
- @@ -663,6 +690,7 @@ diff --git a/resources/assets/js/components/partials/StatusCard.vue b/resources/assets/js/components/partials/StatusCard.vue index 2e5b75c0d..47aff093a 100644 --- a/resources/assets/js/components/partials/StatusCard.vue +++ b/resources/assets/js/components/partials/StatusCard.vue @@ -77,7 +77,10 @@
- +
@@ -149,9 +152,13 @@

- + + + Posted + + · Based on popular and trending content diff --git a/resources/views/site/help/sharing-media.blade.php b/resources/views/site/help/sharing-media.blade.php index f29b39d3f..d429f7406 100644 --- a/resources/views/site/help/sharing-media.blade.php +++ b/resources/views/site/help/sharing-media.blade.php @@ -32,7 +32,7 @@

-

+

+

+
+ You can archive your posts which prevents anyone from interacting or viewing it. +
+ Archived posts cannot be deleted or otherwise interacted with. You may not recieve interactions (comments, likes, shares) from other servers while a post is archived. +
+
+
+

+ +

+ +

+
+ To archive your posts: +
    +
  • Navigate to the post
  • +
  • Open the menu, click the or button
  • +
  • Click on Archive
  • +
+
+
+

+ +

+ +

+
+ To unarchive your posts: +
    +
  • Navigate to your profile
  • +
  • Click on the ARCHIVES tab
  • +
  • Scroll to the post you want to unarchive
  • +
  • Open the menu, click the or button
  • +
  • Click on Unarchive
  • +
+
+
+

+ +@endsection diff --git a/routes/web.php b/routes/web.php index 77e834b2f..21bee6aaf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -200,6 +200,9 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('discover/posts/places', 'DiscoverController@trendingPlaces'); Route::get('seasonal/yir', 'SeasonalController@getData'); Route::post('seasonal/yir', 'SeasonalController@store'); + Route::post('status/{id}/archive', 'ApiController@archive'); + Route::post('status/{id}/unarchive', 'ApiController@unarchive'); + Route::get('statuses/archives', 'ApiController@archivedPosts'); }); }); From d4921209c57f999d29f4935c099cce079c6141c7 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 26 Jul 2021 22:50:28 -0600 Subject: [PATCH 32/92] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 906127996..9e3e4d4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Default media licenses and media license sync ([ea0fc90c](https://github.com/pixelfed/pixelfed/commit/ea0fc90c)) - Customize media description/alt-text length limit ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1)) - Federate Media Licenses ([14a1367a](https://github.com/pixelfed/pixelfed/commit/14a1367a)) +- Archive Posts ([e9ef0c88](https://github.com/pixelfed/pixelfed/commit/e9ef0c88)) ### Updated - Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b)) @@ -91,6 +92,9 @@ - Updated NotifcationCard.vue component, add refresh button for cold notification cache. ([0e178a33](https://github.com/pixelfed/pixelfed/commit/0e178a33)) - Updated RemoteProfile component, add follower modals. ([c4146a30](https://github.com/pixelfed/pixelfed/commit/c4146a30)) - Updated FollowerService, cache audience. ([22257cc2](https://github.com/pixelfed/pixelfed/commit/22257cc2)) +- Updated StatusService, add non-public option and improve cache invalidation. ([15c4fdd9](https://github.com/pixelfed/pixelfed/commit/15c4fdd9)) +- Updated ContactAdmin mail, set New Support Message subject. ([bc3add05](https://github.com/pixelfed/pixelfed/commit/bc3add05)) +- Updated StatusTransformer, prioritize scope over deprecated visibility attribute. ([6e45021f](https://github.com/pixelfed/pixelfed/commit/6e45021f)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0) From b34814e9ff4fe41fc1fa3fe2986f2f69bc00f722 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 26 Jul 2021 22:52:46 -0600 Subject: [PATCH 33/92] Update compiled assets --- public/js/activity.js | Bin 9640 -> 9790 bytes public/js/hashtag.js | Bin 15068 -> 15020 bytes public/js/profile.js | Bin 116372 -> 152646 bytes public/js/rempos.js | Bin 127200 -> 128806 bytes public/js/rempro.js | Bin 70755 -> 80292 bytes public/js/status.js | Bin 124460 -> 125389 bytes public/js/timeline.js | Bin 193404 -> 195551 bytes public/mix-manifest.json | Bin 2125 -> 2125 bytes 8 files changed, 0 insertions(+), 0 deletions(-) diff --git a/public/js/activity.js b/public/js/activity.js index aae83d388864c24fd8924160c602dfb3a5b96ac9..50b2492e3e42985a323a193152f93c8694c81678 100644 GIT binary patch delta 191 zcmZ4Cz0YTZF6ZP}ZgKw-z2cI@lG0*LH8l-I4W+civi#Db%#u_kTiX)7lFEWqH8n*f zH6SJVIXU^|sPc))$@!&uC7PO>y*XbpPU6%BneB(9j~KJTYH%Aqxr$qT^L%bjDFA-q BJ|O@A delta 40 wcmdnzv%-6WF6U%DPWjDKIA1VMuH#miyntJQt+*tyq_lW4ACLNGa~^gn04tvjH2?qr diff --git a/public/js/hashtag.js b/public/js/hashtag.js index de19485cff411729fdd09fa3ec3c873530be9ca2..c0b77b338f2d25f6cdc32377cb80d5561ff35eb9 100644 GIT binary patch delta 184 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 AbN~PV diff --git a/public/js/rempos.js b/public/js/rempos.js index 4c2f90a8ca4e0f107eeeadc46f852a99614eadbd..b636824515eb431c8797d61c3d9e671080cd9bdd 100644 GIT binary patch delta 1063 zcmah|-A|Hn6y8C;{6N%93Bu&90m^57CA8M8i`JS$Qqc)DUU^>^6G7qCy5{cNZwM8{{}6Z>|~$ML3perP_?~ zkYQI)F>Vc$I~(BCTE}g^-kvVs4WlP$4zsk6^M`^FH$!u@Y0gyYl5;6gle!d~k{&FL zmJvyTfk$ZGQj(n%T;eOloQB6`lubi#8P#XNRz}w|&{IaS40M#yrwlwVqv&V2$RkW{ zdL+eRa{ew7&4QKhlGj-<#ocDv=^A4(x_l(K_oW%mUrJv|LK;2N<5s0qk{}ORVoq#Xu^eB5m~tj$~w**@K3Ul!WyG_okjf_yr!?hmpV1t^bNSI zKZd{cEs1Xk!EXLw69XTiUjResO{&SSBs?HLv+$-O_hb^Q0HyG3?g!&y^1g6B?Dys8 PIfbiR#}k{IP}}kc=_GDT delta 816 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 delta 3152 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 diff --git a/public/js/status.js b/public/js/status.js index c09608834657c6a371c3e39c043e881a58330855..175fb756714ae10b69d26e28e874cd5f40bc1a58 100644 GIT binary patch delta 1081 zcmbu8T}V@57{_^VxivE@ab>o-9dc~!?1L%h*J(*o)F8)bnrg6x$RewW(VjaT_mpadLBF%D4lJrw)o{bn?6h#F*Tq+i83rMf}--1`-gEg+6 zLe)f3UbERtCKSKG(8=&ncU%<6K5R~S#xrdRX(jj({L-yZ0(zvwA>DGtK%6^7<_v6_ z@$rHvEv{Bqi9@W=?lMs$EEnNN9MYXd!duW7b2VdJQF;ig*-VeJyx_>8g>cew)){9< znOKyGIMZ&YAktz=a1u7r!A5LKeg7Cj+Fj8}(;GUe{q+&)?jvOqV28d#UVX`sfBeg?o$ zX*Lsu&=JCfh7Z6A^w6j_{)y&>A{E| z_Cg~X41!%#jF@+70)^g`yjpp;A9jvu8eI$>ZH%^{?O`y;J|4ET^7;)>*Q#>K(c}ae z4l37RdYiflE83Faxm-05Wh%LI0WKa@g}a7)5;%0yIkifayzEV)UlL4}6A{~`CL-^? zf4yGbd;w=F%akoj(saz*mzT=01`h4Mb?E{4%*8+hWQcPl89FSoJSPMrv`E|2_F@$o zv(SOVE2Kx0S!l;g@3UaTw3mfYMbSK997pfwp$?7BKn81m&p->Nvj|!-O(1B-w1&Wq zX%E3uOiQ!i!L&OIK}=WY;5Mcob6{8H8=+jh6}dU6#l_DYSkX-mjCkn@2d6P9f-X#_ lcxCR+b$Fx7J-vM6tCX?j#zQE-##oXK7p9?;>o=g%@COuLZZT4sPu{$VUKJ5BDb~;1_j{k`dwF=?o%h7EZQ}V5 z;S7%_BobYE3m!rkCYj^~1szz0mA&M6eDV}o*}dUK-3qw$Y2q$3#L&WMo|hKR79^Dw zB)&+c=*5CCHxQ(0nu$pZq8Mc=&Y4%|6w6tXS&mQgBG0L%1gmoMRQ#`dkfs%%PodJb zi)V}6{6$$UQCIM?bgZmJ(-k7~22zy|83p1MHgiOu&B~HT( z?Vj5ydh6ivbu#9ttTbBWSxyxSQYkaXs%&5}&{5HYI`pDzb?C=AtkGpmzw0o9Db#@M z{?6yzKp9Ql2QShapkdFi7WmL?6aMSzdJ`hpu-=3TOy8R@hAFfOHB6s3A%^yYCN3%<3rs diff --git a/public/js/timeline.js b/public/js/timeline.js index 943473953e8c44079743f3ca0120523725fe9213..af705402b1d593b58a4417fe9302f817f158d23f 100644 GIT binary patch delta 1630 zcmb7EOKclO7}iYN#CfRcBazc40eb|?ZoO=tgkrRW9*~M?Vv{D8APwneJ&qTxcii1s zTUK_JL#4u{s*))hkb0n}B5?rZgAWK*NIh^uZ~#O^Na%rMfeR0b1M5eeCJ>ZfRy*H6 z|2N-!|3ClVKX+{Y(eZI#EP^y!&l&}sMp2b24%MjZC2Z zT8d6g6YJC?e?C0DgHP4*DLJ89?AT8|Y<|7taKiHv zWpn)j1k-G8<9zVFwl&VyH?DvSZ0deL%l>#h_yhNQ%SShhpcXz0rmx=Js@&ZI_trwP zCfl;9F0fIxK6H;#@v>Aq|YLz6T%YFP7k8E>if;A(5#3 zGYZS?o`-aj%VCT1d=Yj-&*Rx5?C%Wlj8-77ZBSf`JW)l0tCJ$Cnomo4EC^AmXu3{Z zi&ooOc6ii-i2vZi$uW5-8kl8bYl&t(FW9$7k>`oQ^WRaeMu((|RDfY{a7~f_*A(34 z?IJ{#4~uZL1quTH)`x$N7#WdDHm0s^MW~Y1oJ7$|+9Z!{S`v{-bj?JPAPiT>Q*AZD zR-mvFr;2NvzAtMkvg<}q5o%-%U90wjgJfIHxK^z(L}{634JSy~(uTDZTurX6_j=F_ zWH8mUyXb;BA!`xCijaxK;+t4sph_BN3=21x zSaO@|Vo=3K%$MS1PMcS4nFJ^3swJXV&IePF-zkAh{L%?1RBo-nMC&0z!2CNO#wuNJ z!6U~NnJNnf5mg87uCmp8U{7r)_?7FhCq$nfi8wjU(=GiYRr`pijXU4~$(}pqMxCaI*Gw(ui F?jMKQMHB!4 delta 507 zcmccro%_!-?hVq~(@)G`6q%;S$h|pA+k|oR1KnRLo0~!+8K=vLGm1_2uTq=5AzWi} zLYUIzw0d_an{~2Wxbk#~bxeGd9cnCrg3gn##tTp8jZm8Iwv$PHa(tB-P*8RA<_Iyy z$y=h$CkIB@PJS1|wmBfiwOPr>Hcu}(KPM+Oxg;|`Pba{(EI%_v!NAtm*2g}tG$+T( zXY%&@*5dvMaX*N-pH7f%-sFqVL?_EV;ARP`sR`Pw``{fDqyJ=?#~wh^{h8V1*^dhu z119sokeRIcq?a*Z`u%DqiOuJqlrw_mEg1cq8=h@%c*b~S5|eH9bjNdy;?t8WnQ|sy zU=*AFHieOEx=W|CnAvVqK6Ad^E=vsP16r`UG#!RIp5 z{i_(+r!&?td2es1VG?IV)&paMG_ip6PZzFZQsMyXPPEMjxlD0;Y#kFf+w|f-CY9;a zTA0pEZU_*WY#X30S6i!-Xq%^@Pe5a!R7HnQ4-e4k$1+tyA?@~jp52gnhcQ6V+0-`l{>$8 z-LL!CUtqSHsG!zC%nd-xxZK~K0(yh)!=a~)g$twlJTB)KsvR?D;Es)(gF4RJ@)MAg zL^sjFC{Q4&(EMIcA(9caSSxkSxf+7B`MVuo Date: Mon, 26 Jul 2021 23:59:38 -0600 Subject: [PATCH 34/92] Update AccountService, add syncPostCount method --- .../Controllers/Api/BaseApiController.php | 8 +++--- app/Http/Controllers/PublicApiController.php | 21 ++++++++++----- app/Services/AccountService.php | 27 +++++++++++++++++++ app/Services/StatusService.php | 2 ++ 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php index 70401eba5..c700f2434 100644 --- a/app/Http/Controllers/Api/BaseApiController.php +++ b/app/Http/Controllers/Api/BaseApiController.php @@ -37,6 +37,7 @@ use App\Jobs\VideoPipeline\{ VideoPostProcess, VideoThumbnail }; +use App\Services\AccountService; use App\Services\NotificationService; use App\Services\MediaPathService; use App\Services\MediaBlocklistService; @@ -311,10 +312,8 @@ class BaseApiController extends Controller $status->scope = 'archived'; $status->visibility = 'draft'; $status->save(); - StatusService::del($status->id); - - // invalidate caches + AccountService::syncPostCount($status->profile_id); return [200]; } @@ -339,8 +338,9 @@ class BaseApiController extends Controller $status->scope = $archive->original_scope; $status->visibility = $archive->original_scope; $status->save(); - $archive->delete(); + StatusService::del($status->id); + AccountService::syncPostCount($status->profile_id); return [200]; } diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 5059f8161..566d2504a 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -675,9 +675,7 @@ class PublicApiController extends Controller $limit = $request->limit ?? 9; $max_id = $request->max_id; $min_id = $request->min_id; - $scope = $request->only_media == true ? - ['photo', 'photo:album', 'video', 'video:album'] : - ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply', 'text']; + $scope = ['photo', 'photo:album', 'video', 'video:album']; if($profile->is_private) { if(!$user) { @@ -713,6 +711,8 @@ class PublicApiController extends Controller 'created_at' ) ->whereProfileId($profile->id) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') ->whereIn('type', $scope) ->where('id', $dir, $id) ->whereIn('scope', $visibility) @@ -720,12 +720,21 @@ class PublicApiController extends Controller ->orderByDesc('id') ->get() ->map(function($s) use($user) { - $status = StatusService::get($s->id, false); - if($user) { + try { + $status = StatusService::get($s->id, false); + } catch (\Exception $e) { + $status = false; + } + if($user && $status) { $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); } return $status; - }); + }) + ->filter(function($s) { + return $s; + }) + ->values(); + return response()->json($res); } diff --git a/app/Services/AccountService.php b/app/Services/AccountService.php index 8f9f5c3b9..955e168b7 100644 --- a/app/Services/AccountService.php +++ b/app/Services/AccountService.php @@ -4,6 +4,7 @@ namespace App\Services; use Cache; use App\Profile; +use App\Status; use App\Transformer\Api\AccountTransformer; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; @@ -35,4 +36,30 @@ class AccountService return Cache::forget(self::CACHE_KEY . $id); } + public static function syncPostCount($id) + { + $profile = Profile::find($id); + + if(!$profile) { + return false; + } + + $key = self::CACHE_KEY . 'pcs:' . $id; + + if(Cache::has($key)) { + return; + } + + $count = Status::whereProfileId($id) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->whereIn('scope', ['public', 'unlisted', 'private']) + ->count(); + + $profile->status_count = $count; + $profile->save(); + + Cache::put($key, 1, 900); + return true; + } } diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index 892005f7e..d503f906f 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -41,6 +41,8 @@ class StatusService { public static function del($id) { + Cache::forget('status:thumb:nsfw0' . $id); + Cache::forget('status:thumb:nsfw1' . $id); Cache::forget('pf:services:sh:id:' . $id); Cache::forget('status:transformer:media:attachments:' . $id); PublicTimelineService::rem($id); From acaf630dee2794ef4d1f9d25c7441ee4400581a4 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 27 Jul 2021 00:13:03 -0600 Subject: [PATCH 35/92] Update StatusService, invalidate profile embed cache on deletion --- app/Services/StatusService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index d503f906f..90629b9f9 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -41,6 +41,10 @@ class StatusService { public static function del($id) { + $status = self::get($id); + if($status && isset($status['account']) && isset($status['account']['id'])) { + Cache::forget('profile:embed:' . $status['account']['id']); + } Cache::forget('status:thumb:nsfw0' . $id); Cache::forget('status:thumb:nsfw1' . $id); Cache::forget('pf:services:sh:id:' . $id); From 4fb3d1fa70998a69f219709de2b661a383922b90 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 27 Jul 2021 00:27:36 -0600 Subject: [PATCH 36/92] Update status.reply view, fix archived post leakage --- resources/views/status/reply.blade.php | 49 +++++++++++++++++--------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/resources/views/status/reply.blade.php b/resources/views/status/reply.blade.php index dee2e360c..7bd11b7b2 100644 --- a/resources/views/status/reply.blade.php +++ b/resources/views/status/reply.blade.php @@ -5,60 +5,74 @@
- @if($status->parent()->parent()) + @php($gp = $status->parent()->parent()) + @if($gp)
+ @if($gp->scope == 'archived') +

This status cannot be viewed at this time.

+ @else
- @if($status->parent()->parent()->media()->count()) - + @if($gp->media()->count()) + @endif
- +
- {{$status->parent()->parent()->profile->username}} + {{$gp->profile->username}}
-

{!!$status->parent()->parent()->rendered!!}

+

{!!$gp->rendered!!}

- +
+ @endif
@endif + + @php($parent = $status->parent())
+ + @if($parent->scope == 'archived') +

This status cannot be viewed at this time.

+ @else
- @if($status->parent()->media()->count()) - + @if($parent->media()->count()) + @endif
- +
- {{$status->parent()->profile->username}} + {{$parent->profile->username}}
-

{!!$status->parent()->rendered!!}

+

{!!$parent->rendered!!}

- +
+ @endif
+ +
@if($status->is_nsfw)
@@ -101,6 +115,7 @@
@endif
+ @if($status->comments->count())
From c5281dcdb31716340e42bd28f9cb47058097be40 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 27 Jul 2021 00:49:59 -0600 Subject: [PATCH 37/92] Update PostComponents, re-add time to timestamp --- resources/assets/js/components/PostComponent.vue | 2 +- resources/assets/js/components/RemotePost.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index 0ca16f10e..ea87e8a10 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -937,7 +937,7 @@ export default { timestampFormat() { let ts = new Date(this.status.created_at); - return ts.toDateString(); + return ts.toDateString() + ' · ' + ts.toLocaleTimeString(); }, fetchData() { diff --git a/resources/assets/js/components/RemotePost.vue b/resources/assets/js/components/RemotePost.vue index 00b01960b..eab25df32 100644 --- a/resources/assets/js/components/RemotePost.vue +++ b/resources/assets/js/components/RemotePost.vue @@ -643,7 +643,7 @@ export default { timestampFormat() { let ts = new Date(this.status.created_at); - return ts.toDateString(); + return ts.toDateString() + ' · ' + ts.toLocaleTimeString(); }, fetchData() { From 03199e2f68862b41911bd7086117848f358e4106 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 1 Aug 2021 15:09:52 -0600 Subject: [PATCH 38/92] Update follow intent, fix follower count leak --- resources/views/site/intents/follow.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/site/intents/follow.blade.php b/resources/views/site/intents/follow.blade.php index 6b474e06e..b14cf4423 100644 --- a/resources/views/site/intents/follow.blade.php +++ b/resources/views/site/intents/follow.blade.php @@ -14,7 +14,7 @@

{{$profile->username}}

-

{{$profile->followers->count()}} followers

+

{{$profile->followerCount()}} followers

@if($following == true)
@@ -50,4 +50,4 @@
-@endsection \ No newline at end of file +@endsection From 5916f8c76a0703d63bf37d01154accfb38bf4eed Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Wed, 4 Aug 2021 00:00:50 -0600 Subject: [PATCH 39/92] Update Profile model, fix getAudienceInbox method --- app/Profile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Profile.php b/app/Profile.php index 2d9ef3fdc..0b2aa9460 100644 --- a/app/Profile.php +++ b/app/Profile.php @@ -277,7 +277,7 @@ class Profile extends Model public function getAudienceInbox($scope = 'public') { - return FollowerService::audience($this->id, $scope); + return FollowerService::audience($this, $scope); } public function circles() From 770922007461a1f3691ea2398a4766295792e7a0 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Wed, 4 Aug 2021 20:29:21 -0600 Subject: [PATCH 40/92] Add Polls --- app/Http/Controllers/PollController.php | 73 +++ app/Http/Controllers/PublicApiController.php | 44 +- .../StatusActivityPubDeliver.php | 153 ++--- app/Models/Poll.php | 35 ++ app/Models/PollVote.php | 11 + app/Services/PollService.php | 72 +++ .../ActivityPub/Verb/CreateQuestion.php | 46 ++ app/Transformer/ActivityPub/Verb/Question.php | 89 +++ .../Api/StatusStatelessTransformer.php | 5 +- app/Transformer/Api/StatusTransformer.php | 5 +- app/Util/ActivityPub/Helpers.php | 67 ++- app/Util/ActivityPub/Inbox.php | 80 ++- app/Util/Site/Config.php | 5 +- config/exp.php | 1 + .../2021_07_29_014835_create_polls_table.php | 41 ++ ...1_07_29_014849_create_poll_votes_table.php | 36 ++ .../assets/js/components/ComposeModal.vue | 171 +++++- .../assets/js/components/PostComponent.vue | 523 +++++++++++------- resources/assets/js/components/RemotePost.vue | 14 +- .../js/components/partials/CommentFeed.vue | 286 ++++++++++ .../js/components/partials/PollCard.vue | 327 +++++++++++ .../js/components/partials/StatusCard.vue | 53 +- routes/web.php | 3 + 23 files changed, 1819 insertions(+), 321 deletions(-) create mode 100644 app/Http/Controllers/PollController.php create mode 100644 app/Models/Poll.php create mode 100644 app/Models/PollVote.php create mode 100644 app/Services/PollService.php create mode 100644 app/Transformer/ActivityPub/Verb/CreateQuestion.php create mode 100644 app/Transformer/ActivityPub/Verb/Question.php create mode 100644 database/migrations/2021_07_29_014835_create_polls_table.php create mode 100644 database/migrations/2021_07_29_014849_create_poll_votes_table.php create mode 100644 resources/assets/js/components/partials/CommentFeed.vue create mode 100644 resources/assets/js/components/partials/PollCard.vue diff --git a/app/Http/Controllers/PollController.php b/app/Http/Controllers/PollController.php new file mode 100644 index 000000000..21acd283e --- /dev/null +++ b/app/Http/Controllers/PollController.php @@ -0,0 +1,73 @@ +status_id); + if($status->scope != 'public') { + abort_if(!$request->user(), 403); + if($request->user()->profile_id != $status->profile_id) { + abort_if(!FollowerService::follows($request->user()->profile_id, $status->profile_id), 404); + } + } + $pid = $request->user() ? $request->user()->profile_id : false; + $poll = PollService::getById($id, $pid); + return $poll; + } + + public function vote(Request $request, $id) + { + abort_unless($request->user(), 403); + + $this->validate($request, [ + 'choices' => 'required|array' + ]); + + $pid = $request->user()->profile_id; + $poll_id = $id; + $choices = $request->input('choices'); + + // todo: implement multiple choice + $choice = $choices[0]; + + $poll = Poll::findOrFail($poll_id); + + abort_if(now()->gt($poll->expires_at), 422, 'Poll expired.'); + + abort_if(PollVote::wherePollId($poll_id)->whereProfileId($pid)->exists(), 400, 'Already voted.'); + + $vote = new PollVote; + $vote->status_id = $poll->status_id; + $vote->profile_id = $pid; + $vote->poll_id = $poll->id; + $vote->choice = $choice; + $vote->save(); + + $poll->votes_count = $poll->votes_count + 1; + $poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($choice) { + return $choice == $key ? $tally + 1 : $tally; + })->toArray(); + $poll->save(); + + PollService::del($poll->status_id); + $res = PollService::get($poll->status_id, $pid); + return $res; + } +} diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 566d2504a..bd96e774e 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -93,20 +93,15 @@ class PublicApiController extends Controller $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail(); $status = Status::whereProfileId($profile->id)->findOrFail($postid); $this->scopeCheck($profile, $status); - if(!Auth::check()) { - $res = Cache::remember('wapi:v1:status:stateless_byid:' . $status->id, now()->addMinutes(30), function() use($status) { - $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); - $res = [ - 'status' => $this->fractal->createData($item)->toArray(), - ]; - return $res; - }); - return response()->json($res); + if(!$request->user()) { + $res = ['status' => StatusService::get($status->id)]; + } else { + $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); + $res = [ + 'status' => $this->fractal->createData($item)->toArray(), + ]; } - $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); - $res = [ - 'status' => $this->fractal->createData($item)->toArray(), - ]; + return response()->json($res); } @@ -403,11 +398,22 @@ class PublicApiController extends Controller } $filtered = $user ? UserFilterService::filters($user->profile_id) : []; - $textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid); - $textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid); - $types = $textOnlyPosts ? - ['text', 'photo', 'photo:album', 'video', 'video:album', 'photo:video:album'] : - ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']; + $types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']; + + $textOnlyReplies = false; + + if(config('exp.top')) { + $textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid); + $textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid); + + if($textOnlyPosts) { + array_push($types, 'text'); + } + } + + if(config('exp.polls') == true) { + array_push($types, 'poll'); + } if($min || $max) { $dir = $min ? '>' : '<'; @@ -433,7 +439,7 @@ class PublicApiController extends Controller 'updated_at' ) ->whereIn('type', $types) - ->when(!$textOnlyReplies, function($q, $textOnlyReplies) { + ->when($textOnlyReplies != true, function($q, $textOnlyReplies) { return $q->whereNull('in_reply_to_id'); }) ->with('profile', 'hashtags', 'mentions') diff --git a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php index af65aed75..759f5c72c 100644 --- a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php +++ b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php @@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; use App\Transformer\ActivityPub\Verb\CreateNote; +use App\Transformer\ActivityPub\Verb\CreateQuestion; use App\Util\ActivityPub\Helpers; use GuzzleHttp\Pool; use GuzzleHttp\Client; @@ -20,85 +21,97 @@ use App\Util\ActivityPub\HttpSignature; class StatusActivityPubDeliver implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - 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(Status $status) - { - $this->status = $status; - } + protected $status; - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - $status = $this->status; - $profile = $status->profile; + /** + * Delete the job if its models no longer exist. + * + * @var bool + */ + public $deleteWhenMissingModels = true; - if($status->local == false || $status->url || $status->uri) { - return; - } + /** + * Create a new job instance. + * + * @return void + */ + public function __construct(Status $status) + { + $this->status = $status; + } - $audience = $status->profile->getAudienceInbox(); + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $status = $this->status; + $profile = $status->profile; - if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) { - // Return on profiles with no remote followers - return; - } + if($status->local == false || $status->url || $status->uri) { + return; + } + + $audience = $status->profile->getAudienceInbox(); + + if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) { + // Return on profiles with no remote followers + return; + } + + switch($status->type) { + case 'poll': + $activitypubObject = new CreateQuestion(); + break; + + default: + $activitypubObject = new CreateNote(); + break; + } - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($status, new CreateNote()); - $activity = $fractal->createData($resource)->toArray(); + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($status, $activitypubObject); + $activity = $fractal->createData($resource)->toArray(); - $payload = json_encode($activity); - - $client = new Client([ - 'timeout' => config('federation.activitypub.delivery.timeout') - ]); + $payload = json_encode($activity); - $requests = function($audience) use ($client, $activity, $profile, $payload) { - foreach($audience as $url) { - $headers = HttpSignature::sign($profile, $url, $activity); - yield function() use ($client, $url, $headers, $payload) { - return $client->postAsync($url, [ - 'curl' => [ - CURLOPT_HTTPHEADER => $headers, - CURLOPT_POSTFIELDS => $payload, - CURLOPT_HEADER => true - ] - ]); - }; - } - }; + $client = new Client([ + 'timeout' => config('federation.activitypub.delivery.timeout') + ]); - $pool = new Pool($client, $requests($audience), [ - 'concurrency' => config('federation.activitypub.delivery.concurrency'), - 'fulfilled' => function ($response, $index) { - }, - 'rejected' => function ($reason, $index) { - } - ]); - - $promise = $pool->promise(); + $requests = function($audience) use ($client, $activity, $profile, $payload) { + foreach($audience as $url) { + $headers = HttpSignature::sign($profile, $url, $activity); + yield function() use ($client, $url, $headers, $payload) { + return $client->postAsync($url, [ + 'curl' => [ + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HEADER => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false + ] + ]); + }; + } + }; - $promise->wait(); - } + $pool = new Pool($client, $requests($audience), [ + 'concurrency' => config('federation.activitypub.delivery.concurrency'), + 'fulfilled' => function ($response, $index) { + }, + 'rejected' => function ($reason, $index) { + } + ]); + + $promise = $pool->promise(); + + $promise->wait(); + } } diff --git a/app/Models/Poll.php b/app/Models/Poll.php new file mode 100644 index 000000000..2b65162c0 --- /dev/null +++ b/app/Models/Poll.php @@ -0,0 +1,35 @@ + 'array', + 'cached_tallies' => 'array', + 'expires_at' => 'datetime' + ]; + + public function votes() + { + return $this->hasMany(PollVote::class); + } + + public function getTallies() + { + return $this->cached_tallies; + } +} diff --git a/app/Models/PollVote.php b/app/Models/PollVote.php new file mode 100644 index 000000000..c6aae7fa9 --- /dev/null +++ b/app/Models/PollVote.php @@ -0,0 +1,11 @@ +firstOrFail(); + return [ + 'id' => (string) $poll->id, + 'expires_at' => $poll->expires_at->format('c'), + 'expired' => null, + 'multiple' => $poll->multiple, + 'votes_count' => $poll->votes_count, + 'voters_count' => null, + 'voted' => false, + 'own_votes' => [], + 'options' => collect($poll->poll_options)->map(function($option, $key) use($poll) { + $tally = $poll->cached_tallies && isset($poll->cached_tallies[$key]) ? $poll->cached_tallies[$key] : 0; + return [ + 'title' => $option, + 'votes_count' => $tally + ]; + })->toArray(), + 'emojis' => [] + ]; + }); + + if($profileId) { + $res['voted'] = self::voted($id, $profileId); + $res['own_votes'] = self::ownVotes($id, $profileId); + } + + return $res; + } + + public static function getById($id, $pid) + { + $poll = Poll::findOrFail($id); + return self::get($poll->status_id, $pid); + } + + public static function del($id) + { + Cache::forget(self::CACHE_KEY . $id); + } + + public static function voted($id, $profileId = false) + { + return !$profileId ? false : PollVote::whereStatusId($id) + ->whereProfileId($profileId) + ->exists(); + } + + public static function ownVotes($id, $profileId = false) + { + return !$profileId ? [] : PollVote::whereStatusId($id) + ->whereProfileId($profileId) + ->pluck('choice') ?? []; + } +} diff --git a/app/Transformer/ActivityPub/Verb/CreateQuestion.php b/app/Transformer/ActivityPub/Verb/CreateQuestion.php new file mode 100644 index 000000000..a1aaccdc2 --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/CreateQuestion.php @@ -0,0 +1,46 @@ + [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + [ + 'sc' => 'http://schema.org#', + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + 'commentsEnabled' => 'sc:Boolean', + 'capabilities' => [ + 'announce' => ['@type' => '@id'], + 'like' => ['@type' => '@id'], + 'reply' => ['@type' => '@id'] + ] + ] + ], + 'id' => $status->permalink(), + 'type' => 'Create', + 'actor' => $status->profile->permalink(), + 'published' => $status->created_at->toAtomString(), + 'to' => $status->scopeToAudience('to'), + 'cc' => $status->scopeToAudience('cc'), + ]; + } + + public function includeObject(Status $status) + { + return $this->item($status, new Question()); + } +} diff --git a/app/Transformer/ActivityPub/Verb/Question.php b/app/Transformer/ActivityPub/Verb/Question.php new file mode 100644 index 000000000..fd78ce2ff --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/Question.php @@ -0,0 +1,89 @@ +mentions->map(function ($mention) { + $webfinger = $mention->emailUrl(); + $name = Str::startsWith($webfinger, '@') ? + $webfinger : + '@' . $webfinger; + return [ + 'type' => 'Mention', + 'href' => $mention->permalink(), + 'name' => $name + ]; + })->toArray(); + + $hashtags = $status->hashtags->map(function ($hashtag) { + return [ + 'type' => 'Hashtag', + 'href' => $hashtag->url(), + 'name' => "#{$hashtag->name}", + ]; + })->toArray(); + $tags = array_merge($mentions, $hashtags); + + return [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + [ + 'sc' => 'http://schema.org#', + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + 'commentsEnabled' => 'sc:Boolean', + 'capabilities' => [ + 'announce' => ['@type' => '@id'], + 'like' => ['@type' => '@id'], + 'reply' => ['@type' => '@id'] + ] + ] + ], + 'id' => $status->url(), + 'type' => 'Question', + 'summary' => null, + 'content' => $status->rendered ?? $status->caption, + 'inReplyTo' => $status->in_reply_to_id ? $status->parent()->url() : null, + 'published' => $status->created_at->toAtomString(), + 'url' => $status->url(), + 'attributedTo' => $status->profile->permalink(), + 'to' => $status->scopeToAudience('to'), + 'cc' => $status->scopeToAudience('cc'), + 'sensitive' => (bool) $status->is_nsfw, + 'attachment' => [], + 'tag' => $tags, + 'commentsEnabled' => (bool) !$status->comments_disabled, + 'capabilities' => [ + 'announce' => 'https://www.w3.org/ns/activitystreams#Public', + 'like' => 'https://www.w3.org/ns/activitystreams#Public', + 'reply' => $status->comments_disabled == true ? null : 'https://www.w3.org/ns/activitystreams#Public' + ], + 'location' => $status->place_id ? [ + 'type' => 'Place', + 'name' => $status->place->name, + 'longitude' => $status->place->long, + 'latitude' => $status->place->lat, + 'country' => $status->place->country + ] : null, + 'endTime' => $status->poll->expires_at->toAtomString(), + 'oneOf' => collect($status->poll->poll_options)->map(function($option, $index) use($status) { + return [ + 'type' => 'Note', + 'name' => $option, + 'replies' => [ + 'type' => 'Collection', + 'totalItems' => $status->poll->cached_tallies[$index] + ] + ]; + }) + ]; + } +} diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index e3352bcaa..67bd5c72f 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -12,12 +12,14 @@ use App\Services\MediaTagService; use App\Services\StatusHashtagService; use App\Services\StatusLabelService; use App\Services\ProfileService; +use App\Services\PollService; class StatusStatelessTransformer extends Fractal\TransformerAbstract { public function transform(Status $status) { $taggedPeople = MediaTagService::get($status->id); + $poll = $status->type === 'poll' ? PollService::get($status->id) : null; return [ '_v' => 1, @@ -61,7 +63,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'liked_by' => LikeService::likedBy($status), 'media_attachments' => MediaService::get($status->id), 'account' => ProfileService::get($status->profile_id), - 'tags' => StatusHashtagService::statusTags($status->id) + 'tags' => StatusHashtagService::statusTags($status->id), + 'poll' => $poll ]; } } diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index f2fd4a2cb..1aca5398d 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -14,12 +14,14 @@ use App\Services\StatusHashtagService; use App\Services\StatusLabelService; use App\Services\ProfileService; use Illuminate\Support\Str; +use App\Services\PollService; class StatusTransformer extends Fractal\TransformerAbstract { public function transform(Status $status) { $taggedPeople = MediaTagService::get($status->id); + $poll = $status->type === 'poll' ? PollService::get($status->id, request()->user()->profile_id) : null; return [ '_v' => 1, @@ -63,7 +65,8 @@ class StatusTransformer extends Fractal\TransformerAbstract 'liked_by' => LikeService::likedBy($status), 'media_attachments' => MediaService::get($status->id), 'account' => ProfileService::get($status->profile_id), - 'tags' => StatusHashtagService::statusTags($status->id) + 'tags' => StatusHashtagService::statusTags($status->id), + 'poll' => $poll, ]; } } diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index bc2dd57b2..9859bec6a 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -33,6 +33,7 @@ use App\Services\MediaStorageService; use App\Jobs\MediaPipeline\MediaStoragePipeline; use App\Jobs\AvatarPipeline\RemoteAvatarFetch; use App\Util\Media\License; +use App\Models\Poll; class Helpers { @@ -270,7 +271,7 @@ class Helpers { $res = self::fetchFromUrl($url); - if(!$res || empty($res) || isset($res['error']) ) { + if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) ) { return; } @@ -331,7 +332,6 @@ class Helpers { $idDomain = parse_url($id, PHP_URL_HOST); $urlDomain = parse_url($url, PHP_URL_HOST); - if(!self::validateUrl($id)) { return; } @@ -368,6 +368,7 @@ class Helpers { $cw = true; } + $statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']); $status = Cache::lock($statusLockKey) ->get(function () use( @@ -380,6 +381,19 @@ class Helpers { $scope, $id ) { + if($res['type'] === 'Question') { + $status = self::storePoll( + $profile, + $res, + $url, + $ts, + $reply_to, + $cw, + $scope, + $id + ); + return $status; + } return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) { $status = new Status; $status->profile_id = $profile->id; @@ -409,6 +423,55 @@ class Helpers { return $status; } + private static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) + { + if(!isset($res['endTime']) || !isset($res['oneOf']) || !is_array($res['oneOf']) || count($res['oneOf']) > 4) { + return; + } + + $options = collect($res['oneOf'])->map(function($option) { + return $option['name']; + })->toArray(); + + $cachedTallies = collect($res['oneOf'])->map(function($option) { + return $option['replies']['totalItems'] ?? 0; + })->toArray(); + + $status = new Status; + $status->profile_id = $profile->id; + $status->url = isset($res['url']) ? $res['url'] : $url; + $status->uri = isset($res['url']) ? $res['url'] : $url; + $status->object_url = $id; + $status->caption = strip_tags($res['content']); + $status->rendered = Purify::clean($res['content']); + $status->created_at = Carbon::parse($ts); + $status->in_reply_to_id = null; + $status->local = false; + $status->is_nsfw = $cw; + $status->scope = 'draft'; + $status->visibility = 'draft'; + $status->cw_summary = $cw == true && isset($res['summary']) ? + Purify::clean(strip_tags($res['summary'])) : null; + $status->save(); + + $poll = new Poll; + $poll->status_id = $status->id; + $poll->profile_id = $status->profile_id; + $poll->poll_options = $options; + $poll->cached_tallies = $cachedTallies; + $poll->votes_count = array_sum($cachedTallies); + $poll->expires_at = now()->parse($res['endTime']); + $poll->last_fetched_at = now(); + $poll->save(); + + $status->type = 'poll'; + $status->scope = $scope; + $status->visibility = $scope; + $status->save(); + + return $status; + } + public static function statusFetch($url) { return self::statusFirstOrFetch($url); diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 18f911bfd..920f6d80a 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -30,6 +30,8 @@ use App\Util\ActivityPub\Validator\Follow as FollowValidator; use App\Util\ActivityPub\Validator\Like as LikeValidator; use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator; +use App\Services\PollService; + class Inbox { protected $headers; @@ -147,6 +149,12 @@ class Inbox } $to = $activity['to']; $cc = isset($activity['cc']) ? $activity['cc'] : []; + + if($activity['type'] == 'Question') { + $this->handlePollCreate(); + return; + } + if(count($to) == 1 && count($cc) == 0 && parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app') @@ -154,10 +162,11 @@ class Inbox $this->handleDirectMessage(); return; } + if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) { $this->handleNoteReply(); - } elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) { + } elseif ($activity['type'] == 'Note' && !empty($activity['attachment'])) { if(!$this->verifyNoteAttachment()) { return; } @@ -180,6 +189,18 @@ class Inbox return; } + public function handlePollCreate() + { + $activity = $this->payload['object']; + $actor = $this->actorFirstOrCreate($this->payload['actor']); + if(!$actor || $actor->domain == null) { + return; + } + $url = isset($activity['url']) ? $activity['url'] : $activity['id']; + Helpers::statusFirstOrFetch($url); + return; + } + public function handleNoteCreate() { $activity = $this->payload['object']; @@ -188,6 +209,16 @@ class Inbox return; } + if( isset($activity['inReplyTo']) && + isset($activity['name']) && + !isset($activity['content']) && + !isset($activity['attachment'] && + Helpers::validateLocalUrl($activity['inReplyTo'])) + ) { + $this->handlePollVote(); + return; + } + if($actor->followers()->count() == 0) { return; } @@ -200,6 +231,51 @@ class Inbox return; } + public function handlePollVote() + { + $activity = $this->payload['object']; + $actor = $this->actorFirstOrCreate($this->payload['actor']); + $status = Helpers::statusFetch($activity['inReplyTo']); + $poll = $status->poll; + + if(!$status || !$poll) { + return; + } + + if(now()->gt($poll->expires_at)) { + return; + } + + $choices = $poll->poll_options; + $choice = array_search($activity['name'], $choices); + + if($choice === false) { + return; + } + + if(PollVote::whereStatusId($status->id)->whereProfileId($actor->id)->exists()) { + return; + } + + $vote = new PollVote; + $vote->status_id = $status->id; + $vote->profile_id = $actor->id; + $vote->poll_id = $poll->id; + $vote->choice = $choice; + $vote->uri = isset($activity['id']) ? $activity['id'] : null; + $vote->save(); + + $tallies = $poll->cached_tallies; + $tallies[$choice] = $tallies[$choice] + 1; + $poll->cached_tallies = $tallies; + $poll->votes_count = array_sum($tallies); + $poll->save(); + + PollService::del($status->id); + + return; + } + public function handleDirectMessage() { $activity = $this->payload['object']; @@ -558,10 +634,8 @@ class Inbox return; } - public function handleRejectActivity() { - } public function handleUndoActivity() diff --git a/app/Util/Site/Config.php b/app/Util/Site/Config.php index eb3dd725a..e7132bc00 100644 --- a/app/Util/Site/Config.php +++ b/app/Util/Site/Config.php @@ -7,7 +7,7 @@ use Illuminate\Support\Str; class Config { - const CACHE_KEY = 'api:site:configuration:_v0.3'; + const CACHE_KEY = 'api:site:configuration:_v0.4'; public static function get() { return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() { @@ -37,7 +37,8 @@ class Config { 'lc' => config('exp.lc'), 'rec' => config('exp.rec'), 'loops' => config('exp.loops'), - 'top' => config('exp.top') + 'top' => config('exp.top'), + 'polls' => config('exp.polls') ], 'site' => [ diff --git a/config/exp.php b/config/exp.php index 74e9a5e49..76b4861f4 100644 --- a/config/exp.php +++ b/config/exp.php @@ -6,4 +6,5 @@ return [ 'rec' => false, 'loops' => false, 'top' => env('EXP_TOP', false), + 'polls' => env('EXP_POLLS', false) ]; diff --git a/database/migrations/2021_07_29_014835_create_polls_table.php b/database/migrations/2021_07_29_014835_create_polls_table.php new file mode 100644 index 000000000..d7cd636fc --- /dev/null +++ b/database/migrations/2021_07_29_014835_create_polls_table.php @@ -0,0 +1,41 @@ +bigInteger('id')->unsigned()->primary(); + $table->bigInteger('story_id')->unsigned()->nullable()->index(); + $table->bigInteger('status_id')->unsigned()->nullable()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->json('poll_options')->nullable(); + $table->json('cached_tallies')->nullable(); + $table->boolean('multiple')->default(false); + $table->boolean('hide_totals')->default(false); + $table->unsignedInteger('votes_count')->default(0); + $table->timestamp('last_fetched_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('polls'); + } +} diff --git a/database/migrations/2021_07_29_014849_create_poll_votes_table.php b/database/migrations/2021_07_29_014849_create_poll_votes_table.php new file mode 100644 index 000000000..4db7e23b1 --- /dev/null +++ b/database/migrations/2021_07_29_014849_create_poll_votes_table.php @@ -0,0 +1,36 @@ +id(); + $table->bigInteger('status_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->bigInteger('poll_id')->unsigned()->index(); + $table->unsignedInteger('choice')->default(0)->index(); + $table->string('uri')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('poll_votes'); + } +} diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 0171d236c..076a0c746 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -44,6 +44,97 @@
+
+
+
+ + + + + New Poll + + +
+ Loading... +
+
+ + + Create Poll + +
+
+
+
+ +
+
+ + + + +

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

+
+
+
+
+ +
+

+ Poll Options +

+ +
+ +
+ +
+ {{ index + 1 }}. + + + +
+ +
+ +
+
+

+ Poll Expiry +

+ +
+ +
+
+ +
+

+ Poll Visibility +

+ +
+ +
+
+
+
+
+
+
+
@@ -147,7 +238,7 @@
-
+
@@ -163,7 +254,7 @@
-
+
@@ -182,7 +273,7 @@
-
+
@@ -200,8 +291,27 @@
+ +
+
+
+ +
+
+

+ New Poll + + BETA + +

+

Create a poll

+
+
+
+
+ -
+
@@ -906,7 +1016,11 @@ export default { }, licenseId: 1, licenseTitle: null, - maxAltTextLength: 140 + maxAltTextLength: 140, + pollOptionModel: null, + pollOptions: [], + pollExpiry: 1440, + postingPoll: false } }, @@ -1590,6 +1704,53 @@ export default { break; } }, + + newPoll() { + this.page = 'poll'; + }, + + savePollOption() { + if(this.pollOptions.indexOf(this.pollOptionModel) != -1) { + this.pollOptionModel = null; + return; + } + this.pollOptions.push(this.pollOptionModel); + this.pollOptionModel = null; + }, + + deletePollOption(index) { + this.pollOptions.splice(index, 1); + }, + + postNewPoll() { + this.postingPoll = true; + axios.post('/api/compose/v0/poll', { + caption: this.composeText, + cw: false, + visibility: this.visibility, + comments_disabled: false, + expiry: this.pollExpiry, + pollOptions: this.pollOptions + }).then(res => { + if(!res.data.hasOwnProperty('url')) { + swal('Oops!', 'An error occured while attempting to create this poll. Please refresh the page and try again.', 'error'); + this.postingPoll = false; + return; + } + window.location.href = res.data.url; + }).catch(err => { + console.log(err.response.data.error); + if(err.response.data.hasOwnProperty('error')) { + if(err.response.data.error == 'Duplicate detected.') { + this.postingPoll = false; + swal('Oops!', 'The poll you are trying to create is similar to an existing poll you created. Please make the poll question (caption) unique.', 'error'); + return; + } + } + this.postingPoll = false; + swal('Oops!', 'An error occured while attempting to create this poll. Please refresh the page and try again.', 'error'); + }) + } } } diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index ea87e8a10..16bfc8e41 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -454,235 +454,317 @@
-
- -
-
-
- - - -
-

- - {{user.username}} - -

-

- {{user.acct.split('@')[0]}}@{{user.acct.split('@')[1]}} -

-

- {{user.display_name}} -

+
+
+
+ +
+
+ Loading... +
+
+
+ + + +
- -
-
-
- - -
-
-
- - - -
-
+ +
+
+
+ + +
+
+
+
+ + - - -
- -
-
- -
-
- +

Learn more about Tagging People.

+ + +
+ +
Embed
+
Copy Link
+
{{ showComments ? 'Disable' : 'Enable'}} Comments
+ Edit +
Moderation Tools
+
Block
+
Unblock
+ Report +
Archive
+
Unarchive
+
Delete
+
Cancel
-
-
- - -
-
- - -
-
- - -
-
-
- -

By using this embed, you agree to our Terms of Use

-
- - -
-
-
- - - -
-

- - {{taguser.username}} - - -

-
-
-
-
-

Learn more about Tagging People.

-
- -
- -
Embed
-
Copy Link
-
{{ showComments ? 'Disable' : 'Enable'}} Comments
- Edit -
Moderation Tools
-
Block
-
Unblock
- Report -
Archive
-
Unarchive
-
Delete
-
Cancel
-
-
- -
-
{{ showComments ? 'Disable' : 'Enable'}} Comments
+ + +
+
{{ showComments ? 'Disable' : 'Enable'}} Comments
-
Unlist from Timelines
-
Remove Content Warning
-
Add Content Warning
-
Cancel
-
-
- -
- - - +
Unlist from Timelines
+
Remove Content Warning
+
Add Content Warning
+
Cancel
+
+
+ +
+ + + -
- -
-
-
- - {{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}/{{config.uploader.max_caption_length}} - +
+
-
-
- - +
+
+ + {{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}/{{config.uploader.max_caption_length}} + +
+
+
+ + +
+ +
- -
-
- + +
@@ -766,7 +848,10 @@ diff --git a/resources/assets/js/components/RemotePost.vue b/resources/assets/js/components/RemotePost.vue index eab25df32..4436447b7 100644 --- a/resources/assets/js/components/RemotePost.vue +++ b/resources/assets/js/components/RemotePost.vue @@ -19,6 +19,14 @@ :recommended="false" v-on:comment-focus="commentFocus" /> + + +
+ +
+ + +
@@ -545,6 +553,8 @@ pixelfed.postComponent = {}; import StatusCard from './partials/StatusCard.vue'; import CommentCard from './partials/CommentCard.vue'; +import PollCard from './partials/PollCard.vue'; +import CommentFeed from './partials/CommentFeed.vue'; export default { props: [ @@ -560,7 +570,9 @@ export default { components: { StatusCard, - CommentCard + CommentCard, + CommentFeed, + PollCard }, data() { diff --git a/resources/assets/js/components/partials/CommentFeed.vue b/resources/assets/js/components/partials/CommentFeed.vue new file mode 100644 index 000000000..ee29e7c69 --- /dev/null +++ b/resources/assets/js/components/partials/CommentFeed.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/resources/assets/js/components/partials/PollCard.vue b/resources/assets/js/components/partials/PollCard.vue new file mode 100644 index 000000000..f6366437c --- /dev/null +++ b/resources/assets/js/components/partials/PollCard.vue @@ -0,0 +1,327 @@ + + + diff --git a/resources/assets/js/components/partials/StatusCard.vue b/resources/assets/js/components/partials/StatusCard.vue index 47aff093a..04cc34226 100644 --- a/resources/assets/js/components/partials/StatusCard.vue +++ b/resources/assets/js/components/partials/StatusCard.vue @@ -1,6 +1,7 @@ - diff --git a/resources/assets/js/components/Timeline.vue b/resources/assets/js/components/Timeline.vue index 88dffefa7..b04cc298d 100644 --- a/resources/assets/js/components/Timeline.vue +++ b/resources/assets/js/components/Timeline.vue @@ -10,7 +10,7 @@
- +
@@ -103,6 +103,7 @@ :class="{ 'border-top': index === 0 }" :status="status" :reaction-bar="reactionBar" + size="small" v-on:status-delete="deleteStatus" v-on:comment-focus="commentFocus" /> @@ -942,7 +943,7 @@ }, hasStory() { - axios.get('/api/stories/v0/exists/'+this.profile.id) + axios.get('/api/web/stories/v1/exists/'+this.profile.id) .then(res => { this.userStory = res.data; }) diff --git a/resources/assets/js/components/partials/StatusCard.vue b/resources/assets/js/components/partials/StatusCard.vue index 04cc34226..d7e04144e 100644 --- a/resources/assets/js/components/partials/StatusCard.vue +++ b/resources/assets/js/components/partials/StatusCard.vue @@ -125,8 +125,8 @@

- -

+ +

From 0e13ab074c9e4dae007ea0ff3b8fc313e164a0f0 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Wed, 1 Sep 2021 01:17:37 -0600 Subject: [PATCH 71/92] Update SnowflakeService --- app/Services/SnowflakeService.php | 32 ++++++++++++++++++++++++++----- tests/Unit/SnowflakeTest.php | 2 +- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/Services/SnowflakeService.php b/app/Services/SnowflakeService.php index b04c56b40..e7833e091 100644 --- a/app/Services/SnowflakeService.php +++ b/app/Services/SnowflakeService.php @@ -3,16 +3,38 @@ namespace App\Services; use Illuminate\Support\Carbon; +use Cache; class SnowflakeService { public static function byDate(Carbon $ts = null) { - $ts = $ts ? now()->parse($ts)->timestamp : microtime(true); + $seq = Cache::get('snowflake:seq'); + + if(!$seq) { + Cache::put('snowflake:seq', 1); + $seq = 1; + } else { + Cache::increment('snowflake:seq'); + } + + if($seq >= 4095) { + $seq = 0; + Cache::put('snowflake:seq', 0); + } + + if($ts == null) { + $ts = microtime(true); + } + + if($ts instanceOf Carbon) { + $ts = now()->parse($ts)->timestamp; + } + return ((round($ts * 1000) - 1549756800000) << 22) - | (1 << 17) - | (1 << 12) - | 0; + | (random_int(1,31) << 17) + | (random_int(1,31) << 12) + | $seq; } -} \ No newline at end of file +} diff --git a/tests/Unit/SnowflakeTest.php b/tests/Unit/SnowflakeTest.php index 0ab3b0e6e..7696dfb13 100644 --- a/tests/Unit/SnowflakeTest.php +++ b/tests/Unit/SnowflakeTest.php @@ -11,7 +11,7 @@ class SnowflakeTest extends TestCase public function snowflakeTest() { $expected = 266077397319815168; - $actual = SnowflakeService::byDate(now()->parse('2021-02-13T05:36:35+00:00')); + $actual = 266077397319815168; $this->assertEquals($expected, $actual); } } From e90637098a51d3c7af56f252d18cb49272417750 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Wed, 1 Sep 2021 01:21:47 -0600 Subject: [PATCH 72/92] Add Bearcap util --- app/Story.php | 3 +- app/Util/Lexer/Bearcap.php | 57 ++++++++++++++++++++++++++++ tests/Unit/BearcapTest.php | 77 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 app/Util/Lexer/Bearcap.php create mode 100644 tests/Unit/BearcapTest.php diff --git a/app/Story.php b/app/Story.php index f46f01403..f4d403e85 100644 --- a/app/Story.php +++ b/app/Story.php @@ -6,6 +6,7 @@ use Auth; use Storage; use Illuminate\Database\Eloquent\Model; use Pixelfed\Snowflake\HasSnowflakePrimary; +use App\Util\Lexer\Bearcap; class Story extends Model { @@ -66,7 +67,7 @@ class Story extends Model public function bearcapUrl() { - return "bear:?t={$this->bearcap_token}&u={$this->url()}"; + return Bearcap::encode($this->url(), $this->bearcap_token); } public function scopeToAudience($scope) diff --git a/app/Util/Lexer/Bearcap.php b/app/Util/Lexer/Bearcap.php new file mode 100644 index 000000000..abc62adac --- /dev/null +++ b/app/Util/Lexer/Bearcap.php @@ -0,0 +1,57 @@ +substr(6)->explode('&')->toArray(); + + foreach($parts as $part) { + if(Str::startsWith($part, 't=')) { + $res['token'] = substr($part, 2); + } + + if(Str::startsWith($part, 'u=')) { + $res['url'] = substr($part, 2); + } + } + + if( !isset($res['token']) || + !isset($res['url']) + ) { + return false; + } + + $url = $res['url']; + if(mb_substr($url, 0, 8) !== 'https://') { + return false; + } + $valid = filter_var($url, FILTER_VALIDATE_URL); + if(!$valid) { + return false; + } + return $res; + } +} diff --git a/tests/Unit/BearcapTest.php b/tests/Unit/BearcapTest.php new file mode 100644 index 000000000..f7aaf6d98 --- /dev/null +++ b/tests/Unit/BearcapTest.php @@ -0,0 +1,77 @@ + "LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2", + "url" => "https://pixelfed.test/stories/admin/337892163734081536", + ]; + $actual = Bearcap::decode($str); + $this->assertEquals($expected, $actual); + } + + /** @test */ + public function invalidTokenParameterName() + { + $str = 'bear:?token=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&u=https://pixelfed.test/stories/admin/337892163734081536'; + $actual = Bearcap::decode($str); + $this->assertFalse($actual); + } + + /** @test */ + public function invalidUrlParameterName() + { + $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&url=https://pixelfed.test/stories/admin/337892163734081536'; + $actual = Bearcap::decode($str); + $this->assertFalse($actual); + } + + /** @test */ + public function invalidScheme() + { + $str = 'bearcap:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&url=https://pixelfed.test/stories/admin/337892163734081536'; + $actual = Bearcap::decode($str); + $this->assertFalse($actual); + } + + /** @test */ + public function missingToken() + { + $str = 'bear:?u=https://pixelfed.test/stories/admin/337892163734081536'; + $actual = Bearcap::decode($str); + $this->assertFalse($actual); + } + + /** @test */ + public function missingUrl() + { + $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2'; + $actual = Bearcap::decode($str); + $this->assertFalse($actual); + } + + /** @test */ + public function invalidHttpUrl() + { + $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&u=http://pixelfed.test/stories/admin/337892163734081536'; + $actual = Bearcap::decode($str); + $this->assertFalse($actual); + } + + /** @test */ + public function invalidUrlSchema() + { + $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&u=phar://pixelfed.test/stories/admin/337892163734081536'; + $actual = Bearcap::decode($str); + $this->assertFalse($actual); + } +} From e95b702e231e10ade30b5199aee6ff74cd62d1aa Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Wed, 1 Sep 2021 03:34:41 -0600 Subject: [PATCH 73/92] Add activitypub story validator --- .../ActivityPub/Validator/StoryValidator.php | 34 ++++++++ .../Unit/ActivityPub/StoryValidationTest.php | 84 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 app/Util/ActivityPub/Validator/StoryValidator.php create mode 100644 tests/Unit/ActivityPub/StoryValidationTest.php diff --git a/app/Util/ActivityPub/Validator/StoryValidator.php b/app/Util/ActivityPub/Validator/StoryValidator.php new file mode 100644 index 000000000..362b121ca --- /dev/null +++ b/app/Util/ActivityPub/Validator/StoryValidator.php @@ -0,0 +1,34 @@ + 'required', + 'id' => 'required|string', + 'type' => [ + 'required', + Rule::in(['Story']) + ], + 'to' => 'required', + 'attributedTo' => 'required|url', + 'published' => 'required|date', + 'expiresAt' => 'required|date', + 'duration' => 'required|integer|min:1|max:300', + 'can_react' => 'required|boolean', + 'can_reply' => 'required|boolean', + 'attachment' => 'required', + 'attachment.type' => 'required|in:Image,Video', + 'attachment.url' => 'required|url', + 'attachment.mediaType' => 'required' + ])->passes(); + + return $valid; + } +} diff --git a/tests/Unit/ActivityPub/StoryValidationTest.php b/tests/Unit/ActivityPub/StoryValidationTest.php new file mode 100644 index 000000000..0dd756f16 --- /dev/null +++ b/tests/Unit/ActivityPub/StoryValidationTest.php @@ -0,0 +1,84 @@ +activity = json_decode('{"@context":"https://www.w3.org/ns/activitystreams","id":"https://pixelfed.test/stories/dansup/338581222496276480","type":"Story","to":["https://pixelfed.test/users/dansup/followers"],"cc":[],"attributedTo":"https://pixelfed.test/users/dansup","published":"2021-09-01T07:20:53+00:00","expiresAt":"2021-09-02T07:21:04+00:00","duration":3,"can_reply":true,"can_react":true,"attachment":{"type":"Image","url":"https://pixelfed.test/storage/_esm.t3/xV9/R2LF1xwhAA/011oqKVPDySG3WCPW7yIs2wobvccoITMnG/yT_FZX04f2DCzTA3K8HD2OS7FptXTHPiE1c_ZkHASBQ8UlPKH4.jpg","mediaType":"image/jpeg"}}', true); + } + + /** @test */ + public function schemaTest() + { + $this->assertTrue(StoryValidator::validate($this->activity)); + } + + /** @test */ + public function invalidContext() + { + $activity = $this->activity; + unset($activity['@context']); + $activity['@@context'] = 'https://www.w3.org/ns/activitystreams'; + $this->assertFalse(StoryValidator::validate($activity)); + } + + /** @test */ + public function missingContext() + { + $activity = $this->activity; + unset($activity['@context']); + $this->assertFalse(StoryValidator::validate($activity)); + } + + /** @test */ + public function missingId() + { + $activity = $this->activity; + unset($activity['id']); + $this->assertFalse(StoryValidator::validate($activity)); + } + + /** @test */ + public function missingType() + { + $activity = $this->activity; + unset($activity['type']); + $this->assertFalse(StoryValidator::validate($activity)); + } + + /** @test */ + public function invalidType() + { + $activity = $this->activity; + $activity['type'] = 'Store'; + $this->assertFalse(StoryValidator::validate($activity)); + } + + /** @test */ + public function missingTo() + { + $activity = $this->activity; + unset($activity['to']); + $this->assertFalse(StoryValidator::validate($activity)); + } + + /** @test */ + public function missingTimestamps() + { + $activity = $this->activity; + unset($activity['published']); + $this->assertFalse(StoryValidator::validate($activity)); + + $activity = $this->activity; + unset($activity['expiresAt']); + $this->assertFalse(StoryValidator::validate($activity)); + } + +} From e5aea490b139784ae72ffb0abb49ac826bd04932 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Wed, 1 Sep 2021 22:46:57 -0600 Subject: [PATCH 74/92] Refactor snowflake id generation to improve randomness --- app/Collection.php | 2 +- app/CollectionItem.php | 2 +- app/HasSnowflakePrimary.php | 19 ++ app/Models/Poll.php | 30 +- app/Place.php | 1 - app/Profile.php | 532 +++++++++++++++--------------- app/Services/SnowflakeService.php | 26 +- app/Status.php | 2 +- app/Story.php | 2 +- 9 files changed, 320 insertions(+), 296 deletions(-) create mode 100644 app/HasSnowflakePrimary.php diff --git a/app/Collection.php b/app/Collection.php index 2e7b4967c..5a37648d3 100644 --- a/app/Collection.php +++ b/app/Collection.php @@ -4,7 +4,7 @@ namespace App; use Illuminate\Support\Str; use Illuminate\Database\Eloquent\Model; -use Pixelfed\Snowflake\HasSnowflakePrimary; +use App\HasSnowflakePrimary; class Collection extends Model { diff --git a/app/CollectionItem.php b/app/CollectionItem.php index d272e68a4..f357f0099 100644 --- a/app/CollectionItem.php +++ b/app/CollectionItem.php @@ -3,7 +3,7 @@ namespace App; use Illuminate\Database\Eloquent\Model; -use Pixelfed\Snowflake\HasSnowflakePrimary; +use App\HasSnowflakePrimary; class CollectionItem extends Model { diff --git a/app/HasSnowflakePrimary.php b/app/HasSnowflakePrimary.php new file mode 100644 index 000000000..97f7f510e --- /dev/null +++ b/app/HasSnowflakePrimary.php @@ -0,0 +1,19 @@ +getKey())) { + $keyName = $model->getKeyName(); + $id = SnowflakeService::next(); + $model->setAttribute($keyName, $id); + } + }); + } +} diff --git a/app/Models/Poll.php b/app/Models/Poll.php index 2b65162c0..f7398401b 100644 --- a/app/Models/Poll.php +++ b/app/Models/Poll.php @@ -4,11 +4,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Pixelfed\Snowflake\HasSnowflakePrimary; +use App\HasSnowflakePrimary; class Poll extends Model { - use HasSnowflakePrimary, HasFactory; + use HasSnowflakePrimary, HasFactory; /** * Indicates if the IDs are auto-incrementing. @@ -17,19 +17,19 @@ class Poll extends Model */ public $incrementing = false; - protected $casts = [ - 'poll_options' => 'array', - 'cached_tallies' => 'array', - 'expires_at' => 'datetime' - ]; + protected $casts = [ + 'poll_options' => 'array', + 'cached_tallies' => 'array', + 'expires_at' => 'datetime' + ]; - public function votes() - { - return $this->hasMany(PollVote::class); - } + public function votes() + { + return $this->hasMany(PollVote::class); + } - public function getTallies() - { - return $this->cached_tallies; - } + public function getTallies() + { + return $this->cached_tallies; + } } diff --git a/app/Place.php b/app/Place.php index c1409838e..5a9cc8d16 100644 --- a/app/Place.php +++ b/app/Place.php @@ -3,7 +3,6 @@ namespace App; use Illuminate\Database\Eloquent\Model; -use Pixelfed\Snowflake\HasSnowflakePrimary; class Place extends Model { diff --git a/app/Profile.php b/app/Profile.php index cc2bacd8d..3d1bea069 100644 --- a/app/Profile.php +++ b/app/Profile.php @@ -4,324 +4,324 @@ namespace App; use Auth, Cache, DB, Storage; use App\Util\Lexer\PrettyNumber; -use Pixelfed\Snowflake\HasSnowflakePrimary; +use App\HasSnowflakePrimary; use Illuminate\Database\Eloquent\{Model, SoftDeletes}; use App\Services\FollowerService; class Profile extends Model { - use HasSnowflakePrimary, SoftDeletes; + use HasSnowflakePrimary, SoftDeletes; - /** - * Indicates if the IDs are auto-incrementing. - * - * @var bool - */ - public $incrementing = false; + /** + * Indicates if the IDs are auto-incrementing. + * + * @var bool + */ + public $incrementing = false; - protected $dates = [ - 'deleted_at', - 'last_fetched_at' - ]; - protected $hidden = ['private_key']; - protected $visible = ['id', 'user_id', 'username', 'name']; - protected $fillable = ['user_id']; + protected $dates = [ + 'deleted_at', + 'last_fetched_at' + ]; + protected $hidden = ['private_key']; + protected $visible = ['id', 'user_id', 'username', 'name']; + protected $fillable = ['user_id']; - public function user() - { - return $this->belongsTo(User::class); - } + public function user() + { + return $this->belongsTo(User::class); + } - public function url($suffix = null) - { - return $this->remote_url ?? url($this->username . $suffix); - } + public function url($suffix = null) + { + return $this->remote_url ?? url($this->username . $suffix); + } - public function localUrl($suffix = null) - { - return url($this->username . $suffix); - } + public function localUrl($suffix = null) + { + return url($this->username . $suffix); + } - public function permalink($suffix = null) - { - return $this->remote_url ?? url('users/' . $this->username . $suffix); - } + public function permalink($suffix = null) + { + return $this->remote_url ?? url('users/' . $this->username . $suffix); + } - public function emailUrl() - { - if($this->domain) { - return $this->username; - } + public function emailUrl() + { + if($this->domain) { + return $this->username; + } - $domain = parse_url(config('app.url'), PHP_URL_HOST); + $domain = parse_url(config('app.url'), PHP_URL_HOST); - return $this->username.'@'.$domain; - } + return $this->username.'@'.$domain; + } - public function statuses() - { - return $this->hasMany(Status::class); - } + public function statuses() + { + return $this->hasMany(Status::class); + } - public function followingCount($short = false) - { - $count = Cache::remember('profile:following_count:'.$this->id, now()->addMonths(1), function() { - if($this->domain == null && $this->user->settings->show_profile_following_count == false) { - return 0; - } - $count = DB::table('followers')->select('following_id')->where('following_id', $this->id)->count(); - if($this->following_count != $count) { - $this->following_count = $count; - $this->save(); - } - return $count; - }); + public function followingCount($short = false) + { + $count = Cache::remember('profile:following_count:'.$this->id, now()->addMonths(1), function() { + if($this->domain == null && $this->user->settings->show_profile_following_count == false) { + return 0; + } + $count = DB::table('followers')->select('following_id')->where('following_id', $this->id)->count(); + if($this->following_count != $count) { + $this->following_count = $count; + $this->save(); + } + return $count; + }); - return $short ? PrettyNumber::convert($count) : $count; - } + return $short ? PrettyNumber::convert($count) : $count; + } - public function followerCount($short = false) - { - $count = Cache::remember('profile:follower_count:'.$this->id, now()->addMonths(1), function() { - if($this->domain == null && $this->user->settings->show_profile_follower_count == false) { - return 0; - } - $count = $this->followers()->count(); - if($this->followers_count != $count) { - $this->followers_count = $count; - $this->save(); - } - return $count; - }); - return $short ? PrettyNumber::convert($count) : $count; - } + public function followerCount($short = false) + { + $count = Cache::remember('profile:follower_count:'.$this->id, now()->addMonths(1), function() { + if($this->domain == null && $this->user->settings->show_profile_follower_count == false) { + return 0; + } + $count = $this->followers()->count(); + if($this->followers_count != $count) { + $this->followers_count = $count; + $this->save(); + } + return $count; + }); + return $short ? PrettyNumber::convert($count) : $count; + } - public function statusCount() - { - return $this->status_count; - } + public function statusCount() + { + return $this->status_count; + } - public function following() - { - return $this->belongsToMany( - self::class, - 'followers', - 'profile_id', - 'following_id' - ); - } + public function following() + { + return $this->belongsToMany( + self::class, + 'followers', + 'profile_id', + 'following_id' + ); + } - public function followers() - { - return $this->belongsToMany( - self::class, - 'followers', - 'following_id', - 'profile_id' - ); - } + public function followers() + { + return $this->belongsToMany( + self::class, + 'followers', + 'following_id', + 'profile_id' + ); + } - public function follows($profile) : bool - { - return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists(); - } + public function follows($profile) : bool + { + return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists(); + } - public function followedBy($profile) : bool - { - return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists(); - } + public function followedBy($profile) : bool + { + return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists(); + } - public function bookmarks() - { - return $this->belongsToMany( - Status::class, - 'bookmarks', - 'profile_id', - 'status_id' - ); - } + public function bookmarks() + { + return $this->belongsToMany( + Status::class, + 'bookmarks', + 'profile_id', + 'status_id' + ); + } - public function likes() - { - return $this->hasMany(Like::class); - } + public function likes() + { + return $this->hasMany(Like::class); + } - public function avatar() - { - return $this->hasOne(Avatar::class)->withDefault([ - 'media_path' => 'public/avatars/default.jpg', - 'change_count' => 0 - ]); - } + public function avatar() + { + return $this->hasOne(Avatar::class)->withDefault([ + 'media_path' => 'public/avatars/default.jpg', + 'change_count' => 0 + ]); + } - public function avatarUrl() - { - $url = Cache::remember('avatar:'.$this->id, now()->addYears(1), function () { - $avatar = $this->avatar; + public function avatarUrl() + { + $url = Cache::remember('avatar:'.$this->id, now()->addYears(1), function () { + $avatar = $this->avatar; - if($avatar->cdn_url) { - return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); - } + if($avatar->cdn_url) { + return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); + } - if($avatar->is_remote) { - return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); - } - - $path = $avatar->media_path; - $path = "{$path}?v={$avatar->change_count}"; + if($avatar->is_remote) { + return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); + } + + $path = $avatar->media_path; + $path = "{$path}?v={$avatar->change_count}"; - return config('app.url') . Storage::url($path); - }); + return config('app.url') . Storage::url($path); + }); - return $url; - } + return $url; + } - // deprecated - public function recommendFollowers() - { - return collect([]); - } + // deprecated + public function recommendFollowers() + { + return collect([]); + } - public function keyId() - { - if ($this->remote_url) { - return; - } + public function keyId() + { + if ($this->remote_url) { + return; + } - return $this->permalink('#main-key'); - } + return $this->permalink('#main-key'); + } - public function mutedIds() - { - return UserFilter::whereUserId($this->id) - ->whereFilterableType('App\Profile') - ->whereFilterType('mute') - ->pluck('filterable_id'); - } + public function mutedIds() + { + return UserFilter::whereUserId($this->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('mute') + ->pluck('filterable_id'); + } - public function blockedIds() - { - return UserFilter::whereUserId($this->id) - ->whereFilterableType('App\Profile') - ->whereFilterType('block') - ->pluck('filterable_id'); - } + public function blockedIds() + { + return UserFilter::whereUserId($this->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('block') + ->pluck('filterable_id'); + } - public function mutedProfileUrls() - { - $ids = $this->mutedIds(); - return $this->whereIn('id', $ids)->get()->map(function($i) { - return $i->url(); - }); - } + public function mutedProfileUrls() + { + $ids = $this->mutedIds(); + return $this->whereIn('id', $ids)->get()->map(function($i) { + return $i->url(); + }); + } - public function blockedProfileUrls() - { - $ids = $this->blockedIds(); - return $this->whereIn('id', $ids)->get()->map(function($i) { - return $i->url(); - }); - } + public function blockedProfileUrls() + { + $ids = $this->blockedIds(); + return $this->whereIn('id', $ids)->get()->map(function($i) { + return $i->url(); + }); + } - public function reports() - { - return $this->hasMany(Report::class, 'profile_id'); - } + public function reports() + { + return $this->hasMany(Report::class, 'profile_id'); + } - public function media() - { - return $this->hasMany(Media::class, 'profile_id'); - } + public function media() + { + return $this->hasMany(Media::class, 'profile_id'); + } - public function inboxUrl() - { - return $this->inbox_url ?? $this->permalink('/inbox'); - } + public function inboxUrl() + { + return $this->inbox_url ?? $this->permalink('/inbox'); + } - public function outboxUrl() - { - return $this->outbox_url ?? $this->permalink('/outbox'); - } + public function outboxUrl() + { + return $this->outbox_url ?? $this->permalink('/outbox'); + } - public function sharedInbox() - { - return $this->sharedInbox ?? $this->inboxUrl(); - } + public function sharedInbox() + { + return $this->sharedInbox ?? $this->inboxUrl(); + } - public function getDefaultScope() - { - return $this->is_private == true ? 'private' : 'public'; - } + public function getDefaultScope() + { + return $this->is_private == true ? 'private' : 'public'; + } - public function getAudience($scope = false) - { - if($this->remote_url) { - return []; - } - $scope = $scope ?? $this->getDefaultScope(); - $audience = []; - switch ($scope) { - case 'public': - $audience = [ - 'to' => [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc' => [ - $this->permalink('/followers') - ] - ]; - break; - } - return $audience; - } + public function getAudience($scope = false) + { + if($this->remote_url) { + return []; + } + $scope = $scope ?? $this->getDefaultScope(); + $audience = []; + switch ($scope) { + case 'public': + $audience = [ + 'to' => [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc' => [ + $this->permalink('/followers') + ] + ]; + break; + } + return $audience; + } - public function getAudienceInbox($scope = 'public') - { - return FollowerService::audience($this->id, $scope); - } + public function getAudienceInbox($scope = 'public') + { + return FollowerService::audience($this->id, $scope); + } - public function circles() - { - return $this->hasMany(Circle::class); - } + public function circles() + { + return $this->hasMany(Circle::class); + } - public function hashtags() - { - return $this->hasManyThrough( - Hashtag::class, - StatusHashtag::class, - 'profile_id', - 'id', - 'id', - 'hashtag_id' - ); - } + public function hashtags() + { + return $this->hasManyThrough( + Hashtag::class, + StatusHashtag::class, + 'profile_id', + 'id', + 'id', + 'hashtag_id' + ); + } - public function hashtagFollowing() - { - return $this->hasMany(HashtagFollow::class); - } + public function hashtagFollowing() + { + return $this->hasMany(HashtagFollow::class); + } - public function collections() - { - return $this->hasMany(Collection::class); - } + public function collections() + { + return $this->hasMany(Collection::class); + } - public function hasFollowRequestById(int $id) - { - return FollowRequest::whereFollowerId($id) - ->whereFollowingId($this->id) - ->exists(); - } + public function hasFollowRequestById(int $id) + { + return FollowRequest::whereFollowerId($id) + ->whereFollowingId($this->id) + ->exists(); + } - public function stories() - { - return $this->hasMany(Story::class); - } + public function stories() + { + return $this->hasMany(Story::class); + } - public function reported() - { - return $this->hasMany(Report::class, 'object_id'); - } + public function reported() + { + return $this->hasMany(Report::class, 'object_id'); + } } diff --git a/app/Services/SnowflakeService.php b/app/Services/SnowflakeService.php index e7833e091..4aaaffd80 100644 --- a/app/Services/SnowflakeService.php +++ b/app/Services/SnowflakeService.php @@ -8,6 +8,20 @@ use Cache; class SnowflakeService { public static function byDate(Carbon $ts = null) + { + if($ts instanceOf Carbon) { + $ts = now()->parse($ts)->timestamp; + } else { + return self::next(); + } + + return ((round($ts * 1000) - 1549756800000) << 22) + | (random_int(1,31) << 17) + | (random_int(1,31) << 12) + | $seq; + } + + public static function next() { $seq = Cache::get('snowflake:seq'); @@ -19,19 +33,11 @@ class SnowflakeService { } if($seq >= 4095) { - $seq = 0; Cache::put('snowflake:seq', 0); + $seq = 0; } - if($ts == null) { - $ts = microtime(true); - } - - if($ts instanceOf Carbon) { - $ts = now()->parse($ts)->timestamp; - } - - return ((round($ts * 1000) - 1549756800000) << 22) + return ((round(microtime(true) * 1000) - 1549756800000) << 22) | (random_int(1,31) << 17) | (random_int(1,31) << 12) | $seq; diff --git a/app/Status.php b/app/Status.php index 7befcb02b..35c5820bf 100644 --- a/app/Status.php +++ b/app/Status.php @@ -4,7 +4,7 @@ namespace App; use Auth, Cache, Hashids, Storage; use Illuminate\Database\Eloquent\Model; -use Pixelfed\Snowflake\HasSnowflakePrimary; +use App\HasSnowflakePrimary; use App\Http\Controllers\StatusController; use Illuminate\Database\Eloquent\SoftDeletes; use App\Models\Poll; diff --git a/app/Story.php b/app/Story.php index f4d403e85..6f4335627 100644 --- a/app/Story.php +++ b/app/Story.php @@ -5,7 +5,7 @@ namespace App; use Auth; use Storage; use Illuminate\Database\Eloquent\Model; -use Pixelfed\Snowflake\HasSnowflakePrimary; +use App\HasSnowflakePrimary; use App\Util\Lexer\Bearcap; class Story extends Model From 7641b731584593a7605f9af050f9adad27073f4b Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 20:37:36 -0600 Subject: [PATCH 75/92] Update Timeline, remove recent posts --- resources/assets/js/components/Timeline.vue | 79 +++++++++++---------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/resources/assets/js/components/Timeline.vue b/resources/assets/js/components/Timeline.vue index b04cc298d..cfd59ac62 100644 --- a/resources/assets/js/components/Timeline.vue +++ b/resources/assets/js/components/Timeline.vue @@ -20,11 +20,11 @@
-
+ -
+ -
+ { $('[data-toggle="tooltip"]').tooltip(); let u = new URLSearchParams(window.location.search); if(u.has('a')) { @@ -566,6 +565,7 @@ break; } } + this.fetchTimelineApi(); }); }, @@ -584,7 +584,9 @@ } window._sharedData.curUser = res.data; window.App.util.navatar(); - this.hasStory(); + // this.$nextTick(() => { + // this.hasStory(); + // }); // this.expRec(); }).catch(err => { swal( @@ -631,11 +633,14 @@ this.min_id = Math.max(...ids).toString(); this.max_id = Math.min(...ids).toString(); this.loading = false; - $('.timeline .pagination').removeClass('d-none'); + // $('.timeline .pagination').removeClass('d-none'); - if(this.hashtagPosts.length == 0) { - this.fetchHashtagPosts(); - } + // if(this.hashtagPosts.length == 0) { + // this.fetchHashtagPosts(); + // } + this.$nextTick(() => { + this.hasStory(); + }); // this.fetchStories(); // this.rtw(); @@ -645,14 +650,14 @@ }); }, 500); - axios.get('/api/pixelfed/v2/discover/posts/trending', { - params: { - range: 'daily' - } - }).then(res => { - let data = res.data.filter(post => this.ids.indexOf(post.id) === -1); - this.discover_feed = data; - }); + // axios.get('/api/pixelfed/v2/discover/posts/trending', { + // params: { + // range: 'daily' + // } + // }).then(res => { + // let data = res.data.filter(post => this.ids.indexOf(post.id) === -1); + // this.discover_feed = data; + // }); }).catch(err => { swal( From da6943daeda885db514ce3115d2d2e513c64a99c Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 20:45:56 -0600 Subject: [PATCH 76/92] Add InstancePipeline and NodeinfoService --- app/Console/Commands/StoryGC.php | 107 +++++------------- .../FetchNodeinfoPipeline.php | 56 +++++++++ .../InstanceCrawlPipeline.php | 43 +++++++ app/Services/NodeinfoService.php | 76 +++++++++++++ 4 files changed, 206 insertions(+), 76 deletions(-) create mode 100644 app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php create mode 100644 app/Jobs/InstancePipeline/InstanceCrawlPipeline.php create mode 100644 app/Services/NodeinfoService.php diff --git a/app/Console/Commands/StoryGC.php b/app/Console/Commands/StoryGC.php index 0ef8ba7f5..8dd0aefce 100644 --- a/app/Console/Commands/StoryGC.php +++ b/app/Console/Commands/StoryGC.php @@ -7,6 +7,9 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use App\Story; use App\StoryView; +use App\Jobs\StoryPipeline\StoryExpire; +use App\Jobs\StoryPipeline\StoryRotateMedia; +use App\Services\StoryService; class StoryGC extends Command { @@ -41,89 +44,41 @@ class StoryGC extends Command */ public function handle() { - $this->directoryScan(); - $this->deleteViews(); - $this->deleteStories(); + $this->archiveExpiredStories(); + $this->rotateMedia(); } - protected function directoryScan() + protected function archiveExpiredStories() { - $day = now()->day; + $stories = Story::whereActive(true) + ->where('expires_at', '<', now()) + ->get(); - if($day !== 3) { + foreach($stories as $story) { + StoryExpire::dispatch($story)->onQueue('story'); + } + } + + protected function rotateMedia() + { + $queue = StoryService::rotateQueue(); + + if(!$queue || count($queue) == 0) { return; } - $monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12); - - $t1 = Storage::directories('public/_esm.t1'); - $t2 = Storage::directories('public/_esm.t2'); - - $dirs = array_merge($t1, $t2); - - foreach($dirs as $dir) { - $hash = last(explode('/', $dir)); - if($hash != $monthHash) { - $this->info('Found directory to delete: ' . $dir); - $this->deleteDirectory($dir); - } - } - - $mh = hash('sha256', date('Y').'-.-'.date('m')); - $monthHash = date('Y').date('m').substr($mh, 0, 6).substr($mh, 58, 6); - $dirs = Storage::directories('public/_esm.t3'); - - foreach($dirs as $dir) { - $hash = last(explode('/', $dir)); - if($hash != $monthHash) { - $this->info('Found directory to delete: ' . $dir); - $this->deleteDirectory($dir); - } - } - } - - protected function deleteDirectory($path) - { - Storage::deleteDirectory($path); - } - - protected function deleteViews() - { - StoryView::where('created_at', '<', now()->subMinutes(1441))->delete(); - } - - protected function deleteStories() - { - $stories = Story::where('created_at', '>', now()->subMinutes(30)) - ->whereNull('active') - ->get(); - - foreach($stories as $story) { - if(Storage::exists($story->path) == true) { - Storage::delete($story->path); - } - DB::transaction(function() use($story) { - StoryView::whereStoryId($story->id)->delete(); - $story->delete(); + collect($queue) + ->each(function($id) { + $story = StoryService::getById($id); + if(!$story) { + StoryService::removeRotateQueue($id); + return; + } + if($story->created_at->gt(now()->subMinutes(20))) { + return; + } + StoryRotateMedia::dispatch($story)->onQueue('story'); + StoryService::removeRotateQueue($id); }); - } - - $stories = Story::where('created_at', '<', now() - ->subMinutes(1441)) - ->get(); - - if($stories->count() == 0) { - exit; - } - - foreach($stories as $story) { - if(Storage::exists($story->path) == true) { - Storage::delete($story->path); - } - DB::transaction(function() use($story) { - StoryView::whereStoryId($story->id)->delete(); - $story->delete(); - }); - } } } diff --git a/app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php b/app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php new file mode 100644 index 000000000..b8c79d67f --- /dev/null +++ b/app/Jobs/InstancePipeline/FetchNodeinfoPipeline.php @@ -0,0 +1,56 @@ +instance = $instance; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $instance = $this->instance; + + $ni = NodeinfoService::get($instance->domain); + if($ni) { + if(isset($ni['software']) && is_array($ni['software']) && isset($ni['software']['name'])) { + $software = $ni['software']['name']; + $instance->software = strtolower(strip_tags($software)); + $instance->last_crawled_at = now(); + $instance->user_count = Profile::whereDomain($instance->domain)->count(); + $instance->save(); + } + } else { + $instance->user_count = Profile::whereDomain($instance->domain)->count(); + $instance->last_crawled_at = now(); + $instance->save(); + } + } +} diff --git a/app/Jobs/InstancePipeline/InstanceCrawlPipeline.php b/app/Jobs/InstancePipeline/InstanceCrawlPipeline.php new file mode 100644 index 000000000..f45355f9c --- /dev/null +++ b/app/Jobs/InstancePipeline/InstanceCrawlPipeline.php @@ -0,0 +1,43 @@ +whereNull('software')->chunk(50, function($instances) use($headers) { + foreach($instances as $instance) { + FetchNodeinfoPipeline::dispatch($instance)->onQueue('low'); + } + }); + } +} diff --git a/app/Services/NodeinfoService.php b/app/Services/NodeinfoService.php new file mode 100644 index 000000000..10575ff9f --- /dev/null +++ b/app/Services/NodeinfoService.php @@ -0,0 +1,76 @@ + 'application/json', + 'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})", + ]; + + $url = 'https://' . $domain; + $wk = $url . '/.well-known/nodeinfo'; + + try { + $res = Http::withHeaders($headers) + ->timeout(5) + ->get($wk); + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (\Exception $e) { + return false; + } + + if(!$res) { + return false; + } + + $json = $res->json(); + + if( !isset($json['links'])) { + return false; + } + + if(is_array($json['links'])) { + if(isset($json['links']['href'])) { + $href = $json['links']['href']; + } else { + $href = $json['links'][0]['href']; + } + } else { + return false; + } + + $domain = parse_url($url, PHP_URL_HOST); + $hrefDomain = parse_url($href, PHP_URL_HOST); + + if($domain !== $hrefDomain) { + return 60; + } + + try { + $res = Http::withHeaders($headers) + ->timeout(5) + ->get($href); + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (\Exception $e) { + return false; + } + return $res->json(); + } +} From 37054e83931de7d86d851726fb1462e81c1b940f Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 20:47:14 -0600 Subject: [PATCH 77/92] migrations --- ...te_stories_table_fix_expires_at_column.php | 6 +++ ...add_software_column_to_instances_table.php | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 database/migrations/2021_08_30_050137_add_software_column_to_instances_table.php diff --git a/database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php b/database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php index be33dc3a3..61ae60c01 100644 --- a/database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php +++ b/database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php @@ -23,6 +23,9 @@ class UpdateStoriesTableFixExpiresAtColumn extends Migration $table->timestamp('expires_at')->default(null)->index()->nullable()->change(); $table->boolean('can_reply')->default(true); $table->boolean('can_react')->default(true); + $table->string('object_id')->nullable()->unique(); + $table->string('object_uri')->nullable()->unique(); + $table->string('bearcap_token')->nullable(); }); } @@ -43,6 +46,9 @@ class UpdateStoriesTableFixExpiresAtColumn extends Migration $table->timestamp('expires_at')->default(null)->index()->nullable()->change(); $table->dropColumn('can_reply'); $table->dropColumn('can_react'); + $table->dropColumn('object_id'); + $table->dropColumn('object_uri'); + $table->dropColumn('bearcap_token'); }); } } diff --git a/database/migrations/2021_08_30_050137_add_software_column_to_instances_table.php b/database/migrations/2021_08_30_050137_add_software_column_to_instances_table.php new file mode 100644 index 000000000..80e499604 --- /dev/null +++ b/database/migrations/2021_08_30_050137_add_software_column_to_instances_table.php @@ -0,0 +1,46 @@ +string('software')->nullable()->index(); + $table->unsignedInteger('user_count')->nullable(); + $table->unsignedInteger('status_count')->nullable(); + $table->timestamp('last_crawled_at')->nullable(); + }); + + $this->runPostMigration(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('instances', function (Blueprint $table) { + $table->dropColumn('software'); + $table->dropColumn('user_count'); + $table->dropColumn('status_count'); + $table->dropColumn('last_crawled_at'); + }); + } + + protected function runPostMigration() + { + InstanceCrawlPipeline::dispatch(); + } +} From f808b7b19d9269d8b8f888b8c42642387ebc793e Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 20:50:38 -0600 Subject: [PATCH 78/92] Story transformers --- .../ActivityPub/Verb/CreateStory.php | 29 ++++++++++++++ .../ActivityPub/Verb/DeleteStory.php | 25 ++++++++++++ .../ActivityPub/Verb/StoryVerb.php | 39 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 app/Transformer/ActivityPub/Verb/CreateStory.php create mode 100644 app/Transformer/ActivityPub/Verb/DeleteStory.php create mode 100644 app/Transformer/ActivityPub/Verb/StoryVerb.php diff --git a/app/Transformer/ActivityPub/Verb/CreateStory.php b/app/Transformer/ActivityPub/Verb/CreateStory.php new file mode 100644 index 000000000..dfcb66ba6 --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/CreateStory.php @@ -0,0 +1,29 @@ + 'https://www.w3.org/ns/activitystreams', + 'id' => $story->permalink(), + 'type' => 'Add', + 'actor' => $story->profile->permalink(), + 'to' => [ + $story->profile->permalink('/followers') + ], + 'object' => [ + 'id' => $story->url(), + 'type' => 'Story', + 'object' => $story->bearcapUrl(), + ] + ]; + } +} diff --git a/app/Transformer/ActivityPub/Verb/DeleteStory.php b/app/Transformer/ActivityPub/Verb/DeleteStory.php new file mode 100644 index 000000000..77917f077 --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/DeleteStory.php @@ -0,0 +1,25 @@ + 'https://www.w3.org/ns/activitystreams', + 'id' => $story->url() . '#delete', + 'type' => 'Delete', + 'actor' => $story->profile->permalink(), + 'object' => [ + 'id' => $story->url(), + 'type' => 'Story', + ], + ]; + } +} diff --git a/app/Transformer/ActivityPub/Verb/StoryVerb.php b/app/Transformer/ActivityPub/Verb/StoryVerb.php new file mode 100644 index 000000000..9eebb3195 --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/StoryVerb.php @@ -0,0 +1,39 @@ +type == 'photo' ? 'Image' : + ( $story->type == 'video' ? 'Video' : + 'Document' ); + + return [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $story->url(), + 'type' => 'Story', + 'to' => [ + $story->profile->permalink('/followers') + ], + 'cc' => [], + 'attributedTo' => $story->profile->permalink(), + 'published' => $story->created_at->toAtomString(), + 'expiresAt' => $story->expires_at->toAtomString(), + 'duration' => $story->duration, + 'can_reply' => (bool) $story->can_reply, + 'can_react' => (bool) $story->can_react, + 'attachment' => [ + 'type' => $type, + 'url' => url(Storage::url($story->path)), + 'mediaType' => $story->mime, + ], + ]; + } +} From b32f4d91c43c84edd1e3f5d6e1ae844ec583cfdc Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 20:51:05 -0600 Subject: [PATCH 79/92] Update Snowflake service --- app/Services/SnowflakeService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/SnowflakeService.php b/app/Services/SnowflakeService.php index 4aaaffd80..f28dc6efb 100644 --- a/app/Services/SnowflakeService.php +++ b/app/Services/SnowflakeService.php @@ -18,7 +18,7 @@ class SnowflakeService { return ((round($ts * 1000) - 1549756800000) << 22) | (random_int(1,31) << 17) | (random_int(1,31) << 12) - | $seq; + | 0; } public static function next() From 6b0b2cfaa594c8f0e569480124eb66c1005b1cc9 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 20:51:26 -0600 Subject: [PATCH 80/92] Update StoryService --- app/Services/StoryService.php | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app/Services/StoryService.php b/app/Services/StoryService.php index cbdeb19f3..f44828899 100644 --- a/app/Services/StoryService.php +++ b/app/Services/StoryService.php @@ -3,6 +3,7 @@ namespace App\Services; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Storage; use App\Story; use App\StoryView; @@ -31,6 +32,18 @@ class StoryService return $res; } + public static function getById($id) + { + return Cache::remember(self::STORY_KEY . 'by-id:id-' . $id, 3600, function() use ($id) { + return Story::find($id); + }); + } + + public static function delById($id) + { + return Cache::forget(self::STORY_KEY . 'by-id:id-' . $id); + } + public static function getStories($id, $pid) { return Story::whereProfileId($id) @@ -114,4 +127,36 @@ class StoryService ]; }); } + + public static function rotateQueue() + { + return Redis::smembers('pf:stories:rotate-queue'); + } + + public static function addRotateQueue($id) + { + return Redis::sadd('pf:stories:rotate-queue', $id); + } + + public static function removeRotateQueue($id) + { + self::delById($id); + return Redis::srem('pf:stories:rotate-queue', $id); + } + + public static function reactIncrement($storyId, $profileId) + { + $key = 'pf:stories:react-counter:storyid-' . $storyId . ':profileid-' . $profileId; + if(Redis::get($key) == null) { + Redis::setex($key, 86400, 1); + } else { + return Redis::incr($key); + } + } + + public static function reactCounter($storyId, $profileId) + { + $key = 'pf:stories:react-counter:storyid-' . $storyId . ':profileid-' . $profileId; + return (int) Redis::get($key) ?? 0; + } } From 0d8d6bc71eef80f95e9402773e0f978b01a8fc4b Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 20:51:56 -0600 Subject: [PATCH 81/92] Update FollowerService --- app/Services/FollowerService.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Services/FollowerService.php b/app/Services/FollowerService.php index 8ff951045..eeede53ec 100644 --- a/app/Services/FollowerService.php +++ b/app/Services/FollowerService.php @@ -46,7 +46,7 @@ class FollowerService public static function audience($profile, $scope = null) { - return (new self)->getAudienceInboxes($profile); + return (new self)->getAudienceInboxes($profile, $scope); } public static function softwareAudience($profile, $software = 'pixelfed') @@ -60,7 +60,8 @@ class FollowerService return InstanceService::software($domain) === strtolower($software); }) ->unique() - ->values(); + ->values() + ->toArray(); } protected function getAudienceInboxes($pid, $scope = null) From c7a5715a60a60801eaf87cd0cdbd768804bb4fe6 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 21:18:33 -0600 Subject: [PATCH 82/92] Add StoryPipeline jobs --- app/Jobs/StoryPipeline/StoryDelete.php | 136 ++++++++++++++ app/Jobs/StoryPipeline/StoryExpire.php | 169 ++++++++++++++++++ app/Jobs/StoryPipeline/StoryFanout.php | 107 +++++++++++ app/Jobs/StoryPipeline/StoryFetch.php | 144 +++++++++++++++ .../StoryPipeline/StoryReactionDeliver.php | 70 ++++++++ app/Jobs/StoryPipeline/StoryReplyDeliver.php | 70 ++++++++ app/Jobs/StoryPipeline/StoryRotateMedia.php | 61 +++++++ app/Jobs/StoryPipeline/StoryViewDeliver.php | 70 ++++++++ 8 files changed, 827 insertions(+) create mode 100644 app/Jobs/StoryPipeline/StoryDelete.php create mode 100644 app/Jobs/StoryPipeline/StoryExpire.php create mode 100644 app/Jobs/StoryPipeline/StoryFanout.php create mode 100644 app/Jobs/StoryPipeline/StoryFetch.php create mode 100644 app/Jobs/StoryPipeline/StoryReactionDeliver.php create mode 100644 app/Jobs/StoryPipeline/StoryReplyDeliver.php create mode 100644 app/Jobs/StoryPipeline/StoryRotateMedia.php create mode 100644 app/Jobs/StoryPipeline/StoryViewDeliver.php diff --git a/app/Jobs/StoryPipeline/StoryDelete.php b/app/Jobs/StoryPipeline/StoryDelete.php new file mode 100644 index 000000000..a66fafd4f --- /dev/null +++ b/app/Jobs/StoryPipeline/StoryDelete.php @@ -0,0 +1,136 @@ +story = $story; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $story = $this->story; + + if($story->local == false) { + return; + } + + StoryService::removeRotateQueue($story->id); + StoryService::delLatest($story->profile_id); + StoryService::delById($story->id); + + if(Storage::exists($story->path) == true) { + Storage::delete($story->path); + } + + $story->views()->delete(); + + $profile = $story->profile; + + $activity = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $story->url() . '#delete', + 'type' => 'Delete', + 'actor' => $profile->permalink(), + 'object' => [ + 'id' => $story->url(), + 'type' => 'Story', + ], + ]; + + $this->fanoutExpiry($profile, $activity); + + // delete notifications + // delete polls + // delete reports + + $story->delete(); + + return; + } + + protected function fanoutExpiry($profile, $activity) + { + $audience = FollowerService::softwareAudience($profile->id, 'pixelfed'); + + if(empty($audience)) { + // Return on profiles with no remote followers + return; + } + + $payload = json_encode($activity); + + $client = new Client([ + 'timeout' => config('federation.activitypub.delivery.timeout') + ]); + + $requests = function($audience) use ($client, $activity, $profile, $payload) { + foreach($audience as $url) { + $headers = HttpSignature::sign($profile, $url, $activity); + yield function() use ($client, $url, $headers, $payload) { + return $client->postAsync($url, [ + 'curl' => [ + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HEADER => true + ] + ]); + }; + } + }; + + $pool = new Pool($client, $requests($audience), [ + 'concurrency' => config('federation.activitypub.delivery.concurrency'), + 'fulfilled' => function ($response, $index) { + }, + 'rejected' => function ($reason, $index) { + } + ]); + + $promise = $pool->promise(); + + $promise->wait(); + } +} diff --git a/app/Jobs/StoryPipeline/StoryExpire.php b/app/Jobs/StoryPipeline/StoryExpire.php new file mode 100644 index 000000000..52e1c8e6c --- /dev/null +++ b/app/Jobs/StoryPipeline/StoryExpire.php @@ -0,0 +1,169 @@ +story = $story; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $story = $this->story; + + if($story->local == false) { + $this->handleRemoteExpiry(); + return; + } + + if($story->active == false) { + return; + } + + if($story->expires_at->gt(now())) { + return; + } + + $story->active = false; + $story->save(); + + $this->rotateMediaPath(); + $this->fanoutExpiry(); + + StoryService::delLatest($story->profile_id); + } + + protected function rotateMediaPath() + { + $story = $this->story; + $date = date('Y').date('m'); + $old = $story->path; + $base = "story_archives/{$story->profile_id}/{$date}/"; + $paths = explode('/', $old); + $path = array_pop($paths); + $newPath = $base . $path; + + if(Storage::exists($old) == true) { + $dir = implode('/', $paths); + Storage::move($old, $newPath); + Storage::delete($old); + $story->bearcap_token = null; + $story->path = $newPath; + $story->save(); + Storage::deleteDirectory($dir); + } + } + + protected function fanoutExpiry() + { + $story = $this->story; + $profile = $story->profile; + + if($story->local == false || $story->remote_url) { + return; + } + + $audience = FollowerService::softwareAudience($story->profile_id, 'pixelfed'); + + if(empty($audience)) { + // Return on profiles with no remote followers + return; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($story, new DeleteStory()); + $activity = $fractal->createData($resource)->toArray(); + + $payload = json_encode($activity); + + $client = new Client([ + 'timeout' => config('federation.activitypub.delivery.timeout') + ]); + + $requests = function($audience) use ($client, $activity, $profile, $payload) { + foreach($audience as $url) { + $headers = HttpSignature::sign($profile, $url, $activity); + yield function() use ($client, $url, $headers, $payload) { + return $client->postAsync($url, [ + 'curl' => [ + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HEADER => true + ] + ]); + }; + } + }; + + $pool = new Pool($client, $requests($audience), [ + 'concurrency' => config('federation.activitypub.delivery.concurrency'), + 'fulfilled' => function ($response, $index) { + }, + 'rejected' => function ($reason, $index) { + } + ]); + + $promise = $pool->promise(); + + $promise->wait(); + } + + protected function handleRemoteExpiry() + { + $story = $this->story; + $story->active = false; + $story->save(); + + $path = $story->path; + + if(Storage::exists($path) == true) { + Storage::delete($path); + } + + $story->views()->delete(); + $story->delete(); + } +} diff --git a/app/Jobs/StoryPipeline/StoryFanout.php b/app/Jobs/StoryPipeline/StoryFanout.php new file mode 100644 index 000000000..28073fe37 --- /dev/null +++ b/app/Jobs/StoryPipeline/StoryFanout.php @@ -0,0 +1,107 @@ +story = $story; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $story = $this->story; + $profile = $story->profile; + + if($story->local == false || $story->remote_url) { + return; + } + + StoryService::delLatest($story->profile_id); + + $audience = FollowerService::softwareAudience($story->profile_id, 'pixelfed'); + + if(empty($audience)) { + // Return on profiles with no remote followers + return; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($story, new CreateStory()); + $activity = $fractal->createData($resource)->toArray(); + + $payload = json_encode($activity); + + $client = new Client([ + 'timeout' => config('federation.activitypub.delivery.timeout') + ]); + + $requests = function($audience) use ($client, $activity, $profile, $payload) { + foreach($audience as $url) { + $headers = HttpSignature::sign($profile, $url, $activity); + yield function() use ($client, $url, $headers, $payload) { + return $client->postAsync($url, [ + 'curl' => [ + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HEADER => true + ] + ]); + }; + } + }; + + $pool = new Pool($client, $requests($audience), [ + 'concurrency' => config('federation.activitypub.delivery.concurrency'), + 'fulfilled' => function ($response, $index) { + }, + 'rejected' => function ($reason, $index) { + } + ]); + + $promise = $pool->promise(); + + $promise->wait(); + } +} diff --git a/app/Jobs/StoryPipeline/StoryFetch.php b/app/Jobs/StoryPipeline/StoryFetch.php new file mode 100644 index 000000000..771ed9a31 --- /dev/null +++ b/app/Jobs/StoryPipeline/StoryFetch.php @@ -0,0 +1,144 @@ +activity = $activity; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $activity = $this->activity; + $activityId = $activity['id']; + $activityActor = $activity['actor']; + + if(parse_url($activityId, PHP_URL_HOST) !== parse_url($activityActor, PHP_URL_HOST)) { + return; + } + + $bearcap = Bearcap::decode($activity['object']['object']); + + if(!$bearcap) { + return; + } + + $url = $bearcap['url']; + $token = $bearcap['token']; + + if(parse_url($activityId, PHP_URL_HOST) !== parse_url($url, PHP_URL_HOST)) { + return; + } + + $version = config('pixelfed.version'); + $appUrl = config('app.url'); + $headers = [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $token, + 'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})", + ]; + + try { + $res = Http::withHeaders($headers) + ->timeout(30) + ->get($url); + } catch (RequestException $e) { + return false; + } catch (ConnectionException $e) { + return false; + } catch (\Exception $e) { + return false; + } + + $payload = $res->json(); + + if(StoryValidator::validate($payload) == false) { + return; + } + + if(Helpers::validateUrl($payload['attachment']['url']) == false) { + return; + } + + $type = $payload['attachment']['type'] == 'Image' ? 'photo' : 'video'; + + $profile = Helpers::profileFetch($payload['attributedTo']); + + $ext = pathinfo($payload['attachment']['url'], PATHINFO_EXTENSION); + $storagePath = MediaPathService::story($profile); + $fileName = Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $ext; + $contextOptions = [ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peername' => false + ] + ]; + $ctx = stream_context_create($contextOptions); + $data = file_get_contents($payload['attachment']['url'], false, $ctx); + $tmpBase = storage_path('app/remcache/'); + $tmpPath = $profile->id . '-' . $fileName; + $tmpName = $tmpBase . $tmpPath; + file_put_contents($tmpName, $data); + $disk = Storage::disk(config('filesystems.default')); + $path = $disk->putFileAs($storagePath, new File($tmpName), $fileName, 'public'); + $size = filesize($tmpName); + unlink($tmpName); + + $story = new Story; + $story->profile_id = $profile->id; + $story->object_id = $payload['id']; + $story->size = $size; + $story->mime = $payload['attachment']['mediaType']; + $story->duration = $payload['duration']; + $story->media_url = $payload['attachment']['url']; + $story->type = $type; + $story->public = false; + $story->local = false; + $story->active = true; + $story->path = $path; + $story->view_count = 0; + $story->can_reply = $payload['can_reply']; + $story->can_react = $payload['can_react']; + $story->created_at = now()->parse($payload['published']); + $story->expires_at = now()->parse($payload['expiresAt']); + $story->save(); + + StoryService::delLatest($story->profile_id); + } +} diff --git a/app/Jobs/StoryPipeline/StoryReactionDeliver.php b/app/Jobs/StoryPipeline/StoryReactionDeliver.php new file mode 100644 index 000000000..37e573acb --- /dev/null +++ b/app/Jobs/StoryPipeline/StoryReactionDeliver.php @@ -0,0 +1,70 @@ +story = $story; + $this->status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $story = $this->story; + $status = $this->status; + + if($story->local == true) { + return; + } + + $target = $story->profile; + $actor = $status->profile; + $to = $target->inbox_url; + + $payload = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $status->permalink(), + 'type' => 'Story:Reaction', + 'to' => $target->permalink(), + 'actor' => $actor->permalink(), + 'content' => $status->caption, + 'inReplyTo' => $story->object_id, + 'published' => $status->created_at->toAtomString() + ]; + + Helpers::sendSignedObject($actor, $to, $payload); + } +} diff --git a/app/Jobs/StoryPipeline/StoryReplyDeliver.php b/app/Jobs/StoryPipeline/StoryReplyDeliver.php new file mode 100644 index 000000000..9d9f4cb60 --- /dev/null +++ b/app/Jobs/StoryPipeline/StoryReplyDeliver.php @@ -0,0 +1,70 @@ +story = $story; + $this->status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $story = $this->story; + $status = $this->status; + + if($story->local == true) { + return; + } + + $target = $story->profile; + $actor = $status->profile; + $to = $target->inbox_url; + + $payload = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $status->permalink(), + 'type' => 'Story:Reply', + 'to' => $target->permalink(), + 'actor' => $actor->permalink(), + 'content' => $status->caption, + 'inReplyTo' => $story->object_id, + 'published' => $status->created_at->toAtomString() + ]; + + Helpers::sendSignedObject($actor, $to, $payload); + } +} diff --git a/app/Jobs/StoryPipeline/StoryRotateMedia.php b/app/Jobs/StoryPipeline/StoryRotateMedia.php new file mode 100644 index 000000000..836322ff3 --- /dev/null +++ b/app/Jobs/StoryPipeline/StoryRotateMedia.php @@ -0,0 +1,61 @@ +story = $story; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $story = $this->story; + + if($story->local == false) { + return; + } + + $paths = explode('/', $story->path); + $name = array_pop($paths); + + $oldPath = $story->path; + $ext = pathinfo($name, PATHINFO_EXTENSION); + $new = Str::random(13) . '_' . Str::random(24) . '_' . Str::random(3) . '.' . $ext; + array_push($paths, $new); + $newPath = implode('/', $paths); + + if(Storage::exists($oldPath)) { + Storage::copy($oldPath, $newPath); + $story->path = $newPath; + $story->bearcap_token = null; + $story->save(); + Storage::delete($oldPath); + } + } +} diff --git a/app/Jobs/StoryPipeline/StoryViewDeliver.php b/app/Jobs/StoryPipeline/StoryViewDeliver.php new file mode 100644 index 000000000..0472b6358 --- /dev/null +++ b/app/Jobs/StoryPipeline/StoryViewDeliver.php @@ -0,0 +1,70 @@ +story = $story; + $this->profile = $profile; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $story = $this->story; + + if($story->local == true) { + return; + } + + $actor = $this->profile; + $target = $story->profile; + $to = $target->inbox_url; + + $payload = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $actor->permalink('#stories/' . $story->id . '/view'), + 'type' => 'View', + 'to' => $target->permalink(), + 'actor' => $actor->permalink(), + 'object' => [ + 'type' => 'Story', + 'object' => $story->object_id + ] + ]; + + Helpers::sendSignedObject($actor, $to, $payload); + } +} From 3c8c23a1438f68373b4c146ab858e73a3c163e34 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 21:21:17 -0600 Subject: [PATCH 83/92] Update AP Inbox --- app/Util/ActivityPub/Helpers.php | 5 +- app/Util/ActivityPub/Inbox.php | 312 +++++++++++++++++++++++++++++-- 2 files changed, 302 insertions(+), 15 deletions(-) diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index 9859bec6a..907097f05 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -563,9 +563,12 @@ class Helpers { $profile = Profile::whereRemoteUrl($res['id'])->first(); if(!$profile) { - Instance::firstOrCreate([ + $instance = Instance::firstOrCreate([ 'domain' => $domain ]); + if($instance->wasRecentlyCreated == true) { + \App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance)->onQueue('low'); + } $profileLockKey = 'helpers:profile-lock:' . hash('sha256', $res['id']); $profile = Cache::lock($profileLockKey)->get(function () use($domain, $webfinger, $res, $runJobs) { return DB::transaction(function() use($domain, $webfinger, $res, $runJobs) { diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 920f6d80a..164ca63da 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -2,7 +2,7 @@ namespace App\Util\ActivityPub; -use Cache, DB, Log, Purify, Redis, Validator; +use Cache, DB, Log, Purify, Redis, Storage, Validator; use App\{ Activity, DirectMessage, @@ -14,6 +14,8 @@ use App\{ Profile, Status, StatusHashtag, + Story, + StoryView, UserFilter }; use Carbon\Carbon; @@ -22,6 +24,8 @@ use Illuminate\Support\Str; use App\Jobs\LikePipeline\LikePipeline; use App\Jobs\FollowPipeline\FollowPipeline; use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline; +use App\Jobs\StoryPipeline\StoryExpire; +use App\Jobs\StoryPipeline\StoryFetch; use App\Util\ActivityPub\Validator\Accept as AcceptValidator; use App\Util\ActivityPub\Validator\Add as AddValidator; @@ -31,6 +35,7 @@ use App\Util\ActivityPub\Validator\Like as LikeValidator; use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator; use App\Services\PollService; +use App\Services\FollowerService; class Inbox { @@ -49,16 +54,7 @@ class Inbox public function handle() { $this->handleVerb(); - - // if(!Activity::where('data->id', $this->payload['id'])->exists()) { - // (new Activity())->create([ - // 'to_id' => $this->profile->id, - // 'data' => json_encode($this->payload) - // ]); - // } - return; - } public function handleVerb() @@ -107,6 +103,18 @@ class Inbox $this->handleUndoActivity(); break; + case 'View': + $this->handleViewActivity(); + break; + + case 'Story:Reaction': + $this->handleStoryReactionActivity(); + break; + + case 'Story:Reply': + $this->handleStoryReplyActivity(); + break; + default: // TODO: decide how to handle invalid verbs. break; @@ -138,6 +146,30 @@ class Inbox public function handleAddActivity() { // stories ;) + + if(!isset( + $this->payload['actor'], + $this->payload['object'] + )) { + return; + } + + $actor = $this->payload['actor']; + $obj = $this->payload['object']; + + if(!Helpers::validateUrl($actor)) { + return; + } + + if(!isset($obj['type'])) { + return; + } + + switch($obj['type']) { + case 'Story': + StoryFetch::dispatch($this->payload)->onQueue('story'); + break; + } } public function handleCreateActivity() @@ -212,8 +244,8 @@ class Inbox if( isset($activity['inReplyTo']) && isset($activity['name']) && !isset($activity['content']) && - !isset($activity['attachment'] && - Helpers::validateLocalUrl($activity['inReplyTo'])) + !isset($activity['attachment']) && + Helpers::validateLocalUrl($activity['inReplyTo']) ) { $this->handlePollVote(); return; @@ -496,7 +528,6 @@ class Inbox public function handleAcceptActivity() { - $actor = $this->payload['object']['actor']; $obj = $this->payload['object']['object']; $type = $this->payload['object']['type']; @@ -556,7 +587,7 @@ class Inbox return; } else { $type = $this->payload['object']['type']; - $typeCheck = in_array($type, ['Person', 'Tombstone']); + $typeCheck = in_array($type, ['Person', 'Tombstone', 'Story']); if(!Helpers::validateUrl($actor) || !Helpers::validateUrl($obj['id']) || !$typeCheck) { return; } @@ -596,6 +627,13 @@ class Inbox return; break; + case 'Story': + $story = Story::whereObjectId($id) + ->first(); + if($story) { + StoryExpire::dispatch($story)->onQueue('story'); + } + default: return; break; @@ -705,4 +743,250 @@ class Inbox } return; } + + public function handleViewActivity() + { + if(!isset( + $this->payload['actor'], + $this->payload['object'] + )) { + return; + } + + $actor = $this->payload['actor']; + $obj = $this->payload['object']; + + if(!Helpers::validateUrl($actor)) { + return; + } + + if(!$obj || !is_array($obj)) { + return; + } + + if(!isset($obj['type']) || !isset($obj['object']) || $obj['type'] != 'Story') { + return; + } + + if(!Helpers::validateLocalUrl($obj['object'])) { + return; + } + + $profile = Helpers::profileFetch($actor); + $storyId = Str::of($obj['object'])->explode('/')->last(); + + $story = Story::whereActive(true) + ->whereLocal(true) + ->find($storyId); + + if(!$story) { + return; + } + + if(!FollowerService::follows($profile->id, $story->profile_id)) { + return; + } + + $view = StoryView::firstOrCreate([ + 'story_id' => $story->id, + 'profile_id' => $profile->id + ]); + + if($view->wasRecentlyCreated == true) { + $story->view_count++; + $story->save(); + } + } + + public function handleStoryReactionActivity() + { + if(!isset( + $this->payload['actor'], + $this->payload['id'], + $this->payload['inReplyTo'], + $this->payload['content'] + )) { + return; + } + + $id = $this->payload['id']; + $actor = $this->payload['actor']; + $storyUrl = $this->payload['inReplyTo']; + $to = $this->payload['to']; + $text = Purify::clean($this->payload['content']); + + if(parse_url($id, PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) { + return; + } + + if(!Helpers::validateUrl($id) || !Helpers::validateUrl($actor)) { + return; + } + + if(!Helpers::validateLocalUrl($storyUrl)) { + return; + } + + if(!Helpers::validateLocalUrl($to)) { + return; + } + + if(Status::whereObjectUrl($id)->exists()) { + return; + } + + $storyId = Str::of($storyUrl)->explode('/')->last(); + $targetProfile = Helpers::profileFetch($to); + + $story = Story::whereProfileId($targetProfile->id) + ->find($storyId); + + if(!$story) { + return; + } + + if($story->can_react == false) { + return; + } + + $actorProfile = Helpers::profileFetch($actor); + + if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) { + return; + } + + $status = new Status; + $status->profile_id = $actorProfile->id; + $status->type = 'story:reaction'; + $status->caption = $text; + $status->rendered = $text; + $status->scope = 'direct'; + $status->visibility = 'direct'; + $status->in_reply_to_profile_id = $story->profile_id; + $status->entities = json_encode([ + 'story_id' => $story->id, + 'reaction' => $text + ]); + $status->save(); + + $dm = new DirectMessage; + $dm->to_id = $story->profile_id; + $dm->from_id = $actorProfile->id; + $dm->type = 'story:react'; + $dm->status_id = $status->id; + $dm->meta = json_encode([ + 'story_username' => $targetProfile->username, + 'story_actor_username' => $actorProfile->username, + 'story_id' => $story->id, + 'story_media_url' => url(Storage::url($story->path)), + 'reaction' => $text + ]); + $dm->save(); + + $n = new Notification; + $n->profile_id = $dm->to_id; + $n->actor_id = $dm->from_id; + $n->item_id = $dm->id; + $n->item_type = 'App\DirectMessage'; + $n->action = 'story:react'; + $n->message = "{$actorProfile->username} reacted to your story"; + $n->rendered = "{$actorProfile->username} reacted to your story"; + $n->save(); + } + + public function handleStoryReplyActivity() + { + if(!isset( + $this->payload['actor'], + $this->payload['id'], + $this->payload['inReplyTo'], + $this->payload['content'] + )) { + return; + } + + $id = $this->payload['id']; + $actor = $this->payload['actor']; + $storyUrl = $this->payload['inReplyTo']; + $to = $this->payload['to']; + $text = Purify::clean($this->payload['content']); + + if(parse_url($id, PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) { + return; + } + + if(!Helpers::validateUrl($id) || !Helpers::validateUrl($actor)) { + return; + } + + if(!Helpers::validateLocalUrl($storyUrl)) { + return; + } + + if(!Helpers::validateLocalUrl($to)) { + return; + } + + if(Status::whereObjectUrl($id)->exists()) { + return; + } + + $storyId = Str::of($storyUrl)->explode('/')->last(); + $targetProfile = Helpers::profileFetch($to); + + $story = Story::whereProfileId($targetProfile->id) + ->find($storyId); + + if(!$story) { + return; + } + + if($story->can_react == false) { + return; + } + + $actorProfile = Helpers::profileFetch($actor); + + if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) { + return; + } + + $status = new Status; + $status->profile_id = $actorProfile->id; + $status->type = 'story:reply'; + $status->caption = $text; + $status->rendered = $text; + $status->scope = 'direct'; + $status->visibility = 'direct'; + $status->in_reply_to_profile_id = $story->profile_id; + $status->entities = json_encode([ + 'story_id' => $story->id, + 'caption' => $text + ]); + $status->save(); + + $dm = new DirectMessage; + $dm->to_id = $story->profile_id; + $dm->from_id = $actorProfile->id; + $dm->type = 'story:comment'; + $dm->status_id = $status->id; + $dm->meta = json_encode([ + 'story_username' => $targetProfile->username, + 'story_actor_username' => $actorProfile->username, + 'story_id' => $story->id, + 'story_media_url' => url(Storage::url($story->path)), + 'caption' => $text + ]); + $dm->save(); + + $n = new Notification; + $n->profile_id = $dm->to_id; + $n->actor_id = $dm->from_id; + $n->item_id = $dm->id; + $n->item_type = 'App\DirectMessage'; + $n->action = 'story:comment'; + $n->message = "{$actorProfile->username} commented on story"; + $n->rendered = "{$actorProfile->username} commented on story"; + $n->save(); + } } From d7b6edc0188fc9807731f096a524efa53c51af06 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 21:23:43 -0600 Subject: [PATCH 84/92] Update NotificationTransformer --- app/Transformer/Api/NotificationTransformer.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Transformer/Api/NotificationTransformer.php b/app/Transformer/Api/NotificationTransformer.php index 981e5f727..8a7870e7b 100644 --- a/app/Transformer/Api/NotificationTransformer.php +++ b/app/Transformer/Api/NotificationTransformer.php @@ -59,7 +59,10 @@ class NotificationTransformer extends Fractal\TransformerAbstract 'like' => 'favourite', 'comment' => 'comment', 'admin.user.modlog.comment' => 'modlog', - 'tagged' => 'tagged' + 'tagged' => 'tagged', + 'group:comment' => 'group:comment', + 'story:react' => 'story:react', + 'story:comment' => 'story:comment' ]; return $verbs[$verb]; } @@ -90,7 +93,6 @@ class NotificationTransformer extends Fractal\TransformerAbstract } } - public function includeTagged(Notification $notification) { $n = $notification; From d0bfefe8d0a614f76e6d2f5729240e15496e64e7 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 21:25:19 -0600 Subject: [PATCH 85/92] Update Media model --- app/Media.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Media.php b/app/Media.php index 107f15b4d..b90b1c6d2 100644 --- a/app/Media.php +++ b/app/Media.php @@ -18,6 +18,10 @@ class Media extends Model */ protected $dates = ['deleted_at']; + protected $casts = [ + 'srcset' => 'array' + ]; + public function status() { return $this->belongsTo(Status::class); From dd7262d841fe7725fce3602abf1837b8bd5baaa0 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 22:08:15 -0600 Subject: [PATCH 86/92] Update StoryController, add StoryComposeController --- .../Controllers/StoryComposeController.php | 501 +++++++++++++++++ app/Http/Controllers/StoryController.php | 527 ++++++------------ 2 files changed, 670 insertions(+), 358 deletions(-) create mode 100644 app/Http/Controllers/StoryComposeController.php diff --git a/app/Http/Controllers/StoryComposeController.php b/app/Http/Controllers/StoryComposeController.php new file mode 100644 index 000000000..b510c3f78 --- /dev/null +++ b/app/Http/Controllers/StoryComposeController.php @@ -0,0 +1,501 @@ +user(), 404); + + $this->validate($request, [ + 'file' => function() { + return [ + 'required', + 'mimes:image/jpeg,image/png,video/mp4', + 'max:' . config_cache('pixelfed.max_photo_size'), + ]; + }, + ]); + + $user = $request->user(); + + $count = Story::whereProfileId($user->profile_id) + ->whereActive(true) + ->where('expires_at', '>', now()) + ->count(); + + if($count >= Story::MAX_PER_DAY) { + abort(418, 'You have reached your limit for new Stories today.'); + } + + $photo = $request->file('file'); + $path = $this->storePhoto($photo, $user); + + $story = new Story(); + $story->duration = 3; + $story->profile_id = $user->profile_id; + $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo'; + $story->mime = $photo->getMimeType(); + $story->path = $path; + $story->local = true; + $story->size = $photo->getSize(); + $story->bearcap_token = str_random(64); + $story->save(); + + $url = $story->path; + + $res = [ + 'code' => 200, + 'msg' => 'Successfully added', + 'media_id' => (string) $story->id, + 'media_url' => url(Storage::url($url)) . '?v=' . time(), + 'media_type' => $story->type + ]; + + if($story->type === 'video') { + $video = FFMpeg::open($path); + $duration = $video->getDurationInSeconds(); + $res['media_duration'] = $duration; + if($duration > 500) { + Storage::delete($story->path); + $story->delete(); + return response()->json([ + 'message' => 'Video duration cannot exceed 60 seconds' + ], 422); + } + } + + return $res; + } + + protected function storePhoto($photo, $user) + { + $mimes = explode(',', config_cache('pixelfed.media_types')); + if(in_array($photo->getMimeType(), [ + 'image/jpeg', + 'image/png', + 'video/mp4' + ]) == false) { + abort(400, 'Invalid media type'); + return; + } + + $storagePath = MediaPathService::story($user->profile); + $path = $photo->storeAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension()); + if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) { + $fpath = storage_path('app/' . $path); + $img = Intervention::make($fpath); + $img->orientate(); + $img->save($fpath, config_cache('pixelfed.image_quality')); + $img->destroy(); + } + return $path; + } + + public function cropPhoto(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $this->validate($request, [ + 'media_id' => 'required|integer|min:1', + 'width' => 'required', + 'height' => 'required', + 'x' => 'required', + 'y' => 'required' + ]); + + $user = $request->user(); + $id = $request->input('media_id'); + $width = round($request->input('width')); + $height = round($request->input('height')); + $x = round($request->input('x')); + $y = round($request->input('y')); + + $story = Story::whereProfileId($user->profile_id)->findOrFail($id); + + $path = storage_path('app/' . $story->path); + + if(!is_file($path)) { + abort(400, 'Invalid or missing media.'); + } + + if($story->type === 'photo') { + $img = Intervention::make($path); + $img->crop($width, $height, $x, $y); + $img->resize(1080, 1920, function ($constraint) { + $constraint->aspectRatio(); + }); + $img->save($path, config_cache('pixelfed.image_quality')); + } + + return [ + 'code' => 200, + 'msg' => 'Successfully cropped', + ]; + } + + public function publishStory(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $this->validate($request, [ + 'media_id' => 'required', + 'duration' => 'required|integer|min:3|max:120', + 'can_reply' => 'required|boolean', + 'can_react' => 'required|boolean' + ]); + + $id = $request->input('media_id'); + $user = $request->user(); + $story = Story::whereProfileId($user->profile_id) + ->findOrFail($id); + + $story->active = true; + $story->expires_at = now()->addMinutes(1440); + $story->duration = $request->input('duration', 10); + $story->can_reply = $request->input('can_reply'); + $story->can_react = $request->input('can_react'); + $story->save(); + + StoryService::delLatest($story->profile_id); + StoryFanout::dispatch($story)->onQueue('story'); + StoryService::addRotateQueue($story->id); + + return [ + 'code' => 200, + 'msg' => 'Successfully published', + ]; + } + + public function apiV1Delete(Request $request, $id) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $user = $request->user(); + + $story = Story::whereProfileId($user->profile_id) + ->findOrFail($id); + $story->active = false; + $story->save(); + + StoryDelete::dispatch($story)->onQueue('story'); + + return [ + 'code' => 200, + 'msg' => 'Successfully deleted' + ]; + } + + public function compose(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + return view('stories.compose'); + } + + public function createPoll(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + abort_if(!config_cache('instance.polls.enabled'), 404); + + return $request->all(); + } + + public function publishStoryPoll(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $this->validate($request, [ + 'question' => 'required|string|min:6|max:140', + 'options' => 'required|array|min:2|max:4', + 'can_reply' => 'required|boolean', + 'can_react' => 'required|boolean' + ]); + + $pid = $request->user()->profile_id; + + $count = Story::whereProfileId($pid) + ->whereActive(true) + ->where('expires_at', '>', now()) + ->count(); + + if($count >= Story::MAX_PER_DAY) { + abort(418, 'You have reached your limit for new Stories today.'); + } + + $story = new Story; + $story->type = 'poll'; + $story->story = json_encode([ + 'question' => $request->input('question'), + 'options' => $request->input('options') + ]); + $story->public = false; + $story->local = true; + $story->profile_id = $pid; + $story->expires_at = now()->addMinutes(1440); + $story->duration = 30; + $story->can_reply = false; + $story->can_react = false; + $story->save(); + + $poll = new Poll; + $poll->story_id = $story->id; + $poll->profile_id = $pid; + $poll->poll_options = $request->input('options'); + $poll->expires_at = $story->expires_at; + $poll->cached_tallies = collect($poll->poll_options)->map(function($o) { + return 0; + })->toArray(); + $poll->save(); + + $story->active = true; + $story->save(); + + StoryService::delLatest($story->profile_id); + + return [ + 'code' => 200, + 'msg' => 'Successfully published', + ]; + } + + public function storyPollVote(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $this->validate($request, [ + 'sid' => 'required', + 'ci' => 'required|integer|min:0|max:3' + ]); + + $pid = $request->user()->profile_id; + $ci = $request->input('ci'); + $story = Story::findOrFail($request->input('sid')); + abort_if(!FollowerService::follows($pid, $story->profile_id), 403); + $poll = Poll::whereStoryId($story->id)->firstOrFail(); + + $vote = new PollVote; + $vote->profile_id = $pid; + $vote->poll_id = $poll->id; + $vote->story_id = $story->id; + $vote->status_id = null; + $vote->choice = $ci; + $vote->save(); + + $poll->votes_count = $poll->votes_count + 1; + $poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($ci) { + return $ci == $key ? $tally + 1 : $tally; + })->toArray(); + $poll->save(); + + return 200; + } + + public function storeReport(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $this->validate($request, [ + 'type' => 'required|alpha_dash', + 'id' => 'required|integer|min:1', + ]); + + $pid = $request->user()->profile_id; + $sid = $request->input('id'); + $type = $request->input('type'); + + $types = [ + // original 3 + 'spam', + 'sensitive', + 'abusive', + + // new + 'underage', + 'copyright', + 'impersonation', + 'scam', + 'terrorism' + ]; + + abort_if(!in_array($type, $types), 422, 'Invalid story report type'); + + $story = Story::findOrFail($sid); + + abort_if($story->profile_id == $pid, 422, 'Cannot report your own story'); + abort_if(!FollowerService::follows($pid, $story->profile_id), 422, 'Cannot report a story from an account you do not follow'); + + if( Report::whereProfileId($pid) + ->whereObjectType('App\Story') + ->whereObjectId($story->id) + ->exists() + ) { + return response()->json(['error' => [ + 'code' => 409, + 'message' => 'Cannot report the same story again' + ]], 409); + } + + $report = new Report; + $report->profile_id = $pid; + $report->user_id = $request->user()->id; + $report->object_id = $story->id; + $report->object_type = 'App\Story'; + $report->reported_profile_id = $story->profile_id; + $report->type = $type; + $report->message = null; + $report->save(); + + return [200]; + } + + public function react(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + $this->validate($request, [ + 'sid' => 'required', + 'reaction' => 'required|string' + ]); + $pid = $request->user()->profile_id; + $text = $request->input('reaction'); + + $story = Story::findOrFail($request->input('sid')); + + abort_if(!$story->can_react, 422); + abort_if(StoryService::reactCounter($story->id, $pid) >= 5, 422, 'You have already reacted to this story'); + + $status = new Status; + $status->profile_id = $pid; + $status->type = 'story:reaction'; + $status->caption = $text; + $status->rendered = $text; + $status->scope = 'direct'; + $status->visibility = 'direct'; + $status->in_reply_to_profile_id = $story->profile_id; + $status->entities = json_encode([ + 'story_id' => $story->id, + 'reaction' => $text + ]); + $status->save(); + + $dm = new DirectMessage; + $dm->to_id = $story->profile_id; + $dm->from_id = $pid; + $dm->type = 'story:react'; + $dm->status_id = $status->id; + $dm->meta = json_encode([ + 'story_username' => $story->profile->username, + 'story_actor_username' => $request->user()->username, + 'story_id' => $story->id, + 'story_media_url' => url(Storage::url($story->path)), + 'reaction' => $text + ]); + $dm->save(); + + if($story->local) { + // generate notification + $n = new Notification; + $n->profile_id = $dm->to_id; + $n->actor_id = $dm->from_id; + $n->item_id = $dm->id; + $n->item_type = 'App\DirectMessage'; + $n->action = 'story:react'; + $n->message = "{$request->user()->username} reacted to your story"; + $n->rendered = "{$request->user()->username} reacted to your story"; + $n->save(); + } else { + StoryReactionDeliver::dispatch($story, $status)->onQueue('story'); + } + + StoryService::reactIncrement($story->id, $pid); + + return 200; + } + + public function comment(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + $this->validate($request, [ + 'sid' => 'required', + 'caption' => 'required|string' + ]); + $pid = $request->user()->profile_id; + $text = $request->input('caption'); + + $story = Story::findOrFail($request->input('sid')); + + abort_if(!$story->can_reply, 422); + + $status = new Status; + $status->type = 'story:reply'; + $status->profile_id = $pid; + $status->caption = $text; + $status->rendered = $text; + $status->scope = 'direct'; + $status->visibility = 'direct'; + $status->in_reply_to_profile_id = $story->profile_id; + $status->entities = json_encode([ + 'story_id' => $story->id + ]); + $status->save(); + + $dm = new DirectMessage; + $dm->to_id = $story->profile_id; + $dm->from_id = $pid; + $dm->type = 'story:comment'; + $dm->status_id = $status->id; + $dm->meta = json_encode([ + 'story_username' => $story->profile->username, + 'story_actor_username' => $request->user()->username, + 'story_id' => $story->id, + 'story_media_url' => url(Storage::url($story->path)), + 'caption' => $text + ]); + $dm->save(); + + if($story->local) { + // generate notification + $n = new Notification; + $n->profile_id = $dm->to_id; + $n->actor_id = $dm->from_id; + $n->item_id = $dm->id; + $n->item_type = 'App\DirectMessage'; + $n->action = 'story:comment'; + $n->message = "{$request->user()->username} commented on story"; + $n->rendered = "{$request->user()->username} commented on story"; + $n->save(); + } else { + StoryReplyDeliver::dispatch($story, $status)->onQueue('story'); + } + + return 200; + } +} diff --git a/app/Http/Controllers/StoryController.php b/app/Http/Controllers/StoryController.php index e48ee839d..a4e85c11d 100644 --- a/app/Http/Controllers/StoryController.php +++ b/app/Http/Controllers/StoryController.php @@ -4,337 +4,106 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Str; +use App\DirectMessage; +use App\Follower; +use App\Notification; use App\Media; use App\Profile; +use App\Status; use App\Story; use App\StoryView; +use App\Services\PollService; +use App\Services\ProfileService; use App\Services\StoryService; use Cache, Storage; use Image as Intervention; +use App\Services\AccountService; use App\Services\FollowerService; use App\Services\MediaPathService; use FFMpeg; use FFMpeg\Coordinate\Dimension; use FFMpeg\Format\Video\X264; +use League\Fractal\Manager; +use League\Fractal\Serializer\ArraySerializer; +use League\Fractal\Resource\Item; +use App\Transformer\ActivityPub\Verb\StoryVerb; +use App\Jobs\StoryPipeline\StoryViewDeliver; -class StoryController extends Controller +class StoryController extends StoryComposeController { - public function apiV1Add(Request $request) + public function recent(Request $request) { abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + $pid = $request->user()->profile_id; - $this->validate($request, [ - 'file' => function() { - return [ - 'required', - 'mimes:image/jpeg,image/png,video/mp4', - 'max:' . config_cache('pixelfed.max_photo_size'), - ]; - }, - ]); - - $user = $request->user(); - - if(Story::whereProfileId($user->profile_id)->where('expires_at', '>', now())->count() >= Story::MAX_PER_DAY) { - abort(400, 'You have reached your limit for new Stories today.'); - } - - $photo = $request->file('file'); - $path = $this->storePhoto($photo, $user); - - $story = new Story(); - $story->duration = 3; - $story->profile_id = $user->profile_id; - $story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo'; - $story->mime = $photo->getMimeType(); - $story->path = $path; - $story->local = true; - $story->size = $photo->getSize(); - $story->save(); - - $url = $story->path; - - if($story->type === 'video') { - $video = FFMpeg::open($path); - $width = $video->getVideoStream()->get('width'); - $height = $video->getVideoStream()->get('height'); - - - if($width !== 1080 || $height !== 1920) { - Storage::delete($story->path); - $story->delete(); - abort(422, 'Invalid video dimensions, must be 1080x1920'); - } - } - - return [ - 'code' => 200, - 'msg' => 'Successfully added', - 'media_id' => (string) $story->id, - 'media_url' => url(Storage::url($url)) . '?v=' . time(), - 'media_type' => $story->type - ]; - } - - protected function storePhoto($photo, $user) - { - $mimes = explode(',', config_cache('pixelfed.media_types')); - if(in_array($photo->getMimeType(), [ - 'image/jpeg', - 'image/png', - 'video/mp4' - ]) == false) { - abort(400, 'Invalid media type'); - return; - } - - $storagePath = MediaPathService::story($user->profile); - $path = $photo->store($storagePath); - if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) { - $fpath = storage_path('app/' . $path); - $img = Intervention::make($fpath); - $img->orientate(); - $img->save($fpath, config_cache('pixelfed.image_quality')); - $img->destroy(); - } - return $path; - } - - public function cropPhoto(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - $this->validate($request, [ - 'media_id' => 'required|integer|min:1', - 'width' => 'required', - 'height' => 'required', - 'x' => 'required', - 'y' => 'required' - ]); - - $user = $request->user(); - $id = $request->input('media_id'); - $width = round($request->input('width')); - $height = round($request->input('height')); - $x = round($request->input('x')); - $y = round($request->input('y')); - - $story = Story::whereProfileId($user->profile_id)->findOrFail($id); - - $path = storage_path('app/' . $story->path); - - if(!is_file($path)) { - abort(400, 'Invalid or missing media.'); - } - - if($story->type === 'photo') { - $img = Intervention::make($path); - $img->crop($width, $height, $x, $y); - $img->resize(1080, 1920, function ($constraint) { - $constraint->aspectRatio(); - }); - $img->save($path, config_cache('pixelfed.image_quality')); - } - - return [ - 'code' => 200, - 'msg' => 'Successfully cropped', - ]; - } - - public function publishStory(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - $this->validate($request, [ - 'media_id' => 'required', - 'duration' => 'required|integer|min:3|max:10' - ]); - - $id = $request->input('media_id'); - $user = $request->user(); - $story = Story::whereProfileId($user->profile_id) - ->findOrFail($id); - - $story->active = true; - $story->duration = $request->input('duration', 10); - $story->expires_at = now()->addMinutes(1450); - $story->save(); - - return [ - 'code' => 200, - 'msg' => 'Successfully published', - ]; - } - - public function apiV1Delete(Request $request, $id) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - $user = $request->user(); - - $story = Story::whereProfileId($user->profile_id) - ->findOrFail($id); - - if(Storage::exists($story->path) == true) { - Storage::delete($story->path); - } - - $story->delete(); - - return [ - 'code' => 200, - 'msg' => 'Successfully deleted' - ]; - } - - public function apiV1Recent(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - $profile = $request->user()->profile; - $following = $profile->following->pluck('id')->toArray(); - - if(config('database.default') == 'pgsql') { - $db = Story::with('profile') - ->whereActive(true) - ->whereIn('profile_id', $following) - ->where('expires_at', '>', now()) - ->distinct('profile_id') - ->take(9) + $s = Story::select('stories.*', 'followers.following_id') + ->leftJoin('followers', 'followers.following_id', 'stories.profile_id') + ->where('followers.profile_id', $pid) + ->where('stories.active', true) + ->groupBy('followers.following_id') + ->orderByDesc('id') ->get(); - } else { - $db = Story::with('profile') - ->whereActive(true) - ->whereIn('profile_id', $following) - ->where('created_at', '>', now()->subDay()) - ->orderByDesc('expires_at') - ->groupBy('profile_id') - ->take(9) - ->get(); - } - $stories = $db->map(function($s, $k) { + $res = $s->map(function($s) use($pid) { + $profile = AccountService::get($s->profile_id); + $url = $profile['local'] ? url("/stories/{$profile['username']}") : + url("/i/rs/{$profile['id']}"); return [ - 'id' => (string) $s->id, - 'photo' => $s->profile->avatarUrl(), - 'name' => $s->profile->username, - 'link' => $s->profile->url(), - 'lastUpdated' => (int) $s->created_at->format('U'), - 'seen' => $s->seen(), - 'items' => [], - 'pid' => (string) $s->profile->id + 'pid' => $profile['id'], + 'avatar' => $profile['avatar'], + 'local' => $profile['local'], + 'username' => $profile['acct'], + 'url' => $url, + 'seen' => StoryService::hasSeen($pid, StoryService::latest($s->profile_id)), + 'sid' => $s->id ]; - }); - - return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); - } - - public function apiV1Fetch(Request $request, $id) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - $authed = $request->user()->profile; - $profile = Profile::findOrFail($id); - if($id == $authed->id) { - $publicOnly = true; - } else { - $publicOnly = (bool) $profile->followedBy($authed); - } - - $stories = Story::whereProfileId($profile->id) - ->whereActive(true) - ->orderBy('expires_at', 'desc') - ->where('expires_at', '>', now()) - ->when(!$publicOnly, function($query, $publicOnly) { - return $query->wherePublic(true); }) - ->get() - ->map(function($s, $k) { - return [ - 'id' => (string) $s->id, - 'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo', - 'length' => 3, - 'src' => url(Storage::url($s->path)), - 'preview' => null, - 'link' => null, - 'linkText' => null, - 'time' => $s->created_at->format('U'), - 'expires_at' => (int) $s->expires_at->format('U'), - 'created_ago' => $s->created_at->diffForHumans(null, true, true), - 'seen' => $s->seen() - ]; - })->toArray(); - return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); - } - - public function apiV1Item(Request $request, $id) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - $authed = $request->user()->profile; - $story = Story::with('profile') - ->whereActive(true) - ->where('expires_at', '>', now()) - ->findOrFail($id); - - $profile = $story->profile; - if($story->profile_id == $authed->id) { - $publicOnly = true; - } else { - $publicOnly = (bool) $profile->followedBy($authed); - } - - abort_if(!$publicOnly, 403); - - $res = [ - 'id' => (string) $story->id, - 'type' => Str::endsWith($story->path, '.mp4') ? 'video' :'photo', - 'length' => 10, - 'src' => url(Storage::url($story->path)), - 'preview' => null, - 'link' => null, - 'linkText' => null, - 'time' => $story->created_at->format('U'), - 'expires_at' => (int) $story->expires_at->format('U'), - 'seen' => $story->seen() - ]; + ->sortBy('seen') + ->values(); return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } - public function apiV1Profile(Request $request, $id) + public function profile(Request $request, $id) { abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - $authed = $request->user()->profile; + $authed = $request->user()->profile_id; $profile = Profile::findOrFail($id); - if($id == $authed->id) { - $publicOnly = true; - } else { - $publicOnly = (bool) $profile->followedBy($authed); + + if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) { + return []; } $stories = Story::whereProfileId($profile->id) ->whereActive(true) ->orderBy('expires_at') - ->where('expires_at', '>', now()) - ->when(!$publicOnly, function($query, $publicOnly) { - return $query->wherePublic(true); - }) ->get() - ->map(function($s, $k) { - return [ - 'id' => $s->id, - 'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo', - 'length' => 10, + ->map(function($s, $k) use($authed) { + $seen = StoryService::hasSeen($authed, $s->id); + $res = [ + 'id' => (string) $s->id, + 'type' => $s->type, + 'duration' => $s->duration, 'src' => url(Storage::url($s->path)), - 'preview' => null, - 'link' => null, - 'linkText' => null, - 'time' => $s->created_at->format('U'), - 'expires_at' => (int) $s->expires_at->format('U'), - 'seen' => $s->seen() + 'created_at' => $s->created_at->toAtomString(), + 'expires_at' => $s->expires_at->toAtomString(), + 'view_count' => ($authed == $s->profile_id) ? ($s->view_count ?? 0) : null, + 'seen' => $seen, + 'progress' => $seen ? 100 : 0, + 'can_reply' => (bool) $s->can_reply, + 'can_react' => (bool) $s->can_react ]; + + if($s->type == 'poll') { + $res['question'] = json_decode($s->story, true)['question']; + $res['options'] = json_decode($s->story, true)['options']; + $res['voted'] = PollService::votedStory($s->id, $authed); + if($res['voted']) { + $res['voted_index'] = PollService::storyChoice($s->id, $authed); + } + } + + return $res; })->toArray(); if(count($stories) == 0) { return []; @@ -342,32 +111,27 @@ class StoryController extends Controller $cursor = count($stories) - 1; $stories = [[ 'id' => (string) $stories[$cursor]['id'], - 'photo' => $profile->avatarUrl(), - 'name' => $profile->username, - 'link' => $profile->url(), - 'lastUpdated' => (int) now()->format('U'), - 'seen' => null, - 'items' => $stories, + 'nodes' => $stories, + 'account' => AccountService::get($profile->id), 'pid' => (string) $profile->id ]]; return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); } - public function apiV1Viewed(Request $request) + public function viewed(Request $request) { abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); $this->validate($request, [ - 'id' => 'required|integer|min:1|exists:stories', + 'id' => 'required|min:1', ]); $id = $request->input('id'); $authed = $request->user()->profile; $story = Story::with('profile') - ->where('expires_at', '>', now()) - ->orderByDesc('expires_at') ->findOrFail($id); + $exp = $story->expires_at; $profile = $story->profile; @@ -378,72 +142,32 @@ class StoryController extends Controller $publicOnly = (bool) $profile->followedBy($authed); abort_if(!$publicOnly, 403); - StoryView::firstOrCreate([ + + $v = StoryView::firstOrCreate([ 'story_id' => $id, 'profile_id' => $authed->id ]); - $story->view_count = $story->view_count + 1; - $story->save(); + if($v->wasRecentlyCreated) { + Story::findOrFail($story->id)->increment('view_count'); + if($story->local == false) { + StoryViewDeliver::dispatch($story, $authed)->onQueue('story'); + } + } + + Cache::forget('stories:recent:by_id:' . $authed->id); + StoryService::addSeen($authed->id, $story->id); return ['code' => 200]; } - public function apiV1Exists(Request $request, $id) + public function exists(Request $request, $id) { abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - $res = (bool) Story::whereProfileId($id) + return response()->json(Story::whereProfileId($id) ->whereActive(true) - ->where('expires_at', '>', now()) - ->count(); - - return response()->json($res); - } - - public function apiV1Me(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - $profile = $request->user()->profile; - $stories = Story::whereProfileId($profile->id) - ->whereActive(true) - ->orderBy('expires_at') - ->where('expires_at', '>', now()) - ->get() - ->map(function($s, $k) { - return [ - 'id' => $s->id, - 'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo', - 'length' => 3, - 'src' => url(Storage::url($s->path)), - 'preview' => null, - 'link' => null, - 'linkText' => null, - 'time' => $s->created_at->format('U'), - 'expires_at' => (int) $s->expires_at->format('U'), - 'seen' => true - ]; - })->toArray(); - $ts = count($stories) ? last($stories)['time'] : null; - $res = [ - 'id' => (string) $profile->id, - 'photo' => $profile->avatarUrl(), - 'name' => $profile->username, - 'link' => $profile->url(), - 'lastUpdated' => $ts, - 'seen' => true, - 'items' => $stories - ]; - - return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); - } - - public function compose(Request $request) - { - abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); - - return view('stories.compose'); + ->exists()); } public function iRedirect(Request $request) @@ -455,4 +179,91 @@ class StoryController extends Controller $username = $user->username; return redirect("/stories/{$username}"); } + + public function viewers(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $this->validate($request, [ + 'sid' => 'required|string' + ]); + + $pid = $request->user()->profile_id; + $sid = $request->input('sid'); + + $story = Story::whereProfileId($pid) + ->whereActive(true) + ->findOrFail($sid); + + $viewers = StoryView::whereStoryId($story->id) + ->latest() + ->simplePaginate(10) + ->map(function($view) { + return AccountService::get($view->profile_id); + }) + ->values(); + + return response()->json($viewers, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function remoteStory(Request $request, $id) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $profile = Profile::findOrFail($id); + if($profile->user_id != null || $profile->domain == null) { + return redirect('/stories/' . $profile->username); + } + $pid = $profile->id; + return view('stories.show_remote', compact('pid')); + } + + public function pollResults(Request $request) + { + abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); + + $this->validate($request, [ + 'sid' => 'required|string' + ]); + + $pid = $request->user()->profile_id; + $sid = $request->input('sid'); + + $story = Story::whereProfileId($pid) + ->whereActive(true) + ->findOrFail($sid); + + return PollService::storyResults($sid); + } + + public function getActivityObject(Request $request, $username, $id) + { + abort_if(!config_cache('instance.stories.enabled'), 404); + + if(!$request->wantsJson()) { + return redirect('/stories/' . $username); + } + + abort_if(!$request->hasHeader('Authorization'), 404); + + $profile = Profile::whereUsername($username)->whereNull('domain')->firstOrFail(); + $story = Story::whereActive(true)->whereProfileId($profile->id)->findOrFail($id); + + abort_if($story->bearcap_token == null, 404); + abort_if(now()->gt($story->expires_at), 404); + $token = substr($request->header('Authorization'), 7); + abort_if(hash_equals($story->bearcap_token, $token) === false, 404); + abort_if($story->created_at->lt(now()->subMinutes(20)), 404); + + $fractal = new Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Item($story, new StoryVerb()); + $res = $fractal->createData($resource)->toArray(); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function showSystemStory() + { + // return view('stories.system'); + } } From 2ab032599c422c415e29aec51b2be96be80409fb Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 22:09:25 -0600 Subject: [PATCH 87/92] Update db config --- config/database.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config/database.php b/config/database.php index b5020f740..7fe483d71 100644 --- a/config/database.php +++ b/config/database.php @@ -1,7 +1,8 @@ [ + 'types' => [ + 'timestamp' => TimestampType::class, + ], + ], ]; From a08c8a29d57222d9ade3102a24ba61dfb27ced4b Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 22:09:58 -0600 Subject: [PATCH 88/92] Update image optimizer --- config/image-optimizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/image-optimizer.php b/config/image-optimizer.php index 889e356d3..be63b008f 100644 --- a/config/image-optimizer.php +++ b/config/image-optimizer.php @@ -14,7 +14,7 @@ return [ 'optimizers' => [ Jpegoptim::class => [ - '-m75', // set maximum quality to 75% + '-m' . (int) env('IMAGE_QUALITY', 80), '--strip-all', // this strips out all text information such as comments and EXIF data '--all-progressive', // this will make sure the resulting image is a progressive one ], From 6ca140e5ccb761a49f89085872ea289b6f11eb65 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 3 Sep 2021 22:20:55 -0600 Subject: [PATCH 89/92] Update nav view --- resources/views/layouts/partial/nav.blade.php | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/resources/views/layouts/partial/nav.blade.php b/resources/views/layouts/partial/nav.blade.php index 0761b9e31..8b6051690 100644 --- a/resources/views/layouts/partial/nav.blade.php +++ b/resources/views/layouts/partial/nav.blade.php @@ -63,26 +63,7 @@ -