RepeatNote.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  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. /**
  20. * @author Eliseu Amaro <mail@eliseuama.ro>
  21. * @author Diogo Peralta Cordeiro <@diogo.site>
  22. * @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
  23. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  24. */
  25. namespace Plugin\RepeatNote;
  26. use App\Core\Cache;
  27. use App\Core\DB\DB;
  28. use App\Core\Event;
  29. use function App\Core\I18n\_m;
  30. use App\Core\Modules\NoteHandlerPlugin;
  31. use App\Core\Router\RouteLoader;
  32. use App\Core\Router\Router;
  33. use App\Entity\Activity;
  34. use App\Entity\Actor;
  35. use App\Entity\Note;
  36. use App\Util\Common;
  37. use App\Util\Exception\ClientException;
  38. use App\Util\Exception\DuplicateFoundException;
  39. use App\Util\Exception\ServerException;
  40. use Component\Language\Entity\Language;
  41. use Component\Posting\Posting;
  42. use DateTime;
  43. use Plugin\RepeatNote\Entity\NoteRepeat as RepeatEntity;
  44. use const SORT_REGULAR;
  45. use Symfony\Component\HttpFoundation\Request;
  46. class RepeatNote extends NoteHandlerPlugin
  47. {
  48. /**
  49. * **Repeats a Note**
  50. *
  51. * This means the current Actor creates a new Note, cloning the contents of
  52. * the original Note provided as an argument.
  53. *
  54. * Bear in mind that, if it's a repeat, the **reply_to** should be to the
  55. * original, and **conversation** ought to be the same.
  56. *
  57. * In the end, the Activity is created, and a new notification for the
  58. * repeat Activity created
  59. *
  60. * @param Note $note Note being repeated
  61. * @param int $actor_id Actor id of whom is performing the repeat Activity
  62. *
  63. * @throws ClientException
  64. * @throws DuplicateFoundException
  65. * @throws ServerException
  66. */
  67. public static function repeatNote(Note $note, int $actor_id, string $source = 'web'): ?Activity
  68. {
  69. $note_repeat = RepeatEntity::getNoteActorRepeat($note, $actor_id);
  70. if (!\is_null($note_repeat)) {
  71. return null;
  72. }
  73. $original_note_id = $note->getId();
  74. // Create a new note with the same content as the original
  75. [, $repeat, ] = Posting::storeLocalNote(
  76. actor: Actor::getById($actor_id),
  77. content: $note->getContent(),
  78. content_type: $note->getContentType(),
  79. locale: \is_null($lang_id = $note->getLanguageId()) ? null : Language::getById($lang_id)->getLocale(),
  80. // If it's a repeat, the reply_to should be to the original, conversation ought to be the same
  81. reply_to: $note->getReplyTo(),
  82. processed_attachments: $processed_attachments = $note->getAttachmentsWithTitle(),
  83. flush_and_notify: false,
  84. rendered: $note->getRendered(),
  85. );
  86. // Increment attachment lives, posting already handled actor_to_attachment relations for us,
  87. // and it obviously handled the attachment_to_note
  88. foreach ($processed_attachments as [$a, $fname]) {
  89. // We usually don't have to increment lives manually when storing an attachment, but we aren't re-storing it
  90. $a->livesIncrementAndGet();
  91. }
  92. DB::persist(RepeatEntity::create([
  93. 'note_id' => $repeat->getId(),
  94. 'actor_id' => $actor_id,
  95. 'repeat_of' => $original_note_id,
  96. ]));
  97. Cache::delete(RepeatEntity::cacheKeys($note->getId(), $actor_id)['actor_repeat']);
  98. Cache::delete(RepeatEntity::cacheKeys($note->getId())['repeats']);
  99. // Log an activity
  100. $repeat_activity = Activity::create([
  101. 'actor_id' => $actor_id,
  102. 'verb' => 'repeat',
  103. 'object_type' => 'note',
  104. 'object_id' => $note->getId(),
  105. 'source' => $source,
  106. ]);
  107. DB::persist($repeat_activity);
  108. return $repeat_activity;
  109. }
  110. /**
  111. * **Undoes a Repeat**
  112. *
  113. * Removes the Repeat from NoteRepeat table, and the deletes the Note
  114. * clone.
  115. *
  116. * Finally, creates a new Activity, undoing the repeat, and the respective
  117. * Notification is handled.
  118. *
  119. * @param int $note_id Note id being unrepeated
  120. * @param int $actor_id Actor undoing repeat Activity
  121. *
  122. * @throws \App\Util\Exception\NotFoundException
  123. * @throws DuplicateFoundException
  124. * @throws ServerException
  125. */
  126. public static function unrepeatNote(int $note_id, int $actor_id, string $source = 'web'): ?Activity
  127. {
  128. $note_repeat = RepeatEntity::getNoteActorRepeat($note_id, $actor_id);
  129. if (!\is_null($note_repeat)) { // If it was repeated, then we can undo it
  130. // Find previous repeat activity
  131. $already_repeated_activity = DB::findOneBy(Activity::class, [
  132. 'actor_id' => $actor_id,
  133. 'verb' => 'repeat',
  134. 'object_type' => 'note',
  135. 'object_id' => $note_repeat->getRepeatOf(),
  136. ], return_null: true);
  137. // Remove the clone note
  138. DB::findOneBy(Note::class, ['id' => $note_repeat->getNoteId()])->delete(actor: Actor::getById($actor_id));
  139. // Remove from the note_repeat table
  140. DB::removeBy(RepeatEntity::class, ['note_id' => $note_repeat->getNoteId()]);
  141. Cache::delete(RepeatEntity::cacheKeys($note_id, $actor_id)['actor_repeat']);
  142. Cache::delete(RepeatEntity::cacheKeys($note_repeat->getNoteId())['repeats']);
  143. // Log an activity
  144. $undo_repeat_activity = Activity::create([
  145. 'actor_id' => $actor_id,
  146. 'verb' => 'undo',
  147. 'object_type' => 'activity',
  148. 'object_id' => $already_repeated_activity->getId(),
  149. 'source' => $source,
  150. ]);
  151. DB::persist($undo_repeat_activity);
  152. return $undo_repeat_activity;
  153. } else {
  154. // Either was undoed already
  155. if (!\is_null($already_repeated_activity = DB::findOneBy(Activity::class, [
  156. 'actor_id' => $actor_id,
  157. 'verb' => 'repeat',
  158. 'object_type' => 'note',
  159. 'object_id' => $note_id,
  160. ], return_null: true))) {
  161. return DB::findOneBy(Activity::class, [
  162. 'actor_id' => $actor_id,
  163. 'verb' => 'undo',
  164. 'object_type' => 'activity',
  165. 'object_id' => $already_repeated_activity->getId(),
  166. ], return_null: true); // null if not undoed
  167. } else {
  168. // or it's an attempt to undo something that wasn't repeated in the first place,
  169. return null;
  170. }
  171. }
  172. }
  173. /**
  174. * Filters repeats out of Conversations, and replaces a repeat with the
  175. * original Note on Actor feed
  176. *
  177. * @param array $notes List of Notes to be filtered
  178. *
  179. * @return bool Event hook, Event::next (true) is returned to allow Event to be handled by other handlers
  180. */
  181. public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): bool
  182. {
  183. // Replaces repeat with original note on Actor feed
  184. // it's pretty cool
  185. if (str_starts_with($request->get('_route'), 'actor_view_')) {
  186. $notes = array_map(
  187. static fn (Note $note) => RepeatEntity::isNoteRepeat($note)
  188. ? Note::getById(RepeatEntity::getByPK($note->getId())->getRepeatOf())
  189. : $note,
  190. $notes,
  191. );
  192. return Event::next;
  193. }
  194. // Filter out repeats altogether
  195. $notes = array_filter($notes, static fn (Note $note) => !RepeatEntity::isNoteRepeat($note));
  196. return Event::next;
  197. }
  198. /**
  199. * HTML rendering event that adds the repeat form as a note
  200. * action, if a user is logged in
  201. *
  202. * @return bool Event hook, Event::next (true) is returned to allow Event to be handled by other handlers
  203. */
  204. public function onAddNoteActions(Request $request, Note $note, array &$actions): bool
  205. {
  206. // Only logged users can repeat notes
  207. if (\is_null($user = Common::user())) {
  208. return Event::next;
  209. }
  210. $note_repeat = RepeatEntity::getNoteActorRepeat($note, $user->getId());
  211. // If note is repeated, "is_repeated" is 1, 0 otherwise.
  212. $is_repeat = !\is_null($note_repeat);
  213. // Generating URL for repeat action route
  214. $args = ['note_id' => !$is_repeat ? $note->getId() : $note_repeat->getRepeatOf()];
  215. $type = Router::ABSOLUTE_PATH;
  216. $repeat_action_url = $is_repeat
  217. ? Router::url('repeat_remove', $args, $type)
  218. : Router::url('repeat_add', $args, $type);
  219. // Concatenating get parameter to redirect the user to where he came from
  220. $repeat_action_url .= '?from=' . urlencode($request->getRequestUri());
  221. $extra_classes = $is_repeat ? 'note-actions-set' : 'note-actions-unset';
  222. $repeat_action = [
  223. 'url' => $repeat_action_url,
  224. 'title' => $is_repeat ? 'Remove this repeat' : 'Repeat this note!',
  225. 'classes' => "button-container repeat-button-container {$extra_classes}",
  226. 'note_id' => 'repeat-button-container-' . $note->getId(),
  227. ];
  228. $actions[] = $repeat_action;
  229. return Event::next;
  230. }
  231. /**
  232. * Appends in Note information stating who and what user actions were
  233. * performed.
  234. *
  235. * @param array $vars Contains the Note currently being rendered
  236. * @param array $result Rendered String containing anchors for Actors that
  237. * repeated the Note
  238. *
  239. * @return bool Event hook, Event::next (true) is returned to allow Event to be handled by other handlers
  240. */
  241. public function onAppendCardNote(array $vars, array &$result)
  242. {
  243. // If note is the original and user isn't the one who repeated, append on end "user repeated this"
  244. // If user is the one who repeated, append on end "you repeated this, remove repeat?"
  245. $check_user = !\is_null(Common::user());
  246. // The current Note being rendered
  247. $note = $vars['note'];
  248. // Will have actors array, and action string
  249. // Actors are the subjects, action is the verb (in the final phrase)
  250. $repeat_actors = [];
  251. $note_repeats = RepeatEntity::getNoteRepeats($note);
  252. // Get actors who repeated the note
  253. foreach ($note_repeats as $repeat) {
  254. $repeat_actors[] = Actor::getByPK($repeat->getActorId());
  255. }
  256. if (\count($repeat_actors) < 1) {
  257. return Event::next;
  258. }
  259. // Filter out multiple replies from the same actor
  260. $repeat_actors = array_unique($repeat_actors, SORT_REGULAR);
  261. $result[] = ['actors' => $repeat_actors, 'action' => 'repeated'];
  262. return Event::next;
  263. }
  264. /**
  265. * Deletes every repeat entity that is related to a deleted Note in its
  266. * respective table
  267. *
  268. * @param Note $note Note to be deleted
  269. * @param Actor $actor Who performed the Delete action
  270. *
  271. * @return bool Event hook, Event::next (true) is returned to allow Event to be handled by other handlers
  272. */
  273. public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
  274. {
  275. $note_repeats_list = RepeatEntity::getNoteRepeats($note);
  276. foreach ($note_repeats_list as $note_repeat) {
  277. DB::remove($note_repeat);
  278. }
  279. return Event::next;
  280. }
  281. /**
  282. * Connects the following Routes to their respective Controllers:
  283. *
  284. * - **repeat_add**
  285. * page containing the Note the user wishes to Repeat and a button to
  286. * confirm it wishes to perform the action
  287. *
  288. * - **repeat_remove**
  289. * same as above, except that it undoes the aforementioned action
  290. *
  291. * @return bool Event hook, Event::next (true) is returned to allow Event to be handled by other handlers
  292. */
  293. public function onAddRoute(RouteLoader $r): bool
  294. {
  295. // Add/remove note to/from repeats
  296. $r->connect(id: 'repeat_add', uri_path: '/object/note/{note_id<\d+>}/repeat', target: [Controller\Repeat::class, 'repeatAddNote']);
  297. $r->connect(id: 'repeat_remove', uri_path: '/object/note/{note_id<\d+>}/unrepeat', target: [Controller\Repeat::class, 'repeatRemoveNote']);
  298. return Event::next;
  299. }
  300. // ActivityPub handling and processing for Repeats is below
  301. /**
  302. * ActivityPub Inbox handler for Announces and Undo Announce activities
  303. *
  304. * @param Actor $actor Actor who authored the activity
  305. * @param \ActivityPhp\Type\AbstractObject $type_activity Activity Streams 2.0 Activity
  306. * @param mixed $type_object Activity's Object
  307. * @param null|\Plugin\ActivityPub\Entity\ActivitypubActivity $ap_act Resulting ActivitypubActivity
  308. *
  309. * @throws \App\Util\Exception\NoSuchActorException
  310. * @throws \App\Util\Exception\NotFoundException
  311. * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
  312. * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
  313. * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
  314. * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
  315. * @throws ClientException
  316. * @throws DuplicateFoundException
  317. * @throws ServerException
  318. *
  319. * @return bool Returns `Event::stop` if handled, `Event::next` otherwise
  320. */
  321. private function activitypub_handler(Actor $actor, \ActivityPhp\Type\AbstractObject $type_activity, mixed $type_object, ?\Plugin\ActivityPub\Entity\ActivitypubActivity &$ap_act): bool
  322. {
  323. if (!\in_array($type_activity->get('type'), ['Announce', 'Undo']) || !$actor->isPerson()) {
  324. return Event::next;
  325. }
  326. if ($type_activity->get('type') === 'Announce') { // Repeat
  327. if ($type_object instanceof \ActivityPhp\Type\AbstractObject) {
  328. if ($type_object->get('type') === 'Note' || $type_object->get('type') === 'ChatMessage' || $type_object->get('type') === 'Page') {
  329. $note = \Plugin\ActivityPub\Util\Model\Note::fromJson($type_object);
  330. $note_id = $note->getId();
  331. } else {
  332. return Event::next;
  333. }
  334. } elseif ($type_object instanceof Note) {
  335. $note = $type_object;
  336. $note_id = $note->getId();
  337. } else {
  338. return Event::next;
  339. }
  340. $activity = self::repeatNote($note ?? Note::getById($note_id), $actor->getId(), source: 'ActivityPub');
  341. } elseif ($type_activity->get('type') === 'Undo') { // Undo Repeat
  342. if ($type_object instanceof \ActivityPhp\Type\AbstractObject) {
  343. $ap_prev_repeat_act = \Plugin\ActivityPub\Util\Model\Activity::fromJson($type_object);
  344. $prev_repeat_act = $ap_prev_repeat_act->getActivity();
  345. if ($prev_repeat_act->getVerb() === 'repeat' && $prev_repeat_act->getObjectType() === 'note') {
  346. $note_id = $prev_repeat_act->getObjectId();
  347. } else {
  348. return Event::next;
  349. }
  350. } elseif ($type_object instanceof Activity) {
  351. if ($type_object->getVerb() === 'repeat' && $type_object->getObjectType() === 'note') {
  352. $note_id = $type_object->getObjectId();
  353. } else {
  354. return Event::next;
  355. }
  356. } else {
  357. return Event::next;
  358. }
  359. $activity = self::unrepeatNote($note_id, $actor->getId(), source: 'ActivityPub');
  360. } else {
  361. return Event::next;
  362. }
  363. if (!\is_null($activity)) {
  364. // Store ActivityPub Activity
  365. $ap_act = \Plugin\ActivityPub\Entity\ActivitypubActivity::create([
  366. 'activity_id' => $activity->getId(),
  367. 'activity_uri' => $type_activity->get('id'),
  368. 'created' => new DateTime($type_activity->get('published') ?? 'now'),
  369. 'modified' => new DateTime(),
  370. ]);
  371. DB::persist($ap_act);
  372. }
  373. return Event::stop;
  374. }
  375. public function onActivityPubNewNotification(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
  376. {
  377. switch ($activity->getVerb()) {
  378. case 'repeat':
  379. Event::handle('NewNotification', [$sender, $activity, $targets, _m('{actor_id} repeated note {note_id}.', ['{actor_id}' => $sender->getId(), '{note_id}' => $activity->getObjectId()])]);
  380. return Event::stop;
  381. case 'undo':
  382. if ($activity->getObjectType() === 'activity') {
  383. $undone_repeat = $activity->getObject();
  384. if ($undone_repeat->getVerb() === 'repeat') {
  385. Event::handle('NewNotification', [$sender, $activity, $targets, _m('{actor_id} unrepeated note {note_id}.', ['{actor_id}' => $sender->getId(), '{note_id}' => $undone_repeat->getObjectId()])]);
  386. return Event::stop;
  387. }
  388. }
  389. }
  390. return Event::next;
  391. }
  392. /**
  393. * Convert an Activity Streams 2.0 Announce or Undo Announce into the appropriate Repeat and Undo Repeat entities
  394. *
  395. * @param Actor $actor Actor who authored the activity
  396. * @param \ActivityPhp\Type\AbstractObject $type_activity Activity Streams 2.0 Activity
  397. * @param \ActivityPhp\Type\AbstractObject $type_object Activity Streams 2.0 Object
  398. * @param null|\Plugin\ActivityPub\Entity\ActivitypubActivity $ap_act Resulting ActivitypubActivity
  399. *
  400. * @throws \App\Util\Exception\NoSuchActorException
  401. * @throws \App\Util\Exception\NotFoundException
  402. * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
  403. * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
  404. * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
  405. * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
  406. * @throws ClientException
  407. * @throws DuplicateFoundException
  408. * @throws ServerException
  409. *
  410. * @return bool Returns `Event::stop` if handled, `Event::next` otherwise
  411. */
  412. public function onNewActivityPubActivity(Actor $actor, \ActivityPhp\Type\AbstractObject $type_activity, \ActivityPhp\Type\AbstractObject $type_object, ?\Plugin\ActivityPub\Entity\ActivitypubActivity &$ap_act): bool
  413. {
  414. return $this->activitypub_handler($actor, $type_activity, $type_object, $ap_act);
  415. }
  416. /**
  417. * Convert an Activity Streams 2.0 formatted activity with a known object into Entities
  418. *
  419. * @param Actor $actor Actor who authored the activity
  420. * @param \ActivityPhp\Type\AbstractObject $type_activity Activity Streams 2.0 Activity
  421. * @param mixed $type_object Object
  422. * @param null|\Plugin\ActivityPub\Entity\ActivitypubActivity $ap_act Resulting ActivitypubActivity
  423. *
  424. * @throws \App\Util\Exception\NoSuchActorException
  425. * @throws \App\Util\Exception\NotFoundException
  426. * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
  427. * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
  428. * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
  429. * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
  430. * @throws ClientException
  431. * @throws DuplicateFoundException
  432. * @throws ServerException
  433. *
  434. * @return bool Returns `Event::stop` if handled, `Event::next` otherwise
  435. */
  436. public function onNewActivityPubActivityWithObject(Actor $actor, \ActivityPhp\Type\AbstractObject $type_activity, mixed $type_object, ?\Plugin\ActivityPub\Entity\ActivitypubActivity &$ap_act): bool
  437. {
  438. return $this->activitypub_handler($actor, $type_activity, $type_object, $ap_act);
  439. }
  440. /**
  441. * Translate GNU social internal verb 'repeat' to Activity Streams 2.0 'Announce'
  442. *
  443. * @param string $verb GNU social's internal verb
  444. * @param null|string $gs_verb_to_activity_stream_two_verb Resulting Activity Streams 2.0 verb
  445. *
  446. * @return bool Returns `Event::stop` if handled, `Event::next` otherwise
  447. */
  448. public function onGSVerbToActivityStreamsTwoActivityType(string $verb, ?string &$gs_verb_to_activity_stream_two_verb): bool
  449. {
  450. if ($verb === 'repeat') {
  451. $gs_verb_to_activity_stream_two_verb = 'Announce';
  452. return Event::stop;
  453. }
  454. return Event::next;
  455. }
  456. }