]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - actions/apitimelineuser.php
Switching variable access in ApiTimelineUser to GNUsocial improvements
[quix0rs-gnu-social.git] / actions / apitimelineuser.php
1 <?php
2 /**
3  * StatusNet, the distributed open-source microblogging tool
4  *
5  * Show a user's timeline
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  API
23  * @package   StatusNet
24  * @author    Craig Andrews <candrews@integralblue.com>
25  * @author    Evan Prodromou <evan@status.net>
26  * @author    Jeffery To <jeffery.to@gmail.com>
27  * @author    mac65 <mac65@mac65.com>
28  * @author    Mike Cochrane <mikec@mikenz.geek.nz>
29  * @author    Robin Millette <robin@millette.info>
30  * @author    Zach Copley <zach@status.net>
31  * @copyright 2009 StatusNet, Inc.
32  * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org
33  * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
34  * @link      http://status.net/
35  */
36
37 if (!defined('GNUSOCIAL')) { exit(1); }
38
39 /**
40  * Returns the most recent notices (default 20) posted by the authenticating
41  * user. Another user's timeline can be requested via the id parameter. This
42  * is the API equivalent of the user profile web page.
43  *
44  * @category API
45  * @package  StatusNet
46  * @author   Craig Andrews <candrews@integralblue.com>
47  * @author   Evan Prodromou <evan@status.net>
48  * @author   Jeffery To <jeffery.to@gmail.com>
49  * @author   mac65 <mac65@mac65.com>
50  * @author   Mike Cochrane <mikec@mikenz.geek.nz>
51  * @author   Robin Millette <robin@millette.info>
52  * @author   Zach Copley <zach@status.net>
53  * @license  http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
54  * @link     http://status.net/
55  */
56 class ApiTimelineUserAction extends ApiBareAuthAction
57 {
58     var $notices = null;
59
60     var $next_id = null;
61
62     /**
63      * Take arguments for running
64      *
65      * @param array $args $_REQUEST args
66      *
67      * @return boolean success flag
68      */
69     protected function prepare(array $args=array())
70     {
71         parent::prepare($args);
72
73         $this->target = $this->getTargetProfile($this->arg('id'));
74
75         if (!($this->target instanceof Profile)) {
76             // TRANS: Client error displayed requesting most recent notices for a non-existing user.
77             $this->clientError(_('No such user.'), 404);
78         }
79
80         if (!$this->target->isLocal()) {
81             $this->serverError(_('Remote user timelines are not available here yet.'), 501);
82         }
83
84         $this->notices = $this->getNotices();
85
86         return true;
87     }
88
89     /**
90      * Handle the request
91      *
92      * Just show the notices
93      *
94      * @return void
95      */
96     protected function handle()
97     {
98         parent::handle();
99
100         if ($this->isPost()) {
101             $this->handlePost();
102         } else {
103             $this->showTimeline();
104         }
105     }
106
107     /**
108      * Show the timeline of notices
109      *
110      * @return void
111      */
112     function showTimeline()
113     {
114         // We'll use the shared params from the Atom stub
115         // for other feed types.
116         $atom = new AtomUserNoticeFeed($this->target->getUser(), $this->scoped);
117
118         $link = common_local_url(
119                                  'showstream',
120                                  array('nickname' => $this->target->getNickname())
121                                  );
122
123         $self = $this->getSelfUri();
124
125         // FriendFeed's SUP protocol
126         // Also added RSS and Atom feeds
127
128         $suplink = common_local_url('sup', null, null, $this->target->getID());
129         header('X-SUP-ID: ' . $suplink);
130
131
132         // paging links
133         $nextUrl = !empty($this->next_id)
134                     ? common_local_url('ApiTimelineUser',
135                                     array('format' => $this->format,
136                                           'id' => $this->target->getID()),
137                                     array('max_id' => $this->next_id))
138                     : null;
139
140         $prevExtra = array();
141         if (!empty($this->notices)) {
142             assert($this->notices[0] instanceof Notice);
143             $prevExtra['since_id'] = $this->notices[0]->id;
144         }
145
146         $prevUrl = common_local_url('ApiTimelineUser',
147                                     array('format' => $this->format,
148                                           'id' => $this->target->getID()),
149                                     $prevExtra);
150         $firstUrl = common_local_url('ApiTimelineUser',
151                                     array('format' => $this->format,
152                                           'id' => $this->target->getID()));
153
154         switch($this->format) {
155         case 'xml':
156             $this->showXmlTimeline($this->notices);
157             break;
158         case 'rss':
159             $this->showRssTimeline(
160                                    $this->notices,
161                                    $atom->title,
162                                    $link,
163                                    $atom->subtitle,
164                                    $suplink,
165                                    $atom->logo,
166                                    $self
167                                    );
168             break;
169         case 'atom':
170             header('Content-Type: application/atom+xml; charset=utf-8');
171
172             $atom->setId($self);
173             $atom->setSelfLink($self);
174
175             // Add navigation links: next, prev, first
176             // Note: we use IDs rather than pages for navigation; page boundaries
177             // change too quickly!
178
179             if (!empty($this->next_id)) {
180                 $atom->addLink($nextUrl,
181                                array('rel' => 'next',
182                                      'type' => 'application/atom+xml'));
183             }
184
185             if (($this->page > 1 || !empty($this->max_id)) && !empty($this->notices)) {
186                 $atom->addLink($prevUrl,
187                                array('rel' => 'prev',
188                                      'type' => 'application/atom+xml'));
189             }
190
191             if ($this->page > 1 || !empty($this->since_id) || !empty($this->max_id)) {
192                 $atom->addLink($firstUrl,
193                                array('rel' => 'first',
194                                      'type' => 'application/atom+xml'));
195
196             }
197
198             $atom->addEntryFromNotices($this->notices);
199             $this->raw($atom->getString());
200
201             break;
202         case 'json':
203             $this->showJsonTimeline($this->notices);
204             break;
205         case 'as':
206             header('Content-Type: ' . ActivityStreamJSONDocument::CONTENT_TYPE);
207             $doc = new ActivityStreamJSONDocument($this->scoped);
208             $doc->setTitle($atom->title);
209             $doc->addLink($link, 'alternate', 'text/html');
210             $doc->addItemsFromNotices($this->notices);
211
212             if (!empty($this->next_id)) {
213                 $doc->addLink($nextUrl,
214                                array('rel' => 'next',
215                                      'type' => ActivityStreamJSONDocument::CONTENT_TYPE));
216             }
217
218             if (($this->page > 1 || !empty($this->max_id)) && !empty($this->notices)) {
219                 $doc->addLink($prevUrl,
220                                array('rel' => 'prev',
221                                      'type' => ActivityStreamJSONDocument::CONTENT_TYPE));
222             }
223
224             if ($this->page > 1 || !empty($this->since_id) || !empty($this->max_id)) {
225                 $doc->addLink($firstUrl,
226                                array('rel' => 'first',
227                                      'type' => ActivityStreamJSONDocument::CONTENT_TYPE));
228             }
229
230             $this->raw($doc->asString());
231             break;
232         default:
233             // TRANS: Client error displayed when coming across a non-supported API method.
234             $this->clientError(_('API method not found.'), 404);
235         }
236     }
237
238     /**
239      * Get notices
240      *
241      * @return array notices
242      */
243     function getNotices()
244     {
245         $notices = array();
246
247         $notice = $this->target->getNotices(($this->page-1) * $this->count,
248                                           $this->count + 1,
249                                           $this->since_id,
250                                           $this->max_id,
251                                           $this->scoped);
252
253         while ($notice->fetch()) {
254             if (count($notices) < $this->count) {
255                 $notices[] = clone($notice);
256             } else {
257                 $this->next_id = $notice->id;
258                 break;
259             }
260         }
261
262         return $notices;
263     }
264
265     /**
266      * We expose AtomPub here, so non-GET/HEAD reqs must be read/write.
267      *
268      * @param array $args other arguments
269      *
270      * @return boolean true
271      */
272
273     function isReadOnly($args)
274     {
275         return ($_SERVER['REQUEST_METHOD'] == 'GET' || $_SERVER['REQUEST_METHOD'] == 'HEAD');
276     }
277
278     /**
279      * When was this feed last modified?
280      *
281      * @return string datestamp of the latest notice in the stream
282      */
283     function lastModified()
284     {
285         if (!empty($this->notices) && (count($this->notices) > 0)) {
286             return strtotime($this->notices[0]->created);
287         }
288
289         return null;
290     }
291
292     /**
293      * An entity tag for this stream
294      *
295      * Returns an Etag based on the action name, language, user ID, and
296      * timestamps of the first and last notice in the timeline
297      *
298      * @return string etag
299      */
300     function etag()
301     {
302         if (!empty($this->notices) && (count($this->notices) > 0)) {
303             $last = count($this->notices) - 1;
304
305             return '"' . implode(
306                                  ':',
307                                  array($this->arg('action'),
308                                        common_user_cache_hash($this->scoped),
309                                        common_language(),
310                                        $this->target->getID(),
311                                        strtotime($this->notices[0]->created),
312                                        strtotime($this->notices[$last]->created))
313                                  )
314               . '"';
315         }
316
317         return null;
318     }
319
320     function handlePost()
321     {
322         if (empty($this->scoped) ||
323             $this->target->sameAs($this->scoped)) {
324             // TRANS: Client error displayed trying to add a notice to another user's timeline.
325             $this->clientError(_('Only the user can add to their own timeline.'), 403);
326         }
327
328         // Only handle posts for Atom
329         if ($this->format != 'atom') {
330             // TRANS: Client error displayed when using another format than AtomPub.
331             $this->clientError(_('Only accept AtomPub for Atom feeds.'));
332         }
333
334         $xml = trim(file_get_contents('php://input'));
335         if (empty($xml)) {
336             // TRANS: Client error displayed attempting to post an empty API notice.
337             $this->clientError(_('Atom post must not be empty.'));
338         }
339
340         $old = error_reporting(error_reporting() & ~(E_WARNING | E_NOTICE));
341         $dom = new DOMDocument();
342         $ok = $dom->loadXML($xml);
343         error_reporting($old);
344         if (!$ok) {
345             // TRANS: Client error displayed attempting to post an API that is not well-formed XML.
346             $this->clientError(_('Atom post must be well-formed XML.'));
347         }
348
349         if ($dom->documentElement->namespaceURI != Activity::ATOM ||
350             $dom->documentElement->localName != 'entry') {
351             // TRANS: Client error displayed when not using an Atom entry.
352             $this->clientError(_('Atom post must be an Atom entry.'));
353         }
354
355         $activity = new Activity($dom->documentElement);
356
357         $saved = null;
358
359         if (Event::handle('StartAtomPubNewActivity', array(&$activity, $this->target->getUser(), &$saved))) {
360             if ($activity->verb != ActivityVerb::POST) {
361                 // TRANS: Client error displayed when not using the POST verb. Do not translate POST.
362                 $this->clientError(_('Can only handle POST activities.'));
363             }
364
365             $note = $activity->objects[0];
366
367             if (!in_array($note->type, array(ActivityObject::NOTE,
368                                              ActivityObject::BLOGENTRY,
369                                              ActivityObject::STATUS))) {
370                 // TRANS: Client error displayed when using an unsupported activity object type.
371                 // TRANS: %s is the unsupported activity object type.
372                 $this->clientError(sprintf(_('Cannot handle activity object type "%s".'),
373                                              $note->type));
374             }
375
376             $saved = $this->postNote($activity);
377
378             Event::handle('EndAtomPubNewActivity', array($activity, $this->target->getUser(), $saved));
379         }
380
381         if (!empty($saved)) {
382             header('HTTP/1.1 201 Created');
383             header("Location: " . common_local_url('ApiStatusesShow', array('id' => $saved->id,
384                                                                             'format' => 'atom')));
385             $this->showSingleAtomStatus($saved);
386         }
387     }
388
389     function postNote($activity)
390     {
391         $note = $activity->objects[0];
392
393         // Use summary as fallback for content
394
395         if (!empty($note->content)) {
396             $sourceContent = $note->content;
397         } else if (!empty($note->summary)) {
398             $sourceContent = $note->summary;
399         } else if (!empty($note->title)) {
400             $sourceContent = $note->title;
401         } else {
402             // @fixme fetch from $sourceUrl?
403             // TRANS: Client error displayed when posting a notice without content through the API.
404             // TRANS: %d is the notice ID (number).
405             $this->clientError(sprintf(_('No content for notice %d.'), $note->id));
406         }
407
408         // Get (safe!) HTML and text versions of the content
409
410         $rendered = common_purify($sourceContent);
411         $content = common_strip_html($rendered);
412
413         $shortened = $this->target->shortenLinks($content);
414
415         $options = array('is_local' => Notice::LOCAL_PUBLIC,
416                          'rendered' => $rendered,
417                          'replies' => array(),
418                          'groups' => array(),
419                          'tags' => array(),
420                          'urls' => array());
421
422         // accept remote URI (not necessarily a good idea)
423
424         common_debug("Note ID is {$note->id}");
425
426         if (!empty($note->id)) {
427             $notice = Notice::getKV('uri', trim($note->id));
428
429             if (!empty($notice)) {
430                 // TRANS: Client error displayed when using another format than AtomPub.
431                 // TRANS: %s is the notice URI.
432                 $this->clientError(sprintf(_('Notice with URI "%s" already exists.'), $note->id));
433             }
434             common_log(LOG_NOTICE, "Saving client-supplied notice URI '$note->id'");
435             $options['uri'] = $note->id;
436         }
437
438         // accept remote create time (also maybe not such a good idea)
439
440         if (!empty($activity->time)) {
441             common_log(LOG_NOTICE, "Saving client-supplied create time {$activity->time}");
442             $options['created'] = common_sql_date($activity->time);
443         }
444
445         // Check for optional attributes...
446
447         if ($activity->context instanceof ActivityContext) {
448
449             foreach ($activity->context->attention as $uri=>$type) {
450                 try {
451                     $profile = Profile::fromUri($uri);
452                     if ($profile->isGroup()) {
453                         $options['groups'][] = $profile->id;
454                     } else {
455                         $options['replies'][] = $uri;
456                     }
457                 } catch (UnknownUriException $e) {
458                     common_log(LOG_WARNING, sprintf('AtomPub post with unknown attention URI %s', $uri));
459                 }
460             }
461
462             // Maintain direct reply associations
463             // @fixme what about conversation ID?
464
465             if (!empty($activity->context->replyToID)) {
466                 $orig = Notice::getKV('uri',
467                                           $activity->context->replyToID);
468                 if (!empty($orig)) {
469                     $options['reply_to'] = $orig->id;
470                 }
471             }
472
473             $location = $activity->context->location;
474
475             if ($location) {
476                 $options['lat'] = $location->lat;
477                 $options['lon'] = $location->lon;
478                 if ($location->location_id) {
479                     $options['location_ns'] = $location->location_ns;
480                     $options['location_id'] = $location->location_id;
481                 }
482             }
483         }
484
485         // Atom categories <-> hashtags
486
487         foreach ($activity->categories as $cat) {
488             if ($cat->term) {
489                 $term = common_canonical_tag($cat->term);
490                 if ($term) {
491                     $options['tags'][] = $term;
492                 }
493             }
494         }
495
496         // Atom enclosures -> attachment URLs
497         foreach ($activity->enclosures as $href) {
498             // @fixme save these locally or....?
499             $options['urls'][] = $href;
500         }
501
502         $saved = Notice::saveNew($this->target->getID(),
503                                  $content,
504                                  'atompub', // TODO: deal with this
505                                  $options);
506
507         return $saved;
508     }
509 }