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