Posting.php 12 KB

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