Activitypub_profile.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. <?php
  2. /**
  3. * GNU social - a federating social network
  4. *
  5. * ActivityPubPlugin implementation for GNU Social
  6. *
  7. * LICENCE: 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 published by
  9. * the Free Software Foundation, either version 3 of the License, or
  10. * (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 <http://www.gnu.org/licenses/>.
  19. *
  20. * @category Plugin
  21. * @package GNUsocial
  22. * @author Diogo Cordeiro <diogo@fc.up.pt>
  23. * @copyright 2018 Free Software Foundation http://fsf.org
  24. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
  25. * @link https://www.gnu.org/software/social/
  26. */
  27. if (!defined('GNUSOCIAL')) {
  28. exit(1);
  29. }
  30. /**
  31. * ActivityPub Profile
  32. *
  33. * @category Plugin
  34. * @package GNUsocial
  35. * @author Diogo Cordeiro <diogo@fc.up.pt>
  36. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
  37. * @link http://www.gnu.org/software/social/
  38. */
  39. class Activitypub_profile extends Managed_DataObject
  40. {
  41. public $__table = 'Activitypub_profile';
  42. public $uri; // text() not_null
  43. public $profile_id; // int(4) primary_key not_null
  44. public $inboxuri; // text() not_null
  45. public $sharedInboxuri; // text()
  46. public $nickname; // varchar(64) multiple_key not_null
  47. public $fullname; // text()
  48. public $profileurl; // text()
  49. public $homepage; // text()
  50. public $bio; // text() multiple_key
  51. public $location; // text()
  52. public $created; // datetime() not_null
  53. public $modified; // timestamp() not_null default_CURRENT_TIMESTAMP
  54. /**
  55. * Return table definition for Schema setup and DB_DataObject usage.
  56. *
  57. * @author Diogo Cordeiro <diogo@fc.up.pt>
  58. * @return array array of column definitions
  59. */
  60. public static function schemaDef()
  61. {
  62. return [
  63. 'fields' => [
  64. 'uri' => ['type' => 'text', 'not null' => true],
  65. 'profile_id' => ['type' => 'integer'],
  66. 'inboxuri' => ['type' => 'text', 'not null' => true],
  67. 'sharedInboxuri' => ['type' => 'text'],
  68. 'created' => ['type' => 'datetime', 'not null' => true],
  69. 'modified' => ['type' => 'datetime', 'not null' => true],
  70. ],
  71. 'primary key' => ['profile_id'],
  72. 'unique keys' => [
  73. 'Activitypub_profile_profile_id_key' => ['profile_id'],
  74. ],
  75. 'foreign keys' => [
  76. 'Activitypub_profile_profile_id_fkey' => ['profile', ['profile_id' => 'id']],
  77. ],
  78. ];
  79. }
  80. /**
  81. * Generates a pretty profile from a Profile object
  82. *
  83. * @author Diogo Cordeiro <diogo@fc.up.pt>
  84. * @param Profile $profile
  85. * @return pretty array to be used in a response
  86. */
  87. public static function profile_to_array($profile)
  88. {
  89. $uri = ActivityPubPlugin::actor_uri($profile);
  90. $id = $profile->getID();
  91. $rsa = new Activitypub_rsa();
  92. $public_key = $rsa->ensure_public_key($profile);
  93. unset($rsa);
  94. $res = [
  95. '@context' => 'https://www.w3.org/ns/activitystreams',
  96. 'id' => $uri,
  97. 'type' => 'Person',
  98. 'following' => common_local_url('apActorFollowing', ['id' => $id]),
  99. 'followers' => common_local_url('apActorFollowers', ['id' => $id]),
  100. 'liked' => common_local_url('apActorLiked', ['id' => $id]),
  101. 'inbox' => common_local_url('apInbox', ['id' => $id]),
  102. 'outbox' => common_local_url('apActorOutbox', ['id' => $id]),
  103. 'preferredUsername' => $profile->getNickname(),
  104. 'name' => $profile->getBestName(),
  105. 'summary' => ($desc = $profile->getDescription()) == null ? "" : $desc,
  106. 'url' => $profile->getUrl(),
  107. 'manuallyApprovesFollowers' => false,
  108. 'publicKey' => [
  109. 'id' => $uri."#public-key",
  110. 'owner' => $uri,
  111. 'publicKeyPem' => $public_key
  112. ],
  113. 'tag' => [],
  114. 'attachment' => [],
  115. 'icon' => [
  116. 'type' => 'Image',
  117. 'mediaType' => 'image/png',
  118. 'height' => AVATAR_PROFILE_SIZE,
  119. 'width' => AVATAR_PROFILE_SIZE,
  120. 'url' => $profile->avatarUrl(AVATAR_PROFILE_SIZE)
  121. ]
  122. ];
  123. if ($profile->isLocal()) {
  124. $res['endpoints']['sharedInbox'] = common_local_url('apInbox');
  125. } else {
  126. $aprofile = new Activitypub_profile();
  127. $aprofile = $aprofile->from_profile($profile);
  128. $res['endpoints']['sharedInbox'] = $aprofile->sharedInboxuri;
  129. }
  130. return $res;
  131. }
  132. /**
  133. * Insert the current object variables into the database
  134. *
  135. * @author Diogo Cordeiro <diogo@fc.up.pt>
  136. * @access public
  137. * @throws ServerException
  138. */
  139. public function do_insert()
  140. {
  141. $profile = new Profile();
  142. $profile->created = $this->created = $this->modified = common_sql_now();
  143. $fields = [
  144. 'uri' => 'profileurl',
  145. 'nickname' => 'nickname',
  146. 'fullname' => 'fullname',
  147. 'bio' => 'bio'
  148. ];
  149. foreach ($fields as $af => $pf) {
  150. $profile->$pf = $this->$af;
  151. }
  152. $this->profile_id = $profile->insert();
  153. if ($this->profile_id === false) {
  154. $profile->query('ROLLBACK');
  155. throw new ServerException('Profile insertion failed.');
  156. }
  157. $ok = $this->insert();
  158. if ($ok === false) {
  159. $profile->query('ROLLBACK');
  160. $this->query('ROLLBACK');
  161. throw new ServerException('Cannot save ActivityPub profile.');
  162. }
  163. }
  164. /**
  165. * Fetch the locally stored profile for this Activitypub_profile
  166. *
  167. * @author Diogo Cordeiro <diogo@fc.up.pt>
  168. * @return Profile
  169. * @throws NoProfileException if it was not found
  170. */
  171. public function local_profile()
  172. {
  173. $profile = Profile::getKV('id', $this->profile_id);
  174. if (!$profile instanceof Profile) {
  175. throw new NoProfileException($this->profile_id);
  176. }
  177. return $profile;
  178. }
  179. /**
  180. * Generates an Activitypub_profile from a Profile
  181. *
  182. * @author Diogo Cordeiro <diogo@fc.up.pt>
  183. * @param Profile $profile
  184. * @return Activitypub_profile
  185. * @throws Exception if no Activitypub_profile exists for given Profile
  186. */
  187. public static function from_profile(Profile $profile)
  188. {
  189. $profile_id = $profile->getID();
  190. $aprofile = self::getKV('profile_id', $profile_id);
  191. if (!$aprofile instanceof Activitypub_profile) {
  192. // No Activitypub_profile for this profile_id,
  193. if (!$profile->isLocal()) {
  194. // create one!
  195. $aprofile = self::create_from_local_profile($profile);
  196. } else {
  197. throw new Exception('No Activitypub_profile for Profile ID: '.$profile_id. ', this is a local user.');
  198. }
  199. }
  200. $fields = [
  201. 'uri' => 'profileurl',
  202. 'nickname' => 'nickname',
  203. 'fullname' => 'fullname',
  204. 'bio' => 'bio'
  205. ];
  206. foreach ($fields as $af => $pf) {
  207. $aprofile->$af = $profile->$pf;
  208. }
  209. return $aprofile;
  210. }
  211. /**
  212. * Given an existent local profile creates an ActivityPub profile.
  213. * One must be careful not to give a user profile to this function
  214. * as only remote users have ActivityPub_profiles on local instance
  215. *
  216. * @author Diogo Cordeiro <diogo@fc.up.pt>
  217. * @param Profile $profile
  218. * @return Activitypub_profile
  219. */
  220. private static function create_from_local_profile(Profile $profile)
  221. {
  222. $url = $profile->getUri();
  223. $inboxes = Activitypub_explorer::get_actor_inboxes_uri($url);
  224. if ($inboxes == null) {
  225. throw new Exception('This is not an ActivityPub user thus AProfile is politely refusing to proceed.');
  226. }
  227. $aprofile->created = $aprofile->modified = common_sql_now();
  228. $aprofile = new Activitypub_profile;
  229. $aprofile->profile_id = $profile->getID();
  230. $aprofile->uri = $url;
  231. $aprofile->nickname = $profile->getNickname();
  232. $aprofile->fullname = $profile->getFullname();
  233. $aprofile->bio = substr($profile->getDescription(), 0, 1000);
  234. $aprofile->inboxuri = $inboxes["inbox"];
  235. $aprofile->sharedInboxuri = $inboxes["sharedInbox"];
  236. $aprofile->insert();
  237. return $aprofile;
  238. }
  239. /**
  240. * Returns sharedInbox if possible, inbox otherwise
  241. *
  242. * @author Diogo Cordeiro <diogo@fc.up.pt>
  243. * @return string Inbox URL
  244. */
  245. public function get_inbox()
  246. {
  247. if (is_null($this->sharedInboxuri)) {
  248. return $this->inboxuri;
  249. }
  250. return $this->sharedInboxuri;
  251. }
  252. /**
  253. * Getter for uri property
  254. *
  255. * @author Diogo Cordeiro <diogo@fc.up.pt>
  256. * @return string URI
  257. */
  258. public function getUri()
  259. {
  260. return $this->uri;
  261. }
  262. /**
  263. * Getter for url property
  264. *
  265. * @author Diogo Cordeiro <diogo@fc.up.pt>
  266. * @return string URL
  267. */
  268. public function getUrl()
  269. {
  270. return $this->getUri();
  271. }
  272. /**
  273. * Getter for id property
  274. *
  275. * @author Diogo Cordeiro <diogo@fc.up.pt>
  276. * @return int32
  277. */
  278. public function getID()
  279. {
  280. return $this->profile_id;
  281. }
  282. /**
  283. * Ensures a valid Activitypub_profile when provided with a valid URI.
  284. *
  285. * @author Diogo Cordeiro <diogo@fc.up.pt>
  286. * @param string $url
  287. * @return Activitypub_profile
  288. * @throws Exception if it isn't possible to return an Activitypub_profile
  289. */
  290. public static function fromUri($url)
  291. {
  292. try {
  293. return self::from_profile(Activitypub_explorer::get_profile_from_url($url));
  294. } catch (Exception $e) {
  295. throw new Exception('No valid ActivityPub profile found for given URI.');
  296. }
  297. }
  298. /**
  299. * Look up, and if necessary create, an Activitypub_profile for the remote
  300. * entity with the given webfinger address.
  301. * This should never return null -- you will either get an object or
  302. * an exception will be thrown.
  303. *
  304. * @author GNU Social
  305. * @author Diogo Cordeiro <diogo@fc.up.pt>
  306. * @param string $addr webfinger address
  307. * @return Activitypub_profile
  308. * @throws Exception on error conditions
  309. */
  310. public static function ensure_web_finger($addr)
  311. {
  312. // Normalize $addr, i.e. add 'acct:' if missing
  313. $addr = Discovery::normalize($addr);
  314. // Try the cache
  315. $uri = self::cacheGet(sprintf('activitypub_profile:webfinger:%s', $addr));
  316. if ($uri !== false) {
  317. if (is_null($uri)) {
  318. // Negative cache entry
  319. // TRANS: Exception.
  320. throw new Exception(_m('Not a valid webfinger address (via cache).'));
  321. }
  322. try {
  323. return self::fromUri($uri);
  324. } catch (Exception $e) {
  325. common_log(LOG_ERR, sprintf(__METHOD__ . ': Webfinger address cache inconsistent with database, did not find Activitypub_profile uri==%s', $uri));
  326. self::cacheSet(sprintf('activitypub_profile:webfinger:%s', $addr), false);
  327. }
  328. }
  329. // Now, try some discovery
  330. $disco = new Discovery();
  331. try {
  332. $xrd = $disco->lookup($addr);
  333. } catch (Exception $e) {
  334. // Save negative cache entry so we don't waste time looking it up again.
  335. // @todo FIXME: Distinguish temporary failures?
  336. self::cacheSet(sprintf('activitypub_profile:webfinger:%s', $addr), null);
  337. // TRANS: Exception.
  338. throw new Exception(_m('Not a valid webfinger address.'));
  339. }
  340. $hints = array_merge(
  341. array('webfinger' => $addr),
  342. DiscoveryHints::fromXRD($xrd)
  343. );
  344. // If there's an Hcard, let's grab its info
  345. if (array_key_exists('hcard', $hints)) {
  346. if (!array_key_exists('profileurl', $hints) ||
  347. $hints['hcard'] != $hints['profileurl']) {
  348. $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
  349. $hints = array_merge($hcardHints, $hints);
  350. }
  351. }
  352. // If we got a profile page, try that!
  353. $profileUrl = null;
  354. if (array_key_exists('profileurl', $hints)) {
  355. $profileUrl = $hints['profileurl'];
  356. try {
  357. common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
  358. $aprofile = self::fromUri($hints['profileurl']);
  359. self::cacheSet(sprintf('activitypub_profile:webfinger:%s', $addr), $aprofile->getUri());
  360. return $aprofile;
  361. } catch (Exception $e) {
  362. common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
  363. // keep looking
  364. //
  365. // @todo FIXME: This means an error discovering from profile page
  366. // may give us a corrupt entry using the webfinger URI, which
  367. // will obscure the correct page-keyed profile later on.
  368. }
  369. }
  370. // XXX: try hcard
  371. // XXX: try FOAF
  372. // TRANS: Exception. %s is a webfinger address.
  373. throw new Exception(sprintf(_m('Could not find a valid profile for "%s".'), $addr));
  374. }
  375. /**
  376. * Update remote user profile in local instance
  377. * Depends on do_update
  378. *
  379. * @author Diogo Cordeiro <diogo@fc.up.pt>
  380. * @param array $res remote response
  381. * @return Profile remote Profile object
  382. */
  383. public static function update_profile($aprofile, $res)
  384. {
  385. // ActivityPub Profile
  386. $aprofile->uri = $res['id'];
  387. $aprofile->nickname = $res['preferredUsername'];
  388. $aprofile->fullname = isset($res['name']) ? $res['name'] : null;
  389. $aprofile->bio = isset($res['summary']) ? substr(strip_tags($res['summary']), 0, 1000) : null;
  390. $aprofile->inboxuri = $res['inbox'];
  391. $aprofile->sharedInboxuri = isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : $res['inbox'];
  392. $profile = $aprofile->local_profile();
  393. $profile->modified = $aprofile->modified = common_sql_now();
  394. $fields = [
  395. 'uri' => 'profileurl',
  396. 'nickname' => 'nickname',
  397. 'fullname' => 'fullname',
  398. 'bio' => 'bio'
  399. ];
  400. foreach ($fields as $af => $pf) {
  401. $profile->$pf = $aprofile->$af;
  402. }
  403. // Profile
  404. $profile->update();
  405. $aprofile->update();
  406. // Public Key
  407. Activitypub_rsa::update_public_key($profile, $res['publicKey']['publicKeyPem']);
  408. // Avatar
  409. if (isset($res['icon']['url'])) {
  410. try {
  411. Activitypub_explorer::update_avatar($profile, $res['icon']['url']);
  412. } catch (Exception $e) {
  413. // Let the exception go, it isn't a serious issue
  414. common_debug('An error ocurred while grabbing remote avatar'.$e->getMessage());
  415. }
  416. }
  417. return $profile;
  418. }
  419. }