<?php
namespace Zotlabs\Lib;
use Zotlabs\Lib\Config;
use Zotlabs\Lib\Libzot;
use Zotlabs\Lib\Zotfinger;
use Zotlabs\Lib\Webfinger;
require_once('include/permissions.php');
class Libzotdir {
/**
* @brief
*
* @param int $dirmode
* @return array
*/
static function find_upstream_directory($dirmode) {
$preferred = Config::Get('system','directory_server');
// Thwart attempts to use a private directory
if(($preferred) && ($preferred != z_root())) {
$r = q("select * from site where site_url = '%s' limit 1",
dbesc($preferred)
);
if(($r) && ($r[0]['site_flags'] & DIRECTORY_MODE_STANDALONE)) {
$preferred = '';
}
}
if (! $preferred) {
/*
* No directory has yet been set. For most sites, pick one at random
* from our list of directory servers. However, if we're a directory
* server ourself, point at the local instance
* We will then set this value so this should only ever happen once.
* Ideally there will be an admin setting to change to a different
* directory server if you don't like our choice or if circumstances change.
*/
$directory_fallback_servers = get_directory_fallback_servers();
$dirmode = intval(Config::Get('system','directory_mode'));
if ($dirmode == DIRECTORY_MODE_NORMAL) {
$toss = mt_rand(0,count($directory_fallback_servers));
$preferred = $directory_fallback_servers[$toss];
if(! $preferred) {
$preferred = DIRECTORY_FALLBACK_MASTER;
}
Config::Set('system','directory_server',$preferred);
}
else {
Config::Set('system','directory_server',z_root());
}
}
if($preferred) {
return [ 'url' => $preferred ];
}
else {
return [];
}
}
/**
* Directories may come and go over time. We will need to check that our
* directory server is still valid occasionally, and reset to something that
* is if our directory has gone offline for any reason
*/
static function check_upstream_directory() {
$directory = Config::Get('system', 'directory_server');
// it's possible there is no directory server configured and the local hub is being used.
// If so, default to preserving the absence of a specific server setting.
$isadir = true;
if ($directory) {
$j = Zotfinger::exec($directory);
if(array_path_exists('data/directory_mode',$j)) {
if ($j['data']['directory_mode'] === 'normal') {
$isadir = false;
}
}
}
if (! $isadir)
Config::Set('system', 'directory_server', '');
}
static function get_directory_setting($observer, $setting) {
if ($observer)
$ret = get_xconfig($observer, 'directory', $setting);
else
$ret = ((array_key_exists($setting,$_SESSION)) ? intval($_SESSION[$setting]) : false);
if($ret === false)
$ret = Config::Get('directory', $setting);
// 'safemode' is the default if there is no observer or no established preference.
if($setting === 'safemode' && $ret === false)
$ret = 1;
if($setting === 'globaldir' && intval(Config::Get('system','localdir_hide')))
$ret = 1;
return $ret;
}
/**
* @brief Called by the directory_sort widget.
*/
static function dir_sort_links() {
$safe_mode = 1;
$observer = get_observer_hash();
$safe_mode = self::get_directory_setting($observer, 'safemode');
$globaldir = self::get_directory_setting($observer, 'globaldir');
$pubforums = self::get_directory_setting($observer, 'pubforums');
$hide_local = intval(Config::Get('system','localdir_hide'));
if($hide_local)
$globaldir = 1;
// Build urls without order and pubforums so it's easy to tack on the changed value
// Probably there's an easier way to do this
$directory_sort_order = Config::Get('system','directory_sort_order');
if(! $directory_sort_order)
$directory_sort_order = 'date';
$current_order = $_REQUEST['order'] ?? $directory_sort_order;
$suggest = ((isset($_REQUEST['suggest'])) ? '&suggest=' . $_REQUEST['suggest'] : '');
$url = 'directory?f=';
$tmp = array_merge($_GET,$_POST);
unset($tmp['suggest']);
unset($tmp['pubforums']);
unset($tmp['global']);
unset($tmp['safe']);
unset($tmp['q']);
unset($tmp['f']);
$forumsurl = $url . http_build_query($tmp) . $suggest;
$o = replace_macros(get_markup_template('dir_sort_links.tpl'), [
'$header' => t('Directory Options'),
'$forumsurl' => $forumsurl,
'$safemode' => array('safemode', t('Safe Mode'),$safe_mode,'',array(t('No'), t('Yes')),' onchange=\'window.location.href="' . $forumsurl . '&safe="+(this.checked ? 1 : 0)\''),
'$pubforums' => array('pubforums', t('Public Forums Only'),$pubforums,'',array(t('No'), t('Yes')),' onchange=\'window.location.href="' . $forumsurl . '&pubforums="+(this.checked ? 1 : 0)\''),
'$hide_local' => $hide_local,
'$globaldir' => array('globaldir', t('This Website Only'), 1-intval($globaldir),'',array(t('No'), t('Yes')),' onchange=\'window.location.href="' . $forumsurl . '&global="+(this.checked ? 0 : 1)\''),
]);
return $o;
}
/**
* @brief fetches updates from known directories
*
* Checks the directory mode of this hub to see if it is some form of directory server. If it is,
* get the directory realm of this hub. Fetch a list of all other directory servers in this realm and request
* a directory sync packet. Store these all in the DB.
* In the case of updates, we will query each of them asynchronously from a poller task.
*
* @param int $dirmode;
*/
static function sync_directories($dirmode) {
if ($dirmode == DIRECTORY_MODE_STANDALONE || $dirmode == DIRECTORY_MODE_NORMAL)
return;
$realm = get_directory_realm();
if ($realm == DIRECTORY_REALM) {
$r = q("select * from site where (site_flags & %d) > 0 and site_url != '%s' and site_type = %d and ( site_realm = '%s' or site_realm = '') and site_dead = 0",
intval(DIRECTORY_MODE_PRIMARY|DIRECTORY_MODE_SECONDARY),
dbesc(z_root()),
intval(SITE_TYPE_ZOT),
dbesc($realm)
);
}
else {
$r = q("select * from site where (site_flags & %d) > 0 and site_url != '%s' and site_realm like '%s' and site_type = %d and site_dead = 0",
intval(DIRECTORY_MODE_PRIMARY|DIRECTORY_MODE_SECONDARY),
dbesc(z_root()),
dbesc(protect_sprintf('%' . $realm . '%')),
intval(SITE_TYPE_ZOT)
);
}
// If there are no directory servers, setup the fallback master
/** @FIXME What to do if we're in a different realm? */
if ((! $r) && (z_root() != DIRECTORY_FALLBACK_MASTER)) {
$x = site_store_lowlevel(
[
'site_url' => DIRECTORY_FALLBACK_MASTER,
'site_flags' => DIRECTORY_MODE_PRIMARY,
'site_update' => NULL_DATE,
'site_directory' => DIRECTORY_FALLBACK_MASTER . '/dirsearch',
'site_realm' => DIRECTORY_REALM,
'site_valid' => 1,
]
);
$r = q("select * from site where site_flags in (%d, %d) and site_url != '%s' and site_type = %d and site_dead = 0",
intval(DIRECTORY_MODE_PRIMARY),
intval(DIRECTORY_MODE_SECONDARY),
dbesc(z_root()),
intval(SITE_TYPE_ZOT)
);
}
if (! $r)
return;
$dir_trusted_hosts = array_merge(get_directory_fallback_servers(), Config::Get('system', 'trusted_directory_servers', []));
foreach ($r as $rr) {
if (! $rr['site_directory'])
continue;
logger('sync directories: ' . $rr['site_directory']);
// for brand new directory servers, only load the last couple of days.
// It will take about a month for a new directory to obtain the full current repertoire of channels.
/** @FIXME Go back and pick up earlier ratings if this is a new directory server. These do not get refreshed. */
$token = Config::Get('system','realm_token');
$syncdate = (($rr['site_sync'] <= NULL_DATE) ? datetime_convert('UTC','UTC','now - 2 days') : $rr['site_sync']);
$x = z_fetch_url($rr['site_directory'] . '?f=&sync=' . urlencode($syncdate) . (($token) ? '&t=' . $token : ''));
if (! $x['success'])
continue;
$j = json_decode($x['body'],true);
if (!$j['transactions'])
continue;
q("update site set site_sync = '%s' where site_url = '%s'",
dbesc(datetime_convert()),
dbesc($rr['site_url'])
);
logger('sync_directories: ' . $rr['site_url'] . ': ' . print_r($j,true), LOGGER_DATA);
if (is_array($j['transactions']) && count($j['transactions'])) {
foreach ($j['transactions'] as $t) {
if (empty($t['hash']) || empty($t['host']) || empty($t['address'])) {
continue;
}
$r = q("select * from updates where ud_hash = '%s' limit 1",
dbesc($t['hash'])
);
if ($r) {
$update = 0;
// no need to look at updates that originated from our own site
if ($t['host'] === z_root()) {
continue;
}
// there is more recent xchan information
if ($r[0]['ud_date'] <= $t['timestamp']) {
$update = 1;
}
// the host is trusted and flags have changed - update flags immediately
if (in_array($t['host'], $dir_trusted_hosts) &&
$rr['site_url'] === $t['host'] &&
intval($r[0]['ud_flags']) !== intval($t['flags'])) {
q("UPDATE updates SET ud_update = %d, ud_flags = %d WHERE ud_id = %d",
intval($update),
intval($t['flags']),
dbesc($r[0]['ud_id'])
);
q("UPDATE xchan SET xchan_censored = %d WHERE xchan_hash = '%s'",
intval($t['flags']),
dbesc($r[0]['ud_hash'])
);
continue;
}
if (!$update) {
continue;
}
q("UPDATE updates SET ud_update = %d WHERE ud_id = %d",
intval($update),
dbesc($r[0]['ud_id'])
);
}
else {
q("insert into updates ( ud_hash, ud_host, ud_date, ud_addr, ud_update, ud_flags )
values ( '%s', '%s', '%s', '%s', 1, %d) ",
dbesc($t['hash']),
dbesc($t['host']),
dbesc($t['timestamp']),
dbesc($t['address']),
dbesc(in_array($t['host'], $dir_trusted_hosts) ? $t['flags'] : 0)
);
}
}
}
}
}
/**
* @brief
*
* Given an update record, probe the channel, grab a zot-info packet and refresh/sync the data.
*
* Ignore updating records marked as deleted.
*
* If successful, sets ud_updated in the DB to the current datetime for this
* reddress/webbie.
* Else update ud_last so we can stop trying after 7 days (Daemon/Poller.php)
*
* @param array $ud Entry from update table
*/
static function update_directory_entry($ud) {
logger('update_directory_entry: ' . print_r($ud,true), LOGGER_DATA);
// TODO: remove this check after all directory servers have version > 8.4
// ud_addr will always be the channel url at that time
$href = ((strpos($ud['ud_addr'], '://') === false) ? Webfinger::zot_url(punify($ud['ud_addr'])) : punify($ud['ud_addr']));
if($href) {
$zf = Zotfinger::exec($href);
if($zf && array_path_exists('signature/signer',$zf) && $zf['signature']['signer'] === $href && intval($zf['signature']['header_valid'])) {
$xc = Libzot::import_xchan($zf['data']);
// xchan_hash mismatch - this can happen after a site re-install at the same url
if ($xc['success'] && $xc['hash'] !== $ud['ud_hash']) {
self::delete_by_hash($ud['ud_hash']);
}
// if the channel was deleted - delete the entry in updates
if (!empty($zf['data']['deleted_locally'])) {
self::delete_by_hash($ud['ud_hash']);
}
// This is a workaround for a missing xchan_updated column
// TODO: implement xchan_updated in the xchan table and update this column instead
if(!empty($zf['data']['primary_location']['url'])) {
q("UPDATE hubloc SET hubloc_updated = '%s' WHERE hubloc_id_url = '%s' AND hubloc_primary = 1",
dbesc(datetime_convert()),
dbesc($zf['data']['primary_location']['url'])
);
}
return true;
}
}
q("UPDATE updates SET ud_addr = '%s', ud_last = '%s' WHERE ud_hash = '%s'",
dbesc($href ? $href : $ud['ud_addr']),
dbesc(datetime_convert()),
dbesc($ud['ud_hash'])
);
return false;
}
/**
* @brief Push local channel updates to a local directory server.
*
* This is called from include/directory.php if a profile is to be pushed to the
* directory and the local hub in this case is any kind of directory server.
*
* @param int $uid
* @param boolean $force
*/
static function local_dir_update($uid, $force) {
logger('local_dir_update uid: ' . $uid, LOGGER_DEBUG);
$p = q("select channel.channel_hash, channel_address, channel_timezone, profile.*, xchan.xchan_hidden, xchan.xchan_url from profile left join channel on channel_id = uid left join xchan on channel_hash = xchan_hash where profile.uid = %d and profile.is_default = 1",
intval($uid)
);
if (!$p) {
logger('profile not found');
return;
}
$profile = [];
$profile['encoding'] = 'zot';
$hash = $p[0]['channel_hash'];
$profile['description'] = $p[0]['pdesc'];
$profile['birthday'] = $p[0]['dob'];
if ($age = age($p[0]['dob'],$p[0]['channel_timezone'],''))
$profile['age'] = $age;
$profile['gender'] = $p[0]['gender'];
$profile['marital'] = $p[0]['marital'];
$profile['sexual'] = $p[0]['sexual'];
$profile['locale'] = $p[0]['locality'];
$profile['region'] = $p[0]['region'];
$profile['postcode'] = $p[0]['postal_code'];
$profile['country'] = $p[0]['country_name'];
$profile['about'] = $p[0]['about'];
$profile['homepage'] = $p[0]['homepage'];
$profile['hometown'] = $p[0]['hometown'];
if ($p[0]['keywords']) {
$tags = array();
$k = explode(' ', $p[0]['keywords']);
if ($k)
foreach ($k as $kk)
if (trim($kk))
$tags[] = trim($kk);
if ($tags)
$profile['keywords'] = $tags;
}
$hidden = (1 - intval($p[0]['publish']));
logger('hidden: ' . $hidden);
if(intval($p[0]['xchan_hidden']) !== $hidden) {
q("update xchan set xchan_hidden = %d where xchan_hash = '%s'",
intval($hidden),
dbesc($hash)
);
}
$arr = [ 'channel_id' => $uid, 'hash' => $hash, 'profile' => $profile ];
call_hooks('local_dir_update', $arr);
if (perm_is_allowed($uid, '', 'view_profile')) {
self::import_directory_profile($hash, $arr['profile']);
}
else {
// they may have made it private
q("delete from xprof where xprof_hash = '%s'",
dbesc($hash)
);
q("delete from xtag where xtag_hash = '%s'",
dbesc($hash)
);
}
self::update($hash, $p[0]['xchan_url']);
}
/**
* @brief Imports a directory profile.
*
* @param string $hash
* @param array $profile
* @return boolean $updated if something changed
*/
static function import_directory_profile($hash, $profile) {
logger('import_directory_profile', LOGGER_DEBUG);
if (! $hash)
return false;
$arr = [];
$arr['xprof_hash'] = $hash;
$arr['xprof_dob'] = '0000-00-00';
if (isset($profile['birthday'])) {
$arr['xprof_dob'] = (($profile['birthday'] === '0000-00-00')
? $profile['birthday']
: datetime_convert('', '', $profile['birthday'], 'Y-m-d')); // !!!! check this for 0000 year
}
$arr['xprof_age'] = ((isset($profile['age']) && $profile['age']) ? intval($profile['age']) : 0);
$arr['xprof_desc'] = ((isset($profile['description']) && $profile['description']) ? htmlspecialchars($profile['description'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_gender'] = ((isset($profile['gender']) && $profile['gender']) ? htmlspecialchars($profile['gender'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_marital'] = ((isset($profile['marital']) && $profile['marital']) ? htmlspecialchars($profile['marital'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_sexual'] = ((isset($profile['sexual']) && $profile['sexual']) ? htmlspecialchars($profile['sexual'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_locale'] = ((isset($profile['locale']) && $profile['locale']) ? htmlspecialchars($profile['locale'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_region'] = ((isset($profile['region']) && $profile['region']) ? htmlspecialchars($profile['region'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_postcode'] = ((isset($profile['postcode']) && $profile['postcode']) ? htmlspecialchars($profile['postcode'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_country'] = ((isset($profile['country']) && $profile['country']) ? htmlspecialchars($profile['country'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_about'] = ((isset($profile['about']) && $profile['about']) ? htmlspecialchars($profile['about'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_homepage'] = ((isset($profile['homepage']) && $profile['homepage']) ? htmlspecialchars($profile['homepage'], ENT_COMPAT,'UTF-8',false) : '');
$arr['xprof_hometown'] = ((isset($profile['hometown']) && $profile['hometown']) ? htmlspecialchars($profile['hometown'], ENT_COMPAT,'UTF-8',false) : '');
$clean = array();
if (array_key_exists('keywords', $profile) and is_array($profile['keywords'])) {
self::import_directory_keywords($hash,$profile['keywords']);
foreach ($profile['keywords'] as $kw) {
if (in_array($kw, $clean)) {
continue;
}
$kw = trim(htmlspecialchars($kw,ENT_COMPAT, 'UTF-8', false));
$kw = trim($kw, ',');
$clean[] = $kw;
}
}
$arr['xprof_keywords'] = implode(' ',$clean);
// Self censored, make it so
// These are not translated, so the German "erwachsenen" keyword will not censor the directory profile. Only the English form - "adult".
if(in_arrayi('nsfw',$clean) || in_arrayi('adult',$clean)) {
q("update xchan set xchan_selfcensored = 1 where xchan_hash = '%s'",
dbesc($hash)
);
}
$r = q("select * from xprof where xprof_hash = '%s' limit 1",
dbesc($hash)
);
if ($arr['xprof_age'] > 150)
$arr['xprof_age'] = 150;
if ($arr['xprof_age'] < 0)
$arr['xprof_age'] = 0;
if ($r) {
$update = false;
foreach ($r[0] as $k => $v) {
if ((array_key_exists($k,$arr)) && ($arr[$k] != $v)) {
logger('import_directory_profile: update ' . $k . ' => ' . $arr[$k]);
$update = true;
break;
}
}
if ($update) {
q("update xprof set
xprof_desc = '%s',
xprof_dob = '%s',
xprof_age = %d,
xprof_gender = '%s',
xprof_marital = '%s',
xprof_sexual = '%s',
xprof_locale = '%s',
xprof_region = '%s',
xprof_postcode = '%s',
xprof_country = '%s',
xprof_about = '%s',
xprof_homepage = '%s',
xprof_hometown = '%s',
xprof_keywords = '%s'
where xprof_hash = '%s'",
dbesc($arr['xprof_desc']),
dbesc($arr['xprof_dob']),
intval($arr['xprof_age']),
dbesc($arr['xprof_gender']),
dbesc($arr['xprof_marital']),
dbesc($arr['xprof_sexual']),
dbesc($arr['xprof_locale']),
dbesc($arr['xprof_region']),
dbesc($arr['xprof_postcode']),
dbesc($arr['xprof_country']),
dbesc($arr['xprof_about']),
dbesc($arr['xprof_homepage']),
dbesc($arr['xprof_hometown']),
dbesc($arr['xprof_keywords']),
dbesc($arr['xprof_hash'])
);
}
} else {
$update = true;
logger('New profile');
q("insert into xprof (xprof_hash, xprof_desc, xprof_dob, xprof_age, xprof_gender, xprof_marital, xprof_sexual, xprof_locale, xprof_region, xprof_postcode, xprof_country, xprof_about, xprof_homepage, xprof_hometown, xprof_keywords) values ('%s', '%s', '%s', %d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s') ",
dbesc($arr['xprof_hash']),
dbesc($arr['xprof_desc']),
dbesc($arr['xprof_dob']),
intval($arr['xprof_age']),
dbesc($arr['xprof_gender']),
dbesc($arr['xprof_marital']),
dbesc($arr['xprof_sexual']),
dbesc($arr['xprof_locale']),
dbesc($arr['xprof_region']),
dbesc($arr['xprof_postcode']),
dbesc($arr['xprof_country']),
dbesc($arr['xprof_about']),
dbesc($arr['xprof_homepage']),
dbesc($arr['xprof_hometown']),
dbesc($arr['xprof_keywords'])
);
}
$d = [
'xprof' => $arr,
'profile' => $profile,
'update' => $update
];
/**
* @hooks import_directory_profile
* Called when processing delivery of a profile structure from an external source (usually for directory storage).
* * \e array \b xprof
* * \e array \b profile
* * \e boolean \b update
*/
call_hooks('import_directory_profile', $d);
return $d['update'];
}
/**
* @brief
*
* @param string $hash An xtag_hash
* @param array $keywords
*/
static function import_directory_keywords($hash, $keywords) {
$existing = array();
$r = q("select * from xtag where xtag_hash = '%s' and xtag_flags = 0",
dbesc($hash)
);
$xchan = q("select xchan_censored from xchan where xchan_hash = '%s'",
dbesc($hash)
);
if($r) {
foreach($r as $rr)
$existing[] = $rr['xtag_term'];
}
$clean = array();
foreach($keywords as $kw) {
if (in_array($kw, $clean)) {
continue;
}
$kw = trim(htmlspecialchars($kw,ENT_COMPAT, 'UTF-8', false));
$kw = trim($kw, ',');
$clean[] = $kw;
}
foreach($existing as $x) {
if(! in_array($x, $clean))
$r = q("delete from xtag where xtag_hash = '%s' and xtag_term = '%s' and xtag_flags = 0",
dbesc($hash),
dbesc($x)
);
}
foreach($clean as $x) {
if(! in_array($x, $existing)) {
$r = q("insert into xtag ( xtag_hash, xtag_term, xtag_flags) values ( '%s' ,'%s', %d )",
dbesc($hash),
dbesc($x),
intval($xchan[0]['xchan_censored'])
);
}
}
}
/**
* @brief
*
* @param string $hash the channel hash
* @param string $addr the channel url
* @param bool $bump_date (optional) default true
*/
static function update($hash, $addr, $bump_date = true, $flag = null) {
$dirmode = intval(Config::Get('system', 'directory_mode'));
if($dirmode == DIRECTORY_MODE_NORMAL) {
return;
}
if (empty($hash) || empty($addr)) {
return;
}
$u = q("SELECT * FROM updates WHERE ud_hash = '%s' LIMIT 1",
dbesc($hash)
);
$date_sql = '';
if ($bump_date) {
$date_sql = "ud_date = '" . dbesc(datetime_convert()) . "',";
}
$flag_sql = '';
if ($flag !== null) {
$flag_sql = "ud_flags = '" . intval($flag) . "',";
}
if ($u) {
$x = q("UPDATE updates SET $date_sql $flag_sql ud_last = '%s', ud_host = '%s', ud_addr = '%s', ud_update = 0 WHERE ud_id = %d",
dbesc(NULL_DATE),
dbesc(z_root()),
dbesc($addr),
intval($u[0]['ud_id'])
);
return;
}
q("INSERT INTO updates (ud_hash, ud_host, ud_date, ud_addr, ud_flags) VALUES ( '%s', '%s', '%s', '%s', %d )",
dbesc($hash),
dbesc(z_root()),
dbesc(datetime_convert()),
dbesc($addr),
intval($flag)
);
return;
}
/**
* @brief deletes a entry in updates by hash
*
* @param string $hash the channel hash
* @return boolean
*/
static function delete_by_hash($hash) {
if (!$hash) {
return false;
}
$x = q("DELETE FROM updates WHERE ud_hash = '%s'",
dbesc($hash)
);
if ($x) {
return true;
}
return false;
}
}