]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/Linkback/LinkbackPlugin.php
64165199eb3bb093459a577ebcf3cd437f43eb64
[quix0rs-gnu-social.git] / plugins / Linkback / LinkbackPlugin.php
1 <?php
2 /**
3  * StatusNet, the distributed open-source microblogging tool
4  *
5  * Plugin to do linkbacks for notices containing links
6  *
7  * PHP version 5
8  *
9  * LICENCE: This program is free software: you can redistribute it and/or modify
10  * it under the terms of the GNU Affero General Public License as published by
11  * the Free Software Foundation, either version 3 of the License, or
12  * (at your option) any later version.
13  *
14  * This program is distributed in the hope that it will be useful,
15  * but WITHOUT ANY WARRANTY; without even the implied warranty of
16  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17  * GNU Affero General Public License for more details.
18  *
19  * You should have received a copy of the GNU Affero General Public License
20  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21  *
22  * @category  Plugin
23  * @package   StatusNet
24  * @author    Evan Prodromou <evan@status.net>
25  * @copyright 2009 StatusNet, Inc.
26  * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
27  * @link      http://status.net/
28  */
29
30 if (!defined('STATUSNET')) {
31     exit(1);
32 }
33
34 require_once('Auth/Yadis/Yadis.php');
35 require_once(__DIR__ . '/lib/util.php');
36
37 define('LINKBACKPLUGIN_VERSION', '0.1');
38
39 /**
40  * Plugin to do linkbacks for notices containing URLs
41  *
42  * After new notices are saved, we check their text for URLs. If there
43  * are URLs, we test each URL to see if it supports any
44  *
45  * @category Plugin
46  * @package  StatusNet
47  * @author   Evan Prodromou <evan@status.net>
48  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
49  * @link     http://status.net/
50  *
51  * @see      Event
52  */
53 class LinkbackPlugin extends Plugin
54 {
55     var $notice = null;
56
57     function __construct()
58     {
59         parent::__construct();
60     }
61
62     function onHandleQueuedNotice($notice)
63     {
64         if (intval($notice->is_local) === Notice::LOCAL_PUBLIC) {
65             // Try to avoid actually mucking with the
66             // notice content
67             $c = $notice->content;
68             $this->notice = $notice;
69
70             if(!$notice->getProfile()->
71                 getPref("linkbackplugin", "disable_linkbacks")
72             ) {
73                 // Ignoring results
74                 common_replace_urls_callback($c,
75                                              array($this, 'linkbackUrl'));
76             }
77
78             if($notice->isRepeat()) {
79                 $repeat = Notice::getByID($notice->repeat_of);
80                 $this->linkbackUrl($repeat->getUrl());
81             } else if(!empty($notice->reply_to)) {
82                 try {
83                     $parent = $notice->getParent();
84                     $this->linkbackUrl($parent->getUrl());
85                 } catch (NoParentNoticeException $e) {
86                     // can't link back to what we don't know (apparently parent notice disappeared from our db)
87                     return true;
88                 }
89             }
90
91             // doubling up getReplies and getAttentionProfileIDs because we're not entirely migrated yet
92             $replyProfiles = Profile::multiGet('id', array_unique(array_merge($notice->getReplies(), $notice->getAttentionProfileIDs())));
93             foreach($replyProfiles->fetchAll('profileurl') as $profileurl) {
94                 $this->linkbackUrl($profileurl);
95             }
96         }
97         return true;
98     }
99
100     function linkbackUrl($url)
101     {
102         common_log(LOG_DEBUG,"Attempting linkback for " . $url);
103
104         $orig = $url;
105         $url = htmlspecialchars_decode($orig);
106         $scheme = parse_url($url, PHP_URL_SCHEME);
107         if (!in_array($scheme, array('http', 'https'))) {
108             return $orig;
109         }
110
111         // XXX: Do a HEAD first to save some time/bandwidth
112
113         $fetcher = Auth_Yadis_Yadis::getHTTPFetcher();
114
115         $result = $fetcher->get($url,
116                                 array('User-Agent: ' . $this->userAgent(),
117                                       'Accept: application/html+xml,text/html'));
118
119         if (!in_array($result->status, array('200', '206'))) {
120             return $orig;
121         }
122
123         // XXX: Should handle relative-URI resolution in these detections
124
125         $wm = $this->getWebmention($result);
126         if(!empty($wm)) {
127             // It is the webmention receiver's job to resolve source
128             // Ref: https://github.com/converspace/webmention/issues/43
129             $this->webmention($url, $wm);
130         } else {
131             $pb = $this->getPingback($result);
132             if (!empty($pb)) {
133                 // Pingback still looks for exact URL in our source, so we
134                 // must send what we have
135                 $this->pingback($url, $pb);
136             } else {
137                 $tb = $this->getTrackback($result);
138                 if (!empty($tb)) {
139                     $this->trackback($result->final_url, $tb);
140                 }
141             }
142         }
143
144         return $orig;
145     }
146
147     // Based on https://github.com/indieweb/mention-client-php
148     // which is licensed Apache 2.0
149     function getWebmention($result) {
150         if (isset($result->headers['Link'])) {
151             // XXX: the fetcher only gives back one of each header, so this may fail on multiple Link headers
152             if(preg_match('~<((?:https?://)?[^>]+)>; rel="webmention"~', $result->headers['Link'], $match)) {
153                 return $match[1];
154             } elseif(preg_match('~<((?:https?://)?[^>]+)>; rel="http://webmention.org/?"~', $result->headers['Link'], $match)) {
155                 return $match[1];
156             }
157         }
158
159         // FIXME: Do proper DOM traversal
160         if(preg_match('/<(?:link|a)[ ]+href="([^"]+)"[ ]+rel="[^" ]* ?webmention ?[^" ]*"[ ]*\/?>/i', $result->body, $match)
161            || preg_match('/<(?:link|a)[ ]+rel="[^" ]* ?webmention ?[^" ]*"[ ]+href="([^"]+)"[ ]*\/?>/i', $result->body, $match)) {
162             return $match[1];
163         } elseif(preg_match('/<(?:link|a)[ ]+href="([^"]+)"[ ]+rel="http:\/\/webmention\.org\/?"[ ]*\/?>/i', $result->body, $match)
164                  || preg_match('/<(?:link|a)[ ]+rel="http:\/\/webmention\.org\/?"[ ]+href="([^"]+)"[ ]*\/?>/i', $result->body, $match)) {
165             return $match[1];
166         }
167     }
168
169     function webmention($url, $endpoint) {
170         $source = $this->notice->getUrl();
171
172         $payload = array(
173             'source' => $source,
174             'target' => $url
175         );
176
177         $request = HTTPClient::start();
178         try {
179             $response = $request->post($endpoint,
180                 array(
181                     'Content-type: application/x-www-form-urlencoded',
182                     'Accept: application/json'
183                 ),
184                 $payload
185             );
186
187             if(!in_array($response->getStatus(), array(200,202))) {
188                 common_log(LOG_WARNING,
189                            "Webmention request failed for '$url' ($endpoint)");
190             }
191         } catch (Exception $e) {
192             common_log(LOG_WARNING, "Webmention request failed for '{$url}' ({$endpoint}): {$e->getMessage()}");
193         }
194     }
195
196     function getPingback($result) {
197         if (array_key_exists('X-Pingback', $result->headers)) {
198             return $result->headers['X-Pingback'];
199         } else if(preg_match('/<(?:link|a)[ ]+href="([^"]+)"[ ]+rel="[^" ]* ?pingback ?[^" ]*"[ ]*\/?>/i', $result->body, $match)
200                   || preg_match('/<(?:link|a)[ ]+rel="[^" ]* ?pingback ?[^" ]*"[ ]+href="([^"]+)"[ ]*\/?>/i', $result->body, $match)) {
201             return $match[1];
202         }
203     }
204
205     function pingback($url, $endpoint)
206     {
207         $args = array($this->notice->getUrl(), $url);
208
209         if (!extension_loaded('xmlrpc')) {
210             if (!dl('xmlrpc.so')) {
211                 common_log(LOG_ERR, "Can't pingback; xmlrpc extension not available.");
212                 return;
213             }
214         }
215
216         $request = HTTPClient::start();
217         try {
218             $request->setBody(xmlrpc_encode_request('pingback.ping', $args));
219             $response = $request->post($endpoint,
220                 array('Content-Type: text/xml'),
221                 false);
222             $response = xmlrpc_decode($response->getBody());
223             if (xmlrpc_is_fault($response)) {
224                 common_log(LOG_WARNING,
225                        "Pingback error for '$url' ($endpoint): ".
226                        "$response[faultString] ($response[faultCode])");
227             } else {
228                 common_log(LOG_INFO,
229                        "Pingback success for '$url' ($endpoint): ".
230                        "'$response'");
231             }
232         } catch (Exception $e) {
233             common_log(LOG_WARNING, "Pingback request failed for '{$url}' ({$endpoint}): {$e->getMessage()}");
234         }
235     }
236
237     // Largely cadged from trackback_cls.php by
238     // Ran Aroussi <ran@blogish.org>, GPL2 or any later version
239     // http://phptrackback.sourceforge.net/
240     function getTrackback($result)
241     {
242         $text = $result->body;
243         $url = $result->final_url;
244
245         if (preg_match_all('/(<rdf:RDF.*?<\/rdf:RDF>)/sm', $text, $match, PREG_SET_ORDER)) {
246             for ($i = 0; $i < count($match); $i++) {
247                 if (preg_match('|dc:identifier="' . preg_quote($url) . '"|ms', $match[$i][1])) {
248                     $rdf_array[] = trim($match[$i][1]);
249                 }
250             }
251
252             // Loop through the RDFs array and extract trackback URIs
253
254             $tb_array = array(); // <- holds list of trackback URIs
255
256             if (!empty($rdf_array)) {
257
258                 for ($i = 0; $i < count($rdf_array); $i++) {
259                     if (preg_match('/trackback:ping="([^"]+)"/', $rdf_array[$i], $array)) {
260                         $tb_array[] = trim($array[1]);
261                         break;
262                     }
263                 }
264             }
265
266             // Return Trackbacks
267
268             if (empty($tb_array)) {
269                 return null;
270             } else {
271                 return $tb_array[0];
272             }
273         }
274
275         if (preg_match_all('/(<a[^>]*?rel=[\'"]trackback[\'"][^>]*?>)/', $text, $match)) {
276             foreach ($match[1] as $atag) {
277                 if (preg_match('/href=[\'"]([^\'"]*?)[\'"]/', $atag, $url)) {
278                     return $url[1];
279                 }
280             }
281         }
282
283         return null;
284
285     }
286
287     function trackback($url, $endpoint)
288     {
289         $profile = $this->notice->getProfile();
290
291         // TRANS: Trackback title.
292         // TRANS: %1$s is a profile nickname, %2$s is a timestamp.
293         $args = array('title' => sprintf(_m('%1$s\'s status on %2$s'),
294                                          $profile->nickname,
295                                          common_exact_date($this->notice->created)),
296                       'excerpt' => $this->notice->content,
297                       'url' => $this->notice->getUrl(),
298                       'blog_name' => $profile->nickname);
299
300         $fetcher = Auth_Yadis_Yadis::getHTTPFetcher();
301
302         $result = $fetcher->post($endpoint,
303                                  http_build_query($args),
304                                  array('User-Agent: ' . $this->userAgent()));
305
306         if ($result->status != '200') {
307             common_log(LOG_WARNING,
308                        "Trackback error for '$url' ($endpoint): ".
309                        "$result->body");
310         } else {
311             common_log(LOG_INFO,
312                        "Trackback success for '$url' ($endpoint): ".
313                        "'$result->body'");
314         }
315     }
316
317
318     public function onRouterInitialized(URLMapper $m)
319     {
320         $m->connect('main/linkback/webmention', array('action' => 'webmention'));
321         $m->connect('main/linkback/pingback', array('action' => 'pingback'));
322     }
323
324     public function onStartShowHTML($action)
325     {
326         header('Link: <' . common_local_url('webmention') . '>; rel="webmention"', false);
327         header('X-Pingback: ' . common_local_url('pingback'));
328     }
329
330     public function version()
331     {
332         return LINKBACKPLUGIN_VERSION;
333     }
334
335     function onPluginVersion(array &$versions)
336     {
337         $versions[] = array('name' => 'Linkback',
338                             'version' => LINKBACKPLUGIN_VERSION,
339                             'author' => 'Evan Prodromou',
340                             'homepage' => 'http://status.net/wiki/Plugin:Linkback',
341                             'rawdescription' =>
342                             // TRANS: Plugin description.
343                             _m('Notify blog authors when their posts have been linked in '.
344                                'microblog notices using '.
345                                '<a href="http://www.hixie.ch/specs/pingback/pingback">Pingback</a> '.
346                                'or <a href="http://www.movabletype.org/docs/mttrackback.html">Trackback</a> protocols.'));
347         return true;
348     }
349
350     public function onStartInitializeRouter(URLMapper $m)
351     {
352         $m->connect('settings/linkback', array('action' => 'linkbacksettings'));
353         return true;
354     }
355
356     function onEndAccountSettingsNav($action)
357     {
358         $action_name = $action->trimmed('action');
359
360         $action->menuItem(common_local_url('linkbacksettings'),
361                           // TRANS: OpenID plugin menu item on user settings page.
362                           _m('MENU', 'Send Linkbacks'),
363                           // TRANS: OpenID plugin tooltip for user settings menu item.
364                           _m('Opt-out of sending linkbacks.'),
365                           $action_name === 'linkbacksettings');
366         return true;
367     }
368
369     function onStartNoticeSourceLink($notice, &$name, &$url, &$title)
370     {
371         // If we don't handle this, keep the event handler going
372         if (!in_array($notice->source, array('linkback'))) {
373             return true;
374         }
375
376         try {
377             $url = $notice->getUrl();
378             // If getUrl() throws exception, $url is never set
379
380             $bits = parse_url($url);
381             $domain = $bits['host'];
382             if (substr($domain, 0, 4) == 'www.') {
383                 $name = substr($domain, 4);
384             } else {
385                 $name = $domain;
386             }
387
388             // TRANS: Title. %s is a domain name.
389             $title = sprintf(_m('Sent from %s via Linkback'), $domain);
390
391             // Abort event handler, we have a name and URL!
392             return false;
393         } catch (InvalidUrlException $e) {
394             // This just means we don't have the notice source data
395             return true;
396         }
397     }
398 }