]> git.mxchange.org Git - friendica.git/blob - src/Model/Item.php
Merge pull request #9829 from Extarys/9827-broken-navbar
[friendica.git] / src / Model / Item.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2020, Friendica
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  */
21
22 namespace Friendica\Model;
23
24 use Friendica\Content\Text\BBCode;
25 use Friendica\Content\Text\HTML;
26 use Friendica\Core\Hook;
27 use Friendica\Core\Logger;
28 use Friendica\Core\Protocol;
29 use Friendica\Core\Renderer;
30 use Friendica\Core\Session;
31 use Friendica\Core\System;
32 use Friendica\Model\Tag;
33 use Friendica\Core\Worker;
34 use Friendica\Database\Database;
35 use Friendica\Database\DBA;
36 use Friendica\Database\DBStructure;
37 use Friendica\DI;
38 use Friendica\Model\Post;
39 use Friendica\Protocol\Activity;
40 use Friendica\Protocol\ActivityPub;
41 use Friendica\Protocol\Diaspora;
42 use Friendica\Util\DateTimeFormat;
43 use Friendica\Util\Map;
44 use Friendica\Util\Network;
45 use Friendica\Util\Strings;
46 use Friendica\Worker\Delivery;
47 use LanguageDetection\Language;
48
49 class Item
50 {
51         // Posting types, inspired by https://www.w3.org/TR/activitystreams-vocabulary/#object-types
52         const PT_ARTICLE = 0;
53         const PT_NOTE = 1;
54         const PT_PAGE = 2;
55         const PT_IMAGE = 16;
56         const PT_AUDIO = 17;
57         const PT_VIDEO = 18;
58         const PT_DOCUMENT = 19;
59         const PT_EVENT = 32;
60         const PT_TAG = 64;
61         const PT_TO = 65;
62         const PT_CC = 66;
63         const PT_BTO = 67;
64         const PT_BCC = 68;
65         const PT_FOLLOWER = 69;
66         const PT_ANNOUNCEMENT = 70;
67         const PT_COMMENT = 71;
68         const PT_STORED = 72;
69         const PT_GLOBAL = 73;
70         const PT_RELAY = 74;
71         const PT_FETCHED = 75;
72         const PT_PERSONAL_NOTE = 128;
73
74         // Field list that is used to display the items
75         const DISPLAY_FIELDLIST = [
76                 'uid', 'id', 'parent', 'uri-id', 'uri', 'thr-parent', 'parent-uri', 'guid', 'network', 'gravity',
77                 'commented', 'created', 'edited', 'received', 'verb', 'object-type', 'postopts', 'plink',
78                 'wall', 'private', 'starred', 'origin', 'title', 'body', 'file', 'language',
79                 'content-warning', 'location', 'coord', 'app', 'rendered-hash', 'rendered-html', 'object',
80                 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'item_id',
81                 'author-id', 'author-link', 'author-name', 'author-avatar', 'author-network',
82                 'owner-id', 'owner-link', 'owner-name', 'owner-avatar', 'owner-network',
83                 'causer-id', 'causer-link', 'causer-name', 'causer-avatar', 'causer-contact-type',
84                 'contact-id', 'contact-uid', 'contact-link', 'contact-name', 'contact-avatar',
85                 'writable', 'self', 'cid', 'alias',
86                 'event-id', 'event-created', 'event-edited', 'event-start', 'event-finish',
87                 'event-summary', 'event-desc', 'event-location', 'event-type',
88                 'event-nofinish', 'event-adjust', 'event-ignore', 'event-id',
89                 'delivery_queue_count', 'delivery_queue_done', 'delivery_queue_failed'
90         ];
91
92         // Field list that is used to deliver items via the protocols
93         const DELIVER_FIELDLIST = ['uid', 'id', 'parent', 'uri-id', 'uri', 'thr-parent', 'parent-uri', 'guid',
94                         'parent-guid', 'created', 'edited', 'verb', 'object-type', 'object', 'target',
95                         'private', 'title', 'body', 'location', 'coord', 'app',
96                         'deleted', 'extid', 'post-type', 'gravity',
97                         'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid',
98                         'author-id', 'author-link', 'owner-link', 'contact-uid',
99                         'signed_text', 'network'];
100
101         // Field list for "item-content" table that is mixed with the item table
102         const MIXED_CONTENT_FIELDLIST = ['title', 'content-warning', 'body', 'location',
103                         'coord', 'app', 'rendered-hash', 'rendered-html', 'verb',
104                         'object-type', 'object', 'target-type', 'target', 'plink'];
105
106         // Field list for "item-content" table that is not present in the "item" table
107         const CONTENT_FIELDLIST = ['language', 'raw-body'];
108
109         // All fields in the item table
110         const ITEM_FIELDLIST = ['id', 'uid', 'parent', 'uri', 'parent-uri', 'thr-parent',
111                         'guid', 'uri-id', 'parent-uri-id', 'thr-parent-id', 'vid',
112                         'contact-id', 'type', 'wall', 'gravity', 'extid', 'psid',
113                         'created', 'edited', 'commented', 'received', 'changed', 'verb',
114                         'postopts', 'plink', 'resource-id', 'event-id', 'inform',
115                         'file', 'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'post-type',
116                         'private', 'pubmail', 'moderated', 'visible', 'starred', 'bookmark',
117                         'unseen', 'deleted', 'origin', 'forum_mode', 'mention', 'global', 'network',
118                         'title', 'content-warning', 'body', 'location', 'coord', 'app',
119                         'rendered-hash', 'rendered-html', 'object-type', 'object', 'target-type', 'target',
120                         'author-id', 'author-link', 'author-name', 'author-avatar', 'author-network',
121                         'owner-id', 'owner-link', 'owner-name', 'owner-avatar', 'causer-id'];
122
123         // List of all verbs that don't need additional content data.
124         // Never reorder or remove entries from this list. Just add new ones at the end, if needed.
125         const ACTIVITIES = [
126                 Activity::LIKE, Activity::DISLIKE,
127                 Activity::ATTEND, Activity::ATTENDNO, Activity::ATTENDMAYBE,
128                 Activity::FOLLOW,
129                 Activity::ANNOUNCE];
130
131         const PUBLIC = 0;
132         const PRIVATE = 1;
133         const UNLISTED = 2;
134
135         const TABLES = ['item', 'user-item', 'item-content', 'post-delivery-data', 'diaspora-interaction'];
136
137         private static function getItemFields()
138         {
139                 $definition = DBStructure::definition('', false);
140
141                 $postfields = [];
142                 foreach (self::TABLES as $table) {
143                         $postfields[$table] = array_keys($definition[$table]['fields']);
144                 }
145
146                 return $postfields;
147         }
148
149         /**
150          * Set the pinned state of an item
151          *
152          * @param integer $iid    Item ID
153          * @param integer $uid    User ID
154          * @param boolean $pinned Pinned state
155          */
156         public static function setPinned(int $iid, int $uid, bool $pinned)
157         {
158                 DBA::update('user-item', ['pinned' => $pinned], ['iid' => $iid, 'uid' => $uid], true);
159         }
160
161         /**
162          * Get the pinned state
163          *
164          * @param integer $iid Item ID
165          * @param integer $uid User ID
166          *
167          * @return boolean pinned state
168          */
169         public static function getPinned(int $iid, int $uid)
170         {
171                 $useritem = DBA::selectFirst('user-item', ['pinned'], ['iid' => $iid, 'uid' => $uid]);
172                 if (!DBA::isResult($useritem)) {
173                         return false;
174                 }
175                 return (bool)$useritem['pinned'];
176         }
177
178         /**
179          * Update existing item entries
180          *
181          * @param array $fields    The fields that are to be changed
182          * @param array $condition The condition for finding the item entries
183          *
184          * In the future we may have to change permissions as well.
185          * Then we had to add the user id as third parameter.
186          *
187          * A return value of "0" doesn't mean an error - but that 0 rows had been changed.
188          *
189          * @return integer|boolean number of affected rows - or "false" if there was an error
190          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
191          */
192         public static function update(array $fields, array $condition)
193         {
194                 if (empty($condition) || empty($fields)) {
195                         return false;
196                 }
197
198                 $data_fields = $fields;
199
200                 // To ensure the data integrity we do it in an transaction
201                 DBA::transaction();
202
203                 // We cannot simply expand the condition to check for origin entries
204                 // The condition needn't to be a simple array but could be a complex condition.
205                 // And we have to execute this query before the update to ensure to fetch the same data.
206                 $items = DBA::select('item', ['id', 'origin', 'uri', 'uri-id', 'uid', 'file'], $condition);
207
208                 $content_fields = [];
209                 foreach (array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST) as $field) {
210                         if (isset($fields[$field])) {
211                                 $content_fields[$field] = $fields[$field];
212                                 if (in_array($field, self::CONTENT_FIELDLIST)) {
213                                         unset($fields[$field]);
214                                 } else {
215                                         $fields[$field] = null;
216                                 }
217                         }
218                 }
219
220                 $delivery_data = Post\DeliveryData::extractFields($fields);
221
222                 $clear_fields = ['bookmark', 'type', 'author-name', 'author-avatar', 'author-link', 'owner-name', 'owner-avatar', 'owner-link', 'postopts', 'inform'];
223                 foreach ($clear_fields as $field) {
224                         if (array_key_exists($field, $fields)) {
225                                 $fields[$field] = null;
226                         }
227                 }
228
229                 if (array_key_exists('file', $fields)) {
230                         $files = $fields['file'];
231                         $fields['file'] = null;
232                 } else {
233                         $files = null;
234                 }
235
236                 if (!empty($content_fields['verb'])) {
237                         $fields['vid'] = Verb::getID($content_fields['verb']);
238                 }
239
240                 if (!empty($fields)) {
241                         $success = DBA::update('item', $fields, $condition);
242
243                         if (!$success) {
244                                 DBA::close($items);
245                                 DBA::rollback();
246                                 return false;
247                         }
248                 }
249
250                 // When there is no content for the "old" item table, this will count the fetched items
251                 $rows = DBA::affectedRows();
252
253                 $notify_items = [];
254
255                 while ($item = DBA::fetch($items)) {
256                         Post\User::update($item['uri-id'], $item['uid'], $data_fields);
257
258                         if (empty($content_fields['verb']) || !in_array($content_fields['verb'], self::ACTIVITIES)) {
259                                 if (!empty($content_fields['body'])) {
260                                         $content_fields['raw-body'] = trim($content_fields['raw-body'] ?? $content_fields['body']);
261                 
262                                         // Remove all media attachments from the body and store them in the post-media table
263                                         $content_fields['raw-body'] = Post\Media::insertFromBody($item['uri-id'], $content_fields['raw-body']);
264                                         $content_fields['raw-body'] = self::setHashtags($content_fields['raw-body']);
265                                 }
266                 
267                                 self::updateContent($content_fields, ['uri-id' => $item['uri-id']]);
268                         }
269
270                         if (!is_null($files)) {
271                                 Post\Category::storeTextByURIId($item['uri-id'], $item['uid'], $files);
272                                 if (!empty($item['file'])) {
273                                         DBA::update('item', ['file' => ''], ['id' => $item['id']]);
274                                 }
275                         }
276
277                         if (!empty($fields['attach'])) {
278                                 Post\Media::insertFromAttachment($item['uri-id'], $fields['attach']);
279                         }
280
281                         Post\DeliveryData::update($item['uri-id'], $delivery_data);
282
283                         self::updateThread($item['id']);
284
285                         // We only need to notfiy others when it is an original entry from us.
286                         // Only call the notifier when the item has some content relevant change.
287                         if ($item['origin'] && in_array('edited', array_keys($fields))) {
288                                 $notify_items[] = $item['id'];
289                         }
290                 }
291
292                 DBA::close($items);
293                 DBA::commit();
294
295                 foreach ($notify_items as $notify_item) {
296                         Worker::add(PRIORITY_HIGH, "Notifier", Delivery::POST, $notify_item);
297                 }
298
299                 return $rows;
300         }
301
302         /**
303          * Delete an item and notify others about it - if it was ours
304          *
305          * @param array   $condition The condition for finding the item entries
306          * @param integer $priority  Priority for the notification
307          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
308          */
309         public static function markForDeletion($condition, $priority = PRIORITY_HIGH)
310         {
311                 $items = Post::select(['id'], $condition);
312                 while ($item = Post::fetch($items)) {
313                         self::markForDeletionById($item['id'], $priority);
314                 }
315                 DBA::close($items);
316         }
317
318         /**
319          * Delete an item for an user and notify others about it - if it was ours
320          *
321          * @param array   $condition The condition for finding the item entries
322          * @param integer $uid       User who wants to delete this item
323          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
324          */
325         public static function deleteForUser($condition, $uid)
326         {
327                 if ($uid == 0) {
328                         return;
329                 }
330
331                 $items = Post::select(['id', 'uid', 'uri-id'], $condition);
332                 while ($item = Post::fetch($items)) {
333                         Post\User::update($item['uri-id'], $item['uid'], ['hidden' => true]);
334
335                         // "Deleting" global items just means hiding them
336                         if ($item['uid'] == 0) {
337                                 DBA::update('user-item', ['hidden' => true], ['iid' => $item['id'], 'uid' => $uid], true);
338                         } elseif ($item['uid'] == $uid) {
339                                 self::markForDeletionById($item['id'], PRIORITY_HIGH);
340                         } else {
341                                 Logger::log('Wrong ownership. Not deleting item ' . $item['id']);
342                         }
343                 }
344                 DBA::close($items);
345         }
346
347         /**
348          * Mark an item for deletion, delete related data and notify others about it - if it was ours
349          *
350          * @param integer $item_id
351          * @param integer $priority Priority for the notification
352          *
353          * @return boolean success
354          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
355          */
356         public static function markForDeletionById($item_id, $priority = PRIORITY_HIGH)
357         {
358                 Logger::info('Mark item for deletion by id', ['id' => $item_id, 'callstack' => System::callstack()]);
359                 // locate item to be deleted
360                 $fields = ['id', 'uri', 'uri-id', 'uid', 'parent', 'parent-uri', 'origin',
361                         'deleted', 'file', 'resource-id', 'event-id',
362                         'verb', 'object-type', 'object', 'target', 'contact-id', 'psid', 'gravity'];
363                 $item = Post::selectFirst($fields, ['id' => $item_id]);
364                 if (!DBA::isResult($item)) {
365                         Logger::info('Item not found.', ['id' => $item_id]);
366                         return false;
367                 }
368
369                 if ($item['deleted']) {
370                         Logger::info('Item has already been marked for deletion.', ['id' => $item_id]);
371                         return false;
372                 }
373
374                 $parent = Post::selectFirst(['origin'], ['id' => $item['parent']]);
375                 if (!DBA::isResult($parent)) {
376                         $parent = ['origin' => false];
377                 }
378
379                 // clean up categories and tags so they don't end up as orphans
380
381                 $matches = [];
382                 $cnt = preg_match_all('/<(.*?)>/', $item['file'], $matches, PREG_SET_ORDER);
383
384                 if ($cnt) {
385                         foreach ($matches as $mtch) {
386                                 FileTag::unsaveFile($item['uid'], $item['id'], $mtch[1],true);
387                         }
388                 }
389
390                 $matches = [];
391
392                 $cnt = preg_match_all('/\[(.*?)\]/', $item['file'], $matches, PREG_SET_ORDER);
393
394                 if ($cnt) {
395                         foreach ($matches as $mtch) {
396                                 FileTag::unsaveFile($item['uid'], $item['id'], $mtch[1],false);
397                         }
398                 }
399
400                 /*
401                  * If item is a link to a photo resource, nuke all the associated photos
402                  * (visitors will not have photo resources)
403                  * This only applies to photos uploaded from the photos page. Photos inserted into a post do not
404                  * generate a resource-id and therefore aren't intimately linked to the item.
405                  */
406                 /// @TODO: this should first check if photo is used elsewhere
407                 if (strlen($item['resource-id'])) {
408                         Photo::delete(['resource-id' => $item['resource-id'], 'uid' => $item['uid']]);
409                 }
410
411                 // If item is a link to an event, delete the event.
412                 if (intval($item['event-id'])) {
413                         Event::delete($item['event-id']);
414                 }
415
416                 // If item has attachments, drop them
417                 $attachments = Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT]);
418                 foreach($attachments as $attachment) {
419                         if (preg_match("|attach/(\d+)|", $attachment['url'], $matches)) {
420                                 Attach::delete(['id' => $matches[1], 'uid' => $item['uid']]);
421                         }
422                 }
423
424                 // Set the item to "deleted"
425                 $item_fields = ['deleted' => true, 'edited' => DateTimeFormat::utcNow(), 'changed' => DateTimeFormat::utcNow()];
426                 DBA::update('item', $item_fields, ['id' => $item['id']]);
427
428                 Post\Category::storeTextByURIId($item['uri-id'], $item['uid'], '');
429                 self::deleteThread($item['id'], $item['parent-uri']);
430
431                 if (!Post::exists(["`uri` = ? AND `uid` != 0 AND NOT `deleted`", $item['uri']])) {
432                         self::markForDeletion(['uri' => $item['uri'], 'uid' => 0, 'deleted' => false], $priority);
433                 }
434
435                 Post\DeliveryData::delete($item['uri-id']);
436
437                 // When the permission set will be used in photo and events as well,
438                 // this query here needs to be extended.
439                 // @todo Currently deactivated. We need the permission set in the deletion process.
440                 // This is a reminder to add the removal somewhere else.
441                 //if (!empty($item['psid']) && !self::exists(['psid' => $item['psid'], 'deleted' => false])) {
442                 //      DBA::delete('permissionset', ['id' => $item['psid']], ['cascade' => false]);
443                 //}
444
445                 // If it's the parent of a comment thread, kill all the kids
446                 if ($item['gravity'] == GRAVITY_PARENT) {
447                         self::markForDeletion(['parent' => $item['parent'], 'deleted' => false], $priority);
448                 }
449
450                 // Is it our comment and/or our thread?
451                 if (($item['origin'] || $parent['origin']) && ($item['uid'] != 0)) {
452                         // When we delete the original post we will delete all existing copies on the server as well
453                         self::markForDeletion(['uri' => $item['uri'], 'deleted' => false], $priority);
454
455                         // send the notification upstream/downstream
456                         if ($priority) {
457                                 Worker::add(['priority' => $priority, 'dont_fork' => true], "Notifier", Delivery::DELETION, intval($item['id']));
458                         }
459                 } elseif ($item['uid'] != 0) {
460                         Post\User::update($item['uri-id'], $item['uid'], ['hidden' => true]);
461
462                         // When we delete just our local user copy of an item, we have to set a marker to hide it
463                         $global_item = Post::selectFirst(['id'], ['uri' => $item['uri'], 'uid' => 0, 'deleted' => false]);
464                         if (DBA::isResult($global_item)) {
465                                 DBA::update('user-item', ['hidden' => true], ['iid' => $global_item['id'], 'uid' => $item['uid']], true);
466                         }
467                 }
468
469                 Logger::info('Item has been marked for deletion.', ['id' => $item_id]);
470
471                 return true;
472         }
473
474
475         private static function guid($item, $notify)
476         {
477                 if (!empty($item['guid'])) {
478                         return Strings::escapeTags(trim($item['guid']));
479                 }
480
481                 if ($notify) {
482                         // We have to avoid duplicates. So we create the GUID in form of a hash of the plink or uri.
483                         // We add the hash of our own host because our host is the original creator of the post.
484                         $prefix_host = DI::baseUrl()->getHostname();
485                 } else {
486                         $prefix_host = '';
487
488                         // We are only storing the post so we create a GUID from the original hostname.
489                         if (!empty($item['author-link'])) {
490                                 $parsed = parse_url($item['author-link']);
491                                 if (!empty($parsed['host'])) {
492                                         $prefix_host = $parsed['host'];
493                                 }
494                         }
495
496                         if (empty($prefix_host) && !empty($item['plink'])) {
497                                 $parsed = parse_url($item['plink']);
498                                 if (!empty($parsed['host'])) {
499                                         $prefix_host = $parsed['host'];
500                                 }
501                         }
502
503                         if (empty($prefix_host) && !empty($item['uri'])) {
504                                 $parsed = parse_url($item['uri']);
505                                 if (!empty($parsed['host'])) {
506                                         $prefix_host = $parsed['host'];
507                                 }
508                         }
509
510                         // Is it in the format data@host.tld? - Used for mail contacts
511                         if (empty($prefix_host) && !empty($item['author-link']) && strstr($item['author-link'], '@')) {
512                                 $mailparts = explode('@', $item['author-link']);
513                                 $prefix_host = array_pop($mailparts);
514                         }
515                 }
516
517                 if (!empty($item['plink'])) {
518                         $guid = self::guidFromUri($item['plink'], $prefix_host);
519                 } elseif (!empty($item['uri'])) {
520                         $guid = self::guidFromUri($item['uri'], $prefix_host);
521                 } else {
522                         $guid = System::createUUID(hash('crc32', $prefix_host));
523                 }
524
525                 return $guid;
526         }
527
528         private static function contactId($item)
529         {
530                 if (!empty($item['contact-id']) && DBA::exists('contact', ['self' => true, 'id' => $item['contact-id']])) {
531                         return $item['contact-id'];
532                 } elseif (($item['gravity'] == GRAVITY_PARENT) && !empty($item['uid']) && !empty($item['contact-id']) && Contact::isSharing($item['contact-id'], $item['uid'])) {
533                         return $item['contact-id'];
534                 } elseif (!empty($item['uid']) && !Contact::isSharing($item['author-id'], $item['uid'])) {
535                         return $item['author-id'];
536                 } elseif (!empty($item['contact-id'])) {
537                         return $item['contact-id'];
538                 } else {
539                         $contact_id = Contact::getIdForURL($item['author-link'], $item['uid']);
540                         if (!empty($contact_id)) {
541                                 return $contact_id;
542                         }
543                 }
544                 return $item['author-id'];
545         }
546
547         /**
548          * Write an item array into a spool file to be inserted later.
549          * This command is called whenever there are issues storing an item.
550          *
551          * @param array $item The item fields that are to be inserted
552          * @throws \Exception
553          */
554         private static function spool($orig_item)
555         {
556                 // Now we store the data in the spool directory
557                 // We use "microtime" to keep the arrival order and "mt_rand" to avoid duplicates
558                 $file = 'item-' . round(microtime(true) * 10000) . '-' . mt_rand() . '.msg';
559
560                 $spoolpath = get_spoolpath();
561                 if ($spoolpath != "") {
562                         $spool = $spoolpath . '/' . $file;
563
564                         file_put_contents($spool, json_encode($orig_item));
565                         Logger::warning("Item wasn't stored - Item was spooled into file", ['file' => $file]);
566                 }
567         }
568
569         /**
570          * Check if the item array is a duplicate
571          *
572          * @param array $item
573          * @return boolean is it a duplicate?
574          */
575         private static function isDuplicate(array $item)
576         {
577                 // Checking if there is already an item with the same guid
578                 $condition = ['guid' => $item['guid'], 'network' => $item['network'], 'uid' => $item['uid']];
579                 if (Post::exists($condition)) {
580                         Logger::notice('Found already existing item', [
581                                 'guid' => $item['guid'],
582                                 'uid' => $item['uid'],
583                                 'network' => $item['network']
584                         ]);
585                         return true;
586                 }
587
588                 $condition = ["`uri` = ? AND `network` IN (?, ?) AND `uid` = ?",
589                         $item['uri'], $item['network'], Protocol::DFRN, $item['uid']];
590                 if (Post::exists($condition)) {
591                         Logger::notice('duplicated item with the same uri found.', $item);
592                         return true;
593                 }
594
595                 // On Friendica and Diaspora the GUID is unique
596                 if (in_array($item['network'], [Protocol::DFRN, Protocol::DIASPORA])) {
597                         $condition = ['guid' => $item['guid'], 'uid' => $item['uid']];
598                         if (Post::exists($condition)) {
599                                 Logger::notice('duplicated item with the same guid found.', $item);
600                                 return true;
601                         }
602                 } elseif ($item['network'] == Protocol::OSTATUS) {
603                         // Check for an existing post with the same content. There seems to be a problem with OStatus.
604                         $condition = ["`body` = ? AND `network` = ? AND `created` = ? AND `contact-id` = ? AND `uid` = ?",
605                                         $item['body'], $item['network'], $item['created'], $item['contact-id'], $item['uid']];
606                         if (Post::exists($condition)) {
607                                 Logger::notice('duplicated item with the same body found.', $item);
608                                 return true;
609                         }
610                 }
611
612                 /*
613                  * Check for already added items.
614                  * There is a timing issue here that sometimes creates double postings.
615                  * An unique index would help - but the limitations of MySQL (maximum size of index values) prevent this.
616                  */
617                 if (($item['uid'] == 0) && Post::exists(['uri' => trim($item['uri']), 'uid' => 0])) {
618                         Logger::notice('Global item already stored.', ['uri' => $item['uri'], 'network' => $item['network']]);
619                         return true;
620                 }
621
622                 return false;
623         }
624
625         /**
626          * Check if the item array is valid
627          *
628          * @param array $item
629          * @return boolean item is valid
630          */
631         public static function isValid(array $item)
632         {
633                 // When there is no content then we don't post it
634                 if ($item['body'] . $item['title'] == '') {
635                         Logger::notice('No body, no title.');
636                         return false;
637                 }
638
639                 if (!empty($item['uid'])) {
640                         $owner = User::getOwnerDataById($item['uid'], false);
641                         if (!$owner) {
642                                 Logger::notice('Missing item user owner data', ['uid' => $item['uid']]);
643                                 return false;
644                         }
645
646                         if ($owner['account_expired'] || $owner['account_removed']) {
647                                 Logger::notice('Item user has been deleted/expired/removed', ['uid' => $item['uid'], 'deleted' => $owner['deleted'], 'account_expired' => $owner['account_expired'], 'account_removed' => $owner['account_removed']]);
648                                 return false;
649                         }
650                 }
651
652                 if (!empty($item['author-id']) && Contact::isBlocked($item['author-id'])) {
653                         Logger::notice('Author is blocked node-wide', ['author-link' => $item['author-link'], 'item-uri' => $item['uri']]);
654                         return false;
655                 }
656
657                 if (!empty($item['author-link']) && Network::isUrlBlocked($item['author-link'])) {
658                         Logger::notice('Author server is blocked', ['author-link' => $item['author-link'], 'item-uri' => $item['uri']]);
659                         return false;
660                 }
661
662                 if (!empty($item['owner-id']) && Contact::isBlocked($item['owner-id'])) {
663                         Logger::notice('Owner is blocked node-wide', ['owner-link' => $item['owner-link'], 'item-uri' => $item['uri']]);
664                         return false;
665                 }
666
667                 if (!empty($item['owner-link']) && Network::isUrlBlocked($item['owner-link'])) {
668                         Logger::notice('Owner server is blocked', ['owner-link' => $item['owner-link'], 'item-uri' => $item['uri']]);
669                         return false;
670                 }
671
672                 if (!empty($item['uid']) && !self::isAllowedByUser($item, $item['uid'])) {
673                         return false;
674                 }
675
676                 if ($item['verb'] == Activity::FOLLOW) {
677                         if (!$item['origin'] && ($item['author-id'] == Contact::getPublicIdByUserId($item['uid']))) {
678                                 // Our own follow request can be relayed to us. We don't store it to avoid notification chaos.
679                                 Logger::info("Follow: Don't store not origin follow request", ['parent-uri' => $item['parent-uri']]);
680                                 return false;
681                         }
682
683                         $condition = ['verb' => Activity::FOLLOW, 'uid' => $item['uid'],
684                                 'parent-uri' => $item['parent-uri'], 'author-id' => $item['author-id']];
685                         if (Post::exists($condition)) {
686                                 // It happens that we receive multiple follow requests by the same author - we only store one.
687                                 Logger::info('Follow: Found existing follow request from author', ['author-id' => $item['author-id'], 'parent-uri' => $item['parent-uri']]);
688                                 return false;
689                         }
690                 }
691
692                 return true;
693         }
694
695         /**
696          * Check if the item array is too old
697          *
698          * @param array $item
699          * @return boolean item is too old
700          */
701         public static function isTooOld(array $item)
702         {
703                 // check for create date and expire time
704                 $expire_interval = DI::config()->get('system', 'dbclean-expire-days', 0);
705
706                 $user = DBA::selectFirst('user', ['expire'], ['uid' => $item['uid']]);
707                 if (DBA::isResult($user) && ($user['expire'] > 0) && (($user['expire'] < $expire_interval) || ($expire_interval == 0))) {
708                         $expire_interval = $user['expire'];
709                 }
710
711                 if (($expire_interval > 0) && !empty($item['created'])) {
712                         $expire_date = time() - ($expire_interval * 86400);
713                         $created_date = strtotime($item['created']);
714                         if ($created_date < $expire_date) {
715                                 Logger::notice('Item created before expiration interval.', [
716                                         'created' => date('c', $created_date),
717                                         'expired' => date('c', $expire_date),
718                                         '$item' => $item
719                                 ]);
720                                 return true;
721                         }
722                 }
723
724                 return false;
725         }
726
727         /**
728          * Return the id of the given item array if it has been stored before
729          *
730          * @param array $item
731          * @return integer item id
732          */
733         private static function getDuplicateID(array $item)
734         {
735                 if (empty($item['network']) || in_array($item['network'], Protocol::FEDERATED)) {
736                         $condition = ["`uri` = ? AND `uid` = ? AND `network` IN (?, ?, ?, ?)",
737                                 trim($item['uri']), $item['uid'],
738                                 Protocol::ACTIVITYPUB, Protocol::DIASPORA, Protocol::DFRN, Protocol::OSTATUS];
739                         $existing = Post::selectFirst(['id', 'network'], $condition);
740                         if (DBA::isResult($existing)) {
741                                 // We only log the entries with a different user id than 0. Otherwise we would have too many false positives
742                                 if ($item['uid'] != 0) {
743                                         Logger::notice('Item already existed for user', [
744                                                 'uri' => $item['uri'],
745                                                 'uid' => $item['uid'],
746                                                 'network' => $item['network'],
747                                                 'existing_id' => $existing["id"],
748                                                 'existing_network' => $existing["network"]
749                                         ]);
750                                 }
751
752                                 return $existing["id"];
753                         }
754                 }
755                 return 0;
756         }
757
758         /**
759          * Fetch top-level parent data for the given item array
760          *
761          * @param array $item
762          * @return array item array with parent data
763          * @throws \Exception
764          */
765         private static function getTopLevelParent(array $item)
766         {
767                 $fields = ['uid', 'uri', 'parent-uri', 'id', 'deleted',
768                         'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid',
769                         'wall', 'private', 'forum_mode', 'origin', 'author-id'];
770                 $condition = ['uri' => $item['thr-parent'], 'uid' => $item['uid']];
771                 $params = ['order' => ['id' => false]];
772                 $parent = Post::selectFirst($fields, $condition, $params);
773
774                 if (!DBA::isResult($parent)) {
775                         Logger::notice('item parent was not found - ignoring item', ['thr-parent' => $item['thr-parent'], 'uid' => $item['uid']]);
776                         return [];
777                 }
778
779                 if ($parent['uri'] == $parent['parent-uri']) {
780                         return $parent;
781                 }
782
783                 $condition = ['uri' => $parent['parent-uri'],
784                         'parent-uri' => $parent['parent-uri'],
785                         'uid' => $parent['uid']];
786                 $params = ['order' => ['id' => false]];
787                 $toplevel_parent = Post::selectFirst($fields, $condition, $params);
788                 if (!DBA::isResult($toplevel_parent)) {
789                         Logger::notice('item top level parent was not found - ignoring item', ['parent-uri' => $parent['parent-uri'], 'uid' => $parent['uid']]);
790                         return [];
791                 }
792
793                 return $toplevel_parent;
794         }
795
796         /**
797          * Get the gravity for the given item array
798          *
799          * @param array $item
800          * @return integer gravity
801          */
802         private static function getGravity(array $item)
803         {
804                 $activity = DI::activity();
805
806                 if (isset($item['gravity'])) {
807                         return intval($item['gravity']);
808                 } elseif ($item['parent-uri'] === $item['uri']) {
809                         return GRAVITY_PARENT;
810                 } elseif ($activity->match($item['verb'], Activity::POST)) {
811                         return GRAVITY_COMMENT;
812                 } elseif ($activity->match($item['verb'], Activity::FOLLOW)) {
813                         return GRAVITY_ACTIVITY;
814                 } elseif ($activity->match($item['verb'], Activity::ANNOUNCE)) {
815                         return GRAVITY_ACTIVITY;
816                 }
817                 Logger::info('Unknown gravity for verb', ['verb' => $item['verb']]);
818                 return GRAVITY_UNKNOWN;   // Should not happen
819         }
820
821         public static function insert($item, $notify = false, $dontcache = false)
822         {
823                 $structure = self::getItemFields();
824
825                 $orig_item = $item;
826
827                 $priority = PRIORITY_HIGH;
828
829                 // If it is a posting where users should get notifications, then define it as wall posting
830                 if ($notify) {
831                         $item['wall'] = 1;
832                         $item['origin'] = 1;
833                         $item['network'] = Protocol::DFRN;
834                         $item['protocol'] = Conversation::PARCEL_DIRECT;
835                         $item['direction'] = Conversation::PUSH;
836
837                         if (in_array($notify, PRIORITIES)) {
838                                 $priority = $notify;
839                         }
840                 } else {
841                         $item['network'] = trim(($item['network'] ?? '') ?: Protocol::PHANTOM);
842                 }
843
844                 $uid = intval($item['uid']);
845
846                 $item['guid'] = self::guid($item, $notify);
847                 $item['uri'] = substr(trim($item['uri'] ?? '') ?: self::newURI($item['uid'], $item['guid']), 0, 255);
848
849                 // Store URI data
850                 $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]);
851
852                 // Backward compatibility: parent-uri used to be the direct parent uri.
853                 // If it is provided without a thr-parent, it probably is the old behavior.
854                 $item['thr-parent'] = trim($item['thr-parent'] ?? $item['parent-uri'] ?? $item['uri']);
855                 $item['parent-uri'] = $item['thr-parent'];
856
857                 // Store conversation data
858                 $item = Conversation::insert($item);
859
860                 /*
861                  * Do we already have this item?
862                  * We have to check several networks since Friendica posts could be repeated
863                  * via OStatus (maybe Diasporsa as well)
864                  */
865                 $duplicate = self::getDuplicateID($item);
866                 if ($duplicate) {
867                         return $duplicate;
868                 }
869
870                 // Additional duplicate checks
871                 /// @todo Check why the first duplication check returns the item number and the second a 0
872                 if (self::isDuplicate($item)) {
873                         return 0;
874                 }
875
876                 $item['wall']          = intval($item['wall'] ?? 0);
877                 $item['extid']         = trim($item['extid'] ?? '');
878                 $item['author-name']   = trim($item['author-name'] ?? '');
879                 $item['author-link']   = trim($item['author-link'] ?? '');
880                 $item['author-avatar'] = trim($item['author-avatar'] ?? '');
881                 $item['owner-name']    = trim($item['owner-name'] ?? '');
882                 $item['owner-link']    = trim($item['owner-link'] ?? '');
883                 $item['owner-avatar']  = trim($item['owner-avatar'] ?? '');
884                 $item['received']      = (isset($item['received'])  ? DateTimeFormat::utc($item['received'])  : DateTimeFormat::utcNow());
885                 $item['created']       = (isset($item['created'])   ? DateTimeFormat::utc($item['created'])   : $item['received']);
886                 $item['edited']        = (isset($item['edited'])    ? DateTimeFormat::utc($item['edited'])    : $item['created']);
887                 $item['changed']       = (isset($item['changed'])   ? DateTimeFormat::utc($item['changed'])   : $item['created']);
888                 $item['commented']     = (isset($item['commented']) ? DateTimeFormat::utc($item['commented']) : $item['created']);
889                 $item['title']         = substr(trim($item['title'] ?? ''), 0, 255);
890                 $item['location']      = trim($item['location'] ?? '');
891                 $item['coord']         = trim($item['coord'] ?? '');
892                 $item['visible']       = (isset($item['visible']) ? intval($item['visible']) : 1);
893                 $item['deleted']       = 0;
894                 $item['post-type']     = ($item['post-type'] ?? '') ?: self::PT_ARTICLE;
895                 $item['verb']          = trim($item['verb'] ?? '');
896                 $item['object-type']   = trim($item['object-type'] ?? '');
897                 $item['object']        = trim($item['object'] ?? '');
898                 $item['target-type']   = trim($item['target-type'] ?? '');
899                 $item['target']        = trim($item['target'] ?? '');
900                 $item['plink']         = substr(trim($item['plink'] ?? ''), 0, 255);
901                 $item['allow_cid']     = trim($item['allow_cid'] ?? '');
902                 $item['allow_gid']     = trim($item['allow_gid'] ?? '');
903                 $item['deny_cid']      = trim($item['deny_cid'] ?? '');
904                 $item['deny_gid']      = trim($item['deny_gid'] ?? '');
905                 $item['private']       = intval($item['private'] ?? self::PUBLIC);
906                 $item['body']          = trim($item['body'] ?? '');
907                 $item['raw-body']      = trim($item['raw-body'] ?? $item['body']);
908                 $item['app']           = trim($item['app'] ?? '');
909                 $item['origin']        = intval($item['origin'] ?? 0);
910                 $item['postopts']      = trim($item['postopts'] ?? '');
911                 $item['resource-id']   = trim($item['resource-id'] ?? '');
912                 $item['event-id']      = intval($item['event-id'] ?? 0);
913                 $item['inform']        = trim($item['inform'] ?? '');
914                 $item['file']          = trim($item['file'] ?? '');
915
916                 // Items cannot be stored before they happen ...
917                 if ($item['created'] > DateTimeFormat::utcNow()) {
918                         $item['created'] = DateTimeFormat::utcNow();
919                 }
920
921                 // We haven't invented time travel by now.
922                 if ($item['edited'] > DateTimeFormat::utcNow()) {
923                         $item['edited'] = DateTimeFormat::utcNow();
924                 }
925
926                 $item['plink'] = ($item['plink'] ?? '') ?: DI::baseUrl() . '/display/' . urlencode($item['guid']);
927
928                 $item['gravity'] = self::getGravity($item);
929
930                 $item['language'] = self::getLanguage($item);
931
932                 $default = ['url' => $item['author-link'], 'name' => $item['author-name'],
933                         'photo' => $item['author-avatar'], 'network' => $item['network']];
934                 $item['author-id'] = ($item['author-id'] ?? 0) ?: Contact::getIdForURL($item['author-link'], 0, null, $default);
935
936                 $default = ['url' => $item['owner-link'], 'name' => $item['owner-name'],
937                         'photo' => $item['owner-avatar'], 'network' => $item['network']];
938                 $item['owner-id'] = ($item['owner-id'] ?? 0) ?: Contact::getIdForURL($item['owner-link'], 0, null, $default);
939
940                 $actor = ($item['gravity'] == GRAVITY_PARENT) ? $item['owner-id'] : $item['author-id'];
941                 if (!$item['origin'] && ($item['uid'] != 0) && Contact::isSharing($actor, $item['uid'])) {
942                         $item['post-type'] = self::PT_FOLLOWER;
943                 }
944
945                 // Ensure that there is an avatar cache
946                 Contact::checkAvatarCache($item['author-id']);
947                 Contact::checkAvatarCache($item['owner-id']);
948
949                 // The contact-id should be set before "self::insert" was called - but there seems to be issues sometimes
950                 $item["contact-id"] = self::contactId($item);
951
952                 if (!empty($item['direction']) && in_array($item['direction'], [Conversation::PUSH, Conversation::RELAY]) &&
953                         self::isTooOld($item)) {
954                         Logger::info('Item is too old', ['item' => $item]);
955                         return 0;
956                 }
957
958                 if (!self::isValid($item)) {
959                         return 0;
960                 }
961
962                 if ($item['gravity'] !== GRAVITY_PARENT) {
963                         $toplevel_parent = self::getTopLevelParent($item);
964                         if (empty($toplevel_parent)) {
965                                 return 0;
966                         }
967
968                         // If the thread originated from this node, we check the permission against the thread starter
969                         $condition = ['uri' => $toplevel_parent['uri'], 'wall' => true];
970                         $localTopLevelParent = Post::selectFirst(['uid'], $condition);
971                         if (!empty($localTopLevelParent['uid']) && !self::isAllowedByUser($item, $localTopLevelParent['uid'])) {
972                                 return 0;
973                         }
974
975                         $parent_id          = $toplevel_parent['id'];
976                         $item['parent-uri'] = $toplevel_parent['uri'];
977                         $item['deleted']    = $toplevel_parent['deleted'];
978                         $item['allow_cid']  = $toplevel_parent['allow_cid'];
979                         $item['allow_gid']  = $toplevel_parent['allow_gid'];
980                         $item['deny_cid']   = $toplevel_parent['deny_cid'];
981                         $item['deny_gid']   = $toplevel_parent['deny_gid'];
982                         $parent_origin      = $toplevel_parent['origin'];
983
984                         // Don't federate received participation messages
985                         if ($item['verb'] != Activity::FOLLOW) {
986                                 $item['wall'] = $toplevel_parent['wall'];
987                         } else {
988                                 $item['wall'] = false;
989                         }
990
991                         /*
992                          * If the parent is private, force privacy for the entire conversation
993                          * This differs from the above settings as it subtly allows comments from
994                          * email correspondents to be private even if the overall thread is not.
995                          */
996                         if ($toplevel_parent['private']) {
997                                 $item['private'] = $toplevel_parent['private'];
998                         }
999
1000                         /*
1001                          * Edge case. We host a public forum that was originally posted to privately.
1002                          * The original author commented, but as this is a comment, the permissions
1003                          * weren't fixed up so it will still show the comment as private unless we fix it here.
1004                          */
1005                         if ((intval($toplevel_parent['forum_mode']) == 1) && ($toplevel_parent['private'] != self::PUBLIC)) {
1006                                 $item['private'] = self::PUBLIC;
1007                         }
1008
1009                         // If its a post that originated here then tag the thread as "mention"
1010                         if ($item['origin'] && $item['uid']) {
1011                                 DBA::update('thread', ['mention' => true], ['iid' => $parent_id]);
1012                                 Logger::info('tagged thread as mention', ['parent' => $parent_id, 'uid' => $item['uid']]);
1013                         }
1014
1015                         // Update the contact relations
1016                         Contact\Relation::store($toplevel_parent['author-id'], $item['author-id'], $item['created']);
1017
1018                         unset($item['parent_origin']);
1019                 } else {
1020                         $parent_id = 0;
1021                         $parent_origin = $item['origin'];
1022                 }
1023
1024                 // We don't store the causer link, only the id
1025                 unset($item['causer-link']);
1026
1027                 // We don't store these fields anymore in the item table
1028                 unset($item['author-link']);
1029                 unset($item['author-name']);
1030                 unset($item['author-avatar']);
1031                 unset($item['author-network']);
1032
1033                 unset($item['owner-link']);
1034                 unset($item['owner-name']);
1035                 unset($item['owner-avatar']);
1036
1037                 $item['parent-uri-id'] = ItemURI::getIdByURI($item['parent-uri']);
1038                 $item['thr-parent-id'] = ItemURI::getIdByURI($item['thr-parent']);
1039
1040                 // Is this item available in the global items (with uid=0)?
1041                 if ($item["uid"] == 0) {
1042                         $item["global"] = true;
1043
1044                         // Set the global flag on all items if this was a global item entry
1045                         DBA::update('item', ['global' => true], ['uri' => $item["uri"]]);
1046                 } else {
1047                         $item["global"] = Post::exists(['uid' => 0, 'uri' => $item["uri"]]);
1048                 }
1049
1050                 // ACL settings
1051                 if (!empty($item["allow_cid"] . $item["allow_gid"] . $item["deny_cid"] . $item["deny_gid"])) {
1052                         $item["private"] = self::PRIVATE;
1053                 }
1054
1055                 if ($notify) {
1056                         $item['edit'] = false;
1057                         $item['parent'] = $parent_id;
1058                         Hook::callAll('post_local', $item);
1059                         unset($item['edit']);
1060                 } else {
1061                         Hook::callAll('post_remote', $item);
1062                 }
1063
1064                 // Set after the insert because top-level posts are self-referencing
1065                 unset($item['parent']);
1066
1067                 if (!empty($item['cancel'])) {
1068                         Logger::log('post cancelled by addon.');
1069                         return 0;
1070                 }
1071
1072                 if (empty($item['vid']) && !empty($item['verb'])) {
1073                         $item['vid'] = Verb::getID($item['verb']);
1074                 }
1075
1076                 // Creates or assigns the permission set
1077                 $item['psid'] = PermissionSet::getIdFromACL(
1078                         $item['uid'],
1079                         $item['allow_cid'],
1080                         $item['allow_gid'],
1081                         $item['deny_cid'],
1082                         $item['deny_gid']
1083                 );
1084
1085                 unset($item['allow_cid']);
1086                 unset($item['allow_gid']);
1087                 unset($item['deny_cid']);
1088                 unset($item['deny_gid']);
1089
1090                 // This array field is used to trigger some automatic reactions
1091                 // It is mainly used in the "post_local" hook.
1092                 unset($item['api_source']);
1093
1094                 if ($item['verb'] == Activity::ANNOUNCE) {
1095                         self::setOwnerforResharedItem($item);
1096                 }
1097
1098                 // Remove all media attachments from the body and store them in the post-media table
1099                 $item['raw-body'] = Post\Media::insertFromBody($item['uri-id'], $item['raw-body']);
1100                 $item['raw-body'] = self::setHashtags($item['raw-body']);
1101
1102                 // Check for hashtags in the body and repair or add hashtag links
1103                 $item['body'] = self::setHashtags($item['body']);
1104
1105                 if (!empty($item['attach'])) {
1106                         Post\Media::insertFromAttachment($item['uri-id'], $item['attach']);
1107                 }
1108
1109                 // Fill the cache field
1110                 self::putInCache($item);
1111
1112                 if (stristr($item['verb'], Activity::POKE)) {
1113                         $notify_type = Delivery::POKE;
1114                 } else {
1115                         $notify_type = Delivery::POST;
1116                 }
1117
1118                 if (!in_array($item['verb'], self::ACTIVITIES) && !self::insertContent($item)) {
1119                         // This shouldn't happen
1120                         Logger::warning('No content stored, quitting', ['guid' => $item['guid'], 'uri-id' => $item['uri-id'], 'causer-id' => ($item['causer-id'] ?? 0), 'post-type' => $item['post-type'], 'network' => $item['network']]);
1121                         return 0;
1122                 }
1123
1124                 $body = $item['body'];
1125                 $verb = $item['verb'];
1126
1127                 // We just remove everything that is content
1128                 foreach (array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST) as $field) {
1129                         unset($item[$field]);
1130                 }
1131
1132                 unset($item['activity']);
1133
1134                 // Filling item related side tables
1135
1136                 // Diaspora signature
1137                 if (!empty($item['diaspora_signed_text'])) {
1138                         DBA::replace('diaspora-interaction', ['uri-id' => $item['uri-id'], 'interaction' => $item['diaspora_signed_text']]);
1139                 }
1140
1141                 unset($item['diaspora_signed_text']);
1142
1143                 // Attached file links
1144                 if (!empty($item['file'])) {
1145                         Post\Category::storeTextByURIId($item['uri-id'], $item['uid'], $item['file']);
1146                 }
1147
1148                 unset($item['file']);
1149
1150                 // Delivery relevant data
1151                 $delivery_data = Post\DeliveryData::extractFields($item);
1152                 unset($item['postopts']);
1153                 unset($item['inform']);
1154
1155                 if (!empty($item['origin']) || !empty($item['wall']) || !empty($delivery_data['postopts']) || !empty($delivery_data['inform'])) {
1156                         Post\DeliveryData::insert($item['uri-id'], $delivery_data);
1157                 }
1158
1159                 // Store tags from the body if this hadn't been handled previously in the protocol classes
1160                 if (!Tag::existsForPost($item['uri-id'])) {
1161                         Tag::storeFromBody($item['uri-id'], $body);
1162                 }
1163
1164                 if (Post\User::insert($item['uri-id'], $item['uid'], $item)) {
1165                         // Remove all fields that aren't part of the item table
1166                         foreach ($item as $field => $value) {
1167                                 if (!in_array($field, $structure['item'])) {
1168                                         unset($item[$field]);
1169                                 }
1170                         }
1171
1172                         $condition = ['uri-id' => $item['uri-id'], 'uid' => $item['uid'], 'network' => $item['network']];
1173                         if (DBA::exists('item', $condition)) {
1174                                 Logger::notice('Item is already inserted - aborting', $condition);
1175                                 return 0;
1176                         }
1177
1178                         $result = DBA::insert('item', $item);
1179
1180                         // When the item was successfully stored we fetch the ID of the item.
1181                         $current_post = DBA::lastInsertId();
1182                 } else {
1183                         Logger::notice('Post-User is already inserted - aborting', ['uid' => $item['uid'], 'uri-id' => $item['uri-id']]);
1184                         return 0;
1185                 }
1186
1187                 if (empty($current_post) || !DBA::isResult($result)) {
1188                         // On failure store the data into a spool file so that the "SpoolPost" worker can try again later.
1189                         Logger::warning('Could not store item. it will be spooled', ['result' => $result, 'id' => $current_post]);
1190                         self::spool($orig_item);
1191                         return 0;
1192                 }
1193
1194                 Logger::notice('created item', ['id' => $current_post, 'uid' => $item['uid'], 'network' => $item['network'], 'uri-id' => $item['uri-id'], 'guid' => $item['guid']]);
1195
1196                 if (!$parent_id || ($item['gravity'] === GRAVITY_PARENT)) {
1197                         $parent_id = $current_post;
1198                 }
1199
1200                 // Set parent id
1201                 DBA::update('item', ['parent' => $parent_id], ['id' => $current_post]);
1202
1203                 $item['id'] = $current_post;
1204                 $item['parent'] = $parent_id;
1205
1206                 // update the commented timestamp on the parent
1207                 if (DI::config()->get('system', 'like_no_comment')) {
1208                         // Update when it is a comment
1209                         $update_commented = in_array($item['gravity'], [GRAVITY_PARENT, GRAVITY_COMMENT]);
1210                 } else {
1211                         // Update when it isn't a follow or tag verb
1212                         $update_commented = !in_array($verb, [Activity::FOLLOW, Activity::TAG]);
1213                 }
1214
1215                 if ($update_commented) {
1216                         DBA::update('item', ['commented' => DateTimeFormat::utcNow(), 'changed' => DateTimeFormat::utcNow()], ['id' => $parent_id]);
1217                 } else {
1218                         DBA::update('item', ['changed' => DateTimeFormat::utcNow()], ['id' => $parent_id]);
1219                 }
1220
1221                 if ($item['gravity'] === GRAVITY_PARENT) {
1222                         self::addThread($current_post);
1223                 } else {
1224                         self::updateThread($parent_id);
1225                 }
1226
1227                 // In that function we check if this is a forum post. Additionally we delete the item under certain circumstances
1228                 if (self::tagDeliver($item['uid'], $current_post)) {
1229                         // Get the user information for the logging
1230                         $user = User::getById($uid);
1231
1232                         Logger::notice('Item had been deleted', ['id' => $current_post, 'user' => $uid, 'account-type' => $user['account-type']]);
1233                         return 0;
1234                 }
1235
1236                 if (!$dontcache) {
1237                         $posted_item = Post::selectFirst(self::ITEM_FIELDLIST, ['id' => $current_post]);
1238                         if (DBA::isResult($posted_item)) {
1239                                 if ($notify) {
1240                                         Hook::callAll('post_local_end', $posted_item);
1241                                 } else {
1242                                         Hook::callAll('post_remote_end', $posted_item);
1243                                 }
1244                         } else {
1245                                 Logger::log('new item not found in DB, id ' . $current_post);
1246                         }
1247                 }
1248
1249                 if ($item['gravity'] === GRAVITY_PARENT) {
1250                         self::addShadow($current_post);
1251                 } else {
1252                         self::addShadowPost($current_post);
1253                 }
1254
1255                 self::updateContact($item);
1256
1257                 UserItem::setNotification($current_post);
1258
1259                 check_user_notification($current_post);
1260
1261                 // Distribute items to users who subscribed to their tags
1262                 self::distributeByTags($item);
1263
1264                 // Automatically reshare the item if the "remote_self" option is selected
1265                 self::autoReshare($item);
1266
1267                 $transmit = $notify || ($item['visible'] && ($parent_origin || $item['origin']));
1268
1269                 if ($transmit) {
1270                         $transmit_item = Post::selectFirst(['verb', 'origin'], ['id' => $item['id']]);
1271                         // Don't relay participation messages
1272                         if (($transmit_item['verb'] == Activity::FOLLOW) && 
1273                                 (!$transmit_item['origin'] || ($item['author-id'] != Contact::getPublicIdByUserId($uid)))) {
1274                                 Logger::info('Participation messages will not be relayed', ['item' => $item['id'], 'uri' => $item['uri'], 'verb' => $transmit_item['verb']]);
1275                                 $transmit = false;
1276                         }
1277                 }
1278
1279                 if ($transmit) {
1280                         Worker::add(['priority' => $priority, 'dont_fork' => true], 'Notifier', $notify_type, $current_post);
1281                 }
1282
1283                 return $current_post;
1284         }
1285
1286         /**
1287          * Change the owner of a parent item if it had been shared by a forum
1288          *
1289          * (public) forum posts in the new format consist of the regular post by the author
1290          * followed by an announce message sent from the forum account.
1291          * Changing the owner helps in grouping forum posts.
1292          *
1293          * @param array $item
1294          * @return void
1295          */
1296         private static function setOwnerforResharedItem(array $item)
1297         {
1298                 $parent = Post::selectFirst(['id', 'causer-id', 'owner-id', 'author-id', 'author-link', 'origin', 'post-type'],
1299                         ['uri-id' => $item['thr-parent-id'], 'uid' => $item['uid']]);
1300                 if (!DBA::isResult($parent)) {
1301                         Logger::error('Parent not found', ['uri-id' => $item['thr-parent-id'], 'uid' => $item['uid']]);
1302                         return;
1303                 }
1304
1305                 $author = Contact::selectFirst(['url', 'contact-type'], ['id' => $item['author-id']]);
1306                 if (!DBA::isResult($author)) {
1307                         Logger::error('Author not found', ['id' => $item['author-id']]);
1308                         return;
1309                 }
1310
1311                 $cid = Contact::getIdForURL($author['url'], $item['uid']);
1312                 if (empty($cid) || !Contact::isSharing($cid, $item['uid'])) {
1313                         Logger::info('The resharer is not a following contact: quit', ['resharer' => $author['url'], 'uid' => $item['uid']]);
1314                         return;
1315                 }
1316
1317                 if ($author['contact-type'] != Contact::TYPE_COMMUNITY) {
1318                         if ($parent['post-type'] == self::PT_ANNOUNCEMENT) {
1319                                 Logger::info('The parent is already marked as announced: quit', ['causer' => $parent['causer-id'], 'owner' => $parent['owner-id'], 'author' => $parent['author-id'], 'uid' => $item['uid']]);
1320                                 return;
1321                         }
1322
1323                         if (Contact::isSharing($parent['owner-id'], $item['uid'])) {
1324                                 Logger::info('The resharer is no forum: quit', ['resharer' => $item['author-id'], 'owner' => $parent['owner-id'], 'author' => $parent['author-id'], 'uid' => $item['uid']]);
1325                                 return;
1326                         }
1327                         self::update(['post-type' => self::PT_ANNOUNCEMENT, 'causer-id' => $item['author-id']], ['id' => $parent['id']]);
1328                         Logger::info('Set announcement post-type', ['uri-id' => $item['uri-id'], 'thr-parent-id' => $item['thr-parent-id'], 'uid' => $item['uid']]);
1329                         return;
1330                 }
1331
1332                 self::update(['owner-id' => $item['author-id'], 'contact-id' => $cid], ['id' => $parent['id']]);
1333                 Logger::info('Change owner of the parent', ['uri-id' => $item['uri-id'], 'thr-parent-id' => $item['thr-parent-id'], 'uid' => $item['uid'], 'owner-id' => $item['author-id'], 'contact-id' => $cid]);
1334         }
1335
1336         /**
1337          * Distribute the given item to users who subscribed to their tags
1338          *
1339          * @param array $item     Processed item
1340          */
1341         private static function distributeByTags(array $item)
1342         {
1343                 if (($item['uid'] != 0) || ($item['gravity'] != GRAVITY_PARENT) || !in_array($item['network'], Protocol::FEDERATED)) {
1344                         return;
1345                 }
1346
1347                 $uids = Tag::getUIDListByURIId($item['uri-id']);
1348                 foreach ($uids as $uid) {
1349                         if (Contact::isSharing($item['author-id'], $uid)) {
1350                                 $fields = [];
1351                         } else {
1352                                 $fields = ['post-type' => self::PT_TAG];
1353                         }
1354
1355                         $stored = self::storeForUserByUriId($item['uri-id'], $uid, $fields);
1356                         Logger::info('Stored item for users', ['uri-id' => $item['uri-id'], 'uid' => $uid, 'fields' => $fields, 'stored' => $stored]);
1357                 }
1358         }
1359
1360         /**
1361          * Insert a new item content entry
1362          *
1363          * @param array $item The item fields that are to be inserted
1364          * @return bool "true" if content was inserted or already existed
1365          * @throws \Exception
1366          */
1367         private static function insertContent(array $item)
1368         {
1369                 $fields = ['uri-plink-hash' => (string)$item['uri-id'], 'uri-id' => $item['uri-id']];
1370
1371                 foreach (array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST) as $field) {
1372                         if (isset($item[$field])) {
1373                                 $fields[$field] = $item[$field];
1374                         }
1375                 }
1376
1377                 $found = DBA::exists('item-content', ['uri-id' => $item['uri-id']]);
1378                 if ($found) {
1379                         Logger::info('Existing content found', ['uri-id' => $item['uri-id'], 'uri' => $item['uri']]);
1380                         return true;
1381                 }
1382
1383                 DBA::insert('item-content', $fields, Database::INSERT_IGNORE);
1384
1385                 $found = DBA::exists('item-content', ['uri-id' => $item['uri-id']]);
1386                 if ($found) {
1387                         Logger::notice('Content inserted', ['uri-id' => $item['uri-id'], 'uri' => $item['uri']]);
1388                         return true;
1389                 }
1390
1391                 // This shouldn't happen.
1392                 Logger::error("Content wasn't inserted", $item);
1393                 return false;
1394         }
1395
1396         /**
1397          * Update existing item content entries
1398          *
1399          * @param array $item      The item fields that are to be changed
1400          * @param array $condition The condition for finding the item content entries
1401          * @throws \Exception
1402          */
1403         private static function updateContent($item, $condition)
1404         {
1405                 // We have to select only the fields from the "item-content" table
1406                 $fields = [];
1407                 foreach (array_merge(self::CONTENT_FIELDLIST, self::MIXED_CONTENT_FIELDLIST) as $field) {
1408                         if (isset($item[$field])) {
1409                                 $fields[$field] = $item[$field];
1410                         }
1411                 }
1412
1413                 if (empty($fields)) {
1414                         return;
1415                 }
1416
1417                 DBA::update('item-content', $fields, $condition, true);
1418                 Logger::info('Updated content', ['condition' => $condition]);
1419         }
1420
1421         /**
1422          * Distributes public items to the receivers
1423          *
1424          * @param integer $itemid      Item ID that should be added
1425          * @param string  $signed_text Original text (for Diaspora signatures), JSON encoded.
1426          * @throws \Exception
1427          */
1428         public static function distribute($itemid, $signed_text = '')
1429         {
1430                 $condition = ["`id` IN (SELECT `parent` FROM `post-view` WHERE `id` = ?)", $itemid];
1431                 $parent = Post::selectFirst(['owner-id'], $condition);
1432                 if (!DBA::isResult($parent)) {
1433                         return;
1434                 }
1435
1436                 // Only distribute public items from native networks
1437                 $condition = ['id' => $itemid, 'uid' => 0,
1438                         'network' => array_merge(Protocol::FEDERATED ,['']),
1439                         'visible' => true, 'deleted' => false, 'moderated' => false, 'private' => [self::PUBLIC, self::UNLISTED]];
1440                 $item = Post::selectFirst(self::ITEM_FIELDLIST, $condition);
1441                 if (!DBA::isResult($item)) {
1442                         return;
1443                 }
1444
1445                 $origin = $item['origin'];
1446
1447                 $users = [];
1448
1449                 /// @todo add a field "pcid" in the contact table that referrs to the public contact id.
1450                 $owner = DBA::selectFirst('contact', ['url', 'nurl', 'alias'], ['id' => $parent['owner-id']]);
1451                 if (!DBA::isResult($owner)) {
1452                         return;
1453                 }
1454
1455                 $condition = ['nurl' => $owner['nurl'], 'rel' => [Contact::SHARING, Contact::FRIEND]];
1456                 $contacts = DBA::select('contact', ['uid'], $condition);
1457                 while ($contact = DBA::fetch($contacts)) {
1458                         if ($contact['uid'] == 0) {
1459                                 continue;
1460                         }
1461
1462                         $users[$contact['uid']] = $contact['uid'];
1463                 }
1464                 DBA::close($contacts);
1465
1466                 $condition = ['alias' => $owner['url'], 'rel' => [Contact::SHARING, Contact::FRIEND]];
1467                 $contacts = DBA::select('contact', ['uid'], $condition);
1468                 while ($contact = DBA::fetch($contacts)) {
1469                         if ($contact['uid'] == 0) {
1470                                 continue;
1471                         }
1472
1473                         $users[$contact['uid']] = $contact['uid'];
1474                 }
1475                 DBA::close($contacts);
1476
1477                 if (!empty($owner['alias'])) {
1478                         $condition = ['nurl' => Strings::normaliseLink($owner['alias']), 'rel' => [Contact::SHARING, Contact::FRIEND]];
1479                         $contacts = DBA::select('contact', ['uid'], $condition);
1480                         while ($contact = DBA::fetch($contacts)) {
1481                                 if ($contact['uid'] == 0) {
1482                                         continue;
1483                                 }
1484
1485                                 $users[$contact['uid']] = $contact['uid'];
1486                         }
1487                         DBA::close($contacts);
1488                 }
1489
1490                 $origin_uid = 0;
1491
1492                 if ($item['uri'] != $item['parent-uri']) {
1493                         $parents = Post::select(['uid', 'origin'], ["`uri` = ? AND `uid` != 0", $item['parent-uri']]);
1494                         while ($parent = Post::fetch($parents)) {
1495                                 $users[$parent['uid']] = $parent['uid'];
1496                                 if ($parent['origin'] && !$origin) {
1497                                         $origin_uid = $parent['uid'];
1498                                 }
1499                         }
1500                         DBA::close($parents);
1501                 }
1502
1503                 foreach ($users as $uid) {
1504                         if ($origin_uid == $uid) {
1505                                 $item['diaspora_signed_text'] = $signed_text;
1506                         }
1507                         self::storeForUser($item, $uid);
1508                 }
1509         }
1510
1511         /**
1512          * Store a public item defined by their URI-ID for the given users
1513          *
1514          * @param integer $uri_id URI-ID of the given item
1515          * @param integer $uid    The user that will receive the item entry
1516          * @param array   $fields Additional fields to be stored
1517          * @return integer stored item id
1518          */
1519         public static function storeForUserByUriId(int $uri_id, int $uid, array $fields = [])
1520         {
1521                 $item = Post::selectFirst(self::ITEM_FIELDLIST, ['uri-id' => $uri_id, 'uid' => 0]);
1522                 if (!DBA::isResult($item)) {
1523                         return 0;
1524                 }
1525
1526                 if (($item['private'] == self::PRIVATE) || !in_array($item['network'], Protocol::FEDERATED)) {
1527                         Logger::notice('Item is private or not from a federated network. It will not be stored for the user.', ['uri-id' => $uri_id, 'uid' => $uid, 'private' => $item['private'], 'network' => $item['network']]);
1528                         return 0;
1529                 }
1530
1531                 $item['post-type'] = self::PT_STORED;
1532
1533                 $item = array_merge($item, $fields);
1534
1535                 $stored = self::storeForUser($item, $uid);
1536                 Logger::info('Public item stored for user', ['uri-id' => $item['uri-id'], 'uid' => $uid, 'stored' => $stored]);
1537                 return $stored;
1538         }
1539
1540         /**
1541          * Store a public item array for the given users
1542          *
1543          * @param array   $item   The item entry that will be stored
1544          * @param integer $uid    The user that will receive the item entry
1545          * @return integer stored item id
1546          * @throws \Exception
1547          */
1548         private static function storeForUser(array $item, int $uid)
1549         {
1550                 if (Post::exists(['uri-id' => $item['uri-id'], 'uid' => $uid])) {
1551                         Logger::info('Item already exists', ['uri-id' => $item['uri-id'], 'uid' => $uid]);
1552                         return 0;
1553                 }
1554
1555                 unset($item['id']);
1556                 unset($item['parent']);
1557                 unset($item['mention']);
1558                 unset($item['starred']);
1559                 unset($item['unseen']);
1560                 unset($item['psid']);
1561
1562                 $item['uid'] = $uid;
1563                 $item['origin'] = 0;
1564                 $item['wall'] = 0;
1565
1566                 if ($item['gravity'] == GRAVITY_PARENT) {
1567                         $contact = Contact::getByURLForUser($item['owner-link'], $uid, false, ['id']);
1568                 } else {
1569                         $contact = Contact::getByURLForUser($item['author-link'], $uid, false, ['id']);
1570                 }
1571
1572                 if (!empty($contact['id'])) {
1573                         $item['contact-id'] = $contact['id'];
1574                 } else {
1575                         // Shouldn't happen at all
1576                         Logger::warning('contact-id could not be fetched', ['uid' => $uid, 'item' => $item]);
1577                         $self = DBA::selectFirst('contact', ['id'], ['self' => true, 'uid' => $uid]);
1578                         if (!DBA::isResult($self)) {
1579                                 // Shouldn't happen even less
1580                                 Logger::warning('self contact could not be fetched', ['uid' => $uid, 'item' => $item]);
1581                                 return 0;
1582                         }
1583                         $item['contact-id'] = $self['id'];
1584                 }
1585
1586                 /// @todo Handling of "event-id"
1587
1588                 $notify = false;
1589                 if ($item['gravity'] == GRAVITY_PARENT) {
1590                         $contact = DBA::selectFirst('contact', [], ['id' => $item['contact-id'], 'self' => false]);
1591                         if (DBA::isResult($contact)) {
1592                                 $notify = self::isRemoteSelf($contact, $item);
1593                         }
1594                 }
1595
1596                 $distributed = self::insert($item, $notify, true);
1597
1598                 if (!$distributed) {
1599                         Logger::info("Distributed public item wasn't stored", ['uri-id' => $item['uri-id'], 'user' => $uid]);
1600                 } else {
1601                         Logger::info('Distributed public item was stored', ['uri-id' => $item['uri-id'], 'user' => $uid, 'stored' => $distributed]);
1602                 }
1603                 return $distributed;
1604         }
1605
1606         /**
1607          * Add a shadow entry for a given item id that is a thread starter
1608          *
1609          * We store every public item entry additionally with the user id "0".
1610          * This is used for the community page and for the search.
1611          * It is planned that in the future we will store public item entries only once.
1612          *
1613          * @param integer $itemid Item ID that should be added
1614          * @throws \Exception
1615          */
1616         private static function addShadow($itemid)
1617         {
1618                 $fields = ['uid', 'private', 'moderated', 'visible', 'deleted', 'network', 'uri'];
1619                 $condition = ['id' => $itemid, 'parent' => [0, $itemid]];
1620                 $item = Post::selectFirst($fields, $condition);
1621
1622                 if (!DBA::isResult($item)) {
1623                         return;
1624                 }
1625
1626                 // is it already a copy?
1627                 if (($itemid == 0) || ($item['uid'] == 0)) {
1628                         return;
1629                 }
1630
1631                 // Is it a visible public post?
1632                 if (!$item["visible"] || $item["deleted"] || $item["moderated"] || ($item["private"] == self::PRIVATE)) {
1633                         return;
1634                 }
1635
1636                 // is it an entry from a connector? Only add an entry for natively connected networks
1637                 if (!in_array($item["network"], array_merge(Protocol::FEDERATED ,['']))) {
1638                         return;
1639                 }
1640
1641                 if (Post::exists(['uri' => $item['uri'], 'uid' => 0])) {
1642                         return;
1643                 }
1644
1645                 $item = Post::selectFirst(self::ITEM_FIELDLIST, ['id' => $itemid]);
1646
1647                 if (DBA::isResult($item)) {
1648                         // Preparing public shadow (removing user specific data)
1649                         $item['uid'] = 0;
1650                         unset($item['id']);
1651                         unset($item['parent']);
1652                         unset($item['wall']);
1653                         unset($item['mention']);
1654                         unset($item['origin']);
1655                         unset($item['starred']);
1656                         unset($item['postopts']);
1657                         unset($item['inform']);
1658                         unset($item['post-type']);
1659                         if ($item['uri'] == $item['parent-uri']) {
1660                                 $item['contact-id'] = $item['owner-id'];
1661                         } else {
1662                                 $item['contact-id'] = $item['author-id'];
1663                         }
1664
1665                         $public_shadow = self::insert($item, false, true);
1666
1667                         Logger::info('Stored public shadow', ['thread' => $itemid, 'id' => $public_shadow]);
1668                 }
1669         }
1670
1671         /**
1672          * Add a shadow entry for a given item id that is a comment
1673          *
1674          * This function does the same like the function above - but for comments
1675          *
1676          * @param integer $itemid Item ID that should be added
1677          * @throws \Exception
1678          */
1679         private static function addShadowPost($itemid)
1680         {
1681                 $item = Post::selectFirst(self::ITEM_FIELDLIST, ['id' => $itemid]);
1682                 if (!DBA::isResult($item)) {
1683                         return;
1684                 }
1685
1686                 // Is it a toplevel post?
1687                 if ($item['gravity'] == GRAVITY_PARENT) {
1688                         self::addShadow($itemid);
1689                         return;
1690                 }
1691
1692                 // Is this a shadow entry?
1693                 if ($item['uid'] == 0) {
1694                         return;
1695                 }
1696
1697                 // Is there a shadow parent?
1698                 if (!Post::exists(['uri' => $item['parent-uri'], 'uid' => 0])) {
1699                         return;
1700                 }
1701
1702                 // Is there already a shadow entry?
1703                 if (Post::exists(['uri' => $item['uri'], 'uid' => 0])) {
1704                         return;
1705                 }
1706
1707                 // Save "origin" and "parent" state
1708                 $origin = $item['origin'];
1709                 $parent = $item['parent'];
1710
1711                 // Preparing public shadow (removing user specific data)
1712                 $item['uid'] = 0;
1713                 unset($item['id']);
1714                 unset($item['parent']);
1715                 unset($item['wall']);
1716                 unset($item['mention']);
1717                 unset($item['origin']);
1718                 unset($item['starred']);
1719                 unset($item['postopts']);
1720                 unset($item['inform']);
1721                 unset($item['post-type']);
1722                 $item['contact-id'] = Contact::getIdForURL($item['author-link']);
1723
1724                 $public_shadow = self::insert($item, false, true);
1725
1726                 Logger::info('Stored public shadow', ['uri' => $item['uri'], 'id' => $public_shadow]);
1727
1728                 // If this was a comment to a Diaspora post we don't get our comment back.
1729                 // This means that we have to distribute the comment by ourselves.
1730                 if ($origin && Post::exists(['id' => $parent, 'network' => Protocol::DIASPORA])) {
1731                         self::distribute($public_shadow);
1732                 }
1733         }
1734
1735         /**
1736          * Adds a language specification in a "language" element of given $arr.
1737          * Expects "body" element to exist in $arr.
1738          *
1739          * @param array $item
1740          * @return string detected language
1741          * @throws \Text_LanguageDetect_Exception
1742          */
1743         private static function getLanguage(array $item)
1744         {
1745                 if (!in_array($item['gravity'], [GRAVITY_PARENT, GRAVITY_COMMENT]) || empty($item['body'])) {
1746                         return '';
1747                 }
1748
1749                 // Convert attachments to links
1750                 $naked_body = BBCode::removeAttachment($item['body']);
1751                 if (empty($naked_body)) {
1752                         return '';
1753                 }
1754
1755                 // Remove links and pictures
1756                 $naked_body = BBCode::removeLinks($naked_body);
1757
1758                 // Convert the title and the body to plain text
1759                 $naked_body = trim($item['title'] . "\n" . BBCode::toPlaintext($naked_body));
1760
1761                 // Remove possibly remaining links
1762                 $naked_body = preg_replace(Strings::autoLinkRegEx(), '', $naked_body);
1763
1764                 if (empty($naked_body)) {
1765                         return '';
1766                 }
1767
1768                 $ld = new Language(DI::l10n()->getAvailableLanguages());
1769                 $languages = $ld->detect($naked_body)->limit(0, 3)->close();
1770                 if (is_array($languages)) {
1771                         return json_encode($languages);
1772                 }
1773
1774                 return '';
1775         }
1776
1777         public static function getLanguageMessage(array $item)
1778         {
1779                 $iso639 = new \Matriphe\ISO639\ISO639;
1780
1781                 $used_languages = '';
1782                 foreach (json_decode($item['language'], true) as $language => $reliability) {
1783                         $used_languages .= $iso639->languageByCode1($language) . ' (' . $language . "): " . number_format($reliability, 5) . '\n';
1784                 }
1785                 $used_languages = DI::l10n()->t('Detected languages in this post:\n%s', $used_languages);
1786                 return $used_languages;
1787         }
1788
1789         /**
1790          * Creates an unique guid out of a given uri
1791          *
1792          * @param string $uri uri of an item entry
1793          * @param string $host hostname for the GUID prefix
1794          * @return string unique guid
1795          */
1796         public static function guidFromUri($uri, $host)
1797         {
1798                 // Our regular guid routine is using this kind of prefix as well
1799                 // We have to avoid that different routines could accidentally create the same value
1800                 $parsed = parse_url($uri);
1801
1802                 // We use a hash of the hostname as prefix for the guid
1803                 $guid_prefix = hash("crc32", $host);
1804
1805                 // Remove the scheme to make sure that "https" and "http" doesn't make a difference
1806                 unset($parsed["scheme"]);
1807
1808                 // Glue it together to be able to make a hash from it
1809                 $host_id = implode("/", $parsed);
1810
1811                 // We could use any hash algorithm since it isn't a security issue
1812                 $host_hash = hash("ripemd128", $host_id);
1813
1814                 return $guid_prefix.$host_hash;
1815         }
1816
1817         /**
1818          * generate an unique URI
1819          *
1820          * @param integer $uid  User id
1821          * @param string  $guid An existing GUID (Otherwise it will be generated)
1822          *
1823          * @return string
1824          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1825          */
1826         public static function newURI($uid, $guid = "")
1827         {
1828                 if ($guid == "") {
1829                         $guid = System::createUUID();
1830                 }
1831
1832                 return DI::baseUrl()->get() . '/objects/' . $guid;
1833         }
1834
1835         /**
1836          * Set "success_update" and "last-item" to the date of the last time we heard from this contact
1837          *
1838          * This can be used to filter for inactive contacts.
1839          * Only do this for public postings to avoid privacy problems, since poco data is public.
1840          * Don't set this value if it isn't from the owner (could be an author that we don't know)
1841          *
1842          * @param array $arr Contains the just posted item record
1843          * @throws \Exception
1844          */
1845         private static function updateContact($arr)
1846         {
1847                 // Unarchive the author
1848                 $contact = DBA::selectFirst('contact', [], ['id' => $arr["author-id"]]);
1849                 if (DBA::isResult($contact)) {
1850                         Contact::unmarkForArchival($contact);
1851                 }
1852
1853                 // Unarchive the contact if it's not our own contact
1854                 $contact = DBA::selectFirst('contact', [], ['id' => $arr["contact-id"], 'self' => false]);
1855                 if (DBA::isResult($contact)) {
1856                         Contact::unmarkForArchival($contact);
1857                 }
1858
1859                 /// @todo On private posts we could obfuscate the date
1860                 $update = ($arr['private'] != self::PRIVATE) || in_array($arr['network'], Protocol::FEDERATED);
1861
1862                 // Is it a forum? Then we don't care about the rules from above
1863                 if (!$update && in_array($arr["network"], [Protocol::ACTIVITYPUB, Protocol::DFRN]) && ($arr["parent-uri"] === $arr["uri"])) {
1864                         if (DBA::exists('contact', ['id' => $arr['contact-id'], 'forum' => true])) {
1865                                 $update = true;
1866                         }
1867                 }
1868
1869                 if ($update) {
1870                         // The "self" contact id is used (for example in the connectors) when the contact is unknown
1871                         // So we have to ensure to only update the last item when it had been our own post,
1872                         // or it had been done by a "regular" contact.
1873                         if (!empty($arr['wall'])) {
1874                                 $condition = ['id' => $arr['contact-id']];
1875                         } else { 
1876                                 $condition = ['id' => $arr['contact-id'], 'self' => false];
1877                         }
1878                         DBA::update('contact', ['failed' => false, 'success_update' => $arr['received'], 'last-item' => $arr['received']], $condition);
1879                 }
1880                 // Now do the same for the system wide contacts with uid=0
1881                 if ($arr['private'] != self::PRIVATE) {
1882                         DBA::update('contact', ['failed' => false, 'success_update' => $arr['received'], 'last-item' => $arr['received']],
1883                                 ['id' => $arr['owner-id']]);
1884
1885                         if ($arr['owner-id'] != $arr['author-id']) {
1886                                 DBA::update('contact', ['failed' => false, 'success_update' => $arr['received'], 'last-item' => $arr['received']],
1887                                         ['id' => $arr['author-id']]);
1888                         }
1889                 }
1890         }
1891
1892         public static function setHashtags($body)
1893         {
1894                 $body = BBCode::performWithEscapedTags($body, ['noparse', 'pre', 'code'], function ($body) {
1895                         $tags = BBCode::getTags($body);
1896
1897                         // No hashtags?
1898                         if (!count($tags)) {
1899                                 return $body;
1900                         }
1901
1902                         // This sorting is important when there are hashtags that are part of other hashtags
1903                         // Otherwise there could be problems with hashtags like #test and #test2
1904                         // Because of this we are sorting from the longest to the shortest tag.
1905                         usort($tags, function ($a, $b) {
1906                                 return strlen($b) <=> strlen($a);
1907                         });
1908
1909                         $URLSearchString = "^\[\]";
1910
1911                         // All hashtags should point to the home server if "local_tags" is activated
1912                         if (DI::config()->get('system', 'local_tags')) {
1913                                 $body = preg_replace("/#\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
1914                                         "#[url=" . DI::baseUrl() . "/search?tag=$2]$2[/url]", $body);
1915                         }
1916
1917                         // mask hashtags inside of url, bookmarks and attachments to avoid urls in urls
1918                         $body = preg_replace_callback("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
1919                                 function ($match) {
1920                                         return ("[url=" . str_replace("#", "&num;", $match[1]) . "]" . str_replace("#", "&num;", $match[2]) . "[/url]");
1921                                 }, $body);
1922
1923                         $body = preg_replace_callback("/\[bookmark\=([$URLSearchString]*)\](.*?)\[\/bookmark\]/ism",
1924                                 function ($match) {
1925                                         return ("[bookmark=" . str_replace("#", "&num;", $match[1]) . "]" . str_replace("#", "&num;", $match[2]) . "[/bookmark]");
1926                                 }, $body);
1927
1928                         $body = preg_replace_callback("/\[attachment (.*)\](.*?)\[\/attachment\]/ism",
1929                                 function ($match) {
1930                                         return ("[attachment " . str_replace("#", "&num;", $match[1]) . "]" . $match[2] . "[/attachment]");
1931                                 }, $body);
1932
1933                         // Repair recursive urls
1934                         $body = preg_replace("/&num;\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",
1935                                 "&num;$2", $body);
1936
1937                         foreach ($tags as $tag) {
1938                                 if ((strpos($tag, '#') !== 0) || strpos($tag, '[url=') || strlen($tag) < 2 || $tag[1] == '#') {
1939                                         continue;
1940                                 }
1941
1942                                 $basetag = str_replace('_', ' ', substr($tag, 1));
1943                                 $newtag = '#[url=' . DI::baseUrl() . '/search?tag=' . $basetag . ']' . $basetag . '[/url]';
1944
1945                                 $body = str_replace($tag, $newtag, $body);
1946                         }
1947
1948                         // Convert back the masked hashtags
1949                         $body = str_replace("&num;", "#", $body);
1950
1951                         return $body;
1952                 });
1953
1954                 return $body;
1955         }
1956
1957         /**
1958          * look for mention tags and setup a second delivery chain for forum/community posts if appropriate
1959          *
1960          * @param int $uid
1961          * @param int $item_id
1962          * @return boolean true if item was deleted, else false
1963          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1964          * @throws \ImagickException
1965          */
1966         private static function tagDeliver($uid, $item_id)
1967         {
1968                 $mention = false;
1969
1970                 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1971                 if (!DBA::isResult($user)) {
1972                         return false;
1973                 }
1974
1975                 $community_page = (($user['page-flags'] == User::PAGE_FLAGS_COMMUNITY) ? true : false);
1976                 $prvgroup = (($user['page-flags'] == User::PAGE_FLAGS_PRVGROUP) ? true : false);
1977
1978                 $item = Post::selectFirst(self::ITEM_FIELDLIST, ['id' => $item_id]);
1979                 if (!DBA::isResult($item)) {
1980                         return false;
1981                 }
1982
1983                 $link = Strings::normaliseLink(DI::baseUrl() . '/profile/' . $user['nickname']);
1984
1985                 /*
1986                  * Diaspora uses their own hardwired link URL in @-tags
1987                  * instead of the one we supply with webfinger
1988                  */
1989                 $dlink = Strings::normaliseLink(DI::baseUrl() . '/u/' . $user['nickname']);
1990
1991                 $cnt = preg_match_all('/[\@\!]\[url\=(.*?)\](.*?)\[\/url\]/ism', $item['body'], $matches, PREG_SET_ORDER);
1992                 if ($cnt) {
1993                         foreach ($matches as $mtch) {
1994                                 if (Strings::compareLink($link, $mtch[1]) || Strings::compareLink($dlink, $mtch[1])) {
1995                                         $mention = true;
1996                                         Logger::log('mention found: ' . $mtch[2]);
1997                                 }
1998                         }
1999                 }
2000
2001                 if (!$mention) {
2002                         $tags = Tag::getByURIId($item['uri-id'], [Tag::MENTION, Tag::EXCLUSIVE_MENTION]);
2003                         foreach ($tags as $tag) {
2004                                 if (Strings::compareLink($link, $tag['url']) || Strings::compareLink($dlink, $tag['url'])) {
2005                                         $mention = true;
2006                                         DI::logger()->info('mention found in tag.', ['url' => $tag['url']]);
2007                                 }
2008                         }
2009                 }
2010                 
2011                 if (!$mention) {
2012                         if (($community_page || $prvgroup) &&
2013                                   !$item['wall'] && !$item['origin'] && ($item['gravity'] == GRAVITY_PARENT)) {
2014                                 Logger::info('Delete private group/communiy top-level item without mention', ['id' => $item_id, 'guid'=> $item['guid']]);
2015                                 DBA::delete('item', ['id' => $item_id]);
2016                                 return true;
2017                         }
2018                         return false;
2019                 }
2020
2021                 $arr = ['item' => $item, 'user' => $user];
2022
2023                 Hook::callAll('tagged', $arr);
2024
2025                 if (!$community_page && !$prvgroup) {
2026                         return false;
2027                 }
2028
2029                 /*
2030                  * tgroup delivery - setup a second delivery chain
2031                  * prevent delivery looping - only proceed
2032                  * if the message originated elsewhere and is a top-level post
2033                  */
2034                 if ($item['wall'] || $item['origin'] || ($item['id'] != $item['parent'])) {
2035                         return false;
2036                 }
2037
2038                 // now change this copy of the post to a forum head message and deliver to all the tgroup members
2039                 $self = DBA::selectFirst('contact', ['id', 'name', 'url', 'thumb'], ['uid' => $uid, 'self' => true]);
2040                 if (!DBA::isResult($self)) {
2041                         return false;
2042                 }
2043
2044                 $owner_id = Contact::getIdForURL($self['url']);
2045
2046                 // also reset all the privacy bits to the forum default permissions
2047
2048                 $private = ($user['allow_cid'] || $user['allow_gid'] || $user['deny_cid'] || $user['deny_gid']) ? self::PRIVATE : self::PUBLIC;
2049
2050                 $psid = PermissionSet::getIdFromACL(
2051                         $user['uid'],
2052                         $user['allow_cid'],
2053                         $user['allow_gid'],
2054                         $user['deny_cid'],
2055                         $user['deny_gid']
2056                 );
2057
2058                 $forum_mode = ($prvgroup ? 2 : 1);
2059
2060                 $fields = ['wall' => true, 'origin' => true, 'forum_mode' => $forum_mode, 'contact-id' => $self['id'],
2061                         'owner-id' => $owner_id, 'private' => $private, 'psid' => $psid];
2062                 self::update($fields, ['id' => $item_id]);
2063
2064                 Worker::add(['priority' => PRIORITY_HIGH, 'dont_fork' => true], 'Notifier', Delivery::POST, $item_id);
2065
2066                 self::performActivity($item_id, 'announce', $uid);
2067
2068                 return false;
2069         }
2070
2071         /**
2072          * Automatically reshare the item if the "remote_self" option is selected
2073          *
2074          * @param array $item
2075          * @return void
2076          */
2077         private static function autoReshare(array $item)
2078         {
2079                 if ($item['gravity'] != GRAVITY_PARENT) {
2080                         return;
2081                 }
2082
2083                 if (!DBA::exists('contact', ['id' => $item['contact-id'], 'remote_self' => Contact::MIRROR_NATIVE_RESHARE])) {
2084                         return;
2085                 }
2086
2087                 if (!in_array($item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN])) {
2088                         return;
2089                 }
2090
2091                 Logger::info('Automatically reshare item', ['uid' => $item['uid'], 'id' => $item['id'], 'guid' => $item['guid'], 'uri-id' => $item['uri-id']]);
2092
2093                 self::performActivity($item['id'], 'announce', $item['uid']);
2094         }
2095
2096         public static function isRemoteSelf($contact, &$datarray)
2097         {
2098                 if (!$contact['remote_self']) {
2099                         return false;
2100                 }
2101
2102                 // Prevent the forwarding of posts that are forwarded
2103                 if (!empty($datarray["extid"]) && ($datarray["extid"] == Protocol::DFRN)) {
2104                         Logger::info('Already forwarded');
2105                         return false;
2106                 }
2107
2108                 // Prevent to forward already forwarded posts
2109                 if ($datarray["app"] == DI::baseUrl()->getHostname()) {
2110                         Logger::info('Already forwarded (second test)');
2111                         return false;
2112                 }
2113
2114                 // Only forward posts
2115                 if ($datarray["verb"] != Activity::POST) {
2116                         Logger::info('No post');
2117                         return false;
2118                 }
2119
2120                 if (($contact['network'] != Protocol::FEED) && ($datarray['private'] == self::PRIVATE)) {
2121                         Logger::info('Not public');
2122                         return false;
2123                 }
2124
2125                 $datarray2 = $datarray;
2126                 Logger::info('remote-self start', ['contact' => $contact['url'], 'remote_self'=> $contact['remote_self'], 'item' => $datarray]);
2127                 if ($contact['remote_self'] == Contact::MIRROR_OWN_POST) {
2128                         $self = DBA::selectFirst('contact', ['id', 'name', 'url', 'thumb'],
2129                                         ['uid' => $contact['uid'], 'self' => true]);
2130                         if (DBA::isResult($self)) {
2131                                 $datarray['contact-id'] = $self["id"];
2132
2133                                 $datarray['owner-name'] = $self["name"];
2134                                 $datarray['owner-link'] = $self["url"];
2135                                 $datarray['owner-avatar'] = $self["thumb"];
2136
2137                                 $datarray['author-name']   = $datarray['owner-name'];
2138                                 $datarray['author-link']   = $datarray['owner-link'];
2139                                 $datarray['author-avatar'] = $datarray['owner-avatar'];
2140
2141                                 unset($datarray['edited']);
2142
2143                                 unset($datarray['network']);
2144                                 unset($datarray['owner-id']);
2145                                 unset($datarray['author-id']);
2146                         }
2147
2148                         if ($contact['network'] != Protocol::FEED) {
2149                                 $old_uri_id = $datarray["uri-id"] ?? 0;
2150                                 $datarray["guid"] = System::createUUID();
2151                                 unset($datarray["plink"]);
2152                                 $datarray["uri"] = self::newURI($contact['uid'], $datarray["guid"]);
2153                                 $datarray["uri-id"] = ItemURI::getIdByURI($datarray["uri"]);
2154                                 $datarray["extid"] = Protocol::DFRN;
2155                                 $urlpart = parse_url($datarray2['author-link']);
2156                                 $datarray["app"] = $urlpart["host"];
2157                                 if (!empty($old_uri_id)) {
2158                                         Post\Media::copy($old_uri_id, $datarray["uri-id"]);
2159                                 }
2160
2161                                 unset($datarray["parent-uri"]);
2162                                 unset($datarray["thr-parent"]);
2163                         } else {
2164                                 $datarray['private'] = self::PUBLIC;
2165                         }
2166                 }
2167
2168                 if ($contact['network'] != Protocol::FEED) {
2169                         // Store the original post
2170                         $result = self::insert($datarray2);
2171                         Logger::info('remote-self post original item', ['contact' => $contact['url'], 'result'=> $result, 'item' => $datarray2]);
2172                 } else {
2173                         $datarray["app"] = "Feed";
2174                         $result = true;
2175                 }
2176
2177                 // Trigger automatic reactions for addons
2178                 $datarray['api_source'] = true;
2179
2180                 // We have to tell the hooks who we are - this really should be improved
2181                 $_SESSION['authenticated'] = true;
2182                 $_SESSION['uid'] = $contact['uid'];
2183
2184                 return (bool)$result;
2185         }
2186
2187         /**
2188          *
2189          * @param string $s
2190          * @param int    $uid
2191          * @param array  $item
2192          * @param int    $cid
2193          * @return string
2194          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
2195          * @throws \ImagickException
2196          */
2197         public static function fixPrivatePhotos($s, $uid, $item = null, $cid = 0)
2198         {
2199                 if (DI::config()->get('system', 'disable_embedded')) {
2200                         return $s;
2201                 }
2202
2203                 Logger::info('check for photos');
2204                 $site = substr(DI::baseUrl(), strpos(DI::baseUrl(), '://'));
2205
2206                 $orig_body = $s;
2207                 $new_body = '';
2208
2209                 $img_start = strpos($orig_body, '[img');
2210                 $img_st_close = ($img_start !== false ? strpos(substr($orig_body, $img_start), ']') : false);
2211                 $img_len = ($img_start !== false ? strpos(substr($orig_body, $img_start + $img_st_close + 1), '[/img]') : false);
2212
2213                 while (($img_st_close !== false) && ($img_len !== false)) {
2214                         $img_st_close++; // make it point to AFTER the closing bracket
2215                         $image = substr($orig_body, $img_start + $img_st_close, $img_len);
2216
2217                         Logger::info('found photo', ['image' => $image]);
2218
2219                         if (stristr($image, $site . '/photo/')) {
2220                                 // Only embed locally hosted photos
2221                                 $replace = false;
2222                                 $i = basename($image);
2223                                 $i = str_replace(['.jpg', '.png', '.gif'], ['', '', ''], $i);
2224                                 $x = strpos($i, '-');
2225
2226                                 if ($x) {
2227                                         $res = substr($i, $x + 1);
2228                                         $i = substr($i, 0, $x);
2229                                         $photo = Photo::getPhotoForUser($uid, $i, $res);
2230                                         if (DBA::isResult($photo)) {
2231                                                 /*
2232                                                  * Check to see if we should replace this photo link with an embedded image
2233                                                  * 1. No need to do so if the photo is public
2234                                                  * 2. If there's a contact-id provided, see if they're in the access list
2235                                                  *    for the photo. If so, embed it.
2236                                                  * 3. Otherwise, if we have an item, see if the item permissions match the photo
2237                                                  *    permissions, regardless of order but first check to see if they're an exact
2238                                                  *    match to save some processing overhead.
2239                                                  */
2240                                                 if (self::hasPermissions($photo)) {
2241                                                         if ($cid) {
2242                                                                 $recips = self::enumeratePermissions($photo);
2243                                                                 if (in_array($cid, $recips)) {
2244                                                                         $replace = true;
2245                                                                 }
2246                                                         } elseif ($item) {
2247                                                                 if (self::samePermissions($uid, $item, $photo)) {
2248                                                                         $replace = true;
2249                                                                 }
2250                                                         }
2251                                                 }
2252                                                 if ($replace) {
2253                                                         $photo_img = Photo::getImageForPhoto($photo);
2254                                                         // If a custom width and height were specified, apply before embedding
2255                                                         if (preg_match("/\[img\=([0-9]*)x([0-9]*)\]/is", substr($orig_body, $img_start, $img_st_close), $match)) {
2256                                                                 Logger::info('scaling photo');
2257
2258                                                                 $width = intval($match[1]);
2259                                                                 $height = intval($match[2]);
2260
2261                                                                 $photo_img->scaleDown(max($width, $height));
2262                                                         }
2263
2264                                                         $data = $photo_img->asString();
2265                                                         $type = $photo_img->getType();
2266
2267                                                         Logger::info('replacing photo');
2268                                                         $image = 'data:' . $type . ';base64,' . base64_encode($data);
2269                                                         Logger::debug('replaced', ['image' => $image]);
2270                                                 }
2271                                         }
2272                                 }
2273                         }
2274
2275                         $new_body = $new_body . substr($orig_body, 0, $img_start + $img_st_close) . $image . '[/img]';
2276                         $orig_body = substr($orig_body, $img_start + $img_st_close + $img_len + strlen('[/img]'));
2277                         if ($orig_body === false) {
2278                                 $orig_body = '';
2279                         }
2280
2281                         $img_start = strpos($orig_body, '[img');
2282                         $img_st_close = ($img_start !== false ? strpos(substr($orig_body, $img_start), ']') : false);
2283                         $img_len = ($img_start !== false ? strpos(substr($orig_body, $img_start + $img_st_close + 1), '[/img]') : false);
2284                 }
2285
2286                 $new_body = $new_body . $orig_body;
2287
2288                 return $new_body;
2289         }
2290
2291         private static function hasPermissions($obj)
2292         {
2293                 return !empty($obj['allow_cid']) || !empty($obj['allow_gid']) ||
2294                         !empty($obj['deny_cid']) || !empty($obj['deny_gid']);
2295         }
2296
2297         private static function samePermissions($uid, $obj1, $obj2)
2298         {
2299                 // first part is easy. Check that these are exactly the same.
2300                 if (($obj1['allow_cid'] == $obj2['allow_cid'])
2301                         && ($obj1['allow_gid'] == $obj2['allow_gid'])
2302                         && ($obj1['deny_cid'] == $obj2['deny_cid'])
2303                         && ($obj1['deny_gid'] == $obj2['deny_gid'])) {
2304                         return true;
2305                 }
2306
2307                 // This is harder. Parse all the permissions and compare the resulting set.
2308                 $recipients1 = self::enumeratePermissions($obj1);
2309                 $recipients2 = self::enumeratePermissions($obj2);
2310                 sort($recipients1);
2311                 sort($recipients2);
2312
2313                 /// @TODO Comparison of arrays, maybe use array_diff_assoc() here?
2314                 return ($recipients1 == $recipients2);
2315         }
2316
2317         /**
2318          * Returns an array of contact-ids that are allowed to see this object
2319          *
2320          * @param array $obj        Item array with at least uid, allow_cid, allow_gid, deny_cid and deny_gid
2321          * @param bool  $check_dead Prunes unavailable contacts from the result
2322          * @return array
2323          * @throws \Exception
2324          */
2325         public static function enumeratePermissions(array $obj, bool $check_dead = false)
2326         {
2327                 $aclFormater = DI::aclFormatter();
2328
2329                 $allow_people = $aclFormater->expand($obj['allow_cid']);
2330                 $allow_groups = Group::expand($obj['uid'], $aclFormater->expand($obj['allow_gid']), $check_dead);
2331                 $deny_people  = $aclFormater->expand($obj['deny_cid']);
2332                 $deny_groups  = Group::expand($obj['uid'], $aclFormater->expand($obj['deny_gid']), $check_dead);
2333                 $recipients   = array_unique(array_merge($allow_people, $allow_groups));
2334                 $deny         = array_unique(array_merge($deny_people, $deny_groups));
2335                 $recipients   = array_diff($recipients, $deny);
2336                 return $recipients;
2337         }
2338
2339         public static function expire(int $uid, int $days, string $network = "", bool $force = false)
2340         {
2341                 if (!$uid || ($days < 1)) {
2342                         return;
2343                 }
2344
2345                 $condition = ["`uid` = ? AND NOT `deleted` AND `gravity` = ?",
2346                         $uid, GRAVITY_PARENT];
2347
2348                 /*
2349                  * $expire_network_only = save your own wall posts
2350                  * and just expire conversations started by others
2351                  */
2352                 $expire_network_only = DI::pConfig()->get($uid, 'expire', 'network_only', false);
2353
2354                 if ($expire_network_only) {
2355                         $condition[0] .= " AND NOT `wall`";
2356                 }
2357
2358                 if ($network != "") {
2359                         $condition[0] .= " AND `network` = ?";
2360                         $condition[] = $network;
2361                 }
2362
2363                 $condition[0] .= " AND `received` < UTC_TIMESTAMP() - INTERVAL ? DAY";
2364                 $condition[] = $days;
2365
2366                 $items = Post::select(['file', 'resource-id', 'starred', 'type', 'id', 'post-type'], $condition);
2367
2368                 if (!DBA::isResult($items)) {
2369                         return;
2370                 }
2371
2372                 $expire_items = DI::pConfig()->get($uid, 'expire', 'items', true);
2373
2374                 // Forcing expiring of items - but not notes and marked items
2375                 if ($force) {
2376                         $expire_items = true;
2377                 }
2378
2379                 $expire_notes = DI::pConfig()->get($uid, 'expire', 'notes', true);
2380                 $expire_starred = DI::pConfig()->get($uid, 'expire', 'starred', true);
2381                 $expire_photos = DI::pConfig()->get($uid, 'expire', 'photos', false);
2382
2383                 $expired = 0;
2384
2385                 $priority = DI::config()->get('system', 'expire-notify-priority');
2386
2387                 while ($item = Post::fetch($items)) {
2388                         // don't expire filed items
2389
2390                         if (strpos($item['file'], '[') !== false) {
2391                                 continue;
2392                         }
2393
2394                         // Only expire posts, not photos and photo comments
2395
2396                         if (!$expire_photos && strlen($item['resource-id'])) {
2397                                 continue;
2398                         } elseif (!$expire_starred && intval($item['starred'])) {
2399                                 continue;
2400                         } elseif (!$expire_notes && (($item['type'] == 'note') || ($item['post-type'] == self::PT_PERSONAL_NOTE))) {
2401                                 continue;
2402                         } elseif (!$expire_items && ($item['type'] != 'note') && ($item['post-type'] != self::PT_PERSONAL_NOTE)) {
2403                                 continue;
2404                         }
2405
2406                         self::markForDeletionById($item['id'], $priority);
2407
2408                         ++$expired;
2409                 }
2410                 DBA::close($items);
2411                 Logger::log('User ' . $uid . ": expired $expired items; expire items: $expire_items, expire notes: $expire_notes, expire starred: $expire_starred, expire photos: $expire_photos");
2412         }
2413
2414         public static function firstPostDate($uid, $wall = false)
2415         {
2416                 $condition = ['uid' => $uid, 'wall' => $wall, 'deleted' => false, 'visible' => true, 'moderated' => false];
2417                 $params = ['order' => ['received' => false]];
2418                 $thread = DBA::selectFirst('thread', ['received'], $condition, $params);
2419                 if (DBA::isResult($thread)) {
2420                         return substr(DateTimeFormat::local($thread['received']), 0, 10);
2421                 }
2422                 return false;
2423         }
2424
2425         /**
2426          * add/remove activity to an item
2427          *
2428          * Toggle activities as like,dislike,attend of an item
2429          *
2430          * @param int $item_id
2431          * @param string $verb
2432          *            Activity verb. One of
2433          *            like, unlike, dislike, undislike, attendyes, unattendyes,
2434          *            attendno, unattendno, attendmaybe, unattendmaybe,
2435          *            announce, unannouce
2436          * @return bool
2437          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
2438          * @throws \ImagickException
2439          * @hook  'post_local_end'
2440          *            array $arr
2441          *            'post_id' => ID of posted item
2442          */
2443         public static function performActivity(int $item_id, string $verb, int $uid)
2444         {
2445                 if (empty($uid)) {
2446                         return false;
2447                 }
2448
2449                 Logger::notice('Start create activity', ['verb' => $verb, 'item' => $item_id, 'user' => $uid]);
2450
2451                 $item = Post::selectFirst(self::ITEM_FIELDLIST, ['id' => $item_id]);
2452                 if (!DBA::isResult($item)) {
2453                         Logger::log('like: unknown item ' . $item_id);
2454                         return false;
2455                 }
2456
2457                 $item_uri = $item['uri'];
2458
2459                 if (!in_array($item['uid'], [0, $uid])) {
2460                         return false;
2461                 }
2462
2463                 if (!Post::exists(['uri-id' => $item['parent-uri-id'], 'uid' => $uid])) {
2464                         $stored = self::storeForUserByUriId($item['parent-uri-id'], $uid);
2465                         if (($item['parent-uri-id'] == $item['uri-id']) && !empty($stored)) {
2466                                 $item = Post::selectFirst(self::ITEM_FIELDLIST, ['id' => $stored]);
2467                                 if (!DBA::isResult($item)) {
2468                                         Logger::info('Could not fetch just created item - should not happen', ['stored' => $stored, 'uid' => $uid, 'item-uri' => $item_uri]);
2469                                         return false;
2470                                 }
2471                         }
2472                 }
2473
2474                 // Retrieves the local post owner
2475                 $owner = User::getOwnerDataById($uid);
2476                 if (empty($owner)) {
2477                         Logger::info('Empty owner for user', ['uid' => $uid]);
2478                         return false;
2479                 }
2480
2481                 // Retrieve the current logged in user's public contact
2482                 $author_id = Contact::getIdForURL($owner['url']);
2483                 if (empty($author_id)) {
2484                         Logger::info('Empty public contact');
2485                         return false;
2486                 }
2487
2488                 $activity = null;
2489                 switch ($verb) {
2490                         case 'like':
2491                         case 'unlike':
2492                                 $activity = Activity::LIKE;
2493                                 break;
2494                         case 'dislike':
2495                         case 'undislike':
2496                                 $activity = Activity::DISLIKE;
2497                                 break;
2498                         case 'attendyes':
2499                         case 'unattendyes':
2500                                 $activity = Activity::ATTEND;
2501                                 break;
2502                         case 'attendno':
2503                         case 'unattendno':
2504                                 $activity = Activity::ATTENDNO;
2505                                 break;
2506                         case 'attendmaybe':
2507                         case 'unattendmaybe':
2508                                 $activity = Activity::ATTENDMAYBE;
2509                                 break;
2510                         case 'follow':
2511                         case 'unfollow':
2512                                 $activity = Activity::FOLLOW;
2513                                 break;
2514                         case 'announce':
2515                         case 'unannounce':
2516                                 $activity = Activity::ANNOUNCE;
2517                                 break;
2518                         default:
2519                                 Logger::notice('unknown verb', ['verb' => $verb, 'item' => $item_id]);
2520                                 return false;
2521                 }
2522
2523                 $mode = Strings::startsWith($verb, 'un') ? 'delete' : 'create';
2524
2525                 // Enable activity toggling instead of on/off
2526                 $event_verb_flag = $activity === Activity::ATTEND || $activity === Activity::ATTENDNO || $activity === Activity::ATTENDMAYBE;
2527
2528                 // Look for an existing verb row
2529                 // Event participation activities are mutually exclusive, only one of them can exist at all times.
2530                 if ($event_verb_flag) {
2531                         $verbs = [Activity::ATTEND, Activity::ATTENDNO, Activity::ATTENDMAYBE];
2532
2533                         // Translate to the index based activity index
2534                         $vids = [];
2535                         foreach ($verbs as $verb) {
2536                                 $vids[] = Verb::getID($verb);
2537                         }
2538                 } else {
2539                         $vids = Verb::getID($activity);
2540                 }
2541
2542                 $condition = ['vid' => $vids, 'deleted' => false, 'gravity' => GRAVITY_ACTIVITY,
2543                         'author-id' => $author_id, 'uid' => $item['uid'], 'thr-parent' => $item_uri];
2544                 $like_item = Post::selectFirst(['id', 'guid', 'verb'], $condition);
2545
2546                 if (DBA::isResult($like_item)) {
2547                         /**
2548                          * Truth table for existing activities
2549                          *
2550                          * |          Inputs            ||      Outputs      |
2551                          * |----------------------------||-------------------|
2552                          * |  Mode  | Event | Same verb || Delete? | Return? |
2553                          * |--------|-------|-----------||---------|---------|
2554                          * | create |  Yes  |    Yes    ||   No    |   Yes   |
2555                          * | create |  Yes  |    No     ||   Yes   |   No    |
2556                          * | create |  No   |    Yes    ||   No    |   Yes   |
2557                          * | create |  No   |    No     ||        N/A†       |
2558                          * | delete |  Yes  |    Yes    ||   Yes   |   N/A‡  |
2559                          * | delete |  Yes  |    No     ||   No    |   N/A‡  |
2560                          * | delete |  No   |    Yes    ||   Yes   |   N/A‡  |
2561                          * | delete |  No   |    No     ||        N/A†       |
2562                          * |--------|-------|-----------||---------|---------|
2563                          * |   A    |   B   |     C     || A xor C | !B or C |
2564                          *
2565                          * â€  Can't happen: It's impossible to find an existing non-event activity without
2566                          *                 the same verb because we are only looking for this single verb.
2567                          *
2568                          * â€¡ The "mode = delete" is returning early whether an existing activity was found or not.
2569                          */
2570                         if ($mode == 'create' xor $like_item['verb'] == $activity) {
2571                                 self::markForDeletionById($like_item['id']);
2572                         }
2573
2574                         if (!$event_verb_flag || $like_item['verb'] == $activity) {
2575                                 return true;
2576                         }
2577                 }
2578
2579                 // No need to go further if we aren't creating anything
2580                 if ($mode == 'delete') {
2581                         return true;
2582                 }
2583
2584                 $objtype = $item['resource-id'] ? Activity\ObjectType::IMAGE : Activity\ObjectType::NOTE;
2585
2586                 $new_item = [
2587                         'guid'          => System::createUUID(),
2588                         'uri'           => self::newURI($item['uid']),
2589                         'uid'           => $item['uid'],
2590                         'contact-id'    => $owner['id'],
2591                         'wall'          => $item['wall'],
2592                         'origin'        => 1,
2593                         'network'       => Protocol::DFRN,
2594                         'protocol'      => Conversation::PARCEL_DIRECT,
2595                         'direction'     => Conversation::PUSH,
2596                         'gravity'       => GRAVITY_ACTIVITY,
2597                         'parent'        => $item['id'],
2598                         'thr-parent'    => $item['uri'],
2599                         'owner-id'      => $author_id,
2600                         'author-id'     => $author_id,
2601                         'body'          => $activity,
2602                         'verb'          => $activity,
2603                         'object-type'   => $objtype,
2604                         'allow_cid'     => $item['allow_cid'],
2605                         'allow_gid'     => $item['allow_gid'],
2606                         'deny_cid'      => $item['deny_cid'],
2607                         'deny_gid'      => $item['deny_gid'],
2608                         'visible'       => 1,
2609                         'unseen'        => 1,
2610                 ];
2611
2612                 $signed = Diaspora::createLikeSignature($uid, $new_item);
2613                 if (!empty($signed)) {
2614                         $new_item['diaspora_signed_text'] = json_encode($signed);
2615                 }
2616
2617                 $new_item_id = self::insert($new_item);
2618
2619                 // If the parent item isn't visible then set it to visible
2620                 if (!$item['visible']) {
2621                         self::update(['visible' => true], ['id' => $item['id']]);
2622                 }
2623
2624                 $new_item['id'] = $new_item_id;
2625
2626                 Hook::callAll('post_local_end', $new_item);
2627
2628                 return true;
2629         }
2630
2631         private static function addThread($itemid, $onlyshadow = false)
2632         {
2633                 $fields = ['uid', 'created', 'edited', 'commented', 'received', 'changed', 'wall', 'private', 'pubmail',
2634                         'moderated', 'visible', 'starred', 'contact-id', 'post-type', 'uri-id',
2635                         'deleted', 'origin', 'forum_mode', 'mention', 'network', 'author-id', 'owner-id'];
2636                 $condition = ["`id` = ? AND (`parent` = ? OR `parent` = 0)", $itemid, $itemid];
2637                 $item = Post::selectFirst($fields, $condition);
2638
2639                 if (!DBA::isResult($item)) {
2640                         return;
2641                 }
2642
2643                 $item['iid'] = $itemid;
2644
2645                 if (!$onlyshadow) {
2646                         $result = DBA::replace('thread', $item);
2647
2648                         Logger::info('Add thread', ['item' => $itemid, 'result' => $result]);
2649                 }
2650         }
2651
2652         private static function updateThread($itemid, $setmention = false)
2653         {
2654                 $fields = ['uid', 'guid', 'created', 'edited', 'commented', 'received', 'changed', 'post-type',
2655                         'wall', 'private', 'pubmail', 'moderated', 'visible', 'starred', 'contact-id', 'uri-id',
2656                         'deleted', 'origin', 'forum_mode', 'network', 'author-id', 'owner-id'];
2657
2658                 $item = Post::selectFirst($fields, ['id' => $itemid, 'gravity' => GRAVITY_PARENT]);
2659                 if (!DBA::isResult($item)) {
2660                         return;
2661                 }
2662
2663                 if ($setmention) {
2664                         $item["mention"] = 1;
2665                 }
2666
2667                 $fields = [];
2668
2669                 foreach ($item as $field => $data) {
2670                         if (!in_array($field, ["guid"])) {
2671                                 $fields[$field] = $data;
2672                         }
2673                 }
2674
2675                 $result = DBA::update('thread', $fields, ['iid' => $itemid]);
2676
2677                 Logger::info('Update thread', ['item' => $itemid, 'guid' => $item["guid"], 'result' => $result]);
2678         }
2679
2680         private static function deleteThread($itemid, $itemuri = "")
2681         {
2682                 $item = DBA::selectFirst('thread', ['uid'], ['iid' => $itemid]);
2683                 if (!DBA::isResult($item)) {
2684                         Logger::info('No thread found', ['id' => $itemid]);
2685                         return;
2686                 }
2687
2688                 $result = DBA::delete('thread', ['iid' => $itemid], ['cascade' => false]);
2689
2690                 Logger::info('Deleted thread', ['item' => $itemid, 'result' => $result]);
2691
2692                 if ($itemuri != "") {
2693                         $condition = ["`uri` = ? AND NOT `deleted` AND NOT (`uid` IN (?, 0))", $itemuri, $item["uid"]];
2694                         if (!Post::exists($condition)) {
2695                                 DBA::delete('item', ['uri' => $itemuri, 'uid' => 0]);
2696                                 Logger::debug('Deleted shadow item', ['id' => $itemid, 'uri' => $itemuri]);
2697                         }
2698                 }
2699         }
2700
2701         /**
2702          * Fetch the SQL condition for the given user id
2703          *
2704          * @param integer $owner_id User ID for which the permissions should be fetched
2705          * @return array condition
2706          */
2707         public static function getPermissionsConditionArrayByUserId(int $owner_id)
2708         {
2709                 $local_user = local_user();
2710                 $remote_user = Session::getRemoteContactID($owner_id);
2711
2712                 // default permissions - anonymous user
2713                 $condition = ["`private` != ?", self::PRIVATE];
2714
2715                 if ($local_user && ($local_user == $owner_id)) {
2716                         // Profile owner - everything is visible
2717                         $condition = [];
2718                 } elseif ($remote_user) {
2719                          // Authenticated visitor - fetch the matching permissionsets
2720                         $set = PermissionSet::get($owner_id, $remote_user);
2721                         if (!empty($set)) {
2722                                 $condition = ["(`private` != ? OR (`private` = ? AND `wall`
2723                                         AND `psid` IN (" . implode(', ', array_fill(0, count($set), '?')) . ")))",
2724                                         self::PRIVATE, self::PRIVATE];
2725                                 $condition = array_merge($condition, $set);
2726                         }
2727                 }
2728
2729                 return $condition;
2730         }
2731
2732         /**
2733          * Get a permission SQL string for the given user
2734          * 
2735          * @param int $owner_id 
2736          * @param string $table 
2737          * @return string 
2738          */
2739         public static function getPermissionsSQLByUserId(int $owner_id, string $table = '')
2740         {
2741                 $local_user = local_user();
2742                 $remote_user = Session::getRemoteContactID($owner_id);
2743
2744                 if (!empty($table)) {
2745                         $table = DBA::quoteIdentifier($table) . '.';
2746                 }
2747
2748                 /*
2749                  * Construct permissions
2750                  *
2751                  * default permissions - anonymous user
2752                  */
2753                 $sql = sprintf(" AND " . $table . "`private` != %d", self::PRIVATE);
2754
2755                 // Profile owner - everything is visible
2756                 if ($local_user && ($local_user == $owner_id)) {
2757                         $sql = '';
2758                 } elseif ($remote_user) {
2759                         /*
2760                          * Authenticated visitor. Unless pre-verified,
2761                          * check that the contact belongs to this $owner_id
2762                          * and load the groups the visitor belongs to.
2763                          * If pre-verified, the caller is expected to have already
2764                          * done this and passed the groups into this function.
2765                          */
2766                         $set = PermissionSet::get($owner_id, $remote_user);
2767
2768                         if (!empty($set)) {
2769                                 $sql_set = sprintf(" OR (" . $table . "`private` = %d AND " . $table . "`wall` AND " . $table . "`psid` IN (", self::PRIVATE) . implode(',', $set) . "))";
2770                         } else {
2771                                 $sql_set = '';
2772                         }
2773
2774                         $sql = sprintf(" AND (" . $table . "`private` != %d", self::PRIVATE) . $sql_set . ")";
2775                 }
2776
2777                 return $sql;
2778         }
2779
2780         /**
2781          * get translated item type
2782          *
2783          * @param $item
2784          * @return string
2785          */
2786         public static function postType($item)
2787         {
2788                 if (!empty($item['event-id'])) {
2789                         return DI::l10n()->t('event');
2790                 } elseif (!empty($item['resource-id'])) {
2791                         return DI::l10n()->t('photo');
2792                 } elseif ($item['gravity'] == GRAVITY_ACTIVITY) {
2793                         return DI::l10n()->t('activity');
2794                 } elseif ($item['gravity'] == GRAVITY_COMMENT) {
2795                         return DI::l10n()->t('comment');
2796                 }
2797
2798                 return DI::l10n()->t('post');
2799         }
2800
2801         /**
2802          * Sets the "rendered-html" field of the provided item
2803          *
2804          * Body is preserved to avoid side-effects as we modify it just-in-time for spoilers and private image links
2805          *
2806          * @param array $item
2807          * @param bool  $update
2808          *
2809          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
2810          * @todo Remove reference, simply return "rendered-html" and "rendered-hash"
2811          */
2812         public static function putInCache(&$item, $update = false)
2813         {
2814                 // Save original body to prevent addons to modify it
2815                 $body = $item['body'];
2816
2817                 $rendered_hash = $item['rendered-hash'] ?? '';
2818                 $rendered_html = $item['rendered-html'] ?? '';
2819
2820                 if ($rendered_hash == ''
2821                         || $rendered_html == ''
2822                         || $rendered_hash != hash('md5', BBCode::VERSION . '::' . $body)
2823                         || DI::config()->get('system', 'ignore_cache')
2824                 ) {
2825                         self::addRedirToImageTags($item);
2826
2827                         $item['rendered-html'] = BBCode::convert($item['body']);
2828                         $item['rendered-hash'] = hash('md5', BBCode::VERSION . '::' . $body);
2829
2830                         $hook_data = ['item' => $item, 'rendered-html' => $item['rendered-html'], 'rendered-hash' => $item['rendered-hash']];
2831                         Hook::callAll('put_item_in_cache', $hook_data);
2832                         $item['rendered-html'] = $hook_data['rendered-html'];
2833                         $item['rendered-hash'] = $hook_data['rendered-hash'];
2834                         unset($hook_data);
2835
2836                         // Force an update if the generated values differ from the existing ones
2837                         if ($rendered_hash != $item['rendered-hash']) {
2838                                 $update = true;
2839                         }
2840
2841                         // Only compare the HTML when we forcefully ignore the cache
2842                         if (DI::config()->get('system', 'ignore_cache') && ($rendered_html != $item['rendered-html'])) {
2843                                 $update = true;
2844                         }
2845
2846                         if ($update && !empty($item['id'])) {
2847                                 self::update(
2848                                         [
2849                                                 'rendered-html' => $item['rendered-html'],
2850                                                 'rendered-hash' => $item['rendered-hash']
2851                                         ],
2852                                         ['id' => $item['id']]
2853                                 );
2854                         }
2855                 }
2856
2857                 $item['body'] = $body;
2858         }
2859
2860         /**
2861          * Find any non-embedded images in private items and add redir links to them
2862          *
2863          * @param array &$item The field array of an item row
2864          */
2865         private static function addRedirToImageTags(array &$item)
2866         {
2867                 $app = DI::app();
2868
2869                 $matches = [];
2870                 $cnt = preg_match_all('|\[img\](http[^\[]*?/photo/[a-fA-F0-9]+?(-[0-9]\.[\w]+?)?)\[\/img\]|', $item['body'], $matches, PREG_SET_ORDER);
2871                 if ($cnt) {
2872                         foreach ($matches as $mtch) {
2873                                 if (strpos($mtch[1], '/redir') !== false) {
2874                                         continue;
2875                                 }
2876
2877                                 if ((local_user() == $item['uid']) && ($item['private'] == self::PRIVATE) && ($item['contact-id'] != $app->contact['id']) && ($item['network'] == Protocol::DFRN)) {
2878                                         $img_url = 'redir/' . $item['contact-id'] . '?url=' . urlencode($mtch[1]);
2879                                         $item['body'] = str_replace($mtch[0], '[img]' . $img_url . '[/img]', $item['body']);
2880                                 }
2881                         }
2882                 }
2883         }
2884
2885         /**
2886          * Given an item array, convert the body element from bbcode to html and add smilie icons.
2887          * If attach is true, also add icons for item attachments.
2888          *
2889          * @param array   $item
2890          * @param boolean $attach
2891          * @param boolean $is_preview
2892          * @return string item body html
2893          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
2894          * @throws \ImagickException
2895          * @hook  prepare_body_init item array before any work
2896          * @hook  prepare_body_content_filter ('item'=>item array, 'filter_reasons'=>string array) before first bbcode to html
2897          * @hook  prepare_body ('item'=>item array, 'html'=>body string, 'is_preview'=>boolean, 'filter_reasons'=>string array) after first bbcode to html
2898          * @hook  prepare_body_final ('item'=>item array, 'html'=>body string) after attach icons and blockquote special case handling (spoiler, author)
2899          */
2900         public static function prepareBody(array &$item, $attach = false, $is_preview = false)
2901         {
2902                 $a = DI::app();
2903                 Hook::callAll('prepare_body_init', $item);
2904
2905                 // In order to provide theme developers more possibilities, event items
2906                 // are treated differently.
2907                 if ($item['object-type'] === Activity\ObjectType::EVENT && isset($item['event-id'])) {
2908                         $ev = Event::getItemHTML($item);
2909                         return $ev;
2910                 }
2911
2912                 $tags = Tag::populateFromItem($item);
2913
2914                 $item['tags'] = $tags['tags'];
2915                 $item['hashtags'] = $tags['hashtags'];
2916                 $item['mentions'] = $tags['mentions'];
2917
2918                 // Compile eventual content filter reasons
2919                 $filter_reasons = [];
2920                 if (!$is_preview && public_contact() != $item['author-id']) {
2921                         if (!empty($item['content-warning']) && (!local_user() || !DI::pConfig()->get(local_user(), 'system', 'disable_cw', false))) {
2922                                 $filter_reasons[] = DI::l10n()->t('Content warning: %s', $item['content-warning']);
2923                         }
2924
2925                         $hook_data = [
2926                                 'item' => $item,
2927                                 'filter_reasons' => $filter_reasons
2928                         ];
2929                         Hook::callAll('prepare_body_content_filter', $hook_data);
2930                         $filter_reasons = $hook_data['filter_reasons'];
2931                         unset($hook_data);
2932                 }
2933
2934                 // Update the cached values if there is no "zrl=..." on the links.
2935                 $update = (!Session::isAuthenticated() && ($item["uid"] == 0));
2936
2937                 // Or update it if the current viewer is the intented viewer.
2938                 if (($item["uid"] == local_user()) && ($item["uid"] != 0)) {
2939                         $update = true;
2940                 }
2941
2942                 self::putInCache($item, $update);
2943                 $s = $item["rendered-html"];
2944
2945                 $hook_data = [
2946                         'item' => $item,
2947                         'html' => $s,
2948                         'preview' => $is_preview,
2949                         'filter_reasons' => $filter_reasons
2950                 ];
2951                 Hook::callAll('prepare_body', $hook_data);
2952                 $s = $hook_data['html'];
2953                 unset($hook_data);
2954
2955                 if (!$attach) {
2956                         // Replace the blockquotes with quotes that are used in mails.
2957                         $mailquote = '<blockquote type="cite" class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">';
2958                         $s = str_replace(['<blockquote>', '<blockquote class="spoiler">', '<blockquote class="author">'], [$mailquote, $mailquote, $mailquote], $s);
2959                         return $s;
2960                 }
2961
2962                 $as = '';
2963                 $vhead = false;
2964                 foreach (Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT, Post\Media::TORRENT, Post\Media::UNKNOWN]) as $attachment) {
2965                         $mime = $attachment['mimetype'];
2966
2967                         $the_url = Contact::magicLinkById($item['author-id'], $attachment['url']);
2968
2969                         if (strpos($mime, 'video') !== false) {
2970                                 if (!$vhead) {
2971                                         $vhead = true;
2972                                         DI::page()['htmlhead'] .= Renderer::replaceMacros(Renderer::getMarkupTemplate('videos_head.tpl'));
2973                                 }
2974
2975                                 $as .= Renderer::replaceMacros(Renderer::getMarkupTemplate('video_top.tpl'), [
2976                                         '$video' => [
2977                                                 'id'     => $item['author-id'],
2978                                                 'title'  => DI::l10n()->t('View Video'),
2979                                                 'src'    => $the_url,
2980                                                 'mime'   => $mime,
2981                                         ],
2982                                 ]);
2983                         }
2984
2985                         $filetype = strtolower(substr($mime, 0, strpos($mime, '/')));
2986                         if ($filetype) {
2987                                 $filesubtype = strtolower(substr($mime, strpos($mime, '/') + 1));
2988                                 $filesubtype = str_replace('.', '-', $filesubtype);
2989                         } else {
2990                                 $filetype = 'unkn';
2991                                 $filesubtype = 'unkn';
2992                         }
2993
2994                         $title = Strings::escapeHtml(trim(($attachment['description'] ?? '') ?: $attachment['url']));
2995                         $title .= ' ' . ($attachment['size'] ?? 0) . ' ' . DI::l10n()->t('bytes');
2996
2997                         $icon = '<div class="attachtype icon s22 type-' . $filetype . ' subtype-' . $filesubtype . '"></div>';
2998                         $as .= '<a href="' . strip_tags($the_url) . '" title="' . $title . '" class="attachlink" target="_blank" rel="noopener noreferrer" >' . $icon . '</a>';
2999                 }
3000
3001                 if ($as != '') {
3002                         $s .= '<div class="body-attach">'.$as.'<div class="clear"></div></div>';
3003                 }
3004
3005                 // Map.
3006                 if (strpos($s, '<div class="map">') !== false && !empty($item['coord'])) {
3007                         $x = Map::byCoordinates(trim($item['coord']));
3008                         if ($x) {
3009                                 $s = preg_replace('/\<div class\=\"map\"\>/', '$0' . $x, $s);
3010                         }
3011                 }
3012
3013                 // Replace friendica image url size with theme preference.
3014                 if (!empty($a->theme_info['item_image_size'])) {
3015                         $ps = $a->theme_info['item_image_size'];
3016                         $s = preg_replace('|(<img[^>]+src="[^"]+/photo/[0-9a-f]+)-[0-9]|', "$1-" . $ps, $s);
3017                 }
3018
3019                 $s = HTML::applyContentFilter($s, $filter_reasons);
3020
3021                 $hook_data = ['item' => $item, 'html' => $s];
3022                 Hook::callAll('prepare_body_final', $hook_data);
3023
3024                 return $hook_data['html'];
3025         }
3026
3027         /**
3028          * get private link for item
3029          *
3030          * @param array $item
3031          * @return boolean|array False if item has not plink, otherwise array('href'=>plink url, 'title'=>translated title)
3032          * @throws \Exception
3033          */
3034         public static function getPlink($item)
3035         {
3036                 if (local_user()) {
3037                         $ret = [
3038                                 'href' => "display/" . $item['guid'],
3039                                 'orig' => "display/" . $item['guid'],
3040                                 'title' => DI::l10n()->t('View on separate page'),
3041                                 'orig_title' => DI::l10n()->t('view on separate page'),
3042                         ];
3043
3044                         if (!empty($item['plink'])) {
3045                                 $ret["href"] = DI::baseUrl()->remove($item['plink']);
3046                                 $ret["title"] = DI::l10n()->t('link to source');
3047                         }
3048                 } elseif (!empty($item['plink']) && ($item['private'] != self::PRIVATE)) {
3049                         $ret = [
3050                                 'href' => $item['plink'],
3051                                 'orig' => $item['plink'],
3052                                 'title' => DI::l10n()->t('link to source'),
3053                         ];
3054                 } else {
3055                         $ret = [];
3056                 }
3057
3058                 return $ret;
3059         }
3060
3061         /**
3062          * Is the given item array a post that is sent as starting post to a forum?
3063          *
3064          * @param array $item
3065          * @param array $owner
3066          *
3067          * @return boolean "true" when it is a forum post
3068          */
3069         public static function isForumPost(array $item, array $owner = [])
3070         {
3071                 if (empty($owner)) {
3072                         $owner = User::getOwnerDataById($item['uid']);
3073                         if (empty($owner)) {
3074                                 return false;
3075                         }
3076                 }
3077
3078                 if (($item['author-id'] == $item['owner-id']) ||
3079                         ($owner['id'] == $item['contact-id']) ||
3080                         ($item['uri'] != $item['parent-uri']) ||
3081                         $item['origin']) {
3082                         return false;
3083                 }
3084
3085                 return Contact::isForum($item['contact-id']);
3086         }
3087
3088         /**
3089          * Search item id for given URI or plink
3090          *
3091          * @param string $uri
3092          * @param integer $uid
3093          *
3094          * @return integer item id
3095          */
3096         public static function searchByLink($uri, $uid = 0)
3097         {
3098                 $ssl_uri = str_replace('http://', 'https://', $uri);
3099                 $uris = [$uri, $ssl_uri, Strings::normaliseLink($uri)];
3100
3101                 $item = DBA::selectFirst('item', ['id'], ['uri' => $uris, 'uid' => $uid]);
3102                 if (DBA::isResult($item)) {
3103                         return $item['id'];
3104                 }
3105
3106                 $itemcontent = DBA::selectFirst('item-content', ['uri-id'], ['plink' => $uris]);
3107                 if (!DBA::isResult($itemcontent)) {
3108                         return 0;
3109                 }
3110
3111                 $itemuri = DBA::selectFirst('item-uri', ['uri'], ['id' => $itemcontent['uri-id']]);
3112                 if (!DBA::isResult($itemuri)) {
3113                         return 0;
3114                 }
3115
3116                 $item = DBA::selectFirst('item', ['id'], ['uri' => $itemuri['uri'], 'uid' => $uid]);
3117                 if (DBA::isResult($item)) {
3118                         return $item['id'];
3119                 }
3120
3121                 return 0;
3122         }
3123
3124         /**
3125          * Return the URI for a link to the post 
3126          * 
3127          * @param string $uri URI or link to post
3128          *
3129          * @return string URI
3130          */
3131         public static function getURIByLink(string $uri)
3132         {
3133                 $ssl_uri = str_replace('http://', 'https://', $uri);
3134                 $uris = [$uri, $ssl_uri, Strings::normaliseLink($uri)];
3135
3136                 $item = DBA::selectFirst('item', ['uri'], ['uri' => $uris]);
3137                 if (DBA::isResult($item)) {
3138                         return $item['uri'];
3139                 }
3140
3141                 $itemcontent = DBA::selectFirst('item-content', ['uri-id'], ['plink' => $uris]);
3142                 if (!DBA::isResult($itemcontent)) {
3143                         return '';
3144                 }
3145
3146                 $itemuri = DBA::selectFirst('item-uri', ['uri'], ['id' => $itemcontent['uri-id']]);
3147                 if (DBA::isResult($itemuri)) {
3148                         return $itemuri['uri'];
3149                 }
3150
3151                 return '';
3152         }
3153
3154         /**
3155          * Fetches item for given URI or plink
3156          *
3157          * @param string $uri
3158          * @param integer $uid
3159          *
3160          * @return integer item id
3161          */
3162         public static function fetchByLink(string $uri, int $uid = 0)
3163         {
3164                 Logger::info('Trying to fetch link', ['uid' => $uid, 'uri' => $uri]);
3165                 $item_id = self::searchByLink($uri, $uid);
3166                 if (!empty($item_id)) {
3167                         Logger::info('Link found', ['uid' => $uid, 'uri' => $uri, 'id' => $item_id]);
3168                         return $item_id;
3169                 }
3170
3171                 if ($fetched_uri = ActivityPub\Processor::fetchMissingActivity($uri)) {
3172                         $item_id = self::searchByLink($fetched_uri, $uid);
3173                 } else {
3174                         $item_id = Diaspora::fetchByURL($uri);
3175                 }
3176
3177                 if (!empty($item_id)) {
3178                         Logger::info('Link fetched', ['uid' => $uid, 'uri' => $uri, 'id' => $item_id]);
3179                         return $item_id;
3180                 }
3181
3182                 Logger::info('Link not found', ['uid' => $uid, 'uri' => $uri]);
3183                 return 0;
3184         }
3185
3186         /**
3187          * Return share data from an item array (if the item is shared item)
3188          * We are providing the complete Item array, because at some time in the future
3189          * we hopefully will define these values not in the body anymore but in some item fields.
3190          * This function is meant to replace all similar functions in the system.
3191          *
3192          * @param array $item
3193          *
3194          * @return array with share information
3195          */
3196         public static function getShareArray($item)
3197         {
3198                 if (!preg_match("/(.*?)\[share(.*?)\]\s?(.*?)\s?\[\/share\]\s?/ism", $item['body'], $matches)) {
3199                         return [];
3200                 }
3201
3202                 $attribute_string = $matches[2];
3203                 $attributes = ['comment' => trim($matches[1]), 'shared' => trim($matches[3])];
3204                 foreach (['author', 'profile', 'avatar', 'guid', 'posted', 'link'] as $field) {
3205                         if (preg_match("/$field=(['\"])(.+?)\\1/ism", $attribute_string, $matches)) {
3206                                 $attributes[$field] = trim(html_entity_decode($matches[2] ?? '', ENT_QUOTES, 'UTF-8'));
3207                         }
3208                 }
3209                 return $attributes;
3210         }
3211
3212         /**
3213          * Fetch item information for shared items from the original items and adds it.
3214          *
3215          * @param array $item
3216          *
3217          * @return array item array with data from the original item
3218          */
3219         public static function addShareDataFromOriginal(array $item)
3220         {
3221                 $shared = self::getShareArray($item);
3222                 if (empty($shared)) {
3223                         return $item;
3224                 }
3225
3226                 // Real reshares always have got a GUID.
3227                 if (empty($shared['guid'])) {
3228                         return $item;
3229                 }
3230
3231                 $uid = $item['uid'] ?? 0;
3232
3233                 // first try to fetch the item via the GUID. This will work for all reshares that had been created on this system
3234                 $shared_item = Post::selectFirst(['title', 'body'], ['guid' => $shared['guid'], 'uid' => [0, $uid]]);
3235                 if (!DBA::isResult($shared_item)) {
3236                         if (empty($shared['link'])) {
3237                                 return $item;
3238                         }
3239
3240                         // Otherwhise try to find (and possibly fetch) the item via the link. This should work for Diaspora and ActivityPub posts
3241                         $id = self::fetchByLink($shared['link'] ?? '', $uid);
3242                         if (empty($id)) {
3243                                 Logger::info('Original item not found', ['url' => $shared['link'] ?? '', 'callstack' => System::callstack()]);
3244                                 return $item;
3245                         }
3246
3247                         $shared_item = Post::selectFirst(['title', 'body'], ['id' => $id]);
3248                         if (!DBA::isResult($shared_item)) {
3249                                 return $item;
3250                         }
3251                         Logger::info('Got shared data from url', ['url' => $shared['link'], 'callstack' => System::callstack()]);
3252                 } else {
3253                         Logger::info('Got shared data from guid', ['guid' => $shared['guid'], 'callstack' => System::callstack()]);
3254                 }
3255
3256                 if (!empty($shared_item['title'])) {
3257                         $body = '[h3]' . $shared_item['title'] . "[/h3]\n" . $shared_item['body'];
3258                         unset($shared_item['title']);
3259                 } else {
3260                         $body = $shared_item['body'];
3261                 }
3262
3263                 $item['body'] = preg_replace("/\[share ([^\[\]]*)\].*\[\/share\]/ism", '[share $1]' . $body . '[/share]', $item['body']);
3264                 unset($shared_item['body']);
3265
3266                 return array_merge($item, $shared_item);
3267         }
3268
3269         /**
3270          * Check a prospective item array against user-level permissions
3271          *
3272          * @param array $item Expected keys: uri, gravity, and
3273          *                    author-link if is author-id is set,
3274          *                    owner-link if is owner-id is set,
3275          *                    causer-link if is causer-id is set.
3276          * @param int   $user_id Local user ID
3277          * @return bool
3278          * @throws \Exception
3279          */
3280         protected static function isAllowedByUser(array $item, int $user_id)
3281         {
3282                 if (!empty($item['author-id']) && Contact\User::isBlocked($item['author-id'], $user_id)) {
3283                         Logger::notice('Author is blocked by user', ['author-link' => $item['author-link'], 'uid' => $user_id, 'item-uri' => $item['uri']]);
3284                         return false;
3285                 }
3286
3287                 if (!empty($item['owner-id']) && Contact\User::isBlocked($item['owner-id'], $user_id)) {
3288                         Logger::notice('Owner is blocked by user', ['owner-link' => $item['owner-link'], 'uid' => $user_id, 'item-uri' => $item['uri']]);
3289                         return false;
3290                 }
3291
3292                 // The causer is set during a thread completion, for example because of a reshare. It countains the responsible actor.
3293                 if (!empty($item['causer-id']) && Contact\User::isBlocked($item['causer-id'], $user_id)) {
3294                         Logger::notice('Causer is blocked by user', ['causer-link' => $item['causer-link'] ?? $item['causer-id'], 'uid' => $user_id, 'item-uri' => $item['uri']]);
3295                         return false;
3296                 }
3297
3298                 if (!empty($item['causer-id']) && ($item['gravity'] === GRAVITY_PARENT) && Contact\User::isIgnored($item['causer-id'], $user_id)) {
3299                         Logger::notice('Causer is ignored by user', ['causer-link' => $item['causer-link'] ?? $item['causer-id'], 'uid' => $user_id, 'item-uri' => $item['uri']]);
3300                         return false;
3301                 }
3302
3303                 return true;
3304         }
3305 }