We are no longer offering accounts on this server. Consider https://gitlab.freedesktop.org/ as a place to host projects.

Notice.php 103 KB
Newer Older
Evan Prodromou's avatar
Evan Prodromou committed
1
<?php
Brenda Wallace's avatar
Brenda Wallace committed
2
/**
3
 * StatusNet - the distributed open-source microblogging tool
4
 * Copyright (C) 2008-2011 StatusNet, Inc.
Evan Prodromou's avatar
Evan Prodromou committed
5
 *
Evan Prodromou's avatar
Evan Prodromou committed
6 7 8 9
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
Evan Prodromou's avatar
Evan Prodromou committed
10
 *
Evan Prodromou's avatar
Evan Prodromou committed
11 12
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.     See the
Evan Prodromou's avatar
Evan Prodromou committed
14
 * GNU Affero General Public License for more details.
Evan Prodromou's avatar
Evan Prodromou committed
15
 *
Evan Prodromou's avatar
Evan Prodromou committed
16
 * You should have received a copy of the GNU Affero General Public License
17
 * along with this program.     If not, see <http://www.gnu.org/licenses/>.
Evan Prodromou's avatar
Evan Prodromou committed
18
 *
Brenda Wallace's avatar
Brenda Wallace committed
19 20 21 22 23 24 25 26 27 28 29 30 31
 * @category Notices
 * @package  StatusNet
 * @author   Brenda Wallace <shiny@cpan.org>
 * @author   Christopher Vollick <psycotica0@gmail.com>
 * @author   CiaranG <ciaran@ciarang.com>
 * @author   Craig Andrews <candrews@integralblue.com>
 * @author   Evan Prodromou <evan@controlezvous.ca>
 * @author   Gina Haeussge <osd@foosel.net>
 * @author   Jeffery To <jeffery.to@gmail.com>
 * @author   Mike Cochrane <mikec@mikenz.geek.nz>
 * @author   Robin Millette <millette@controlyourself.ca>
 * @author   Sarven Capadisli <csarven@controlyourself.ca>
 * @author   Tom Adams <tom@holizz.com>
mattl's avatar
mattl committed
32
 * @author   Mikael Nordfeldth <mmn@hethane.se>
33
 * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org
Brenda Wallace's avatar
Brenda Wallace committed
34
 * @license  GNU Affero General Public License http://www.gnu.org/licenses/
Evan Prodromou's avatar
Evan Prodromou committed
35
 */
Evan Prodromou's avatar
Evan Prodromou committed
36

37
if (!defined('GNUSOCIAL')) { exit(1); }
Evan Prodromou's avatar
Evan Prodromou committed
38

Evan Prodromou's avatar
Evan Prodromou committed
39
/**
Evan Prodromou's avatar
Evan Prodromou committed
40 41
 * Table Definition for notice
 */
Evan Prodromou's avatar
Evan Prodromou committed
42

43
/* We keep 200 notices, the max number of notices available per API request,
44 45
 * in the memcached cache. */

46
define('NOTICE_CACHE_WINDOW', CachingNoticeStream::CACHE_WINDOW);
47

Evan Prodromou's avatar
Evan Prodromou committed
48 49
define('MAX_BOXCARS', 128);

50
class Notice extends Managed_DataObject
Evan Prodromou's avatar
Evan Prodromou committed
51
{
52 53
    ###START_AUTOCODE
    /* the code below is auto generated do not remove the above tag */
Evan Prodromou's avatar
Evan Prodromou committed
54

55 56
    public $__table = 'notice';                          // table name
    public $id;                              // int(4)  primary_key not_null
57
    public $profile_id;                      // int(4)  multiple_key not_null
58
    public $uri;                             // varchar(191)  unique_key   not 255 because utf8mb4 takes more space
59 60
    public $content;                         // text
    public $rendered;                        // text
61
    public $url;                             // varchar(191)   not 255 because utf8mb4 takes more space
62 63
    public $created;                         // datetime  multiple_key not_null default_0000-00-00%2000%3A00%3A00
    public $modified;                        // timestamp   not_null default_CURRENT_TIMESTAMP
64
    public $reply_to;                        // int(4)
65
    public $is_local;                        // int(4)
66
    public $source;                          // varchar(32)
67
    public $conversation;                    // int(4)
68
    public $repeat_of;                       // int(4)
69 70
    public $verb;                            // varchar(191)   not 255 because utf8mb4 takes more space
    public $object_type;                     // varchar(191)   not 255 because utf8mb4 takes more space
Evan Prodromou's avatar
Evan Prodromou committed
71
    public $scope;                           // int(4)
Evan Prodromou's avatar
Evan Prodromou committed
72

73 74
    /* the code above is auto generated do not remove the tag below */
    ###END_AUTOCODE
Evan Prodromou's avatar
Evan Prodromou committed
75

76 77
    public static function schemaDef()
    {
78
        $def = array(
79 80 81
            'fields' => array(
                'id' => array('type' => 'serial', 'not null' => true, 'description' => 'unique identifier'),
                'profile_id' => array('type' => 'int', 'not null' => true, 'description' => 'who made the update'),
82
                'uri' => array('type' => 'varchar', 'length' => 191, 'description' => 'universally unique identifier, usually a tag URI'),
83
                'content' => array('type' => 'text', 'description' => 'update content', 'collate' => 'utf8mb4_general_ci'),
84
                'rendered' => array('type' => 'text', 'description' => 'HTML version of the content'),
85
                'url' => array('type' => 'varchar', 'length' => 191, 'description' => 'URL of any attachment (image, video, bookmark, whatever)'),
86 87 88 89 90 91 92
                'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
                'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
                'reply_to' => array('type' => 'int', 'description' => 'notice replied to (usually a guess)'),
                'is_local' => array('type' => 'int', 'size' => 'tiny', 'default' => 0, 'description' => 'notice was generated by a user'),
                'source' => array('type' => 'varchar', 'length' => 32, 'description' => 'source of comment, like "web", "im", or "clientname"'),
                'conversation' => array('type' => 'int', 'description' => 'id of root notice in this conversation'),
                'repeat_of' => array('type' => 'int', 'description' => 'notice this is a repeat of'),
93 94
                'object_type' => array('type' => 'varchar', 'length' => 191, 'description' => 'URI representing activity streams object type', 'default' => 'http://activitystrea.ms/schema/1.0/note'),
                'verb' => array('type' => 'varchar', 'length' => 191, 'description' => 'URI representing activity streams verb', 'default' => 'http://activitystrea.ms/schema/1.0/post'),
95
                'scope' => array('type' => 'int',
96
                                 'description' => 'bit map for distribution scope; 0 = everywhere; 1 = this server only; 2 = addressees; 4 = followers; null = default'),
97 98 99 100 101 102 103 104 105 106 107 108
            ),
            'primary key' => array('id'),
            'unique keys' => array(
                'notice_uri_key' => array('uri'),
            ),
            'foreign keys' => array(
                'notice_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
                'notice_reply_to_fkey' => array('notice', array('reply_to' => 'id')),
                'notice_conversation_fkey' => array('conversation', array('conversation' => 'id')), # note... used to refer to notice.id
                'notice_repeat_of_fkey' => array('notice', array('repeat_of' => 'id')), # @fixme: what about repeats of deleted notices?
            ),
            'indexes' => array(
109
                'notice_created_id_is_local_idx' => array('created', 'id', 'is_local'),
110
                'notice_profile_id_idx' => array('profile_id', 'created', 'id'),
111 112 113
                'notice_repeat_of_created_id_idx' => array('repeat_of', 'created', 'id'),
                'notice_conversation_created_id_idx' => array('conversation', 'created', 'id'),
                'notice_replyto_idx' => array('reply_to')
114 115
            )
        );
116 117 118 119 120 121

        if (common_config('search', 'type') == 'fulltext') {
            $def['fulltext indexes'] = array('content' => array('content'));
        }

        return $def;
122
    }
123

124
    /* Notice types */
125
    const LOCAL_PUBLIC    =  1;
126
    const REMOTE          =  0;
127 128
    const LOCAL_NONPUBLIC = -1;
    const GATEWAY         = -2;
Evan Prodromou's avatar
Evan Prodromou committed
129

130
    const PUBLIC_SCOPE    = 0; // Useful fake constant
Evan Prodromou's avatar
Evan Prodromou committed
131 132
    const SITE_SCOPE      = 1;
    const ADDRESSEE_SCOPE = 2;
133 134
    const GROUP_SCOPE     = 4;
    const FOLLOWER_SCOPE  = 8;
Evan Prodromou's avatar
Evan Prodromou committed
135

136
    protected $_profile = array();
137

138 139 140 141
    /**
     * Will always return a profile, if anything fails it will
     * (through _setProfile) throw a NoProfileException.
     */
mattl's avatar
mattl committed
142
    public function getProfile()
143
    {
144
        if (!isset($this->_profile[$this->profile_id])) {
145 146 147 148
            // We could've sent getKV directly to _setProfile, but occasionally we get
            // a "false" (instead of null), likely because it indicates a cache miss.
            $profile = Profile::getKV('id', $this->profile_id);
            $this->_setProfile($profile instanceof Profile ? $profile : null);
149
        }
150
        return $this->_profile[$this->profile_id];
151
    }
152

153
    public function _setProfile(Profile $profile=null)
154
    {
155 156 157
        if (!$profile instanceof Profile) {
            throw new NoProfileException($this->profile_id);
        }
158
        $this->_profile[$this->profile_id] = $profile;
159
    }
Evan Prodromou's avatar
Evan Prodromou committed
160

161
    public function deleteAs(Profile $actor, $delete_event=true)
mattl's avatar
mattl committed
162
    {
163 164
        if (!$this->getProfile()->sameAs($actor) && !$actor->hasRight(Right::DELETEOTHERSNOTICE)) {
            throw new AuthorizationException(_('You are not allowed to delete another user\'s notice.'));
mattl's avatar
mattl committed
165 166
        }

Evan Prodromou's avatar
Evan Prodromou committed
167 168 169
        if (Event::handle('NoticeDeleteRelated', array($this))) {
            // Clear related records
            $this->clearReplies();
170
            $this->clearLocation();
Evan Prodromou's avatar
Evan Prodromou committed
171 172 173
            $this->clearRepeats();
            $this->clearTags();
            $this->clearGroupInboxes();
174
            $this->clearFiles();
175
            $this->clearAttentions();
Evan Prodromou's avatar
Evan Prodromou committed
176 177
            // NOTE: we don't clear queue items
        }
Evan Prodromou's avatar
Evan Prodromou committed
178

179 180 181 182 183 184 185 186 187 188 189 190
        $result = null;
        if (!$delete_event || Event::handle('DeleteNoticeAsProfile', array($this, $actor, &$result))) {
            // If $delete_event is true, we run the event. If the Event then 
            // returns false it is assumed everything was handled properly 
            // and the notice was deleted.
            $result = $this->delete();
        }
        return $result;
    }

    public function delete($useWhere=false)
    {
191
        $result = parent::delete($useWhere);
192 193 194

        $this->blowOnDelete();
        return $result;
195
    }
Evan Prodromou's avatar
Evan Prodromou committed
196

197 198 199 200 201
    public function getUri()
    {
        return $this->uri;
    }

202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
    /*
     * Get a Notice object by URI. Will call external plugins for help
     * using the event StartGetNoticeFromURI.
     *
     * @param string $uri A unique identifier for a resource (notice in this case)
     */
    static function fromUri($uri)
    {
        $notice = null;

        if (Event::handle('StartGetNoticeFromUri', array($uri, &$notice))) {
            $notice = Notice::getKV('uri', $uri);
            Event::handle('EndGetNoticeFromUri', array($uri, $notice));
        }

        if (!$notice instanceof Notice) {
            throw new UnknownUriException($uri);
        }

        return $notice;
    }

224 225 226 227 228 229 230 231 232 233 234 235 236
    /*
     * @param $root boolean If true, link to just the conversation root.
     *
     * @return URL to conversation
     */
    public function getConversationUrl($anchor=true)
    {
        return Conversation::getUrlFromNotice($this, $anchor);
    }

    /*
     * Get the local representation URL of this notice.
     */
237 238 239 240 241
    public function getLocalUrl()
    {
        return common_local_url('shownotice', array('notice' => $this->id), null, null, false);
    }

mattl's avatar
mattl committed
242 243 244 245 246 247 248 249 250 251 252 253
    public function getTitle()
    {
        $title = null;
        if (Event::handle('GetNoticeTitle', array($this, &$title))) {
            // TRANS: Title of a notice posted without a title value.
            // TRANS: %1$s is a user name, %2$s is the notice creation date/time.
            $title = sprintf(_('%1$s\'s status on %2$s'),
                             $this->getProfile()->getFancyName(),
                             common_exact_date($this->created));
        }
        return $title;
    }
254

255 256 257 258
    public function getContent()
    {
        return $this->content;
    }
mattl's avatar
mattl committed
259

260 261 262 263 264
    public function getRendered()
    {
        return $this->rendered;
    }

265 266
    /*
     * Get the original representation URL of this notice.
267 268
     *
     * @param boolean $fallback     Whether to fall back to generate a local URL or throw InvalidUrlException
269
     */
270
    public function getUrl($fallback=false)
271 272
    {
        // The risk is we start having empty urls and non-http uris...
273 274
        // and we can't really handle any other protocol right now.
        switch (true) {
mattl's avatar
mattl committed
275
        case common_valid_http_url($this->url): // should we allow non-http/https URLs?
276
            return $this->url;
277 278
        case !$this->isLocal() && common_valid_http_url($this->uri): // Sometimes we only have the URI for remote posts.
            return $this->uri;
279
        case $this->isLocal() || $fallback:
mattl's avatar
mattl committed
280 281
            // let's generate a valid link to our locally available notice on demand
            return common_local_url('shownotice', array('notice' => $this->id), null, null, false);
282
        default:
mattl's avatar
mattl committed
283
            common_debug('No URL available for notice: id='.$this->id);
284
            throw new InvalidUrlException($this->url);
285
        }
286 287
    }

mattl's avatar
mattl committed
288
    public function getObjectType($canonical=false) {
289
        return ActivityUtils::resolveUri($this->object_type, $canonical);
290 291
    }

mattl's avatar
mattl committed
292
    public static function getByUri($uri)
293
    {
mattl's avatar
mattl committed
294 295 296 297 298 299
        $notice = new Notice();
        $notice->uri = $uri;
        if (!$notice->find(true)) {
            throw new NoResultException($notice);
        }
        return $notice;
300 301
    }

302 303 304
    /**
     * Extract #hashtags from this notice's content and save them to the database.
     */
305 306
    function saveTags()
    {
307
        /* extract all #hastags */
308
        $count = preg_match_all('/(?:^|\s)#([\pL\pN_\-\.]{1,64})/u', strtolower($this->content), $match);
309 310 311
        if (!$count) {
            return true;
        }
Evan Prodromou's avatar
Evan Prodromou committed
312

313 314 315 316 317 318 319 320 321 322
        /* Add them to the database */
        return $this->saveKnownTags($match[1]);
    }

    /**
     * Record the given set of hash tags in the db for this notice.
     * Given tag strings will be normalized and checked for dupes.
     */
    function saveKnownTags($hashtags)
    {
323 324
        //turn each into their canonical tag
        //this is needed to remove dupes before saving e.g. #hash.tag = #hashtag
325
        for($i=0; $i<count($hashtags); $i++) {
Brion Vibber's avatar
Brion Vibber committed
326
            /* elide characters we don't want in the tag */
327
            $hashtags[$i] = common_canonical_tag($hashtags[$i]);
328
        }
Evan Prodromou's avatar
Evan Prodromou committed
329

330
        foreach(array_unique($hashtags) as $hashtag) {
331
            $this->saveTag($hashtag);
332
            self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, $hashtag);
333 334 335
        }
        return true;
    }
Evan Prodromou's avatar
Evan Prodromou committed
336

337 338 339 340
    /**
     * Record a single hash tag as associated with this notice.
     * Tag format and uniqueness must be validated by caller.
     */
341 342 343 344 345 346 347
    function saveTag($hashtag)
    {
        $tag = new Notice_tag();
        $tag->notice_id = $this->id;
        $tag->tag = $hashtag;
        $tag->created = $this->created;
        $id = $tag->insert();
Evan Prodromou's avatar
Evan Prodromou committed
348

349
        if (!$id) {
350
            // TRANS: Server exception. %s are the error details.
351
            throw new ServerException(sprintf(_('Database error inserting hashtag: %s.'),
352 353 354
                                              $last_error->message));
            return;
        }
355 356 357

        // if it's saved, blow its cache
        $tag->blowCache(false);
358
    }
Evan Prodromou's avatar
Evan Prodromou committed
359

360 361 362 363 364 365 366 367 368 369 370 371
    /**
     * Save a new notice and push it out to subscribers' inboxes.
     * Poster's permissions are checked before sending.
     *
     * @param int $profile_id Profile ID of the poster
     * @param string $content source message text; links may be shortened
     *                        per current user's preference
     * @param string $source source key ('web', 'api', etc)
     * @param array $options Associative array of optional properties:
     *              string 'created' timestamp of notice; defaults to now
     *              int 'is_local' source/gateway ID, one of:
     *                  Notice::LOCAL_PUBLIC    - Local, ok to appear in public timeline
372
     *                  Notice::REMOTE          - Sent from a remote service;
373 374 375
     *                                            hide from public timeline but show in
     *                                            local "and friends" timelines
     *                  Notice::LOCAL_NONPUBLIC - Local, but hide from public timeline
376
     *                  Notice::GATEWAY         - From another non-OStatus service;
377 378 379 380 381 382 383
     *                                            will not appear in public views
     *              float 'lat' decimal latitude for geolocation
     *              float 'lon' decimal longitude for geolocation
     *              int 'location_id' geoname identifier
     *              int 'location_ns' geoname namespace to interpret location_id
     *              int 'reply_to'; notice ID this is a reply to
     *              int 'repeat_of'; notice ID this is a repeat of
384
     *              string 'uri' unique ID for notice; a unique tag uri (can be url or anything too)
385 386 387 388 389 390
     *              string 'url' permalink to notice; defaults to local notice URL
     *              string 'rendered' rendered HTML version of content
     *              array 'replies' list of profile URIs for reply delivery in
     *                              place of extracting @-replies from content.
     *              array 'groups' list of group IDs to deliver to, in place of
     *                              extracting ! tags from content
391 392
     *              array 'tags' list of hashtag strings to save with the notice
     *                           in place of extracting # tags from content
393 394
     *              array 'urls' list of attached/referred URLs to save with the
     *                           notice in place of extracting links from content
395
     *              boolean 'distribute' whether to distribute the notice, default true
Evan Prodromou's avatar
Evan Prodromou committed
396
     *              string 'object_type' URL of the associated object type (default ActivityObject::NOTE)
397
     *              string 'verb' URL of the associated verb (default ActivityVerb::POST)
398
     *              int 'scope' Scope bitmask; default to SITE_SCOPE on private sites, 0 otherwise
399
     *
400
     * @fixme tag override
401 402 403 404
     *
     * @return Notice
     * @throws ClientException
     */
405
    static function saveNew($profile_id, $content, $source, array $options=null) {
406
        $defaults = array('uri' => null,
407
                          'url' => null,
408 409 410
                          'conversation' => null,   // URI of conversation
                          'reply_to' => null,       // This will override convo URI if the parent is known
                          'repeat_of' => null,      // This will override convo URI if the repeated notice is known
411
                          'scope' => null,
412 413 414
                          'distribute' => true,
                          'object_type' => null,
                          'verb' => null);
415

416 417
        if (!empty($options) && is_array($options)) {
            $options = array_merge($defaults, $options);
418
            extract($options);
419 420
        } else {
            extract($defaults);
421 422
        }

423
        if (!isset($is_local)) {
424 425
            $is_local = Notice::LOCAL_PUBLIC;
        }
Evan Prodromou's avatar
Evan Prodromou committed
426

427
        $profile = Profile::getKV('id', $profile_id);
mattl's avatar
mattl committed
428 429 430 431 432
        if (!$profile instanceof Profile) {
            // TRANS: Client exception thrown when trying to save a notice for an unknown user.
            throw new ClientException(_('Problem saving notice. Unknown user.'));
        }

433
        $user = User::getKV('id', $profile_id);
mattl's avatar
mattl committed
434
        if ($user instanceof User) {
435 436 437 438 439
            // Use the local user's shortening preferences, if applicable.
            $final = $user->shortenLinks($content);
        } else {
            $final = common_shorten_links($content);
        }
Evan Prodromou's avatar
Evan Prodromou committed
440 441

        if (Notice::contentTooLong($final)) {
442
            // TRANS: Client exception thrown if a notice contains too many characters.
Evan Prodromou's avatar
Evan Prodromou committed
443 444 445 446 447
            throw new ClientException(_('Problem saving notice. Too long.'));
        }

        if (common_config('throttle', 'enabled') && !Notice::checkEditThrottle($profile_id)) {
            common_log(LOG_WARNING, 'Excessive posting by profile #' . $profile_id . '; throttled.');
448
            // TRANS: Client exception thrown when a user tries to post too many notices in a given time frame.
Evan Prodromou's avatar
Evan Prodromou committed
449
            throw new ClientException(_('Too many notices too fast; take a breather '.
450
                                        'and post again in a few minutes.'));
Evan Prodromou's avatar
Evan Prodromou committed
451 452 453 454
        }

        if (common_config('site', 'dupelimit') > 0 && !Notice::checkDupes($profile_id, $final)) {
            common_log(LOG_WARNING, 'Dupe posting by profile #' . $profile_id . '; throttled.');
455
            // TRANS: Client exception thrown when a user tries to post too many duplicate notices in a given time frame.
Evan Prodromou's avatar
Evan Prodromou committed
456
            throw new ClientException(_('Too many duplicate messages too quickly;'.
457
                                        ' take a breather and post again in a few minutes.'));
Evan Prodromou's avatar
Evan Prodromou committed
458 459
        }

460 461
        if (!$profile->hasRight(Right::NEWNOTICE)) {
            common_log(LOG_WARNING, "Attempted post from user disallowed to post: " . $profile->nickname);
Zach Copley's avatar
Zach Copley committed
462

463
            // TRANS: Client exception thrown when a user tries to post while being banned.
464
            throw new ClientException(_('You are banned from posting notices on this site.'), 403);
Evan Prodromou's avatar
Evan Prodromou committed
465 466 467 468 469 470 471
        }

        $notice = new Notice();
        $notice->profile_id = $profile_id;

        $autosource = common_config('public', 'autosource');

472
        // Sandboxed are non-false, but not 1, either
Evan Prodromou's avatar
Evan Prodromou committed
473

474
        if (!$profile->hasRight(Right::PUBLICNOTICE) ||
Evan Prodromou's avatar
Evan Prodromou committed
475 476 477 478 479 480 481 482 483 484 485 486
            ($source && $autosource && in_array($source, $autosource))) {
            $notice->is_local = Notice::LOCAL_NONPUBLIC;
        } else {
            $notice->is_local = $is_local;
        }

        if (!empty($created)) {
            $notice->created = $created;
        } else {
            $notice->created = common_sql_now();
        }

mattl's avatar
mattl committed
487 488 489
        if (!$notice->isLocal()) {
            // Only do these checks for non-local notices. Local notices will generate these values later.
            if (!common_valid_http_url($url)) {
mattl's avatar
mattl committed
490
                common_debug('Bad notice URL: ['.$url.'], URI: ['.$uri.']. Cannot link back to original! This is normal for shared notices etc.');
mattl's avatar
mattl committed
491 492 493 494 495 496
            }
            if (empty($uri)) {
                throw new ServerException('No URI for remote notice. Cannot accept that.');
            }
        }

Evan Prodromou's avatar
Evan Prodromou committed
497
        $notice->content = $final;
498

Evan Prodromou's avatar
Evan Prodromou committed
499 500
        $notice->source = $source;
        $notice->uri = $uri;
501
        $notice->url = $url;
Evan Prodromou's avatar
Evan Prodromou committed
502

503 504
        // Get the groups here so we can figure out replies and such
        if (!isset($groups)) {
505
            $groups = User_group::idsFromText($notice->content, $profile);
506 507
        }

508 509
        $reply = null;

Evan Prodromou's avatar
Evan Prodromou committed
510 511
        // Handle repeat case

512
        if (!empty($options['repeat_of'])) {
513 514 515

            // Check for a private one

516
            $repeat = Notice::getByID($options['repeat_of']);
517

518
            if ($profile->sameAs($repeat->getProfile())) {
519 520 521 522 523
                // TRANS: Client error displayed when trying to repeat an own notice.
                throw new ClientException(_('You cannot repeat your own notice.'));
            }

            if ($repeat->scope != Notice::SITE_SCOPE &&
524
                $repeat->scope != Notice::PUBLIC_SCOPE) {
525
                // TRANS: Client error displayed when trying to repeat a non-public notice.
526 527 528
                throw new ClientException(_('Cannot repeat a private notice.'), 403);
            }

529 530 531 532 533 534
            if (!$repeat->inScope($profile)) {
                // The generic checks above should cover this, but let's be sure!
                // TRANS: Client error displayed when trying to repeat a notice you cannot access.
                throw new ClientException(_('Cannot repeat a notice you cannot read.'), 403);
            }

535
            if ($profile->hasRepeated($repeat)) {
536 537 538
                // TRANS: Client error displayed when trying to repeat an already repeated notice.
                throw new ClientException(_('You already repeated that notice.'));
            }
539

540 541
            $notice->repeat_of = $repeat->id;
            $notice->conversation = $repeat->conversation;
Evan Prodromou's avatar
Evan Prodromou committed
542
        } else {
543 544 545 546 547 548 549 550 551 552 553
            $reply = null;

            // If $reply_to is specified, we check that it exists, and then
            // return it if it does
            if (!empty($reply_to)) {
                $reply = Notice::getKV('id', $reply_to);
            } elseif (in_array($source, array('xmpp', 'mail', 'sms'))) {
                // If the source lacks capability of sending the "reply_to"
                // metadata, let's try to find an inline replyto-reference.
                $reply = self::getInlineReplyTo($profile, $final);
            }
554

555
            if ($reply instanceof Notice) {
556 557 558 559 560 561
                if (!$reply->inScope($profile)) {
                    // TRANS: Client error displayed when trying to reply to a notice a the target has no access to.
                    // TRANS: %1$s is a user nickname, %2$d is a notice ID (number).
                    throw new ClientException(sprintf(_('%1$s has no access to notice %2$d.'),
                                                      $profile->nickname, $reply->id), 403);
                }
Evan Prodromou's avatar
Evan Prodromou committed
562

563
                // If it's a repeat, the reply_to should be to the original
564
                if ($reply->isRepeat()) {
565 566 567 568 569
                    $notice->reply_to = $reply->repeat_of;
                } else {
                    $notice->reply_to = $reply->id;
                }
                // But the conversation ought to be the same :)
570
                $notice->conversation = $reply->conversation;
Evan Prodromou's avatar
Evan Prodromou committed
571

572 573
                // If the original is private to a group, and notice has
                // no group specified, make it to the same group(s)
574

575
                if (empty($groups) && ($reply->scope & Notice::GROUP_SCOPE)) {
576 577 578 579 580 581 582 583 584
                    $groups = array();
                    $replyGroups = $reply->getGroups();
                    foreach ($replyGroups as $group) {
                        if ($profile->isMember($group)) {
                            $groups[] = $group->id;
                        }
                    }
                }

585
                // Scope set below
586
            }
587 588 589 590 591 592

            // If we don't know the reply, we might know the conversation!
            // This will happen if a known remote user replies to an
            // unknown remote user - within a known conversation.
            if (empty($notice->conversation) and !empty($options['conversation'])) {
                $conv = Conversation::getKV('uri', $options['conversation']);
593
                if ($conv instanceof Conversation) {
594
                    common_debug('Conversation stitched together from (probably) a reply to unknown remote user. Activity creation time ('.$notice->created.') should maybe be compared to conversation creation time ('.$conv->created.').');
595
                    $notice->conversation = $conv->id;
596 597 598
                } else {
                    // Conversation URI was not found, so we must create it. But we can't create it
                    // until we have a Notice ID because of the database layout...
599 600
                    // $options['conversation'] will be used later after the $notice->insert();
                    common_debug('Conversation URI not found, so we have to create it after inserting this Notice: '.$options['conversation']);
601
                }
602 603 604 605 606
            } else {
                // If we're not using the attached conversation URI let's remove it
                // so we don't mistake ourselves later, when creating our own Conversation.
                // This implies that the notice knows which conversation it belongs to.
                $options['conversation'] = null;
607
            }
Evan Prodromou's avatar
Evan Prodromou committed
608 609
        }

610
        $notloc = new Notice_location();
611
        if (!empty($lat) && !empty($lon)) {
612 613
            $notloc->lat = $lat;
            $notloc->lon = $lon;
614 615 616
        }

        if (!empty($location_ns) && !empty($location_id)) {
617 618
            $notloc->location_id = $location_id;
            $notloc->location_ns = $location_ns;
619 620
        }

621 622 623
        if (!empty($rendered)) {
            $notice->rendered = $rendered;
        } else {
624 625 626
            $notice->rendered = common_render_content($final,
                                                      $notice->getProfile(),
                                                      $notice->hasParent() ? $notice->getParent() : null);
627 628
        }

629
        if (empty($verb)) {
630
            if ($notice->isRepeat()) {
631
                $notice->verb        = ActivityVerb::SHARE;
632
                $notice->object_type = ActivityObject::ACTIVITY;
633 634 635 636 637 638 639
            } else {
                $notice->verb        = ActivityVerb::POST;
            }
        } else {
            $notice->verb = $verb;
        }

Evan Prodromou's avatar
Evan Prodromou committed
640
        if (empty($object_type)) {
641
            $notice->object_type = (empty($notice->reply_to)) ? ActivityObject::NOTE : ActivityObject::COMMENT;
Evan Prodromou's avatar
Evan Prodromou committed
642 643 644 645
        } else {
            $notice->object_type = $object_type;
        }

646 647
        if (is_null($scope) && $reply instanceof Notice) {
            $notice->scope = $reply->scope;
648 649 650 651
        } else {
            $notice->scope = $scope;
        }

652
        $notice->scope = self::figureOutScope($profile, $groups, $notice->scope);
653

Evan Prodromou's avatar
Evan Prodromou committed
654 655
        if (Event::handle('StartNoticeSave', array(&$notice))) {

656
            // XXX: some of these functions write to the DB
Evan Prodromou's avatar
Evan Prodromou committed
657

658
            try {
659 660 661 662 663 664 665
                $notice->insert();  // throws exception on failure, if successful we have an ->id

                if (($notloc->lat && $notloc->lon) || ($notloc->location_id && $notloc->location_ns)) {
                    $notloc->notice_id = $notice->getID();
                    $notloc->insert();  // store the notice location if it had any information
                }

666 667
                // If it's not part of a conversation, it's
                // the beginning of a new conversation.
668
                if (empty($notice->conversation)) {
669 670
                    $orig = clone($notice);
                    // $act->context->conversation will be null if it was not provided
671

672 673 674 675
                    $conv = Conversation::create($notice, $options['conversation']);
                    $notice->conversation = $conv->id;
                    $notice->update($orig);
                }
676 677 678 679 680 681
            } catch (Exception $e) {
                // Let's test if we managed initial insert, which would imply
                // failing on some update-part (check 'insert()'). Delete if
                // something had been stored to the database.
                if (!empty($notice->id)) {
                    $notice->delete();
Evan Prodromou's avatar
Evan Prodromou committed
682
                }
683
                throw $e;
Evan Prodromou's avatar
Evan Prodromou committed
684
            }
685 686
        }

687 688 689
        // Only save 'attention' and metadata stuff (URLs, tags...) stuff if
        // the activityverb is a POST (since stuff like repeat, favorite etc.
        // reasonably handle notifications themselves.
690
        if (ActivityUtils::compareVerbs($notice->verb, array(ActivityVerb::POST))) {
691 692 693 694 695
            if (isset($replies)) {
                $notice->saveKnownReplies($replies);
            } else {
                $notice->saveReplies();
            }
696

697 698 699 700 701
            if (isset($tags)) {
                $notice->saveKnownTags($tags);
            } else {
                $notice->saveTags();
            }
702

703 704 705
            // Note: groups may save tags, so must be run after tags are saved
            // to avoid errors on duplicates.
            // Note: groups should always be set.
706

707
            $notice->saveKnownGroups($groups);
708

709 710 711 712 713
            if (isset($urls)) {
                $notice->saveKnownUrls($urls);
            } else {
                $notice->saveUrls();
            }
714
        }
715

716 717 718 719
        if ($distribute) {
            // Prepare inbox delivery, may be queued to background.
            $notice->distribute();
        }
720

721 722
        return $notice;
    }
723

724 725
    static function saveActivity(Activity $act, Profile $actor, array $options=array())
    {
726 727 728 729 730 731 732 733 734 735 736 737 738 739 740
        // First check if we're going to let this Activity through from the specific actor
        if (!$actor->hasRight(Right::NEWNOTICE)) {
            common_log(LOG_WARNING, "Attempted post from user disallowed to post: " . $actor->getNickname());

            // TRANS: Client exception thrown when a user tries to post while being banned.
            throw new ClientException(_m('You are banned from posting notices on this site.'), 403);
        }
        if (common_config('throttle', 'enabled') && !self::checkEditThrottle($actor->id)) {
            common_log(LOG_WARNING, 'Excessive posting by profile #' . $actor->id . '; throttled.');
            // TRANS: Client exception thrown when a user tries to post too many notices in a given time frame.
            throw new ClientException(_m('Too many notices too fast; take a breather '.
                                        'and post again in a few minutes.'));
        }

        // Get ActivityObject properties
741
        if (!empty($act->id)) {
742 743 744
            // implied object
            $options['uri'] = $act->id;
            $options['url'] = $act->link;
745 746 747 748 749 750 751 752 753 754
        } else {
            $actobj = count($act->objects)==1 ? $act->objects[0] : null;
            if (!is_null($actobj) && !empty($actobj->id)) {
                $options['uri'] = $actobj->id;
                if ($actobj->link) {
                    $options['url'] = $actobj->link;
                } elseif (preg_match('!^https?://!', $actobj->id)) {
                    $options['url'] = $actobj->id;
                }
            }
755 756 757 758
        }

        $defaults = array(
                          'groups'   => array(),
759
                          'is_local' => $actor->isLocal() ? self::LOCAL_PUBLIC : self::REMOTE,
760 761 762 763 764 765 766 767 768 769 770 771
                          'mentions' => array(),
                          'reply_to' => null,
                          'repeat_of' => null,
                          'scope' => null,
                          'source' => 'unknown',
                          'tags' => array(),
                          'uri' => null,
                          'url' => null,
                          'urls' => array(),
                          'distribute' => true);

        // options will have default values when nothing has been supplied
772
        $options = array_merge($defaults, $options);
773 774 775 776 777 778
        foreach (array_keys($defaults) as $key) {
            // Only convert the keynames we specify ourselves from 'defaults' array into variables
            $$key = $options[$key];
        }
        extract($options, EXTR_SKIP);

779
        // dupe check
780
        $stored = new Notice();
781
        if (!empty($uri) && !ActivityUtils::compareVerbs($act->verb, array(ActivityVerb::DELETE))) {
782 783 784
            $stored->uri = $uri;
            if ($stored->find()) {
                common_debug('cannot create duplicate Notice URI: '.$stored->uri);
785 786 787
                // I _assume_ saving a Notice with a colliding URI means we're really trying to
                // save the same notice again...
                throw new AlreadyFulfilledException('Notice URI already exists');
788 789 790
            }
        }

mattl's avatar
mattl committed
791 792 793 794
        $autosource = common_config('public', 'autosource');

        // Sandboxed are non-false, but not 1, either
        if (!$actor->hasRight(Right::PUBLICNOTICE) ||
795 796
                ($source && $autosource && in_array($source, $autosource))) {
            // FIXME: ...what about remote nonpublic? Hmmm. That is, if we sandbox remote profiles...
mattl's avatar
mattl committed
797 798
            $stored->is_local = Notice::LOCAL_NONPUBLIC;
        } else {
mattl's avatar
mattl committed
799
            $stored->is_local = intval($is_local);
mattl's avatar
mattl committed
800 801 802 803 804 805 806 807 808 809 810 811
        }

        if (!$stored->isLocal()) {
            // Only do these checks for non-local notices. Local notices will generate these values later.
            if (!common_valid_http_url($url)) {
                common_debug('Bad notice URL: ['.$url.'], URI: ['.$uri.']. Cannot link back to original! This is normal for shared notices etc.');
            }
            if (empty($uri)) {
                throw new ServerException('No URI for remote notice. Cannot accept that.');
            }
        }

812 813 814 815 816 817
        $stored->profile_id = $actor->id;
        $stored->source = $source;
        $stored->uri = $uri;
        $stored->url = $url;
        $stored->verb = $act->verb;

818
        // Use the local user's shortening preferences, if applicable.
819
        $stored->rendered = $actor->isLocal()
820
                                ? $actor->shortenLinks($act->content)
mattl's avatar
mattl committed
821
                                : common_purify($act->content);
822
        $stored->content = common_strip_html($stored->rendered);
823

824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862
        // Maybe a missing act-time should be fatal if the actor is not local?
        if (!empty($act->time)) {
            $stored->created = common_sql_date($act->time);
        } else {
            $stored->created = common_sql_now();
        }

        $reply = null;
        if ($act->context instanceof ActivityContext && !empty($act->context->replyToID)) {
            $reply = self::getKV('uri', $act->context->replyToID);
        }
        if (!$reply instanceof Notice && $act->target instanceof ActivityObject) {
            $reply = self::getKV('uri', $act->target->id);
        }

        if ($reply instanceof Notice) {
            if (!$reply->inScope($actor)) {
                // TRANS: Client error displayed when trying to reply to a notice a the target has no access to.
                // TRANS: %1$s is a user nickname, %2$d is a notice ID (number).
                throw new ClientException(sprintf(_m('%1$s has no right to reply to notice %2$d.'), $actor->getNickname(), $reply->id), 403);
            }

            $stored->reply_to     = $reply->id;
            $stored->conversation = $reply->conversation;

            // If the original is private to a group, and notice has no group specified,
            // make it to the same group(s)
            if (empty($groups) && ($reply->scope & Notice::GROUP_SCOPE)) {
                $replyGroups = $reply->getGroups();
                foreach ($replyGroups as $group) {
                    if ($actor->isMember($group)) {
                        $groups[] = $group->id;
                    }
                }
            }

            if (is_null($scope)) {
                $scope = $reply->scope;
            }
863 864 865 866 867 868 869 870
        } else {
            // If we don't know the reply, we might know the conversation!
            // This will happen if a known remote user replies to an
            // unknown remote user - within a known conversation.
            if (empty($stored->conversation) and !empty($act->context->conversation)) {
                $conv = Conversation::getKV('uri', $act->context->conversation);
                if ($conv instanceof Conversation) {
                    common_debug('Conversation stitched together from (probably) a reply activity to unknown remote user. Activity creation time ('.$stored->created.') should maybe be compared to conversation creation time ('.$conv->created.').');
871
                    $stored->conversation = $conv->getID();
872 873 874 875 876 877 878
                } else {
                    // Conversation URI was not found, so we must create it. But we can't create it
                    // until we have a Notice ID because of the database layout...
                    // $options['conversation'] will be used later after the $stored->insert();
                    common_debug('Conversation URI from activity context not found, so we have to create it after inserting this Notice: '.$act->context->conversation);
                }
            }
879 880
        }

881
        $notloc = null;
882
        if ($act->context instanceof ActivityContext) {
883 884
            if ($act->context->location instanceof Location) {
                $notloc = Notice_location::fromLocation($act->context->location);
885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905
            }
        } else {
            $act->context = new ActivityContext();
        }

        $stored->scope = self::figureOutScope($actor, $groups, $scope);

        foreach ($act->categories as $cat) {
            if ($cat->term) {
                $term = common_canonical_tag($cat->term);
                if (!empty($term)) {
                    $tags[] = $term;
                }
            }
        }

        foreach ($act->enclosures as $href) {
            // @todo FIXME: Save these locally or....?
            $urls[] = $href;
        }

mattl's avatar
mattl committed
906
        if (ActivityUtils::compareVerbs($stored->verb, array(ActivityVerb::POST))) {
907
            if (empty($act->objects[0]->type)) {
mattl's avatar
mattl committed
908 909 910
                // Default type for the post verb is 'note', but we know it's
                // a 'comment' if it is in reply to something.
                $stored->object_type = empty($stored->reply_to) ? ActivityObject::NOTE : ActivityObject::COMMENT;
911 912 913 914 915
            } else {
                //TODO: Is it safe to always return a relative URI? The
                // JSON version of ActivityStreams always use it, so we
                // should definitely be able to handle it...
                $stored->object_type = ActivityUtils::resolveUri($act->objects[0]->type, true);
mattl's avatar
mattl committed
916 917 918
            }
        }

919 920 921 922
        if (Event::handle('StartNoticeSave', array(&$stored))) {
            // XXX: some of these functions write to the DB

            try {
923
                $result = $stored->insert();    // throws exception on error
924 925 926 927 928 929

                if ($notloc instanceof Notice_location) {
                    $notloc->notice_id = $stored->getID();
                    $notloc->insert();
                }

930 931
                $orig = clone($stored); // for updating later in this try clause

932 933 934
                $object = null;
                Event::handle('StoreActivityObject', array($act, $stored, $options, &$object));
                if (empty($object)) {
935
                    throw new ServerException('Unsuccessful call to StoreActivityObject '.$stored->getUri() . ': '.$act->asString());
936 937
                }

938 939
                // If it's not part of a conversation, it's the beginning
                // of a new one (or belongs to a previously unknown URI).
940 941
                if (empty($stored->conversation)) {
                    // $act->context->conversation will be null if it was not provided
942
                    common_debug('Creating a new conversation for stored notice ID='.$stored->getID().' with URI: '.$act->context->conversation);
943
                    $conv = Conversation::create($stored, $act->context->conversation);
944
                    $stored->conversation = $conv->getID();
945
                }
946 947 948 949 950 951 952 953 954 955 956 957

                $stored->update($orig);
            } catch (Exception $e) {
                if (empty($stored->id)) {
                    common_debug('Failed to save stored object entry in database ('.$e->getMessage().')');
                } else {
                    common_debug('Failed to store activity object in database ('.$e->getMessage().'), deleting notice id '.$stored->id);
                    $stored->delete();
                }
                throw $e;
            }
        }
958 959 960
        if (!$stored instanceof Notice) {
            throw new ServerException('StartNoticeSave did not give back a Notice');
        }
961 962 963

        // Save per-notice metadata...
        $mentions = array();
964
        $group_ids   = array();
965 966 967

        // This event lets plugins filter out non-local recipients (attentions we don't care about)
        // Used primarily for OStatus (and if we don't federate, all attentions would be local anyway)
968
        Event::handle('GetLocalAttentions', array($actor, $act->context->attention, &$mentions, &$group_ids));
969

970 971 972 973 974 975 976 977 978
        // Only save 'attention' and metadata stuff (URLs, tags...) stuff if
        // the activityverb is a POST (since stuff like repeat, favorite etc.
        // reasonably handle notifications themselves.
        if (ActivityUtils::compareVerbs($stored->verb, array(ActivityVerb::POST))) {
            if (!empty($mentions)) {
                $stored->saveKnownReplies($mentions);
            } else {
                $stored->saveReplies();
            }
979

980 981 982 983 984
            if (!empty($tags)) {
                $stored->saveKnownTags($tags);
            } else {
                $stored->saveTags();
            }