Friendica Communications Platform (please note that this is a clone of the repository at github, issues are handled there) https://friendi.ca
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.
 
 
 
 
 
 

1073 lines
31 KiB

  1. <?php
  2. /**
  3. * @copyright Copyright (C) 2020, Friendica
  4. *
  5. * @license GNU AGPL version 3 or any later version
  6. *
  7. * This program is free software: you can redistribute it and/or modify
  8. * it under the terms of the GNU Affero General Public License as
  9. * published by the Free Software Foundation, either version 3 of the
  10. * License, or (at your option) any later version.
  11. *
  12. * This program is distributed in the hope that it will be useful,
  13. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. * GNU Affero General Public License for more details.
  16. *
  17. * You should have received a copy of the GNU Affero General Public License
  18. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. *
  20. */
  21. namespace Friendica\Protocol\ActivityPub;
  22. use Friendica\Content\PageInfo;
  23. use Friendica\Content\Text\BBCode;
  24. use Friendica\Content\Text\HTML;
  25. use Friendica\Core\Logger;
  26. use Friendica\Core\Protocol;
  27. use Friendica\Database\DBA;
  28. use Friendica\DI;
  29. use Friendica\Model\APContact;
  30. use Friendica\Model\Contact;
  31. use Friendica\Model\Conversation;
  32. use Friendica\Model\Event;
  33. use Friendica\Model\Item;
  34. use Friendica\Model\ItemURI;
  35. use Friendica\Model\Mail;
  36. use Friendica\Model\Tag;
  37. use Friendica\Model\User;
  38. use Friendica\Protocol\Activity;
  39. use Friendica\Protocol\ActivityPub;
  40. use Friendica\Util\DateTimeFormat;
  41. use Friendica\Util\JsonLD;
  42. use Friendica\Util\Strings;
  43. /**
  44. * ActivityPub Processor Protocol class
  45. */
  46. class Processor
  47. {
  48. /**
  49. * Converts mentions from Pleroma into the Friendica format
  50. *
  51. * @param string $body
  52. *
  53. * @return string converted body
  54. */
  55. private static function convertMentions($body)
  56. {
  57. $URLSearchString = "^\[\]";
  58. $body = preg_replace("/\[url\=([$URLSearchString]*)\]([#@!])(.*?)\[\/url\]/ism", '$2[url=$1]$3[/url]', $body);
  59. return $body;
  60. }
  61. /**
  62. * Replaces emojis in the body
  63. *
  64. * @param array $emojis
  65. * @param string $body
  66. *
  67. * @return string with replaced emojis
  68. */
  69. private static function replaceEmojis($body, array $emojis)
  70. {
  71. foreach ($emojis as $emoji) {
  72. $replace = '[class=emoji mastodon][img=' . $emoji['href'] . ']' . $emoji['name'] . '[/img][/class]';
  73. $body = str_replace($emoji['name'], $replace, $body);
  74. }
  75. return $body;
  76. }
  77. /**
  78. * Add attachment data to the item array
  79. *
  80. * @param array $activity
  81. * @param array $item
  82. *
  83. * @return array array
  84. */
  85. private static function constructAttachList($activity, $item)
  86. {
  87. if (empty($activity['attachments'])) {
  88. return $item;
  89. }
  90. foreach ($activity['attachments'] as $attach) {
  91. switch ($attach['type']) {
  92. case 'link':
  93. $data = [
  94. 'url' => $attach['url'],
  95. 'type' => $attach['type'],
  96. 'title' => $attach['title'] ?? '',
  97. 'text' => $attach['desc'] ?? '',
  98. 'image' => $attach['image'] ?? '',
  99. 'images' => [],
  100. 'keywords' => [],
  101. ];
  102. $item['body'] = PageInfo::appendDataToBody($item['body'], $data);
  103. break;
  104. default:
  105. $filetype = strtolower(substr($attach['mediaType'], 0, strpos($attach['mediaType'], '/')));
  106. if ($filetype == 'image') {
  107. if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) {
  108. continue 2;
  109. }
  110. $item['body'] .= "\n";
  111. // image is the preview/thumbnail URL
  112. if (!empty($attach['image'])) {
  113. $item['body'] .= '[url=' . $attach['url'] . ']';
  114. $attach['url'] = $attach['image'];
  115. }
  116. if (empty($attach['name'])) {
  117. $item['body'] .= '[img]' . $attach['url'] . '[/img]';
  118. } else {
  119. $item['body'] .= '[img=' . $attach['url'] . ']' . $attach['name'] . '[/img]';
  120. }
  121. if (!empty($attach['image'])) {
  122. $item['body'] .= '[/url]';
  123. }
  124. } elseif ($filetype == 'audio') {
  125. if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) {
  126. continue 2;
  127. }
  128. $item['body'] .= "\n[audio]" . $attach['url'] . '[/audio]';
  129. } elseif ($filetype == 'video') {
  130. if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) {
  131. continue 2;
  132. }
  133. $item['body'] .= "\n[video]" . $attach['url'] . '[/video]';
  134. } else {
  135. if (!empty($item["attach"])) {
  136. $item["attach"] .= ',';
  137. } else {
  138. $item["attach"] = '';
  139. }
  140. $item["attach"] .= '[attach]href="' . $attach['url'] . '" length="' . ($attach['length'] ?? '0') . '" type="' . $attach['mediaType'] . '" title="' . ($attach['name'] ?? '') . '"[/attach]';
  141. }
  142. }
  143. }
  144. return $item;
  145. }
  146. /**
  147. * Updates a message
  148. *
  149. * @param array $activity Activity array
  150. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  151. */
  152. public static function updateItem($activity)
  153. {
  154. $item = Item::selectFirst(['uri', 'uri-id', 'thr-parent', 'gravity'], ['uri' => $activity['id']]);
  155. if (!DBA::isResult($item)) {
  156. Logger::warning('No existing item, item will be created', ['uri' => $activity['id']]);
  157. $item = self::createItem($activity);
  158. self::postItem($activity, $item);
  159. return;
  160. }
  161. $item['changed'] = DateTimeFormat::utcNow();
  162. $item['edited'] = DateTimeFormat::utc($activity['updated']);
  163. $item = self::processContent($activity, $item);
  164. if (empty($item)) {
  165. return;
  166. }
  167. Item::update($item, ['uri' => $activity['id']]);
  168. }
  169. /**
  170. * Prepares data for a message
  171. *
  172. * @param array $activity Activity array
  173. * @return array Internal item
  174. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  175. * @throws \ImagickException
  176. */
  177. public static function createItem($activity)
  178. {
  179. $item = [];
  180. $item['verb'] = Activity::POST;
  181. $item['thr-parent'] = $activity['reply-to-id'];
  182. if ($activity['reply-to-id'] == $activity['id']) {
  183. $item['gravity'] = GRAVITY_PARENT;
  184. $item['object-type'] = Activity\ObjectType::NOTE;
  185. } else {
  186. $item['gravity'] = GRAVITY_COMMENT;
  187. $item['object-type'] = Activity\ObjectType::COMMENT;
  188. }
  189. if (empty($activity['directmessage']) && ($activity['id'] != $activity['reply-to-id']) && !Item::exists(['uri' => $activity['reply-to-id']])) {
  190. Logger::notice('Parent not found. Try to refetch it.', ['parent' => $activity['reply-to-id']]);
  191. self::fetchMissingActivity($activity['reply-to-id'], $activity);
  192. }
  193. $item['diaspora_signed_text'] = $activity['diaspora:comment'] ?? '';
  194. /// @todo What to do with $activity['context']?
  195. if (empty($activity['directmessage']) && ($item['gravity'] != GRAVITY_PARENT) && !Item::exists(['uri' => $item['thr-parent']])) {
  196. Logger::info('Parent not found, message will be discarded.', ['thr-parent' => $item['thr-parent']]);
  197. return [];
  198. }
  199. $item['network'] = Protocol::ACTIVITYPUB;
  200. $item['author-link'] = $activity['author'];
  201. $item['author-id'] = Contact::getIdForURL($activity['author']);
  202. $item['owner-link'] = $activity['actor'];
  203. $item['owner-id'] = Contact::getIdForURL($activity['actor']);
  204. if (in_array(0, $activity['receiver']) && !empty($activity['unlisted'])) {
  205. $item['private'] = Item::UNLISTED;
  206. } elseif (in_array(0, $activity['receiver'])) {
  207. $item['private'] = Item::PUBLIC;
  208. } else {
  209. $item['private'] = Item::PRIVATE;
  210. }
  211. if (!empty($activity['raw'])) {
  212. $item['source'] = $activity['raw'];
  213. $item['protocol'] = Conversation::PARCEL_ACTIVITYPUB;
  214. $item['conversation-href'] = $activity['context'] ?? '';
  215. $item['conversation-uri'] = $activity['conversation'] ?? '';
  216. if (isset($activity['push'])) {
  217. $item['direction'] = $activity['push'] ? Conversation::PUSH : Conversation::PULL;
  218. }
  219. }
  220. $item['isForum'] = false;
  221. if (!empty($activity['thread-completion'])) {
  222. // Store the original actor in the "causer" fields to enable the check for ignored or blocked contacts
  223. $item['causer-link'] = $item['owner-link'];
  224. $item['causer-id'] = $item['owner-id'];
  225. Logger::info('Ignoring actor because of thread completion.', ['actor' => $item['owner-link']]);
  226. $item['owner-link'] = $item['author-link'];
  227. $item['owner-id'] = $item['author-id'];
  228. } else {
  229. $actor = APContact::getByURL($item['owner-link'], false);
  230. $item['isForum'] = ($actor['type'] == 'Group');
  231. }
  232. $item['uri'] = $activity['id'];
  233. $item['created'] = DateTimeFormat::utc($activity['published']);
  234. $item['edited'] = DateTimeFormat::utc($activity['updated']);
  235. $guid = $activity['sc:identifier'] ?: self::getGUIDByURL($item['uri']);
  236. $item['guid'] = $activity['diaspora:guid'] ?: $guid;
  237. $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]);
  238. $item = self::processContent($activity, $item);
  239. if (empty($item)) {
  240. Logger::info('Message was not processed');
  241. return [];
  242. }
  243. $item['plink'] = $activity['alternate-url'] ?? $item['uri'];
  244. $item = self::constructAttachList($activity, $item);
  245. return $item;
  246. }
  247. /**
  248. * Delete items
  249. *
  250. * @param array $activity
  251. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  252. * @throws \ImagickException
  253. */
  254. public static function deleteItem($activity)
  255. {
  256. $owner = Contact::getIdForURL($activity['actor']);
  257. Logger::info('Deleting item', ['object' => $activity['object_id'], 'owner' => $owner]);
  258. Item::markForDeletion(['uri' => $activity['object_id'], 'owner-id' => $owner]);
  259. }
  260. /**
  261. * Prepare the item array for an activity
  262. *
  263. * @param array $activity Activity array
  264. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  265. * @throws \ImagickException
  266. */
  267. public static function addTag($activity)
  268. {
  269. if (empty($activity['object_content']) || empty($activity['object_id'])) {
  270. return;
  271. }
  272. foreach ($activity['receiver'] as $receiver) {
  273. $item = Item::selectFirst(['id', 'uri-id', 'tag', 'origin', 'author-link'], ['uri' => $activity['target_id'], 'uid' => $receiver]);
  274. if (!DBA::isResult($item)) {
  275. // We don't fetch missing content for this purpose
  276. continue;
  277. }
  278. if (($item['author-link'] != $activity['actor']) && !$item['origin']) {
  279. Logger::info('Not origin, not from the author, skipping update', ['id' => $item['id'], 'author' => $item['author-link'], 'actor' => $activity['actor']]);
  280. continue;
  281. }
  282. Tag::store($item['uri-id'], Tag::HASHTAG, $activity['object_content'], $activity['object_id']);
  283. Logger::info('Tagged item', ['id' => $item['id'], 'tag' => $activity['object_content'], 'uri' => $activity['target_id'], 'actor' => $activity['actor']]);
  284. }
  285. }
  286. /**
  287. * Prepare the item array for an activity
  288. *
  289. * @param array $activity Activity array
  290. * @param string $verb Activity verb
  291. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  292. * @throws \ImagickException
  293. */
  294. public static function createActivity($activity, $verb)
  295. {
  296. $item = self::createItem($activity);
  297. $item['verb'] = $verb;
  298. $item['thr-parent'] = $activity['object_id'];
  299. $item['gravity'] = GRAVITY_ACTIVITY;
  300. $item['object-type'] = Activity\ObjectType::NOTE;
  301. $item['diaspora_signed_text'] = $activity['diaspora:like'] ?? '';
  302. self::postItem($activity, $item);
  303. }
  304. /**
  305. * Create an event
  306. *
  307. * @param array $activity Activity array
  308. * @param array $item
  309. * @throws \Exception
  310. */
  311. public static function createEvent($activity, $item)
  312. {
  313. $event['summary'] = HTML::toBBCode($activity['name']);
  314. $event['desc'] = HTML::toBBCode($activity['content']);
  315. $event['start'] = $activity['start-time'];
  316. $event['finish'] = $activity['end-time'];
  317. $event['nofinish'] = empty($event['finish']);
  318. $event['location'] = $activity['location'];
  319. $event['adjust'] = true;
  320. $event['cid'] = $item['contact-id'];
  321. $event['uid'] = $item['uid'];
  322. $event['uri'] = $item['uri'];
  323. $event['edited'] = $item['edited'];
  324. $event['private'] = $item['private'];
  325. $event['guid'] = $item['guid'];
  326. $event['plink'] = $item['plink'];
  327. $condition = ['uri' => $item['uri'], 'uid' => $item['uid']];
  328. $ev = DBA::selectFirst('event', ['id'], $condition);
  329. if (DBA::isResult($ev)) {
  330. $event['id'] = $ev['id'];
  331. }
  332. $event_id = Event::store($event);
  333. Logger::info('Event was stored', ['id' => $event_id]);
  334. }
  335. /**
  336. * Process the content
  337. *
  338. * @param array $activity Activity array
  339. * @param array $item
  340. * @return array|bool Returns the item array or false if there was an unexpected occurrence
  341. * @throws \Exception
  342. */
  343. private static function processContent($activity, $item)
  344. {
  345. $item['title'] = HTML::toBBCode($activity['name']);
  346. if (!empty($activity['source'])) {
  347. $item['body'] = $activity['source'];
  348. } else {
  349. $content = HTML::toBBCode($activity['content']);
  350. if (!empty($activity['emojis'])) {
  351. $content = self::replaceEmojis($content, $activity['emojis']);
  352. }
  353. $content = self::convertMentions($content);
  354. if (empty($activity['directmessage']) && ($item['thr-parent'] != $item['uri']) && ($item['gravity'] == GRAVITY_COMMENT)) {
  355. $item_private = !in_array(0, $activity['item_receiver']);
  356. $parent = Item::selectFirst(['id', 'uri-id', 'private', 'author-link', 'alias'], ['uri' => $item['thr-parent']]);
  357. if (!DBA::isResult($parent)) {
  358. Logger::warning('Unknown parent item.', ['uri' => $item['thr-parent']]);
  359. return false;
  360. }
  361. if ($item_private && ($parent['private'] != Item::PRIVATE)) {
  362. Logger::warning('Item is private but the parent is not. Dropping.', ['item-uri' => $item['uri'], 'thr-parent' => $item['thr-parent']]);
  363. return false;
  364. }
  365. $content = self::removeImplicitMentionsFromBody($content, $parent);
  366. }
  367. $item['content-warning'] = HTML::toBBCode($activity['summary']);
  368. $item['body'] = $content;
  369. }
  370. self::storeFromBody($item);
  371. self::storeTags($item['uri-id'], $activity['tags']);
  372. $item['location'] = $activity['location'];
  373. if (!empty($activity['latitude']) && !empty($activity['longitude'])) {
  374. $item['coord'] = $activity['latitude'] . ' ' . $activity['longitude'];
  375. }
  376. $item['app'] = $activity['generator'];
  377. return $item;
  378. }
  379. /**
  380. * Store hashtags and mentions
  381. *
  382. * @param array $item
  383. */
  384. private static function storeFromBody(array $item)
  385. {
  386. // Make sure to delete all existing tags (can happen when called via the update functionality)
  387. DBA::delete('post-tag', ['uri-id' => $item['uri-id']]);
  388. Tag::storeFromBody($item['uri-id'], $item['body'], '@!');
  389. }
  390. /**
  391. * Generate a GUID out of an URL
  392. *
  393. * @param string $url message URL
  394. * @return string with GUID
  395. */
  396. private static function getGUIDByURL(string $url)
  397. {
  398. $parsed = parse_url($url);
  399. $host_hash = hash('crc32', $parsed['host']);
  400. unset($parsed["scheme"]);
  401. unset($parsed["host"]);
  402. $path = implode("/", $parsed);
  403. return $host_hash . '-'. hash('fnv164', $path) . '-'. hash('joaat', $path);
  404. }
  405. /**
  406. * Creates an item post
  407. *
  408. * @param array $activity Activity data
  409. * @param array $item item array
  410. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  411. * @throws \ImagickException
  412. */
  413. public static function postItem(array $activity, array $item)
  414. {
  415. if (empty($item)) {
  416. return;
  417. }
  418. $stored = false;
  419. foreach ($activity['receiver'] as $receiver) {
  420. if ($receiver == -1) {
  421. continue;
  422. }
  423. $item['uid'] = $receiver;
  424. $type = $activity['reception_type'][$receiver] ?? Receiver::TARGET_UNKNOWN;
  425. switch($type) {
  426. case Receiver::TARGET_TO:
  427. $item['post-type'] = Item::PT_TO;
  428. break;
  429. case Receiver::TARGET_CC:
  430. $item['post-type'] = Item::PT_CC;
  431. break;
  432. case Receiver::TARGET_BTO:
  433. $item['post-type'] = Item::PT_BTO;
  434. break;
  435. case Receiver::TARGET_BCC:
  436. $item['post-type'] = Item::PT_BCC;
  437. break;
  438. case Receiver::TARGET_FOLLOWER:
  439. $item['post-type'] = Item::PT_FOLLOWER;
  440. break;
  441. case Receiver::TARGET_ANSWER:
  442. $item['post-type'] = Item::PT_COMMENT;
  443. break;
  444. case Receiver::TARGET_GLOBAL:
  445. $item['post-type'] = Item::PT_GLOBAL;
  446. break;
  447. default:
  448. $item['post-type'] = Item::PT_ARTICLE;
  449. }
  450. if ($item['isForum'] ?? false) {
  451. $item['contact-id'] = Contact::getIdForURL($activity['actor'], $receiver);
  452. } else {
  453. $item['contact-id'] = Contact::getIdForURL($activity['author'], $receiver);
  454. }
  455. if (($receiver != 0) && empty($item['contact-id'])) {
  456. $item['contact-id'] = Contact::getIdForURL($activity['author']);
  457. }
  458. if (!empty($activity['directmessage'])) {
  459. self::postMail($activity, $item);
  460. continue;
  461. }
  462. if (DI::pConfig()->get($receiver, 'system', 'accept_only_sharer', false) && ($receiver != 0) && ($item['gravity'] == GRAVITY_PARENT)) {
  463. $skip = !Contact::isSharingByURL($activity['author'], $receiver);
  464. if ($skip && (($activity['type'] == 'as:Announce') || ($item['isForum'] ?? false))) {
  465. $skip = !Contact::isSharingByURL($activity['actor'], $receiver);
  466. }
  467. if ($skip) {
  468. Logger::info('Skipping post', ['uid' => $receiver, 'url' => $item['uri']]);
  469. continue;
  470. }
  471. Logger::info('Accepting post', ['uid' => $receiver, 'url' => $item['uri']]);
  472. }
  473. if (($item['gravity'] != GRAVITY_ACTIVITY) && ($activity['object_type'] == 'as:Event')) {
  474. self::createEvent($activity, $item);
  475. }
  476. $item_id = Item::insert($item);
  477. if ($item_id) {
  478. Logger::info('Item insertion successful', ['user' => $item['uid'], 'item_id' => $item_id]);
  479. } else {
  480. Logger::notice('Item insertion aborted', ['user' => $item['uid']]);
  481. }
  482. if ($item['uid'] == 0) {
  483. $stored = $item_id;
  484. }
  485. }
  486. // Store send a follow request for every reshare - but only when the item had been stored
  487. if ($stored && ($item['private'] != Item::PRIVATE) && ($item['gravity'] == GRAVITY_PARENT) && ($item['author-link'] != $item['owner-link'])) {
  488. $author = APContact::getByURL($item['owner-link'], false);
  489. // We send automatic follow requests for reshared messages. (We don't need though for forum posts)
  490. if ($author['type'] != 'Group') {
  491. Logger::info('Send follow request', ['uri' => $item['uri'], 'stored' => $stored, 'to' => $item['author-link']]);
  492. ActivityPub\Transmitter::sendFollowObject($item['uri'], $item['author-link']);
  493. }
  494. }
  495. }
  496. /**
  497. * Store tags and mentions into the tag table
  498. *
  499. * @param integer $uriid
  500. * @param array $tags
  501. */
  502. private static function storeTags(int $uriid, array $tags = null)
  503. {
  504. foreach ($tags as $tag) {
  505. if (empty($tag['name']) || empty($tag['type']) || !in_array($tag['type'], ['Mention', 'Hashtag'])) {
  506. continue;
  507. }
  508. $hash = substr($tag['name'], 0, 1);
  509. if ($tag['type'] == 'Mention') {
  510. if (in_array($hash, [Tag::TAG_CHARACTER[Tag::MENTION],
  511. Tag::TAG_CHARACTER[Tag::EXCLUSIVE_MENTION],
  512. Tag::TAG_CHARACTER[Tag::IMPLICIT_MENTION]])) {
  513. $tag['name'] = substr($tag['name'], 1);
  514. }
  515. $type = Tag::IMPLICIT_MENTION;
  516. if (!empty($tag['href'])) {
  517. $apcontact = APContact::getByURL($tag['href']);
  518. if (!empty($apcontact['name']) || !empty($apcontact['nick'])) {
  519. $tag['name'] = $apcontact['name'] ?: $apcontact['nick'];
  520. }
  521. }
  522. } elseif ($tag['type'] == 'Hashtag') {
  523. if ($hash == Tag::TAG_CHARACTER[Tag::HASHTAG]) {
  524. $tag['name'] = substr($tag['name'], 1);
  525. }
  526. $type = Tag::HASHTAG;
  527. }
  528. if (empty($tag['name'])) {
  529. continue;
  530. }
  531. Tag::store($uriid, $type, $tag['name'], $tag['href']);
  532. }
  533. }
  534. /**
  535. * Creates an mail post
  536. *
  537. * @param array $activity Activity data
  538. * @param array $item item array
  539. * @return int|bool New mail table row id or false on error
  540. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  541. */
  542. private static function postMail($activity, $item)
  543. {
  544. if (($item['gravity'] != GRAVITY_PARENT) && !DBA::exists('mail', ['uri' => $item['thr-parent'], 'uid' => $item['uid']])) {
  545. Logger::info('Parent not found, mail will be discarded.', ['uid' => $item['uid'], 'uri' => $item['thr-parent']]);
  546. return false;
  547. }
  548. Logger::info('Direct Message', $item);
  549. $msg = [];
  550. $msg['uid'] = $item['uid'];
  551. $msg['contact-id'] = $item['contact-id'];
  552. $contact = Contact::getById($item['contact-id'], ['name', 'url', 'photo']);
  553. $msg['from-name'] = $contact['name'];
  554. $msg['from-url'] = $contact['url'];
  555. $msg['from-photo'] = $contact['photo'];
  556. $msg['uri'] = $item['uri'];
  557. $msg['created'] = $item['created'];
  558. $parent = DBA::selectFirst('mail', ['parent-uri', 'title'], ['uri' => $item['thr-parent']]);
  559. if (DBA::isResult($parent)) {
  560. $msg['parent-uri'] = $parent['parent-uri'];
  561. $msg['title'] = $parent['title'];
  562. } else {
  563. $msg['parent-uri'] = $item['thr-parent'];
  564. if (!empty($item['title'])) {
  565. $msg['title'] = $item['title'];
  566. } elseif (!empty($item['content-warning'])) {
  567. $msg['title'] = $item['content-warning'];
  568. } else {
  569. // Trying to generate a title out of the body
  570. $title = $item['body'];
  571. while (preg_match('#^(@\[url=([^\]]+)].*?\[\/url]\s)(.*)#is', $title, $matches)) {
  572. $title = $matches[3];
  573. }
  574. $title = trim(HTML::toPlaintext(BBCode::convert($title, false, BBCode::API, true), 0));
  575. if (strlen($title) > 20) {
  576. $title = substr($title, 0, 20) . '...';
  577. }
  578. $msg['title'] = $title;
  579. }
  580. }
  581. $msg['body'] = $item['body'];
  582. return Mail::insert($msg);
  583. }
  584. /**
  585. * Fetches missing posts
  586. *
  587. * @param string $url message URL
  588. * @param array $child activity array with the child of this message
  589. * @return string fetched message URL
  590. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  591. */
  592. public static function fetchMissingActivity(string $url, array $child = [])
  593. {
  594. if (!empty($child['receiver'])) {
  595. $uid = ActivityPub\Receiver::getFirstUserFromReceivers($child['receiver']);
  596. } else {
  597. $uid = 0;
  598. }
  599. $object = ActivityPub::fetchContent($url, $uid);
  600. if (empty($object)) {
  601. Logger::log('Activity ' . $url . ' was not fetchable, aborting.');
  602. return '';
  603. }
  604. if (empty($object['id'])) {
  605. Logger::log('Activity ' . $url . ' has got not id, aborting. ' . json_encode($object));
  606. return '';
  607. }
  608. if (!empty($object['actor'])) {
  609. $object_actor = $object['actor'];
  610. } elseif (!empty($object['attributedTo'])) {
  611. $object_actor = $object['attributedTo'];
  612. } else {
  613. // Shouldn't happen
  614. $object_actor = '';
  615. }
  616. $signer = [$object_actor];
  617. if (!empty($child['author'])) {
  618. $actor = $child['author'];
  619. $signer[] = $actor;
  620. } else {
  621. $actor = $object_actor;
  622. }
  623. if (!empty($object['published'])) {
  624. $published = $object['published'];
  625. } elseif (!empty($child['published'])) {
  626. $published = $child['published'];
  627. } else {
  628. $published = DateTimeFormat::utcNow();
  629. }
  630. $activity = [];
  631. $activity['@context'] = $object['@context'];
  632. unset($object['@context']);
  633. $activity['id'] = $object['id'];
  634. $activity['to'] = $object['to'] ?? [];
  635. $activity['cc'] = $object['cc'] ?? [];
  636. $activity['actor'] = $actor;
  637. $activity['object'] = $object;
  638. $activity['published'] = $published;
  639. $activity['type'] = 'Create';
  640. $ldactivity = JsonLD::compact($activity);
  641. $ldactivity['thread-completion'] = true;
  642. ActivityPub\Receiver::processActivity($ldactivity, json_encode($activity), $uid, true, false, $signer);
  643. Logger::notice('Activity had been fetched and processed.', ['url' => $url, 'object' => $activity['id']]);
  644. return $activity['id'];
  645. }
  646. /**
  647. * perform a "follow" request
  648. *
  649. * @param array $activity
  650. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  651. * @throws \ImagickException
  652. */
  653. public static function followUser($activity)
  654. {
  655. $uid = User::getIdForURL($activity['object_id']);
  656. if (empty($uid)) {
  657. return;
  658. }
  659. $owner = User::getOwnerDataById($uid);
  660. if (empty($owner)) {
  661. return;
  662. }
  663. $cid = Contact::getIdForURL($activity['actor'], $uid);
  664. if (!empty($cid)) {
  665. self::switchContact($cid);
  666. DBA::update('contact', ['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]);
  667. $contact = DBA::selectFirst('contact', [], ['id' => $cid, 'network' => Protocol::NATIVE_SUPPORT]);
  668. } else {
  669. $contact = [];
  670. }
  671. $item = ['author-id' => Contact::getIdForURL($activity['actor']),
  672. 'author-link' => $activity['actor']];
  673. $note = Strings::escapeTags(trim($activity['content'] ?? ''));
  674. // Ensure that the contact has got the right network type
  675. self::switchContact($item['author-id']);
  676. $result = Contact::addRelationship($owner, $contact, $item, false, $note);
  677. if ($result === true) {
  678. ActivityPub\Transmitter::sendContactAccept($item['author-link'], $activity['id'], $owner['uid']);
  679. }
  680. $cid = Contact::getIdForURL($activity['actor'], $uid);
  681. if (empty($cid)) {
  682. return;
  683. }
  684. if (empty($contact)) {
  685. DBA::update('contact', ['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]);
  686. }
  687. Logger::log('Follow user ' . $uid . ' from contact ' . $cid . ' with id ' . $activity['id']);
  688. }
  689. /**
  690. * Update the given profile
  691. *
  692. * @param array $activity
  693. * @throws \Exception
  694. */
  695. public static function updatePerson($activity)
  696. {
  697. if (empty($activity['object_id'])) {
  698. return;
  699. }
  700. Logger::info('Updating profile', ['object' => $activity['object_id']]);
  701. Contact::updateFromProbeByURL($activity['object_id']);
  702. }
  703. /**
  704. * Delete the given profile
  705. *
  706. * @param array $activity
  707. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  708. */
  709. public static function deletePerson($activity)
  710. {
  711. if (empty($activity['object_id']) || empty($activity['actor'])) {
  712. Logger::info('Empty object id or actor.');
  713. return;
  714. }
  715. if ($activity['object_id'] != $activity['actor']) {
  716. Logger::info('Object id does not match actor.');
  717. return;
  718. }
  719. $contacts = DBA::select('contact', ['id'], ['nurl' => Strings::normaliseLink($activity['object_id'])]);
  720. while ($contact = DBA::fetch($contacts)) {
  721. Contact::remove($contact['id']);
  722. }
  723. DBA::close($contacts);
  724. Logger::info('Deleted contact', ['object' => $activity['object_id']]);
  725. }
  726. /**
  727. * Accept a follow request
  728. *
  729. * @param array $activity
  730. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  731. * @throws \ImagickException
  732. */
  733. public static function acceptFollowUser($activity)
  734. {
  735. $uid = User::getIdForURL($activity['object_actor']);
  736. if (empty($uid)) {
  737. return;
  738. }
  739. $cid = Contact::getIdForURL($activity['actor'], $uid);
  740. if (empty($cid)) {
  741. Logger::info('No contact found', ['actor' => $activity['actor']]);
  742. return;
  743. }
  744. self::switchContact($cid);
  745. $fields = ['pending' => false];
  746. $contact = DBA::selectFirst('contact', ['rel'], ['id' => $cid]);
  747. if ($contact['rel'] == Contact::FOLLOWER) {
  748. $fields['rel'] = Contact::FRIEND;
  749. }
  750. $condition = ['id' => $cid];
  751. DBA::update('contact', $fields, $condition);
  752. Logger::info('Accept contact request', ['contact' => $cid, 'user' => $uid]);
  753. }
  754. /**
  755. * Reject a follow request
  756. *
  757. * @param array $activity
  758. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  759. * @throws \ImagickException
  760. */
  761. public static function rejectFollowUser($activity)
  762. {
  763. $uid = User::getIdForURL($activity['object_actor']);
  764. if (empty($uid)) {
  765. return;
  766. }
  767. $cid = Contact::getIdForURL($activity['actor'], $uid);
  768. if (empty($cid)) {
  769. Logger::info('No contact found', ['actor' => $activity['actor']]);
  770. return;
  771. }
  772. self::switchContact($cid);
  773. if (DBA::exists('contact', ['id' => $cid, 'rel' => Contact::SHARING])) {
  774. Contact::remove($cid);
  775. Logger::info('Rejected contact request - contact removed', ['contact' => $cid, 'user' => $uid]);
  776. } else {
  777. Logger::info('Rejected contact request', ['contact' => $cid, 'user' => $uid]);
  778. }
  779. }
  780. /**
  781. * Undo activity like "like" or "dislike"
  782. *
  783. * @param array $activity
  784. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  785. * @throws \ImagickException
  786. */
  787. public static function undoActivity($activity)
  788. {
  789. if (empty($activity['object_id'])) {
  790. return;
  791. }
  792. if (empty($activity['object_actor'])) {
  793. return;
  794. }
  795. $author_id = Contact::getIdForURL($activity['object_actor']);
  796. if (empty($author_id)) {
  797. return;
  798. }
  799. Item::markForDeletion(['uri' => $activity['object_id'], 'author-id' => $author_id, 'gravity' => GRAVITY_ACTIVITY]);
  800. }
  801. /**
  802. * Activity to remove a follower
  803. *
  804. * @param array $activity
  805. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  806. * @throws \ImagickException
  807. */
  808. public static function undoFollowUser($activity)
  809. {
  810. $uid = User::getIdForURL($activity['object_object']);
  811. if (empty($uid)) {
  812. return;
  813. }
  814. $owner = User::getOwnerDataById($uid);
  815. if (empty($owner)) {
  816. return;
  817. }
  818. $cid = Contact::getIdForURL($activity['actor'], $uid);
  819. if (empty($cid)) {
  820. Logger::info('No contact found', ['actor' => $activity['actor']]);
  821. return;
  822. }
  823. self::switchContact($cid);
  824. $contact = DBA::selectFirst('contact', [], ['id' => $cid]);
  825. if (!DBA::isResult($contact)) {
  826. return;
  827. }
  828. Contact::removeFollower($owner, $contact);
  829. Logger::info('Undo following request', ['contact' => $cid, 'user' => $uid]);
  830. }
  831. /**
  832. * Switches a contact to AP if needed
  833. *
  834. * @param integer $cid Contact ID
  835. * @throws \Exception
  836. */
  837. private static function switchContact($cid)
  838. {
  839. $contact = DBA::selectFirst('contact', ['network', 'url'], ['id' => $cid]);
  840. if (!DBA::isResult($contact) || in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN]) || Contact::isLocal($contact['url'])) {
  841. return;
  842. }
  843. Logger::info('Change existing contact', ['cid' => $cid, 'previous' => $contact['network']]);
  844. Contact::updateFromProbe($cid);
  845. }
  846. /**
  847. * Collects implicit mentions like:
  848. * - the author of the parent item
  849. * - all the mentioned conversants in the parent item
  850. *
  851. * @param array $parent Item array with at least ['id', 'author-link', 'alias']
  852. * @return array
  853. * @throws \Friendica\Network\HTTPException\InternalServerErrorException
  854. */
  855. private static function getImplicitMentionList(array $parent)
  856. {
  857. $parent_terms = Tag::getByURIId($parent['uri-id'], [Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION]);
  858. $parent_author = Contact::getByURL($parent['author-link'], false, ['url', 'nurl', 'alias']);
  859. $implicit_mentions = [];
  860. if (empty($parent_author['url'])) {
  861. Logger::notice('Author public contact unknown.', ['author-link' => $parent['author-link'], 'item-id' => $parent['id']]);
  862. } else {
  863. $implicit_mentions[] = $parent_author['url'];
  864. $implicit_mentions[] = $parent_author['nurl'];
  865. $implicit_mentions[] = $parent_author['alias'];
  866. }
  867. if (!empty($parent['alias'])) {
  868. $implicit_mentions[] = $parent['alias'];
  869. }
  870. foreach ($parent_terms as $term) {
  871. $contact = Contact::getByURL($term['url'], false, ['url', 'nurl', 'alias']);
  872. if (!empty($contact['url'])) {
  873. $implicit_mentions[] = $contact['url'];
  874. $implicit_mentions[] = $contact['nurl'];
  875. $implicit_mentions[] = $contact['alias'];
  876. }
  877. }
  878. return $implicit_mentions;
  879. }
  880. /**
  881. * Strips from the body prepended implicit mentions
  882. *
  883. * @param string $body
  884. * @param array $parent
  885. * @return string
  886. */
  887. private static function removeImplicitMentionsFromBody(string $body, array $parent)
  888. {
  889. if (DI::config()->get('system', 'disable_implicit_mentions')) {
  890. return $body;
  891. }
  892. $potential_mentions = self::getImplicitMentionList($parent);
  893. $kept_mentions = [];
  894. // Extract one prepended mention at a time from the body
  895. while(preg_match('#^(@\[url=([^\]]+)].*?\[\/url]\s)(.*)#is', $body, $matches)) {
  896. if (!in_array($matches[2], $potential_mentions)) {
  897. $kept_mentions[] = $matches[1];
  898. }
  899. $body = $matches[3];
  900. }
  901. // Re-appending the kept mentions to the body after extraction
  902. $kept_mentions[] = $body;
  903. return implode('', $kept_mentions);
  904. }
  905. }