upstream V3 development https://www.gnusocial.rocks/v3
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 
 
 

318 行
13 KiB

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