diff --git a/CHANGELOG.md b/CHANGELOG.md index a05796a79..3fce31c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - 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)) +- Federate Media Licenses ([14a1367a](https://github.com/pixelfed/pixelfed/commit/14a1367a)) +- Archive Posts ([e9ef0c88](https://github.com/pixelfed/pixelfed/commit/e9ef0c88)) +- Polls ([77092200](https://github.com/pixelfed/pixelfed/commit/77092200)) ### Updated - Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b)) @@ -73,6 +78,28 @@ - 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)) +- 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)) +- 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)) +- Updated StatusService, invalidate profile embed cache on deletion. ([acaf630d](https://github.com/pixelfed/pixelfed/commit/acaf630d)) +- Updated status.reply view, fix archived post leakage. ([4fb3d1fa](https://github.com/pixelfed/pixelfed/commit/4fb3d1fa)) +- Updated PostComponents, re-add time to timestamp. ([c5281dcd](https://github.com/pixelfed/pixelfed/commit/c5281dcd)) +- Updated follow intent, fix follower count leak. ([03199e2f](https://github.com/pixelfed/pixelfed/commit/03199e2f)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0) 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/Console/Commands/FailedJobGC.php b/app/Console/Commands/FailedJobGC.php index f48d49b84..f50d97afd 100644 --- a/app/Console/Commands/FailedJobGC.php +++ b/app/Console/Commands/FailedJobGC.php @@ -40,7 +40,7 @@ class FailedJobGC extends Command { FailedJob::chunk(50, function($jobs) { foreach($jobs as $job) { - if($job->failed_at->lt(now()->subMonth())) { + if($job->failed_at->lt(now()->subHours(48))) { $job->delete(); } } 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/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/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 0bf49da89..8c9d7e218 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -11,6 +11,7 @@ use App\{ Profile, Report, Status, + Story, User }; use DB, Cache; @@ -27,6 +28,7 @@ use App\Http\Controllers\Admin\{ }; use Illuminate\Validation\Rule; use App\Services\AdminStatsService; +use App\Services\StoryService; class AdminController extends Controller { @@ -465,4 +467,11 @@ class AdminController extends Controller return response()->json($res); } + + public function stories(Request $request) + { + $stories = Story::with('profile')->latest()->paginate(10); + $stats = StoryService::adminStats(); + return view('admin.stories.home', compact('stories', 'stats')); + } } diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 5c9f95442..b2ce2f85a 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(); @@ -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) { @@ -1140,7 +1154,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(); @@ -1302,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..c700f2434 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, @@ -36,9 +37,11 @@ use App\Jobs\VideoPipeline\{ VideoPostProcess, VideoThumbnail }; +use App\Services\AccountService; use App\Services\NotificationService; use App\Services\MediaPathService; use App\Services\MediaBlocklistService; +use App\Services\StatusService; class BaseApiController extends Controller { @@ -54,26 +57,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); @@ -272,4 +289,74 @@ 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); + AccountService::syncPostCount($status->profile_id); + + 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(); + StatusService::del($status->id); + AccountService::syncPostCount($status->profile_id); + + 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/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index b20afab99..700bae2d9 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -15,8 +15,10 @@ use App\{ Profile, Place, Status, - UserFilter + UserFilter, + UserSetting }; +use App\Models\Poll; use App\Transformer\Api\{ MediaTransformer, MediaDraftTransformer, @@ -41,7 +43,7 @@ use App\Services\MediaPathService; use App\Services\MediaBlocklistService; use App\Services\MediaStorageService; use App\Services\MediaTagService; -use App\Services\ServiceService; +use App\Services\StatusService; use Illuminate\Support\Str; use App\Util\Lexer\Autolink; use App\Util\Lexer\Extractor; @@ -403,7 +405,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', @@ -661,4 +663,73 @@ class ComposeController extends Controller 'finished' => $finished ]; } + + 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 array_merge($default, Cache::remember('profile:compose:settings:' . $uid, now()->addHours(12), function() use($uid) { + $res = UserSetting::whereUserId($uid)->first(); + + if(!$res || empty($res->compose_settings)) { + return []; + } + + return json_decode($res->compose_settings, true); + })); + } + + public function createPoll(Request $request) + { + $this->validate($request, [ + 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500), + 'cw' => 'nullable|boolean', + 'visibility' => 'required|string|in:public,private', + 'comments_disabled' => 'nullable', + 'expiry' => 'required|in:60,360,1440,10080', + 'pollOptions' => 'required|array|min:1|max:4' + ]); + + abort_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled'); + + abort_if(Status::whereType('poll') + ->whereProfileId($request->user()->profile_id) + ->whereCaption($request->input('caption')) + ->where('created_at', '>', now()->subDays(2)) + ->exists() + , 422, 'Duplicate detected.'); + + $status = new Status; + $status->profile_id = $request->user()->profile_id; + $status->caption = $request->input('caption'); + $status->rendered = Autolink::create()->autolink($status->caption); + $status->visibility = 'draft'; + $status->scope = 'draft'; + $status->type = 'poll'; + $status->local = true; + $status->save(); + + $poll = new Poll; + $poll->status_id = $status->id; + $poll->profile_id = $status->profile_id; + $poll->poll_options = $request->input('pollOptions'); + $poll->expires_at = now()->addMinutes($request->input('expiry')); + $poll->cached_tallies = collect($poll->poll_options)->map(function($o) { + return 0; + })->toArray(); + $poll->save(); + + $status->visibility = $request->input('visibility'); + $status->scope = $request->input('visibility'); + $status->save(); + + NewStatusPipeline::dispatch($status); + + return ['url' => $status->url()]; + } } 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 { diff --git a/app/Http/Controllers/PollController.php b/app/Http/Controllers/PollController.php new file mode 100644 index 000000000..13d1f4518 --- /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/ProfileController.php b/app/Http/Controllers/ProfileController.php index 74e84f63e..a3331c8d0 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -13,6 +13,7 @@ use App\Story; use App\User; use App\UserFilter; use League\Fractal; +use App\Services\FollowerService; use App\Util\Lexer\Nickname; use App\Util\Webfinger\Webfinger; use App\Transformer\ActivityPub\ProfileOutbox; @@ -238,12 +239,12 @@ class ProfileController extends Controller abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); $profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail(); $pid = $profile->id; - $authed = Auth::user()->profile; - abort_if($pid != $authed->id && $profile->followedBy($authed) == false, 404); + $authed = Auth::user()->profile_id; + abort_if($pid != $authed && !FollowerService::follows($authed, $pid), 404); $exists = Story::whereProfileId($pid) - ->where('expires_at', '>', now()) - ->count(); - abort_unless($exists > 0, 404); + ->whereActive(true) + ->exists(); + abort_unless($exists, 404); return view('profile.story', compact('pid', 'profile')); } } diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index fd8dae9ce..bd96e774e 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 @@ -92,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); } @@ -402,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 ? '>' : '<'; @@ -432,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') @@ -591,17 +598,27 @@ 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 []; } - $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); } @@ -612,7 +629,6 @@ class PublicApiController extends Controller $profile = Profile::with('user') ->whereNull('status') - ->whereNull('domain') ->findOrFail($id); // filter by username @@ -621,7 +637,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 []; @@ -656,28 +675,27 @@ class PublicApiController extends Controller 'limit' => 'nullable|integer|min:1|max:24' ]); + $user = $request->user(); $profile = Profile::whereNull('status')->findOrFail($id); $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']; + $scope = ['photo', 'photo:album', 'video', 'video:album']; 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 +706,42 @@ 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(); + 'created_at' + ) + ->whereProfileId($profile->id) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->whereIn('type', $scope) + ->where('id', $dir, $id) + ->whereIn('scope', $visibility) + ->limit($limit) + ->orderByDesc('id') + ->get() + ->map(function($s) use($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(); - $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 response()->json($res); } } diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index 65dfca5d0..bb7677373 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; @@ -21,6 +22,7 @@ use App\Http\Controllers\Settings\{ SecuritySettings }; use App\Jobs\DeletePipeline\DeleteAccountPipeline; +use App\Jobs\MediaPipeline\MediaSyncLicensePipeline; class SettingsController extends Controller { @@ -221,7 +223,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 +251,69 @@ 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'; + $uid = $request->user()->id; + + $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; + } + + 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); + } + + 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/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'); + } } 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); + } + } } 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/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/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/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); + } +} 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')); } } 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); diff --git a/app/Models/Poll.php b/app/Models/Poll.php new file mode 100644 index 000000000..f7398401b --- /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 @@ +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/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 d4532fcb1..3d1bea069 100644 --- a/app/Profile.php +++ b/app/Profile.php @@ -2,333 +2,326 @@ namespace App; -use Auth, Cache, Storage; +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; - - protected $dates = [ - 'deleted_at', - 'last_fetched_at' - ]; - protected $hidden = ['private_key']; - protected $visible = ['id', 'user_id', 'username', 'name']; - protected $fillable = ['user_id']; + /** + * Indicates if the IDs are auto-incrementing. + * + * @var bool + */ + public $incrementing = false; - public function user() - { - return $this->belongsTo(User::class); - } + protected $dates = [ + 'deleted_at', + 'last_fetched_at' + ]; + protected $hidden = ['private_key']; + protected $visible = ['id', 'user_id', 'username', 'name']; + protected $fillable = ['user_id']; - public function url($suffix = null) - { - return $this->remote_url ?? url($this->username . $suffix); - } + public function user() + { + return $this->belongsTo(User::class); + } - public function localUrl($suffix = null) - { - return url($this->username . $suffix); - } + public function url($suffix = null) + { + return $this->remote_url ?? url($this->username . $suffix); + } - public function permalink($suffix = null) - { - return $this->remote_url ?? url('users/' . $this->username . $suffix); - } + public function localUrl($suffix = null) + { + return url($this->username . $suffix); + } - public function emailUrl() - { - if($this->domain) { - return $this->username; - } - - $domain = parse_url(config('app.url'), PHP_URL_HOST); + public function permalink($suffix = null) + { + return $this->remote_url ?? url('users/' . $this->username . $suffix); + } - return $this->username.'@'.$domain; - } + public function emailUrl() + { + if($this->domain) { + return $this->username; + } - public function statuses() - { - return $this->hasMany(Status::class); - } + $domain = parse_url(config('app.url'), PHP_URL_HOST); - 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 = $this->following()->count(); - if($this->following_count != $count) { - $this->following_count = $count; - $this->save(); - } - return $count; - }); + return $this->username.'@'.$domain; + } - return $short ? PrettyNumber::convert($count) : $count; - } + public function statuses() + { + return $this->hasMany(Status::class); + } - 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 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 statusCount() - { - return $this->status_count; - } + return $short ? PrettyNumber::convert($count) : $count; + } - public function following() - { - return $this->belongsToMany( - self::class, - 'followers', - 'profile_id', - 'following_id' - ); - } + 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 followers() - { - return $this->belongsToMany( - self::class, - 'followers', - 'following_id', - 'profile_id' - ); - } + public function statusCount() + { + return $this->status_count; + } - public function follows($profile) : bool - { - return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists(); - } + public function following() + { + return $this->belongsToMany( + self::class, + 'followers', + 'profile_id', + 'following_id' + ); + } - public function followedBy($profile) : bool - { - return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists(); - } + public function followers() + { + return $this->belongsToMany( + self::class, + 'followers', + 'following_id', + 'profile_id' + ); + } - public function bookmarks() - { - return $this->belongsToMany( - Status::class, - 'bookmarks', - 'profile_id', - 'status_id' - ); - } + public function follows($profile) : bool + { + return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists(); + } - public function likes() - { - return $this->hasMany(Like::class); - } + public function followedBy($profile) : bool + { + return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists(); + } - public function avatar() - { - return $this->hasOne(Avatar::class)->withDefault([ - 'media_path' => 'public/avatars/default.jpg', - 'change_count' => 0 - ]); - } + public function bookmarks() + { + return $this->belongsToMany( + Status::class, + 'bookmarks', + 'profile_id', + 'status_id' + ); + } - public function avatarUrl() - { - $url = Cache::remember('avatar:'.$this->id, now()->addYears(1), function () { - $avatar = $this->avatar; + public function likes() + { + return $this->hasMany(Like::class); + } - if($avatar->cdn_url) { - return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); - } + public function avatar() + { + return $this->hasOne(Avatar::class)->withDefault([ + 'media_path' => 'public/avatars/default.jpg', + 'change_count' => 0 + ]); + } - if($avatar->is_remote) { - return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); - } - - $path = $avatar->media_path; - $path = "{$path}?v={$avatar->change_count}"; + public function avatarUrl() + { + $url = Cache::remember('avatar:'.$this->id, now()->addYears(1), function () { + $avatar = $this->avatar; - return config('app.url') . Storage::url($path); - }); + if($avatar->cdn_url) { + return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); + } - return $url; - } + if($avatar->is_remote) { + return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); + } + + $path = $avatar->media_path; + $path = "{$path}?v={$avatar->change_count}"; - // deprecated - public function recommendFollowers() - { - return collect([]); - } + return config('app.url') . Storage::url($path); + }); - public function keyId() - { - if ($this->remote_url) { - return; - } + return $url; + } - return $this->permalink('#main-key'); - } + // deprecated + public function recommendFollowers() + { + return collect([]); + } - public function mutedIds() - { - return UserFilter::whereUserId($this->id) - ->whereFilterableType('App\Profile') - ->whereFilterType('mute') - ->pluck('filterable_id'); - } + public function keyId() + { + if ($this->remote_url) { + return; + } - public function blockedIds() - { - return UserFilter::whereUserId($this->id) - ->whereFilterableType('App\Profile') - ->whereFilterType('block') - ->pluck('filterable_id'); - } + return $this->permalink('#main-key'); + } - public function mutedProfileUrls() - { - $ids = $this->mutedIds(); - return $this->whereIn('id', $ids)->get()->map(function($i) { - return $i->url(); - }); - } + public function mutedIds() + { + return UserFilter::whereUserId($this->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('mute') + ->pluck('filterable_id'); + } - public function blockedProfileUrls() - { - $ids = $this->blockedIds(); - return $this->whereIn('id', $ids)->get()->map(function($i) { - return $i->url(); - }); - } + public function blockedIds() + { + return UserFilter::whereUserId($this->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('block') + ->pluck('filterable_id'); + } - public function reports() - { - return $this->hasMany(Report::class, 'profile_id'); - } + public function mutedProfileUrls() + { + $ids = $this->mutedIds(); + return $this->whereIn('id', $ids)->get()->map(function($i) { + return $i->url(); + }); + } - public function media() - { - return $this->hasMany(Media::class, 'profile_id'); - } + public function blockedProfileUrls() + { + $ids = $this->blockedIds(); + return $this->whereIn('id', $ids)->get()->map(function($i) { + return $i->url(); + }); + } - public function inboxUrl() - { - return $this->inbox_url ?? $this->permalink('/inbox'); - } + public function reports() + { + return $this->hasMany(Report::class, 'profile_id'); + } - public function outboxUrl() - { - return $this->outbox_url ?? $this->permalink('/outbox'); - } + public function media() + { + return $this->hasMany(Media::class, 'profile_id'); + } - public function sharedInbox() - { - return $this->sharedInbox ?? $this->inboxUrl(); - } + public function inboxUrl() + { + return $this->inbox_url ?? $this->permalink('/inbox'); + } - public function getDefaultScope() - { - return $this->is_private == true ? 'private' : 'public'; - } + public function outboxUrl() + { + return $this->outbox_url ?? $this->permalink('/outbox'); + } - 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 sharedInbox() + { + return $this->sharedInbox ?? $this->inboxUrl(); + } - public function getAudienceInbox($scope = 'public') - { - return $this - ->followers() - ->whereLocalProfile(false) - ->get() - ->map(function($follow) { - return $follow->sharedInbox ?? $follow->inbox_url; - }) - ->unique() - ->toArray(); - } + public function getDefaultScope() + { + return $this->is_private == true ? 'private' : 'public'; + } - public function circles() - { - return $this->hasMany(Circle::class); - } + 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 hashtags() - { - return $this->hasManyThrough( - Hashtag::class, - StatusHashtag::class, - 'profile_id', - 'id', - 'id', - 'hashtag_id' - ); - } + public function getAudienceInbox($scope = 'public') + { + return FollowerService::audience($this->id, $scope); + } - public function hashtagFollowing() - { - return $this->hasMany(HashtagFollow::class); - } + public function circles() + { + return $this->hasMany(Circle::class); + } - public function collections() - { - return $this->hasMany(Collection::class); - } + public function hashtags() + { + return $this->hasManyThrough( + Hashtag::class, + StatusHashtag::class, + 'profile_id', + 'id', + 'id', + 'hashtag_id' + ); + } - public function hasFollowRequestById(int $id) - { - return FollowRequest::whereFollowerId($id) - ->whereFollowingId($this->id) - ->exists(); - } + public function hashtagFollowing() + { + return $this->hasMany(HashtagFollow::class); + } - public function stories() - { - return $this->hasMany(Story::class); - } + public function collections() + { + return $this->hasMany(Collection::class); + } + + public function hasFollowRequestById(int $id) + { + return FollowRequest::whereFollowerId($id) + ->whereFollowingId($this->id) + ->exists(); + } + + 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/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; }); diff --git a/app/Services/AccountService.php b/app/Services/AccountService.php index 38e29169d..955e168b7 100644 --- a/app/Services/AccountService.php +++ b/app/Services/AccountService.php @@ -4,12 +4,13 @@ namespace App\Services; use Cache; use App\Profile; +use App\Status; 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 +20,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 +36,30 @@ class AccountService { return Cache::forget(self::CACHE_KEY . $id); } -} \ No newline at end of file + 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/FollowerService.php b/app/Services/FollowerService.php index 68ecb118e..eeede53ec 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,46 +44,34 @@ 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); + return (new self)->getAudienceInboxes($profile, $scope); } - protected function getAudienceInboxes($profile) + public static function softwareAudience($profile, $software = 'pixelfed') { - if($profile instanceOf User) { - return $profile - ->profile - ->followers() - ->whereLocalProfile(false) - ->get() - ->map(function($follow) { - return $follow->sharedInbox ?? $follow->inbox_url; - }) - ->unique() - ->values() - ->toArray(); - } + return collect(self::audience($profile)) + ->filter(function($inbox) use($software) { + $domain = parse_url($inbox, PHP_URL_HOST); + if(!$domain) { + return false; + } + return InstanceService::software($domain) === strtolower($software); + }) + ->unique() + ->values() + ->toArray(); + } - if($profile instanceOf Profile) { - return $profile - ->followers() - ->whereLocalProfile(false) - ->get() - ->map(function($follow) { - return $follow->sharedInbox ?? $follow->inbox_url; - }) - ->unique() - ->values() - ->toArray(); - } - - if(is_string($profile) || is_integer($profile)) { - $profile = Profile::whereNull('domain')->find($profile); + protected function getAudienceInboxes($pid, $scope = null) + { + $key = 'pf:services:follow:audience:' . $pid; + return Cache::remember($key, 86400, function() use($pid) { + $profile = Profile::find($pid); if(!$profile) { return []; } - return $profile ->followers() ->whereLocalProfile(false) @@ -92,9 +82,7 @@ class FollowerService ->unique() ->values() ->toArray(); - } - - return []; + }); } } diff --git a/app/Services/InstanceService.php b/app/Services/InstanceService.php index 8b504d77f..48117d159 100644 --- a/app/Services/InstanceService.php +++ b/app/Services/InstanceService.php @@ -27,4 +27,16 @@ class InstanceService return Instance::whereAutoCw(true)->pluck('domain')->toArray(); }); } + + public static function software($domain) + { + $key = 'instances:software:' . strtolower($domain); + return Cache::remember($key, 86400, function() use($domain) { + $instance = Instance::whereDomain($domain)->first(); + if(!$instance) { + return; + } + return $instance->software; + }); + } } diff --git a/app/Services/LikeService.php b/app/Services/LikeService.php index 8b2ef467c..de2ca1895 100644 --- a/app/Services/LikeService.php +++ b/app/Services/LikeService.php @@ -80,4 +80,9 @@ class LikeService { return $res; } + + public static function count($id) + { + return Like::whereStatusId($id)->count(); + } } diff --git a/app/Services/MediaPathService.php b/app/Services/MediaPathService.php index a794ce621..09727f578 100644 --- a/app/Services/MediaPathService.php +++ b/app/Services/MediaPathService.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use App\Media; use App\Profile; use App\User; +use App\Services\HashidService; class MediaPathService { @@ -51,27 +52,27 @@ class MediaPathService { public static function story($account, $version = 1) { $mh = hash('sha256', date('Y').'-.-'.date('m')); - $monthHash = date('Y').date('m').substr($mh, 0, 6).substr($mh, 58, 6); - $random = '03'.Str::random(random_int(6,9)).'_'.Str::random(random_int(6,17)); + $monthHash = HashidService::encode(date('Y').date('m')); + $random = date('d').Str::random(32); if($account instanceOf User) { switch ($version) { case 1: - $userHash = $account->profile_id; + $userHash = HashidService::encode($account->profile_id); $path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}"; break; default: - $userHash = $account->profile_id; + $userHash = HashidService::encode($account->profile_id); $path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}"; break; } } if($account instanceOf Profile) { - $userHash = $account->id; + $userHash = HashidService::encode($account->id); $path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}"; } return $path; } -} \ No newline at end of file +} diff --git a/app/Services/MediaService.php b/app/Services/MediaService.php new file mode 100644 index 000000000..f6cbd87f4 --- /dev/null +++ b/app/Services/MediaService.php @@ -0,0 +1,62 @@ +created_at->lt(now()->subMinutes(30)) ? 129600 : 30; + return Cache::remember(self::CACHE_KEY.$statusId, $ttl, function() use($status) { + if(!$status) { + return []; + } + if(in_array($status->type, ['group:post', '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); + } + + public static function activitypub($statusId) + { + $status = self::get($statusId); + if(!$status) { + return []; + } + + return collect($status)->map(function($s) { + $license = isset($s['license']) && $s['license']['title'] ? $s['license']['title'] : null; + return [ + 'type' => 'Document', + 'mediaType' => $s['mime'], + 'url' => $s['url'], + 'name' => $s['description'], + 'blurhash' => $s['blurhash'], + 'license' => $license + ]; + }); + } +} diff --git a/app/Services/MediaStorageService.php b/app/Services/MediaStorageService.php index 2c44c4ca3..4baa7cdf8 100644 --- a/app/Services/MediaStorageService.php +++ b/app/Services/MediaStorageService.php @@ -70,7 +70,7 @@ class MediaStorageService { protected function localToCloud($media) { $path = storage_path('app/'.$media->media_path); - $thumb = storage_path('app/'.$media->thumbnail_path); + $thumb = storage_path('app/'.$media->thumbnail_path); $p = explode('/', $media->media_path); $name = array_pop($p); 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(); + } +} diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 3de1bf650..6eaca0301 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -46,7 +46,7 @@ class NotificationService { return $ids; } - public static function getMax($id = false, $start, $limit = 10) + public static function getMax($id = false, $start = 0, $limit = 10) { $ids = self::getRankedMaxId($id, $start, $limit); @@ -61,7 +61,7 @@ class NotificationService { return $res->toArray(); } - public static function getMin($id = false, $start, $limit = 10) + public static function getMin($id = false, $start = 0, $limit = 10) { $ids = self::getRankedMinId($id, $start, $limit); diff --git a/app/Services/PollService.php b/app/Services/PollService.php new file mode 100644 index 000000000..8c30af7dc --- /dev/null +++ b/app/Services/PollService.php @@ -0,0 +1,97 @@ +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 votedStory($id, $profileId = false) + { + return !$profileId ? false : PollVote::whereStoryId($id) + ->whereProfileId($profileId) + ->exists(); + } + + public static function storyResults($sid) + { + $key = self::CACHE_KEY . 'story_poll_results:' . $sid; + return Cache::remember($key, 60, function() use($sid) { + return Poll::whereStoryId($sid) + ->firstOrFail() + ->cached_tallies; + }); + } + + public static function storyChoice($id, $profileId = false) + { + return !$profileId ? false : PollVote::whereStoryId($id) + ->whereProfileId($profileId) + ->pluck('choice') + ->first(); + } + + public static function ownVotes($id, $profileId = false) + { + return !$profileId ? [] : PollVote::whereStatusId($id) + ->whereProfileId($profileId) + ->pluck('choice') ?? []; + } +} 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); + } } diff --git a/app/Services/SnowflakeService.php b/app/Services/SnowflakeService.php index b04c56b40..f28dc6efb 100644 --- a/app/Services/SnowflakeService.php +++ b/app/Services/SnowflakeService.php @@ -3,16 +3,44 @@ 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); + if($ts instanceOf Carbon) { + $ts = now()->parse($ts)->timestamp; + } else { + return self::next(); + } + return ((round($ts * 1000) - 1549756800000) << 22) - | (1 << 17) - | (1 << 12) + | (random_int(1,31) << 17) + | (random_int(1,31) << 12) | 0; } -} \ No newline at end of file + public static function next() + { + $seq = Cache::get('snowflake:seq'); + + if(!$seq) { + Cache::put('snowflake:seq', 1); + $seq = 1; + } else { + Cache::increment('snowflake:seq'); + } + + if($seq >= 4095) { + Cache::put('snowflake:seq', 0); + $seq = 0; + } + + return ((round(microtime(true) * 1000) - 1549756800000) << 22) + | (random_int(1,31) << 17) + | (random_int(1,31) << 12) + | $seq; + } + +} 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/Services/StatusService.php b/app/Services/StatusService.php index 8807e37b1..10fa762ff 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -16,15 +16,20 @@ class StatusService { const CACHE_KEY = 'pf:services:status:'; - public static function key($id) + public static function key($id, $publicOnly = true) { - return self::CACHE_KEY . $id; + $p = $publicOnly ? '' : 'all:'; + return self::CACHE_KEY . $p . $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, $publicOnly), now()->addDays(7), function() use($id, $publicOnly) { + if($publicOnly) { + $status = Status::whereScope('public')->find($id); + } else { + $status = Status::whereIn('scope', ['public', 'private', 'unlisted', 'group'])->find($id); + } if(!$status) { return null; } @@ -37,7 +42,17 @@ 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:transformer:media:attachments:' . $id); + MediaService::del($id); + Cache::forget('status:thumb:nsfw0' . $id); + Cache::forget('status:thumb:nsfw1' . $id); + Cache::forget('pf:services:sh:id:' . $id); PublicTimelineService::rem($id); + Cache::forget(self::key($id, false)); return Cache::forget(self::key($id)); } } diff --git a/app/Services/StoryService.php b/app/Services/StoryService.php new file mode 100644 index 000000000..f44828899 --- /dev/null +++ b/app/Services/StoryService.php @@ -0,0 +1,162 @@ + [ + 'id' => (string) $account['id'], + 'avatar' => $account['avatar'], + 'username' => $account['username'], + 'url' => $account['url'] + ] + ]; + + $res['stories'] = self::getStories($id); + 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) + ->latest() + ->get() + ->map(function($s) use($pid) { + return [ + 'id' => (string) $s->id, + 'type' => $s->type, + 'duration' => 10, + 'seen' => in_array($pid, self::views($s->id)), + 'created_at' => $s->created_at->toAtomString(), + 'expires_at' => $s->expires_at->toAtomString(), + 'media' => url(Storage::url($s->path)), + 'can_reply' => (bool) $s->can_reply, + 'can_react' => (bool) $s->can_react, + 'poll' => $s->type == 'poll' ? PollService::storyPoll($s->id) : null + ]; + }) + ->toArray(); + } + + public static function views($id) + { + return StoryView::whereStoryId($id) + ->pluck('profile_id') + ->toArray(); + } + + public static function hasSeen($pid, $sid) + { + $key = self::STORY_KEY . 'seen:' . $pid . ':' . $sid; + return Cache::remember($key, 3600, function() use($pid, $sid) { + return StoryView::whereStoryId($sid) + ->whereProfileId($pid) + ->exists(); + }); + } + + public static function latest($pid) + { + return Cache::remember(self::STORY_KEY . 'latest:pid-' . $pid, 3600, function() use ($pid) { + return Story::whereProfileId($pid) + ->latest() + ->first() + ->id; + }); + } + + public static function delLatest($pid) + { + return Cache::forget(self::STORY_KEY . 'latest:pid-' . $pid); + } + + public static function addSeen($pid, $sid) + { + return Cache::put(self::STORY_KEY . 'seen:' . $pid . ':' . $sid, true, 86400); + } + + public static function adminStats() + { + return Cache::remember('pf:admin:stories:stats', 300, function() { + $total = Story::count(); + return [ + 'active' => [ + 'today' => Story::whereDate('created_at', now()->today())->count(), + 'month' => Story::whereMonth('created_at', now()->month)->whereYear('created_at', now()->year)->count() + ], + 'total' => $total, + 'remote' => [ + 'today' => Story::whereLocal(false)->whereDate('created_at', now()->today())->count(), + 'month' => Story::whereLocal(false)->whereMonth('created_at', now()->month)->whereYear('created_at', now()->year)->count() + ], + 'storage' => [ + 'sum' => (int) Story::sum('size'), + 'average' => (int) Story::avg('size') + ], + 'avg_spu' => (int) ($total / Story::groupBy('profile_id')->pluck('profile_id')->count()), + 'avg_duration' => (int) floor(Story::avg('duration')), + 'avg_type' => Story::selectRaw('type, count(id) as count')->groupBy('type')->orderByDesc('count')->first()->type + ]; + }); + } + + 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; + } +} diff --git a/app/Status.php b/app/Status.php index d9adae0ac..35c5820bf 100644 --- a/app/Status.php +++ b/app/Status.php @@ -4,414 +4,419 @@ 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; 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 = 2; - 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); + } + public function poll() + { + return $this->hasOne(Poll::class); + } } diff --git a/app/Story.php b/app/Story.php index 700d1659e..dfd95502d 100644 --- a/app/Story.php +++ b/app/Story.php @@ -3,8 +3,10 @@ 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 { @@ -19,14 +21,11 @@ class Story extends Model */ public $incrementing = false; - /** - * The attributes that should be mutated to dates. - * - * @var array - */ - protected $dates = ['published_at', 'expires_at']; + protected $casts = [ + 'expires_at' => 'datetime' + ]; - protected $fillable = ['profile_id']; + protected $fillable = ['profile_id', 'view_count']; protected $visible = ['id']; @@ -51,6 +50,42 @@ class Story extends Model public function permalink() { - return url("/story/$this->id"); + $username = $this->profile->username; + return url("/stories/{$username}/{$this->id}/activity"); + } + + public function url() + { + $username = $this->profile->username; + return url("/stories/{$username}/{$this->id}"); + } + + public function mediaUrl() + { + return url(Storage::url($this->path)); + } + + public function bearcapUrl() + { + return Bearcap::encode($this->url(), $this->bearcap_token); + } + + public function scopeToAudience($scope) + { + $res = []; + + switch ($scope) { + case 'to': + $res = [ + $this->profile->permalink('/followers') + ]; + break; + + default: + $res = []; + break; + } + + return $res; } } 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/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/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/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/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, + ], + ]; + } +} diff --git a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php index deadd5859..6b4177384 100644 --- a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php +++ b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php @@ -5,81 +5,47 @@ 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', - ]; - - 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 includeAccount(Status $status) - { - $account = $status->profile; - - 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 + 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), + ]; + } +} 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; diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index b3ba463da..5dbca96b2 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -7,21 +7,19 @@ 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 App\Services\PollService; class StatusStatelessTransformer extends Fractal\TransformerAbstract { - protected $defaultIncludes = [ - 'account', - 'tags', - 'media_attachments', - ]; - public function transform(Status $status) { $taggedPeople = MediaTagService::get($status->id); + $poll = $status->type === 'poll' ? PollService::get($status->id) : null; return [ '_v' => 1, @@ -29,8 +27,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'shortcode' => HashidService::encode($status->id), 'uri' => $status->url(), 'url' => $status->url(), - 'in_reply_to_id' => $status->in_reply_to_id, - 'in_reply_to_account_id' => $status->in_reply_to_profile_id, + 'in_reply_to_id' => (string) $status->in_reply_to_id, + 'in_reply_to_account_id' => (string) $status->in_reply_to_profile_id, 'reblog' => null, 'content' => $status->rendered ?? $status->caption, 'content_text' => $status->caption, @@ -43,7 +41,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 @@ -62,31 +60,11 @@ 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), + 'poll' => $poll ]; } - - 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..1aca5398d 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -8,22 +8,20 @@ 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; +use App\Services\PollService; class StatusTransformer extends Fractal\TransformerAbstract { - protected $defaultIncludes = [ - 'account', - 'tags', - 'media_attachments', - ]; - 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, @@ -45,7 +43,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 @@ -64,31 +62,11 @@ 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), + 'poll' => $poll, ]; } - - 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()); - } - }); - } } diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index f2e1adec5..907097f05 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -32,6 +32,8 @@ use App\Services\MediaPathService; 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 { @@ -269,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; } @@ -330,7 +332,6 @@ class Helpers { $idDomain = parse_url($id, PHP_URL_HOST); $urlDomain = parse_url($url, PHP_URL_HOST); - if(!self::validateUrl($id)) { return; } @@ -367,6 +368,7 @@ class Helpers { $cw = true; } + $statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']); $status = Cache::lock($statusLockKey) ->get(function () use( @@ -379,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; @@ -408,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); @@ -428,6 +492,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 +506,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(); @@ -495,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 18f911bfd..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; @@ -30,6 +34,9 @@ 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; +use App\Services\FollowerService; + class Inbox { protected $headers; @@ -47,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() @@ -105,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; @@ -136,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() @@ -147,6 +181,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 +194,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 +221,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 +241,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 +263,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']; @@ -420,7 +528,6 @@ class Inbox public function handleAcceptActivity() { - $actor = $this->payload['object']['actor']; $obj = $this->payload['object']['object']; $type = $this->payload['object']['type']; @@ -480,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; } @@ -520,6 +627,13 @@ class Inbox return; break; + case 'Story': + $story = Story::whereObjectId($id) + ->first(); + if($story) { + StoryExpire::dispatch($story)->onQueue('story'); + } + default: return; break; @@ -558,10 +672,8 @@ class Inbox return; } - public function handleRejectActivity() { - } public function handleUndoActivity() @@ -631,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(); + } } 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/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/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']; + } } 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/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, + ], + ], ]; 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/config/horizon.php b/config/horizon.php index 786eb6741..e43ff35b6 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -81,6 +81,7 @@ return [ 'waits' => [ 'redis:feed' => 30, 'redis:default' => 30, + 'redis:low' => 30, 'redis:high' => 30, 'redis:delete' => 30 ], @@ -166,7 +167,7 @@ return [ 'production' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['high', 'default', 'feed', 'delete'], + 'queue' => ['high', 'default', 'feed', 'low', 'story', 'delete'], 'balance' => 'auto', 'maxProcesses' => 20, 'memory' => 128, @@ -178,7 +179,7 @@ return [ 'local' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['high', 'default', 'feed', 'delete'], + 'queue' => ['high', 'default', 'feed', 'low', 'story', 'delete'], 'balance' => 'auto', 'maxProcesses' => 20, 'memory' => 128, 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 ], diff --git a/config/instance.php b/config/instance.php index a4cc534b9..f0b99e60e 100644 --- a/config/instance.php +++ b/config/instance.php @@ -47,6 +47,10 @@ return [ ] ], + 'polls' => [ + 'enabled' => false + ], + 'stories' => [ 'enabled' => env('STORIES_ENABLED', false), ], 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/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/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..0ae7a58ad --- /dev/null +++ b/database/migrations/2021_07_29_014835_create_polls_table.php @@ -0,0 +1,42 @@ +bigInteger('id')->unsigned()->primary(); + $table->bigInteger('story_id')->unsigned()->nullable()->index(); + $table->bigInteger('status_id')->unsigned()->nullable()->index(); + $table->bigInteger('group_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..ac7316f1b --- /dev/null +++ b/database/migrations/2021_07_29_014849_create_poll_votes_table.php @@ -0,0 +1,37 @@ +id(); + $table->bigInteger('story_id')->unsigned()->nullable()->index(); + $table->bigInteger('status_id')->unsigned()->nullable()->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/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 new file mode 100644 index 000000000..61ae60c01 --- /dev/null +++ b/database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php @@ -0,0 +1,54 @@ +getDoctrineSchemaManager(); + $doctrineTable = $sm->listTableDetails('stories'); + + if($doctrineTable->hasIndex('stories_expires_at_index')) { + $table->dropIndex('stories_expires_at_index'); + } + $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(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('stories', function (Blueprint $table) { + $sm = Schema::getConnection()->getDoctrineSchemaManager(); + $doctrineTable = $sm->listTableDetails('stories'); + + if($doctrineTable->hasIndex('stories_expires_at_index')) { + $table->dropIndex('stories_expires_at_index'); + } + $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(); + } +} diff --git a/public/css/app.css b/public/css/app.css index a56803d3f..5de7f6da3 100644 Binary files a/public/css/app.css and b/public/css/app.css differ diff --git a/public/css/appdark.css b/public/css/appdark.css index 09a275aee..d51b86da6 100644 Binary files a/public/css/appdark.css and b/public/css/appdark.css differ diff --git a/public/css/landing.css b/public/css/landing.css index 7d6b091c5..02e9fa29c 100644 Binary files a/public/css/landing.css and b/public/css/landing.css differ diff --git a/public/fonts/fa-light-300.eot b/public/fonts/fa-light-300.eot new file mode 100644 index 000000000..fd2cfc79a Binary files /dev/null and b/public/fonts/fa-light-300.eot differ diff --git a/public/fonts/fa-light-300.svg b/public/fonts/fa-light-300.svg new file mode 100644 index 000000000..666198f45 Binary files /dev/null and b/public/fonts/fa-light-300.svg differ diff --git a/public/fonts/fa-light-300.ttf b/public/fonts/fa-light-300.ttf new file mode 100644 index 000000000..ea1f36c79 Binary files /dev/null and b/public/fonts/fa-light-300.ttf differ diff --git a/public/fonts/fa-light-300.woff b/public/fonts/fa-light-300.woff new file mode 100644 index 000000000..da6981a74 Binary files /dev/null and b/public/fonts/fa-light-300.woff differ diff --git a/public/fonts/fa-light-300.woff2 b/public/fonts/fa-light-300.woff2 new file mode 100644 index 000000000..fbdeaaa98 Binary files /dev/null and b/public/fonts/fa-light-300.woff2 differ diff --git a/public/fonts/fa-regular-400.eot b/public/fonts/fa-regular-400.eot index 55085ca95..c0bf92e98 100644 Binary files a/public/fonts/fa-regular-400.eot and b/public/fonts/fa-regular-400.eot differ diff --git a/public/fonts/fa-regular-400.svg b/public/fonts/fa-regular-400.svg index bba54466b..71b2f7c44 100644 Binary files a/public/fonts/fa-regular-400.svg and b/public/fonts/fa-regular-400.svg differ diff --git a/public/fonts/fa-regular-400.ttf b/public/fonts/fa-regular-400.ttf index a309313d5..2d03c198b 100644 Binary files a/public/fonts/fa-regular-400.ttf and b/public/fonts/fa-regular-400.ttf differ diff --git a/public/fonts/fa-regular-400.woff b/public/fonts/fa-regular-400.woff index 257826189..012f42c6b 100644 Binary files a/public/fonts/fa-regular-400.woff and b/public/fonts/fa-regular-400.woff differ diff --git a/public/fonts/fa-regular-400.woff2 b/public/fonts/fa-regular-400.woff2 index 3ef9c3edb..70fc754f4 100644 Binary files a/public/fonts/fa-regular-400.woff2 and b/public/fonts/fa-regular-400.woff2 differ diff --git a/public/fonts/fa-solid-900.eot b/public/fonts/fa-solid-900.eot index 68c010a86..ddbd2a570 100644 Binary files a/public/fonts/fa-solid-900.eot and b/public/fonts/fa-solid-900.eot differ diff --git a/public/fonts/fa-solid-900.svg b/public/fonts/fa-solid-900.svg index 4ef85aa37..b068060b4 100644 Binary files a/public/fonts/fa-solid-900.svg and b/public/fonts/fa-solid-900.svg differ diff --git a/public/fonts/fa-solid-900.ttf b/public/fonts/fa-solid-900.ttf index 7ece3282a..e6330e6aa 100644 Binary files a/public/fonts/fa-solid-900.ttf and b/public/fonts/fa-solid-900.ttf differ diff --git a/public/fonts/fa-solid-900.woff b/public/fonts/fa-solid-900.woff index a892a7a9c..45f5cd54c 100644 Binary files a/public/fonts/fa-solid-900.woff and b/public/fonts/fa-solid-900.woff differ diff --git a/public/fonts/fa-solid-900.woff2 b/public/fonts/fa-solid-900.woff2 index 71b07ce02..dff46edd0 100644 Binary files a/public/fonts/fa-solid-900.woff2 and b/public/fonts/fa-solid-900.woff2 differ diff --git a/public/js/activity.js b/public/js/activity.js index 27da599a1..d2e23c0c8 100644 Binary files a/public/js/activity.js and b/public/js/activity.js differ diff --git a/public/js/app.js b/public/js/app.js index ec571a5ef..0a48581c4 100644 Binary files a/public/js/app.js and b/public/js/app.js differ diff --git a/public/js/compose.js b/public/js/compose.js index e176e9f5e..a1f26cad1 100644 Binary files a/public/js/compose.js and b/public/js/compose.js differ diff --git a/public/js/direct.js b/public/js/direct.js index c6499ef86..24472f367 100644 Binary files a/public/js/direct.js and b/public/js/direct.js differ diff --git a/public/js/hashtag.js b/public/js/hashtag.js index de19485cf..c0b77b338 100644 Binary files a/public/js/hashtag.js and b/public/js/hashtag.js differ diff --git a/public/js/memoryprofile.js b/public/js/memoryprofile.js new file mode 100644 index 000000000..ccd4f67f7 Binary files /dev/null and b/public/js/memoryprofile.js differ diff --git a/public/js/mode-dot.js b/public/js/mode-dot.js index 3770f2662..d18f5d9b8 100644 Binary files a/public/js/mode-dot.js and b/public/js/mode-dot.js differ diff --git a/public/js/profile-directory.js b/public/js/profile-directory.js index 78d265d64..ec442eacf 100644 Binary files a/public/js/profile-directory.js and b/public/js/profile-directory.js differ diff --git a/public/js/profile.js b/public/js/profile.js index 5a441adbc..1f6a0d993 100644 Binary files a/public/js/profile.js and b/public/js/profile.js differ diff --git a/public/js/quill.js b/public/js/quill.js index c729e43a2..d18b5786f 100644 Binary files a/public/js/quill.js and b/public/js/quill.js differ diff --git a/public/js/rempos.js b/public/js/rempos.js index 4c2f90a8c..955ba7707 100644 Binary files a/public/js/rempos.js and b/public/js/rempos.js differ diff --git a/public/js/rempro.js b/public/js/rempro.js index 5f13bafab..9a9615761 100644 Binary files a/public/js/rempro.js and b/public/js/rempro.js differ diff --git a/public/js/search.js b/public/js/search.js index 8438a2a63..c796a9bda 100644 Binary files a/public/js/search.js and b/public/js/search.js differ diff --git a/public/js/status.js b/public/js/status.js index c09608834..deb717729 100644 Binary files a/public/js/status.js and b/public/js/status.js differ diff --git a/public/js/stories.js b/public/js/stories.js new file mode 100644 index 000000000..9b3c29dce Binary files /dev/null and b/public/js/stories.js differ diff --git a/public/js/story-compose.js b/public/js/story-compose.js index 84256a502..31fb4870e 100644 Binary files a/public/js/story-compose.js and b/public/js/story-compose.js differ diff --git a/public/js/theme-monokai.js b/public/js/theme-monokai.js index 88f12f95b..767581ba5 100644 Binary files a/public/js/theme-monokai.js and b/public/js/theme-monokai.js differ diff --git a/public/js/timeline.js b/public/js/timeline.js index 23b0783e3..687411b0e 100644 Binary files a/public/js/timeline.js and b/public/js/timeline.js differ diff --git a/public/js/vendor.js b/public/js/vendor.js index f43c3454e..4dbac61c1 100644 Binary files a/public/js/vendor.js and b/public/js/vendor.js differ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index fdf5fbc07..194741aa2 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js index 8471cd6d4..07c7fe8e6 100644 --- a/resources/assets/js/app.js +++ b/resources/assets/js/app.js @@ -103,6 +103,32 @@ window.App.util = { } return Math.floor(seconds) + "s"; }), + timeAhead: (function(ts) { + let date = Date.parse(ts); + let diff = date - Date.parse(new Date()); + let seconds = Math.floor((diff) / 1000); + let interval = Math.floor(seconds / 63072000); + if (interval >= 1) { + return interval + "y"; + } + interval = Math.floor(seconds / 604800); + if (interval >= 1) { + return interval + "w"; + } + interval = Math.floor(seconds / 86400); + if (interval >= 1) { + return interval + "d"; + } + interval = Math.floor(seconds / 3600); + if (interval >= 1) { + return interval + "h"; + } + interval = Math.floor(seconds / 60); + if (interval >= 1) { + return interval + "m"; + } + return Math.floor(seconds) + "s"; + }), rewriteLinks: (function(i) { let tag = i.innerText; @@ -233,7 +259,7 @@ window.App.util = { $('#navbarDropdown img').attr('src',window._sharedData.curUser.avatar) .removeClass('d-none') .addClass('rounded-circle border shadow') - .attr('width', 34).attr('height', 34); + .attr('width', 38).attr('height', 38); }) }; diff --git a/resources/assets/js/components/Activity.vue b/resources/assets/js/components/Activity.vue index a5ef1525b..2b3e68686 100644 --- a/resources/assets/js/components/Activity.vue +++ b/resources/assets/js/components/Activity.vue @@ -27,6 +27,21 @@ {{n.account.local == false ? '@':''}}{{truncate(n.account.username)}} commented on your post.

+
+

+ {{n.account.local == false ? '@':''}}{{truncate(n.account.username)}} commented on your group post. +

+
+
+

+ {{n.account.local == false ? '@':''}}{{truncate(n.account.username)}} reacted to your story. +

+
+
+

+ {{n.account.local == false ? '@':''}}{{truncate(n.account.username)}} commented on your story. +

+

{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}} mentioned you. @@ -134,20 +149,22 @@ 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; + } + if(n.type == 'favourite' && !n.status) { + return false; + } + if(n.type == 'follow' && !n.account) { return false; } return true; @@ -167,19 +184,24 @@ export default { } axios.get('/api/pixelfed/v1/notifications', { params: { - pg: true, - page: this.notificationCursor + max_id: this.notificationMaxId } }).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/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 0aa4f8b63..acc332f2d 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 +

+ +
+ +
+
+
+
+
+
+
+
@@ -100,10 +191,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}}
@@ -147,10 +238,10 @@
-
+
- +

@@ -162,8 +253,8 @@

-
-
+
+
@@ -182,9 +273,9 @@
-
+
-
+
@@ -194,17 +285,36 @@ BETA

-

Add Photo to Story

+

Add to your story

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

+ New Poll + + BETA + +

+

Create a poll

-
+
-
- +
+

@@ -219,7 +329,6 @@

-

Help

@@ -336,7 +445,13 @@

Tag people

-

Add license NEW

+

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

Add location

@@ -530,8 +645,8 @@
- -

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

+ +

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


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

--> - @@ -845,52 +960,85 @@ 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: 1, + licenseTitle: null, + maxAltTextLength: 140, + pollOptionModel: null, + pollOptions: [], + pollExpiry: 1440, + postingPoll: false } }, 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; + this.maxAltTextLength = res.data.max_altext_length; + if(this.licenseId > 10) { + this.licenseTitle = this.availableLicenses.filter(l => { + return l.id == this.licenseId; + }).map(l => { + return l.title; + })[0]; + } + }); }, mounted() { @@ -1064,6 +1212,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 +1238,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 +1673,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': @@ -1535,6 +1703,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/DirectMessage.vue b/resources/assets/js/components/DirectMessage.vue index 3f2346870..09861c78c 100644 --- a/resources/assets/js/components/DirectMessage.vue +++ b/resources/assets/js/components/DirectMessage.vue @@ -80,14 +80,32 @@

{{convo.text}}

+

+ + + {{convo.meta.reaction}} + +

+ + + + {{convo.meta.caption}} + +

{{convo.text}}

-

{{convo.timeAgo}}

+

+ {{ convo.meta.story_actor_username }} reacted your story +

+

+ {{ convo.meta.story_actor_username }} replied to your story +

+

{{convo.timeAgo}}

 

-
+

@@ -127,10 +145,28 @@

{{convo.text}}

+

+ + + {{convo.meta.reaction}} + +

+ + + + {{convo.meta.caption}} + +

{{convo.text}}

-

{{convo.timeAgo}} +

+ You reacted to {{ convo.meta.story_username }}'s story +

+

+ You replied to {{ convo.meta.story_username }}'s story +

+

{{convo.timeAgo}}

 

@@ -262,60 +298,60 @@ diff --git a/resources/assets/js/components/Profile.vue b/resources/assets/js/components/Profile.vue index 6545a8eae..e4c48fccb 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/StoryTimelineComponent.vue b/resources/assets/js/components/StoryTimelineComponent.vue index ac4d2f10a..659939505 100644 --- a/resources/assets/js/components/StoryTimelineComponent.vue +++ b/resources/assets/js/components/StoryTimelineComponent.vue @@ -1,27 +1,46 @@ - - - diff --git a/resources/assets/js/components/Timeline.vue b/resources/assets/js/components/Timeline.vue index 88dffefa7..cfd59ac62 100644 --- a/resources/assets/js/components/Timeline.vue +++ b/resources/assets/js/components/Timeline.vue @@ -10,7 +10,7 @@
- +
@@ -20,11 +20,11 @@
-
+ -
+ -
+ @@ -501,7 +502,7 @@ discover_min_id: 0, discover_max_id: 0, discover_feed: [], - recentFeed: this.scope === 'home' ? true : false, + recentFeed: false, recentFeedMin: null, recentFeedMax: null, reactionBar: true, @@ -510,23 +511,22 @@ }, beforeMount() { - let avop = window.localStorage.getItem('pf.feed:avop') === 'always'; - let u = new URLSearchParams(window.location.search); - if(u.has('a')) { - switch(u.get('a')) { - case 'recent_feed': - if(this.scope === 'home') { - this.recentFeed = true; - } - break; - case 'vop': - this.recentFeed = false; - break; - } - } - this.recentFeed = avop ? false : this.recentFeed; + // let avop = window.localStorage.getItem('pf.feed:avop') === 'always'; + // let u = new URLSearchParams(window.location.search); + // if(u.has('a')) { + // switch(u.get('a')) { + // case 'recent_feed': + // if(this.scope === 'home') { + // this.recentFeed = true; + // } + // break; + // case 'vop': + // this.recentFeed = false; + // break; + // } + // } + // this.recentFeed = avop ? false : this.recentFeed; this.fetchProfile(); - this.fetchTimelineApi(); }, mounted() { @@ -555,7 +555,7 @@ this.showTips = false; } - this.$nextTick(function () { + this.$nextTick(() => { $('[data-toggle="tooltip"]').tooltip(); let u = new URLSearchParams(window.location.search); if(u.has('a')) { @@ -565,6 +565,7 @@ break; } } + this.fetchTimelineApi(); }); }, @@ -583,7 +584,9 @@ } window._sharedData.curUser = res.data; window.App.util.navatar(); - this.hasStory(); + // this.$nextTick(() => { + // this.hasStory(); + // }); // this.expRec(); }).catch(err => { swal( @@ -630,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(); @@ -644,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( @@ -942,7 +948,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/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/ContextMenu.vue b/resources/assets/js/components/partials/ContextMenu.vue index 9cdfd0809..3cf22c8a8 100644 --- a/resources/assets/js/components/partials/ContextMenu.vue +++ b/resources/assets/js/components/partials/ContextMenu.vue @@ -11,14 +11,16 @@
-
View Post
-
View Profile
+
View Post
+
View Profile
-
Share
-
Moderation Tools
+
Share
+
Moderation Tools
Report
-
Delete
+
Archive
+
Unarchive
+
Delete
Cancel
@@ -680,6 +682,29 @@ ownerOrAdmin(status) { return this.owner(status) || this.admin(); }, + + 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.$emit('status-delete', status.id); + this.closeModals(); + }); + }, + + 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.closeModals(); + }); + } } } 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 2e5b75c0d..d7e04144e 100644 --- a/resources/assets/js/components/partials/StatusCard.vue +++ b/resources/assets/js/components/partials/StatusCard.vue @@ -1,6 +1,7 @@