Posting.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  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\Cache;
  21. use App\Core\DB;
  22. use App\Core\Event;
  23. use App\Core\GSFile;
  24. use function App\Core\I18n\_m;
  25. use App\Core\Modules\Component;
  26. use App\Core\Router;
  27. use App\Core\VisibilityScope;
  28. use App\Entity\Activity;
  29. use App\Entity\Actor;
  30. use App\Entity\LocalUser;
  31. use App\Entity\Note;
  32. use App\Util\Common;
  33. use App\Util\Exception\BugFoundException;
  34. use App\Util\Exception\ClientException;
  35. use App\Util\Exception\DuplicateFoundException;
  36. use App\Util\Exception\RedirectException;
  37. use App\Util\Exception\ServerException;
  38. use App\Util\Formatting;
  39. use App\Util\HTML;
  40. use Component\Attachment\Entity\ActorToAttachment;
  41. use Component\Attachment\Entity\Attachment;
  42. use Component\Attachment\Entity\AttachmentToNote;
  43. use Component\Conversation\Conversation;
  44. use Component\Language\Entity\Language;
  45. use Component\Notification\Entity\Attention;
  46. use EventResult;
  47. use Symfony\Component\Form\FormInterface;
  48. use Symfony\Component\HttpFoundation\File\UploadedFile;
  49. use Symfony\Component\HttpFoundation\Request;
  50. class Posting extends Component
  51. {
  52. public const route = 'posting_form_action';
  53. public function onAddRoute(Router $r): EventResult
  54. {
  55. $r->connect(self::route, '/form/posting', Controller\Posting::class);
  56. return Event::next;
  57. }
  58. /**
  59. * HTML render event handler responsible for adding and handling
  60. * the result of adding the note submission form, only if a user is logged in
  61. *
  62. * @param array{post_form?: FormInterface} $res
  63. *
  64. * @throws BugFoundException
  65. * @throws ClientException
  66. * @throws DuplicateFoundException
  67. * @throws RedirectException
  68. * @throws ServerException
  69. */
  70. public function onAddMainRightPanelBlock(Request $request, array &$res): EventResult
  71. {
  72. if (\is_null($user = Common::user()) || preg_match('(feed|conversation|group|view)', $request->get('_route')) === 0) {
  73. return Event::next;
  74. }
  75. $res['post_form'] = Form\Posting::create($request)->createView();
  76. return Event::next;
  77. }
  78. /**
  79. * @param Actor $actor The Actor responsible for the creation of this Note
  80. * @param null|string $content The raw text content
  81. * @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...)
  82. * @param null|string $locale Note's written text language, set by the default Actor language or upon filling
  83. * @param null|VisibilityScope $scope The visibility of this Note
  84. * @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and target
  85. * @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
  86. * @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note
  87. * @param array<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
  88. * @param array{note?: Note, content?: string, content_type?: string, extra_args?: array<string, mixed>} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
  89. * @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
  90. * @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
  91. * @param string $source The source of this Note
  92. *
  93. * @throws ClientException
  94. * @throws DuplicateFoundException
  95. * @throws ServerException
  96. *
  97. * @return array{\App\Entity\Activity, \App\Entity\Note, array<int, \App\Entity\Actor>}
  98. */
  99. public static function storeLocalArticle(
  100. Actor $actor,
  101. ?string $content,
  102. string $content_type,
  103. ?string $locale = null,
  104. ?VisibilityScope $scope = null,
  105. array $attentions = [],
  106. null|int|Note $reply_to = null,
  107. array $attachments = [],
  108. array $processed_attachments = [],
  109. array $process_note_content_extra_args = [],
  110. bool $flush_and_notify = true,
  111. ?string $rendered = null,
  112. string $source = 'web',
  113. ?string $title = null,
  114. ): array {
  115. [$activity, $note, $effective_attentions] = self::storeLocalNote(
  116. actor: $actor,
  117. content: $content,
  118. content_type: $content_type,
  119. locale: $locale,
  120. scope: $scope,
  121. attentions: $attentions,
  122. reply_to: $reply_to,
  123. attachments: $attachments,
  124. processed_attachments: $processed_attachments,
  125. process_note_content_extra_args: $process_note_content_extra_args,
  126. flush_and_notify: false,
  127. rendered: $rendered,
  128. source: $source,
  129. );
  130. $note->setType('article');
  131. $note->setTitle($title);
  132. if ($flush_and_notify) {
  133. // Flush before notification
  134. DB::flush();
  135. Event::handle('NewNotification', [
  136. $actor,
  137. $activity,
  138. $effective_attentions,
  139. _m('Actor {actor_id} created article {note_id}.', [
  140. '{actor_id}' => $actor->getId(),
  141. '{note_id}' => $activity->getObjectId(),
  142. ]),
  143. ]);
  144. }
  145. return [$activity, $note, $effective_attentions];
  146. }
  147. /**
  148. * Store the given note with $content and $attachments, created by
  149. * $actor_id, possibly as a reply to note $reply_to and with flag
  150. * $is_local. Sanitizes $content and $attachments
  151. *
  152. * @param Actor $actor The Actor responsible for the creation of this Note
  153. * @param null|string $content The raw text content
  154. * @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...)
  155. * @param null|string $locale Note's written text language, set by the default Actor language or upon filling
  156. * @param null|VisibilityScope $scope The visibility of this Note
  157. * @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and targte
  158. * @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
  159. * @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note
  160. * @param array<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
  161. * @param array{note?: Note, content?: string, content_type?: string, extra_args?: array<string, mixed>} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
  162. * @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
  163. * @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
  164. * @param string $source The source of this Note
  165. *
  166. * @throws ClientException
  167. * @throws DuplicateFoundException
  168. * @throws ServerException
  169. *
  170. * @return array{\App\Entity\Activity, \App\Entity\Note, array<int, \App\Entity\Actor>}
  171. */
  172. public static function storeLocalNote(
  173. Actor $actor,
  174. ?string $content,
  175. string $content_type,
  176. ?string $locale = null,
  177. ?VisibilityScope $scope = null,
  178. array $attentions = [],
  179. null|int|Note $reply_to = null,
  180. array $attachments = [],
  181. array $processed_attachments = [],
  182. array $process_note_content_extra_args = [],
  183. bool $flush_and_notify = true,
  184. ?string $rendered = null,
  185. string $source = 'web',
  186. ): array {
  187. $scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
  188. $reply_to_id = \is_null($reply_to) ? null : (\is_int($reply_to) ? $reply_to : $reply_to->getId());
  189. /** @var array<int, array{ mentioned?: array<int, Actor|LocalUser> }> $mentions */
  190. $mentions = [];
  191. if (\is_null($rendered) && !empty($content)) {
  192. Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
  193. }
  194. $note = Note::create([
  195. 'actor_id' => $actor->getId(),
  196. 'content' => $content,
  197. 'content_type' => $content_type,
  198. 'rendered' => $rendered,
  199. 'language_id' => !\is_null($locale) ? Language::getByLocale($locale)->getId() : null,
  200. 'is_local' => true,
  201. 'scope' => $scope,
  202. 'reply_to' => $reply_to_id,
  203. 'source' => $source,
  204. ]);
  205. /** @var UploadedFile[] $attachments */
  206. foreach ($attachments as $f) {
  207. $filesize = $f->getSize();
  208. $max_file_size = Common::getUploadLimit();
  209. if ($max_file_size < $filesize) {
  210. throw new ClientException(_m('No file may be larger than {quota} bytes and the file you sent was {size} bytes. '
  211. . 'Try to upload a smaller version.', ['quota' => $max_file_size, 'size' => $filesize], ));
  212. }
  213. Event::handle('EnforceUserFileQuota', [$filesize, $actor->getId()]);
  214. $processed_attachments[] = [GSFile::storeFileAsAttachment($f), $f->getClientOriginalName()];
  215. }
  216. DB::persist($note);
  217. Conversation::assignLocalConversation($note, $reply_to_id);
  218. // Update replies cache
  219. if (!\is_null($reply_to_id)) {
  220. Cache::incr(Note::cacheKeys($reply_to_id)['replies-count']);
  221. // Not having them cached doesn't mean replies don't exist, but don't push it to the
  222. // list, as that means they need to be re-fetched, or some would be missed
  223. if (Cache::exists(Note::cacheKeys($reply_to_id)['replies'])) {
  224. Cache::listPushRight(Note::cacheKeys($reply_to_id)['replies'], $note);
  225. }
  226. }
  227. // Need file and note ids for the next step
  228. $note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
  229. if (!empty($content)) {
  230. Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
  231. }
  232. // These are note attachments now, and not just attachments, ensure these relations are respected
  233. if ($processed_attachments !== []) {
  234. foreach ($processed_attachments as [$a, $fname]) {
  235. // Most attachments should already be associated with its author, but maybe it didn't make sense
  236. //for this attachment, or it's simply a repost of an attachment by a different actor
  237. if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
  238. DB::persist(ActorToAttachment::create($args));
  239. }
  240. DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
  241. }
  242. }
  243. $activity = Activity::create([
  244. 'actor_id' => $actor->getId(),
  245. 'verb' => 'create',
  246. 'object_type' => 'note',
  247. 'object_id' => $note->getId(),
  248. 'source' => $source,
  249. ]);
  250. DB::persist($activity);
  251. $effective_attentions = [];
  252. foreach ($attentions as $target) {
  253. if (\is_int($target)) {
  254. $target_id = $target;
  255. $add = !\array_key_exists($target_id, $effective_attentions);
  256. $effective_attentions[$target_id] = $target;
  257. } else {
  258. $target_id = $target->getId();
  259. if ($add = !\array_key_exists($target_id, $effective_attentions)) {
  260. $effective_attentions[$target_id] = $target_id;
  261. }
  262. }
  263. if ($add) {
  264. DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id]));
  265. }
  266. }
  267. foreach ($mentions as $m) {
  268. foreach ($m['mentioned'] ?? [] as $mentioned) {
  269. $target_id = $mentioned->getId();
  270. if (!\array_key_exists($target_id, $effective_attentions)) {
  271. DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id]));
  272. }
  273. $effective_attentions[$target_id] = $mentioned;
  274. }
  275. }
  276. foreach ($actor->getSubscribers() as $subscriber) {
  277. $target_id = $subscriber->getId();
  278. DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $target_id]));
  279. $effective_attentions[$target_id] = $subscriber;
  280. }
  281. if ($flush_and_notify) {
  282. // Flush before notification
  283. DB::flush();
  284. Event::handle('NewNotification', [
  285. $actor,
  286. $activity,
  287. $effective_attentions,
  288. _m('Actor {actor_id} created note {note_id}.', [
  289. '{actor_id}' => $actor->getId(),
  290. '{note_id}' => $activity->getObjectId(),
  291. ]),
  292. ]);
  293. }
  294. return [$activity, $note, $effective_attentions];
  295. }
  296. /**
  297. * @param array<int, \App\Entity\Actor> $mentions
  298. */
  299. public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = []): EventResult
  300. {
  301. switch ($content_type) {
  302. case 'text/plain':
  303. $rendered = Formatting::renderPlainText($content, $language);
  304. [$rendered, $mentions] = Formatting::linkifyMentions($rendered, $author, $language);
  305. return Event::stop;
  306. case 'text/html':
  307. // TODO: It has to linkify and stuff as well
  308. $rendered = HTML::sanitize($content);
  309. return Event::stop;
  310. default:
  311. return Event::next;
  312. }
  313. }
  314. }