<?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\Note; use ActivityPub\Activities\AnnounceActivity; use ActivityPub\Activities\CreateActivity; use ActivityPub\Activities\DeleteActivity; use ActivityPub\Activities\UndoActivity; use ActivityPub\Objects\TombstoneObject; use CodeIgniter\Events\Events; use CodeIgniter\HTTP\URI; use CodeIgniter\I18n\Time; use Michalsn\Uuid\UuidModel; class NoteModel extends UuidModel { protected $table = 'activitypub_notes'; protected $primaryKey = 'id'; 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 = \ActivityPub\Entities\Note::class; protected $useSoftDeletes = false; protected $useTimestamps = true; protected $updatedField = null; protected $validationRules = [ 'actor_id' => 'required', 'message_html' => 'required_without[reblog_of_id]|max_length[500]', ]; protected $beforeInsert = ['setNoteId']; public function getNoteById($noteId) { $cacheName = config('ActivityPub')->cachePrefix . "note#{$noteId}"; if (!($found = cache($cacheName))) { $found = $this->find($noteId); cache()->save($cacheName, $found, DECADE); } return $found; } public function getNoteByUri($noteUri) { $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 * * @return \ActivityPub\Entities\Note[] */ public function getActorPublishedNotes($actorId) { $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. * * @param mixed $noteId * @param boolean $withBlocked false by default * @return array */ public function getNoteReplies($noteId, $withBlocked = false) { $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($noteId) { $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($noteId, $previewCardId) { 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 * * @param \ActivityPub\Entities\Note $note * @param boolean $registerActivity * @param boolean $createPreviewCard * @return string|false returns the new note id if success or false otherwise */ public function addNote( $note, $createPreviewCard = true, $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]), )) ) { if (!$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->id = $newNoteId; $note->uri = base_url( 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, $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(), ]); } $this->db->transComplete(); return $newNoteId; } public function editNote($updatedNote) { $this->db->transStart(); // update note create activity schedule in database $scheduledActivity = model('ActivityModel') ->where([ 'type' => 'Create', 'note_id' => $this->uuid ->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), '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 * * @param \ActivityPub\Entities\Note $note * @return mixed */ public function removeNote($note, $registerActivity = true) { $this->db->transStart(); $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(), ) ->decrement('replies_count'); $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); } // remove all note reblogs foreach ($note->reblogs as $reblog) { $this->removeNote($reblog); } // remove all note replies foreach ($note->replies as $reply) { $this->removeNote($reply); } if ($note->preview_card) { // check that preview card in no longer used elsewhere before deleting it if ( $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( $reply, $createPreviewCard = true, $registerActivity = true ) { 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(), ) ->increment('replies_count'); $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; } /** * * @param \ActivityPub\Entities\Actor $actor * @param \ActivityPub\Entities\Note $note * @return ActivityPub\Models\BaseResult|int|string|false */ public function reblog($actor, $note, $registerActivity = true) { $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, true); 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"); model('NoteModel') ->where('id', $this->uuid->fromString($note->id)->getBytes()) ->increment('reblogs_count'); $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; } /** * @param \ActivityPub\Entities\Note $reblogNote * @return mixed */ public function undoReblog($reblogNote, $registerActivity = true) { $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", ); model('NoteModel') ->where( 'id', $this->uuid->fromString($reblogNote->reblog_of_id)->getBytes(), ) ->decrement('reblogs_count'); $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, 'note_id' => $this->uuid ->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, $note) { if ( !($reblogNote = $this->where([ 'actor_id' => $actor->id, 'reblog_of_id' => $this->uuid ->fromString($note->id) ->getBytes(), ])->first()) ) { $this->reblog($actor, $note); } else { $this->undoReblog($reblogNote); } } protected function setNoteId($data) { $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; } }