. // }}} namespace Component\Posting; use App\Core\Cache; use App\Core\DB; use App\Core\Event; use App\Core\GSFile; use function App\Core\I18n\_m; use App\Core\Modules\Component; use App\Core\Router; use App\Core\VisibilityScope; use App\Entity\Activity; use App\Entity\Actor; use App\Entity\LocalUser; use App\Entity\Note; use App\Util\Common; use App\Util\Exception\BugFoundException; use App\Util\Exception\ClientException; use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\RedirectException; use App\Util\Exception\ServerException; use App\Util\Formatting; use App\Util\HTML; use Component\Attachment\Entity\ActorToAttachment; use Component\Attachment\Entity\Attachment; use Component\Attachment\Entity\AttachmentToNote; use Component\Conversation\Conversation; use Component\Language\Entity\Language; use Component\Notification\Entity\Attention; use EventResult; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; class Posting extends Component { public const route = 'posting_form_action'; public function onAddRoute(Router $r): EventResult { $r->connect(self::route, '/form/posting', Controller\Posting::class); return Event::next; } /** * HTML render event handler responsible for adding and handling * the result of adding the note submission form, only if a user is logged in * * @param array{post_form?: FormInterface} $res * * @throws BugFoundException * @throws ClientException * @throws DuplicateFoundException * @throws RedirectException * @throws ServerException */ public function onAddMainRightPanelBlock(Request $request, array &$res): EventResult { if (\is_null($user = Common::user()) || preg_match('(feed|conversation|group|view)', $request->get('_route')) === 0) { return Event::next; } $res['post_form'] = Form\Posting::create($request)->createView(); return Event::next; } /** * @param Actor $actor The Actor responsible for the creation of this Note * @param null|string $content The raw text content * @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...) * @param null|string $locale Note's written text language, set by the default Actor language or upon filling * @param null|VisibilityScope $scope The visibility of this Note * @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and target * @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself * @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note * @param array $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note * @param array{note?: Note, content?: string, content_type?: string, extra_args?: array} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent * @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification * @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent * @param string $source The source of this Note * * @throws ClientException * @throws DuplicateFoundException * @throws ServerException * * @return array{\App\Entity\Activity, \App\Entity\Note, array} */ public static function storeLocalArticle( Actor $actor, ?string $content, string $content_type, ?string $locale = null, ?VisibilityScope $scope = null, array $attentions = [], null|int|Note $reply_to = null, array $attachments = [], array $processed_attachments = [], array $process_note_content_extra_args = [], bool $flush_and_notify = true, ?string $rendered = null, string $source = 'web', ?string $title = null, ): array { [$activity, $note, $effective_attentions] = self::storeLocalNote( actor: $actor, content: $content, content_type: $content_type, locale: $locale, scope: $scope, attentions: $attentions, reply_to: $reply_to, attachments: $attachments, processed_attachments: $processed_attachments, process_note_content_extra_args: $process_note_content_extra_args, flush_and_notify: false, rendered: $rendered, source: $source, ); $note->setType('article'); $note->setTitle($title); if ($flush_and_notify) { // Flush before notification DB::flush(); Event::handle('NewNotification', [ $actor, $activity, $effective_attentions, _m('Actor {actor_id} created article {note_id}.', [ '{actor_id}' => $actor->getId(), '{note_id}' => $activity->getObjectId(), ]), ]); } return [$activity, $note, $effective_attentions]; } /** * Store the given note with $content and $attachments, created by * $actor_id, possibly as a reply to note $reply_to and with flag * $is_local. Sanitizes $content and $attachments * * @param Actor $actor The Actor responsible for the creation of this Note * @param null|string $content The raw text content * @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...) * @param null|string $locale Note's written text language, set by the default Actor language or upon filling * @param null|VisibilityScope $scope The visibility of this Note * @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and targte * @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself * @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note * @param array $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note * @param array{note?: Note, content?: string, content_type?: string, extra_args?: array} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent * @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification * @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent * @param string $source The source of this Note * * @throws ClientException * @throws DuplicateFoundException * @throws ServerException * * @return array{\App\Entity\Activity, \App\Entity\Note, array} */ public static function storeLocalNote( Actor $actor, ?string $content, string $content_type, ?string $locale = null, ?VisibilityScope $scope = null, array $attentions = [], null|int|Note $reply_to = null, array $attachments = [], array $processed_attachments = [], array $process_note_content_extra_args = [], bool $flush_and_notify = true, ?string $rendered = null, string $source = 'web', ): array { $scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL $reply_to_id = \is_null($reply_to) ? null : (\is_int($reply_to) ? $reply_to : $reply_to->getId()); /** @var array }> $mentions */ $mentions = []; if (\is_null($rendered) && !empty($content)) { Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]); } $note = Note::create([ 'actor_id' => $actor->getId(), 'content' => $content, 'content_type' => $content_type, 'rendered' => $rendered, 'language_id' => !\is_null($locale) ? Language::getByLocale($locale)->getId() : null, 'is_local' => true, 'scope' => $scope, 'reply_to' => $reply_to_id, 'source' => $source, ]); /** @var UploadedFile[] $attachments */ foreach ($attachments as $f) { $filesize = $f->getSize(); $max_file_size = Common::getUploadLimit(); if ($max_file_size < $filesize) { throw new ClientException(_m('No file may be larger than {quota} bytes and the file you sent was {size} bytes. ' . 'Try to upload a smaller version.', ['quota' => $max_file_size, 'size' => $filesize], )); } Event::handle('EnforceUserFileQuota', [$filesize, $actor->getId()]); $processed_attachments[] = [GSFile::storeFileAsAttachment($f), $f->getClientOriginalName()]; } DB::persist($note); Conversation::assignLocalConversation($note, $reply_to_id); // Update replies cache if (!\is_null($reply_to_id)) { Cache::incr(Note::cacheKeys($reply_to_id)['replies-count']); // Not having them cached doesn't mean replies don't exist, but don't push it to the // list, as that means they need to be re-fetched, or some would be missed if (Cache::exists(Note::cacheKeys($reply_to_id)['replies'])) { Cache::listPushRight(Note::cacheKeys($reply_to_id)['replies'], $note); } } // Need file and note ids for the next step $note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL)); if (!empty($content)) { Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]); } // These are note attachments now, and not just attachments, ensure these relations are respected if ($processed_attachments !== []) { foreach ($processed_attachments as [$a, $fname]) { // Most attachments should already be associated with its author, but maybe it didn't make sense //for this attachment, or it's simply a repost of an attachment by a different actor if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) { DB::persist(ActorToAttachment::create($args)); } DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname])); } } $activity = Activity::create([ 'actor_id' => $actor->getId(), 'verb' => 'create', 'object_type' => 'note', 'object_id' => $note->getId(), 'source' => $source, ]); DB::persist($activity); $effective_attentions = []; foreach ($attentions as $target) { if (\is_int($target)) { $target_id = $target; $add = !\array_key_exists($target_id, $effective_attentions); $effective_attentions[$target_id] = $target; } else { $target_id = $target->getId(); if ($add = !\array_key_exists($target_id, $effective_attentions)) { $effective_attentions[$target_id] = $target_id; } } if ($add) { DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id])); } } foreach ($mentions as $m) { foreach ($m['mentioned'] ?? [] as $mentioned) { $target_id = $mentioned->getId(); if (!\array_key_exists($target_id, $effective_attentions)) { DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id])); } $effective_attentions[$target_id] = $mentioned; } } foreach ($actor->getSubscribers() as $subscriber) { $target_id = $subscriber->getId(); DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $target_id])); $effective_attentions[$target_id] = $subscriber; } if ($flush_and_notify) { // Flush before notification DB::flush(); Event::handle('NewNotification', [ $actor, $activity, $effective_attentions, _m('Actor {actor_id} created note {note_id}.', [ '{actor_id}' => $actor->getId(), '{note_id}' => $activity->getObjectId(), ]), ]); } return [$activity, $note, $effective_attentions]; } /** * @param array $mentions */ public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = []): EventResult { switch ($content_type) { case 'text/plain': $rendered = Formatting::renderPlainText($content, $language); [$rendered, $mentions] = Formatting::linkifyMentions($rendered, $author, $language); return Event::stop; case 'text/html': // TODO: It has to linkify and stuff as well $rendered = HTML::sanitize($content); return Event::stop; default: return Event::next; } } }