Note.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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. * ActivityPub implementation for GNU social
  21. *
  22. * @package GNUsocial
  23. * @category ActivityPub
  24. *
  25. * @author Diogo Peralta Cordeiro <@diogo.site>
  26. * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
  27. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  28. */
  29. namespace Plugin\ActivityPub\Util\Model;
  30. use ActivityPhp\Type;
  31. use ActivityPhp\Type\AbstractObject;
  32. use App\Core\Cache;
  33. use App\Core\DB\DB;
  34. use App\Core\Event;
  35. use App\Core\GSFile;
  36. use App\Core\HTTPClient;
  37. use function App\Core\I18n\_m;
  38. use App\Core\Log;
  39. use App\Core\Router\Router;
  40. use App\Core\VisibilityScope;
  41. use App\Entity\Note as GSNote;
  42. use App\Util\Common;
  43. use App\Util\Exception\ClientException;
  44. use App\Util\Exception\DuplicateFoundException;
  45. use App\Util\Exception\NoSuchActorException;
  46. use App\Util\Exception\ServerException;
  47. use App\Util\Formatting;
  48. use App\Util\TemporaryFile;
  49. use Component\Attachment\Entity\ActorToAttachment;
  50. use Component\Attachment\Entity\AttachmentToNote;
  51. use Component\Conversation\Conversation;
  52. use Component\FreeNetwork\FreeNetwork;
  53. use Component\Language\Entity\Language;
  54. use Component\Tag\Entity\NoteTag;
  55. use Component\Tag\Tag;
  56. use DateTime;
  57. use DateTimeInterface;
  58. use Exception;
  59. use InvalidArgumentException;
  60. use Plugin\ActivityPub\ActivityPub;
  61. use Plugin\ActivityPub\Entity\ActivitypubObject;
  62. use Plugin\ActivityPub\Util\Model;
  63. use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
  64. use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
  65. use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
  66. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  67. /**
  68. * This class handles translation between JSON and GSNotes
  69. *
  70. * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
  71. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  72. */
  73. class Note extends Model
  74. {
  75. /**
  76. * Create an Entity from an ActivityStreams 2.0 JSON string
  77. * This will persist a new GSNote
  78. *
  79. * @throws ClientException
  80. * @throws ClientExceptionInterface
  81. * @throws DuplicateFoundException
  82. * @throws NoSuchActorException
  83. * @throws RedirectionExceptionInterface
  84. * @throws ServerException
  85. * @throws ServerExceptionInterface
  86. * @throws TransportExceptionInterface
  87. */
  88. public static function fromJson(string|AbstractObject $json, array $options = []): GSNote
  89. {
  90. $handleInReplyTo = function (AbstractObject|string $type_note): ?int {
  91. try {
  92. $parent_note = \is_null($type_note->get('inReplyTo')) ? null : ActivityPub::getObjectByUri($type_note->get('inReplyTo'), try_online: true);
  93. if ($parent_note instanceof GSNote) {
  94. return $parent_note->getId();
  95. } elseif ($parent_note instanceof Type\AbstractObject && $parent_note->get('type') === 'Note') {
  96. return self::fromJson($parent_note)->getId();
  97. } else {
  98. return null;
  99. }
  100. } catch (Exception $e) {
  101. Log::debug('ActivityStreams:Model:Note-> An error occurred retrieving parent note.', [$e]);
  102. // Sadly we won't be able to have this note inside the correct conversation for now.
  103. // TODO: Create an entity that registers notes falsely without parent so, when the parent is retrieved,
  104. // we can update the child with the correct parent.
  105. return null;
  106. }
  107. };
  108. $source = $options['source'] ?? 'ActivityPub';
  109. $type_note = \is_string($json) ? self::jsonToType($json) : $json;
  110. $actor = null;
  111. $actor_id = null;
  112. if ($json instanceof AbstractObject
  113. && \array_key_exists('test_authority', $options)
  114. && $options['test_authority']
  115. && \array_key_exists('actor_uri', $options)
  116. ) {
  117. $actor_uri = $options['actor_uri'];
  118. if ($actor_uri !== $type_note->get('attributedTo')) {
  119. if (parse_url($actor_uri)['host'] !== parse_url($type_note->get('attributedTo'))['host']) {
  120. throw new Exception('You don\'t seem to have enough authority to create this note.');
  121. }
  122. } else {
  123. $actor = $options['actor'] ?? null;
  124. $actor_id = $options['actor_id'] ?? $actor?->getId();
  125. }
  126. }
  127. if (\is_null($actor_id)) {
  128. $actor = ActivityPub::getActorByUri($type_note->get('attributedTo'));
  129. $actor_id = $actor->getId();
  130. }
  131. $map = [
  132. 'is_local' => false,
  133. 'created' => new DateTime($type_note->get('published') ?? 'now'),
  134. 'content' => $type_note->get('content') ?? null,
  135. 'rendered' => null,
  136. 'content_type' => 'text/html',
  137. 'language_id' => $type_note->get('contentLang') ?? null,
  138. 'url' => $type_note->get('url') ?? $type_note->get('id'),
  139. 'actor_id' => $actor_id,
  140. 'reply_to' => $reply_to = $handleInReplyTo($type_note),
  141. 'modified' => new DateTime(),
  142. 'source' => $source,
  143. ];
  144. if ($map['content'] !== null) {
  145. $mentions = [];
  146. Event::handle('RenderNoteContent', [
  147. $map['content'],
  148. $map['content_type'],
  149. &$map['rendered'],
  150. $actor,
  151. $map['language_id'],
  152. &$mentions,
  153. ]);
  154. }
  155. if (!\is_null($map['language_id'])) {
  156. $map['language_id'] = Language::getByLocale($map['language_id'])->getId();
  157. } else {
  158. $map['language_id'] = null;
  159. }
  160. // Scope
  161. if (\in_array('https://www.w3.org/ns/activitystreams#Public', $type_note->get('to'))) {
  162. // Public: Visible for all, shown in public feeds
  163. $map['scope'] = VisibilityScope::EVERYWHERE;
  164. } elseif (\in_array('https://www.w3.org/ns/activitystreams#Public', $type_note->get('cc'))) {
  165. // Unlisted: Visible for all but not shown in public feeds
  166. // It isn't the note that dictates what feed is shown in but the feed, it only dictates who can access it.
  167. $map['scope'] = VisibilityScope::EVERYWHERE;
  168. } else {
  169. // Either Followers-only or Direct
  170. if ($type_note->get('directMessage') ?? false // Is DM explicitly?
  171. || (empty($type_note->get('cc')))) { // Only has TO targets
  172. $map['scope'] = VisibilityScope::MESSAGE;
  173. } else { // Then is collection
  174. $map['scope'] = VisibilityScope::COLLECTION;
  175. }
  176. }
  177. $object_mentions_ids = [];
  178. foreach ([$type_note->get('to'), $type_note->get('cc')] as $target) {
  179. foreach ($target as $to) {
  180. if ($to === 'https://www.w3.org/ns/activitystreams#Public') {
  181. continue;
  182. }
  183. try {
  184. $actor = ActivityPub::getActorByUri($to);
  185. if ($actor->getIsLocal()) {
  186. $object_mentions_ids[] = $actor->getId();
  187. }
  188. // TODO: If group, set note's scope as Group
  189. } catch (Exception $e) {
  190. Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]);
  191. }
  192. }
  193. }
  194. $obj = new GSNote();
  195. foreach ($map as $prop => $val) {
  196. $set = Formatting::snakeCaseToCamelCase("set_{$prop}");
  197. $obj->{$set}($val);
  198. }
  199. // Attachments
  200. $processed_attachments = [];
  201. foreach ($type_note->get('attachment') as $attachment) {
  202. if ($attachment->get('type') === 'Document') {
  203. // Retrieve media
  204. $get_response = HTTPClient::get($attachment->get('url'));
  205. $media = $get_response->getContent();
  206. unset($get_response);
  207. // Ignore empty files
  208. if (!empty($media)) {
  209. // Create an attachment for this
  210. $temp_file = new TemporaryFile();
  211. $temp_file->write($media);
  212. $filesize = $temp_file->getSize();
  213. $max_file_size = Common::getUploadLimit();
  214. if ($max_file_size < $filesize) {
  215. throw new ClientException(_m('No file may be larger than {quota} bytes and the file you sent was {size} bytes. '
  216. . 'Try to upload a smaller version.', ['quota' => $max_file_size, 'size' => $filesize], ));
  217. }
  218. Event::handle('EnforceUserFileQuota', [$filesize, $actor_id]);
  219. $processed_attachments[] = [GSFile::storeFileAsAttachment($temp_file), $attachment->get('name')];
  220. }
  221. }
  222. }
  223. DB::persist($obj);
  224. // Assign conversation to this note
  225. Conversation::assignLocalConversation($obj, $reply_to);
  226. foreach ($type_note->get('tag') as $ap_tag) {
  227. switch ($ap_tag->get('type')) {
  228. case 'Mention':
  229. try {
  230. $actor = ActivityPub::getActorByUri($ap_tag->get('href'));
  231. if ($actor->getIsLocal()) {
  232. $object_mentions_ids[] = $actor->getId();
  233. }
  234. } catch (Exception $e) {
  235. Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]);
  236. }
  237. break;
  238. case 'Hashtag':
  239. $match = ltrim($ap_tag->get('name'), '#');
  240. $tag = Tag::extract($match);
  241. $canonical_tag = $ap_tag->get('canonical') ?? Tag::canonicalTag($tag, \is_null($lang_id = $obj->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
  242. DB::persist(NoteTag::create([
  243. 'tag' => $tag,
  244. 'canonical' => $canonical_tag,
  245. 'note_id' => $obj->getId(),
  246. 'use_canonical' => $ap_tag->get('canonical') ?? false,
  247. 'language_id' => $lang_id,
  248. ]));
  249. Cache::pushList("tag-{$canonical_tag}", $obj);
  250. foreach (Tag::cacheKeys($canonical_tag) as $key) {
  251. Cache::delete($key);
  252. }
  253. break;
  254. }
  255. }
  256. $obj->setObjectMentionsIds(array_unique($object_mentions_ids));
  257. // The content would be non-sanitized text/html
  258. Event::handle('ProcessNoteContent', [$obj, $obj->getRendered(), $obj->getContentType(), $process_note_content_extra_args = ['TagProcessed' => true]]);
  259. if ($processed_attachments !== []) {
  260. foreach ($processed_attachments as [$a, $fname]) {
  261. if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor_id]) === 0) {
  262. DB::persist(ActorToAttachment::create($args));
  263. }
  264. DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $obj->getId(), 'title' => $fname]));
  265. }
  266. }
  267. $map = [
  268. 'object_uri' => $type_note->get('id'),
  269. 'object_type' => 'note',
  270. 'object_id' => $obj->getId(),
  271. 'created' => new DateTime($type_note->get('published') ?? 'now'),
  272. 'modified' => new DateTime(),
  273. ];
  274. $ap_obj = new ActivitypubObject();
  275. foreach ($map as $prop => $val) {
  276. $set = Formatting::snakeCaseToCamelCase("set_{$prop}");
  277. $ap_obj->{$set}($val);
  278. }
  279. DB::persist($ap_obj);
  280. return $obj;
  281. }
  282. /**
  283. * Get a JSON
  284. *
  285. * @throws Exception
  286. */
  287. public static function toJson(mixed $object, ?int $options = null): string
  288. {
  289. if ($object::class !== GSNote::class) {
  290. throw new InvalidArgumentException('First argument type must be a Note.');
  291. }
  292. $attr = [
  293. '@context' => 'https://www.w3.org/ns/activitystreams',
  294. 'type' => 'Note',
  295. 'id' => $object->getUrl(),
  296. 'published' => $object->getCreated()->format(DateTimeInterface::RFC3339),
  297. 'attributedTo' => $object->getActor()->getUri(Router::ABSOLUTE_URL),
  298. 'content' => $object->getRendered(),
  299. 'attachment' => [],
  300. 'tag' => [],
  301. 'inReplyTo' => \is_null($object->getReplyTo()) ? null : ActivityPub::getUriByObject(GSNote::getById($object->getReplyTo())),
  302. 'inConversation' => $object->getConversationUri(),
  303. 'directMessage' => $object->getScope() === VisibilityScope::MESSAGE,
  304. ];
  305. // Target scope
  306. switch ($object->getScope()) {
  307. case VisibilityScope::EVERYWHERE:
  308. $attr['to'] = ['https://www.w3.org/ns/activitystreams#Public'];
  309. $attr['cc'] = [Router::url('actor_subscribers_id', ['id' => $object->getActor()->getId()], Router::ABSOLUTE_URL)];
  310. break;
  311. case VisibilityScope::LOCAL:
  312. throw new ClientException('This note was not federated.', 403);
  313. case VisibilityScope::ADDRESSEE:
  314. case VisibilityScope::MESSAGE:
  315. $attr['to'] = []; // Will be filled later
  316. $attr['cc'] = [];
  317. break;
  318. case VisibilityScope::GROUP: // Will have the group in the To
  319. case VisibilityScope::COLLECTION:
  320. // Since we don't support sending unlisted/followers-only
  321. // notices, arriving here means we're instead answering to that type
  322. // of posts. In this situation, it's safer to always send answers of type unlisted.
  323. $attr['to'] = [];
  324. $attr['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
  325. break;
  326. default:
  327. Log::error('ActivityPub->Note->toJson: Found an unknown visibility scope.');
  328. throw new ServerException('Found an unknown visibility scope which cannot federate.');
  329. }
  330. // Mentions
  331. foreach ($object->getNotificationTargets() as $mention) {
  332. $attr['tag'][] = [
  333. 'type' => 'Mention',
  334. 'href' => ($href = $mention->getUri()),
  335. 'name' => FreeNetwork::mentionToName($mention->getNickname(), $href),
  336. ];
  337. $attr['to'][] = $href;
  338. }
  339. // Hashtags
  340. foreach ($object->getTags() as $hashtag) {
  341. $attr['tag'][] = [
  342. 'type' => 'Hashtag',
  343. 'href' => $hashtag->getUrl(type: Router::ABSOLUTE_URL),
  344. 'name' => "#{$hashtag->getTag()}",
  345. 'canonical' => $hashtag->getCanonical(),
  346. ];
  347. }
  348. // Attachments
  349. foreach ($object->getAttachments() as $attachment) {
  350. $attr['attachment'][] = [
  351. 'type' => 'Document',
  352. 'mediaType' => $attachment->getMimetype(),
  353. 'url' => $attachment->getUrl(note: $object, type: Router::ABSOLUTE_URL),
  354. 'name' => AttachmentToNote::getByPK(['attachment_id' => $attachment->getId(), 'note_id' => $object->getId()])->getTitle(),
  355. 'width' => $attachment->getWidth(),
  356. 'height' => $attachment->getHeight(),
  357. ];
  358. }
  359. $type = self::jsonToType($attr);
  360. Event::handle('ActivityPubAddActivityStreamsTwoData', [$type->get('type'), &$type]);
  361. return $type->toJson($options);
  362. }
  363. }