Posting.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. <?php
  2. declare(strict_types = 1);
  3. // {{{ License
  4. // This file is part of GNU social - https://www.gnu.org/software/social
  5. //
  6. // GNU social is free software: you can redistribute it and/or modify
  7. // it under the terms of the GNU Affero General Public License as published by
  8. // the Free Software Foundation, either version 3 of the License, or
  9. // (at your option) any later version.
  10. //
  11. // GNU social is distributed in the hope that it will be useful,
  12. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. // GNU Affero General Public License for more details.
  15. //
  16. // You should have received a copy of the GNU Affero General Public License
  17. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
  18. // }}}
  19. namespace Component\Posting;
  20. use App\Core\DB\DB;
  21. use App\Core\Event;
  22. use App\Core\Form;
  23. use App\Core\GSFile;
  24. use function App\Core\I18n\_m;
  25. use App\Core\Modules\Component;
  26. use App\Core\Router\Router;
  27. use App\Core\VisibilityScope;
  28. use App\Entity\Activity;
  29. use App\Entity\Actor;
  30. use App\Entity\Note;
  31. use App\Util\Common;
  32. use App\Util\Exception\BugFoundException;
  33. use App\Util\Exception\ClientException;
  34. use App\Util\Exception\DuplicateFoundException;
  35. use App\Util\Exception\RedirectException;
  36. use App\Util\Exception\ServerException;
  37. use App\Util\Form\FormFields;
  38. use App\Util\Formatting;
  39. use App\Util\HTML;
  40. use Component\Attachment\Entity\ActorToAttachment;
  41. use Component\Attachment\Entity\AttachmentToNote;
  42. use Component\Conversation\Conversation;
  43. use Component\Language\Entity\Language;
  44. use Functional as F;
  45. use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
  46. use Symfony\Component\Form\Extension\Core\Type\FileType;
  47. use Symfony\Component\Form\Extension\Core\Type\SubmitType;
  48. use Symfony\Component\Form\Extension\Core\Type\TextareaType;
  49. use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
  50. use Symfony\Component\HttpFoundation\File\UploadedFile;
  51. use Symfony\Component\HttpFoundation\Request;
  52. use Symfony\Component\Routing\Exception\ResourceNotFoundException;
  53. use Symfony\Component\Validator\Constraints\Length;
  54. class Posting extends Component
  55. {
  56. /**
  57. * HTML render event handler responsible for adding and handling
  58. * the result of adding the note submission form, only if a user is logged in
  59. *
  60. * @throws ClientException
  61. * @throws RedirectException
  62. * @throws ServerException
  63. */
  64. public function onAppendRightPostingBlock(Request $request, array &$res): bool
  65. {
  66. if (\is_null($user = Common::user())) {
  67. return Event::next;
  68. }
  69. $actor = $user->getActor();
  70. $placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
  71. Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
  72. $placeholder = $placeholder_strings[array_rand($placeholder_strings)];
  73. $initial_content = '';
  74. Event::handle('PostingInitialContent', [&$initial_content]);
  75. $available_content_types = [
  76. _m('Plain Text') => 'text/plain',
  77. ];
  78. Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
  79. $in_targets = [];
  80. Event::handle('PostingFillTargetChoices', [$request, $actor, &$in_targets]);
  81. $context_actor = null;
  82. Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
  83. $form_params = [];
  84. if (!empty($in_targets)) { // @phpstan-ignore-line
  85. // Add "none" option to the top of choices
  86. $in_targets = array_merge([_m('Public') => 'public'], $in_targets);
  87. $form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
  88. }
  89. // TODO: if in group page, add GROUP visibility to the choices.
  90. $form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [
  91. _m('Public') => VisibilityScope::EVERYWHERE->value,
  92. _m('Local') => VisibilityScope::LOCAL->value,
  93. _m('Addressee') => VisibilityScope::ADDRESSEE->value,
  94. ]]];
  95. $form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
  96. $form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
  97. $form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
  98. if (\count($available_content_types) > 1) {
  99. $form_params[] = ['content_type', ChoiceType::class,
  100. [
  101. 'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
  102. 'data' => $available_content_types[array_key_first($available_content_types)],
  103. 'choices' => $available_content_types,
  104. ],
  105. ];
  106. }
  107. Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
  108. $form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
  109. $form = Form::create($form_params);
  110. $form->handleRequest($request);
  111. if ($form->isSubmitted()) {
  112. try {
  113. if ($form->isValid()) {
  114. $data = $form->getData();
  115. Event::handle('PostingModifyData', [$request, $actor, &$data, $form_params, $form]);
  116. if (empty($data['content']) && empty($data['attachments'])) {
  117. // TODO Display error: At least one of `content` and `attachments` must be provided
  118. throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
  119. }
  120. if (\is_null(VisibilityScope::tryFrom($data['visibility']))) {
  121. throw new ClientException(_m('You have selected an impossible visibility.'));
  122. }
  123. $content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
  124. $extra_args = [];
  125. Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
  126. if (\array_key_exists('in', $data) && $data['in'] !== 'public') {
  127. $target = $data['in'];
  128. }
  129. self::storeLocalNote(
  130. actor: $user->getActor(),
  131. content: $data['content'],
  132. content_type: $content_type,
  133. locale: $data['language'],
  134. scope: VisibilityScope::from($data['visibility']),
  135. target: $target ?? null, // @phpstan-ignore-line
  136. reply_to_id: $data['reply_to_id'],
  137. attachments: $data['attachments'],
  138. process_note_content_extra_args: $extra_args,
  139. );
  140. try {
  141. if ($request->query->has('from')) {
  142. $from = $request->query->get('from');
  143. if (str_contains($from, '#')) {
  144. [$from, $fragment] = explode('#', $from);
  145. }
  146. Router::match($from);
  147. throw new RedirectException(url: $from . (isset($fragment) ? '#' . $fragment : ''));
  148. }
  149. } catch (ResourceNotFoundException $e) {
  150. // continue
  151. }
  152. throw new RedirectException();
  153. }
  154. } catch (FormSizeFileException $e) {
  155. throw new ClientException(_m('Invalid file size given'), previous: $e);
  156. }
  157. }
  158. $res['post_form'] = $form->createView();
  159. return Event::next;
  160. }
  161. /**
  162. * Store the given note with $content and $attachments, created by
  163. * $actor_id, possibly as a reply to note $reply_to and with flag
  164. * $is_local. Sanitizes $content and $attachments
  165. *
  166. * @param array $attachments Array of UploadedFile to be stored as GSFiles associated to this note
  167. * @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note
  168. * @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
  169. *
  170. * @throws BugFoundException
  171. * @throws ClientException
  172. * @throws DuplicateFoundException
  173. * @throws ServerException
  174. */
  175. public static function storeLocalNote(
  176. Actor $actor,
  177. ?string $content,
  178. string $content_type,
  179. ?string $locale = null,
  180. ?VisibilityScope $scope = null,
  181. null|Actor|int $target = null,
  182. ?int $reply_to_id = null,
  183. array $attachments = [],
  184. array $processed_attachments = [],
  185. array $process_note_content_extra_args = [],
  186. bool $notify = true,
  187. ?string $rendered = null,
  188. string $source = 'web',
  189. ): Note {
  190. $scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
  191. $mentions = [];
  192. if (\is_null($rendered) && !empty($content)) {
  193. Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
  194. }
  195. $note = Note::create([
  196. 'actor_id' => $actor->getId(),
  197. 'content' => $content,
  198. 'content_type' => $content_type,
  199. 'rendered' => $rendered,
  200. 'language_id' => !\is_null($locale) ? Language::getByLocale($locale)->getId() : null,
  201. 'is_local' => true,
  202. 'scope' => $scope,
  203. 'reply_to' => $reply_to_id,
  204. 'source' => $source,
  205. ]);
  206. /** @var UploadedFile[] $attachments */
  207. foreach ($attachments as $f) {
  208. $filesize = $f->getSize();
  209. $max_file_size = Common::getUploadLimit();
  210. if ($max_file_size < $filesize) {
  211. throw new ClientException(_m('No file may be larger than {quota} bytes and the file you sent was {size} bytes. '
  212. . 'Try to upload a smaller version.', ['quota' => $max_file_size, 'size' => $filesize], ));
  213. }
  214. Event::handle('EnforceUserFileQuota', [$filesize, $actor->getId()]);
  215. $processed_attachments[] = [GSFile::storeFileAsAttachment($f), $f->getClientOriginalName()];
  216. }
  217. DB::persist($note);
  218. // Need file and note ids for the next step
  219. $note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
  220. if (!empty($content)) {
  221. Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
  222. }
  223. if ($processed_attachments !== []) {
  224. foreach ($processed_attachments as [$a, $fname]) {
  225. if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
  226. DB::persist(ActorToAttachment::create($args));
  227. }
  228. DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
  229. $a->livesIncrementAndGet();
  230. }
  231. }
  232. Conversation::assignLocalConversation($note, $reply_to_id);
  233. $activity = Activity::create([
  234. 'actor_id' => $actor->getId(),
  235. 'verb' => 'create',
  236. 'object_type' => 'note',
  237. 'object_id' => $note->getId(),
  238. 'source' => $source,
  239. ]);
  240. DB::persist($activity);
  241. if (!\is_null($target)) {
  242. $target = \is_int($target) ? Actor::getById($target) : $target;
  243. $mentions[] = [
  244. 'mentioned' => [$target],
  245. 'type' => match ($target->getType()) {
  246. Actor::PERSON => 'mention',
  247. Actor::GROUP => 'group',
  248. default => throw new ClientException(_m('Unknown target type give in \'In\' field: {target}', ['{target}' => $target?->getNickname() ?? '<null>'])),
  249. },
  250. 'text' => $target->getNickname(),
  251. ];
  252. }
  253. $mention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId())));
  254. // Flush before notification
  255. DB::flush();
  256. if ($notify) {
  257. Event::handle('NewNotification', [$actor, $activity, ['object' => $mention_ids], _m('{nickname} created a note {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]);
  258. }
  259. return $note;
  260. }
  261. public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = [])
  262. {
  263. switch ($content_type) {
  264. case 'text/plain':
  265. $rendered = Formatting::renderPlainText($content, $language);
  266. [$rendered, $mentions] = Formatting::linkifyMentions($rendered, $author, $language);
  267. return Event::stop;
  268. case 'text/html':
  269. // TODO: It has to linkify and stuff as well
  270. $rendered = HTML::sanitize($content);
  271. return Event::stop;
  272. default:
  273. return Event::next;
  274. }
  275. }
  276. }