Friendica Communications Platform (please note that this is a clone of the repository at github, issues are handled there) https://friendi.ca
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

3221 lines
107 KiB

<?php
/**
* @copyright Copyright (C) 2010-2021, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
namespace Friendica\Model;
use Friendica\Content\Text\BBCode;
use Friendica\Content\Text\HTML;
use Friendica\Core\Hook;
use Friendica\Core\Logger;
use Friendica\Core\Protocol;
use Friendica\Core\Renderer;
use Friendica\Core\Session;
use Friendica\Core\System;
use Friendica\Model\Tag;
use Friendica\Core\Worker;
use Friendica\Database\DBA;
use Friendica\DI;
use Friendica\Model\Post;
use Friendica\Protocol\Activity;
use Friendica\Protocol\ActivityPub;
use Friendica\Protocol\Diaspora;
use Friendica\Util\DateTimeFormat;
use Friendica\Util\Map;
use Friendica\Util\Network;
use Friendica\Util\Proxy;
use Friendica\Util\Strings;
use Friendica\Worker\Delivery;
use LanguageDetection\Language;
class Item
{
// Posting types, inspired by https://www.w3.org/TR/activitystreams-vocabulary/#object-types
const PT_ARTICLE = 0;
const PT_NOTE = 1;
const PT_PAGE = 2;
const PT_IMAGE = 16;
const PT_AUDIO = 17;
const PT_VIDEO = 18;
const PT_DOCUMENT = 19;
const PT_EVENT = 32;
const PT_PERSONAL_NOTE = 128;
// Posting reasons (Why had a post been stored for a user?)
const PR_NONE = 0;
const PR_TAG = 64;
const PR_TO = 65;
const PR_CC = 66;
const PR_BTO = 67;
const PR_BCC = 68;
const PR_FOLLOWER = 69;
const PR_ANNOUNCEMENT = 70;
const PR_COMMENT = 71;
const PR_STORED = 72;
const PR_GLOBAL = 73;
const PR_RELAY = 74;
const PR_FETCHED = 75;
// Field list that is used to display the items
const DISPLAY_FIELDLIST = [
'uid', 'id', 'parent', 'guid', 'network', 'gravity',
'uri-id', 'uri', 'thr-parent-id', 'thr-parent', 'parent-uri-id', 'parent-uri',
'commented', 'created', 'edited', 'received', 'verb', 'object-type', 'postopts', 'plink',
'wall', 'private', 'starred', 'origin', 'parent-origin', 'title', 'body', 'language',
'content-warning', 'location', 'coord', 'app', 'rendered-hash', 'rendered-html', 'object',
'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'mention',
'author-id', 'author-link', 'author-name', 'author-avatar', 'author-network',
'owner-id', 'owner-link', 'owner-name', 'owner-avatar', 'owner-network', 'owner-contact-type',
'causer-id', 'causer-link', 'causer-name', 'causer-avatar', 'causer-contact-type', 'causer-network',
'contact-id', 'contact-uid', 'contact-link', 'contact-name', 'contact-avatar',
'writable', 'self', 'cid', 'alias',
'event-created', 'event-edited', 'event-start', 'event-finish',
'event-summary', 'event-desc', 'event-location', 'event-type',
'event-nofinish', 'event-adjust', 'event-ignore', 'event-id',
'delivery_queue_count', 'delivery_queue_done', 'delivery_queue_failed'
];
// Field list that is used to deliver items via the protocols
const DELIVER_FIELDLIST = ['uid', 'id', 'parent', 'uri-id', 'uri', 'thr-parent', 'parent-uri', 'guid',
'parent-guid', 'received', 'created', 'edited', 'verb', 'object-type', 'object', 'target',
'private', 'title', 'body', 'raw-body', 'location', 'coord', 'app',
'inform', 'deleted', 'extid', 'post-type', 'post-reason', 'gravity',
'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid',
'author-id', 'author-link', 'author-name', 'author-avatar', 'owner-id', 'owner-link', 'contact-uid',
'signed_text', 'network', 'wall', 'contact-id', 'plink', 'forum_mode', 'origin',
'thr-parent-id', 'parent-uri-id', 'postopts', 'pubmail',
'event-created', 'event-edited', 'event-start', 'event-finish',
'event-summary', 'event-desc', 'event-location', 'event-type',
'event-nofinish', 'event-adjust', 'event-ignore', 'event-id'];
// All fields in the item table
const ITEM_FIELDLIST = ['id', 'uid', 'parent', 'uri', 'parent-uri', 'thr-parent',
'guid', 'uri-id', 'parent-uri-id', 'thr-parent-id', 'vid',
'contact-id', 'wall', 'gravity', 'extid', 'psid',
'created', 'edited', 'commented', 'received', 'changed', 'verb',
'postopts', 'plink', 'resource-id', 'event-id', 'inform',
'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'post-type', 'post-reason',
'private', 'pubmail', 'visible', 'starred',
'unseen', 'deleted', 'origin', 'forum_mode', 'mention', 'global', 'network',
'title', 'content-warning', 'body', 'location', 'coord', 'app',
'rendered-hash', 'rendered-html', 'object-type', 'object', 'target-type', 'target',
'author-id', 'author-link', 'author-name', 'author-avatar', 'author-network',
'owner-id', 'owner-link', 'owner-name', 'owner-avatar', 'causer-id'];
// List of all verbs that don't need additional content data.
4 years ago
// Never reorder or remove entries from this list. Just add new ones at the end, if needed.
const ACTIVITIES = [
Activity::LIKE, Activity::DISLIKE,
Activity::ATTEND, Activity::ATTENDNO, Activity::ATTENDMAYBE,
Activity::FOLLOW,
Activity::ANNOUNCE];
const PUBLIC = 0;
const PRIVATE = 1;
const UNLISTED = 2;
/**
* Update existing item entries
*
* @param array $fields The fields that are to be changed
* @param array $condition The condition for finding the item entries
*
* In the future we may have to change permissions as well.
* Then we had to add the user id as third parameter.
*
* A return value of "0" doesn't mean an error - but that 0 rows had been changed.
*
* @return integer|boolean number of affected rows - or "false" if there was an error
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public static function update(array $fields, array $condition)
{
if (empty($condition) || empty($fields)) {
return false;
}
if (isset($fields['extid'])) {
$fields['external-id'] = ItemURI::getIdByURI($fields['extid']);
}
if (!empty($fields['verb'])) {
$fields['vid'] = Verb::getID($fields['verb']);
}
$rows = Post::update($fields, $condition);
if (is_bool($rows)) {
return $rows;
}
// We only need to call the line by line update for specific fields
if (empty($fields['body']) && empty($fields['file']) &&
empty($fields['attach']) && empty($fields['edited'])) {
return $rows;
}
Logger::info('Updating per single row method', ['fields' => $fields, 'condition' => $condition]);
$items = Post::select(['id', 'origin', 'uri-id', 'uid'], $condition);
$notify_items = [];
while ($item = DBA::fetch($items)) {
if (!empty($fields['body'])) {
Post\Media::insertFromAttachmentData($item['uri-id'], $fields['body']);
$content_fields = ['raw-body' => trim($fields['raw-body'] ?? $fields['body'])];
// Remove all media attachments from the body and store them in the post-media table
// @todo On shared postings (Diaspora style and commented reshare) don't fetch content from the shared part
$content_fields['raw-body'] = Post\Media::insertFromBody($item['uri-id'], $content_fields['raw-body']);
$content_fields['raw-body'] = self::setHashtags($content_fields['raw-body']);
}
if (!empty($fields['file'])) {
Post\Category::storeTextByURIId($item['uri-id'], $item['uid'], $fields['file']);
}
if (!empty($fields['attach'])) {
Post\Media::insertFromAttachment($item['uri-id'], $fields['attach']);
}
// We only need to notfiy others when it is an original entry from us.
// Only call the notifier when the item has some content relevant change.
if ($item['origin'] && in_array('edited', array_keys($fields))) {
$notify_items[] = $item['id'];
}
}
DBA::close($items);
foreach ($notify_items as $notify_item) {
$post = Post::selectFirst(['uri-id', 'uid'], ['id' => $notify_item]);
Worker::add(PRIORITY_HIGH, "Notifier", Delivery::POST, (int)$post['uri-id'], (int)$post['uid']);
}
return $rows;
}
/**
* Delete an item and notify others about it - if it was ours
*
* @param array $condition The condition for finding the item entries
* @param integer $priority Priority for the notification
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public static function markForDeletion($condition, $priority = PRIORITY_HIGH)
{
$items = Post::select(['id'], $condition);
while ($item = Post::fetch($items)) {
self::markForDeletionById($item['id'], $priority);
}
DBA::close($items);
}
/**
* Delete an item for an user and notify others about it - if it was ours
*
* @param array $condition The condition for finding the item entries
* @param integer $uid User who wants to delete this item
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public static function deleteForUser($condition, $uid)
{
if ($uid == 0) {
return;
}
$items = Post::select(['id', 'uid', 'uri-id'], $condition);
while ($item = Post::fetch($items)) {
if (in_array($item['uid'], [$uid, 0])) {
Post\User::update($item['uri-id'], $uid, ['hidden' => true], true);
Post\ThreadUser::update($item['uri-id'], $uid, ['hidden' => true], true);
}
if ($item['uid'] == $uid) {
self::markForDeletionById($item['id'], PRIORITY_HIGH);
} elseif ($item['uid'] != 0) {
Logger::notice('Wrong ownership. Not deleting item', ['id' => $item['id']]);
}
}
DBA::close($items);
}
/**
* Mark an item for deletion, delete related data and notify others about it - if it was ours
*
* @param integer $item_id
* @param integer $priority Priority for the notification
*
* @return boolean success
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
public static function markForDeletionById($item_id, $priority = PRIORITY_HIGH)
{
Logger::info('Mark item for deletion by id', ['id' => $item_id, 'callstack' => System::callstack()]);
// locate item to be deleted
$fields = ['id', 'uri', 'uri-id', 'uid', 'parent', 'parent-uri-id', 'origin',
'deleted', 'resource-id', 'event-id',
'verb', 'object-type', 'object', 'target', 'contact-id', 'psid', 'gravity'];
$item = Post::selectFirst($fields, ['id' => $item_id]);
if (!DBA::isResult($item)) {
Logger::info('Item not found.', ['id' => $item_id]);
return false;
}
if ($item['deleted']) {
Logger::info('Item has already been marked for deletion.', ['id' => $item_id]);
return false;
}
$parent = Post::selectFirst(['origin'], ['id' => $item['parent']]);
if (!DBA::isResult($parent)) {
$parent = ['origin' => false];
}
// clean up categories and tags so they don't end up as orphans
Post\Category::deleteByURIId($item['uri-id'], $item['uid']);
/*
* If item is a link to a photo resource, nuke all the associated photos
* (visitors will not have photo resources)
* This only applies to photos uploaded from the photos page. Photos inserted into a post do not
* generate a resource-id and therefore aren't intimately linked to the item.
*/
/// @TODO: this should first check if photo is used elsewhere
if (strlen($item['resource-id'])) {
Photo::delete(['resource-id' => $item['resource-id'], 'uid' => $item['uid']]);
}
// If item is a link to an event, delete the event.
if (intval($item['event-id'])) {
Event::delete($item['event-id']);
}
// If item has attachments, drop them
$attachments = Post\Media::getByURIId($item['uri-id'], [Post\Media::DOCUMENT]);
foreach($attachments as $attachment) {
if (preg_match("|attach/(\d+)|", $attachment['url'], $matches)) {
Attach::delete(['id' => $matches[1], 'uid' => $item['uid']]);
}
}
// Set the item to "deleted"
$item_fields = ['deleted' => true, 'edited' => DateTimeFormat::utcNow(), 'changed' => DateTimeFormat::utcNow()];
Post::update($item_fields, ['id' => $item['id']]);
Post\Category::storeTextByURIId($item['uri-id'], $item['uid'], '');
if (!Post::exists(["`uri-id` = ? AND `uid` != 0 AND NOT `deleted`", $item['uri-id']])) {
self::markForDeletion(['uri-id' => $item['uri-id'], 'uid' => 0, 'deleted' => false], $priority);
}
Post\DeliveryData::delete($item['uri-id']);
// If it's the parent of a comment thread, kill all the kids
if ($item['gravity'] == GRAVITY_PARENT) {
self::markForDeletion(['parent' => $item['parent'], 'deleted' => false], $priority);
}
// Is it our comment and/or our thread?
if (($item['origin'] || $parent['origin']) && ($item['uid'] != 0)) {
// When we delete the original post we will delete all existing copies on the server as well
self::markForDeletion(['uri-id' => $item['uri-id'], 'deleted' => false], $priority);
// send the notification upstream/downstream
if ($priority) {
Worker::add(['priority' => $priority, 'dont_fork' => true], "Notifier", Delivery::DELETION, (int)$item['uri-id'], (int)$item['uid']);
}
} elseif ($item['uid'] != 0) {
Post\User::update($item['uri-id'], $item['uid'], ['hidden' => true]);
Post\ThreadUser::update($item['uri-id'], $item['uid'], ['hidden' => true]);
}
Logger::info('Item has been marked for deletion.', ['id' => $item_id]);
return true;
}
private static function guid($item, $notify)
{
4 years ago
if (!empty($item['guid'])) {
return Strings::escapeTags(trim($item['guid']));
}
if ($notify) {
// We have to avoid duplicates. So we create the GUID in form of a hash of the plink or uri.
// We add the hash of our own host because our host is the original creator of the post.
$prefix_host = DI::baseUrl()->getHostname();
} else {
$prefix_host = '';
// We are only storing the post so we create a GUID from the original hostname.
if (!empty($item['author-link'])) {
$parsed = parse_url($item['author-link']);
if (!empty($parsed['host'])) {
$prefix_host = $parsed['host'];
}
}
if (empty($prefix_host) && !empty($item['plink'])) {
$parsed = parse_url($item['plink']);
if (!empty($parsed['host'])) {
$prefix_host = $parsed['host'];
}
}
if (empty($prefix_host) && !empty($item['uri'])) {
$parsed = parse_url($item['uri']);
if (!empty($parsed['host'])) {
$prefix_host = $parsed['host'];
}
}
// Is it in the format data@host.tld? - Used for mail contacts
if (empty($prefix_host) && !empty($item['author-link']) && strstr($item['author-link'], '@')) {
$mailparts = explode('@', $item['author-link']);
$prefix_host = array_pop($mailparts);
}
}
if (!empty($item['plink'])) {
$guid = self::guidFromUri($item['plink'], $prefix_host);
} elseif (!empty($item['uri'])) {
$guid = self::guidFromUri($item['uri'], $prefix_host);
} else {
$guid = System::createUUID(hash('crc32', $prefix_host));
}
return $guid;
}
private static function contactId($item)
{
if (!empty($item['contact-id']) && DBA::exists('contact', ['self' => true, 'id' => $item['contact-id']])) {
return $item['contact-id'];
} elseif (($item['gravity'] == GRAVITY_PARENT) && !empty($item['uid']) && !empty($item['contact-id']) && Contact::isSharing($item['contact-id'], $item['uid'])) {
return $item['contact-id'];
} elseif (!empty($item['uid']) && !Contact::isSharing($item['author-id'], $item['uid'])) {
return $item['author-id'];
} elseif (!empty($item['contact-id'])) {
return $item['contact-id'];
} else {
$contact_id = Contact::getIdForURL($item['author-link'], $item['uid']);
if (!empty($contact_id)) {
return $contact_id;
}
}
return $item['author-id'];
}
3 years ago
/**
* Write an item array into a spool file to be inserted later.
* This command is called whenever there are issues storing an item.
*
* @param array $item The item fields that are to be inserted
* @throws \Exception
*/
private static function spool($orig_item)
{
// Now we store the data in the spool directory
// We use "microtime" to keep the arrival order and "mt_rand" to avoid duplicates
$file = 'item-' . round(microtime(true) * 10000) . '-' . mt_rand() . '.msg';
$spoolpath = get_spoolpath();
if ($spoolpath != "") {
$spool = $spoolpath . '/' . $file;
file_put_contents($spool, json_encode($orig_item));
Logger::warning("Item wasn't stored - Item was spooled into file", ['file' => $file]);
}
}
/**
* Check if the item array is a duplicate
*
* @param array $item
* @return boolean is it a duplicate?
*/
private static function isDuplicate(array $item)
{
// Checking if there is already an item with the same guid
$condition = ['guid' => $item['guid'], 'network' => $item['network'], 'uid' => $item['uid']];
if (Post::exists($condition)) {
Logger::notice('Found already existing item', [
'guid' => $item['guid'],
'uid' => $item['uid'],
'network' => $item['network']
]);
return true;
}
$condition = ['uri-id' => $item['uri-id'], 'uid' => $item['uid'],
'network' => [$item['network'], Protocol::DFRN]];
if (Post::exists($condition)) {
Logger::notice('duplicated item with the same uri found.', $item);
return true;
}
// On Friendica and Diaspora the GUID is unique
if (in_array($item['network'], [Protocol::DFRN, Protocol::DIASPORA])) {
$condition = ['guid' => $item['guid'], 'uid' => $item['uid']];
if (Post::exists($condition)) {
Logger::notice('duplicated item with the same guid found.', $item);
return true;
}
} elseif ($item['network'] == Protocol::OSTATUS) {
// Check for an existing post with the same content. There seems to be a problem with OStatus.
$condition = ["`body` = ? AND `network` = ? AND `created` = ? AND `contact-id` = ? AND `uid` = ?",
$item['body'], $item['network'], $item['created'], $item['contact-id'], $item['uid']];
if (Post::exists($condition)) {
Logger::notice('duplicated item with the same body found.', $item);
return true;
}
}
/*
* Check for already added items.
* There is a timing issue here that sometimes creates double postings.
* An unique index would help - but the limitations of MySQL (maximum size of index values) prevent this.
*/
if (($item['uid'] == 0) && Post::exists(['uri-id' => $item['uri-id'], 'uid' => 0])) {
Logger::notice('Global item already stored.', ['uri-id' => $item['uri-id'], 'network' => $item['network']]);
return true;
}
return false;
}
/**
* Check if the item array is valid
*
* @param array $item
* @return boolean item is valid
*/
public static function isValid(array $item)
{
// When there is no content then we don't post it
if (($item['body'] . $item['title'] == '') && !Post\Media::existsByURIId($item['uri-id'])) {
Logger::notice('No body, no title.');
return false;
}
if (!empty($item['uid'])) {
$owner = User::getOwnerDataById($item['uid'], false);
if (!$owner) {
Logger::notice('Missing item user owner data', ['uid' => $item['uid']]);
return false;
}
if ($owner['account_expired'] || $owner['account_removed']) {
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']]);
return false;
}
}
if (!empty($item['author-id']) && Contact::isBlocked($item['author-id'])) {
Logger::notice('Author is blocked node-wide', ['author-link' => $item['author-link'], 'item-uri' => $item['uri']]);
return false;
}
if (!empty($item['author-link']) && Network::isUrlBlocked($item['author-link'])) {
Logger::notice('Author server is blocked', ['author-link' => $item['author-link'], 'item-uri' => $item['uri']]);
return false;
}
if (!empty($item['owner-id']) && Contact::isBlocked($item['owner-id'])) {
Logger::notice('Owner is blocked node-wide', ['owner-link' => $item['owner-link'], 'item-uri' => $item['uri']]);
return false;
}
if (!empty($item['owner-link']) && Network::isUrlBlocked($item['owner-link'])) {
Logger::notice('Owner server is blocked', ['owner-link' => $item['owner-link'], 'item-uri' => $item['uri']]);
return false;
}
if (!empty($item['uid']) && !self::isAllowedByUser($item, $item['uid'])) {
return false;
}
if ($item['verb'] == Activity::FOLLOW) {
if (!$item['origin'] && ($item['author-id'] == Contact::getPublicIdByUserId($item['uid']))) {
// Our own follow request can be relayed to us. We don't store it to avoid notification chaos.
Logger::info("Follow: Don't store not origin follow request", ['parent-uri' => $item['parent-uri']]);
return false;
}
$condition = ['verb' => Activity::FOLLOW, 'uid' => $item['uid'],
'parent-uri' => $item['parent-uri'], 'author-id' => $item['author-id']];
if (Post::exists($condition)) {
// It happens that we receive multiple follow requests by the same author - we only store one.
Logger::info('Follow: Found existing follow request from author', ['author-id' => $item['author-id'], 'parent-uri' => $item['parent-uri']]);
return false;
}
}
return true;
}
/**
* Check if the item array is too old
*
* @param array $item
* @return boolean item is too old
*/
public static function isTooOld(array $item)
{
// check for create date and expire time
$expire_interval = DI::config()->get('system', 'dbclean-expire-days', 0);
$user = DBA::selectFirst('user', ['expire'], ['uid' => $item['uid']]);
if (DBA::isResult($user) && ($user['expire'] > 0) && (($user['expire'] < $expire_interval) || ($expire_interval == 0))) {
$expire_interval = $user['expire'];
}
if (($expire_interval > 0) && !empty($item['created'])) {
$expire_date = time() - ($expire_interval * 86400);
$created_date = strtotime($item['created']);
if ($created_date < $expire_date) {
Logger::notice('Item created before expiration interval.', [
'created' => date('c', $created_date),
'expired' => date('c', $expire_date),
'$item' => $item
]);
return true;
}
}
return false;
}
/**
2 years ago
* Return the id of the given item array if it has been stored before
*
* @param array $item
* @return integer item id
*/
private static function getDuplicateID(array $item)
{
if (empty($item['network']) || in_array($item['network'], Protocol::FEDERATED)) {
$condition = ["`uri-id` = ? AND `uid` = ? AND `network` IN (?, ?, ?, ?)",
$item['uri-id'], $item['uid'],
Protocol::ACTIVITYPUB, Protocol::DIASPORA, Protocol::DFRN, Protocol::OSTATUS];
$existing = Post::selectFirst(['id', 'network'], $condition);
if (DBA::isResult($existing)) {
// We only log the entries with a different user id than 0. Otherwise we would have too many false positives
if ($item['uid'] != 0) {
Logger::notice('Item already existed for user', [
'uri-id' => $item['uri-id'],
'uid' => $item['uid'],
'network' => $item['network'],
'existing_id' => $existing["id"],
'existing_network' => $existing["network"]
]);
}
return $existing["id"];
}
}
return 0;
}
/**
* Fetch top-level parent data for the given item array
*
* @param array $item
* @return array item array with parent data
* @throws \Exception
*/
private static function getTopLevelParent(array $item)
{
$fields = ['uid', 'uri', 'parent-uri', 'id', 'deleted',
'uri-id', 'parent-uri-id',
'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid',
'wall', 'private', 'forum_mode', 'origin', 'author-id'];
$condition = ['uri-id' => $item['thr-parent-id'], 'uid' => $item['uid']];
$params = ['order' => ['id' => false]];
$parent = Post::selectFirst($fields, $condition, $params);
if (!DBA::isResult($parent)) {
Logger::notice('item parent was not found - ignoring item', ['thr-parent-id' => $item['thr-parent-id'], 'uid' => $item['uid']]);
return [];
}
if ($parent['uri-id'] == $parent['parent-uri-id']) {
return $parent;
}
$condition = ['uri-id' => $parent['parent-uri-id'],
'parent-uri-id' => $parent['parent-uri-id'],
'uid' => $parent['uid']];
$params = ['order' => ['id' => false]];
$toplevel_parent = Post::selectFirst($fields, $condition, $params);
if (!DBA::isResult($toplevel_parent)) {
Logger::notice('item top level parent was not found - ignoring item', ['parent-uri-id' => $parent['parent-uri-id'], 'uid' => $parent['uid']]);
return [];
}
return $toplevel_parent;
}