Skip to content
Snippets Groups Projects
StatusModel.php 18.4 KiB
Newer Older
/**
 * @copyright  2021 Podlibre
 * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
 * @link       https://castopod.org/
 */

namespace ActivityPub\Models;

use ActivityPub\Activities\AnnounceActivity;
use ActivityPub\Activities\CreateActivity;
use ActivityPub\Activities\DeleteActivity;
use ActivityPub\Activities\UndoActivity;
use ActivityPub\Entities\Status;
use ActivityPub\Objects\TombstoneObject;
use CodeIgniter\Database\BaseResult;
use CodeIgniter\Events\Events;
use CodeIgniter\HTTP\URI;
use CodeIgniter\I18n\Time;
class StatusModel extends UuidModel
    protected $table = 'activitypub_statuses';
    protected $uuidFields = ['id', 'in_reply_to_id', 'reblog_of_id'];

    protected $allowedFields = [
        'id',
        'uri',
        'actor_id',
        'in_reply_to_id',
        'reblog_of_id',
        'message',
        'message_html',
        'favourites_count',
        'reblogs_count',
        'replies_count',
        'published_at',
    ];

    protected $returnType = Status::class;
    protected $validationRules = [
        'actor_id' => 'required',
    protected $beforeInsert = ['setStatusId'];
    public function getStatusById(string $statusId): ?Status
            ->cachePrefix . "status#{$statusId}";
        if (! ($found = cache($cacheName))) {
            $found = $this->find($statusId);
            cache()
                ->save($cacheName, $found, DECADE);
    public function getStatusByUri(string $statusUri): ?Status
        $hashedStatusUri = md5($statusUri);
                ->cachePrefix . "status-{$hashedStatusUri}";
            $found = $this->where('uri', $statusUri)
            cache()
                ->save($cacheName, $found, DECADE);
     * Retrieves all published statuses for a given actor ordered by publication date
    public function getActorPublishedStatuses(int $actorId): array
            "actor#{$actorId}_published_statuses";
            $found = $this->where([
                'actor_id' => $actorId,
                'in_reply_to_id' => null,
            ])
                ->where('`published_at` <= NOW()', null, false)
                ->orderBy('published_at', 'DESC')
                ->findAll();

            $secondsToNextUnpublishedStatus = $this->getSecondsToNextUnpublishedStatuses($actorId);
                ->save($cacheName, $found, $secondsToNextUnpublishedStatus ? $secondsToNextUnpublishedStatus : DECADE);
     * Returns the timestamp difference in seconds between the next status to publish and the current timestamp. Returns
     * false if there's no status to publish
    public function getSecondsToNextUnpublishedStatuses(int $actorId): int | false
        $result = $this->select('TIMESTAMPDIFF(SECOND, NOW(), `published_at`) as timestamp_diff')
            ->where([
                'actor_id' => $actorId,
            ])
            ->where('`published_at` > NOW()', null, false)
            ->orderBy('published_at', 'asc')
            ->get()
            ->getResultArray();

        return count($result) !== 0
            ? (int) $result[0]['timestamp_diff']
            : false;
    }

     * Retrieves all published replies for a given status. By default, it does not get replies from blocked actors.
    public function getStatusReplies(string $statusId, bool $withBlocked = false): array
            "status#{$statusId}_replies" .
        if (! ($found = cache($cacheName))) {
            if (! $withBlocked) {
                $this->select('activitypub_statuses.*')
                    ->join('activitypub_actors', 'activitypub_actors.id = activitypub_statuses.actor_id', 'inner')
                    ->where('activitypub_actors.is_blocked', 0);
            }
            $this->where('in_reply_to_id', $this->uuid->fromString($statusId) ->getBytes())
                ->where('`published_at` <= NOW()', null, false)
                ->orderBy('published_at', 'ASC');
            $found = $this->findAll();
            cache()
                ->save($cacheName, $found, DECADE);
     * Retrieves all published reblogs for a given status
    public function getStatusReblogs(string $statusId): array
                ->cachePrefix . "status#{$statusId}_reblogs";
            $found = $this->where('reblog_of_id', $this->uuid->fromString($statusId) ->getBytes())
                ->where('`published_at` <= NOW()', null, false)
                ->orderBy('published_at', 'ASC')
                ->findAll();

            cache()
                ->save($cacheName, $found, DECADE);
    public function addPreviewCard(string $statusId, int $previewCardId): Query | bool
        return $this->db->table('activitypub_statuses_preview_cards')
                'status_id' => $this->uuid->fromString($statusId)
                    ->getBytes(),
                'preview_card_id' => $previewCardId,
            ]);
     * Adds status in database along preview card if relevant
     * @return string|false returns the new status id if success or false otherwise
    public function addStatus(
        Status $status,
        bool $createPreviewCard = true,
        bool $registerActivity = true
        if (! ($newStatusId = $this->insert($status, true))) {
            // Couldn't insert status
            return false;
        }

        if ($createPreviewCard) {
            // parse message
            $messageUrls = extract_urls_from_message($status->message);
                ($previewCard = get_or_create_preview_card_from_url(new URI($messageUrls[0]))) &&
                ! $this->addPreviewCard($newStatusId, $previewCard->id)
                // problem when linking status to preview card
            ->where('id', $status->actor_id)
            ->increment('statuses_count');
            // set status id and uri to construct NoteObject
            $status->id = $newStatusId;
            $status->uri = url_to('status', $status->actor->username, $newStatusId);
            $noteObjectClass = config('ActivityPub')
                ->noteObject;
                ->set('actor', $status->actor->uri)
                ->set('object', new $noteObjectClass($status));
            $activityId = model('ActivityModel')
                ->newActivity(
                    'Create',
            $createActivity->set('id', url_to('activity', $status->actor->username, $activityId));

            model('ActivityModel')
                ->update($activityId, [
                    'payload' => $createActivity->toJSON(),
                ]);
        Events::trigger('on_status_add', $status);
        $this->clearCache($status);
    public function editStatus(Status $updatedStatus): bool
        // update status create activity schedule in database
        $scheduledActivity = model('ActivityModel')
            ->where([
                'type' => 'Create',
                'status_id' => $this->uuid
                    ->fromString($updatedStatus->id)
                    ->getBytes(),
            ])
            ->first();

        // update published date in payload
        $newPayload = $scheduledActivity->payload;
        $newPayload->object->published = $updatedStatus->published_at->format(DATE_W3C);
        model('ActivityModel')
            ->update($scheduledActivity->id, [
                'payload' => json_encode($newPayload, JSON_THROW_ON_ERROR),
                'scheduled_at' => $updatedStatus->published_at,
        // update status
        $updateResult = $this->update($updatedStatus->id, $updatedStatus);
        Events::trigger('on_status_edit', $updatedStatus);
        $this->clearCache($updatedStatus);
     * Removes a status from the database and decrements meta data
    public function removeStatus(Status $status, bool $registerActivity = true): BaseResult | bool
            ->where('id', $status->actor_id)
            ->decrement('statuses_count');
        if ($status->in_reply_to_id !== null) {
            // Status to remove is a reply
            model('StatusModel')
                ->where('id', $this->uuid->fromString($status->in_reply_to_id) ->getBytes())
            Events::trigger('on_reply_remove', $status);
        // remove all status reblogs
        foreach ($status->reblogs as $reblog) {
            // FIXME: issue when actor is not local, can't get actor information
            $this->removeStatus($reblog);
        // remove all status replies
        foreach ($status->replies as $reply) {
            $this->removeStatus($reply);
        // check that preview card is no longer used elsewhere before deleting it
            $status->preview_card &&
                ->table('activitypub_statuses_preview_cards')
                ->where('preview_card_id', $status->preview_card->id)
            model('PreviewCardModel')->deletePreviewCard($status->preview_card->id, $status->preview_card->url);
        if ($registerActivity) {
            $deleteActivity = new DeleteActivity();
            $tombstoneObject = new TombstoneObject();
            $tombstoneObject->set('id', $status->uri);
                ->set('actor', $status->actor->uri)
            $activityId = model('ActivityModel')
                ->newActivity(
                    'Delete',
                    null,
                    null,
                    $deleteActivity->toJSON(),
                    Time::now(),
                    'queued',
                );

            $deleteActivity->set('id', url_to('activity', $status->actor->username, $activityId));

            model('ActivityModel')
                ->update($activityId, [
                    'payload' => $deleteActivity->toJSON(),
                ]);
        $result = model('StatusModel', false)
            ->delete($status->id);
        Events::trigger('on_status_remove', $status);
        $this->clearCache($status);
        $this->db->transComplete();

        return $result;
    }

    public function addReply(
        bool $createPreviewCard = true,
        bool $registerActivity = true
    ): string | false {
        if (! $reply->in_reply_to_id) {
            throw new Exception('Passed status is not a reply!');
        $statusId = $this->addStatus($reply, $createPreviewCard, $registerActivity);
            ->where('id', $this->uuid->fromString($reply->in_reply_to_id) ->getBytes())
        Events::trigger('on_status_reply', $reply);
    public function reblog(Actor $actor, Status $status, bool $registerActivity = true): string | false
            'reblog_of_id' => $status->id,
        $reblogId = $this->insert($reblog);

        model('ActorModel')
            ->where('id', $actor->id)
            ->increment('statuses_count');
        model('StatusModel')
            ->where('id', $this->uuid->fromString($status->id)->getBytes())
            ->increment('reblogs_count');

        if ($registerActivity) {
            $announceActivity = new AnnounceActivity($reblog);

            $activityId = model('ActivityModel')
                ->newActivity(
                    'Announce',
                    $actor->id,
                    null,
                    $announceActivity->toJSON(),
                    $reblog->published_at,
                    'queued',
                );

            $announceActivity->set('id', url_to('activity', $status->actor->username, $activityId));

            model('ActivityModel')
                ->update($activityId, [
                    'payload' => $announceActivity->toJSON(),
                ]);
        Events::trigger('on_status_reblog', $actor, $status);
        $this->clearCache($status);
    public function undoReblog(Status $reblogStatus, bool $registerActivity = true): BaseResult | bool
            ->where('id', $reblogStatus->actor_id)
            ->decrement('statuses_count');
        model('StatusModel')
            ->where('id', $this->uuid->fromString($reblogStatus->reblog_of_id) ->getBytes())
            ->decrement('reblogs_count');

        if ($registerActivity) {
            $undoActivity = new UndoActivity();
            // get like activity
            $activity = model('ActivityModel')
                ->where([
                    'type' => 'Announce',
                    'actor_id' => $reblogStatus->actor_id,
                    'status_id' => $this->uuid
                        ->fromString($reblogStatus->reblog_of_id)
            $announceActivity = new AnnounceActivity($reblogStatus);
            $announceActivity->set('id', url_to('activity', $reblogStatus->actor->username, $activity->id),);
                ->set('actor', $reblogStatus->actor->uri)
            $activityId = model('ActivityModel')
                ->newActivity(
                    'Undo',
                    $reblogStatus->actor_id,
                    $reblogStatus->reblog_of_id,
            $undoActivity->set('id', url_to('activity', $reblogStatus->actor->username, $activityId));
            model('ActivityModel')
                ->update($activityId, [
                    'payload' => $undoActivity->toJSON(),
                ]);
        $result = model('StatusModel', false)
            ->delete($reblogStatus->id);
        Events::trigger('on_status_undo_reblog', $reblogStatus);
        $this->clearCache($reblogStatus);
    public function toggleReblog(Actor $actor, Status $status): void
            ! ($reblogStatus = $this->where([
                    ->fromString($status->id)
            $this->reblog($actor, $status);
            $this->undoReblog($reblogStatus);
    public function clearCache(Status $status): void
        $hashedStatusUri = md5($status->uri);
            ->clearCache($status->actor);
            ->deleteMatching($cachePrefix . "status#{$status->id}*");
            ->deleteMatching($cachePrefix . "status-{$hashedStatusUri}*");
        if ($status->in_reply_to_id !== null) {
            $this->clearCache($status->reply_to_status);
        if ($status->reblog_of_id !== null) {
            $this->clearCache($status->reblog_of_status);
     * @param array<string, array<string|int, mixed>> $data
     * @return array<string, array<string|int, mixed>>
     */
    protected function setStatusId(array $data): array
        $uuid4 = $this->uuid->{$this->uuidVersion}();
        $data['data']['id'] = $uuid4->toString();
        if (! isset($data['data']['uri'])) {
            $actor = model('ActorModel')
                ->getActorById((int) $data['data']['actor_id']);
            $data['data']['uri'] = url_to('status', $actor->username, $uuid4->toString());