*/ protected $validationRules = [ 'actor_id' => 'required', 'message_html' => 'max_length[500]', ]; /** * @var string[] */ protected $beforeInsert = ['setPostId']; public function getPostById(string $postId): ?Post { $cacheName = config('Fediverse') ->cachePrefix . "post#{$postId}"; if (! ($found = cache($cacheName))) { $found = $this->find($postId); cache() ->save($cacheName, $found, DECADE); } return $found; } public function getPostByUri(string $postUri): ?Post { $hashedPostUri = md5($postUri); $cacheName = config('Fediverse') ->cachePrefix . "post-{$hashedPostUri}"; if (! ($found = cache($cacheName))) { $found = $this->where('uri', $postUri) ->first(); cache() ->save($cacheName, $found, DECADE); } return $found; } /** * Retrieves all published posts for a given actor ordered by publication date * * @return Post[] */ public function getActorPublishedPosts(int $actorId): array { $cacheName = config('Fediverse') ->cachePrefix . "actor#{$actorId}_published_posts"; if (! ($found = cache($cacheName))) { $found = $this->where([ 'actor_id' => $actorId, 'in_reply_to_id' => null, ]) ->where('`published_at` <= UTC_TIMESTAMP()', null, false) ->orderBy('published_at', 'DESC') ->findAll(); $secondsToNextUnpublishedPost = $this->getSecondsToNextUnpublishedPosts($actorId); cache() ->save($cacheName, $found, $secondsToNextUnpublishedPost ? $secondsToNextUnpublishedPost : DECADE); } return $found; } /** * Returns the timestamp difference in seconds between the next post to publish and the current timestamp. Returns * false if there's no post to publish */ public function getSecondsToNextUnpublishedPosts(int $actorId): int | false { $result = $this->select('TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), `published_at`) as timestamp_diff') ->where([ 'actor_id' => $actorId, ]) ->where('`published_at` > UTC_TIMESTAMP()', null, false) ->orderBy('published_at', 'asc') ->get() ->getResultArray(); return $result !== [] ? (int) $result[0]['timestamp_diff'] : false; } /** * Retrieves all published replies for a given post. By default, it does not get replies from blocked actors. * * @return Post[] */ public function getPostReplies(string $postId, bool $withBlocked = false): array { $cacheName = config('Fediverse') ->cachePrefix . "post#{$postId}_replies" . ($withBlocked ? '_withBlocked' : ''); if (! ($found = cache($cacheName))) { $tablesPrefix = config('Fediverse') ->tablesPrefix; if (! $withBlocked) { $this->select($tablesPrefix . 'posts.*') ->join( $tablesPrefix . 'actors', $tablesPrefix . 'actors.id = ' . $tablesPrefix . 'posts.actor_id', 'inner' ) ->where($tablesPrefix . 'actors.is_blocked', 0); } $this->where('in_reply_to_id', $this->uuid->fromString($postId) ->getBytes()) ->where('`published_at` <= UTC_TIMESTAMP()', null, false) ->orderBy('published_at', 'ASC'); $found = $this->findAll(); cache() ->save($cacheName, $found, DECADE); } return $found; } /** * Retrieves all published reblogs for a given post * * @return Post[] */ public function getPostReblogs(string $postId): array { $cacheName = config('Fediverse') ->cachePrefix . "post#{$postId}_reblogs"; if (! ($found = cache($cacheName))) { $found = $this->where('reblog_of_id', $this->uuid->fromString($postId) ->getBytes()) ->where('`published_at` <= UTC_TIMESTAMP()', null, false) ->orderBy('published_at', 'ASC') ->findAll(); cache() ->save($cacheName, $found, DECADE); } return $found; } public function addPreviewCard(string $postId, int $previewCardId): bool { return $this->db->table(config('Fediverse')->tablesPrefix . 'posts_preview_cards') ->insert([ 'post_id' => $this->uuid->fromString($postId) ->getBytes(), 'preview_card_id' => $previewCardId, ]); } /** * Adds post in database along preview card if relevant * * @return string|false returns the new post id if success or false otherwise */ public function addPost( Post $post, bool $createPreviewCard = true, bool $registerActivity = true ): bool|int|object|string { helper('fediverse'); $this->db->transStart(); if (! ($newPostId = $this->insert($post, true))) { $this->db->transRollback(); // Couldn't insert post return false; } if ($createPreviewCard) { // parse message $messageUrls = extract_urls_from_message($post->message); if ( $messageUrls !== [] && ($previewCard = get_or_create_preview_card_from_url(new URI($messageUrls[0]))) && ! $this->addPreviewCard($newPostId, $previewCard->id) ) { $this->db->transRollback(); // problem when linking post to preview card return false; } } if ($post->in_reply_to_id === null) { // post is not a reply model('ActorModel', false) ->where('id', $post->actor_id) ->increment('posts_count'); Events::trigger('on_post_add', $post); } if ($registerActivity) { // set post id and uri to construct NoteObject $post->id = $newPostId; $post->uri = url_to('post', esc($post->actor->username), $newPostId); $createActivity = new CreateActivity(); $noteObjectClass = config('Fediverse') ->noteObject; $createActivity ->set('actor', $post->actor->uri) ->set('object', new $noteObjectClass($post)); $activityId = model('ActivityModel', false) ->newActivity( 'Create', $post->actor_id, $post->in_reply_to_id === null ? null : $post->reply_to_post->actor_id, $newPostId, $createActivity->toJSON(), $post->published_at, 'queued', ); $createActivity->set('id', url_to('activity', esc($post->actor->username), $activityId)); model('ActivityModel', false) ->update($activityId, [ 'payload' => $createActivity->toJSON(), ]); } $this->clearCache($post); $this->db->transComplete(); return $newPostId; } public function editPost(Post $updatedPost): bool { $this->db->transStart(); // update post create activity schedule in database $scheduledActivity = model('ActivityModel', false) ->where([ 'type' => 'Create', 'post_id' => $this->uuid ->fromString($updatedPost->id) ->getBytes(), ]) ->first(); // update published date in payload $newPayload = $scheduledActivity->payload; $newPayload->object->published = $updatedPost->published_at->format(DATE_W3C); model('ActivityModel', false) ->update($scheduledActivity->id, [ 'payload' => json_encode($newPayload, JSON_THROW_ON_ERROR), 'scheduled_at' => $updatedPost->published_at, ]); // update post $updateResult = $this->update($updatedPost->id, $updatedPost); Events::trigger('on_post_edit', $updatedPost); $this->clearCache($updatedPost); $this->db->transComplete(); return $updateResult; } /** * Removes a post from the database and decrements meta data */ public function removePost(Post $post, bool $registerActivity = true): BaseResult | bool { $this->db->transStart(); // remove all post reblogs foreach ($post->reblogs as $reblog) { // FIXME: issue when actor is not local, can't get actor information $this->undoReblog($reblog); } // remove all replies foreach ($post->replies as $reply) { $this->removePost($reply); } // check that preview card is no longer used elsewhere before deleting it if ( $post->preview_card && $this->db ->table(config('Fediverse')->tablesPrefix . 'posts_preview_cards') ->where('preview_card_id', $post->preview_card->id) ->countAll() <= 1 ) { model('PreviewCardModel', false)->deletePreviewCard($post->preview_card->id, $post->preview_card->url); } if ($registerActivity) { $deleteActivity = new DeleteActivity(); $tombstoneObject = new TombstoneObject(); $tombstoneObject->set('id', $post->uri); $deleteActivity ->set('actor', $post->actor->uri) ->set('object', $tombstoneObject); $activityId = model('ActivityModel', false) ->newActivity( 'Delete', $post->actor_id, null, null, $deleteActivity->toJSON(), Time::now(), 'queued', ); $deleteActivity->set('id', url_to('activity', esc($post->actor->username), $activityId)); model('ActivityModel', false) ->update($activityId, [ 'payload' => $deleteActivity->toJSON(), ]); } if ($post->in_reply_to_id === null && $post->reblog_of_id === null) { model('ActorModel', false) ->where('id', $post->actor_id) ->decrement('posts_count'); Events::trigger('on_post_remove', $post); } elseif ($post->in_reply_to_id !== null) { // Post to remove is a reply model('PostModel', false) ->where('id', $this->uuid->fromString($post->in_reply_to_id) ->getBytes()) ->decrement('replies_count'); Events::trigger('on_reply_remove', $post); } $result = model('PostModel', false) ->delete($post->id); $this->clearCache($post); $this->db->transComplete(); return $result; } public function addReply( Post $reply, bool $createPreviewCard = true, bool $registerActivity = true ): string | false { if (! $reply->in_reply_to_id) { throw new Exception('Passed post is not a reply!'); } $this->db->transStart(); $postId = $this->addPost($reply, $createPreviewCard, $registerActivity); model('PostModel', false) ->where('id', $this->uuid->fromString($reply->in_reply_to_id) ->getBytes()) ->increment('replies_count'); Events::trigger('on_post_reply', $reply); $this->clearCache($reply); $this->db->transComplete(); return $postId; } public function reblog(Actor $actor, Post $post, bool $registerActivity = true): string | false { $this->db->transStart(); $userId = null; if (function_exists('user_id')) { $userId = user_id(); } $reblog = new Post([ 'actor_id' => $actor->id, 'reblog_of_id' => $post->id, 'published_at' => Time::now(), 'created_by' => $userId, ]); // add reblog $reblogId = $this->insert($reblog); model('ActorModel', false) ->where('id', $actor->id) ->increment('posts_count'); model('PostModel', false) ->where('id', $this->uuid->fromString($post->id)->getBytes()) ->increment('reblogs_count'); if ($registerActivity) { $announceActivity = new AnnounceActivity($reblog); $activityId = model('ActivityModel', false) ->newActivity( 'Announce', $actor->id, $post->actor_id, $post->id, $announceActivity->toJSON(), $reblog->published_at, 'queued', ); $announceActivity->set('id', url_to('activity', esc($post->actor->username), $activityId)); model('ActivityModel', false) ->update($activityId, [ 'payload' => $announceActivity->toJSON(), ]); } Events::trigger('on_post_reblog', $actor, $post); $this->clearCache($post); $this->db->transComplete(); return $reblogId; } public function undoReblog(Post $reblogPost, bool $registerActivity = true): BaseResult | bool { $this->db->transStart(); model('ActorModel', false) ->where('id', $reblogPost->actor_id) ->decrement('posts_count'); model('PostModel', false) ->where('id', $this->uuid->fromString($reblogPost->reblog_of_id) ->getBytes()) ->decrement('reblogs_count'); if ($registerActivity) { $undoActivity = new UndoActivity(); // get like activity $activity = model('ActivityModel', false) ->where([ 'type' => 'Announce', 'actor_id' => $reblogPost->actor_id, 'post_id' => $this->uuid ->fromString($reblogPost->reblog_of_id) ->getBytes(), ]) ->first(); $announceActivity = new AnnounceActivity($reblogPost); $announceActivity->set('id', url_to('activity', $reblogPost->actor->username, $activity->id),); $undoActivity ->set('actor', $reblogPost->actor->uri) ->set('object', $announceActivity); $activityId = model('ActivityModel', false) ->newActivity( 'Undo', $reblogPost->actor_id, $reblogPost->reblog_of_post->actor_id, $reblogPost->reblog_of_id, $undoActivity->toJSON(), Time::now(), 'queued', ); $undoActivity->set('id', url_to('activity', $reblogPost->actor->username, $activityId)); model('ActivityModel', false) ->update($activityId, [ 'payload' => $undoActivity->toJSON(), ]); } Events::trigger('on_post_undo_reblog', $reblogPost); $result = model('PostModel', false) ->delete($reblogPost->id); $this->clearCache($reblogPost); $this->db->transComplete(); return $result; } public function toggleReblog(Actor $actor, Post $post): void { if ( ! ($reblogPost = $this->where([ 'actor_id' => $actor->id, 'reblog_of_id' => $this->uuid ->fromString($post->id) ->getBytes(), ])->first()) ) { $this->reblog($actor, $post); } else { $this->undoReblog($reblogPost); } } public function getTotalLocalPosts(): int { helper('fediverse'); $cacheName = config('Fediverse') ->cachePrefix . 'blocked_actors'; if (! ($found = cache($cacheName))) { $tablePrefix = config('Fediverse') ->tablesPrefix; $result = $this->select('COUNT(*) as total_local_posts') ->join($tablePrefix . 'actors', $tablePrefix . 'actors.id = ' . $tablePrefix . 'posts.actor_id') ->where($tablePrefix . 'actors.domain', get_current_domain()) ->where('`published_at` <= UTC_TIMESTAMP()', null, false) ->get() ->getResultArray(); $found = (int) $result[0]['total_local_posts']; cache() ->save($cacheName, $found, DECADE); } return $found; } public function resetFavouritesCount(): int | false { $tablePrefix = config('Fediverse') ->tablesPrefix; $postsFavouritesCount = $this->db->table($tablePrefix . 'favourites')->select( 'post_id as id, COUNT(*) as `favourites_count`' ) ->groupBy('id') ->get() ->getResultArray(); if ($postsFavouritesCount !== []) { $this->uuidUseBytes = false; return $this->updateBatch($postsFavouritesCount, 'id'); } return 0; } public function resetReblogsCount(): int | false { $tablePrefix = config('Fediverse') ->tablesPrefix; $postsReblogsCount = $this->select($tablePrefix . 'posts.id, COUNT(*) as `replies_count`') ->join($tablePrefix . 'posts as p2', $tablePrefix . 'posts.id = p2.reblog_of_id') ->groupBy($tablePrefix . 'posts.id') ->get() ->getResultArray(); if ($postsReblogsCount !== []) { $this->uuidUseBytes = false; return $this->updateBatch($postsReblogsCount, 'id'); } return 0; } public function resetRepliesCount(): int | false { $tablePrefix = config('Fediverse') ->tablesPrefix; $postsRepliesCount = $this->select($tablePrefix . 'posts.id, COUNT(*) as `replies_count`') ->join($tablePrefix . 'posts as p2', $tablePrefix . 'posts.id = p2.in_reply_to_id') ->groupBy($tablePrefix . 'posts.id') ->get() ->getResultArray(); if ($postsRepliesCount !== []) { $this->uuidUseBytes = false; return $this->updateBatch($postsRepliesCount, 'id'); } return 0; } public function clearCache(Post $post): void { $cachePrefix = config('Fediverse') ->cachePrefix; $hashedPostUri = md5($post->uri); model('ActorModel', false) ->clearCache($post->actor); cache() ->deleteMatching($cachePrefix . "post#{$post->id}*"); cache() ->deleteMatching($cachePrefix . "post-{$hashedPostUri}*"); if ($post->in_reply_to_id !== null) { $this->clearCache($post->reply_to_post); } if ($post->reblog_of_id !== null) { $this->clearCache($post->reblog_of_post); } } /** * @param array> $data * @return array> */ protected function setPostId(array $data): array { $uuid4 = $this->uuid->{$this->uuidVersion}(); $data['data']['id'] = $uuid4->toString(); if (! isset($data['data']['uri'])) { $actor = model('ActorModel', false) ->getActorById((int) $data['data']['actor_id']); $data['data']['uri'] = url_to('post', esc($actor->username), $uuid4->toString()); } return $data; } }