Activitypub_notice.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. <?php
  2. // This file is part of GNU social - https://www.gnu.org/software/social
  3. //
  4. // GNU social is free software: you can redistribute it and/or modify
  5. // it under the terms of the GNU Affero General Public License as published by
  6. // the Free Software Foundation, either version 3 of the License, or
  7. // (at your option) any later version.
  8. //
  9. // GNU social is distributed in the hope that it will be useful,
  10. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. // GNU Affero General Public License for more details.
  13. //
  14. // You should have received a copy of the GNU Affero General Public License
  15. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
  16. /**
  17. * ActivityPub implementation for GNU social
  18. *
  19. * @package GNUsocial
  20. * @author Diogo Cordeiro <diogo@fc.up.pt>
  21. * @copyright 2018-2019 Free Software Foundation, Inc http://www.fsf.org
  22. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  23. * @link http://www.gnu.org/software/social/
  24. */
  25. defined('GNUSOCIAL') || die();
  26. /**
  27. * ActivityPub notice representation
  28. *
  29. * @category Plugin
  30. * @package GNUsocial
  31. * @author Diogo Cordeiro <diogo@fc.up.pt>
  32. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  33. */
  34. class Activitypub_notice
  35. {
  36. /**
  37. * Generates a pretty notice from a Notice object
  38. *
  39. * @param Notice $notice
  40. * @return array array to be used in a response
  41. * @throws EmptyPkeyValueException
  42. * @throws InvalidUrlException
  43. * @throws ServerException
  44. * @throws Exception
  45. * @author Diogo Cordeiro <diogo@fc.up.pt>
  46. */
  47. public static function notice_to_array(Notice $notice): array
  48. {
  49. $profile = $notice->getProfile();
  50. $attachments = [];
  51. foreach ($notice->attachments() as $attachment) {
  52. $attachments[] = Activitypub_attachment::attachment_to_array($attachment);
  53. }
  54. $tags = [];
  55. foreach ($notice->getTags() as $tag) {
  56. if ($tag != "") { // Hacky workaround to avoid stupid outputs
  57. $tags[] = Activitypub_tag::tag_to_array($tag);
  58. }
  59. }
  60. if ($notice->isPublic()) {
  61. $to = ['https://www.w3.org/ns/activitystreams#Public'];
  62. $cc = [common_local_url('apActorFollowers', ['id' => $profile->getID()])];
  63. } else {
  64. // Since we currently don't support sending unlisted/followers-only
  65. // notices, arriving here means we're instead answering to that type
  66. // of posts. Not having subscription policy working, its safer to
  67. // always send answers of type unlisted.
  68. $to = [];
  69. $cc = ['https://www.w3.org/ns/activitystreams#Public'];
  70. }
  71. foreach ($notice->getAttentionProfiles() as $to_profile) {
  72. $to[] = $href = $to_profile->getUri();
  73. $tags[] = Activitypub_mention_tag::mention_tag_to_array_from_values($href, $to_profile->getNickname() . '@' . parse_url($href, PHP_URL_HOST));
  74. }
  75. if (ActivityUtils::compareVerbs($notice->getVerb(), ActivityVerb::DELETE)) {
  76. $item = [
  77. '@context' => 'https://www.w3.org/ns/activitystreams',
  78. 'id' => self::getUri($notice),
  79. 'type' => 'Delete',
  80. // XXX: A bit of ugly code here
  81. 'object' => array_merge(Activitypub_tombstone::tombstone_to_array((int)substr(explode(':', $notice->getUri())[2], 9)), ['deleted' => str_replace(' ', 'T', $notice->getCreated()) . 'Z']),
  82. 'url' => $notice->getUrl(),
  83. 'actor' => $profile->getUri(),
  84. 'to' => $to,
  85. 'cc' => $cc,
  86. 'conversationId' => $notice->getConversationUrl(false),
  87. 'conversationUrl' => $notice->getConversationUrl(),
  88. 'content' => $notice->getRendered(),
  89. 'isLocal' => $notice->isLocal(),
  90. 'attachment' => $attachments,
  91. 'tag' => $tags
  92. ];
  93. } else { // Note
  94. $item = [
  95. '@context' => 'https://www.w3.org/ns/activitystreams',
  96. 'id' => self::note_uri($notice->getID()),
  97. 'type' => 'Note',
  98. 'published' => str_replace(' ', 'T', $notice->getCreated()) . 'Z',
  99. 'url' => $notice->getUrl(),
  100. 'attributedTo' => $profile->getUri(),
  101. 'to' => $to,
  102. 'cc' => $cc,
  103. 'conversationId' => $notice->getConversationUrl(false),
  104. 'conversationUrl' => $notice->getConversationUrl(),
  105. 'content' => $notice->getRendered(),
  106. 'isLocal' => $notice->isLocal(),
  107. 'attachment' => $attachments,
  108. 'tag' => $tags
  109. ];
  110. }
  111. // Is this a reply?
  112. if (!empty($notice->reply_to)) {
  113. $item['inReplyTo'] = self::getUri(Notice::getById($notice->reply_to));
  114. }
  115. // Do we have a location for this notice?
  116. try {
  117. $location = Notice_location::locFromStored($notice);
  118. $item['latitude'] = $location->lat;
  119. $item['longitude'] = $location->lon;
  120. } catch (Exception $e) {
  121. // Apparently no.
  122. }
  123. return $item;
  124. }
  125. /**
  126. * Create a Notice via ActivityPub Note Object.
  127. * Returns created Notice.
  128. *
  129. * @param array $object
  130. * @param Profile $actor_profile
  131. * @param bool $directMessage
  132. * @return Notice
  133. * @throws Exception
  134. * @author Diogo Cordeiro <diogo@fc.up.pt>
  135. */
  136. public static function create_notice(array $object, Profile $actor_profile, bool $directMessage = false): Notice
  137. {
  138. $id = $object['id']; // int
  139. $url = isset($object['url']) ? $object['url'] : $id; // string
  140. $content = $object['content']; // string
  141. // possible keys: ['inReplyTo', 'latitude', 'longitude']
  142. $settings = [];
  143. if (isset($object['inReplyTo'])) {
  144. $settings['inReplyTo'] = $object['inReplyTo'];
  145. }
  146. if (isset($object['latitude'])) {
  147. $settings['latitude'] = $object['latitude'];
  148. }
  149. if (isset($object['longitude'])) {
  150. $settings['longitude'] = $object['longitude'];
  151. }
  152. $act = new Activity();
  153. $act->verb = ActivityVerb::POST;
  154. $act->time = time();
  155. $act->actor = $actor_profile->asActivityObject();
  156. $act->context = new ActivityContext();
  157. [$note_type, $note_scope] = self::getNotePolicy($object, $actor_profile);
  158. $options = [
  159. 'source' => 'ActivityPub',
  160. 'uri' => $id,
  161. 'url' => $url,
  162. 'is_local' => $note_type,
  163. 'scope' => $note_scope,
  164. ];
  165. if ($directMessage) {
  166. $options['is_local'] = Notice::GATEWAY;
  167. $options['scope'] = Notice::MESSAGE_SCOPE;
  168. }
  169. // Is this a reply?
  170. if (isset($settings['inReplyTo'])) {
  171. try {
  172. $inReplyTo = ActivityPubPlugin::grab_notice_from_url($settings['inReplyTo']);
  173. $act->context->replyToID = $inReplyTo->getUri();
  174. $act->context->replyToUrl = $inReplyTo->getUrl();
  175. } catch (Exception $e) {
  176. // It failed to grab, maybe we got this note from another source
  177. // (e.g.: OStatus) that handles this differently or we really
  178. // failed to get it...
  179. // Welp, nothing that we can do about, let's
  180. // just fake we don't have such notice.
  181. }
  182. } else {
  183. $inReplyTo = null;
  184. }
  185. // Mentions
  186. $mentions = [];
  187. if (isset($object['tag']) && is_array($object['tag'])) {
  188. foreach ($object['tag'] as $tag) {
  189. if (array_key_exists('type', $tag) && $tag['type'] == 'Mention') {
  190. $mentions[] = $tag['href'];
  191. }
  192. }
  193. }
  194. $mentions_profiles = [];
  195. $discovery = new Activitypub_explorer;
  196. foreach ($mentions as $mention) {
  197. try {
  198. $mentioned_profile = $discovery->lookup($mention);
  199. if (!empty($mentioned_profile)) {
  200. $mentions_profiles[] = $mentioned_profile[0];
  201. }
  202. } catch (Exception $e) {
  203. // Invalid actor found, just let it go, it will eventually be handled by some other federation plugin like OStatus.
  204. }
  205. }
  206. unset($discovery);
  207. foreach ($mentions_profiles as $mp) {
  208. if (!$mp->hasBlocked($actor_profile)) {
  209. $act->context->attention[$mp->getUri()] = 'http://activitystrea.ms/schema/1.0/person';
  210. }
  211. }
  212. // Add location if that is set
  213. if (isset($settings['latitude'], $settings['longitude'])) {
  214. $act->context->location = Location::fromLatLon($settings['latitude'], $settings['longitude']);
  215. }
  216. // Reject notice if it is too long (without the HTML)
  217. if (Notice::contentTooLong($content)) {
  218. throw new Exception('That\'s too long. Maximum notice size is %d character.');
  219. }
  220. // Attachments (first part)
  221. $attachments = [];
  222. if (isset($object['attachment']) && is_array($object['attachment'])) {
  223. foreach ($object['attachment'] as $attachment) {
  224. if (array_key_exists('type', $attachment)
  225. && $attachment['type'] === 'Document'
  226. && array_key_exists('url', $attachment)) {
  227. try {
  228. $file = new File();
  229. $file->url = $attachment['url'];
  230. $file->title = array_key_exists('type', $attachment) ? $attachment['name'] : null;
  231. if (array_key_exists('type', $attachment)) {
  232. $file->mimetype = $attachment['mediaType'];
  233. } else {
  234. $http = new HTTPClient();
  235. common_debug(
  236. 'Performing HEAD request for incoming activity '
  237. . 'to avoid unnecessarily downloading too '
  238. . 'large files. URL: ' . $file->url
  239. );
  240. $head = $http->head($file->url);
  241. $headers = $head->getHeader();
  242. $headers = array_change_key_case($headers, CASE_LOWER);
  243. if (array_key_exists('content-type', $headers)) {
  244. $file->mimetype = $headers['content-type'];
  245. } else {
  246. continue;
  247. }
  248. if (array_key_exists('content-length', $headers)) {
  249. $file->size = $headers['content-length'];
  250. }
  251. }
  252. $file->saveFile();
  253. $attachments[] = $file;
  254. } catch (Exception $e) {
  255. // Whatever.
  256. continue;
  257. }
  258. }
  259. }
  260. }
  261. $actobj = new ActivityObject();
  262. $actobj->type = ActivityObject::NOTE;
  263. $actobj->content = strip_tags($content, '<p><b><i><u><a><ul><ol><li><br>');
  264. // Finally add the activity object to our activity
  265. $act->objects[] = $actobj;
  266. $note = Notice::saveActivity($act, $actor_profile, $options);
  267. // Attachments (last part)
  268. foreach ($attachments as $file) {
  269. File_to_post::processNew($file, $note);
  270. }
  271. return $note;
  272. }
  273. /**
  274. * Validates a note.
  275. *
  276. * @param array $object
  277. * @return bool false if unacceptable for GS but valid ActivityPub object
  278. * @throws Exception if invalid ActivityPub object
  279. * @author Diogo Cordeiro <diogo@fc.up.pt>
  280. */
  281. public static function validate_note(array $object): bool
  282. {
  283. if (!isset($object['id'])) {
  284. common_debug('ActivityPub Notice Validator: Rejected because Object ID was not specified.');
  285. throw new Exception('Object ID not specified.');
  286. } elseif (!filter_var($object['id'], FILTER_VALIDATE_URL)) {
  287. common_debug('ActivityPub Notice Validator: Rejected because Object ID is invalid.');
  288. throw new Exception('Invalid Object ID.');
  289. }
  290. if (!isset($object['type']) || $object['type'] !== 'Note') {
  291. common_debug('ActivityPub Notice Validator: Rejected because of Type.');
  292. throw new Exception('Invalid Object type.');
  293. }
  294. if (isset($object['url']) && !filter_var($object['url'], FILTER_VALIDATE_URL)) {
  295. common_debug('ActivityPub Notice Validator: Rejected because Object URL is invalid.');
  296. throw new Exception('Invalid Object URL.');
  297. }
  298. if (!(isset($object['to']) && isset($object['cc']))) {
  299. common_debug('ActivityPub Notice Validator: Rejected because either Object CC or TO wasn\'t specified.');
  300. throw new Exception('Either Object CC or TO wasn\'t specified.');
  301. }
  302. if (!isset($object['content'])) {
  303. common_debug('ActivityPub Notice Validator: Rejected because Content was not specified (GNU social requires content in notes).');
  304. return false;
  305. }
  306. return true;
  307. }
  308. /**
  309. * Get the original representation URL of a given notice.
  310. *
  311. * @param Notice $notice notice from which to retrieve the URL
  312. * @return string URL
  313. * @throws InvalidUrlException
  314. * @throws Exception
  315. * @author Bruno Casteleiro <brunoccast@fc.up.pt>
  316. * @see note_uri when it's not a generic activity but a object type note
  317. */
  318. public static function getUri(Notice $notice): string
  319. {
  320. if ($notice->isLocal()) {
  321. return common_local_url('apNotice', ['id' => $notice->getID()]);
  322. } else {
  323. return $notice->getUrl();
  324. }
  325. }
  326. /**
  327. * Use this if your Notice is in fact a note
  328. *
  329. * @param int $id
  330. * @return string it's uri
  331. * @author Diogo Cordeiro <diogo@fc.up.pt>
  332. * @see getUri for every other activity that aren't objects of a certain type like note
  333. */
  334. public static function note_uri(int $id): string
  335. {
  336. return common_root_url() . 'object/note/' . $id;
  337. }
  338. /**
  339. * Extract note policy type from note targets.
  340. *
  341. * @param array $note received Note
  342. * @param Profile $actor_profile Note author
  343. * @return [int NoteType, ?int NoteScope] Notice policy type
  344. * @author Bruno Casteleiro <brunoccast@fc.up.pt>
  345. */
  346. public static function getNotePolicy(array $note, Profile $actor_profile): array
  347. {
  348. if (in_array('https://www.w3.org/ns/activitystreams#Public', $note['to'])) { // Public: Visible for all, shown in public feeds
  349. return [$actor_profile->isLocal() ? Notice::LOCAL_PUBLIC : Notice::REMOTE, null];
  350. } elseif (in_array('https://www.w3.org/ns/activitystreams#Public', $note['cc'])) { // Unlisted: Visible for all but not shown in public feeds
  351. return [$actor_profile->isLocal() ? Notice::LOCAL_NONPUBLIC : Notice::GATEWAY, null];
  352. } else { // Either Followers-only or Direct (but this function isn't used for direct)
  353. return [$actor_profile->isLocal() ? Notice::LOCAL_NONPUBLIC : Notice::REMOTE, Notice::FOLLOWER_SCOPE];
  354. }
  355. }
  356. }