]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - classes/Notice.php
b8cd2bd7f29710f47ac2da0efe075b4ab2a54942
[quix0rs-gnu-social.git] / classes / Notice.php
1 <?php
2 /*
3  * Laconica - a distributed open-source microblogging tool
4  * Copyright (C) 2008, Controlez-Vous, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.     See the
14  * GNU Affero General Public License for more details.
15  *
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/>.
18  */
19
20 if (!defined('LACONICA')) { exit(1); }
21
22 /**
23  * Table Definition for notice
24  */
25 require_once INSTALLDIR.'/classes/Memcached_DataObject.php';
26
27 /* We keep the first three 20-notice pages, plus one for pagination check,
28  * in the memcached cache. */
29
30 define('NOTICE_CACHE_WINDOW', 61);
31
32 class Notice extends Memcached_DataObject
33 {
34     ###START_AUTOCODE
35     /* the code below is auto generated do not remove the above tag */
36
37     public $__table = 'notice';                             // table name
38     public $id;                                 // int(4)    primary_key not_null
39     public $profile_id;                         // int(4)     not_null
40     public $uri;                             // varchar(255)  unique_key
41     public $content;                         // varchar(140)
42     public $rendered;                         // text()
43     public $url;                             // varchar(255)
44     public $created;                         // datetime()     not_null
45     public $modified;                         // timestamp()      not_null default_CURRENT_TIMESTAMP
46     public $reply_to;                         // int(4)
47     public $is_local;                         // tinyint(1)
48     public $source;                             // varchar(32)
49
50     /* Static get */
51     function staticGet($k,$v=null)
52     { return Memcached_DataObject::staticGet('Notice',$k,$v); }
53
54     /* the code above is auto generated do not remove the tag below */
55     ###END_AUTOCODE
56
57     function getProfile()
58     {
59         return Profile::staticGet('id', $this->profile_id);
60     }
61
62     function delete()
63     {
64         $this->blowCaches(true);
65         $this->blowFavesCache(true);
66         $this->blowSubsCache(true);
67
68         $this->query('BEGIN');
69         $related = array('Reply',
70                          'Fave',
71                          'Notice_tag',
72                          'Group_inbox',
73                          'Queue_item');
74         if (common_config('inboxes', 'enabled')) {
75             $related[] = 'Notice_inbox';
76         }
77         foreach ($related as $cls) {
78             $inst = new $cls();
79             $inst->notice_id = $this->id;
80             $inst->delete();
81         }
82         $result = parent::delete();
83         $this->query('COMMIT');
84     }
85
86     function saveTags()
87     {
88         /* extract all #hastags */
89         $count = preg_match_all('/(?:^|\s)#([A-Za-z0-9_\-\.]{1,64})/', strtolower($this->content), $match);
90         if (!$count) {
91             return true;
92         }
93
94         /* Add them to the database */
95         foreach(array_unique($match[1]) as $hashtag) {
96             /* elide characters we don't want in the tag */
97             $hashtag = common_canonical_tag($hashtag);
98
99             $tag = DB_DataObject::factory('Notice_tag');
100             $tag->notice_id = $this->id;
101             $tag->tag = $hashtag;
102             $tag->created = $this->created;
103             $id = $tag->insert();
104             if (!$id) {
105                 $last_error = PEAR::getStaticProperty('DB_DataObject','lastError');
106                 common_log(LOG_ERR, 'DB error inserting hashtag: ' . $last_error->message);
107                 common_server_error(sprintf(_('DB error inserting hashtag: %s'), $last_error->message));
108                 return;
109             }
110         }
111         return true;
112     }
113
114     static function saveNew($profile_id, $content, $source=null, $is_local=1, $reply_to=null, $uri=null) {
115
116         $profile = Profile::staticGet($profile_id);
117
118         if (!$profile) {
119             common_log(LOG_ERR, 'Problem saving notice. Unknown user.');
120             return _('Problem saving notice. Unknown user.');
121         }
122
123         if (common_config('throttle', 'enabled') && !Notice::checkEditThrottle($profile_id)) {
124             common_log(LOG_WARNING, 'Excessive posting by profile #' . $profile_id . '; throttled.');
125             return _('Too many notices too fast; take a breather and post again in a few minutes.');
126         }
127
128         $banned = common_config('profile', 'banned');
129
130         if ( in_array($profile_id, $banned) || in_array($profile->nickname, $banned)) {
131             common_log(LOG_WARNING, "Attempted post from banned user: $profile->nickname (user id = $profile_id).");
132             return _('You are banned from posting notices on this site.');
133         }
134
135         $notice = new Notice();
136         $notice->profile_id = $profile_id;
137
138         $blacklist = common_config('public', 'blacklist');
139         $autosource = common_config('public', 'autosource');
140
141         # Blacklisted are non-false, but not 1, either
142
143         if (($blacklist && in_array($profile_id, $blacklist)) ||
144             ($source && $autosource && in_array($source, $autosource))) {
145             $notice->is_local = -1;
146         } else {
147             $notice->is_local = $is_local;
148         }
149
150                 $notice->query('BEGIN');
151
152         $notice->reply_to = $reply_to;
153         $notice->created = common_sql_now();
154         $notice->content = common_shorten_links($content);
155         $notice->rendered = common_render_content($notice->content, $notice);
156         $notice->source = $source;
157         $notice->uri = $uri;
158
159         if (Event::handle('StartNoticeSave', array(&$notice))) {
160
161             $id = $notice->insert();
162
163             if (!$id) {
164                 common_log_db_error($notice, 'INSERT', __FILE__);
165                 return _('Problem saving notice.');
166             }
167
168             # Update the URI after the notice is in the database
169             if (!$uri) {
170                 $orig = clone($notice);
171                 $notice->uri = common_notice_uri($notice);
172
173                 if (!$notice->update($orig)) {
174                     common_log_db_error($notice, 'UPDATE', __FILE__);
175                     return _('Problem saving notice.');
176                 }
177             }
178
179             # XXX: do we need to change this for remote users?
180
181             $notice->saveReplies();
182             $notice->saveTags();
183             $notice->saveGroups();
184
185             $notice->addToInboxes();
186             $notice->query('COMMIT');
187
188             Event::handle('EndNoticeSave', array($notice));
189         }
190
191         # Clear the cache for subscribed users, so they'll update at next request
192         # XXX: someone clever could prepend instead of clearing the cache
193
194         if (common_config('memcached', 'enabled')) {
195             $notice->blowCaches();
196         }
197
198         return $notice;
199     }
200
201     static function checkEditThrottle($profile_id) {
202         $profile = Profile::staticGet($profile_id);
203         if (!$profile) {
204             return false;
205         }
206         # Get the Nth notice
207         $notice = $profile->getNotices(common_config('throttle', 'count') - 1, 1);
208         if ($notice && $notice->fetch()) {
209             # If the Nth notice was posted less than timespan seconds ago
210             if (time() - strtotime($notice->created) <= common_config('throttle', 'timespan')) {
211                 # Then we throttle
212                 return false;
213             }
214         }
215         # Either not N notices in the stream, OR the Nth was not posted within timespan seconds
216         return true;
217     }
218
219     function blowCaches($blowLast=false)
220     {
221         $this->blowSubsCache($blowLast);
222         $this->blowNoticeCache($blowLast);
223         $this->blowRepliesCache($blowLast);
224         $this->blowPublicCache($blowLast);
225         $this->blowTagCache($blowLast);
226         $this->blowGroupCache($blowLast);
227     }
228
229     function blowGroupCache($blowLast=false)
230     {
231         $cache = common_memcache();
232         if ($cache) {
233             $group_inbox = new Group_inbox();
234             $group_inbox->notice_id = $this->id;
235             if ($group_inbox->find()) {
236                 while ($group_inbox->fetch()) {
237                     $cache->delete(common_cache_key('group:notices:'.$group_inbox->group_id));
238                     if ($blowLast) {
239                         $cache->delete(common_cache_key('group:notices:'.$group_inbox->group_id.';last'));
240                     }
241                     $member = new Group_member();
242                     $member->group_id = $group_inbox->group_id;
243                     if ($member->find()) {
244                         while ($member->fetch()) {
245                             $cache->delete(common_cache_key('user:notices_with_friends:' . $member->profile_id));
246                             if ($blowLast) {
247                                 $cache->delete(common_cache_key('user:notices_with_friends:' . $member->profile_id . ';last'));
248                             }
249                         }
250                     }
251                 }
252             }
253             $group_inbox->free();
254             unset($group_inbox);
255         }
256     }
257
258     function blowTagCache($blowLast=false)
259     {
260         $cache = common_memcache();
261         if ($cache) {
262             $tag = new Notice_tag();
263             $tag->notice_id = $this->id;
264             if ($tag->find()) {
265                 while ($tag->fetch()) {
266                     $cache->delete(common_cache_key('notice_tag:notice_stream:' . $tag->tag));
267                     if ($blowLast) {
268                         $cache->delete(common_cache_key('notice_tag:notice_stream:' . $tag->tag . ';last'));
269                     }
270                 }
271             }
272             $tag->free();
273             unset($tag);
274         }
275     }
276
277     function blowSubsCache($blowLast=false)
278     {
279         $cache = common_memcache();
280         if ($cache) {
281             $user = new User();
282
283             $UT = common_config('db','type')=='pgsql'?'"user"':'user';
284             $user->query('SELECT id ' .
285
286                          "FROM $UT JOIN subscription ON $UT.id = subscription.subscriber " .
287                          'WHERE subscription.subscribed = ' . $this->profile_id);
288
289             while ($user->fetch()) {
290                 $cache->delete(common_cache_key('user:notices_with_friends:' . $user->id));
291                 if ($blowLast) {
292                     $cache->delete(common_cache_key('user:notices_with_friends:' . $user->id . ';last'));
293                 }
294             }
295             $user->free();
296             unset($user);
297         }
298     }
299
300     function blowNoticeCache($blowLast=false)
301     {
302         if ($this->is_local) {
303             $cache = common_memcache();
304             if ($cache) {
305                 $cache->delete(common_cache_key('profile:notices:'.$this->profile_id));
306                 if ($blowLast) {
307                     $cache->delete(common_cache_key('profile:notices:'.$this->profile_id.';last'));
308                 }
309             }
310         }
311     }
312
313     function blowRepliesCache($blowLast=false)
314     {
315         $cache = common_memcache();
316         if ($cache) {
317             $reply = new Reply();
318             $reply->notice_id = $this->id;
319             if ($reply->find()) {
320                 while ($reply->fetch()) {
321                     $cache->delete(common_cache_key('user:replies:'.$reply->profile_id));
322                     if ($blowLast) {
323                         $cache->delete(common_cache_key('user:replies:'.$reply->profile_id.';last'));
324                     }
325                 }
326             }
327             $reply->free();
328             unset($reply);
329         }
330     }
331
332     function blowPublicCache($blowLast=false)
333     {
334         if ($this->is_local == 1) {
335             $cache = common_memcache();
336             if ($cache) {
337                 $cache->delete(common_cache_key('public'));
338                 if ($blowLast) {
339                     $cache->delete(common_cache_key('public').';last');
340                 }
341             }
342         }
343     }
344
345     function blowFavesCache($blowLast=false)
346     {
347         $cache = common_memcache();
348         if ($cache) {
349             $fave = new Fave();
350             $fave->notice_id = $this->id;
351             if ($fave->find()) {
352                 while ($fave->fetch()) {
353                     $cache->delete(common_cache_key('user:faves:'.$fave->user_id));
354                     if ($blowLast) {
355                         $cache->delete(common_cache_key('user:faves:'.$fave->user_id.';last'));
356                     }
357                 }
358             }
359             $fave->free();
360             unset($fave);
361         }
362     }
363
364     # XXX: too many args; we need to move to named params or even a separate
365     # class for notice streams
366
367     static function getStream($qry, $cachekey, $offset=0, $limit=20, $since_id=0, $before_id=0, $order=null, $since=null) {
368
369         if (common_config('memcached', 'enabled')) {
370
371             # Skip the cache if this is a since, since_id or before_id qry
372             if ($since_id > 0 || $before_id > 0 || $since) {
373                 return Notice::getStreamDirect($qry, $offset, $limit, $since_id, $before_id, $order, $since);
374             } else {
375                 return Notice::getCachedStream($qry, $cachekey, $offset, $limit, $order);
376             }
377         }
378
379         return Notice::getStreamDirect($qry, $offset, $limit, $since_id, $before_id, $order, $since);
380     }
381
382     static function getStreamDirect($qry, $offset, $limit, $since_id, $before_id, $order, $since) {
383
384         $needAnd = false;
385         $needWhere = true;
386
387         if (preg_match('/\bWHERE\b/i', $qry)) {
388             $needWhere = false;
389             $needAnd = true;
390         }
391
392         if ($since_id > 0) {
393
394             if ($needWhere) {
395                 $qry .= ' WHERE ';
396                 $needWhere = false;
397             } else {
398                 $qry .= ' AND ';
399             }
400
401             $qry .= ' notice.id > ' . $since_id;
402         }
403
404         if ($before_id > 0) {
405
406             if ($needWhere) {
407                 $qry .= ' WHERE ';
408                 $needWhere = false;
409             } else {
410                 $qry .= ' AND ';
411             }
412
413             $qry .= ' notice.id < ' . $before_id;
414         }
415
416         if ($since) {
417
418             if ($needWhere) {
419                 $qry .= ' WHERE ';
420                 $needWhere = false;
421             } else {
422                 $qry .= ' AND ';
423             }
424
425             $qry .= ' notice.created > \'' . date('Y-m-d H:i:s', $since) . '\'';
426         }
427
428         # Allow ORDER override
429
430         if ($order) {
431             $qry .= $order;
432         } else {
433             $qry .= ' ORDER BY notice.created DESC, notice.id DESC ';
434         }
435
436         if (common_config('db','type') == 'pgsql') {
437             $qry .= ' LIMIT ' . $limit . ' OFFSET ' . $offset;
438         } else {
439             $qry .= ' LIMIT ' . $offset . ', ' . $limit;
440         }
441
442         $notice = new Notice();
443
444         $notice->query($qry);
445
446         return $notice;
447     }
448
449     # XXX: this is pretty long and should probably be broken up into
450     # some helper functions
451
452     static function getCachedStream($qry, $cachekey, $offset, $limit, $order) {
453
454         # If outside our cache window, just go to the DB
455
456         if ($offset + $limit > NOTICE_CACHE_WINDOW) {
457             return Notice::getStreamDirect($qry, $offset, $limit, null, null, $order, null);
458         }
459
460         # Get the cache; if we can't, just go to the DB
461
462         $cache = common_memcache();
463
464         if (!$cache) {
465             return Notice::getStreamDirect($qry, $offset, $limit, null, null, $order, null);
466         }
467
468         # Get the notices out of the cache
469
470         $notices = $cache->get(common_cache_key($cachekey));
471
472         # On a cache hit, return a DB-object-like wrapper
473
474         if ($notices !== false) {
475             $wrapper = new ArrayWrapper(array_slice($notices, $offset, $limit));
476             return $wrapper;
477         }
478
479         # If the cache was invalidated because of new data being
480         # added, we can try and just get the new stuff. We keep an additional
481         # copy of the data at the key + ';last'
482
483         # No cache hit. Try to get the *last* cached version
484
485         $last_notices = $cache->get(common_cache_key($cachekey) . ';last');
486
487         if ($last_notices) {
488
489             # Reverse-chron order, so last ID is last.
490
491             $last_id = $last_notices[0]->id;
492
493             # XXX: this assumes monotonically increasing IDs; a fair
494             # bet with our DB.
495
496             $new_notice = Notice::getStreamDirect($qry, 0, NOTICE_CACHE_WINDOW,
497                                                   $last_id, null, $order, null);
498
499             if ($new_notice) {
500                 $new_notices = array();
501                 while ($new_notice->fetch()) {
502                     $new_notices[] = clone($new_notice);
503                 }
504                 $new_notice->free();
505                 $notices = array_slice(array_merge($new_notices, $last_notices),
506                                        0, NOTICE_CACHE_WINDOW);
507
508                 # Store the array in the cache for next time
509
510                 $result = $cache->set(common_cache_key($cachekey), $notices);
511                 $result = $cache->set(common_cache_key($cachekey) . ';last', $notices);
512
513                 # return a wrapper of the array for use now
514
515                 return new ArrayWrapper(array_slice($notices, $offset, $limit));
516             }
517         }
518
519         # Otherwise, get the full cache window out of the DB
520
521         $notice = Notice::getStreamDirect($qry, 0, NOTICE_CACHE_WINDOW, null, null, $order, null);
522
523         # If there are no hits, just return the value
524
525         if (!$notice) {
526             return $notice;
527         }
528
529         # Pack results into an array
530
531         $notices = array();
532
533         while ($notice->fetch()) {
534             $notices[] = clone($notice);
535         }
536
537         $notice->free();
538
539         # Store the array in the cache for next time
540
541         $result = $cache->set(common_cache_key($cachekey), $notices);
542         $result = $cache->set(common_cache_key($cachekey) . ';last', $notices);
543
544         # return a wrapper of the array for use now
545
546         $wrapper = new ArrayWrapper(array_slice($notices, $offset, $limit));
547
548         return $wrapper;
549     }
550
551     function publicStream($offset=0, $limit=20, $since_id=0, $before_id=0, $since=null)
552     {
553
554         $parts = array();
555
556         $qry = 'SELECT * FROM notice ';
557
558         if (common_config('public', 'localonly')) {
559             $parts[] = 'is_local = 1';
560         } else {
561             # -1 == blacklisted
562             $parts[] = 'is_local != -1';
563         }
564
565         if ($parts) {
566             $qry .= ' WHERE ' . implode(' AND ', $parts);
567         }
568
569         return Notice::getStream($qry,
570                                  'public',
571                                  $offset, $limit, $since_id, $before_id, null, $since);
572     }
573
574     function addToInboxes()
575     {
576         $enabled = common_config('inboxes', 'enabled');
577
578         if ($enabled === true || $enabled === 'transitional') {
579             $inbox = new Notice_inbox();
580             $UT = common_config('db','type')=='pgsql'?'"user"':'user';
581             $qry = 'INSERT INTO notice_inbox (user_id, notice_id, created) ' .
582               "SELECT $UT.id, " . $this->id . ', "' . $this->created . '" ' .
583               "FROM $UT JOIN subscription ON $UT.id = subscription.subscriber " .
584               'WHERE subscription.subscribed = ' . $this->profile_id . ' ' .
585               'AND NOT EXISTS (SELECT user_id, notice_id ' .
586               'FROM notice_inbox ' .
587               "WHERE user_id = $UT.id " .
588               'AND notice_id = ' . $this->id . ' )';
589             if ($enabled === 'transitional') {
590                 $qry .= " AND $UT.inboxed = 1";
591             }
592             $inbox->query($qry);
593         }
594         return;
595     }
596
597     function saveGroups()
598     {
599         $enabled = common_config('inboxes', 'enabled');
600         if ($enabled !== true && $enabled !== 'transitional') {
601             return;
602         }
603
604         /* extract all !group */
605         $count = preg_match_all('/(?:^|\s)!([A-Za-z0-9]{1,64})/',
606                                 strtolower($this->content),
607                                 $match);
608         if (!$count) {
609             return true;
610         }
611
612         $profile = $this->getProfile();
613
614         /* Add them to the database */
615
616         foreach (array_unique($match[1]) as $nickname) {
617             /* XXX: remote groups. */
618             $group = User_group::staticGet('nickname', $nickname);
619
620             if (!$group) {
621                 continue;
622             }
623
624             if ($profile->isMember($group)) {
625
626                 $gi = new Group_inbox();
627
628                 $gi->group_id  = $group->id;
629                 $gi->notice_id = $this->id;
630                 $gi->created   = common_sql_now();
631
632                 $result = $gi->insert();
633
634                 if (!$result) {
635                     common_log_db_error($gi, 'INSERT', __FILE__);
636                 }
637
638                 // FIXME: do this in an offline daemon
639
640                 $inbox = new Notice_inbox();
641                 $UT = common_config('db','type')=='pgsql'?'"user"':'user';
642                 $qry = 'INSERT INTO notice_inbox (user_id, notice_id, created, source) ' .
643                   "SELECT $UT.id, " . $this->id . ', "' . $this->created . '", 2 ' .
644                   "FROM $UT JOIN group_member ON $UT.id = group_member.profile_id " .
645                   'WHERE group_member.group_id = ' . $group->id . ' ' .
646                   'AND NOT EXISTS (SELECT user_id, notice_id ' .
647                   'FROM notice_inbox ' .
648                   "WHERE user_id = $UT.id " .
649                   'AND notice_id = ' . $this->id . ' )';
650                 if ($enabled === 'transitional') {
651                     $qry .= " AND $UT.inboxed = 1";
652                 }
653                 $result = $inbox->query($qry);
654             }
655         }
656     }
657
658     function saveReplies()
659     {
660         // Alternative reply format
661         $tname = false;
662         if (preg_match('/^T ([A-Z0-9]{1,64}) /', $this->content, $match)) {
663             $tname = $match[1];
664         }
665         // extract all @messages
666         $cnt = preg_match_all('/(?:^|\s)@([a-z0-9]{1,64})/', $this->content, $match);
667
668         $names = array();
669
670         if ($cnt || $tname) {
671             // XXX: is there another way to make an array copy?
672             $names = ($tname) ? array_unique(array_merge(array(strtolower($tname)), $match[1])) : array_unique($match[1]);
673         }
674
675         $sender = Profile::staticGet($this->profile_id);
676
677         $replied = array();
678
679         // store replied only for first @ (what user/notice what the reply directed,
680         // we assume first @ is it)
681
682         for ($i=0; $i<count($names); $i++) {
683             $nickname = $names[$i];
684             $recipient = common_relative_profile($sender, $nickname, $this->created);
685             if (!$recipient) {
686                 continue;
687             }
688             if ($i == 0 && ($recipient->id != $sender->id) && !$this->reply_to) { // Don't save reply to self
689                 $reply_for = $recipient;
690                 $recipient_notice = $reply_for->getCurrentNotice();
691                 if ($recipient_notice) {
692                     $orig = clone($this);
693                     $this->reply_to = $recipient_notice->id;
694                     $this->update($orig);
695                 }
696             }
697             // Don't save replies from blocked profile to local user
698             $recipient_user = User::staticGet('id', $recipient->id);
699             if ($recipient_user && $recipient_user->hasBlocked($sender)) {
700                 continue;
701             }
702             $reply = new Reply();
703             $reply->notice_id = $this->id;
704             $reply->profile_id = $recipient->id;
705             $id = $reply->insert();
706             if (!$id) {
707                 $last_error = &PEAR::getStaticProperty('DB_DataObject','lastError');
708                 common_log(LOG_ERR, 'DB error inserting reply: ' . $last_error->message);
709                 common_server_error(sprintf(_('DB error inserting reply: %s'), $last_error->message));
710                 return;
711             } else {
712                 $replied[$recipient->id] = 1;
713             }
714         }
715
716         // Hash format replies, too
717         $cnt = preg_match_all('/(?:^|\s)@#([a-z0-9]{1,64})/', $this->content, $match);
718         if ($cnt) {
719             foreach ($match[1] as $tag) {
720                 $tagged = Profile_tag::getTagged($sender->id, $tag);
721                 foreach ($tagged as $t) {
722                     if (!$replied[$t->id]) {
723                         // Don't save replies from blocked profile to local user
724                         $t_user = User::staticGet('id', $t->id);
725                         if ($t_user && $t_user->hasBlocked($sender)) {
726                             continue;
727                         }
728                         $reply = new Reply();
729                         $reply->notice_id = $this->id;
730                         $reply->profile_id = $t->id;
731                         $id = $reply->insert();
732                         if (!$id) {
733                             common_log_db_error($reply, 'INSERT', __FILE__);
734                             return;
735                         }
736                     }
737                 }
738             }
739         }
740     }
741 }