pushhub.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  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. * Integrated WebSub hub; lets us only ping them what need it.
  18. * @package Hub
  19. * @author Brion Vibber <brion@status.net>
  20. * @copyright 2010 StatusNet, Inc.
  21. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  22. */
  23. defined('GNUSOCIAL') || die();
  24. /**
  25. * Things to consider...
  26. * should we purge incomplete subscriptions that never get a verification pingback?
  27. * when can we send subscription renewal checks?
  28. * - at next send time probably ok
  29. * when can we handle trimming of subscriptions?
  30. * - at next send time probably ok
  31. * should we keep a fail count?
  32. */
  33. class PushHubAction extends Action
  34. {
  35. public function arg($arg, $def = null)
  36. {
  37. // PHP converts '.'s in incoming var names to '_'s.
  38. // It also merges multiple values, which'll break hub.verify and hub.topic for publishing
  39. // @fixme handle multiple args
  40. $arg = str_replace('hub.', 'hub_', $arg);
  41. return parent::arg($arg, $def);
  42. }
  43. protected function prepare(array $args = [])
  44. {
  45. GNUsocial::setApi(true); // reduce exception reports to aid in debugging
  46. return parent::prepare($args);
  47. }
  48. protected function handle()
  49. {
  50. $mode = $this->trimmed('hub.mode');
  51. switch ($mode) {
  52. case "subscribe":
  53. case "unsubscribe":
  54. $this->subunsub($mode);
  55. break;
  56. case "publish":
  57. throw new ClientException(
  58. // TRANS: Client exception.
  59. _m('Publishing outside feeds not supported.'),
  60. 400
  61. );
  62. default:
  63. throw new ClientException(sprintf(
  64. // TRANS: Client exception. %s is a mode.
  65. _m('Unrecognized mode "%s".'), $mode),
  66. 400
  67. );
  68. }
  69. }
  70. /**
  71. * Process a request for a new or modified WebSub feed subscription.
  72. * If asynchronous verification is requested, updates won't be saved immediately.
  73. *
  74. * HTTP return codes:
  75. * 202 Accepted - request saved and awaiting verification
  76. * 204 No Content - already subscribed
  77. * 400 Bad Request - rejecting this (not specifically spec'd)
  78. */
  79. public function subunsub($mode)
  80. {
  81. $callback = $this->argUrl('hub.callback');
  82. common_debug('New WebSub hub request ('._ve($mode).') for callback '._ve($callback));
  83. $topic = $this->argUrl('hub.topic');
  84. if (!$this->recognizedFeed($topic)) {
  85. common_debug('WebSub hub request had unrecognized feed topic=='._ve($topic));
  86. throw new ClientException(sprintf(
  87. // TRANS: Client exception. %s is a topic.
  88. _m('Unsupported hub.topic %s this hub only serves local user and group Atom feeds.'),
  89. $topic
  90. ));
  91. }
  92. $lease = $this->arg('hub.lease_seconds', null);
  93. if ($mode == 'subscribe' && $lease != '' && !preg_match('/^\d+$/', $lease)) {
  94. common_debug('WebSub hub request had invalid lease_seconds=='._ve($lease));
  95. // TRANS: Client exception. %s is the invalid lease value.
  96. throw new ClientException(sprintf(
  97. _m('Invalid hub.lease "%s". It must be empty or positive integer.'),
  98. $lease
  99. ));
  100. }
  101. $secret = $this->arg('hub.secret', null);
  102. if ($secret != '' && strlen($secret) >= 200) {
  103. common_debug('WebSub hub request had invalid secret=='._ve($secret));
  104. throw new ClientException(sprintf(
  105. // TRANS: Client exception. %s is the invalid hub secret.
  106. _m('Invalid hub.secret "%s". It must be under 200 bytes.'),
  107. $secret
  108. ));
  109. }
  110. $sub = HubSub::getByHashkey($topic, $callback);
  111. if (!$sub instanceof HubSub) {
  112. // Creating a new one!
  113. common_debug('WebSub creating new HubSub entry for topic=='._ve($topic).' to remote callback '._ve($callback));
  114. $sub = new HubSub();
  115. $sub->topic = $topic;
  116. $sub->callback = $callback;
  117. }
  118. if ($mode == 'subscribe') {
  119. if ($secret) {
  120. $sub->secret = $secret;
  121. }
  122. if ($lease) {
  123. $sub->setLease(intval($lease));
  124. }
  125. }
  126. $verify = $this->arg('hub.verify'); // TODO: deprecated
  127. $token = $this->arg('hub.verify_token', null); // TODO: deprecated
  128. if ($verify == 'sync') { // pre-0.4 PuSH
  129. $sub->verify($mode, $token);
  130. http_response_code(204);
  131. } else { // If $verify is not "sync", we might be using WebSub or PuSH 0.4
  132. $sub->scheduleVerify($mode, $token); // If we were certain it's WebSub or PuSH 0.4, token could be removed
  133. http_response_code(202);
  134. }
  135. }
  136. /**
  137. * Check whether the given URL represents one of our canonical
  138. * user or group Atom feeds.
  139. *
  140. * @param string $feed URL
  141. * @return boolean true if it matches, false if not a recognized local feed
  142. * @throws exception if local entity does not exist
  143. */
  144. protected function recognizedFeed($feed)
  145. {
  146. $matches = array();
  147. // Simple mapping to local ID for user or group
  148. if (preg_match('!/(\d+)\.atom$!', $feed, $matches)) {
  149. $id = $matches[1];
  150. $params = array('id' => $id, 'format' => 'atom');
  151. // Double-check against locally generated URLs
  152. switch ($feed) {
  153. case common_local_url('ApiTimelineUser', $params):
  154. $user = User::getKV('id', $id);
  155. if (!$user instanceof User) {
  156. throw new ClientException(sprintf(
  157. // TRANS: Client exception. %s is a feed URL.
  158. _m('Invalid hub.topic "%s". User does not exist.'),
  159. $feed
  160. ));
  161. }
  162. return true;
  163. case common_local_url('ApiTimelineGroup', $params):
  164. $group = Local_group::getKV('group_id', $id);
  165. if (!$group instanceof Local_group) {
  166. throw new ClientException(sprintf(
  167. // TRANS: Client exception. %s is a feed URL.
  168. _m('Invalid hub.topic "%s". Local_group does not exist.'),
  169. $feed
  170. ));
  171. }
  172. return true;
  173. }
  174. common_debug("Feed was not recognized by any local User or Group Atom feed URLs: {$feed}");
  175. return false;
  176. }
  177. // Profile lists are unique per user, so we need both IDs
  178. if (preg_match('!/(\d+)/lists/(\d+)/statuses\.atom$!', $feed, $matches)) {
  179. $user = $matches[1];
  180. $id = $matches[2];
  181. $params = array('user' => $user, 'id' => $id, 'format' => 'atom');
  182. // Double-check against locally generated URLs
  183. switch ($feed) {
  184. case common_local_url('ApiTimelineList', $params):
  185. $list = Profile_list::getKV('id', $id);
  186. $user = User::getKV('id', $user);
  187. if (!$list instanceof Profile_list || !$user instanceof User || $list->tagger != $user->id) {
  188. throw new ClientException(sprintf(
  189. // TRANS: Client exception. %s is a feed URL.
  190. _m('Invalid hub.topic %s; list does not exist.'),
  191. $feed
  192. ));
  193. }
  194. return true;
  195. }
  196. common_debug("Feed was not recognized by any local Profile_list Atom feed URL: {$feed}");
  197. return false;
  198. }
  199. common_debug("Unknown feed URL structure, can't match against local user, group or profile_list: {$feed}");
  200. return false;
  201. }
  202. /**
  203. * Grab and validate a URL from POST parameters.
  204. * @throws ClientException for malformed or non-http/https or blacklisted URLs
  205. */
  206. protected function argUrl($arg)
  207. {
  208. $url = $this->arg($arg);
  209. $params = array('domain_check' => false, // otherwise breaks my local tests :P
  210. 'allowed_schemes' => array('http', 'https'));
  211. $validate = new Validate();
  212. if (!$validate->uri($url, $params)) {
  213. throw new ClientException(sprintf(
  214. // TRANS: Client exception.
  215. // TRANS: %1$s is this argument to the method this exception occurs in, %2$s is a URL.
  216. _m('Invalid URL passed for %1$s: "%2$s"'),
  217. $arg,
  218. $url
  219. ));
  220. }
  221. Event::handle('UrlBlacklistTest', array($url));
  222. return $url;
  223. }
  224. /**
  225. * Get HubSub subscription record for a given feed & subscriber.
  226. *
  227. * @param string $feed
  228. * @param string $callback
  229. * @return mixed HubSub or false
  230. */
  231. protected function getSub($feed, $callback)
  232. {
  233. return HubSub::getByHashkey($feed, $callback);
  234. }
  235. }