Skip to content
Snippets Groups Projects
NoteModel.php 20 KiB
Newer Older
  • Learn to ignore specific revisions
  • <?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\Entities\Actor;
    use CodeIgniter\Database\Query;
    use Exception;
    
    use ActivityPub\Entities\Note;
    use ActivityPub\Activities\AnnounceActivity;
    use ActivityPub\Activities\CreateActivity;
    use ActivityPub\Activities\DeleteActivity;
    use ActivityPub\Activities\UndoActivity;
    use ActivityPub\Objects\TombstoneObject;
    
    use CodeIgniter\Database\BaseResult;
    
    use CodeIgniter\Events\Events;
    use CodeIgniter\HTTP\URI;
    use CodeIgniter\I18n\Time;
    
    use CodeIgniter\Router\Exceptions\RouterException;
    use InvalidArgumentException;
    
        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))) {
                $found = $this->find($noteId);
    
                cache()->save($cacheName, $found, DECADE);
            }
    
            return $found;
    
        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);
            }
    
            return $found;
    
        }
    
        /**
         * Retrieves all published notes for a given actor ordered by publication date
         *
    
        public function getActorPublishedNotes(int $actorId): array
    
            $cacheName =
                config('ActivityPub')->cachePrefix .
                "actor#{$actorId}_published_notes";
            if (!($found = cache($cacheName))) {
                $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);
            }
    
            return $found;
    
        }
    
        /**
         * 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 {
    
            $cacheName =
                config('ActivityPub')->cachePrefix .
                "note#{$noteId}_replies" .
                ($withBlocked ? '_withBlocked' : '');
    
            if (!($found = cache($cacheName))) {
                if (!$withBlocked) {
                    $this->select('activitypub_notes.*')
                        ->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);
            }
    
            return $found;
    
        }
    
        /**
         * Retrieves all published reblogs for a given note
    
        public function getNoteReblogs(string $noteId): array
    
            $cacheName =
                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);
            }
    
            return $found;
    
        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
    
            helper('activitypub');
    
            $this->db->transStart();
    
            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 (
                    !empty($messageUrls) &&
                    ($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
    
                    route_to('note', $note->actor->username, $newNoteId),
    
                );
    
                $createActivity = new CreateActivity();
                $noteObjectClass = config('ActivityPub')->noteObject;
                $createActivity
                    ->set('actor', $note->actor->uri)
                    ->set('object', new $noteObjectClass($note));
    
                $activityId = model('ActivityModel')->newActivity(
                    'Create',
                    $note->actor_id,
                    null,
    
                    $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(),
                ]);
            }
    
            $this->db->transComplete();
    
            return $newNoteId;
        }
    
    
        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);
    
    
            // Clear note cache
            $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(),
    
    
                $replyToNote = $note->reply_to_note;
                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 {
    
                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())
    
            $hashedNoteUri = md5($note->uri);
            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(),
                ]);
            }
    
            $this->db->transComplete();
    
            return $reblogId;
        }
    
    
        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",
            );
    
    
                    $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',
                );
    
                $undoActivity->set(
                    'id',
                    base_url(
                        route_to(
                            'activity',
                            $reblogNote->actor->username,
                            $activityId,
                        ),
                    ),
                );
    
                model('ActivityModel')->update($activityId, [
                    'payload' => $undoActivity->toJSON(),
                ]);
            }
    
            $result = model('NoteModel', false)->delete($reblogNote->id);
    
            $this->db->transComplete();
    
            return $result;
        }
    
    
        public function toggleReblog(Actor $actor, Note $note): void
    
        {
            if (
                !($reblogNote = $this->where([
                    'actor_id' => $actor->id,
    
                        ->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()),
                );
            }
    
            return $data;
        }
    }