Skip to content
Snippets Groups Projects
NoteModel.php 19.8 KiB
Newer Older
<?php

/**
 * @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\Actor;
use ActivityPub\Entities\Note;
use ActivityPub\Objects\TombstoneObject;
use CodeIgniter\Database\BaseResult;
use CodeIgniter\Events\Events;
use CodeIgniter\HTTP\URI;
use CodeIgniter\I18n\Time;
    protected $table = 'activitypub_notes';
    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',
    ];

    /**
     * @var string
     */
    protected $returnType = Note::class;

    /**
     * @var bool
     */
    protected $validationRules = [
        'actor_id' => 'required',
        'message_html' => 'required_without[reblog_of_id]|max_length[500]',
    ];

    protected $beforeInsert = ['setNoteId'];

    public function getNoteById(string $noteId): ?Note
        $cacheName = config('ActivityPub')
            ->cachePrefix . "note#{$noteId}";
        if (! ($found = cache($cacheName))) {
            cache()
                ->save($cacheName, $found, DECADE);
    public function getNoteByUri(string $noteUri): ?Note
        $hashedNoteUri = md5($noteUri);
        $cacheName =
            config('ActivityPub')
                ->cachePrefix . "note-{$hashedNoteUri}";
        if (! ($found = cache($cacheName))) {
            $found = $this->where('uri', $noteUri)
                ->first();
            cache()
                ->save($cacheName, $found, DECADE);
    }

    /**
     * Retrieves all published notes for a given actor ordered by publication date
     *
    public function getActorPublishedNotes(int $actorId): array
            $found = $this->where([
                'actor_id' => $actorId,
                'in_reply_to_id' => null,
            ])
                ->where('`published_at` <= NOW()', null, false)
                ->orderBy('published_at', 'DESC')
                ->findAll();

            cache()
                ->save($cacheName, $found, DECADE);
     * Retrieves all published replies for a given note. By default, it does not get replies from blocked actors.
    public function getNoteReplies(string $noteId, bool $withBlocked = false): array
    {
            "note#{$noteId}_replies" .
            ($withBlocked ? '_withBlocked' : '');

        if (! ($found = cache($cacheName))) {
            if (! $withBlocked) {
                    ->join('activitypub_actors', 'activitypub_actors.id = activitypub_notes.actor_id', 'inner',)
                    ->where('activitypub_actors.is_blocked', 0);
            }
            $this->where('in_reply_to_id', $this->uuid->fromString($noteId) ->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 note
    public function getNoteReblogs(string $noteId): array
            config('ActivityPub')
                ->cachePrefix . "note#{$noteId}_reblogs";
        if (! ($found = cache($cacheName))) {
            $found = $this->where('reblog_of_id', $this->uuid->fromString($noteId) ->getBytes(),)
                ->where('`published_at` <= NOW()', null, false)
                ->orderBy('published_at', 'ASC')
                ->findAll();

            cache()
                ->save($cacheName, $found, DECADE);
    public function addPreviewCard(string $noteId, int $previewCardId): Query | bool
        return $this->db->table('activitypub_notes_preview_cards')
            ->insert([
                'note_id' => $this->uuid->fromString($noteId)
                    ->getBytes(),
                'preview_card_id' => $previewCardId,
            ]);
    }

    /**
     * Adds note in database along preview card if relevant
     *
     * @return string|false returns the new note id if success or false otherwise
     */
    public function addNote(
        Note $note,
        bool $createPreviewCard = true,
        bool $registerActivity = true
        if (! ($newNoteId = $this->insert($note, true))) {
            $this->db->transRollback();

            // Couldn't insert note
            return false;
        }

        if ($createPreviewCard) {
            // parse message
            $messageUrls = extract_urls_from_message($note->message);

            if (
                ($previewCard = get_or_create_preview_card_from_url(new URI($messageUrls[0]),)) &&
                ! $this->addPreviewCard($newNoteId, $previewCard->id)
                $this->db->transRollback();
                // problem when linking note to preview card
                return false;
            }
        }

        model('ActorModel')
            ->where('id', $note->actor_id)
            ->increment('notes_count');

        $cachePrefix = config('ActivityPub')
            ->cachePrefix;
        cache()
            ->delete($cachePrefix . "actor#{$note->actor_id}");
        cache()
            ->delete($cachePrefix . "actor#{$note->actor_id}_published_notes",);
        Events::trigger('on_note_add', $note);

        if ($registerActivity) {
            // set note id and uri to construct NoteObject
            $note->uri = base_url(route_to('note', $note->actor->username, $newNoteId),);
            $noteObjectClass = config('ActivityPub')
                ->noteObject;
            $createActivity
                ->set('actor', $note->actor->uri)
                ->set('object', new $noteObjectClass($note));

            $activityId = model('ActivityModel')
                ->newActivity(
                    'Create',
                    $note->actor_id,
                    null,
                    $newNoteId,
                    $createActivity->toJSON(),
                    $note->published_at,
                    'queued',
                );

            $createActivity->set('id', base_url(route_to('activity', $note->actor->username, $activityId),),);

            model('ActivityModel')
                ->update($activityId, [
                    'payload' => $createActivity->toJSON(),
                ]);
    public function editNote(Note $updatedNote): bool
    {
        $this->db->transStart();

        // update note create activity schedule in database
        $scheduledActivity = model('ActivityModel')
            ->where([
                'type' => 'Create',
                    ->fromString($updatedNote->id)
                    ->getBytes(),
            ])
            ->first();

        // update published date in payload
        $newPayload = $scheduledActivity->payload;
        $newPayload->object->published = $updatedNote->published_at->format(DATE_W3C,);
        model('ActivityModel')
            ->update($scheduledActivity->id, [
                'payload' => json_encode($newPayload, JSON_THROW_ON_ERROR),
                'scheduled_at' => $updatedNote->published_at,
            ]);

        // update note
        $updateResult = $this->update($updatedNote->id, $updatedNote);

        $prefix = config('ActivityPub')
            ->cachePrefix;
        $hashedNoteUri = md5($updatedNote->uri);
        cache()
            ->delete($prefix . "note#{$updatedNote->id}");
        cache()
            ->delete($prefix . "note-{$hashedNoteUri}");
        $this->db->transComplete();

        return $updateResult;
    }

    /**
     * Removes a note from the database and decrements meta data
     */
    public function removeNote(Note $note, bool $registerActivity = true): BaseResult | bool
        $cachePrefix = config('ActivityPub')
            ->cachePrefix;
        model('ActorModel')
            ->where('id', $note->actor_id)
            ->decrement('notes_count');
        cache()
            ->delete($cachePrefix . "actor#{$note->actor_id}");
        cache()
            ->delete($cachePrefix . "actor#{$note->actor_id}_published_notes",);

        if ($note->in_reply_to_id) {
            // Note to remove is a reply
            model('NoteModel')
                ->where('id', $this->uuid->fromString($note->in_reply_to_id) ->getBytes(),)
            cache()
                ->delete($cachePrefix . "note#{$replyToNote->id}");
            cache()
                ->delete($cachePrefix . "note-{$replyToNote->uri}");
            cache()
                ->delete($cachePrefix . "note#{$replyToNote->id}_replies");
            cache()
                ->delete($cachePrefix . "note#{$replyToNote->id}_replies_withBlocked",);

            Events::trigger('on_reply_remove', $note);
        foreach ($note->reblogs as $reblog) {
            $this->removeNote($reblog);
        }

        foreach ($note->replies as $reply) {
            $this->removeNote($reply);
        }

        // check that preview card in no longer used elsewhere before deleting it
        if (
            $note->preview_card &&
            $this->db
                ->table('activitypub_notes_preview_cards')
                ->where('preview_card_id', $note->preview_card->id)
                ->countAll() <= 1
        ) {
            model('PreviewCardModel')->deletePreviewCard($note->preview_card->id, $note->preview_card->url,);
        Events::trigger('on_note_remove', $note);

        if ($registerActivity) {
            $deleteActivity = new DeleteActivity();
            $tombstoneObject = new TombstoneObject();
            $tombstoneObject->set('id', $note->uri);
            $deleteActivity
                ->set('actor', $note->actor->uri)
                ->set('object', $tombstoneObject);

            $activityId = model('ActivityModel')
                ->newActivity(
                    'Delete',
                    $note->actor_id,
                    null,
                    null,
                    $deleteActivity->toJSON(),
                    Time::now(),
                    'queued',
                );

            $deleteActivity->set('id', base_url(route_to('activity', $note->actor->username, $activityId),),);

            model('ActivityModel')
                ->update($activityId, [
                    'payload' => $deleteActivity->toJSON(),
                ]);
        // clear note + replies / reblogs + actor  and its published notes
        $hashedNoteUri = md5($note->uri);
        cache()
            ->delete($cachePrefix . "note#{$note->id}");
        cache()
            ->delete($cachePrefix . "note-{$hashedNoteUri}");
        cache()
            ->delete($cachePrefix . "note#{$note->id}_replies");
        cache()
            ->delete($cachePrefix . "note#{$note->id}_replies_withBlocked");
        cache()
            ->delete($cachePrefix . "note#{$note->id}_reblogs");
        cache()
            ->delete($cachePrefix . "note#{$note->id}_preview_card");

        $result = model('NoteModel', false)
            ->delete($note->id);

        $this->db->transComplete();

        return $result;
    }

    public function addReply(
        Note $reply,
        bool $createPreviewCard = true,
        bool $registerActivity = true
    ): string | false {
        if (! $reply->in_reply_to_id) {
            throw new Exception('Passed note is not a reply!');
        }

        $this->db->transStart();

        $noteId = $this->addNote($reply, $createPreviewCard, $registerActivity);

        model('NoteModel')
            ->where('id', $this->uuid->fromString($reply->in_reply_to_id) ->getBytes(),)
        $prefix = config('ActivityPub')
            ->cachePrefix;
        $hashedNoteUri = md5($reply->reply_to_note->uri);
        cache()
            ->delete($prefix . "note#{$reply->in_reply_to_id}");
        cache()
            ->delete($prefix . "note-{$hashedNoteUri}");
        cache()
            ->delete($prefix . "note#{$reply->in_reply_to_id}_replies");
        cache()
            ->delete($prefix . "note#{$reply->in_reply_to_id}_replies_withBlocked",);
        Events::trigger('on_note_reply', $reply);

        $this->db->transComplete();

        return $noteId;
    }

    public function reblog(Actor $actor, Note $note, bool $registerActivity = true): string | false
    {
        $this->db->transStart();

        $reblog = new Note([
            'actor_id' => $actor->id,
            'reblog_of_id' => $note->id,
            'published_at' => Time::now(),
        ]);

        // add reblog
        $reblogId = $this->insert($reblog);

        model('ActorModel')
            ->where('id', $actor->id)
            ->increment('notes_count');

        $prefix = config('ActivityPub')
            ->cachePrefix;
        cache()
            ->delete($prefix . "actor#{$note->actor_id}");
        cache()
            ->delete($prefix . "actor#{$note->actor_id}_published_notes");
            ->where('id', $this->uuid->fromString($note->id)->getBytes())
        cache()
            ->delete($prefix . "note#{$note->id}");
        cache()
            ->delete($prefix . "note-{$hashedNoteUri}");
        cache()
            ->delete($prefix . "note#{$note->id}_reblogs");
        Events::trigger('on_note_reblog', $actor, $note);

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

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

            $announceActivity->set('id', base_url(route_to('activity', $note->actor->username, $activityId),),);

            model('ActivityModel')
                ->update($activityId, [
                    'payload' => $announceActivity->toJSON(),
                ]);
    public function undoReblog(Note $reblogNote, bool $registerActivity = true): BaseResult | bool
    {
        $this->db->transStart();

        model('ActorModel')
            ->where('id', $reblogNote->actor_id)
            ->decrement('notes_count');

        $cachePrefix = config('ActivityPub')
            ->cachePrefix;
        cache()
            ->delete($cachePrefix . "actor#{$reblogNote->actor_id}");
        cache()
            ->delete($cachePrefix . "actor#{$reblogNote->actor_id}_published_notes",);
            ->where('id', $this->uuid->fromString($reblogNote->reblog_of_id) ->getBytes(),)
        $hashedReblogNoteUri = md5($reblogNote->uri);
        $hashedNoteUri = md5($reblogNote->reblog_of_note->uri);
        cache()
            ->delete($cachePrefix . "note#{$reblogNote->id}");
        cache()
            ->delete($cachePrefix . "note-{$hashedReblogNoteUri}");
        cache()
            ->delete($cachePrefix . "note#{$reblogNote->reblog_of_id}");
        cache()
            ->delete($cachePrefix . "note-{$hashedNoteUri}");
        Events::trigger('on_note_undo_reblog', $reblogNote);

        if ($registerActivity) {
            $undoActivity = new UndoActivity();
            // get like activity
            $activity = model('ActivityModel')
                ->where([
                    'type' => 'Announce',
                    'actor_id' => $reblogNote->actor_id,
                        ->fromString($reblogNote->reblog_of_id)
                        ->getBytes(),
                ])
                ->first();

            $announceActivity = new AnnounceActivity($reblogNote);
            $announceActivity->set(
                'id',
                base_url(route_to('activity', $reblogNote->actor->username, $activity->id,),),
            );

            $undoActivity
                ->set('actor', $reblogNote->actor->uri)
                ->set('object', $announceActivity);

            $activityId = model('ActivityModel')
                ->newActivity(
                    'Undo',
                    $reblogNote->actor_id,
                    null,
                    $reblogNote->reblog_of_id,
                    $undoActivity->toJSON(),
                    Time::now(),
                    'queued',
                );
                base_url(route_to('activity', $reblogNote->actor->username, $activityId,),),
            model('ActivityModel')
                ->update($activityId, [
                    'payload' => $undoActivity->toJSON(),
                ]);
        $result = model('NoteModel', false)
            ->delete($reblogNote->id);
    public function toggleReblog(Actor $actor, Note $note): void
                    ->fromString($note->id)
                    ->getBytes(),
            ])->first())
        ) {
            $this->reblog($actor, $note);
        } else {
            $this->undoReblog($reblogNote);
        }
    }

     * @param array<string, array<string|int, mixed>> $data
     * @return array<string, array<string|int, mixed>>
     */
    protected function setNoteId(array $data): array
        $uuid4 = $this->uuid->{$this->uuidVersion}();
        $data['data']['id'] = $uuid4->toString();
        if (! isset($data['data']['uri'])) {
            $actor = model('ActorModel')
                ->getActorById($data['data']['actor_id'],);
            $data['data']['uri'] = base_url(route_to('note', $actor->username, $uuid4->toString()),);