$item, 'closed' => 'unset' ]; /** * @hooks comments_are_now_closed * Called to determine whether commenting should be closed * * \e array \b item * * \e boolean \b closed - return value */ Hook::call('comments_are_now_closed', $x); if ($x['closed'] !== 'unset') { return $x['closed']; } if ($item['comments_closed'] > NULL_DATE && datetime_convert() > $item['comments_closed']) { return true; } return false; } function item_normal() { return " and item.item_hidden = 0 and item.item_type = 0 and item.item_deleted = 0 and item.item_unpublished = 0 and item.item_delayed = 0 and item.item_pending_remove = 0 and item.item_blocked = 0 and item.obj_type != '" . ACTIVITY_OBJ_FILE . "' "; } function item_normal_draft() { return " and item.item_hidden = 0 and item.item_type = 0 and item.item_deleted = 0 and item.item_pending_remove = 0 and item.item_blocked = 0 and item.obj_type != '" . ACTIVITY_OBJ_FILE . "' "; } function item_normal_search() { return " and item.item_hidden = 0 and item.item_type in (0,3,6,7) and item.item_deleted = 0 and item.item_unpublished = 0 and item.item_delayed = 0 and item.item_pending_remove = 0 and item.item_blocked = 0 and item.obj_type != '" . ACTIVITY_OBJ_FILE . "' "; } function item_normal_update() { return " and item.item_hidden = 0 and item.item_type = 0 and item.item_unpublished = 0 and item.item_delayed = 0 and item.item_pending_remove = 0 and item.item_blocked = 0 and item.obj_type != '" . ACTIVITY_OBJ_FILE . "' "; } function item_normal_moderate() { return " and item.item_hidden = 0 and item.item_type = 0 and item.item_deleted = 0 and item.item_unpublished = 0 and item.item_delayed = 0 and item.item_pending_remove = 0 and item.item_blocked in (0, 4) and item.obj_type != '" . ACTIVITY_OBJ_FILE . "' "; } /** * @brief * * This is a compatibility function primarily for plugins, because * in earlier DB schemas this was a much simpler single integer compare * * @param array $item */ function is_item_normal($item) { if (intval($item['item_hidden']) || intval($item['item_type']) || intval($item['item_deleted']) || intval($item['item_unpublished']) || intval($item['item_delayed']) || intval($item['item_pending_remove']) || intval($item['item_blocked']) || ($item['obj_type'] == ACTIVITY_OBJ_FILE)) { return false; } return true; } /** * @brief Decide if current observer has sufficient privileges to comment on item. * * This function examines the comment_policy attached to an item and decides if the current observer has * sufficient privileges to comment. This will normally be called on a remote site where perm_is_allowed() * will not be suitable because the post owner does not have a local channel_id. * Generally we should look at the item - in particular the author['abook_flags'] and see if ABOOK_FLAG_SELF is set. * If it is, you should be able to use perm_is_allowed( ... 'post_comments'), and if it isn't you need to call * can_comment_on_post() * We also check the comments_closed date/time on the item if this is set. * * @param string $observer_xchan * @param array $item * @return bool */ function can_comment_on_post($observer_xchan, $item) { // logger('Comment_policy: ' . $item['comment_policy'], LOGGER_DEBUG); $x = [ 'observer_hash' => $observer_xchan, 'item' => $item, 'allowed' => 'unset' ]; /** * @hooks can_comment_on_post * Called when deciding whether to present a comment box for a post. * * \e string \b observer_hash * * \e array \b item * * \e boolean \b allowed - return value */ Hook::call('can_comment_on_post', $x); if ($x['allowed'] !== 'unset') { return $x['allowed']; } if (! $observer_xchan) { return false; } if ($item['comment_policy'] === 'none' || intval($item['item_nocomment']) || comments_are_now_closed($item)) { return false; } if ($observer_xchan === $item['author_xchan'] || $observer_xchan === $item['owner_xchan']) { return true; } switch ($item['comment_policy']) { case 'network: red': case 'network: activitypub': case 'public': case 'authenticated': // Anonymous folks won't ever reach this point (as $observer_xchan will be empty). // This means we can identify the viewer. return true; case 'any connections': case 'specific': case 'contacts': case '': // local posts only - check if the post owner granted me comment permission if (local_channel() && array_key_exists('owner', $item) && their_perms_contains(local_channel(), $item['owner']['abook_xchan'], 'post_comments')) { return true; } if (intval($item['item_wall']) && perm_is_allowed($item['uid'], $observer_xchan, 'post_comments')) { return true; } break; case 'self': default: break; } if (strstr($item['comment_policy'], 'site:') && strstr($item['comment_policy'], App::get_hostname())) { return true; } return false; } function absolutely_no_comments($item) { if ($item['comment_policy'] === 'none') { return true; } if (intval($item['item_nocomment'])) { return true; } if(comments_are_now_closed($item)) { return true; } return false; } /** * @brief Post an activity. * * In its simplest form one needs only to set $arr['body'] to post a note to the logged in channel's wall. * Much more complex activities can be created. Permissions are checked. No filtering, tag expansion * or other processing is performed. * * @param array $arr * @param boolean $deliver (optional) default true * @returns array * * \e boolean \b success true or false * * \e array \b activity the resulting activity if successful */ function post_activity_item($arr, $deliver = true, $channel = null, $observer = null) { logger('input: ' . print_r($arr,true), LOGGER_DATA); $ret = [ 'success' => false ]; $is_comment = false; if ((isset($arr['parent']) && $arr['parent'] && $arr['parent'] != $arr['id']) || (isset($arr['parent_mid']) && $arr['parent_mid'] && $arr['parent_mid'] != $arr['mid'])) { $is_comment = true; } if (! array_key_exists('item_origin',$arr)) { $arr['item_origin'] = 1; } if (! array_key_exists('item_wall',$arr) && (! $is_comment)) { $arr['item_wall'] = 0; } if (! array_key_exists('item_thread_top',$arr) && (! $is_comment)) { $arr['item_thread_top'] = 1; } if (!$channel) { $channel = App::get_channel(); } if (!$observer) { $observer = App::get_observer(); } $arr['aid'] = ((isset($arr['aid'])) ? $arr['aid'] : $channel['channel_account_id']); $arr['uid'] = ((isset($arr['uid'])) ? $arr['uid'] : $channel['channel_id']); if (! perm_is_allowed($arr['uid'], $observer['xchan_hash'], (($is_comment) ? 'post_comments' : 'post_wall'))) { $ret['message'] = t('Permission denied'); return $ret; } if (! array_key_exists('mimetype',$arr)) { $arr['mimetype'] = 'text/x-multicode'; } if (! (isset($arr['mid']) && $arr['mid'])) { $arr['uuid'] = ((isset($arr['uuid'])) ? $arr['uuid'] : new_uuid()); } $arr['mid'] = ((isset($arr['mid'])) ? $arr['mid'] : z_root() . '/item/' . $arr['uuid']); $arr['parent_mid'] = ((isset($arr['parent_mid'])) ? $arr['parent_mid'] : $arr['mid']); $arr['thr_parent'] = ((isset($arr['thr_parent'])) ? $arr['thr_parent'] : $arr['mid']); $arr['owner_xchan'] = ((isset($arr['owner_xchan'])) ? $arr['owner_xchan'] : $channel['channel_hash']); $arr['author_xchan'] = ((isset($arr['author_xchan'])) ? $arr['author_xchan'] : $observer['xchan_hash']); $arr['verb'] = ((x($arr,'verb')) ? $arr['verb'] : ACTIVITY_POST); $arr['obj_type'] = ((x($arr,'obj_type')) ? $arr['obj_type'] : ACTIVITY_OBJ_ARTICLE); if(! ( array_key_exists('allow_cid',$arr) || array_key_exists('allow_gid',$arr) || array_key_exists('deny_cid',$arr) || array_key_exists('deny_gid',$arr))) { $arr['allow_cid'] = $channel['channel_allow_cid']; $arr['allow_gid'] = $channel['channel_allow_gid']; $arr['deny_cid'] = $channel['channel_deny_cid']; $arr['deny_gid'] = $channel['channel_deny_gid']; } $arr['comment_policy'] = map_scope(PermissionLimits::Get($channel['channel_id'],'post_comments')); if (empty($arr['plink'])) { $arr['plink'] = $arr['mid']; } // for the benefit of plugins, we will behave as if this is an API call rather than a normal online post $_REQUEST['api_source'] = 1; /** * @hooks post_local * Called when an item has been posted on this machine via mod/item.php (also via API). */ Hook::call('post_local', $arr); if(x($arr, 'cancel')) { logger('Post cancelled by plugin.'); return $ret; } $post = item_store($arr,$deliver); $post_id = 0; if($post['success']) { $post_id = $post['item_id']; $ret['success'] = true; $ret['item_id'] = $post_id; $ret['activity'] = $post['item']; /** * @hooks post_local_end * Called after a local post operation has completed. * * \e array - the item returned from item_store() */ Hook::call('post_local_end', $ret['activity']); sync_an_item($channel ? $channel['channel_id'] : $arr['uid'], $post_id); } if($post_id && $deliver) { Run::Summon([ 'Notifier','activity',$post_id ]); } return $ret; } function validate_item_elements($message,$arr) { $result = ['success' => false]; if(! array_key_exists('created',$arr)) $result['message'] = 'missing created, possible author/owner lookup failure'; if((! $arr['mid']) || (! $arr['parent_mid'])) $result['message'] = 'missing message-id or parent message-id'; if(array_key_exists('flags',$message) && in_array('relay',$message['flags']) && $arr['mid'] === $arr['parent_mid']) $result['message'] = 'relay set on top level post'; if(! $result['message']) $result['success'] = true; return $result; } function get_item_elements($x) { $arr = []; $arr['body'] = $x['body']; $arr['summary'] = $x['summary']; $mirror = array_key_exists('revision', $x); $maxlen = get_max_import_size(); if($maxlen && mb_strlen($arr['body']) > $maxlen) { $arr['body'] = mb_substr($arr['body'],0,$maxlen,'UTF-8'); logger('get_item_elements: message length exceeds max_import_size: truncated'); } if($maxlen && mb_strlen($arr['summary']) > $maxlen) { $arr['summary'] = mb_substr($arr['summary'],0,$maxlen,'UTF-8'); logger('get_item_elements: message summary length exceeds max_import_size: truncated'); } $arr['created'] = datetime_convert('UTC','UTC',$x['created']); $arr['edited'] = datetime_convert('UTC','UTC',$x['edited']); $arr['expires'] = ((x($x,'expires') && $x['expires']) ? datetime_convert('UTC','UTC',$x['expires']) : NULL_DATE); $arr['commented'] = ((x($x,'commented') && $x['commented']) ? datetime_convert('UTC','UTC',$x['commented']) : $arr['created']); $arr['comments_closed'] = ((x($x,'comments_closed') && $x['comments_closed']) ? datetime_convert('UTC','UTC',$x['comments_closed']) : NULL_DATE); $arr['title'] = (($x['title']) ? htmlspecialchars($x['title'], ENT_COMPAT,'UTF-8',false) : ''); if(mb_strlen($arr['title']) > 255) $arr['title'] = mb_substr($arr['title'],0,255); $arr['uuid'] = (($x['uuid']) ? htmlspecialchars($x['uuid'], ENT_COMPAT,'UTF-8',false) : ''); $arr['app'] = (($x['app']) ? htmlspecialchars($x['app'], ENT_COMPAT,'UTF-8',false) : ''); $arr['mid'] = (($x['message_id']) ? htmlspecialchars($x['message_id'], ENT_COMPAT,'UTF-8',false) : ''); $arr['parent_mid'] = (($x['message_top']) ? htmlspecialchars($x['message_top'], ENT_COMPAT,'UTF-8',false) : ''); $arr['thr_parent'] = (($x['message_parent']) ? htmlspecialchars($x['message_parent'], ENT_COMPAT,'UTF-8',false) : ''); $arr['approved'] = (($x['approved']) ? htmlspecialchars($x['approved'], ENT_COMPAT,'UTF-8',false) : ''); $arr['plink'] = (($x['permalink']) ? htmlspecialchars($x['permalink'], ENT_COMPAT,'UTF-8',false) : ''); $arr['location'] = (($x['location']) ? htmlspecialchars($x['location'], ENT_COMPAT,'UTF-8',false) : ''); $arr['verb'] = (($x['verb']) ? htmlspecialchars($x['verb'], ENT_COMPAT,'UTF-8',false) : ''); $arr['mimetype'] = (($x['mimetype']) ? htmlspecialchars($x['mimetype'], ENT_COMPAT,'UTF-8',false) : ''); $arr['obj_type'] = (($x['object_type']) ? htmlspecialchars($x['object_type'], ENT_COMPAT,'UTF-8',false) : ''); $arr['tgt_type'] = (($x['target_type']) ? htmlspecialchars($x['target_type'], ENT_COMPAT,'UTF-8',false) : ''); if ($x['longlat']) { $coordinates = explode(' ', $x['longlat']); if (count($coordinates) > 1) { $arr['lat'] = floatval($coordinates[0]); $arr['lon'] = floatval($coordinates[1]); } } // convert AS1 namespaced elements to AS-JSONLD $arr['verb'] = Activity::activity_mapper($arr['verb']); $arr['obj_type'] = Activity::activity_obj_mapper($arr['obj_type'], $mirror); $arr['tgt_type'] = Activity::activity_obj_mapper($arr['tgt_type'], $mirror); $arr['comment_policy'] = (($x['comment_scope']) ? htmlspecialchars($x['comment_scope'], ENT_COMPAT,'UTF-8',false) : 'contacts'); $arr['sig'] = (($x['signature']) ? htmlspecialchars($x['signature'], ENT_COMPAT,'UTF-8',false) : ''); // fix old-style signatures imported from hubzilla if($arr['sig'] && (! strpos($arr['sig'],'.'))) { $arr['sig'] = 'sha256.' . $arr['sig']; } $arr['obj'] = activity_sanitise($x['object']); if($arr['obj'] && is_array($arr['obj']) && array_key_exists('asld',$arr['obj'])) { $arr['obj'] = $arr['obj']['asld']; } $arr['target'] = isset($x['target']) ? activity_sanitise($x['target']) : ''; if($arr['target'] && is_array($arr['target']) && array_key_exists('asld',$arr['target'])) { $arr['target'] = $arr['target']['asld']; } $arr['attach'] = activity_sanitise($x['attach']); $arr['replyto'] = activity_sanitise($x['replyto']); $arr['term'] = isset($x['tags']) ? decode_tags($x['tags']) : []; $arr['iconfig'] = decode_item_meta($x['meta']); $arr['item_private'] = 0; $arr['item_flags'] = 0; if(array_key_exists('flags',$x)) { if(in_array('consensus',$x['flags'])) $arr['item_consensus'] = 1; if(in_array('deleted',$x['flags'])) $arr['item_deleted'] = 1; if(in_array('notshown',$x['flags'])) $arr['item_notshown'] = 1; if(in_array('obscured',$x['flags'])) $arr['item_obscured'] = 1; // hidden item are no longer propagated - notshown may be a suitable alternative if(in_array('hidden',$x['flags'])) $arr['item_hidden'] = 1; if(in_array('private',$x['flags'])) $arr['item_private'] = 1; if(in_array('direct',$x['flags'])) $arr['item_private'] = 2; } // Here's the deal - the site might be down or whatever but if there's a new person you've never // seen before sending stuff to your stream, we MUST be able to look them up and import their data from their // hub and verify that they are legit - or else we're going to toss the post. We only need to do this // once, and after that your hub knows them. Sure some info is in the post, but it's only a transit identifier // and not enough info to be able to look you up from your hash - which is the only thing stored with the post. $xchan_hash = import_author_xchan($x['author']); if($xchan_hash) $arr['author_xchan'] = $xchan_hash; else return []; $xchan_hash = import_author_xchan($x['owner']); if($xchan_hash) { $arr['owner_xchan'] = $xchan_hash; } else { return []; } // Check signature on the body text received. // This presents an issue that we aren't verifying the text that is actually displayed // on this site. We are however verifying the received text was exactly as received. // We have every right to strip content that poses a security risk. You are welcome to // create a plugin to verify the content after filtering if this offends you. if($arr['sig']) { // check the supplied signature against the supplied content. // Note that we will purify the content which could change it. $r = q("select xchan_pubkey from xchan where xchan_hash = '%s' limit 1", dbesc($arr['author_xchan']) ); if($r) { if($r[0]['xchan_pubkey']) { if(Libzot::verify($x['body'],$arr['sig'],$r[0]['xchan_pubkey'])) { $arr['item_verified'] = 1; } else { logger('get_item_elements: message verification failed.'); } } else { // If we don't have a public key, strip the signature, so that it won't show as invalid. // This won't happen in normal use, but could happen if import_author_xchan() // failed to load the zot-info packet due to a server failure and had // to create an alternate xchan with network 'unknown' unset($arr['sig']); } } } // Strip old-style hubzilla bookmarks // Do this after signature verification if (str_contains($x['body'], "#^[")) { $x['body'] = str_replace("#^[","[",$x['body']); } // if the input is markdown, remove one level of html escaping. // It will be re-applied in item_store() and/or item_store_update(). // Do this after signature checking as the original signature // was generated on the escaped content. if ($arr['mimetype'] === 'text/markdown') { $arr['summary'] = MarkdownSoap::unescape($arr['summary']); $arr['body'] = MarkdownSoap::unescape($arr['body']); } if ($mirror) { // extended export encoding $arr['revision'] = $x['revision']; $arr['allow_cid'] = $x['allow_cid']; $arr['allow_gid'] = $x['allow_gid']; $arr['deny_cid'] = $x['deny_cid']; $arr['deny_gid'] = $x['deny_gid']; $arr['layout_mid'] = $x['layout_mid']; $arr['postopts'] = $x['postopts']; $arr['resource_id'] = $x['resource_id']; $arr['resource_type'] = $x['resource_type']; $arr['item_origin'] = intval($x['item_origin']); $arr['item_unseen'] = intval($x['item_unseen']); $arr['item_starred'] = intval($x['item_starred']); $arr['item_uplink'] = intval($x['item_uplink']); $arr['item_consensus'] = intval($x['item_consensus']); $arr['item_wall'] = intval($x['item_wall']); $arr['item_thread_top'] = intval($x['item_thread_top']); $arr['item_notshown'] = intval($x['item_notshown']); $arr['item_nsfw'] = intval($x['item_nsfw']); // local only $arr['item_relay'] = $x['item_relay']; $arr['item_mentionsme'] = intval($x['item_mentionsme']); $arr['item_nocomment'] = intval($x['item_nocomment']); $arr['item_obscured'] = intval($x['item_obscured']); // local only $arr['item_verified'] = $x['item_verified']; $arr['item_retained'] = intval($x['item_retained']); $arr['item_rss'] = intval($x['item_rss']); $arr['item_deleted'] = intval($x['item_deleted']); $arr['item_type'] = intval($x['item_type']); $arr['item_hidden'] = intval($x['item_hidden']); $arr['item_unpublished'] = intval($x['item_unpublished']); $arr['item_delayed'] = intval($x['item_delayed']); $arr['item_pending_remove'] = intval($x['item_pending_remove']); $arr['item_blocked'] = intval($x['item_blocked']); $arr['item_restrict'] = isset($x['item_restrict']) ? intval($x['item_restrict']) : 0; $arr['item_flags'] = isset($x['item_flags']) ? intval($x['item_flags']) : 0; } return $arr; } function import_author_xchan($x) { if(! $x) { return false; } $arr = [ 'xchan' => $x, 'xchan_hash' => '' ]; /** * @hooks import_author_xchan * Called when looking up an author of a post by xchan_hash to ensure they have an xchan record on our site. * * \e array \b xchan * * \e string \b xchan_hash - The returned value */ Hook::call('import_author_xchan', $arr); if($arr['xchan_hash']) return $arr['xchan_hash']; $y = false; if((! array_key_exists('network', $x)) || in_array($x['network'], ['nomad', 'zot6'])) { $y = Libzot::import_author_zot($x); } // if we were told that it's a zot connection, don't probe/import anything else if(array_key_exists('network', $x) && in_array($x['network'], ['nomad', 'zot6'])) { return $y; } if(! $y) { $y = import_author_activitypub($x); } if(! $y) { $y = import_author_unknown($x); } return($y); } function import_author_activitypub($x) { if(! $x['url']) return false; // let somebody upgrade from an 'unknown' connection which has no xchan_addr and resolve issues with identities from multiple protocols using the same url $r = q("select xchan_hash, xchan_url, xchan_network, xchan_name, xchan_photo_s from xchan left join hubloc on xchan_hash = hubloc_hash where hubloc_id_url = '%s' and hubloc_deleted = 0", dbesc($x['url']) ); if(! $r) { $r = q("select xchan_hash, xchan_url, xchan_network, xchan_name, xchan_photo_s from xchan where xchan_hash = '%s' ", dbesc($x['url']) ); } if($r) { $ptr = null; foreach($r as $rv) { if (str_contains($rv['xchan_network'], 'zot') || str_contains($rv['xchan_network'], 'nomad')) { $ptr = $rv; } } if(! $ptr) { $ptr = $r[0]; } logger('in_cache: ' . $ptr['xchan_name'], LOGGER_DATA); return $ptr['xchan_hash']; } $z = discover_resource($x['url']); if($z) { $r = q("select xchan_hash, xchan_url, xchan_network, xchan_name, xchan_photo_s from xchan left join hubloc on xchan_hash = hubloc_hash where hubloc_id_url = '%s' and hubloc_deleted = 0", dbesc($x['url']) ); if(! $r) { $r = q("select xchan_hash, xchan_url, xchan_name, xchan_photo_s from xchan where xchan_hash = '%s' ", dbesc($x['url']) ); } if($r) { foreach($r as $rv) { if (str_contains($rv['xchan_network'], 'zot')) { return $rv['xchan_hash']; } } return $r[0]['xchan_hash']; } } return false; } function import_author_unknown($x) { $arr = [ 'author' => $x, 'result' => false ]; /** * @hooks import_author * * \e array \b author * * \e boolean|string \b result - Return value, default false */ Hook::call('import_author', $arr); if($arr['result']) return $arr['result']; if(! $x['url']) return false; $r = q("select xchan_hash from xchan where xchan_network = 'unknown' and xchan_url = '%s' limit 1", dbesc($x['url']) ); if($r) { logger('In cache' , LOGGER_DEBUG); return $r[0]['xchan_hash']; } $name = trim($x['name']); $r = xchan_store_lowlevel( [ 'xchan_hash' => $x['url'], 'xchan_guid' => $x['url'], 'xchan_url' => $x['url'], 'xchan_updated' => datetime_convert(), 'xchan_name' => (($name) ?: t('(Unknown)')), 'xchan_name_date' => datetime_convert(), 'xchan_network' => 'unknown' ] ); if($r && $x['photo']) { $photos = import_remote_xchan_photo($x['photo']['src'],$x['url']); if($photos) { $r = q("update xchan set xchan_updated = '%s', xchan_photo_date = '%s', xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s', xchan_photo_mimetype = '%s' where xchan_hash = '%s' and xchan_network = 'unknown'", dbesc(datetime_convert()), dbesc(datetime_convert()), dbesc($photos[0]), dbesc($photos[1]), dbesc($photos[2]), dbesc($photos[3]), dbesc($x['url']) ); if($r) return $x['url']; } } return false; } function empty_acl($item) { return $item['allow_cid'] === EMPTY_STR && $item['allow_gid'] === EMPTY_STR && $item['deny_cid'] === EMPTY_STR && $item['deny_gid'] === EMPTY_STR; } function encode_item($item,$mirror = false) { $x = []; $x['type'] = 'activity'; $x['encoding'] = 'zot'; // If we're trying to back up an item so that it's recoverable or for export/imprt, // add all the attributes we need to recover it if($mirror) { $x['id'] = $item['id']; $x['parent'] = $item['parent']; $x['uid'] = $item['uid']; $x['allow_cid'] = $item['allow_cid']; $x['allow_gid'] = $item['allow_gid']; $x['deny_cid'] = $item['deny_cid']; $x['deny_gid'] = $item['deny_gid']; $x['revision'] = $item['revision']; $x['layout_mid'] = $item['layout_mid']; $x['postopts'] = $item['postopts']; $x['resource_id'] = $item['resource_id']; $x['resource_type'] = $item['resource_type']; $x['attach'] = $item['attach']; $x['item_origin'] = $item['item_origin']; $x['item_unseen'] = $item['item_unseen']; $x['item_starred'] = $item['item_starred']; $x['item_uplink'] = $item['item_uplink']; $x['item_consensus'] = $item['item_consensus']; $x['item_wall'] = $item['item_wall']; $x['item_thread_top'] = $item['item_thread_top']; $x['item_notshown'] = $item['item_notshown']; $x['item_nsfw'] = $item['item_nsfw']; $x['item_relay'] = $item['item_relay']; $x['item_mentionsme'] = $item['item_mentionsme']; $x['item_nocomment'] = $item['item_nocomment']; $x['item_obscured'] = $item['item_obscured']; $x['item_verified'] = $item['item_verified']; $x['item_retained'] = $item['item_retained']; $x['item_rss'] = $item['item_rss']; $x['item_deleted'] = $item['item_deleted']; $x['item_type'] = $item['item_type']; $x['item_hidden'] = $item['item_hidden']; $x['item_unpublished'] = $item['item_unpublished']; $x['item_delayed'] = $item['item_delayed']; $x['item_pending_remove'] = $item['item_pending_remove']; $x['item_blocked'] = $item['item_blocked']; } $x['uuid'] = $item['uuid']; $x['message_id'] = $item['mid']; $x['approved'] = $item['approved']; $x['message_top'] = $item['parent_mid']; $x['message_parent'] = $item['thr_parent']; $x['created'] = $item['created']; $x['edited'] = $item['edited']; // always send 0's over the wire $x['expires'] = (($item['expires'] === '0001-01-01 00:00:00') ? '0000-00-00 00:00:00' : $item['expires']); $x['commented'] = $item['commented']; $x['mimetype'] = $item['mimetype']; $x['title'] = $item['title']; $x['summary'] = $item['summary']; $x['body'] = $item['body']; $x['app'] = $item['app']; $x['verb'] = $item['verb']; $x['object_type'] = $item['obj_type']; $x['target_type'] = $item['tgt_type']; $x['permalink'] = $item['plink']; $x['location'] = $item['location']; $x['longlat'] = ($item['lat'] || $item['lon']) ? $item['lat'] . ' ' . $item['lon'] : 0.0; $x['signature'] = $item['sig']; $x['replyto'] = $item['replyto']; $x['owner'] = encode_item_xchan($item['owner']); $x['author'] = encode_item_xchan($item['author']); if($item['obj']) $x['object'] = json_decode($item['obj'],true); if($item['target']) $x['target'] = json_decode($item['target'],true); if($item['attach']) $x['attach'] = json_decode($item['attach'],true); if($y = encode_item_flags($item)) $x['flags'] = $y; if($item['comments_closed'] > NULL_DATE) { $x['comments_closed'] = $item['comments_closed']; } $x['comment_scope'] = $item['comment_policy']; if($item['term']) $x['tags'] = encode_item_terms($item['term'],$mirror); if($item['iconfig']) $x['meta'] = encode_item_meta($item['iconfig'],$mirror); logger('encode_item: ' . print_r($x,true), LOGGER_DATA); return $x; } /** * @brief * * @param int $scope * @param boolean $strip (optional) default false * @return string */ function map_scope($scope, $strip = false) { switch($scope) { case 0: return 'self'; case PERMS_PUBLIC: if($strip) return ''; return 'public'; case PERMS_NETWORK: return 'network: red'; case PERMS_AUTHED: return 'authenticated'; case PERMS_SITE: return 'site: ' . App::get_hostname(); case PERMS_PENDING: return 'any connections'; case PERMS_SPECIFIC: return 'specific'; case PERMS_CONTACTS: default: return 'contacts'; } } /** * @brief Returns a descriptive text for a given $scope. * * @param string $scope * @return string translated string describing the scope */ function translate_scope($scope) { if(! $scope || $scope === 'public') return t('Visible to anybody on the internet.'); if(str_starts_with($scope, 'self')) return t('Visible to you only.'); if(str_starts_with($scope, 'network:')) return t('Visible to anybody in this network.'); if(str_starts_with($scope, 'authenticated')) return t('Visible to anybody authenticated.'); if(str_starts_with($scope, 'site:')) return sprintf( t('Visible to anybody on %s.'), strip_tags(substr($scope,6))); if(str_starts_with($scope, 'any connections')) return t('Visible to all connections.'); if(str_starts_with($scope, 'contacts')) return t('Visible to approved connections.'); if(str_starts_with($scope, 'specific')) return t('Visible to specific connections.'); return ''; // This shouldn't happen. } /** * @brief * * @param array $xchan * @return array an associative array */ function encode_item_xchan($xchan) { $ret = []; $ret['name'] = $xchan['xchan_name']; $ret['address'] = $xchan['xchan_addr']; $ret['url'] = $xchan['xchan_url']; $ret['network'] = $xchan['xchan_network']; $ret['photo'] = [ 'mimetype' => $xchan['xchan_photo_mimetype'], 'src' => $xchan['xchan_photo_m'] ]; $ret['id'] = $xchan['xchan_guid']; $ret['id_sig'] = $xchan['xchan_guid_sig']; $ret['key'] = $xchan['xchan_pubkey']; return $ret; } function encode_item_terms($terms,$mirror = false) { $ret = []; $allowed_export_terms = [TERM_UNKNOWN, TERM_HASHTAG, TERM_MENTION, TERM_CATEGORY, TERM_BOOKMARK, TERM_COMMUNITYTAG, TERM_FORUM]; if($mirror) { $allowed_export_terms[] = TERM_PCATEGORY; $allowed_export_terms[] = TERM_FILE; } if($terms) { foreach($terms as $term) { if(in_array($term['ttype'],$allowed_export_terms)) $ret[] = ['tag' => $term['term'], 'url' => $term['url'], 'type' => termtype($term['ttype'])]; } } return $ret; } function encode_item_meta($meta,$mirror = false) { $ret = []; if($meta) { foreach($meta as $m) { if($m['sharing'] || $mirror) $ret[] = ['family' => $m['cat'], 'key' => $m['k'], 'value' => $m['v'], 'sharing' => intval($m['sharing'])]; } } return $ret; } function decode_item_meta($meta) { $ret = []; if(is_array($meta) && $meta) { foreach($meta as $m) { $ret[] = ['cat' => escape_tags($m['family']),'k' => escape_tags($m['key']),'v' => $m['value'],'sharing' => $m['sharing']]; } } return $ret; } /** * @brief * * @param int $t * @return string */ function termtype($t) { $types = ['unknown', 'hashtag', 'mention', 'category', 'personal_category', 'file', 'search', 'thing', 'bookmark', 'hierarchy', 'communitytag', 'forum']; return(($types[$t]) ?: 'unknown'); } /** * @brief * * @param array $t * @return array|string empty string or array containing associative arrays with * * \e string \b term * * \e string \b url * * \e int \b type */ function decode_tags($t) { if($t) { $ret = []; foreach($t as $x) { $tag = []; $tag['term'] = htmlspecialchars($x['tag'], ENT_COMPAT, 'UTF-8', false); $tag['url'] = htmlspecialchars($x['url'], ENT_COMPAT, 'UTF-8', false); switch($x['type']) { case 'hashtag': $tag['ttype'] = TERM_HASHTAG; break; case 'mention': $tag['ttype'] = TERM_MENTION; break; case 'category': $tag['ttype'] = TERM_CATEGORY; break; case 'personal_category': $tag['ttype'] = TERM_PCATEGORY; break; case 'file': $tag['ttype'] = TERM_FILE; break; case 'search': $tag['ttype'] = TERM_SAVEDSEARCH; break; case 'thing': $tag['ttype'] = TERM_THING; break; case 'bookmark': $tag['ttype'] = TERM_BOOKMARK; break; case 'communitytag': $tag['ttype'] = TERM_COMMUNITYTAG; break; case 'forum': $tag['ttype'] = TERM_FORUM; break; default: case 'unknown': $tag['ttype'] = TERM_UNKNOWN; break; } $ret[] = $tag; } return $ret; } return ''; } function purify_imported_object($obj) { $ret = null; if (is_array($obj)) { foreach ( $obj as $k => $v ) { if (is_array($v)) { $ret[$k] = purify_imported_object($v); } elseif (is_string($v)) { $ret[$k] = purify_html($v); } } } elseif (is_string($obj)) { $ret = purify_html($obj); } return $ret; } /** * @brief Sanitise a potentially complex array. * * Walks the array and applies htmlspecialchars to the content unless it is a known HTML element, * in which case the result is purified * * @param array $arr * @return array|string */ function activity_sanitise($arr) { if($arr) { if(is_array($arr)) { $ret = []; foreach($arr as $k => $x) { if ($k === 'source' && array_path_exists('source/mediaType',$arr)) { if (array_path_exists('source/content',$arr) && isset($arr['source']['mediaType']) && in_array($arr['source']['mediaType'], [ 'text/bbcode', 'text/x-multicode' ])) { // @FIXME multicode $ret[$k] = [ 'mediaType' => 'text/x-multicode' ]; if (is_string($arr['source']['content'])) { $ret[$k]['content'] = multicode_purify($arr['source']['content']); } if (array_path_exists('source/summary',$arr) && is_string($arr['source']['summary'])) { $ret[$k]['summary'] = multicode_purify($arr['source']['summary']); } continue; } } if (in_array($k, [ 'content', 'summary', 'contentMap', 'summaryMap' ])) { $ret[$k] = purify_imported_object($x); continue; } if(is_array($x)) $ret[$k] = activity_sanitise($x); else $ret[$k] = htmlspecialchars((isset($x) ? $x : ''), ENT_COMPAT, 'UTF-8', false); } return $ret; } else { return htmlspecialchars($arr, ENT_COMPAT, 'UTF-8', false); } } return ''; } /** * @brief Sanitise a simple linear array. * * @param array $arr * @return array|string */ function array_sanitise($arr) { if($arr) { $ret = []; foreach($arr as $x) { $ret[] = htmlspecialchars($x, ENT_COMPAT,'UTF-8',false); } return $ret; } return ''; } function encode_item_flags($item) { // most of item_flags and item_restrict are local settings which don't apply when transmitted. // We may need those for the case of syncing other hub locations which you are attached to. $ret = []; if(intval($item['item_deleted'])) $ret[] = 'deleted'; if(intval($item['item_hidden'])) $ret[] = 'hidden'; if(intval($item['item_notshown'])) $ret[] = 'notshown'; if(intval($item['item_thread_top'])) $ret[] = 'thread_parent'; if(intval($item['item_nsfw'])) $ret[] = 'nsfw'; if(intval($item['item_consensus'])) $ret[] = 'consensus'; if(intval($item['item_obscured'])) $ret[] = 'obscured'; if(intval($item['item_private'])) $ret[] = 'private'; if(intval($item['item_private']) === 2) $ret[] = 'direct'; return $ret; } // Superceded by Libzotdir::import_directory_profile // This is probably no longer needed. function get_profile_elements($profile) { $arr = []; $xchan_hash = import_author_xchan($profile['from']); if($xchan_hash) { $arr['xprof_hash'] = $xchan_hash; } else { return []; } $arr['desc'] = (($profile['title']) ? htmlspecialchars($profile['title'],ENT_COMPAT,'UTF-8',false) : ''); $arr['dob'] = datetime_convert('UTC','UTC',$profile['birthday'],'Y-m-d'); $arr['age'] = (($profile['age']) ? intval($profile['age']) : 0); $arr['gender'] = (($profile['gender']) ? htmlspecialchars($profile['gender'], ENT_COMPAT,'UTF-8',false) : ''); $arr['marital'] = (($profile['marital']) ? htmlspecialchars($profile['marital'], ENT_COMPAT,'UTF-8',false) : ''); $arr['sexual'] = (($profile['sexual']) ? htmlspecialchars($profile['sexual'], ENT_COMPAT,'UTF-8',false) : ''); $arr['locale'] = (($profile['locale']) ? htmlspecialchars($profile['locale'], ENT_COMPAT,'UTF-8',false) : ''); $arr['region'] = (($profile['region']) ? htmlspecialchars($profile['region'], ENT_COMPAT,'UTF-8',false) : ''); $arr['postcode'] = (($profile['postcode']) ? htmlspecialchars($profile['postcode'], ENT_COMPAT,'UTF-8',false) : ''); $arr['country'] = (($profile['country']) ? htmlspecialchars($profile['country'], ENT_COMPAT,'UTF-8',false) : ''); $arr['keywords'] = (($profile['keywords'] && is_array($profile['keywords'])) ? array_sanitise($profile['keywords']) : []); return $arr; } /** * @brief Signs an item body. * * @param[in,out] array $item */ function item_sign(&$item) { if(array_key_exists('sig',$item) && $item['sig']) return; $r = q("select channel_prvkey from channel where channel_id = %d and channel_hash = '%s' ", intval($item['uid']), dbesc($item['author_xchan']) ); if(! $r) return; $item['sig'] = Libzot::sign($item['body'], $r[0]['channel_prvkey']); $item['item_verified'] = 1; } // packs json data for storage. // if it is a string, check if it is already json encoded. // Otherwise, json encode it // If it is an array, sanitise it and then json_encode it. function item_json_encapsulate($arr,$k) { $retval = null; if (isset($arr[$k])) { if (is_string($arr[$k])) { // determine if it is json encoded already $test = json_decode($arr[$k]); // assume it is json encoded already $retval = $arr[$k]; if ($test === NULL) { $retval = json_encode($arr[$k], JSON_UNESCAPED_SLASHES); } } else { activity_sanitise($arr[$k]); $retval = json_encode($arr[$k], JSON_UNESCAPED_SLASHES); } } return $retval; } /** * @brief Stores an item type record. * * @param array $arr * @param boolean $deliver (optional) default true * * @return array * * \e boolean \b success * * \e int \b item_id */ function item_store($arr, $deliver = true) { $d = [ 'item' => $arr, ]; /** * @hooks item_store_before * Called when item_store() stores a record of type item. * * \e array \b item */ Hook::call('item_store_before', $d); $arr = $d['item']; $ret = ['success' => false, 'item_id' => 0]; if(array_key_exists('cancel',$arr) && $arr['cancel']) { logger('cancelled by plugin'); return $ret; } if(! $arr['uid']) { logger('item_store: no uid'); $ret['message'] = 'No uid.'; return $ret; } //$uplinked_comment = false; // If a page layout is provided, ensure it exists and belongs to us. if(array_key_exists('layout_mid',$arr) && $arr['layout_mid']) { $l = q("select item_type from item where mid = '%s' and uid = %d limit 1", dbesc($arr['layout_mid']), intval($arr['uid']) ); if((! $l) || ($l[0]['item_type'] != ITEM_TYPE_PDL)) unset($arr['layout_mid']); } // Don't let anybody set these, either intentionally or accidentally if(array_key_exists('id',$arr)) unset($arr['id']); if(array_key_exists('parent',$arr)) unset($arr['parent']); $arr['mimetype'] = ((x($arr,'mimetype')) ? notags(trim($arr['mimetype'])) : 'text/x-multicode'); $arr['title'] = ((array_key_exists('title',$arr) && strlen($arr['title'])) ? trim(escape_tags($arr['title'])) : ''); $arr['summary'] = ((array_key_exists('summary',$arr) && strlen($arr['summary'])) ? trim($arr['summary']) : ''); $arr['body'] = ((array_key_exists('body',$arr) && strlen($arr['body'])) ? trim($arr['body']) : ''); $arr['allow_cid'] = ((x($arr,'allow_cid')) ? trim($arr['allow_cid']) : ''); $arr['allow_gid'] = ((x($arr,'allow_gid')) ? trim($arr['allow_gid']) : ''); $arr['deny_cid'] = ((x($arr,'deny_cid')) ? trim($arr['deny_cid']) : ''); $arr['deny_gid'] = ((x($arr,'deny_gid')) ? trim($arr['deny_gid']) : ''); $arr['postopts'] = ((x($arr,'postopts')) ? trim($arr['postopts']) : ''); $arr['uuid'] = ((x($arr,'uuid')) ? trim($arr['uuid']) : ''); $arr['approved'] = ((x($arr,'approved')) ? trim($arr['approved']) : ''); $arr['item_private'] = ((x($arr,'item_private')) ? intval($arr['item_private']) : 0 ); $arr['item_wall'] = ((x($arr,'item_wall')) ? intval($arr['item_wall']) : 0 ); $arr['item_type'] = ((x($arr,'item_type')) ? intval($arr['item_type']) : 0 ); $arr['item_verified'] = ((x($arr,'item_verified')) ? intval($arr['item_verified']) : 0 ); // obsolete, but needed so as not to throw not-null constraints on some database driveres $arr['item_flags'] = ((x($arr,'item_flags')) ? intval($arr['item_flags']) : 0 ); $languagetext = prepare_text($arr['body'],((isset($arr['mimetype'])) ? $arr['mimetype'] : 'text/x-multicode')); $languagetext = html2plain((isset($arr['title']) && $arr['title']) ? $arr['title'] . ' ' . $languagetext : $languagetext); $detector = new LanguageDetect(); $arr['lang'] = $detector->detect($languagetext); // apply the input filter here $arr['summary'] = trim(z_input_filter($arr['summary'],$arr['mimetype'])); $arr['body'] = trim(z_input_filter($arr['body'],$arr['mimetype'])); item_sign($arr); if(! array_key_exists('sig',$arr)) $arr['sig'] = ''; $allowed_languages = get_pconfig($arr['uid'],'system','allowed_languages'); if((is_array($allowed_languages)) && ($arr['lang']) && (! array_key_exists($arr['lang'],$allowed_languages))) { $translate = [ 'item' => $arr, 'from' => $arr['lang'], 'to' => $allowed_languages, 'translated' => false ]; /** * @hooks item_translate * Called from item_store and item_store_update after the post language has been autodetected. * * \e array \b item * * \e string \b from * * \e string \b to * * \e boolean \b translated */ Hook::call('item_translate', $translate); if((! $translate['translated']) && (intval(get_pconfig($arr['uid'],'system','reject_disallowed_languages')))) { logger('language ' . $arr['lang'] . ' not accepted for uid ' . $arr['uid']); $ret['message'] = 'language not accepted'; return $ret; } $arr = $translate['item']; } if(isset($arr['obj'])) { $arr['obj'] = item_json_encapsulate($arr,'obj'); } if(isset($arr['target'])) { $arr['target'] = item_json_encapsulate($arr,'target'); } if(isset($arr['attach'])) { $arr['attach'] = item_json_encapsulate($arr,'attach'); } $arr['aid'] = ((x($arr,'aid')) ? intval($arr['aid']) : 0); $arr['mid'] = ((x($arr,'mid')) ? notags(trim($arr['mid'])) : random_string()); $arr['revision'] = ((x($arr,'revision') && intval($arr['revision']) > 0) ? intval($arr['revision']) : 0); $arr['author_xchan'] = ((x($arr,'author_xchan')) ? notags(trim($arr['author_xchan'])) : ''); $arr['owner_xchan'] = ((x($arr,'owner_xchan')) ? notags(trim($arr['owner_xchan'])) : ''); $arr['created'] = ((x($arr,'created') !== false) ? datetime_convert('UTC','UTC',$arr['created']) : datetime_convert()); $arr['edited'] = ((x($arr,'edited') !== false) ? datetime_convert('UTC','UTC',$arr['edited']) : datetime_convert()); $arr['expires'] = ((x($arr,'expires') !== false) ? datetime_convert('UTC','UTC',$arr['expires']) : NULL_DATE); $arr['commented'] = ((x($arr,'commented') !== false) ? datetime_convert('UTC','UTC',$arr['commented']) : datetime_convert()); $arr['comments_closed'] = ((x($arr,'comments_closed') !== false) ? datetime_convert('UTC','UTC',$arr['comments_closed']) : NULL_DATE); $arr['html'] = ((array_key_exists('html',$arr)) ? $arr['html'] : ''); if($deliver) { $arr['received'] = datetime_convert(); $arr['changed'] = datetime_convert(); } else { // When deliver flag is false, we are *probably* performing an import or bulk migration. // If one updates the changed timestamp it will be made available to zotfeed and delivery // will still take place through backdoor methods. Since these fields are rarely used // otherwise, just preserve the original timestamp. $arr['received'] = ((x($arr,'received') !== false) ? datetime_convert('UTC','UTC',$arr['received']) : datetime_convert()); $arr['changed'] = ((x($arr,'changed') !== false) ? datetime_convert('UTC','UTC',$arr['changed']) : datetime_convert()); } $arr['location'] = ((x($arr,'location')) ? notags(trim($arr['location'])) : ''); $arr['lat'] = ((x($arr, 'lat')) ? floatval($arr['lat']) : 0.0); $arr['lon'] = ((x($arr, 'lon')) ? floatval($arr['lon']) : 0.0); $arr['parent_mid'] = ((x($arr,'parent_mid')) ? notags(trim($arr['parent_mid'])) : ''); $arr['thr_parent'] = ((x($arr,'thr_parent')) ? notags(trim($arr['thr_parent'])) : $arr['parent_mid']); $arr['verb'] = ((x($arr,'verb')) ? notags(trim($arr['verb'])) : ACTIVITY_POST); $arr['obj_type'] = ((x($arr,'obj_type')) ? notags(trim($arr['obj_type'])) : ACTIVITY_OBJ_NOTE); $arr['obj'] = ((x($arr,'obj')) ? trim($arr['obj']) : ''); $arr['tgt_type'] = ((x($arr,'tgt_type')) ? notags(trim($arr['tgt_type'])) : ''); $arr['target'] = ((x($arr,'target')) ? trim($arr['target']) : ''); $arr['plink'] = ((x($arr,'plink')) ? notags(trim($arr['plink'])) : ''); $arr['attach'] = ((x($arr,'attach')) ? notags(trim($arr['attach'])) : ''); $arr['app'] = ((x($arr,'app')) ? notags(trim($arr['app'])) : ''); $arr['replyto'] = ((x($arr,'replyto')) ? serialise($arr['replyto']) : ''); // No longer used but needs to be set to something or the database will complain. $arr['route'] = ''; $arr['public_policy'] = ''; $arr['comment_policy'] = ((x($arr,'comment_policy')) ? notags(trim($arr['comment_policy'])) : 'contacts' ); $arr['item_unseen'] = ((array_key_exists('item_unseen',$arr)) ? intval($arr['item_unseen']) : 1 ); $arr['item_restrict'] = ((array_key_exists('item_restrict',$arr)) ? intval($arr['item_restrict']) : 0 ); if((! array_key_exists('item_nocomment',$arr)) && ($arr['comment_policy'] == 'none')) $arr['item_nocomment'] = 1; // handle time travelers // Allow a bit of fudge in case somebody just has a slightly slow/fast clock $d1 = new DateTime('now +10 minutes', new DateTimeZone('UTC')); $d2 = new DateTime($arr['created'] . '+00:00'); if($d2 > $d1) $arr['item_delayed'] = 1; $arr['llink'] = z_root() . '/display/?mid=' . gen_link_id($arr['mid']); if (! $arr['plink']) { $arr['plink'] = $arr['llink']; } if($arr['parent_mid'] === $arr['mid']) { $parent_id = 0; $parent_deleted = 0; $allow_cid = $arr['allow_cid']; $allow_gid = $arr['allow_gid']; $deny_cid = $arr['deny_cid']; $deny_gid = $arr['deny_gid']; $comments_closed = $arr['comments_closed']; $arr['item_thread_top'] = 1; $arr['item_level'] = 0; } else { // find the parent and snarf the item id and ACL's // and anything else we need to inherit $r = q("SELECT item_level FROM item WHERE (mid = '%s' OR mid = '%s') AND uid = %d ORDER BY id ASC LIMIT 1", dbesc($arr['thr_parent']), dbesc(reverse_activity_mid($arr['thr_parent'])), intval($arr['uid']) ); if($r) { $arr['item_level'] = intval($r[0]['item_level']) + 1; } else { $arr['item_level'] = 1; } $r = q("SELECT * FROM item WHERE (mid = '%s' OR mid = '%s') AND uid = %d ORDER BY id ASC LIMIT 1", dbesc($arr['parent_mid']), dbesc(reverse_activity_mid($arr['parent_mid'])), intval($arr['uid']) ); // We may have this parent_mid without a token, so try that if we find a token if (! $r) { if (strpos($arr['parent_mid'],'token=')) { $r = q("SELECT * FROM item WHERE mid = '%s' AND uid = %d ORDER BY id ASC LIMIT 1", dbesc(substr($arr['parent_mid'],0,strpos($arr['parent_mid'],'?'))), intval($arr['uid']) ); if ($r) { $arr['parent_mid'] = $arr['thr_parent'] = substr($arr['parent_mid'],0,strpos($arr['parent_mid'],'?')); } } } if ($r) { $parent_item = array_shift($r); // in case item_store was killed before the parent's parent attribute got set, // set it now. This happens with some regularity on Dreamhost. This will keep // us from getting notifications for threads that exist but which we can't see. if(($parent_item['mid'] === $parent_item['parent_mid']) && (! intval($parent_item['parent']))) { q("update item set parent = id where id = %d", intval($parent_item['id']) ); } if(comments_are_now_closed($parent_item)) { logger('item_store: comments closed'); $ret['message'] = 'Comments closed.'; return $ret; } if(($arr['obj_type'] == ACTIVITY_OBJ_NOTE) && (! $arr['obj'])) $arr['obj_type'] = ACTIVITY_OBJ_COMMENT; // is the new message multi-level threaded? // even though we don't support it now, preserve the info // and re-attach to the conversation parent. if($parent_item['mid'] != $parent_item['parent_mid']) { $arr['parent_mid'] = $parent_item['parent_mid']; $z = q("SELECT * FROM item WHERE mid = '%s' AND parent_mid = '%s' AND uid = %d ORDER BY id ASC LIMIT 1", dbesc($parent_item['parent_mid']), dbesc($parent_item['parent_mid']), intval($arr['uid']) ); if($z && count($z)) $r = $z; } $parent_id = $parent_item['id']; $parent_deleted = $parent_item['item_deleted']; // item_restrict indicates an activitypub reply with a different // delivery algorithm than the "parent-relay" or inherited privacy mode if(! (intval($arr['item_restrict']) & 1)) { $allow_cid = $parent_item['allow_cid']; $allow_gid = $parent_item['allow_gid']; $deny_cid = $parent_item['deny_cid']; $deny_gid = $parent_item['deny_gid']; } $comments_closed = $parent_item['comments_closed']; if(intval($parent_item['item_wall'])) $arr['item_wall'] = 1; // An uplinked comment might arrive with a downstream owner. // Fix it. if($parent_item['owner_xchan'] !== $arr['owner_xchan']) { $arr['owner_xchan'] = $parent_item['owner_xchan']; // $uplinked_comment = true; } // if the parent is private and the child is not, force privacy for the entire conversation // if the child is private, leave it alone regardless of the parent privacy state if(intval($parent_item['item_private']) && (! intval($arr['item_private']))) { $arr['item_private'] = $parent_item['item_private']; } // Edge case. We host a public forum that was originally posted privately. // The original author commented, but as this is a comment, the permissions // weren't fixed up and will still show the comment as private unless we fix it here. if(intval($parent_item['item_uplink']) && (! $parent_item['item_private'])) $arr['item_private'] = 0; if(in_array($arr['obj_type'], ['Note','Answer']) && $parent_item['obj_type'] === 'Question' && intval($parent_item['item_wall'])) { Activity::update_poll($parent_item, $arr); } } else { logger('item_store: item parent was not found - ignoring item'); $ret['message'] = 'parent not found.'; return $ret; } } if($parent_deleted) $arr['item_deleted'] = 1; $r = q("SELECT id FROM item WHERE mid = '%s' AND uid = %d and revision = %d LIMIT 1", dbesc($arr['mid']), intval($arr['uid']), intval($arr['revision']) ); if($r) { logger('duplicate item ignored. ' . print_r($arr,true)); $ret['message'] = 'duplicate post.'; return $ret; } if (LibBlock::fetch_by_entity($arr['uid'],$arr['owner_xchan']) || LibBlock::fetch_by_entity($arr['uid'],$arr['author_xchan'])) { logger('Post cancelled by block rule.'); $ret['message'] = 'cancelled.'; return $ret; } /** * @hooks item_store * Called when item_store() stores a record of type item. */ Hook::call('item_store', $arr); /** * @hooks post_remote * Called when an activity arrives from another site. * This hook remains for backward compatibility. */ Hook::call('post_remote', $arr); if(x($arr, 'cancel')) { logger('Post cancelled by plugin.'); $ret['message'] = 'cancelled.'; return $ret; } // pull out all the taxonomy stuff for separate storage $terms = null; if(array_key_exists('term',$arr)) { $terms = $arr['term']; unset($arr['term']); } $meta = null; if(array_key_exists('iconfig',$arr)) { $meta = $arr['iconfig']; unset($arr['iconfig']); } $private = intval($arr['item_private']); if (! $private) { if (strlen($allow_cid) || strlen($allow_gid) || strlen($deny_cid) || strlen($deny_gid)) { $private = 1; } } $arr['parent'] = $parent_id; $arr['allow_cid'] = $allow_cid; $arr['allow_gid'] = $allow_gid; $arr['deny_cid'] = $deny_cid; $arr['deny_gid'] = $deny_gid; $arr['item_private'] = $private; $arr['comments_closed'] = $comments_closed; logger('item_store: ' . print_r($arr,true), LOGGER_DATA); logger('item_store_terms: ' . print_r($terms, true), LOGGER_DATA); create_table_from_array('item',$arr); // find the item we just created $r = q("SELECT * FROM item WHERE mid = '%s' AND uid = %d and revision = %d ORDER BY id ASC ", dbesc($arr['mid']), intval($arr['uid']), intval($arr['revision']) ); if($r) { // This will give us a fresh copy of what's now in the DB and undo the db escaping, // which really messes up the notifications $current_post = $r[0]['id']; $arr = $r[0]; logger('item_store: created item ' . $current_post, LOGGER_DEBUG); } else { logger('item_store: could not locate stored item'); $ret['message'] = 'unable to retrieve.'; return $ret; } if(count($r) > 1) { logger('item_store: duplicated post occurred. Removing duplicates.'); q("DELETE FROM item WHERE mid = '%s' AND uid = %d AND id != %d ", $arr['mid'], intval($arr['uid']), intval($current_post) ); } $arr['id'] = $current_post; if(! intval($r[0]['parent'])) { q("update item set parent = id where id = %d", intval($r[0]['id']) ); $arr['parent'] = $r[0]['id']; } // Store taxonomy if(($terms) && (is_array($terms))) { foreach($terms as $t) { q("insert into term (uid,oid,otype,ttype,term,url) values(%d,%d,%d,%d,'%s','%s') ", intval($arr['uid']), intval($current_post), intval(TERM_OBJ_POST), intval($t['ttype']), dbesc($t['term']), dbesc($t['url']) ); } $arr['term'] = $terms; } if($meta) { foreach($meta as $m) { set_iconfig($current_post,$m['cat'],$m['k'],$m['v'],$m['sharing']); } $arr['iconfig'] = $meta; } $ret['item'] = $arr; /** * @hooks post_remote_end * Called after processing a remote post. */ Hook::call('post_remote_end', $arr); item_update_parent_commented($arr); if((str_contains($arr['body'], '[embed]')) || (str_contains($arr['body'], '[/img]')) || (str_contains($arr['body'], '[/zmg]'))) { Run::Summon([ 'Cache_embeds', $current_post ]); } // If _creating_ a deleted item, don't propagate it further or send out notifications. // We need to store the item details just in case the delete came in before the original post, // so that we have an item in the DB that's marked deleted and won't store a fresh post // that isn't aware that we were already told to delete it. if(($deliver) && (! intval($arr['item_deleted']))) { send_status_notifications($arr); tag_deliver($arr['uid'],$current_post); } $ret['success'] = true; $ret['item_id'] = $current_post; // if($linkid) { // $li = [ $ret['item'] ]; // xchan_query($li); // $sync_item = fetch_post_tags($li); // Libsync::build_link_packet($arr['uid'],[ 'item' => [ encode_item($sync_item[0],true) ] ]); // } return $ret; } /** * @brief Update a stored item. * * @param array $arr an item * @param boolean $deliver (optional) default true * @return array */ function item_store_update($arr, $deliver = true) { $d = [ 'item' => $arr, ]; /** * @hooks item_store_update_before * Called when item_store_update() is called to update a stored item. It * overwrites the function's parameters $arr * * \e array \b item */ Hook::call('item_store_update_before', $d); $arr = $d['item']; $ret = ['success' => false, 'item_id' => 0]; if(array_key_exists('cancel',$arr) && $arr['cancel']) { logger('cancelled by plugin'); return $ret; } if(! intval($arr['uid'])) { logger('no uid'); $ret['message'] = 'no uid.'; return $ret; } if(! intval($arr['id'])) { logger('no id'); $ret['message'] = 'no id.'; return $ret; } $orig_post_id = $arr['id']; $uid = $arr['uid']; $orig = q("select * from item where id = %d and uid = %d limit 1", intval($orig_post_id), intval($uid) ); if(! $orig) { logger('Original post not found: ' . $orig_post_id); $ret['message'] = 'no original'; return $ret; } // override the unseen flag with the original $arr['item_unseen'] = $orig[0]['item_unseen']; $arr['title'] = ((array_key_exists('title',$arr) && strlen($arr['title'])) ? trim(escape_tags($arr['title'])) : ''); $arr['body'] = ((array_key_exists('body',$arr) && strlen($arr['body'])) ? trim($arr['body']) : ''); $arr['html'] = ((array_key_exists('html',$arr) && strlen($arr['html'])) ? trim($arr['html']) : ''); if(array_key_exists('edit',$arr)) unset($arr['edit']); $arr['mimetype'] = ((x($arr,'mimetype')) ? notags(trim($arr['mimetype'])) : 'text/x-multicode'); $languagetext = prepare_text($arr['body'],((isset($arr['mimetype'])) ? $arr['mimetype'] : 'text/x-multicode')); $languagetext = html2plain((isset($arr['title']) && $arr['title']) ? $arr['title'] . ' ' . $languagetext : $languagetext); $detector = new LanguageDetect(); $arr['lang'] = $detector->detect($languagetext); // apply the input filter here $arr['summary'] = trim(z_input_filter($arr['summary'],$arr['mimetype'])); $arr['body'] = trim(z_input_filter($arr['body'],$arr['mimetype'])); item_sign($arr); $allowed_languages = get_pconfig($arr['uid'],'system','allowed_languages'); if((is_array($allowed_languages)) && ($arr['lang']) && (! array_key_exists($arr['lang'],$allowed_languages))) { $translate = [ 'item' => $arr, 'from' => $arr['lang'], 'to' => $allowed_languages, 'translated' => false ]; /** * @hooks item_translate * Called from item_store() and item_store_update() after the post language has been autodetected. * * \e array \b item - returned value * * \e string \b from * * \e string \b to * * \e boolean \b translated - default false, set true if hook translated it and provide it in item */ Hook::call('item_translate', $translate); if((! $translate['translated']) && (intval(get_pconfig($arr['uid'],'system','reject_disallowed_languages')))) { logger('item_store: language ' . $arr['lang'] . ' not accepted for uid ' . $arr['uid']); $ret['message'] = 'language not accepted'; return $ret; } $arr = $translate['item']; } if(isset($arr['obj'])) { $arr['obj'] = item_json_encapsulate($arr,'obj'); } if(isset($arr['target'])) { $arr['target'] = item_json_encapsulate($arr,'target'); } if(isset($arr['attach'])) { $arr['attach'] = item_json_encapsulate($arr,'attach'); } unset($arr['id']); unset($arr['uid']); unset($arr['aid']); unset($arr['uuid']); unset($arr['mid']); unset($arr['parent']); unset($arr['parent_mid']); unset($arr['author_xchan']); unset($arr['owner_xchan']); unset($arr['source_xchan']); unset($arr['thr_parent']); unset($arr['llink']); if (intval($orig[0]['item_unpublished'])) { $arr['created'] = ((x($arr,'created') !== false) ? datetime_convert('UTC','UTC',$arr['created']) : datetime_convert()); $arr['edited'] = $arr['created']; $arr['expires'] = ((x($arr,'expires') !== false) ? datetime_convert('UTC','UTC',$arr['expires']) : NULL_DATE); if(array_key_exists('comments_closed',$arr) && $arr['comments_closed'] > NULL_DATE) $arr['comments_closed'] = datetime_convert('UTC','UTC',$arr['comments_closed']); else $arr['comments_closed'] = NULL_DATE; $arr['commented'] = $arr['created']; $arr['received'] = $arr['created']; $arr['changed'] = $arr['created']; } else { unset($arr['created']); $arr['expires'] = ((x($arr,'expires') !== false) ? datetime_convert('UTC','UTC',$arr['expires']) : $orig[0]['expires']); if(array_key_exists('comments_closed',$arr) && $arr['comments_closed'] > NULL_DATE) $arr['comments_closed'] = datetime_convert('UTC','UTC',$arr['comments_closed']); else $arr['comments_closed'] = $orig[0]['comments_closed']; $arr['commented'] = $orig[0]['commented']; $arr['received'] = $orig[0]['received']; $arr['changed'] = $orig[0]['changed']; } $arr['edited'] = ((x($arr,'edited') !== false) ? datetime_convert('UTC','UTC',$arr['edited']) : datetime_convert()); $arr['revision'] = ((x($arr,'revision') && $arr['revision'] > 0) ? intval($arr['revision']) : 0); $arr['location'] = ((x($arr,'location')) ? notags(trim($arr['location'])) : $orig[0]['location']); $arr['uuid'] = ((x($arr,'uuid')) ? notags(trim($arr['uuid'])) : $orig[0]['uuid']); $arr['approved'] = ((x($arr,'approved')) ? notags(trim($arr['approved'])) : ''); $arr['lat'] = ((x($arr,'lat')) ? floatval($arr['lat']) : $orig[0]['lat']); $arr['lon'] = ((x($arr,'lon')) ? floatval($arr['lon']) : $orig[0]['lon']); $arr['verb'] = ((x($arr,'verb')) ? notags(trim($arr['verb'])) : $orig[0]['verb']); $arr['obj_type'] = ((x($arr,'obj_type')) ? notags(trim($arr['obj_type'])) : $orig[0]['obj_type']); $arr['obj'] = ((x($arr,'obj')) ? trim($arr['obj']) : $orig[0]['obj']); $arr['tgt_type'] = ((x($arr,'tgt_type')) ? notags(trim($arr['tgt_type'])) : $orig[0]['tgt_type']); $arr['target'] = ((x($arr,'target')) ? trim($arr['target']) : $orig[0]['target']); $arr['plink'] = ((x($arr,'plink')) ? notags(trim($arr['plink'])) : $orig[0]['plink']); $arr['replyto'] = ((x($arr,'replyto')) ? serialise($arr['replyto']) : $orig[0]['replyto']); $arr['allow_cid'] = ((array_key_exists('allow_cid',$arr)) ? trim($arr['allow_cid']) : $orig[0]['allow_cid']); $arr['allow_gid'] = ((array_key_exists('allow_gid',$arr)) ? trim($arr['allow_gid']) : $orig[0]['allow_gid']); $arr['deny_cid'] = ((array_key_exists('deny_cid',$arr)) ? trim($arr['deny_cid']) : $orig[0]['deny_cid']); $arr['deny_gid'] = ((array_key_exists('deny_gid',$arr)) ? trim($arr['deny_gid']) : $orig[0]['deny_gid']); $arr['item_private'] = ((array_key_exists('item_private',$arr)) ? intval($arr['item_private']) : $orig[0]['item_private']); $arr['attach'] = ((array_key_exists('attach',$arr)) ? notags(trim($arr['attach'])) : $orig[0]['attach']); $arr['app'] = ((array_key_exists('app',$arr)) ? notags(trim($arr['app'])) : $orig[0]['app']); $arr['item_origin'] = ((array_key_exists('item_origin',$arr)) ? intval($arr['item_origin']) : $orig[0]['item_origin'] ); $arr['item_unseen'] = ((array_key_exists('item_unseen',$arr)) ? intval($arr['item_unseen']) : $orig[0]['item_unseen'] ); $arr['item_starred'] = ((array_key_exists('item_starred',$arr)) ? intval($arr['item_starred']) : $orig[0]['item_starred'] ); $arr['item_uplink'] = ((array_key_exists('item_uplink',$arr)) ? intval($arr['item_uplink']) : $orig[0]['item_uplink'] ); $arr['item_consensus'] = ((array_key_exists('item_consensus',$arr)) ? intval($arr['item_consensus']) : $orig[0]['item_consensus'] ); $arr['item_wall'] = ((array_key_exists('item_wall',$arr)) ? intval($arr['item_wall']) : $orig[0]['item_wall'] ); $arr['item_thread_top'] = ((array_key_exists('item_thread_top',$arr)) ? intval($arr['item_thread_top']) : $orig[0]['item_thread_top'] ); $arr['item_notshown'] = ((array_key_exists('item_notshown',$arr)) ? intval($arr['item_notshown']) : $orig[0]['item_notshown'] ); $arr['item_nsfw'] = ((array_key_exists('item_nsfw',$arr)) ? intval($arr['item_nsfw']) : $orig[0]['item_nsfw'] ); $arr['item_relay'] = ((array_key_exists('item_relay',$arr)) ? intval($arr['item_relay']) : $orig[0]['item_relay'] ); $arr['item_mentionsme'] = ((array_key_exists('item_mentionsme',$arr)) ? intval($arr['item_mentionsme']) : $orig[0]['item_mentionsme'] ); $arr['item_nocomment'] = ((array_key_exists('item_nocomment',$arr)) ? intval($arr['item_nocomment']) : $orig[0]['item_nocomment'] ); $arr['item_obscured'] = ((array_key_exists('item_obscured',$arr)) ? intval($arr['item_obscured']) : $orig[0]['item_obscured'] ); $arr['item_verified'] = ((array_key_exists('item_verified',$arr)) ? intval($arr['item_verified']) : $orig[0]['item_verified'] ); $arr['item_retained'] = ((array_key_exists('item_retained',$arr)) ? intval($arr['item_retained']) : $orig[0]['item_retained'] ); $arr['item_rss'] = ((array_key_exists('item_rss',$arr)) ? intval($arr['item_rss']) : $orig[0]['item_rss'] ); $arr['item_deleted'] = ((array_key_exists('item_deleted',$arr)) ? intval($arr['item_deleted']) : $orig[0]['item_deleted'] ); $arr['item_type'] = ((array_key_exists('item_type',$arr)) ? intval($arr['item_type']) : $orig[0]['item_type'] ); $arr['item_hidden'] = ((array_key_exists('item_hidden',$arr)) ? intval($arr['item_hidden']) : $orig[0]['item_hidden'] ); $arr['item_unpublished'] = ((array_key_exists('item_unpublished',$arr)) ? intval($arr['item_unpublished']) : $orig[0]['item_unpublished'] ); $arr['item_delayed'] = ((array_key_exists('item_delayed',$arr)) ? intval($arr['item_delayed']) : $orig[0]['item_delayed'] ); $arr['item_pending_remove'] = ((array_key_exists('item_pending_remove',$arr)) ? intval($arr['item_pending_remove']) : $orig[0]['item_pending_remove'] ); $arr['item_blocked'] = ((array_key_exists('item_blocked',$arr)) ? intval($arr['item_blocked']) : $orig[0]['item_blocked'] ); $arr['sig'] = ((x($arr,'sig')) ? $arr['sig'] : ''); $arr['layout_mid'] = ((array_key_exists('layout_mid',$arr)) ? dbesc($arr['layout_mid']) : $orig[0]['layout_mid'] ); $arr['public_policy'] = ''; $arr['comment_policy'] = ((x($arr,'comment_policy')) ? notags(trim($arr['comment_policy'])) : $orig[0]['comment_policy'] ); /** * @hooks post_remote_update * Called when processing a remote post that involved an edit or update. */ Hook::call('post_remote_update', $arr); if(x($arr, 'cancel')) { logger('Post cancelled by plugin.'); $ret['message'] = 'cancelled.'; return $ret; } // pull out all the taxonomy stuff for separate storage $terms = null; if(array_key_exists('term',$arr)) { $terms = $arr['term']; unset($arr['term']); } $meta = null; if(array_key_exists('iconfig',$arr)) { $meta = $arr['iconfig']; unset($arr['iconfig']); } logger('item_store_update: ' . print_r($arr,true), LOGGER_DATA); logger('item_store_update_terms: ' . print_r($terms, true), LOGGER_DATA); $str = ''; foreach($arr as $k => $v) { if($str) $str .= ","; $str .= " " . TQUOT . dbesc($k) . TQUOT . " = '" . dbesc($v) . "' "; } $r = dbq("update item set " . $str . " where id = " . $orig_post_id ); if($r) logger('Updated item ' . $orig_post_id, LOGGER_DEBUG); else { logger('Could not update item'); $ret['message'] = 'DB update failed.'; return $ret; } // fetch an unescaped complete copy of the stored item $r = q("select * from item where id = %d", intval($orig_post_id) ); if($r) $arr = $r[0]; $r = q("delete from term where oid = %d and otype = %d", intval($orig_post_id), intval(TERM_OBJ_POST) ); if(is_array($terms)) { foreach($terms as $t) { q("insert into term (uid,oid,otype,ttype,term,url) values(%d,%d,%d,%d,'%s','%s') ", intval($uid), intval($orig_post_id), intval(TERM_OBJ_POST), intval($t['ttype']), dbesc($t['term']), dbesc($t['url']) ); } $arr['term'] = $terms; } $r = q("delete from iconfig where iid = %d", intval($orig_post_id) ); if($meta) { foreach($meta as $m) { set_iconfig($orig_post_id,$m['cat'],$m['k'],$m['v'],$m['sharing']); } $arr['iconfig'] = $meta; } $ret['item'] = $arr; /** * @hooks post_remote_update_end * Called after processing a remote post that involved an edit or update. */ Hook::call('post_remote_update_end', $arr); if((str_contains($arr['body'], '[embed]')) || (str_contains($arr['body'], '[/img]'))) { Run::Summon([ 'Cache_embeds', $orig_post_id ]); } if($deliver) { // don't send notify_comment for edits // send_status_notifications($orig_post_id,$arr); tag_deliver($uid,$orig_post_id); } $ret['success'] = true; $ret['item_id'] = $orig_post_id; return $ret; } function item_update_parent_commented($item) { $update_parent = true; // update the commented timestamp on the parent // - unless this is a moderated comment or a potential clone of an older item // which we don't wish to bring to the surface. As the queue only holds deliveries // for 3 days, it's suspected of being an older cloned item if the creation time // is older than that. if(intval($item['item_blocked']) === ITEM_MODERATED) $update_parent = false; if($item['created'] < datetime_convert('','','now - 4 days')) $update_parent = false; if($update_parent) { $z = q("select max(created) as commented from item where parent_mid = '%s' and uid = %d and item_delayed = 0 and item_deleted = 0", dbesc($item['parent_mid']), intval($item['uid']) ); q("UPDATE item set commented = '%s', changed = '%s' WHERE id = %d", dbesc(($z) ? $z[0]['commented'] : datetime_convert()), dbesc(datetime_convert()), intval($item['parent']) ); } } function send_status_notifications($item) { // logger('notifications: ' . print_r($item, true)); $original_author = false; if ($item['mid'] === $item['parent_mid']) { if (intval($item['item_blocked']) === ITEM_MODERATED) { if (str_contains($item['body'],'[/share]')) { preg_match("/portable_id='(.*?)'/ism", $item['body'], $matches); logger('matches: ' . print_r($matches, true)); if ($matches) { $s = q("select * from xchan where xchan_hash = '%s'", dbesc(urldecode($matches[1])) ); if ($s) { $original_author = array_shift($s); } } } } else { return; } } $notify = false; $unfollowed = false; $parent = 0; $thr_parent_id = 0; if (array_key_exists('verb', $item) && (activity_match($item['verb'], ACTIVITY_LIKE) || activity_match($item['verb'], ACTIVITY_DISLIKE))) { $r = q("select id from item where mid = '%s' and uid = %d limit 1", dbesc($item['thr_parent']), intval($item['uid']) ); $thr_parent_id = $r[0]['id']; } $r = Channel::from_id($item['uid']); if (!$r) { return; } // my own post - no notification needed if ($item['author_xchan'] === $r['channel_hash'] && intval($item['item_blocked']) !== ITEM_MODERATED) { return; } // I'm the owner - notify me if ($item['owner_xchan'] === $r['channel_hash']) { $notify = true; } // Was I involved in this conversation? $x = q("select * from item where parent_mid = '%s' and uid = %d", dbesc($item['parent_mid']), intval($item['uid']) ); if ($x) { foreach ($x as $xx) { if ($xx['author_xchan'] === $r['channel_hash']) { $notify = true; // check for an unfollow thread activity - we should probably decode the obj and check the id // but it will be extremely rare for this to be wrong. if (($xx['verb'] === ACTIVITY_IGNORE) && ($xx['obj_type'] === ACTIVITY_OBJ_NOTE || $xx['obj_type'] === ACTIVITY_OBJ_PHOTO) && ($xx['parent'] != $xx['id'])) { $unfollowed = true; } } if ($xx['id'] == $xx['parent']) { $parent = $xx['parent']; } } } if ($unfollowed) { return; } if (intval($item['item_private']) === 2) { $notify_type = NOTIFY_MAIL; } elseif (intval($item['item_blocked']) === ITEM_MODERATED) { $notify_type = NOTIFY_MODERATE; } elseif ($item['verb'] === 'Announce') { $notify_type = NOTIFY_RESHARE; } else { $notify_type = NOTIFY_COMMENT; } $link = z_root() . (($notify_type === NOTIFY_MODERATE) ? '/moderate' : '/display' ) . '/?mid=' . gen_link_id($item['mid']); $y = q("select id from notify where link = '%s' and uid = %d limit 1", dbesc($link), intval($item['uid']) ); if ($y) { $notify = false; } if (! $notify) { return; } Enotify::submit([ 'type' => $notify_type, 'from_xchan' => $original_author ? $original_author['xchan_hash'] : $item['author_xchan'], 'to_xchan' => $r['channel_hash'], 'item' => $item, 'link' => $link, 'verb' => $item['verb'], 'otype' => 'item', 'parent' => $thr_parent_id ?: $parent, 'parent_mid' => $thr_parent_id ? $item['thr_parent'] : $item['parent_mid'] ]); } /** * @brief Called when we deliver things that might be tagged in ways that require delivery processing. * * Handles community tagging of posts and also look for mention tags and sets up * a second delivery chain if appropriate. * * @param int $uid * @param int $item_id */ function tag_deliver($uid, $item_id) { $role = get_pconfig($uid,'system','permissions_role'); $rolesettings = PermissionRoles::role_perms($role); $channel_type = isset($rolesettings['channel_type']) ? $rolesettings['channel_type'] : 'normal'; $is_group = ($channel_type === 'group'); $is_collection = ($channel_type === 'collection'); logger('checking tags'); if ($is_group) { logger('Channel is a group.'); } /* * Fetch stuff we need - a channel and an item */ $u = Channel::from_id($uid); if (! $u) { return; } $i = q("select * from item where id = %d and uid = %d limit 1", intval($item_id), intval($uid) ); if (! $i) { return; } xchan_query($i,true); $i = fetch_post_tags($i); $item = array_shift($i); if (intval($item['item_uplink']) && $item['source_xchan'] && intval($item['item_thread_top']) && ($item['edited'] != $item['created'])) { // this is an update (edit) to a post which was already processed by us and has a second delivery chain // Just start the second delivery chain to deliver the updated post // after resetting ownership and permission bits logger('updating edited tag_deliver post for ' . $u['channel_address']); start_delivery_chain($u, $item, $item_id, false, false, true); return; } // send mail (DM) notifications here, but only for top level posts. // followups will be processed by send_status_notifications() $mail_notify = false; if ((! $item['item_wall']) && intval($item['item_thread_top']) && $item['author_xchan'] !== $u['channel_hash'] && intval($item['item_private']) === 2) { Enotify::submit([ 'to_xchan' => $u['channel_hash'], 'from_xchan' => $item['author_xchan'], 'type' => NOTIFY_MAIL, 'item' => $item, 'link' => $item['llink'], 'verb' => 'DM', 'otype' => 'item' ]); $mail_notify = true; } // important - ignore wall posts here else dm's by group owner will be sent to group. if ($is_group && intval($item['item_private']) === 2 && intval($item['item_thread_top']) && (! intval($item['item_wall']))) { // group delivery via DM - use post_wall permission since send_stream is probably turned off // and this will be turned into an embedded wall-to-wall post if(perm_is_allowed($uid, $item['author_xchan'], 'post_wall')) { logger('group DM delivery for ' . $u['channel_address']); start_delivery_chain($u, $item, $item_id, false, true, (($item['edited'] != $item['created']) || $item['item_deleted'])); q("update item set item_blocked = %d where id = %d", intval(ITEM_HIDDEN), intval($item_id) ); } return; } // Deliver to group via target collection if ($is_group && intval($item['item_thread_top']) && (! intval($item['item_wall'])) && (str_contains($item['tgt_type'], 'Collection')) && $item['target']) { // group delivery via target - use post_wall permission since send_stream is probably turned off // and this will be turned into an embedded wall-to-wall post if (is_array($item['target'])) { $a = $item['target']; } else { $a = json_decode($item['target'],true); } if ($a) { $id = ((is_string($a)) ? $a : EMPTY_STR); if (is_array($a) && isset($a['id'])) { $id = $a['id']; } if ($id == z_root() . '/outbox/' . $u['channel_address']) { if(perm_is_allowed($uid, $item['author_xchan'], 'post_wall')) { logger('group collection delivery for ' . $u['channel_address']); start_delivery_chain($u, $item, $item_id, false, true, (($item['edited'] != $item['created']) || $item['item_deleted'])); q("update item set item_blocked = %d where id = %d", intval(ITEM_HIDDEN), intval($item_id) ); } return; } } } if ($is_group && intval($item['item_thread_top']) && intval($item['item_wall']) && $item['author_xchan'] !== $item['owner_xchan']) { if (strpos($item['body'],'[/share]')) { logger('W2W post already shared'); return; } // group delivery via W2W logger('rewriting W2W post for ' . $u['channel_address']); start_delivery_chain($u, $item, $item_id, false, true, (($item['edited'] != $item['created']) || $item['item_deleted'])); q("update item set item_wall = 0 where id = %d", intval($item_id) ); return; } /* * A "union" is a message which our channel has sourced from another channel. * This sets up a second delivery chain just like forum tags do. * Find out if this is a source-able post. */ $union = check_item_source($uid,$item); if ($union) { logger('check_item_source returns true'); } // This might be a followup (e.g. comment) to an already uplinked item // If so setup a second delivery chain if (! intval($item['item_thread_top'])) { $x = q("select * from item where id = parent and parent = %d and uid = %d limit 1", intval($item['parent']), intval($uid) ); if ($x) { // group comments don't normally require a second delivery chain // but we create a linked Announce so they will show up in the home timeline // on microblog platforms and this creates a second delivery chain if (($is_group || $union) && intval($x[0]['item_wall'])) { // don't let the forked delivery chain recurse if ($item['verb'] === 'Announce' && $item['author_xchan'] === $u['channel_hash']) { return; } // don't boost moderated content until it has been approved if (intval($item['item_blocked']) === ITEM_MODERATED) { return; } // don't boost likes and other response activities as it is likely that // few platforms will handle this in an elegant way if (ActivityStreams::is_response_activity($item['verb'])) { return; } logger('group_comment'); start_delivery_chain($u, $item, $item_id, $x[0], true, (($item['edited'] != $item['created']) || $item['item_deleted'])); } elseif (intval($x[0]['item_uplink'])) { start_delivery_chain($u,$item,$item_id,$x[0]); } } } /* * Now we've got those out of the way. Let's see if this is a post that's tagged for re-delivery */ $terms = ((isset($item['term'])) ? get_terms_oftype($item['term'],[TERM_MENTION, TERM_FORUM]) : false); $pterms = ((isset($item['term'])) ? get_terms_oftype($item['term'], [TERM_PCATEGORY, TERM_HASHTAG] ) : false); if ($terms) { logger('Post mentions: ' . print_r($terms,true), LOGGER_DATA); } if ($pterms) { logger('Post collections: ' . print_r($pterms,true), LOGGER_DATA); } $link = normalise_link($u['xchan_url']); if ($terms) { foreach ($terms as $term) { if (! link_compare($term['url'],$link)) { continue; } logger('Mention found for ' . $u['channel_name']); q("update item set item_mentionsme = 1 where id = %d", intval($item_id) ); // At this point we've determined that the person receiving this post was mentioned in it or it is a union. // Now let's check if this mention was inside a reshare so we don't spam a forum $body = strip_share_content($item['body']); $tagged = false; $matches = []; $pattern = '/[!@]!?\[[uz]rl=' . preg_quote($term['url'],'/') . '](.*?)\[\/[uz]rl]/'; if (preg_match($pattern,$body,$matches)) $tagged = true; $pattern = '/\[[uz]rl=' . preg_quote($term['url'],'/') . '][!@](.*?)\[\/[uz]rl]/'; if (preg_match($pattern,$body,$matches)) $tagged = true; if (! $tagged ) { logger('Mention was in a reshare - ignoring'); continue; } $arr = [ 'channel_id' => $uid, 'item' => $item, 'body' => $body ]; /** * @hooks tagged * Called when a delivery is processed which results in you being tagged. * * \e number \b channel_id * * \e array \b item * * \e string \b body */ Hook::call('tagged', $arr); /** * post to a group via group tags (!group) or normal @-mentions *only if* the group is public * but let the owner change this with a hidden pconfig and either allow * or deny this option regardless of the type of group */ if ($is_group && intval($item['item_thread_top']) && (! intval($item['item_wall']))) { if ((intval($term['ttype']) === TERM_FORUM || get_pconfig($uid,'system','post_via_mentions',in_array($role,['forum','forum_moderated']))) && perm_is_allowed($uid, $item['author_xchan'], 'post_wall')) { logger('group mention delivery for ' . $u['channel_address']); start_delivery_chain($u, $item, $item_id, false, true, (($item['edited'] != $item['created']) || $item['item_deleted'])); q("update item set item_blocked = %d where id = %d", intval(ITEM_HIDDEN), intval($item_id) ); } } /* * Send a mention notification - unless we just sent a mail notification for the same item */ if (! $mail_notify) { Enotify::submit([ 'to_xchan' => $u['channel_hash'], 'from_xchan' => $item['author_xchan'], 'type' => NOTIFY_TAGSELF, 'item' => $item, 'link' => $item['llink'], 'verb' => ACTIVITY_TAG, 'otype' => 'item' ]); } } } if ($is_collection && $pterms) { foreach ($pterms as $term) { $ptagged = false; if (link_compare($term['url'],$link)) { $ptagged = true; } elseif ($term['ttype'] === TERM_HASHTAG && strtolower($term['term']) === strtolower($u['channel_address'])) { $ptagged = true; } if (! $ptagged) { continue; } logger('Collection post found for ' . $u['channel_name']); // ptagged - keep going, next check permissions if ((! perm_is_allowed($uid, $item['author_xchan'], 'write_collection')) && ($item['author_xchan'] !== $u['channel_parent'])) { logger('tag_delivery denied for uid ' . $uid . ' and xchan ' . $item['author_xchan']); continue; } // tgroup delivery - set up a second delivery chain // prevent delivery looping - only proceed // if the message originated elsewhere and is a top-level post if (intval($item['item_wall']) || intval($item['item_origin']) || (! intval($item['item_thread_top'])) || ($item['id'] != $item['parent'])) { logger('Item was local or a comment. rejected.'); continue; } logger('Creating second delivery chain.'); start_delivery_chain($u,$item,$item_id, false); } } if ($union) { if (intval($item['item_wall']) || intval($item['item_origin']) || (! intval($item['item_thread_top'])) || ($item['id'] != $item['parent'])) { logger('Item was local or a comment. rejected.'); return; } logger('Creating second delivery chain.'); start_delivery_chain($u,$item,$item_id,false); } } /** * @brief This function is called pre-deliver to see if a post matches the criteria to be tag delivered. * * We don't actually do anything except check that it matches the criteria. * This is so that the channel with tag_delivery enabled can receive the post even if they turn off * permissions for the sender to send their stream. tag_deliver() can't be called until the post is actually stored. * By then it would be too late to reject it. * * @param number $uid A chnnel_id * @param array $item * @return boolean */ function tgroup_check($uid, $item) { $role = get_pconfig($uid,'system','permissions_role'); $rolesettings = PermissionRoles::role_perms($role); $channel_type = isset($rolesettings['channel_type']) ? $rolesettings['channel_type'] : 'normal'; $is_group = $channel_type === 'group'; $is_collection = $channel_type === 'collection'; // If a comment, check if we have already accepted the top level post as an uplink // Applies to collections only at this time // @FIXME We need a comment clause for groups if ($item['mid'] !== $item['parent_mid']) { $r = q("select id from item where mid = '%s' and uid = %d and item_uplink = 1 limit 1", dbesc($item['parent_mid']), intval($uid) ); if ($r) { return true; } } $u = Channel::from_id($uid); if (! $u) { return false; } // post to group via DM if ($is_group) { if (intval($item['item_private']) === 2 && $item['mid'] === $item['parent_mid']) { return true; } if ($item['mid'] === $item['parent_mid'] && (! intval($item['item_wall'])) && (str_contains($item['tgt_type'], 'Collection')) && $item['target']) { // accept posts to collections only if the collection belongs to us if ((is_string($item['target']) && str_contains($item['target'], z_root())) || (isset($item['target']['id']) && str_contains($item['target']['id'], z_root()))) { return true; } } if (get_pconfig($uid,'system','post_via_mentions',in_array($role, ['forum','forum_moderated'])) && i_am_mentioned($u,$item)) { return true; } elseif (i_am_mentioned($u,$item, true)) { return true; } } // see if we already have this item. Maybe it is being updated. $r = q("select id from item where mid = '%s' and uid = %d limit 1", dbesc($item['mid']), intval($uid) ); if ($r) { return true; } if (($is_collection) && (perm_is_allowed($uid, $item['author_xchan'], 'write_collection') || $item['author_xchan'] === $u['channel_parent'])) { return true; } // return true if we are mentioned, and we permit delivery of mentions from strangers if (PConfig::Get($uid, 'system','permit_all_mentions') && i_am_mentioned($u,$item)) { return true; } if (PConfig::Get($uid, 'system','permit_all_likes') && $item['verb'] === 'Like' && $item['obj_type'] === 'Note') { return true; } $tag_result = false; $terms = ((isset($item['term'])) ? get_terms_oftype($item['term'],TERM_HASHTAG) : false); if ($terms) { $followed_tags = PConfig::Get($uid,'system','followed_tags'); if (! (is_array($followed_tags) && $followed_tags)) { return false; } foreach ($terms as $term) { foreach ($followed_tags as $tag) { if (strcasecmp($term['term'],$tag) === 0) { $tag_result = true; } } } $unless = intval(PConfig::Get($uid, 'system', 'unless_tag_count', Config::Get('system', 'unless_tag_count', 20))); if ($unless && count($terms) > $unless) { $tag_result= false; } } return $tag_result; } function i_am_mentioned($channel,$item,$check_groups = false) { $link = $channel['xchan_url']; $body = preg_replace('/\[share(.*?)\[\/share]/','',$item['body']); $tagged = false; $matches = []; $tagtype = $check_groups ? TERM_FORUM : TERM_MENTION; $terms = ((isset($item['term'])) ? get_terms_oftype($item['term'], $tagtype) : false); if ($terms) { foreach ($terms as $term) { if ($link === $term['url']) { $pattern = '/[!@]!?\[[uz]rl=' . preg_quote($term['url'],'/') . '](.*?)\[\/[uz]rl]/'; if (preg_match($pattern,$body,$matches)) { $tagged = true; } $pattern = '/\[[uz]rl=' . preg_quote($term['url'],'/') . '][!@](.*?)\[\/[uz]rl]/'; if (preg_match($pattern,$body,$matches)) { $tagged = true; } } } } $unless = intval(get_pconfig($channel['channel_id'], 'system', 'unless_mention_count', get_config('system', 'unless_mention_count', 20))); if ($unless && $terms && count($terms) > $unless) { $tagged = false; } return $tagged; } /** * Sourced and tag-delivered posts are re-targetted for delivery to the connections of the channel * receiving the post. This starts the second delivery chain, by resetting permissions and ensuring * that ITEM_UPLINK is set on the parent post, and storing the current owner_xchan as the source_xchan. * We'll become the new owner. If called without $parent, this *is* the parent post. * * @param array $channel * @param array $item * @param int $item_id * @param bool|array $parent */ function start_delivery_chain($channel, $item, $item_id, bool|array $parent, $group = false, $edit = false) { // btlogger('start_chain: ' . $channel['channel_id'] . ' item: ' . $item_id); $moderated = perm_is_allowed($channel['channel_id'], $item['author_xchan'], 'moderated'); $source = check_item_source($channel['channel_id'],$item); // This creates an embedded share authored by the group actor. // The original message is no longer needed and its presence can cause // confusion so make it hidden. if (!$parent) { $arr = []; // hide original message q("update item set item_hidden = 1 where id = %d", intval($item_id) ); if ($edit) { // process edit or delete action $r = q("select * from item where source_xchan = '%s' and body like '%s' and uid = %d limit 1", dbesc($item['owner_xchan']), dbesc("%message_id='" . $item['mid'] . "'%"), intval($channel['channel_id']) ); if ($r) { if (intval($item['item_deleted'])) { drop_item($r[0]['id'],DROPITEM_PHASE1); Run::Summon([ 'Notifier','drop',$r[0]['id'] ]); return; } $arr['id'] = intval($r[0]['id']); $arr['parent'] = intval($r[0]['parent']); $arr['mid'] = $r[0]['mid']; $arr['parent_mid'] = $r[0]['parent_mid']; $arr['edited'] = datetime_convert(); } else { logger('unable to locate original group post ' . $item['mid']); return; } } else { $arr['mid'] = item_message_id(); $arr['parent_mid'] = $arr['mid']; IConfig::Set($arr,'activitypub','context', str_replace('/item/','/conversation/',$arr['mid'])); } $arr['aid'] = $channel['channel_account_id']; $arr['uid'] = $channel['channel_id']; // WARNING: the presence of both source_xchan and non-zero item_uplink here will cause a delivery loop $arr['item_uplink'] = 0; $arr['source_xchan'] = $item['author_xchan']; $arr['item_private'] = (($channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 1 : 0); $arr['item_origin'] = 1; $arr['item_wall'] = 1; $arr['item_thread_top'] = 1; if ($moderated) { $arr['item_blocked'] = ITEM_MODERATED; } $bb = "[share author='" . urlencode($item['author']['xchan_name']) . "' profile='" . $item['author']['xchan_url'] . "' portable_id='" . $item['author']['xchan_hash'] . "' avatar='" . $item['author']['xchan_photo_s'] . "' link='" . $item['plink'] . "' auth='" . ((isOWAEnabled($item['author']['xchan_url'])) ? 'true' : 'false') . "' posted='" . $item['created'] . "' message_id='" . $item['mid'] . "']"; if ($item['title']) { $bb .= '[b]' . $item['title'] . '[/b]' . "\r\n"; $arr['title'] = $item['title']; } $bb .= $item['body']; $bb .= "[/share]"; $arr['body'] = $bb; $tagpref = intval(PConfig::Get($channel['channel_id'], 'system', 'tag_username', Config::Get('system', 'tag_username', false))); $mention = $item['author']['xchan_name']; if ($tagpref === 1 && $item['author']['xchan_addr']) { $mention = $item['author']['xchan_addr']; } if ($tagpref === 2 && $item['author']['xchan_addr']) { $mention = sprintf(t('%1$s (%2$s)'), $item['author']['xchan_name'], $item['author']['xchan_addr']); } $arr['body'] .= "\n" . '@[zrl=' . $item['author']['xchan_url'] . ']' . $mention . '[/zrl]'; // Conversational objects shouldn't be copied, but other objects should. if (in_array($item['obj_type'], [ 'Image', 'Event', 'Question'])) { $arr['obj'] = $item['obj']; $t = json_decode($arr['obj'],true); if ($t !== NULL) { $arr['obj'] = $t; } $arr['obj']['content'] = bbcode($bb, [ 'export' => true ]); $arr['obj']['source']['content'] = $bb; $arr['obj']['id'] = $arr['mid']; if (! array_path_exists('obj/source/mediaType',$arr)) { $arr['obj']['source']['mediaType'] = 'text/x-multicode'; } } $arr['tgt_type'] = $item['tgt_type']; if (is_array($item['target'])) { $arr['target'] = $item['target']['id']; } else { $arr['target'] = $item['target']; } // Add a mention for the author. $arr['term'] = ($item['term']) ?: []; $arr['term'][] = [ 'uid' => $channel['channel_id'], 'ttype' => TERM_MENTION, 'otype' => TERM_OBJ_POST, 'term' => '@' . $item['author']['xchan_addr'], 'url' => $item['author']['xchan_url'] ]; $arr['author_xchan'] = $channel['channel_hash']; $arr['owner_xchan'] = $channel['channel_hash']; $arr['obj_type'] = $item['obj_type']; $arr['verb'] = 'Create'; $arr['item_restrict'] = 1; $arr['allow_cid'] = $channel['channel_allow_cid']; $arr['allow_gid'] = $channel['channel_allow_gid']; $arr['deny_cid'] = $channel['channel_deny_cid']; $arr['deny_gid'] = $channel['channel_deny_gid']; $arr['comment_policy'] = map_scope(PermissionLimits::Get($channel['channel_id'],'post_comments')); $merge = (($item['attach']) ?: []); if (is_string($merge)) { $merge = json_decode($merge, true); } if ($merge) { $arr['attach'] = array_merge($merge, [['type' => 'application/activity+json', 'href' => $item['mid']]]); } else { $arr['attach'] = [['type' => 'application/activity+json', 'href' => $item['mid']]]; } $arr['replyto'] = z_root() . '/channel/' . $channel['channel_address']; if ($arr['id']) { $post = item_store_update($arr); } else { $post = item_store($arr); } $post_id = $post['item_id']; if ($source && $post_id && !$edit) { $t = trim($source['src_tag']); if ($t) { $tags = explode(',', $t); if ($tags) { foreach ($tags as $tt) { $tt = trim($tt); if ($tt) { q("insert into term (uid,oid,otype,ttype,term,url) values(%d,%d,%d,%d,'%s','%s') ", intval($channel['channel_id']), intval($post_id), intval(TERM_OBJ_POST), intval(TERM_CATEGORY), dbesc($tt), dbesc(z_root() . '/channel/' . $channel['channel_address'] . '?f=&cat=' . urlencode($tt)) ); } } } } } if($post_id) { Run::Summon([ 'Notifier','tgroup',$post_id ]); } q("update channel set channel_lastpost = '%s' where channel_id = %d", dbesc(datetime_convert()), intval($channel['channel_id']) ); return; } // Send Announce activities for group comments, so they will show up in microblog streams if ($parent) { logger('comment arrived for new delivery chain', LOGGER_DEBUG); $arr = []; // don't let this recurse. We checked for this before calling, but this ensures // it doesn't sneak through another way because recursion is nasty. if ($item['verb'] === 'Announce' && $item['author_xchan'] === $channel['channel_hash']) { return; } // Don't send Announce activities for poll responses. if ($item['obj_type'] === 'Answer') { return; } if ($edit) { if (intval($item['item_deleted'])) { drop_item($item['id'],DROPITEM_PHASE1); Run::Summon(['Notifier', 'drop', $item['id'] ]); return; } return; } else { $arr['mid'] = item_message_id(); $arr['parent_mid'] = $item['parent_mid']; IConfig::Set($arr,'activitypub','context', str_replace('/item/','/conversation/',$item['parent_mid'])); } $arr['aid'] = $channel['channel_account_id']; $arr['uid'] = $channel['channel_id']; $arr['verb'] = 'Announce'; if (is_array($item['obj'])) { $arr['obj'] = $item['obj']; } elseif (is_string($item['obj']) && strlen($item['obj'])) { $arr['obj'] = json_decode($item['obj'],true); } if (! $arr['obj']) { $arr['obj'] = $item['mid']; } if (is_array($arr['obj'])) { $obj_actor = ((isset($arr['obj']['actor'])) ? $arr['obj']['actor'] : $arr['obj']['attributedTo']); $mention = Activity::get_actor_bbmention($obj_actor); $arr['body'] = sprintf( t('📢 Repeated %1$s\'s %2$s'), $mention, $arr['obj']['type']); } $arr['author_xchan'] = $channel['channel_hash']; $arr['item_wall'] = 1; $arr['item_private'] = (($channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 1 : 0); $arr['item_origin'] = 1; $arr['item_thread_top'] = 0; $arr['allow_cid'] = $channel['channel_allow_cid']; $arr['allow_gid'] = $channel['channel_allow_gid']; $arr['deny_cid'] = $channel['channel_deny_cid']; $arr['deny_gid'] = $channel['channel_deny_gid']; $arr['comment_policy'] = map_scope(PermissionLimits::Get($channel['channel_id'],'post_comments')); $arr['replyto'] = z_root() . '/channel/' . $channel['channel_address']; $post = item_store($arr); $post_id = $post['item_id']; if ($post_id) { Run::Summon([ 'Notifier','tgroup',$post_id ]); } q("update channel set channel_lastpost = '%s' where channel_id = %d", dbesc(datetime_convert()), intval($channel['channel_id']) ); return; } } /** * @brief * * Checks to see if this item owner is referenced as a source for this channel and if the post * matches the rules for inclusion in this channel. Returns true if we should create a second delivery * chain and false if none of the rules apply, or if the item is private. * * @param int $uid * @param array $item */ function check_item_source($uid, $item) { logger('source: uid: ' . $uid, LOGGER_DEBUG); $xchan = ((isset($item['source_xchan']) && $item['source_xchan'] && isset($item['item_uplink']) && intval($item['item_uplink'])) ? $item['source_xchan'] : $item['owner_xchan']); $linkedIds = null; $channel = Channel::from_id($uid); if ($channel) { $linkedIds = q("select * from linkid where ident = '%s' and sigtype = %d", dbesc($channel['channel_hash']), intval(IDLINK_RELME) ); } $r = q("select * from source where src_channel_id = %d and ( src_xchan = '%s' or src_xchan = '*' ) limit 1", intval($uid), dbesc($xchan) ); if (! $r) { logger('source: no source record for this channel and source', LOGGER_DEBUG); return false; } $x = q("select abook_feed from abook where abook_channel = %d and abook_xchan = '%s' limit 1", intval($uid), dbesc($xchan) ); if (! $x) { logger('source: not connected to this channel.'); return false; } if (! their_perms_contains($uid,$xchan,'republish')) { if ($linkedIds) { foreach ($linkedIds as $linkId) { if ($linkId['link'] === $item['owner_xchan'] || $linkId['link'] === $item['owner']['xchan_url']) { logger('source: success - linked identity'); return true; } } } logger('source: no republish permission'); return false; } if ($item['item_private'] && (! intval($x[0]['abook_feed']))) { logger('source: item is private'); return false; } if ($r[0]['src_channel_xchan'] === $xchan) { logger('source: cannot source yourself'); return false; } if (! $r[0]['src_patt']) { logger('source: success'); return array_shift($r); } if (MessageFilter::evaluate($item, $r[0]['src_patt'], EMPTY_STR)) { logger('source: text filter success'); return array_shift($r); } logger('source: filter fail'); return false; } // Checks an incoming item against the per-channel and per-connection content filter. // This implements the backend of the 'Content Filter' system app function post_is_importable($channel_id, $item, $abook) { if (! $item) { return false; } if (! Apps::system_app_installed($channel_id, 'Content Filter')) { return true; } $text = prepare_text($item['body'],((isset($item['mimetype'])) ? $item['mimetype'] : 'text/x-multicode')); $text = html2plain((isset($item['title']) && $item['title']) ? $item['title'] . ' ' . $text : $text); $incl = PConfig::Get($channel_id, 'system', 'message_filter_incl', EMPTY_STR); $excl = PConfig::Get($channel_id, 'system', 'message_filter_excl', EMPTY_STR); if ($incl || $excl) { $x = MessageFilter::evaluate($item, $incl, $excl, ['plaintext' => $text]); if (! $x) { logger('MessageFilter: channel blocked content', LOGGER_DEBUG, LOG_INFO); return false; } } if (! $abook) { return true; } foreach ($abook as $ab) { // check eligibility if (intval($ab['abook_self'])) { continue; } if (! ($ab['abook_incl'] || $ab['abook_excl']) ) { continue; } $evaluator = MessageFilter::evaluate($item, $ab['abook_incl'], $ab['abook_excl'], ['plaintext' => $text]); // A negative assessment for any individual connections is an instant fail. if (! $evaluator) { return false; } } return true; } function has_permissions($obj) { return (! (empty($obj['allow_cid']) && empty($obj['allow_gid']) && empty($obj['deny_cid']) && empty($obj['deny_gid']))); } function compare_permissions($obj1,$obj2) { // first part is easy. Check that these are exactly the same. if (($obj1['allow_cid'] == $obj2['allow_cid']) && ($obj1['allow_gid'] == $obj2['allow_gid']) && ($obj1['deny_cid'] == $obj2['deny_cid']) && ($obj1['deny_gid'] == $obj2['deny_gid'])) { return true; } // This is harder. Parse all the permissions and compare the resulting set. $recipients1 = enumerate_permissions($obj1); $recipients2 = enumerate_permissions($obj2); sort($recipients1); sort($recipients2); if ($recipients1 == $recipients2) { return true; } return false; } /** * @brief Returns an array of connection identifiers that are allowed to see this object. * * @param object $obj * @return array */ function enumerate_permissions($obj) { $allow_people = expand_acl($obj['allow_cid']); $allow_groups = AccessList::expand(expand_acl($obj['allow_gid'])); $deny_people = expand_acl($obj['deny_cid']); $deny_groups = AccessList::expand(expand_acl($obj['deny_gid'])); $recipients = array_unique(array_merge($allow_people,$allow_groups)); $deny = array_unique(array_merge($deny_people,$deny_groups)); $recipients = array_diff($recipients,$deny); return $recipients; } function item_expire($uid,$days,$comment_days = 7) { if ((! $uid) || ($days < 1)) { return; } if (! $comment_days) { $comment_days = 7; } // $expire_stream_only = save your own wall posts // and just expire conversations started by others // do not enable this until we can pass bulk delete messages through zot // $expire_stream_only = get_pconfig($uid,'expire','stream_only'); $expire_stream_only = 1; $sql_extra = ((intval($expire_stream_only)) ? " AND item_wall = 0 " : ""); // We need to set an arbitrary limit on the number of records to be expired to // prevent memory exhaustion. The site admin can configure it to something else // if they desire/require. $expire_limit = get_config('system','expire_limit'); if (! intval($expire_limit)) { $expire_limit = 5000; } $item_normal = item_normal(); $r = q("SELECT id FROM item WHERE uid = %d AND created < %s - INTERVAL %s AND commented < %s - INTERVAL %s AND item_retained = 0 AND item_thread_top = 1 AND resource_type = '' AND item_starred = 0 $sql_extra $item_normal LIMIT $expire_limit ", intval($uid), db_utcnow(), db_quoteinterval(intval($days) . ' DAY'), db_utcnow(), db_quoteinterval(intval($comment_days) . ' DAY') ); if (! $r) { return; } $r = fetch_post_tags($r); foreach ($r as $item) { // don't expire filed items $terms = get_terms_oftype($item['term'],TERM_FILE); if ($terms) { retain_item($item['id']); continue; } // don't expire pinned items either $pinned = PConfig::Get($item['uid'], 'pinned', $item['item_type'], []); if (in_array($item['mid'], $pinned)) { retain_item($item['id']); continue; } drop_item($item['id'], expire: true); } } function retain_item($id) { $r = q("update item set item_retained = 1 where id = %d", intval($id) ); } // Items is array of item.id function drop_items($items, $stage = DROPITEM_NORMAL, $force = false, $expire = false) { $uid = 0; if (count($items)) { foreach ($items as $item) { $owner = drop_item($item, $stage, $force, $expire); if ($owner && (! $uid)) { $uid = $owner; } } } // multiple threads may have been deleted, send an expire notification if ($uid) { Run::Summon(['Notifier', 'expire', $uid]); } } // Delete item with given item $id. $interactive means we're running interactively, and must check // permissions to carry out this act. If it is non-interactive, we are deleting something at the // system's request and do not check permission. This is very important to know. // Some deletion requests (those coming from remote sites) must be staged. // $stage = 0 => unstaged // $stage = 1 => set deleted flag on the item and perform intial notifications // $stage = 2 => perform low level delete at a later stage function drop_item($id, $stage = DROPITEM_NORMAL, $force = false, $uid = 0, $observer_hash = '', $expire = false) { // locate item to be deleted $r = q("SELECT * FROM item WHERE id = %d", intval($id) ); if ((! $r) || (intval($r[0]['item_deleted']) && ($stage === DROPITEM_NORMAL))) { return false; } $item = array_shift($r); $ok_to_delete = false; // admin deletion if (is_site_admin()) { $ok_to_delete = true; } // owner deletion if (local_channel() && local_channel() == $item['uid']) { $ok_to_delete = true; } // remote delete when nobody is authenticated (called from Libzot) if ($uid && intval($uid) === intval($item['uid'])) { $ok_to_delete = true; } // author deletion if ($observer_hash) { $observer = ['xchan_hash' => $observer_hash]; } else { $observer = App::get_observer(); } if ($observer && $observer['xchan_hash'] && ($observer['xchan_hash'] === $item['author_xchan'])) { $ok_to_delete = true; } if ($observer && $observer['xchan_hash'] && ($observer['xchan_hash'] === $item['owner_xchan'])) { $ok_to_delete = true; } if ($ok_to_delete) { $r = q("UPDATE item SET item_deleted = 1 WHERE id = %d", intval($item['id']) ); if ($item['resource_type'] === 'event' ) { q("delete from event where event_hash = '%s' and uid = %d", dbesc($item['resource_id']), intval($item['uid']) ); } if ($item['resource_type'] === 'photo' ) { attach_delete($item['uid'], $item['resource_id'], true); } $arr = [ 'item' => $item, 'stage' => $stage ]; /** * @hooks drop_item * Called when an 'item' is removed. */ Hook::call('drop_item', $arr); $notify_id = intval($item['id']); $items = q("select * from item where parent = %d and uid = %d", intval($item['id']), intval($item['uid']) ); if ($items) { foreach ($items as $i) { delete_item_lowlevel($i, $stage, $force, $expire); } } else { delete_item_lowlevel($item, $stage, $force, $expire); } return true; } else { return false; } } /** * @warning This function does not check for permission and does not send * notifications and does not check recursion. * It merely destroys all resources associated with an item. * Please do not use without a suitable wrapper. * * @param array $item * @param int $stage * @param bool $force * @return bool */ function delete_item_lowlevel($item, $stage = DROPITEM_NORMAL, $force = false, $expire = false) { logger('item: ' . $item['id'] . ' stage: ' . $stage . ' force: ' . ($force) ? 'true' : 'false', LOGGER_DATA); if (!$expire) { Tombstone::store($item['mid'], $item['uid']); } match ($stage) { DROPITEM_PHASE2 => q("UPDATE item SET item_pending_remove = 1, body = '', title = '', changed = '%s', edited = '%s' WHERE id = %d", dbesc(datetime_convert()), dbesc(datetime_convert()), intval($item['id']) ), DROPITEM_PHASE1 => q("UPDATE item set item_deleted = 1, changed = '%s', edited = '%s' where id = %d", dbesc(datetime_convert()), dbesc(datetime_convert()), intval($item['id']) ), default => q("DELETE FROM item WHERE id = %d", intval($item['id']) ), }; // immediately remove any undesired profile likes. q("delete from likes where iid = %d and channel_id = %d", intval($item['id']), intval($item['uid']) ); // network deletion request. Keep the message structure so that we can deliver delete notifications. // Come back after several days to do the lowlevel delete (DROPITEM_PHASE2). if ($stage == DROPITEM_PHASE1) { return true; } $r = q("delete from term where otype = %d and oid = %d", intval(TERM_OBJ_POST), intval($item['id']) ); q("delete from iconfig where iid = %d", intval($item['id']) ); $n = q("select id from item where mid = '%s'", dbesc($item['mid']) ); if (!$n) { ObjCache::Delete($item['mid']); } q("delete from term where oid = %d and otype = %d", intval($item['id']), intval(TERM_OBJ_POST) ); // remove delivery reports $c = q("select channel_hash from channel where channel_id = %d limit 1", intval($item['uid']) ); if ($c) { q("delete from dreport where dreport_xchan = '%s' and dreport_mid = '%s'", dbesc($c[0]['channel_hash']), dbesc($item['mid']) ); } // The threadlistener interface does not support uid/channel_id, so // only remove threadlisteners when doing the second stage of a federated delete. if ($stage === DROPITEM_PHASE2) { ThreadListener::delete_by_target($item['mid']); } q("delete from notify where uid = %d and link = '%s'", intval($item['uid']), dbesc($item['mid']) ); return true; } /** * @brief Return the first post date. * * @param int $uid * @param boolean $wall (optional) no longer used * @return string|boolean date string, otherwise false */ function first_post_date($uid, $wall = false) { $sql_extra = ''; switch (App::$module) { case 'articles': $sql_extra .= " and item_type = 7 "; $item_normal = " and item.item_hidden = 0 and item.item_type = 7 and item.item_deleted = 0 and item.item_unpublished = 0 and item.item_delayed = 0 and item.item_pending_remove = 0 and item.item_blocked = 0 "; break; case 'channel': $sql_extra = " and item_wall = 1 "; default: $item_normal = item_normal(); break; } $r = q("select id, created from item where uid = %d and id = parent $item_normal $sql_extra order by created asc limit 1", intval($uid) ); if ($r) { return datetime_convert('UTC',date_default_timezone_get(),$r[0]['created'], 'Y-m-d'); } return false; } /** * modified posted_dates() {below} to arrange the list in years, which we'll eventually * use to make a menu of years with collapsible sub-menus for the months instead of the * current flat list of all representative dates. * * @param int $uid * @param boolean $wall * @param string $mindate * @return array */ function list_post_dates($uid, $wall, $mindate) { $ret = []; $dnow = datetime_convert('',date_default_timezone_get(),'now','Y-m-d'); if ($mindate) { $dthen = datetime_convert('',date_default_timezone_get(), $mindate); } else { $dthen = first_post_date($uid, $wall); } if (! $dthen) { return []; } // If it's near the end of a long month, backup to the 28th so that in // consecutive loops we'll always get a whole month difference. if (intval(substr($dnow,8)) > 28) { $dnow = substr($dnow,0,8) . '28'; } if (intval(substr($dthen,8)) > 28) { $dthen = substr($dthen,0,8) . '28'; } // Starting with the current month, get the first and last days of every // month down to and including the month of the first post while (substr($dnow, 0, 7) >= substr($dthen, 0, 7)) { $dyear = intval(substr($dnow,0,4)); $dstart = substr($dnow,0,8) . '01'; $dend = substr($dnow,0,8) . get_dim(intval($dnow),intval(substr($dnow,5))); $start_month = datetime_convert('','',$dstart,'Y-m-d'); $end_month = datetime_convert('','',$dend,'Y-m-d'); $str = day_translate(datetime_convert('','',$dnow,'F')); if (! $ret[$dyear]) { $ret[$dyear] = []; } $ret[$dyear][] = [ $str, $end_month, $start_month ]; $dnow = datetime_convert('','',$dnow . ' -1 month', 'Y-m-d'); } return $ret; } function posted_dates($uid,$wall) { $dnow = datetime_convert('UTC',date_default_timezone_get(),'now','Y-m-d'); $dthen = first_post_date($uid,$wall); if (! $dthen) { return []; } // If it's near the end of a long month, backup to the 28th so that in // consecutive loops we'll always get a whole month difference. if (intval(substr($dnow,8)) > 28) { $dnow = substr($dnow,0,8) . '28'; } if (intval(substr($dthen,8)) > 28) { $dthen = substr($dthen,0,8) . '28'; } $ret = []; // Starting with the current month, get the first and last days of every // month down to and including the month of the first post while (substr($dnow, 0, 7) >= substr($dthen, 0, 7)) { $dstart = substr($dnow,0,8) . '01'; $dend = substr($dnow,0,8) . get_dim(intval($dnow),intval(substr($dnow,5))); $start_month = datetime_convert('','',$dstart,'Y-m-d'); $end_month = datetime_convert('','',$dend,'Y-m-d'); $str = day_translate(datetime_convert('','',$dnow,'F Y')); $ret[] = [$str,$end_month,$start_month]; $dnow = datetime_convert('','',$dnow . ' -1 month', 'Y-m-d'); } return $ret; } /** * @brief Extend an item array with the associated tags of the posts. * * @param array $items * @return array Return the provided $items array after extended the posts with tags */ function fetch_post_tags($items) { $tag_finder = []; if ($items) { foreach ($items as $item) { if (is_array($item)) { if (array_key_exists('item_id',$item)) { if (! in_array($item['item_id'],$tag_finder)) { $tag_finder[] = $item['item_id']; } } else { if (! in_array($item['id'],$tag_finder)) { $tag_finder[] = $item['id']; } } } } } $tag_finder_str = implode(', ', $tag_finder); if (strlen($tag_finder_str)) { $tags = q("select * from term where oid in ( %s ) and otype = %d", dbesc($tag_finder_str), intval(TERM_OBJ_POST) ); $imeta = q("select * from iconfig where iid in ( %s )", dbesc($tag_finder_str) ); } for ($x = 0; $x < count((array)$items); $x ++) { if ($tags) { foreach ($tags as $t) { if (array_key_exists('item_id',$items[$x])) { if ($t['oid'] == $items[$x]['item_id']) { if (! (isset($items[$x]['term']) && is_array($items[$x]['term']))) { $items[$x]['term'] = []; } $items[$x]['term'][] = $t; } } else { if ($t['oid'] == $items[$x]['id']) { if (! (isset($items[$x]['term']) && is_array($items[$x]['term']))) { $items[$x]['term'] = []; } $items[$x]['term'][] = $t; } } } } if ($imeta) { foreach ($imeta as $i) { if (array_key_exists('item_id',$items[$x])) { if ($i['iid'] == $items[$x]['item_id']) { if (! (isset($items[$x]['iconfig']) && is_array($items[$x]['iconfig']))) { $items[$x]['iconfig'] = []; } $i['v'] = unserialise($i['v']); $items[$x]['iconfig'][] = $i; } } else { if ($i['iid'] == $items[$x]['id']) { if (! (isset($items[$x]['iconfig']) && is_array($items[$x]['iconfig']))) { $items[$x]['iconfig'] = []; } $i['v'] = unserialise($i['v']); $items[$x]['iconfig'][] = $i; } } } } } return $items; } /** * @brief * * @param int $uid * @param string $observer_hash * @param array $arr * @return array */ function zot_feed($uid, $observer_hash, $arr) { $result = []; $mindate = null; $message_id = null; $wall = true; require_once('include/security.php'); $encoding = ((array_key_exists('encoding',$arr)) ? $arr['encoding'] : 'zot'); if(array_key_exists('mindate',$arr)) { $mindate = datetime_convert('UTC','UTC',$arr['mindate']); } if(array_key_exists('message_id',$arr)) { $message_id = $arr['message_id']; } if(array_key_exists('wall',$arr)) { $wall = intval($arr['wall']); } if(! $mindate) $mindate = NULL_DATE; $mindate = dbesc($mindate); logger('zot_feed: requested for uid ' . $uid . ' from observer ' . $observer_hash, LOGGER_DEBUG); if($message_id) logger('message_id: ' . $message_id,LOGGER_DEBUG); if(! perm_is_allowed($uid, $observer_hash, 'view_stream')) { logger('zot_feed: permission denied.'); return $result; } if(! Channel::is_system($uid)) $sql_extra = item_permissions_sql($uid,$observer_hash); $limit = " LIMIT 5000 "; if($mindate > NULL_DATE) { $sql_extra .= " and ( created > '$mindate' or changed > '$mindate' ) "; } if($message_id) { $sql_extra .= " and mid = '" . dbesc($message_id) . "' "; $limit = ''; } if($wall) { $sql_extra .= " and item_wall = 1 "; } $items = []; $item_normal = item_normal(); if(Channel::is_system($uid)) { $nonsys_uids = q("SELECT channel_id FROM channel WHERE channel_system = 0"); $nonsys_uids_str = ids_to_querystr($nonsys_uids,'channel_id'); $r = q("SELECT parent FROM item WHERE uid IN ( %s ) AND item_private = 0 $item_normal $sql_extra ORDER BY created ASC $limit", intval($nonsys_uids_str) ); } else { $r = q("SELECT parent FROM item WHERE uid = %d $item_normal $sql_extra ORDER BY created ASC $limit", intval($uid) ); } $parents = []; if($r) { foreach($r as $rv) { if(array_key_exists($rv['parent'],$parents)) continue; $parents[$rv['parent']] = $rv; if(count($parents) > 200) break; } $parents_str = ids_to_querystr($parents,'parent'); $sys_query = ((Channel::is_system($uid)) ? $sql_extra : ''); $item_normal = item_normal(); $items = q("SELECT item.*, item.id AS item_id FROM item WHERE item.parent IN ( %s ) $item_normal $sys_query ", dbesc($parents_str) ); } if($items) { xchan_query($items); $items = fetch_post_tags($items); require_once('include/conversation.php'); $items = conv_sort($items,'ascending'); } else $items = []; logger('zot_feed: number items: ' . count($items),LOGGER_DEBUG); foreach($items as $item) { if($encoding === 'zot') $result[] = encode_item($item); elseif($encoding === 'activitystreams') $result[] = Activity::encode_activity($item, true, Activity::isContainer($item)); } return $result; } function items_fetch($arr,$channel = null,$observer_hash = null,$client_mode = CLIENT_MODE_NORMAL,$module = 'stream') { $result = [ 'success' => false ]; $uid = 0; $sql_extra = ''; $sql_nets = ''; $sql_options = ''; $sql_extra2 = ''; $sql_extra3 = ''; $def_acl = ''; $item_uids = ' true '; $item_normal = item_normal(); if (isset($arr['uid']) && $arr['uid']) { $uid = $arr['uid']; } if ($channel) { $uid = $channel['channel_id']; $uidhash = $channel['channel_hash']; $item_uids = " item.uid = " . intval($uid) . " "; } if (! (isset($arr['include_follow']) && intval($arr['include_follow']))) { $item_normal .= " and not verb in ( 'Follow' , 'Ignore' ) "; } if (isset($arr['star']) && $arr['star']) { $sql_options .= " and item_starred = 1 "; } if (isset($arr['wall']) && $arr['wall']) { $sql_options .= " and item_wall = 1 "; } if (isset($arr['item_id']) && $arr['item_id']) { $sql_options .= " and parent = " . intval($arr['item_id']) . " "; } if (isset($arr['mid']) && $arr['mid']) { $sql_options .= " and parent_mid = '" . dbesc($arr['mid']) . "' "; } $sql_extra = " AND item.parent IN ( SELECT parent FROM item WHERE item_thread_top = 1 $sql_options $item_normal ) "; if (!empty($arr['since_id'])) { $sql_extra .= " and item.id > " . $arr['since_id'] . " "; } if (!empty($arr['cat'])) { $sql_extra .= protect_sprintf(term_query('item', $arr['cat'], TERM_CATEGORY)); } if ($uid && !empty($arr['gid'])) { $r = q("SELECT * FROM pgrp WHERE id = %d AND uid = %d LIMIT 1", intval($arr['group']), intval($uid) ); if (! $r) { $result['message'] = t('Access list not found.'); return $result; } $contact_str = ''; $contacts = AccessList::members($uid,$r[0]['id']); if ($contacts) { foreach ($contacts as $c) { if ($contact_str) { $contact_str .= ','; } $contact_str .= "'" . $c['xchan'] . "'"; } } else { $contact_str = ' 0 '; $result['message'] = t('Privacy group is empty.'); return $result; } $sql_extra = " AND item.parent IN ( SELECT DISTINCT parent FROM item WHERE true $sql_options AND (( author_xchan IN ( $contact_str ) OR owner_xchan in ( $contact_str)) or allow_gid like '" . protect_sprintf('%<' . dbesc($r[0]['hash']) . '>%') . "' ) and id = parent $item_normal ) "; $x = AccessList::rec_byhash($uid,$r[0]['hash']); $result['headline'] = sprintf( t('Access list: %s'),$x['gname']); } elseif (isset($arr['cid']) && $arr['cid'] && $uid) { $r = q("SELECT abook.*, xchan.* from abook left join xchan on abook_xchan = xchan_hash where abook_id = %d and abook_channel = %d and abook_blocked = 0 limit 1", intval($arr['cid']), intval(local_channel()) ); if ($r) { $sql_extra = " AND item.parent IN ( SELECT DISTINCT parent FROM item WHERE true $sql_options AND uid = " . intval($arr['uid']) . " AND ( author_xchan = '" . dbesc($r[0]['abook_xchan']) . "' or owner_xchan = '" . dbesc($r[0]['abook_xchan']) . "' ) $item_normal ) "; $result['headline'] = sprintf( t('Connection: %s'),$r[0]['xchan_name']); } else { $result['message'] = t('Channel not found.'); return $result; } } if ($channel && intval($arr['compat']) === 1) { $sql_extra = " AND author_xchan = '" . $channel['channel_hash'] . "' and item_private = 0 $item_normal "; } if ($arr['datequery']) { $sql_extra3 .= protect_sprintf(sprintf(" AND item.created <= '%s' ", dbesc(datetime_convert('UTC','UTC',$arr['datequery'])))); } if ($arr['datequery2']) { $sql_extra3 .= protect_sprintf(sprintf(" AND item.created >= '%s' ", dbesc(datetime_convert('UTC','UTC',$arr['datequery2'])))); } if ($arr['search']) { if (str_starts_with($arr['search'], '#')) { $sql_extra .= term_query('item',substr($arr['search'],1),TERM_HASHTAG,TERM_COMMUNITYTAG); } else { $sql_extra .= sprintf(" AND item.body like '%s' ", dbesc(protect_sprintf('%' . $arr['search'] . '%')) ); } } if (isset($arr['file']) && strlen($arr['file'])) { $sql_extra .= term_query('item',$arr['file'],TERM_FILE); } if (isset($arr['conv']) && $arr['conv'] && $channel) { $sql_extra .= sprintf(" AND parent IN (SELECT distinct parent from item where ( author_xchan like '%s' or item_mentionsme = 1 )) ", dbesc(protect_sprintf($uidhash)) ); } if (($client_mode & CLIENT_MODE_UPDATE) && (! ($client_mode & CLIENT_MODE_LOAD))) { // only setup pagination on initial page view $pager_sql = ''; } else { if (! (isset($arr['total']) && $arr['total']) ) { $itemspage = ((isset($arr['records'])) ? $arr['records'] : 0); if (! $itemspage) { $itemspage = (($channel) ? get_pconfig($uid,'system','itemspage') : 20); App::set_pager_itemspage(((intval($itemspage)) ? $itemspage : 20)); $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval(App::$pager['itemspage']), intval(App::$pager['start'])); } } } if (isset($arr['start']) && isset($arr['records'])) { $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval($arr['records']), intval($arr['start'])); } if (array_key_exists('cmin',$arr) || array_key_exists('cmax',$arr)) { if (($arr['cmin'] != 0) || ($arr['cmax'] != 99)) { // Not everybody who shows up in the stream will be in your address book. // By default, those that aren't are assumed to have closeness = 99; but this isn't // recorded anywhere. So if cmax is 99, we'll open the search up to anybody in // the stream with a NULL address book entry. $sql_nets .= " AND "; if ($arr['cmax'] == 99) $sql_nets .= " ( "; $sql_nets .= "( abook.abook_closeness >= " . intval($arr['cmin']) . " "; $sql_nets .= " AND abook.abook_closeness <= " . intval($arr['cmax']) . " ) "; if ($arr['cmax'] == 99) $sql_nets .= " OR abook.abook_closeness IS NULL ) "; } } $simple_update = (($client_mode & CLIENT_MODE_UPDATE) ? " and item.item_unseen = 1 " : ''); if ($client_mode & CLIENT_MODE_LOAD) { $simple_update = ''; } $sql_extra .= item_permissions_sql($channel['channel_id'],$observer_hash); if (isset($arr['pages']) && $arr['pages']) { $item_restrict = " AND item_type = " . ITEM_TYPE_WEBPAGE . " "; } else { $item_restrict = " AND item_type = 0 "; } if (isset($arr['item_type']) && $arr['item_type'] === '*') { $item_restrict = ''; } if (((isset($arr['compat']) && $arr['compat']) || ((isset($arr['nouveau']) && $arr['nouveau']) && ($client_mode & CLIENT_MODE_LOAD))) && $channel) { // "New Item View" - show all items unthreaded in reverse created date order if (isset($arr['total']) && $arr['total']) { $items = q("SELECT count(item.id) AS total FROM item WHERE $item_uids $item_restrict $simple_update $sql_extra $sql_nets $sql_extra3" ); if ($items) { return intval($items[0]['total']); } return 0; } $items = q("SELECT item.*, item.id AS item_id FROM item WHERE $item_uids $item_restrict $simple_update $sql_extra $sql_nets $sql_extra3 ORDER BY item.received DESC $pager_sql" ); xchan_query($items); $items = fetch_post_tags($items); } else { // Normal conversation view if (isset($arr['order']) && $arr['order'] === 'post') { $ordering = "created"; } else { $ordering = "commented"; } if (($client_mode & CLIENT_MODE_LOAD) || ($client_mode == CLIENT_MODE_NORMAL)) { // Fetch a page full of parent items for this page $r = q("SELECT distinct item.id AS item_id, item.$ordering FROM item left join abook on item.author_xchan = abook.abook_xchan WHERE $item_uids $item_restrict AND item.parent = item.id and (abook.abook_blocked = 0 or abook.abook_flags is null) $sql_extra3 $sql_extra $sql_nets ORDER BY item.$ordering DESC $pager_sql " ); } else { // update $r = q("SELECT item.parent AS item_id FROM item left join abook on item.author_xchan = abook.abook_xchan WHERE $item_uids $item_restrict $simple_update and (abook.abook_blocked = 0 or abook.abook_flags is null) $sql_extra3 $sql_extra $sql_nets " ); } // Then fetch all the children of the parents that are on this page if ($r) { $parents_str = ids_to_querystr($r,'item_id'); if (isset($arr['top']) && $arr['top']) { $sql_extra = ' and id = parent ' . $sql_extra; } $items = q("SELECT item.*, item.id AS item_id FROM item WHERE $item_uids $item_restrict AND item.parent IN ( %s ) $sql_extra ", dbesc($parents_str ?: '0') ); xchan_query($items); $items = fetch_post_tags($items); require_once('include/conversation.php'); $items = conv_sort($items,$ordering); } else { $items = []; } } return $items; } function webpage_to_namespace($webpage) { if ($webpage == ITEM_TYPE_WEBPAGE) $page_type = 'WEBPAGE'; elseif ($webpage == ITEM_TYPE_BLOCK) $page_type = 'BUILDBLOCK'; elseif ($webpage == ITEM_TYPE_PDL) $page_type = 'PDL'; elseif ($webpage == ITEM_TYPE_CARD) $page_type = 'CARD'; elseif ($webpage == ITEM_TYPE_ARTICLE) $page_type = 'ARTICLE'; elseif ($webpage == ITEM_TYPE_DOC) $page_type = 'docfile'; else $page_type = 'unknown'; return $page_type; } function update_remote_id($channel,$post_id,$webpage,$pagetitle,$namespace,$remote_id,$mid) { if (! $post_id) { return; } $page_type = webpage_to_namespace($webpage); if ($page_type == 'unknown' && $namespace && $remote_id) { $page_type = $namespace; $pagetitle = $remote_id; } else { $page_type = ''; } if ($page_type) { // store page info as an alternate message_id so we can access it via // https://sitename/page/$channelname/$pagetitle // if no pagetitle was given or it couldn't be transliterated into a url, use the first // sixteen bytes of the mid - which makes the link portable and not quite as daunting // as the entire mid. If it were the post_id the link would be less portable. $post_id = intval($post_id); IConfig::Set( $post_id, 'system', $page_type, ($pagetitle) ?: substr($mid,0,16), false ); } } /** * @brief Change access control for item with message_id $mid and channel_id $uid. * * @param string $xchan_hash * @param string $mid * @param int $uid */ function item_add_cid($xchan_hash, $mid, $uid) { $r = q("select id from item where mid = '%s' and uid = %d and allow_cid like '%s'", dbesc($mid), intval($uid), dbesc('<' . $xchan_hash . '>') ); if (! $r) { $r = q("update item set allow_cid = concat(allow_cid,'%s') where mid = '%s' and uid = %d", dbesc('<' . $xchan_hash . '>'), dbesc($mid), intval($uid) ); } } function item_remove_cid($xchan_hash,$mid,$uid) { $r = q("select allow_cid from item where mid = '%s' and uid = %d and allow_cid like '%s'", dbesc($mid), intval($uid), dbesc('<' . $xchan_hash . '>') ); if ($r) { $x = q("update item set allow_cid = '%s' where mid = '%s' and uid = %d", dbesc(str_replace('<' . $xchan_hash . '>','',$r[0]['allow_cid'])), dbesc($mid), intval($uid) ); } } // Set item permissions based on results obtained from linkify_tags() function set_linkified_perms($linkified, &$str_contact_allow, &$str_group_allow, $profile_uid, $parent_item, &$private) { $first_access_tag = true; foreach($linkified as $x) { $access_tag = $x['success']['access_tag']; if(($access_tag) && (! $parent_item)) { logger('access_tag: ' . print_r($access_tag,true), LOGGER_DATA); if ($first_access_tag && (! get_pconfig($profile_uid,'system','no_private_mention_acl_override'))) { // This is a tough call, hence configurable. The issue is that one can type in a @!privacy mention // and also have a default ACL (perhaps from viewing a collection) and could be suprised that the // privacy mention wasn't the only recipient. So the default is to wipe out the existing ACL if a // private mention is found. This can be over-ridden if you wish private mentions to be in // addition to the current ACL settings. $str_contact_allow = ''; $str_group_allow = ''; $first_access_tag = false; } if(str_starts_with($access_tag, 'cid:')) { $str_contact_allow .= '<' . substr($access_tag,4) . '>'; $access_tag = ''; $private = 2; } elseif(str_starts_with($access_tag, 'gid:')) { $str_group_allow .= '<' . substr($access_tag,4) . '>'; $access_tag = ''; $private = 2; } } } } /** * We can't trust ITEM_ORIGIN to tell us if this is a local comment * which needs to be relayed, because it was misconfigured at one point for several * months and set for some remote items (in alternate delivery chains). This could * cause looping, so use this hackish but accurate method. * * @param array $item * @return boolean */ function comment_local_origin($item) { if(stripos($item['mid'], App::get_hostname()) && ($item['parent'] != $item['id'])) return true; return false; } function send_profile_photo_activity($channel,$photo,$profile) { // for now only create activities for the default profile if (! (isset($profile) && isset($profile['is_default']) && intval($profile['is_default']))) { return; } $arr = []; $arr['item_thread_top'] = 1; $arr['item_origin'] = 1; $arr['item_wall'] = 1; $arr['obj_type'] = ACTIVITY_OBJ_NOTE; $arr['verb'] = ACTIVITY_CREATE; $arr['uuid'] = new_uuid(); $arr['mid'] = z_root() . '/item/' . $arr['uuid']; if(stripos($profile['gender'],t('female')) !== false) $t = t('%1$s updated her %2$s'); elseif(stripos($profile['gender'],t('male')) !== false) $t = t('%1$s updated his %2$s'); else $t = t('%1$s updated their %2$s'); $ptext = '[zrl=' . z_root() . '/photos/' . $channel['channel_address'] . '/image/' . $photo['resource_id'] . ']' . t('profile photo') . '[/zrl]'; $ltext = '[zrl=' . z_root() . '/profile/' . $channel['channel_address'] . ']' . '[zmg=150x150]' . z_root() . '/photo/' . $photo['resource_id'] . '-4[/zmg][/zrl]'; $arr['body'] = sprintf($t,$channel['channel_name'],$ptext) . "\n\n" . $ltext; $arr['obj'] = [ 'type' => ACTIVITY_OBJ_NOTE, 'published' => datetime_convert('UTC', 'UTC', 'now', ATOM_TIME), 'updated' => datetime_convert('UTC', 'UTC', 'now', ATOM_TIME), 'id' => $arr['mid'], 'url' => [ 'type' => 'Link', 'mediaType' => $photo['mimetype'], 'href' => z_root() . '/photo/profile/l/' . $channel['channel_id'] ], 'source' => [ 'content' => $arr['body'], 'mediaType' => 'text/x-multicode' ], 'content' => bbcode($arr['body']), 'actor' => Activity::encode_person($channel,false), ]; $acl = new AccessControl($channel); $x = $acl->get(); $arr['allow_cid'] = $x['allow_cid']; $arr['allow_gid'] = $x['allow_gid']; $arr['deny_cid'] = $x['deny_cid']; $arr['deny_gid'] = $x['deny_gid']; $arr['uid'] = $channel['channel_id']; $arr['aid'] = $channel['channel_account_id']; $arr['owner_xchan'] = $channel['channel_hash']; $arr['author_xchan'] = $channel['channel_hash']; post_activity_item($arr); } function sync_an_item($channel_id,$item_id) { $r = q("select * from item where id = %d", intval($item_id) ); if($r) { if (intval($r[0]['parent']) !== intval($r[0]['id'])) { // sync the parent also. This prevents mis-deliveries from sync packets arriving out of order. $y = q("select * from item where id = %d", intval($r[0]['parent']) ); if ($y) { $r = array_merge($y,$r); } } $encoded = []; xchan_query($r); $sync_items = fetch_post_tags($r); if ($sync_items) { foreach ($sync_items as $i) { $encoded[] = encode_item($i,true); } } Libsync::build_sync_packet($channel_id, ['item' => $encoded]); } } function list_attached_local_files($body) { $files = []; $match = []; // match img and zmg image links if (preg_match_all("/\[[zi]mg(.*?)](.*?)\[\/[zi]mg]/",$body,$match)) { $images = $match[2]; if ($images) { foreach ($images as $image) { if (! stristr($image,z_root() . '/photo/')) { continue; } $image_uri = substr($image,strrpos($image,'/') + 1); if (str_contains($image_uri, '-')) { $image_uri = substr($image_uri,0, strrpos($image_uri,'-')); } if (str_contains($image_uri, '.')) { $image_uri = substr($image_uri,0, strpos($image_uri,'.')); } if ($image_uri) { $files[] = $image_uri; } } } } if (preg_match_all("/\[attachment](.*?)\[\/attachment]/",$body,$match)) { $attaches = $match[1]; if ($attaches) { foreach ($attaches as $attach) { $hash = substr($attach,0,strpos($attach,',')); if ($hash) { $files[] = $hash; } } } } return $files; } function fix_attached_permissions($uid,$body,$str_contact_allow,$str_group_allow,$str_contact_deny,$str_group_deny,$token = EMPTY_STR) { $files = list_attached_local_files($body); if (! $files) { return; } foreach ($files as $file) { $attach_q = q("select id, hash, flags, is_photo, allow_cid, allow_gid, deny_cid, deny_gid from attach where hash = '%s' and uid = %d", dbesc($file), intval($uid) ); if (! $attach_q) { continue; } $attach = array_shift($attach_q); $new_public = !(($str_contact_allow || $str_group_allow || $str_contact_deny || $str_group_deny)); $existing_public = !(($attach['allow_cid'] || $attach['allow_gid'] || $attach['deny_cid'] || $attach['deny_gid'])); if ($existing_public) { // permissions have already been fixed and they are public. There's nothing for us to do. continue; } // if flags & 1, the attachment was uploaded directly into a post and needs to have permissions corrected // or - if it is a private file and a new token was generated, we'll need to add the token to the ACL. if (((intval($attach['flags']) & 1) !== 1) && (! $token)) { continue; } $item_private = 0; if ($new_public === false) { $item_private = (($str_group_allow || ($str_contact_allow && substr_count($str_contact_allow,'<') > 2)) ? 1 : 2); // preserve any existing tokens that may have been set for this file $token_matches = null; if (preg_match_all('//',$attach['allow_cid'],$token_matches, PREG_SET_ORDER)) { foreach ($token_matches as $m) { $tok = ''; if (!str_contains($str_contact_allow, $tok)) { $str_contact_allow .= $tok; } } } if ($token) { $str_contact_allow .= ''; } } q("update attach SET allow_cid = '%s', allow_gid = '%s', deny_cid = '%s', deny_gid = '%s', flags = 0 WHERE id = %d AND uid = %d", dbesc($str_contact_allow), dbesc($str_group_allow), dbesc($str_contact_deny), dbesc($str_group_deny), intval($attach['id']), intval($uid) ); if ($attach['is_photo']) { $r = q("UPDATE photo SET allow_cid = '%s', allow_gid = '%s', deny_cid = '%s', deny_gid = '%s' WHERE resource_id = '%s' AND uid = %d ", dbesc($str_contact_allow), dbesc($str_group_allow), dbesc($str_contact_deny), dbesc($str_group_deny), dbesc($file), intval($uid) ); $r = q("UPDATE item SET allow_cid = '%s', allow_gid = '%s', deny_cid = '%s', deny_gid = '%s', item_private = %d WHERE resource_id = '%s' AND 'resource_type' = 'photo' AND uid = %d", dbesc($str_contact_allow), dbesc($str_group_allow), dbesc($str_contact_deny), dbesc($str_group_deny), intval($item_private), dbesc($file), intval($uid) ); } } } function item_create_edit_activity($post) { // obsolete and not maintained, left in case it is ever needed again if((! $post) || (! $post['item']) || ($post['item']['item_type'] != ITEM_TYPE_POST)) return; $update_item = $post['item']; $new_item = $update_item; $author = q("select * from xchan where xchan_hash = '%s' limit 1", dbesc($new_item['author_xchan']) ); if($author) $item_author = $author[0]; $new_item['id'] = 0; $new_item['parent'] = 0; $new_item['uuid'] = new_uuid(); $new_item['mid'] = z_root() . '/item/' . $new_item['uuid']; $new_item['body'] = sprintf( t('[Edited %s]'), (($update_item['item_thread_top']) ? t('Post','edit_activity') : t('Comment','edit_activity'))); $new_item['body'] .= "\n\n"; $new_item['body'] .= $update_item['body']; $new_item['sig'] = ''; $new_item['verb'] = ACTIVITY_UPDATE; $new_item['item_thread_top'] = 0; $new_item['created'] = $new_item['edited'] = datetime_convert(); $new_item['obj_type'] = (($update_item['item_thread_top']) ? ACTIVITY_OBJ_NOTE : ACTIVITY_OBJ_COMMENT); $new_item['obj'] = json_encode([ 'type' => $new_item['obj_type'], 'id' => $update_item['mid'], 'parent' => $update_item['parent_mid'], 'link' => [['rel' => 'alternate','type' => 'text/html', 'href' => $update_item['plink']]], 'title' => $update_item['title'], 'content' => $update_item['body'], 'created' => $update_item['created'], 'edited' => $update_item['edited'], 'author' => [ 'name' => $item_author['xchan_name'], 'address' => $item_author['xchan_addr'], 'guid' => $item_author['xchan_guid'], 'guid_sig' => $item_author['xchan_guid_sig'], 'link' => [ ['rel' => 'alternate', 'type' => 'text/html', 'href' => $item_author['xchan_url']], ['rel' => 'photo', 'type' => $item_author['xchan_photo_mimetype'], 'href' => $item_author['xchan_photo_m']]], ], ],JSON_UNESCAPED_SLASHES); $x = post_activity_item($new_item); $post_id = $x['id']; if($post_id) { $r = q("select * from item where id = %d", intval($post_id) ); if($r) { xchan_query($r); $sync_item = fetch_post_tags($r); Libsync::build_sync_packet($new_item['uid'], ['item' => [encode_item($sync_item[0],true)]]); } } Run::Summon([ 'Notifier', 'edit_activity', $post_id ]); } /** * @brief copies an entire conversation from the pubstream to this channel's stream * which will allow you to interact with it. */ function copy_of_pubitem($channel,$mid) { $result = null; $syschan = Channel::get_system(); logger('copy_of_pubitem: ' . $channel['channel_id'] . ' mid: ' . $mid); $r = q("select * from item where mid = '%s' and uid = %d limit 1", dbesc($mid), intval($channel['channel_id']) ); if ($r) { logger('exists'); $item = fetch_post_tags($r); return array_shift($item); } // this query is used for the global public stream $r = q("select * from item where parent_mid = ( select parent_mid from item where mid = '%s' and uid = %d ) order by id ", dbesc($mid), intval($syschan['channel_id']) ); // if that failed, try to find entries that would have been posted in the local public stream if (! $r) { $r = q("select * from item where parent_mid = ( select distinct (parent_mid) from item where mid = '%s' and item_wall = 1 and item_private = 0 ) order by id ", dbesc($mid), intval($syschan['channel_id']) ); } if ($r) { $items = fetch_post_tags($r); foreach ($items as $rv) { $d = q("select id from item where mid = '%s' and uid = %d limit 1", dbesc($rv['mid']), intval($channel['channel_id']) ); if ($d) { logger('mid: ' . $rv['mid'] . ' already copied. Continuing.'); continue; } unset($rv['id']); unset($rv['parent']); $rv['aid'] = $channel['channel_account_id']; $rv['uid'] = $channel['channel_id']; $rv['item_wall'] = 0; $rv['item_origin'] = 0; $x = item_store($rv); if ($x['item_id'] && $x['item']['mid'] === $mid) { $result = $x['item']; sync_an_item($channel['channel_id'],$x['item_id']); } } } else { logger('copy query failed.'); } return $result; } function reverse_activity_mid($string) { return str_replace(z_root() . '/activity/', z_root() . '/item/', $string); } function set_activity_mid($string) { return str_replace(z_root() . '/item/', z_root() . '/activity/', $string); }