*/ protected $validationRules = [ 'actor_id' => 'required', 'message_html' => 'required_without[reblog_of_id]|max_length[500]', ]; /** * @var string[] */ 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 Note[] */ public function getActorPublishedNotes($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. * * @return Note[] */ 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($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; } /** * @return bool|Query */ 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 * * @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 $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): bool { $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, 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 * * @return BaseResult|bool */ public function removeNote(Note $note, bool $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); } // 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; } /** * @return string|bool */ 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; } /** * @return BaseResult|int|string|false */ public function reblog(Actor $actor, Note $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; } /** * @return BaseResult|bool */ public function undoReblog(Note $reblogNote, bool $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): void { 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; } }