upstream V3 development https://www.gnusocial.rocks/v3
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

478 lines
21 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. /**
  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;
  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;
  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\HTML;
  49. use App\Util\TemporaryFile;
  50. use Component\Attachment\Entity\ActorToAttachment;
  51. use Component\Attachment\Entity\AttachmentToNote;
  52. use Component\Conversation\Conversation;
  53. use Component\FreeNetwork\FreeNetwork;
  54. use Component\Language\Entity\Language;
  55. use Component\Notification\Entity\Attention;
  56. use Component\Tag\Entity\NoteTag;
  57. use Component\Tag\Tag;
  58. use DateTime;
  59. use DateTimeInterface;
  60. use Exception;
  61. use InvalidArgumentException;
  62. use Plugin\ActivityPub\ActivityPub;
  63. use Plugin\ActivityPub\Entity\ActivitypubObject;
  64. use Plugin\ActivityPub\Util\Explorer;
  65. use Plugin\ActivityPub\Util\Model;
  66. use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
  67. use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
  68. use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
  69. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  70. /**
  71. * This class handles translation between JSON and GSNotes
  72. *
  73. * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
  74. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  75. */
  76. class Note extends Model
  77. {
  78. /**
  79. * Create an Entity from an ActivityStreams 2.0 JSON string
  80. * This will persist a new GSNote
  81. *
  82. * @throws ClientException
  83. * @throws ClientExceptionInterface
  84. * @throws DuplicateFoundException
  85. * @throws NoSuchActorException
  86. * @throws RedirectionExceptionInterface
  87. * @throws ServerException
  88. * @throws ServerExceptionInterface
  89. * @throws TransportExceptionInterface
  90. */
  91. public static function fromJson(string|AbstractObject $json, array $options = []): GSNote
  92. {
  93. $handleInReplyTo = function (AbstractObject|string $type_note): ?int {
  94. try {
  95. $parent_note = \is_null($type_note->get('inReplyTo')) ? null : ActivityPub::getObjectByUri($type_note->get('inReplyTo'), try_online: true);
  96. if ($parent_note instanceof GSNote) {
  97. return $parent_note->getId();
  98. } elseif ($parent_note instanceof Type\AbstractObject && $parent_note->get('type') === 'Note') {
  99. return self::fromJson($parent_note)->getId();
  100. } else {
  101. return null;
  102. }
  103. } catch (Exception $e) {
  104. Log::debug('ActivityStreams:Model:Note-> An error occurred retrieving parent note.', [$e]);
  105. // Sadly we won't be able to have this note inside the correct conversation for now.
  106. // TODO: Create an entity that registers notes falsely without parent so, when the parent is retrieved,
  107. // we can update the child with the correct parent.
  108. return null;
  109. }
  110. };
  111. $source = $options['source'] ?? 'ActivityPub';
  112. $type_note = \is_string($json) ? self::jsonToType($json) : $json;
  113. $actor_id = null;
  114. $actor = null;
  115. $to = $type_note->has('to') ? (\is_string($type_note->get('to')) ? [$type_note->get('to')] : $type_note->get('to')) : [];
  116. $cc = $type_note->has('cc') ? (\is_string($type_note->get('cc')) ? [$type_note->get('cc')] : $type_note->get('cc')) : [];
  117. if ($json instanceof AbstractObject
  118. && \array_key_exists('test_authority', $options)
  119. && $options['test_authority']
  120. && \array_key_exists('actor_uri', $options)
  121. ) {
  122. $actor_uri = $options['actor_uri'];
  123. if ($actor_uri !== $type_note->get('attributedTo')) {
  124. if (parse_url($actor_uri)['host'] !== parse_url($type_note->get('attributedTo'))['host']) {
  125. throw new Exception('You don\'t seem to have enough authority to create this note.');
  126. }
  127. } else {
  128. $actor = $options['actor'] ?? null;
  129. $actor_id = $options['actor_id'] ?? $actor?->getId();
  130. }
  131. }
  132. if (\is_null($actor_id)) {
  133. $actor = Explorer::getOneFromUri($type_note->get('attributedTo'));
  134. $actor_id = $actor->getId();
  135. }
  136. // Figure the locale of the note when possible
  137. $locale = null;
  138. if (\is_array($type_note->get('@context'))) {
  139. $locale = array_column($type_note->get('@context'), '@language');
  140. if ($locale !== []) {
  141. $locale = $locale[0];
  142. if ($locale === 'und') {
  143. $locale = null;
  144. }
  145. } else {
  146. $locale = null;
  147. }
  148. }
  149. $map = [
  150. 'is_local' => false,
  151. 'created' => new DateTime($type_note->get('published') ?? 'now'),
  152. 'title' => $type_note->get('name') ?? null,
  153. 'language_id' => \is_null($locale) ? null : Language::getByLocale($locale)->getId(),
  154. 'url' => $type_note->get('url') ?? $type_note->get('id'),
  155. 'actor_id' => $actor_id,
  156. 'reply_to' => $reply_to = $handleInReplyTo($type_note),
  157. 'modified' => new DateTime(),
  158. 'type' => match ($type_note->get('type')) {
  159. 'Article' => 'article',
  160. 'Page' => 'page',
  161. default => 'note' // graceful degradation
  162. },
  163. 'source' => $source,
  164. ];
  165. // Scope
  166. if (\in_array('https://www.w3.org/ns/activitystreams#Public', $to)) {
  167. // Public: Visible for all, shown in public feeds
  168. $map['scope'] = VisibilityScope::EVERYWHERE;
  169. } elseif (\in_array('https://www.w3.org/ns/activitystreams#Public', $cc)) {
  170. // Unlisted: Visible for all but not shown in public feeds
  171. // It isn't the note that dictates what feed is shown in but the feed, it only dictates who can access it.
  172. $map['scope'] = 'unlisted';
  173. } else {
  174. // Either Followers-only or Direct
  175. if ($type_note->get('type') === 'ChatMessage' // Is DM explicitly?
  176. || ($type_note->get('cc') === [])) { // Only has TO targets
  177. $map['scope'] = VisibilityScope::MESSAGE;
  178. } else { // Then is collection
  179. $map['scope'] = VisibilityScope::COLLECTION;
  180. }
  181. }
  182. $explorer = new Explorer();
  183. $attention_targets = [];
  184. foreach ($to as $target) {
  185. if (\in_array($target, ActivityPub::PUBLIC_TO)) {
  186. continue;
  187. }
  188. try {
  189. try {
  190. $actor_targets = $explorer->lookup($target);
  191. foreach ($actor_targets as $actor) {
  192. $attention_targets[$actor->getId()] = $actor;
  193. }
  194. } catch (Exception $e) {
  195. Log::debug('ActivityPub->Model->Note->fromJson->Attention->Explorer', [$e]);
  196. }
  197. $actor = Explorer::getOneFromUri($target);
  198. $attention_targets[$actor->getId()] = $actor;
  199. // If $to is a group and note is unlisted, set note's scope as Group
  200. if ($actor->isGroup() && $map['scope'] === 'unlisted') {
  201. $map['scope'] = VisibilityScope::GROUP;
  202. }
  203. } catch (Exception $e) {
  204. Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]);
  205. }
  206. }
  207. // We can drop this insight already
  208. if ($map['scope'] === 'unlisted') {
  209. $map['scope'] = VisibilityScope::EVERYWHERE;
  210. }
  211. foreach ($cc as $target) {
  212. if (\in_array($target, ActivityPub::PUBLIC_TO)) {
  213. continue;
  214. }
  215. try {
  216. $actor_targets = $explorer->lookup($target);
  217. foreach ($actor_targets as $actor) {
  218. $attention_targets[$actor->getId()] = $actor;
  219. }
  220. } catch (Exception $e) {
  221. Log::debug('ActivityPub->Model->Note->fromJson->Attention->Explorer', [$e]);
  222. }
  223. }
  224. // content and rendered
  225. $map['content'] = $type_note->get('content') ?? null;
  226. $map['content_type'] = \is_null($map['content']) ? null : 'text/html';
  227. $map['rendered'] = \is_null($map['content']) ? null : HTML::sanitize($map['content']);
  228. if ($type_note->has('source')) {
  229. $map['content_type'] = $type_note->get('source')['mediaType'] ?? null;
  230. $map['content'] = $type_note->get('source')['content'];
  231. if (\is_null($map['rendered'])) {
  232. // It doesn't have the content pre-rendered, we can render it ourselves then
  233. Event::handle('RenderNoteContent', [$map['content'], $map['content_type'], &$map['rendered'], $actor, $locale, /*&$mentions = */ []]);
  234. }
  235. }
  236. $obj = GSNote::create($map);
  237. DB::persist($obj);
  238. // Attachments
  239. $processed_attachments = [];
  240. foreach ($type_note->get('attachment') ?? [] as $attachment) {
  241. if ($attachment->get('type') === 'Document') {
  242. // Retrieve media
  243. $get_response = HTTPClient::get($attachment->get('url'));
  244. $media = $get_response->getContent();
  245. unset($get_response);
  246. // Ignore empty files
  247. if (!empty($media)) {
  248. // Create an attachment for this
  249. $temp_file = new TemporaryFile();
  250. $temp_file->write($media);
  251. $filesize = $temp_file->getSize();
  252. $max_file_size = Common::getUploadLimit();
  253. if ($max_file_size < $filesize) {
  254. throw new ClientException(_m('No file may be larger than {quota} bytes and the file you sent was {size} bytes. '
  255. . 'Try to upload a smaller version.', ['quota' => $max_file_size, 'size' => $filesize], ));
  256. }
  257. Event::handle('EnforceUserFileQuota', [$filesize, $actor_id]);
  258. $processed_attachments[] = [GSFile::storeFileAsAttachment($temp_file), $attachment->get('name')];
  259. }
  260. }
  261. }
  262. // Assign conversation to this note
  263. Conversation::assignLocalConversation($obj, $reply_to);
  264. // Update replies cache
  265. if (!\is_null($reply_to)) {
  266. Cache::incr(GSNote::cacheKeys($reply_to)['replies-count']);
  267. if (Cache::exists(GSNote::cacheKeys($reply_to)['replies'])) {
  268. Cache::listPushRight(GSNote::cacheKeys($reply_to)['replies'], $obj);
  269. }
  270. }
  271. $mention_uris = [];
  272. foreach ($type_note->get('tag') ?? [] as $ap_tag) {
  273. switch ($ap_tag->get('type')) {
  274. case 'Mention':
  275. try {
  276. $mention_uris[] = $resource = $ap_tag->get('href');
  277. $actor_targets = $explorer->lookup($resource);
  278. foreach ($actor_targets as $actor) {
  279. $attention_targets[$actor->getId()] = $actor;
  280. }
  281. } catch (Exception $e) {
  282. Log::debug('ActivityPub->Model->Note->fromJson->Mention->Explorer', [$e]);
  283. }
  284. break;
  285. case 'Hashtag':
  286. $match = ltrim($ap_tag->get('name'), '#');
  287. $tag = Tag::extract($match);
  288. $canonical_tag = $ap_tag->get('canonical') ?? Tag::canonicalTag($tag, \is_null($lang_id = $obj->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
  289. DB::persist(NoteTag::create([
  290. 'tag' => $tag,
  291. 'canonical' => $canonical_tag,
  292. 'note_id' => $obj->getId(),
  293. 'use_canonical' => $ap_tag->get('canonical') ?? false,
  294. 'language_id' => $lang_id ?? null,
  295. ]));
  296. Cache::listPushLeft("tag-{$canonical_tag}", $obj);
  297. foreach (Tag::cacheKeys($canonical_tag) as $key) {
  298. Cache::delete($key);
  299. }
  300. break;
  301. }
  302. }
  303. Event::handle('ProcessNoteContent', [$obj, $obj->getContent(), $obj->getContentType(), $process_note_content_extra_args = ['TagProcessed' => true, 'ignoreLinks' => $mention_uris]]);
  304. foreach ($attention_targets as $target) {
  305. DB::persist(Attention::create(['object_type' => GSNote::schemaName(), 'object_id' => $obj->getId(), 'target_id' => $target->getId()]));
  306. }
  307. if ($processed_attachments !== []) {
  308. foreach ($processed_attachments as [$a, $fname]) {
  309. if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor_id]) === 0) {
  310. DB::persist(ActorToAttachment::create($args));
  311. }
  312. DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $obj->getId(), 'title' => $fname]));
  313. }
  314. }
  315. $map = [
  316. 'object_uri' => $type_note->get('id'),
  317. 'object_type' => 'note',
  318. 'object_id' => $obj->getId(),
  319. 'created' => new DateTime($type_note->get('published') ?? 'now'),
  320. 'modified' => new DateTime(),
  321. ];
  322. $ap_obj = new ActivitypubObject();
  323. foreach ($map as $prop => $val) {
  324. $set = Formatting::snakeCaseToCamelCase("set_{$prop}");
  325. $ap_obj->{$set}($val);
  326. }
  327. DB::persist($ap_obj);
  328. return $obj;
  329. }
  330. /**
  331. * Get a JSON
  332. *
  333. * @throws ClientException
  334. * @throws InvalidArgumentException
  335. * @throws ServerException
  336. */
  337. public static function toType(mixed $object): AbstractObject
  338. {
  339. if ($object::class !== GSNote::class) {
  340. throw new InvalidArgumentException('First argument type must be a Note.');
  341. }
  342. $attr = [
  343. '@context' => ActivityPub::$activity_streams_two_context,
  344. 'type' => $object->getScope() === VisibilityScope::MESSAGE ? 'ChatMessage' : (match ($object->getType()) {
  345. 'note' => 'Note',
  346. 'article' => 'Article',
  347. 'page' => 'Page',
  348. default => throw new Exception('Unsupported note type.')
  349. }),
  350. 'id' => $object->getUrl(),
  351. 'url' => $object->getUrl(),
  352. 'published' => $object->getCreated()->format(DateTimeInterface::RFC3339),
  353. 'attributedTo' => $object->getActor()->getUri(Router::ABSOLUTE_URL),
  354. 'name' => $object->getTitle(),
  355. 'content' => $object->getRendered(),
  356. 'mediaType' => 'text/html',
  357. 'source' => ['content' => $object->getContent(), 'mediaType' => $object->getContentType()],
  358. 'attachment' => [],
  359. 'tag' => [],
  360. 'inReplyTo' => \is_null($object->getReplyTo()) ? null : ActivityPub::getUriByObject(GSNote::getById($object->getReplyTo())),
  361. 'inConversation' => $object->getConversationUri(),
  362. ];
  363. $attentions = $object->getAttentionTargets();
  364. // Target scope
  365. switch ($object->getScope()) {
  366. case VisibilityScope::EVERYWHERE:
  367. $attr['to'] = ['https://www.w3.org/ns/activitystreams#Public'];
  368. $attr['cc'] = [];
  369. foreach ($attentions as $target) {
  370. if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) {
  371. $attr['to'][] = $target->getUri(Router::ABSOLUTE_URL);
  372. } else {
  373. $attr['cc'][] = $target->getUri(Router::ABSOLUTE_URL);
  374. }
  375. }
  376. break;
  377. case VisibilityScope::LOCAL:
  378. throw new ClientException('This note was not federated.', 403);
  379. case VisibilityScope::ADDRESSEE:
  380. case VisibilityScope::MESSAGE:
  381. $attr['to'] = [];
  382. foreach ($attentions as $target) {
  383. $attr['to'][] = $target->getUri(Router::ABSOLUTE_URL);
  384. }
  385. $attr['cc'] = [];
  386. break;
  387. case VisibilityScope::GROUP:
  388. // Will have the group in the To coming from attentions
  389. case VisibilityScope::COLLECTION:
  390. // Since we don't support sending unlisted/followers-only
  391. // notices, arriving here means we're instead answering to that type
  392. // of posts. In this situation, it's safer to always send answers of type unlisted.
  393. $attr['to'] = [];
  394. $attr['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
  395. foreach ($attentions as $target) {
  396. if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) {
  397. $attr['to'][] = $target->getUri(Router::ABSOLUTE_URL);
  398. } else {
  399. $attr['cc'][] = $target->getUri(Router::ABSOLUTE_URL);
  400. }
  401. }
  402. break;
  403. default:
  404. Log::error('ActivityPub->Note->toJson: Found an unknown visibility scope.');
  405. throw new ServerException('Found an unknown visibility scope which cannot federate.');
  406. }
  407. // Mentions
  408. foreach ($object->getMentionTargets() as $mention) {
  409. $attr['tag'][] = [
  410. 'type' => 'Mention',
  411. 'href' => ($href = $mention->getUri()),
  412. 'name' => $mention->isGroup() ? FreeNetwork::groupTagToName($mention->getNickname(), $href) : FreeNetwork::mentionTagToName($mention->getNickname(), $href),
  413. ];
  414. $attr['to'][] = $href;
  415. }
  416. // Hashtags
  417. foreach ($object->getTags() as $hashtag) {
  418. $attr['tag'][] = [
  419. 'type' => 'Hashtag',
  420. 'href' => $hashtag->getUrl(type: Router::ABSOLUTE_URL),
  421. 'name' => "#{$hashtag->getTag()}",
  422. 'canonical' => $hashtag->getCanonical(),
  423. ];
  424. }
  425. // Attachments
  426. foreach ($object->getAttachments() as $attachment) {
  427. $attr['attachment'][] = [
  428. 'type' => 'Document',
  429. 'mediaType' => $attachment->getMimetype(),
  430. 'url' => $attachment->getUrl(note: $object, type: Router::ABSOLUTE_URL),
  431. 'name' => DB::findOneBy(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $object->getId()], return_null: true)?->getTitle(),
  432. 'width' => $attachment->getWidth(),
  433. 'height' => $attachment->getHeight(),
  434. ];
  435. }
  436. // Language
  437. $attr['@context'][] = ['@language' => $object->getLanguageLocale() ?? 'und'];
  438. $type = self::jsonToType($attr);
  439. Event::handle('ActivityPubAddActivityStreamsTwoData', [$type->get('type'), &$type]);
  440. return $type;
  441. }
  442. }