diff --git a/Code/Access/AccessControl.php b/Code/Access/AccessControl.php new file mode 100644 index 000000000..cb95be4d6 --- /dev/null +++ b/Code/Access/AccessControl.php @@ -0,0 +1,169 @@ + string of allowed cids + * * \e string \b channel_allow_gid => string of allowed gids + * * \e string \b channel_deny_cid => string of denied cids + * * \e string \b channel_deny_gid => string of denied gids + */ + public function __construct($channel) + { + if ($channel) { + $this->allow_cid = $channel['channel_allow_cid']; + $this->allow_gid = $channel['channel_allow_gid']; + $this->deny_cid = $channel['channel_deny_cid']; + $this->deny_gid = $channel['channel_deny_gid']; + } else { + $this->allow_cid = ''; + $this->allow_gid = ''; + $this->deny_cid = ''; + $this->deny_gid = ''; + } + + $this->explicit = false; + } + + /** + * @brief Get if we are using the default constructor values + * or values that have been set explicitly. + * + * @return bool + */ + public function get_explicit() + { + return $this->explicit; + } + + /** + * @brief Set access list from strings such as those in already + * existing stored data items. + * + * @note The array to pass to this set function is different from the array + * that you provide to the constructor or set_from_array(). + * + * @param array $arr + * * \e string \b allow_cid => string of allowed cids + * * \e string \b allow_gid => string of allowed gids + * * \e string \b deny_cid => string of denied cids + * * \e string \b deny_gid => string of denied gids + * @param bool $explicit (optional) default true + */ + public function set($arr, $explicit = true) + { + $this->allow_cid = $arr['allow_cid']; + $this->allow_gid = $arr['allow_gid']; + $this->deny_cid = $arr['deny_cid']; + $this->deny_gid = $arr['deny_gid']; + + $this->explicit = $explicit; + } + + /** + * @brief Return an array consisting of the current access list components + * where the elements are directly storable. + * + * @return array An associative array with: + * * \e string \b allow_cid => string of allowed cids + * * \e string \b allow_gid => string of allowed gids + * * \e string \b deny_cid => string of denied cids + * * \e string \b deny_gid => string of denied gids + */ + public function get() + { + return [ + 'allow_cid' => $this->allow_cid, + 'allow_gid' => $this->allow_gid, + 'deny_cid' => $this->deny_cid, + 'deny_gid' => $this->deny_gid, + ]; + } + + /** + * @brief Set access list components from arrays, such as those provided by + * acl_selector(). + * + * For convenience, a string (or non-array) input is assumed to be a + * comma-separated list and auto-converted into an array. + * + * @note The array to pass to this set function is different from the array + * that you provide to the constructor or set(). + * + * @param array $arr An associative array with: + * * \e array|string \b contact_allow => array with cids or comma-seperated string + * * \e array|string \b group_allow => array with gids or comma-seperated string + * * \e array|string \b contact_deny => array with cids or comma-seperated string + * * \e array|string \b group_deny => array with gids or comma-seperated string + * @param bool $explicit (optional) default true + */ + public function set_from_array($arr, $explicit = true) + { + $this->allow_cid = perms2str((is_array($arr['contact_allow'])) + ? $arr['contact_allow'] : explode(',', $arr['contact_allow'])); + $this->allow_gid = perms2str((is_array($arr['group_allow'])) + ? $arr['group_allow'] : explode(',', $arr['group_allow'])); + $this->deny_cid = perms2str((is_array($arr['contact_deny'])) + ? $arr['contact_deny'] : explode(',', $arr['contact_deny'])); + $this->deny_gid = perms2str((is_array($arr['group_deny'])) + ? $arr['group_deny'] : explode(',', $arr['group_deny'])); + + $this->explicit = $explicit; + } + + /** + * @brief Returns true if any access lists component is set. + * + * @return bool Return true if any of allow_* deny_* values is set. + */ + public function is_private() + { + return (($this->allow_cid || $this->allow_gid || $this->deny_cid || $this->deny_gid) ? true : false); + } +} diff --git a/Code/Access/PermissionLimits.php b/Code/Access/PermissionLimits.php new file mode 100644 index 000000000..9fe7d5928 --- /dev/null +++ b/Code/Access/PermissionLimits.php @@ -0,0 +1,109 @@ + $v) { + if (strstr($k, 'view')) { + $limits[$k] = PERMS_PUBLIC; + } else { + $limits[$k] = PERMS_SPECIFIC; + } + } + + return $limits; + } + + /** + * @brief Sets a permission limit for a channel. + * + * @param int $channel_id + * @param string $perm + * @param int $perm_limit one of PERMS_* constants + */ + public static function Set($channel_id, $perm, $perm_limit) + { + PConfig::Set($channel_id, 'perm_limits', $perm, $perm_limit); + } + + /** + * @brief Get a channel's permission limits. + * + * Return a channel's permission limits from PConfig. If $perm is set just + * return this permission limit, if not set, return an array with all + * permission limits. + * + * @param int $channel_id + * @param string $perm (optional) + * @return + * * \b false if no perm_limits set for this channel + * * \b int if $perm is set, return one of PERMS_* constants for this permission, default 0 + * * \b array with all permission limits, if $perm is not set + */ + public static function Get($channel_id, $perm = '') + { + + if (! intval($channel_id)) { + return false; + } + + if ($perm) { + $x = PConfig::Get($channel_id, 'perm_limits', $perm); + if ($x === false) { + $a = [ 'channel_id' => $channel_id, 'permission' => $perm, 'value' => $x ]; + Hook::call('permission_limits_get', $a); + return intval($a['value']); + } + return intval($x); + } + + PConfig::Load($channel_id); + if (array_key_exists($channel_id, App::$config) && array_key_exists('perm_limits', App::$config[$channel_id])) { + return App::$config[$channel_id]['perm_limits']; + } + + return false; + } +} diff --git a/Code/Access/PermissionRoles.php b/Code/Access/PermissionRoles.php new file mode 100644 index 000000000..1b6ec352b --- /dev/null +++ b/Code/Access/PermissionRoles.php @@ -0,0 +1,155 @@ + $role, 'result' => $ret]; + + Hook::call('get_role_perms', $x); + + return $x['result']; + } + + + /** + * @brief Array with translated role names and grouping. + * + * Return an associative array with grouped role names that can be used + * to create select groups like in \e field_select_grouped.tpl. + * + * @return array + */ + public static function roles() + { + $roles = [ + t('Social Networking') => [ + 'social' => t('Social - Normal'), + 'social_restricted' => t('Social - Restricted') + ], + + t('Community Group') => [ + 'forum' => t('Group - Normal'), + 'forum_restricted' => t('Group - Restricted'), + 'forum_moderated' => t('Group - Moderated') + ], + ]; + + Hook::call('list_permission_roles', $roles); + + return $roles; + } + +} diff --git a/Code/Access/Permissions.php b/Code/Access/Permissions.php new file mode 100644 index 000000000..3fd019214 --- /dev/null +++ b/Code/Access/Permissions.php @@ -0,0 +1,298 @@ + t('Grant viewing access to and delivery of your channel stream and posts'), + 'view_profile' => t('Grant viewing access to your default channel profile'), + 'view_contacts' => t('Grant viewing access to your address book (connections)'), + 'view_storage' => t('Grant viewing access to your file storage and photos'), + 'post_wall' => t('Grant permission to post on your channel (wall) page'), + 'post_mail' => t('Accept delivery of direct messages and personal mail'), + 'send_stream' => t('Accept delivery of their posts and all comments to their posts'), + 'post_comments' => t('Accept delivery of their comments and likes on your posts'), + 'write_storage' => t('Grant upload permissions to your file storage and photos'), + 'republish' => t('Grant permission to republish/mirror your posts'), + 'moderated' => t('Accept comments and wall posts only after approval (moderation)'), + 'delegate' => t('Grant channel administration (delegation) permission') + ]; + + $x = [ + 'permissions' => $perms, + 'filter' => $filter + ]; + /** + * @hooks permissions_list + * * \e array \b permissions + * * \e string \b filter + */ + Hook::call('permissions_list', $x); + + return($x['permissions']); + } + + /** + * @brief Perms from the above list that are blocked from anonymous observers. + * + * e.g. you must be authenticated. + * + * @return array Associative array with permissions and short description. + */ + public static function BlockedAnonPerms() + { + + $res = []; + $perms = PermissionLimits::Std_limits(); + foreach ($perms as $perm => $limit) { + if ($limit != PERMS_PUBLIC) { + $res[] = $perm; + } + } + + $x = ['permissions' => $res]; + /** + * @hooks write_perms + * * \e array \b permissions + */ + Hook::call('write_perms', $x); + + return($x['permissions']); + } + + /** + * @brief Converts indexed perms array to associative perms array. + * + * Converts [ 0 => 'view_stream', ... ] + * to [ 'view_stream' => 1 ] for any permissions in $arr; + * Undeclared permissions which exist in Perms() are added and set to 0. + * + * @param array $arr + * @return array + */ + public static function FilledPerms($arr) + { + if (is_null($arr) || (! is_array($arr))) { + btlogger('FilledPerms: ' . print_r($arr, true)); + $arr = []; + } + + $everything = self::Perms(); + $ret = []; + foreach ($everything as $k => $v) { + if (in_array($k, $arr)) { + $ret[$k] = 1; + } else { + $ret[$k] = 0; + } + } + + return $ret; + } + + /** + * @brief Convert perms array to indexed array. + * + * Converts [ 'view_stream' => 1 ] for any permissions in $arr + * to [ 0 => ['name' => 'view_stream', 'value' => 1], ... ] + * + * @param array $arr associative perms array 'view_stream' => 1 + * @return array Indexed array with elements that look like + * * \e string \b name the perm name (e.g. view_stream) + * * \e int \b value the value of the perm (e.g. 1) + */ + public static function OPerms($arr) + { + $ret = []; + if ($arr) { + foreach ($arr as $k => $v) { + $ret[] = [ 'name' => $k, 'value' => $v ]; + } + } + return $ret; + } + + /** + * @brief + * + * @param int $channel_id + * @return bool|array + */ + public static function FilledAutoperms($channel_id) + { + if (! intval(get_pconfig($channel_id, 'system', 'autoperms'))) { + return false; + } + + $arr = []; + $r = q( + "select * from pconfig where uid = %d and cat = 'autoperms'", + intval($channel_id) + ); + if ($r) { + foreach ($r as $rr) { + $arr[$rr['k']] = intval($rr['v']); + } + } + return $arr; + } + + /** + * @brief Compares that all Permissions from $p1 exist also in $p2. + * + * @param array $p1 The perms that have to exist in $p2 + * @param array $p2 The perms to compare against + * @return bool true if all perms from $p1 exist also in $p2 + */ + public static function PermsCompare($p1, $p2) + { + foreach ($p1 as $k => $v) { + if (! array_key_exists($k, $p2)) { + return false; + } + + if ($p1[$k] != $p2[$k]) { + return false; + } + } + + return true; + } + + /** + * @brief + * + * @param int $channel_id A channel id + * @return array Associative array with + * * \e array \b perms Permission array + * * \e int \b automatic 0 or 1 + */ + public static function connect_perms($channel_id) + { + + $my_perms = []; + $permcat = null; + $automatic = 0; + + // If a default permcat exists, use that + + $pc = ((Zlib\Apps::system_app_installed($channel_id, 'Roles')) ? get_pconfig($channel_id, 'system', 'default_permcat') : 'default'); + if (! in_array($pc, [ '','default' ])) { + $pcp = new Zlib\Permcat($channel_id); + $permcat = $pcp->fetch($pc); + if ($permcat && $permcat['perms']) { + foreach ($permcat['perms'] as $p) { + $my_perms[$p['name']] = $p['value']; + } + } + } + + $automatic = intval(get_pconfig($channel_id, 'system', 'autoperms')); + + // look up the permission role to see if it specified auto-connect + // and if there was no permcat or a default permcat, set the perms + // from the role + + $role = get_pconfig($channel_id, 'system', 'permissions_role'); + if ($role) { + $xx = PermissionRoles::role_perms($role); + + if ((! $my_perms) && ($xx['perms_connect'])) { + $default_perms = $xx['perms_connect']; + $my_perms = Permissions::FilledPerms($default_perms); + } + } + + // If we reached this point without having any permission information, + // it is likely a custom permissions role. First see if there are any + // automatic permissions. + + if (! $my_perms) { + $m = Permissions::FilledAutoperms($channel_id); + if ($m) { + $my_perms = $m; + } + } + + // If we reached this point with no permissions, the channel is using + // custom perms but they are not automatic. They will be stored in abconfig with + // the channel's channel_hash (the 'self' connection). + + if (! $my_perms) { + $c = Channel::from_id($channel_id); + if ($c) { + $my_perms = Permissions::FilledPerms(explode(',', get_abconfig($channel_id, $c['channel_hash'], 'system', 'my_perms', EMPTY_STR))); + } + } + + return ( [ 'perms' => $my_perms, 'automatic' => $automatic ] ); + } + + + public static function serialise($p) + { + $n = []; + if ($p) { + foreach ($p as $k => $v) { + if (intval($v)) { + $n[] = $k; + } + } + } + return implode(',', $n); + } +} diff --git a/Code/Daemon/Addon.php b/Code/Daemon/Addon.php new file mode 100644 index 000000000..3f62bd834 --- /dev/null +++ b/Code/Daemon/Addon.php @@ -0,0 +1,15 @@ + $max_thumb || $height > $max_thumb) { + $imagick_path = get_config('system', 'imagick_convert_path'); + if ($imagick_path && @file_exists($imagick_path)) { + $tmp_name = $path . '-001'; + $newsize = photo_calculate_scale(array_merge($is, ['max' => $max_thumb])); + $cmd = $imagick_path . ' ' . escapeshellarg(PROJECT_BASE . '/' . $path) . ' -resize ' . $newsize . ' ' . escapeshellarg(PROJECT_BASE . '/' . $tmp_name); + + for ($x = 0; $x < 4; $x++) { + exec($cmd); + if (file_exists($tmp_name)) { + break; + } + continue; + } + + if (! file_exists($tmp_name)) { + return; + } + @rename($tmp_name, $path); + } + } + } +} diff --git a/Code/Daemon/Cache_embeds.php b/Code/Daemon/Cache_embeds.php new file mode 100644 index 000000000..d24d34709 --- /dev/null +++ b/Code/Daemon/Cache_embeds.php @@ -0,0 +1,35 @@ + 1) && ($argv[1])) { + $site_id = $argv[1]; + } + + if ($site_id) { + $sql_options = " and site_url = '" . dbesc($argv[1]) . "' "; + } + + $days = intval(get_config('system', 'sitecheckdays')); + if ($days < 1) { + $days = 30; + } + + $r = q( + "select * from site where site_dead = 0 and site_update < %s - INTERVAL %s and site_type = %d $sql_options ", + db_utcnow(), + db_quoteinterval($days . ' DAY'), + intval(SITE_TYPE_ZOT) + ); + + if (! $r) { + return; + } + + foreach ($r as $rr) { + if (! strcasecmp($rr['site_url'], z_root())) { + continue; + } + + $x = ping_site($rr['site_url']); + if ($x['success']) { + logger('checksites: ' . $rr['site_url']); + q( + "update site set site_update = '%s' where site_url = '%s' ", + dbesc(datetime_convert()), + dbesc($rr['site_url']) + ); + } else { + logger('marking dead site: ' . $x['message']); + q( + "update site set site_dead = 1 where site_url = '%s' ", + dbesc($rr['site_url']) + ); + } + } + + return; + } +} diff --git a/Code/Daemon/Content_importer.php b/Code/Daemon/Content_importer.php new file mode 100644 index 000000000..1e1873be0 --- /dev/null +++ b/Code/Daemon/Content_importer.php @@ -0,0 +1,63 @@ + random_string(), + 'X-API-Request' => $hz_server . '/api/z/1.0/item/export_page?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until) . '&page=' . $page , + 'Host' => $m['host'], + '(request-target)' => 'get /api/z/1.0/item/export_page?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until) . '&page=' . $page , + ]; + + $headers = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel), true, 'sha512'); + + $x = z_fetch_url($hz_server . '/api/z/1.0/item/export_page?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until) . '&page=' . $page, false, $redirects, [ 'headers' => $headers ]); + + if (! $x['success']) { + logger('no API response', LOGGER_DEBUG); + killme(); + } + + $j = json_decode($x['body'], true); + + if (! $j) { + killme(); + } + + if (! ($j['item'] || count($j['item']))) { + killme(); + } + + import_items($channel, $j['item'], false, ((array_key_exists('relocate', $j)) ? $j['relocate'] : null)); + + killme(); + } +} diff --git a/Code/Daemon/Convo.php b/Code/Daemon/Convo.php new file mode 100644 index 000000000..0c18349e2 --- /dev/null +++ b/Code/Daemon/Convo.php @@ -0,0 +1,62 @@ +get(); + + if ($messages) { + foreach ($messages as $message) { + if (is_string($message)) { + $message = Activity::fetch($message, $channel); + } + // set client flag because comments will probably just be objects and not full blown activities + // and that lets us use implied_create + $AS = new ActivityStreams($message, null, true); + if ($AS->is_valid() && is_array($AS->obj)) { + $item = Activity::decode_note($AS, true); + Activity::store($channel, $contact['abook_xchan'], $AS, $item, true, true); + } + } + } + } +} diff --git a/Code/Daemon/Cron.php b/Code/Daemon/Cron.php new file mode 100644 index 000000000..93547eb8e --- /dev/null +++ b/Code/Daemon/Cron.php @@ -0,0 +1,223 @@ + $maxsysload) { + logger('system: load ' . $load . ' too high. Cron deferred to next scheduled run.'); + return; + } + } + + // Check for a lockfile. If it exists, but is over an hour old, it's stale. Ignore it. + $lockfile = 'cache/cron'; + if ( + (file_exists($lockfile)) && (filemtime($lockfile) > (time() - 3600)) + && (! get_config('system', 'override_cron_lockfile')) + ) { + logger("cron: Already running"); + return; + } + + // Create a lockfile. Needs two vars, but $x doesn't need to contain anything. + file_put_contents($lockfile, $x); + + logger('cron: start'); + + // run queue delivery process in the background + + Run::Summon([ 'Queue' ]); + + Run::Summon([ 'Poller' ]); + + // maintenance for mod sharedwithme - check for updated items and remove them + + require_once('include/sharedwithme.php'); + apply_updates(); + + // expire any expired items + + $r = q( + "select id,item_wall from item where expires > '2001-01-01 00:00:00' and expires < %s + and item_deleted = 0 ", + db_utcnow() + ); + if ($r) { + require_once('include/items.php'); + foreach ($r as $rr) { + drop_item($rr['id'], false, (($rr['item_wall']) ? DROPITEM_PHASE1 : DROPITEM_NORMAL)); + if ($rr['item_wall']) { + // The notifier isn't normally invoked unless item_drop is interactive. + Run::Summon([ 'Notifier', 'drop', $rr['id'] ]); + } + } + } + + + // delete expired access tokens + + $r = q( + "select atoken_id from atoken where atoken_expires > '%s' and atoken_expires < %s", + dbesc(NULL_DATE), + db_utcnow() + ); + if ($r) { + require_once('include/security.php'); + foreach ($r as $rr) { + atoken_delete($rr['atoken_id']); + } + } + + // Ensure that every channel pings their directory occasionally. + + $r = q( + "select channel_id from channel where channel_dirdate < %s - INTERVAL %s and channel_removed = 0", + db_utcnow(), + db_quoteinterval('7 DAY') + ); + if ($r) { + foreach ($r as $rr) { + Run::Summon([ 'Directory', $rr['channel_id'], 'force' ]); + if ($interval) { + @time_sleep_until(microtime(true) + (float) $interval); + } + } + } + + // publish any applicable items that were set to be published in the future + // (time travel posts). Restrict to items that have come of age in the last + // couple of days to limit the query to something reasonable. + + $r = q( + "select id from item where item_delayed = 1 and created <= %s and created > '%s' ", + db_utcnow(), + dbesc(datetime_convert('UTC', 'UTC', 'now - 2 days')) + ); + if ($r) { + foreach ($r as $rr) { + $x = q( + "update item set item_delayed = 0 where id = %d", + intval($rr['id']) + ); + if ($x) { + $z = q( + "select * from item where id = %d", + intval($message_id) + ); + if ($z) { + xchan_query($z); + $sync_item = fetch_post_tags($z); + Libsync::build_sync_packet( + $sync_item[0]['uid'], + [ + 'item' => [ encode_item($sync_item[0], true) ] + ] + ); + } + Run::Summon([ 'Notifier','wall-new',$rr['id'] ]); + } + } + } + + require_once('include/attach.php'); + attach_upgrade(); + + $abandon_days = intval(get_config('system', 'account_abandon_days')); + if ($abandon_days < 1) { + $abandon_days = 0; + } + + + // once daily run birthday_updates and then expire in background + + // FIXME: add birthday updates, both locally and for xprof for use + // by directory servers + + $d1 = intval(get_config('system', 'last_expire_day')); + $d2 = intval(datetime_convert('UTC', 'UTC', 'now', 'd')); + + // Allow somebody to staggger daily activities if they have more than one site on their server, + // or if it happens at an inconvenient (busy) hour. + + $h1 = intval(get_config('system', 'cron_hour')); + $h2 = intval(datetime_convert('UTC', 'UTC', 'now', 'G')); + + + if (($d2 != $d1) && ($h1 == $h2)) { + Run::Summon([ 'Cron_daily' ]); + } + + // update any photos which didn't get imported properly + // This should be rare + + $r = q( + "select xchan_photo_l, xchan_hash from xchan where xchan_photo_l != '' and xchan_photo_m = '' + and xchan_photo_date < %s - INTERVAL %s", + db_utcnow(), + db_quoteinterval('1 DAY') + ); + if ($r) { + require_once('include/photo_factory.php'); + foreach ($r as $rr) { + $photos = import_remote_xchan_photo($rr['xchan_photo_l'], $rr['xchan_hash']); + if ($photos) { + $x = q( + "update xchan set xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s', xchan_photo_mimetype = '%s' + where xchan_hash = '%s'", + dbesc($photos[0]), + dbesc($photos[1]), + dbesc($photos[2]), + dbesc($photos[3]), + dbesc($rr['xchan_hash']) + ); + } + } + } + + $generation = 0; + + $restart = false; + + if (($argc > 1) && ($argv[1] == 'restart')) { + $restart = true; + $generation = intval($argv[2]); + if (! $generation) { + return; + } + } + + Addon::reload_all(); + + $d = datetime_convert(); + + // TODO check to see if there are any cronhooks before wasting a process + + if (! $restart) { + Run::Summon([ 'Cronhooks' ]); + } + + set_config('system', 'lastcron', datetime_convert()); + + //All done - clear the lockfile + @unlink($lockfile); + + return; + } +} diff --git a/Code/Daemon/Cron_daily.php b/Code/Daemon/Cron_daily.php new file mode 100644 index 000000000..bdbc01fd5 --- /dev/null +++ b/Code/Daemon/Cron_daily.php @@ -0,0 +1,117 @@ + %s - INTERVAL %s and channel_deleted < %s - INTERVAL %s", + db_utcnow(), + db_quoteinterval('21 DAY'), + db_utcnow(), + db_quoteinterval('10 DAY') + ); + if ($r) { + foreach ($r as $rv) { + Channel::channel_remove_final($rv['channel_id']); + } + } + + // get rid of really old poco records + + q( + "delete from xlink where xlink_updated < %s - INTERVAL %s and xlink_static = 0 ", + db_utcnow(), + db_quoteinterval('14 DAY') + ); + + // Check for dead sites + Run::Summon(['Checksites' ]); + + + // clean up image cache - use site expiration or 60 days if not set or zero + + $files = glob('cache/img/*/*'); + $expire_days = intval(get_config('system', 'default_expire_days')); + if ($expire_days <= 0) { + $expire_days = 60; + } + $now = time(); + $maxage = 86400 * $expire_days; + if ($files) { + foreach ($files as $file) { + if (is_file($file)) { + if ($now - filemtime($file) >= $maxage) { + unlink($file); + } + } + } + } + + // update searchable doc indexes + + Run::Summon([ 'Importdoc']); + + /** + * End Cron Weekly + */ + } +} diff --git a/Code/Daemon/Cronhooks.php b/Code/Daemon/Cronhooks.php new file mode 100644 index 000000000..dbc523d66 --- /dev/null +++ b/Code/Daemon/Cronhooks.php @@ -0,0 +1,22 @@ +start(); + + $_SESSION['authenticated'] = 1; + $_SESSION['uid'] = $argv[1]; + + $x = session_id(); + + $f = 'cache/cookie_' . $argv[1]; + $c = 'cache/cookien_' . $argv[1]; + + $e = file_exists($f); + + $output = ''; + + if ($e) { + $lines = file($f); + if ($lines) { + foreach ($lines as $line) { + if (strlen($line) > 0 && $line[0] != '#' && substr_count($line, "\t") == 6) { + $tokens = explode("\t", $line); + $tokens = array_map('trim', $tokens); + if ($tokens[4] > time()) { + $output .= $line . "\n"; + } + } else { + $output .= $line; + } + } + } + } + $t = time() + (24 * 3600); + file_put_contents($f, $output . 'HttpOnly_' . App::get_hostname() . "\tFALSE\t/\tTRUE\t$t\tPHPSESSID\t" . $x, (($e) ? FILE_APPEND : 0)); + + file_put_contents($c, $x); + + return; + } +} diff --git a/Code/Daemon/Deliver.php b/Code/Daemon/Deliver.php new file mode 100644 index 000000000..bfbc6875d --- /dev/null +++ b/Code/Daemon/Deliver.php @@ -0,0 +1,36 @@ + 2) { + if ($argv[2] === 'force') { + $force = true; + } + if ($argv[2] === 'nopush') { + $pushall = false; + } + } + + logger('directory update', LOGGER_DEBUG); + + $channel = Channel::from_id($argv[1]); + if (! $channel) { + return; + } + + // update the local directory - was optional, but now done regardless + + Libzotdir::local_dir_update($argv[1], $force); + + q( + "update channel set channel_dirdate = '%s' where channel_id = %d", + dbesc(datetime_convert()), + intval($channel['channel_id']) + ); + + // Now update all the connections + if ($pushall) { + Run::Summon([ 'Notifier','refresh_all',$channel['channel_id'] ]); + } + } +} diff --git a/Code/Daemon/Expire.php b/Code/Daemon/Expire.php new file mode 100644 index 000000000..2d2fb0912 --- /dev/null +++ b/Code/Daemon/Expire.php @@ -0,0 +1,98 @@ + random_string(), + 'X-API-Request' => $hz_server . '/api/z/1.0/file/export?f=&zap_compat=1&file_id=' . $attach_id, + 'Host' => $m['host'], + '(request-target)' => 'get /api/z/1.0/file/export?f=&zap_compat=1&file_id=' . $attach_id, + ]; + + $headers = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel), true, 'sha512'); + $x = z_fetch_url($hz_server . '/api/z/1.0/file/export?f=&zap_compat=1&file_id=' . $attach_id, false, $redirects, [ 'headers' => $headers ]); + + if (! $x['success']) { + logger('no API response', LOGGER_DEBUG); + return; + } + + $j = json_decode($x['body'], true); + + $r = sync_files($channel, [$j]); + + killme(); + } +} diff --git a/Code/Daemon/Gprobe.php b/Code/Daemon/Gprobe.php new file mode 100644 index 000000000..1785074df --- /dev/null +++ b/Code/Daemon/Gprobe.php @@ -0,0 +1,66 @@ + 3) ? $argv[3] : ''); + $dstname = (($argc > 4) ? $argv[4] : ''); + + $hash = random_string(); + + $arr = [ + 'src' => $srcfile, + 'filename' => (($dstname) ? $dstname : basename($srcfile)), + 'hash' => $hash, + 'allow_cid' => $channel['channel_allow_cid'], + 'allow_gid' => $channel['channel_allow_gid'], + 'deny_cid' => $channel['channel_deny_cid'], + 'deny_gid' => $channel['channel_deny_gid'], + 'preserve_original' => true, + 'replace' => true + ]; + + if ($folder) { + $arr['folder'] = $folder; + } + + attach_store($channel, $channel['channel_hash'], 'import', $arr); + + $sync = attach_export_data($channel, $hash); + if ($sync) { + Libsync::build_sync_packet($channel['channel_id'], array('file' => array($sync))); + } + + return; + } +} diff --git a/Code/Daemon/Notifier.php b/Code/Daemon/Notifier.php new file mode 100644 index 000000000..3be23de3f --- /dev/null +++ b/Code/Daemon/Notifier.php @@ -0,0 +1,816 @@ + $argv[4] ]; + self::$encoding = 'zot'; + $normal_mode = false; + } elseif ($cmd === 'keychange') { + self::$channel = Channel::from_id($item_id); + $r = q( + "select abook_xchan from abook where abook_channel = %d", + intval($item_id) + ); + if ($r) { + foreach ($r as $rr) { + self::$recipients[] = $rr['abook_xchan']; + } + } + self::$private = false; + self::$packet_type = 'keychange'; + self::$encoded_item = get_pconfig(self::$channel['channel_id'], 'system', 'keychange'); + self::$encoding = 'zot'; + $normal_mode = false; + } elseif (in_array($cmd, [ 'permissions_update', 'permissions_reject', 'permissions_accept', 'permissions_create' ])) { + // Get the (single) recipient + + $r = q( + "select * from abook left join xchan on abook_xchan = xchan_hash where abook_id = %d and abook_self = 0", + intval($item_id) + ); + if ($r) { + $recip = array_shift($r); + $uid = $recip['abook_channel']; + // Get the sender + self::$channel = Channel::from_id($uid); + if (self::$channel) { + $perm_update = [ 'sender' => self::$channel, 'recipient' => $recip, 'success' => false, 'deliveries' => '' ]; + + switch ($cmd) { + case 'permissions_create': + ActivityPub::permissions_create($perm_update); + break; + case 'permissions_accept': + ActivityPub::permissions_accept($perm_update); + break; + case 'permissions_update': + ActivityPub::permissions_update($perm_update); + break; + + default: + break; + } + if (! $perm_update['success']) { + Hook::call($cmd, $perm_update); + } + + if ($perm_update['success']) { + if ($perm_update['deliveries']) { + self::$deliveries[] = $perm_update['deliveries']; + do_delivery(self::$deliveries); + } + return; + } else { + self::$recipients[] = $recip['abook_xchan']; + self::$private = false; + self::$packet_type = 'refresh'; + self::$env_recips = [ $recip['xchan_hash'] ]; + } + } + } + } elseif ($cmd === 'refresh_all') { + logger('notifier: refresh_all: ' . $item_id); + + self::$channel = Channel::from_id($item_id, true); + $r = q( + "select abook_xchan from abook where abook_channel = %d", + intval($item_id) + ); + if ($r) { + foreach ($r as $rr) { + self::$recipients[] = $rr['abook_xchan']; + } + } + self::$recipients[] = self::$channel['channel_hash']; + self::$private = false; + self::$packet_type = 'refresh'; + } elseif ($cmd === 'purge') { + $xchan = $argv[3]; + logger('notifier: purge: ' . $item_id . ' => ' . $xchan); + if (! $xchan) { + return; + } + + self::$channel = Channel::from_id($item_id, true); + self::$recipients = [ $xchan ]; + self::$private = true; + self::$packet_type = 'purge'; + } elseif ($cmd === 'purge_all') { + logger('notifier: purge_all: ' . $item_id); + self::$channel = Channel::from_id($item_id, true); + + self::$recipients = []; + $r = q( + "select abook_xchan from abook where abook_channel = %d and abook_self = 0", + intval($item_id) + ); + if (! $r) { + return; + } + foreach ($r as $rr) { + self::$recipients[] = $rr['abook_xchan']; + } + + self::$private = false; + self::$packet_type = 'purge'; + } else { + // Normal items + + // Fetch the target item + + $r = q( + "SELECT * FROM item WHERE id = %d and parent != 0 LIMIT 1", + intval($item_id) + ); + + if (! $r) { + return; + } + + xchan_query($r); + $r = fetch_post_tags($r); + + $target_item = array_shift($r); + + if ($target_item['author']['xchan_network'] === 'anon') { + logger('notifier: target item author is not a fetchable actor', LOGGER_DEBUG); + return; + } + + $deleted_item = false; + + if (intval($target_item['item_deleted'])) { + logger('notifier: target item ITEM_DELETED', LOGGER_DEBUG); + $deleted_item = true; + } + + if (! in_array(intval($target_item['item_type']), [ ITEM_TYPE_POST, ITEM_TYPE_MAIL ])) { + if (intval($target_item['item_type'] == ITEM_TYPE_CUSTOM)) { + $hookinfo=[ + 'targetitem' => $target_item, + 'deliver' => false + ]; + + Hook::call('customitem_deliver', $hookinfo); + } + + if (! $hookinfo['deliver']) { + logger('notifier: target item not forwardable: type ' . $target_item['item_type'], LOGGER_DEBUG); + return; + } + } + + // Check for non published items, but allow an exclusion for transmitting hidden file activities + + if (intval($target_item['item_unpublished']) || intval($target_item['item_delayed']) || + intval($target_item['item_blocked']) || + ( intval($target_item['item_hidden']) && ($target_item['obj_type'] !== ACTIVITY_OBJ_FILE))) { + logger('notifier: target item not published, so not forwardable', LOGGER_DEBUG); + return; + } + + if (in_array($target_item['verb'], [ ACTIVITY_FOLLOW, ACTIVITY_IGNORE ])) { + logger('not fowarding follow|unfollow->note activity'); + return; + } + + $s = q( + "select * from channel left join xchan on channel_hash = xchan_hash where channel_id = %d limit 1", + intval($target_item['uid']) + ); + if ($s) { + self::$channel = array_shift($s); + } + + if (self::$channel['channel_hash'] !== $target_item['author_xchan'] && self::$channel['channel_hash'] !== $target_item['owner_xchan']) { + logger("notifier: Sending channel is not owner {$target_item['owner_xchan']} or author {$target_item['author_xchan']}", LOGGER_NORMAL, LOG_WARNING); + return; + } + + $thread_is_public = false; + + if ($target_item['mid'] === $target_item['parent_mid']) { + $parent_item = $target_item; + $top_level_post = true; + } else { + // fetch the parent item + $r = q( + "SELECT * from item where id = %d order by id asc", + intval($target_item['parent']) + ); + + if (! $r) { + return; + } + + xchan_query($r); + $r = fetch_post_tags($r); + + $parent_item = array_shift($r); + $top_level_post = false; + $thread_is_public = ((intval($parent_item['item_private'])) ? false : true) ; + } + + // avoid looping of discover items 12/4/2014 + + if ($sys && $parent_item['uid'] == $sys['channel_id']) { + return; + } + + $m = get_iconfig($target_item, 'activitypub', 'signed_data'); + // Re-use existing signature unless the activity type changed to a Tombstone, which won't verify. + if ($m && (! intval($target_item['item_deleted']))) { + self::$encoded_item = json_decode($m, true); + } else { + self::$encoded_item = array_merge(['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], Activity::encode_activity($target_item, true)); + self::$encoded_item['signature'] = LDSignatures::sign(self::$encoded_item, self::$channel); + } + + + logger('target_item: ' . print_r($target_item, true), LOGGER_DEBUG); + logger('encoded: ' . print_r(self::$encoded_item, true), LOGGER_DEBUG); + + // Send comments to the owner to re-deliver to everybody in the conversation + // We only do this if the item in question originated on this site. This prevents looping. + // To clarify, a site accepting a new comment is responsible for sending it to the owner for relay. + // Relaying should never be initiated on a post that arrived from elsewhere. + + // We should normally be able to rely on ITEM_ORIGIN, but start_delivery_chain() incorrectly set this + // flag on comments for an extended period. So we'll also call comment_local_origin() which looks at + // the hostname in the message_id and provides a second (fallback) opinion. + + $relay_to_owner = (((! $top_level_post) && (intval($target_item['item_origin'])) && comment_local_origin($target_item) && $cmd !== 'hyper') ? true : false); + + $uplink = false; + + // $cmd === 'relay' indicates the owner is sending it to the original recipients + // don't allow the item in the relay command to relay to owner under any circumstances, it will loop + + logger('notifier: relay_to_owner: ' . (($relay_to_owner) ? 'true' : 'false'), LOGGER_DATA, LOG_DEBUG); + logger('notifier: top_level_post: ' . (($top_level_post) ? 'true' : 'false'), LOGGER_DATA, LOG_DEBUG); + + // tag_deliver'd post which needs to be sent back to the original author + + if (($cmd === 'uplink') && intval($parent_item['item_uplink']) && (! $top_level_post)) { + logger('notifier: uplink'); + $uplink = true; + self::$packet_type = 'response'; + } + + if (($relay_to_owner || $uplink) && ($cmd !== 'relay')) { + logger('followup relay (upstream delivery)', LOGGER_DEBUG); + $sendto = ($uplink) ? $parent_item['source_xchan'] : $parent_item['owner_xchan']; + self::$recipients = [ $sendto ]; + // over-ride upstream recipients if 'replyTo' was set in the parent. + if ($parent_item['replyto'] && (! $uplink)) { + logger('replyto: over-riding owner ' . $sendto, LOGGER_DEBUG); + // unserialise is a no-op if presented with data that wasn't serialised. + $ptr = unserialise($parent_item['replyto']); + if (is_string($ptr)) { + if (ActivityStreams::is_url($sendto)) { + $sendto = $ptr; + self::$recipients = [ $sendto ]; + } + } elseif (is_array($ptr)) { + $sendto = []; + foreach ($ptr as $rto) { + if (is_string($rto)) { + $sendto[] = $rto; + } elseif (is_array($rto) && isset($rto['id'])) { + $sendto[] = $rto['id']; + } + } + self::$recipients = $sendto; + } + } + + logger('replyto: upstream recipients ' . print_r($sendto, true), LOGGER_DEBUG); + + + self::$private = true; + $upstream = true; + self::$packet_type = 'response'; + $is_moderated = their_perms_contains($parent_item['uid'], $sendto, 'moderated'); + if ($relay_to_owner && $thread_is_public && (! $is_moderated) && (! Channel::is_group($parent_item['uid']))) { + if (get_pconfig($target_item['uid'], 'system', 'hyperdrive', true)) { + Run::Summon([ 'Notifier' , 'hyper', $item_id ]); + } + } + } else { + if ($cmd === 'relay') { + logger('owner relay (downstream delivery)'); + } else { + logger('normal (downstream) distribution', LOGGER_DEBUG); + } + $upstream = false; + + if ($parent_item && $parent_item['item_private'] !== $target_item['item_private']) { + logger('parent_item: ' . $parent_item['id'] . ' item_private: ' . $parent_item['item_private']); + logger('target_item: ' . $target_item['id'] . ' item_private: ' . $target_item['item_private']); + + logger('conversation privacy mismatch - downstream delivery prevented'); + return; + } + + // if our parent is a tag_delivery recipient, uplink to the original author causing + // a delivery fork. + + if (($parent_item) && intval($parent_item['item_uplink']) && (! $top_level_post) && ($cmd !== 'uplink')) { + // don't uplink a relayed post to the relay owner + if ($parent_item['source_xchan'] !== $parent_item['owner_xchan']) { + logger('notifier: uplinking this item'); + Run::Summon([ 'Notifier','uplink',$item_id ]); + } + } + + if ($thread_is_public && $cmd === 'hyper') { + self::$recipients = []; + $r = q( + "select abook_xchan, xchan_network from abook left join xchan on abook_xchan = xchan_hash where abook_channel = %d and abook_self = 0 and abook_pending = 0 and abook_archived = 0 and not abook_xchan in ( '%s', '%s', '%s' ) ", + intval($target_item['uid']), + dbesc($target_item['author_xchan']), + dbesc($target_item['owner_xchan']), + dbesc($target_item['source_xchan']) + ); + if ($r) { + foreach ($r as $rv) { + self::$recipients[] = $rv['abook_xchan']; + } + } + self::$private = false; + } else { + self::$private = false; + self::$recipients = collect_recipients($parent_item, self::$private); + } + + // @FIXME add any additional recipients such as mentions, etc. + + if ($top_level_post) { + // remove clones who will receive the post via sync + self::$recipients = array_values(array_diff(self::$recipients, [ $target_item['owner_xchan'] ])); + } + + // don't send deletions onward for other people's stuff + + if (intval($target_item['item_deleted']) && (! intval($target_item['item_wall']))) { + logger('notifier: ignoring delete notification for non-wall item', LOGGER_NORMAL, LOG_NOTICE); + return; + } + } + } + + // Generic delivery section, we have an encoded item and recipients + // Now start the delivery process + + logger('encoded item: ' . print_r(self::$encoded_item, true), LOGGER_DATA, LOG_DEBUG); + + // This addresses an issue that crossposting addons weren't being called if the sender had no friends + // and only wanted to crosspost. + + $crossposting = (isset($target_item['postopts']) && $target_item['postopts']) ? true : false; + + stringify_array_elms(self::$recipients); + if ( (! self::$recipients && ! $crossposting)) { + logger('no recipients'); + return; + } + + // logger('recipients: ' . print_r(self::$recipients,true), LOGGER_NORMAL, LOG_DEBUG); + + if (! count(self::$env_recips)) { + self::$env_recips = ((self::$private) ? [] : null); + } + + $recip_list = []; + + if (self::$recipients) { + $details = q("select xchan_hash, xchan_network, xchan_addr, xchan_guid, xchan_guid_sig from xchan + where xchan_hash in (" . protect_sprintf(implode(',', self::$recipients)) . ")"); + } + else { + $details = []; + } + + if ($details) { + foreach ($details as $d) { + $recip_list[] = $d['xchan_addr'] . ' (' . $d['xchan_hash'] . ')'; + if (self::$private) { + self::$env_recips[] = $d['xchan_hash']; + } + } + } + + + $narr = [ + 'channel' => self::$channel, + 'upstream' => $upstream, + 'env_recips' => self::$env_recips, + 'recipients' => self::$recipients, + 'item' => $item, + 'target_item' => $target_item, + 'parent_item' => $parent_item, + 'top_level_post' => $top_level_post, + 'private' => self::$private, + 'relay_to_owner' => $relay_to_owner, + 'uplink' => $uplink, + 'cmd' => $cmd, + 'single' => (($cmd === 'single_activity') ? true : false), + 'request' => $request, + 'normal_mode' => $normal_mode, + 'packet_type' => self::$packet_type, + 'queued' => [] + ]; + + Hook::call('notifier_process', $narr); + if ($narr['queued']) { + foreach ($narr['queued'] as $pq) { + self::$deliveries[] = $pq; + } + } + + // notifier_process can alter the recipient list + + self::$recipients = $narr['recipients']; + self::$env_recips = $narr['env_recips']; + + if ((self::$private) && (! self::$env_recips)) { + // shouldn't happen + logger('private message with no envelope recipients.' . print_r($argv, true), LOGGER_NORMAL, LOG_NOTICE); + return; + } + + logger('notifier: recipients (may be delivered to more if public): ' . print_r($recip_list, true), LOGGER_DEBUG); + + // Now we have collected recipients (except for external mentions, @FIXME) + // Let's reduce this to a set of hubs; checking that the site is not dead. + + if (self::$recipients) { + $hubs = q("select hubloc.*, site.site_crypto, site.site_flags from hubloc left join site on site_url = hubloc_url + where hubloc_hash in (" . protect_sprintf(implode(',', self::$recipients)) . ") + and hubloc_error = 0 and hubloc_deleted = 0 "); + } + else { + $hubs = []; + } + // public posts won't make it to the local public stream unless there's a recipient on this site. + // This code block sees if it's a public post and localhost is missing, and if so adds an entry for the local sys channel to the $hubs list + + if (! self::$private) { + $found_localhost = false; + if ($hubs) { + foreach ($hubs as $h) { + if ($h['hubloc_url'] === z_root()) { + $found_localhost = true; + break; + } + } + } + if (! $found_localhost) { + $localhub = q( + "select hubloc.*, site.site_crypto, site.site_flags, site.site_dead from hubloc + left join site on site_url = hubloc_url where hubloc_id_url = '%s' and hubloc_error = 0 and hubloc_deleted = 0 ", + dbesc(z_root() . '/channel/sys') + ); + if ($localhub) { + $hubs = array_merge($hubs, $localhub); + } + } + } + + if (! $hubs) { + logger('notifier: no hubs', LOGGER_NORMAL, LOG_NOTICE); + return; + } + + /** + * Reduce the hubs to those that are unique. For zot hubs, we need to verify uniqueness by the sitekey, + * since it may have been a re-install which has not yet been detected and pruned. + * For other networks which don't have or require sitekeys, we'll have to use the URL + */ + + + $hublist = []; // this provides an easily printable list for the logs + $dhubs = []; // delivery hubs where we store our resulting unique array + $keys = []; // array of keys to check uniquness for zot hubs + $urls = []; // array of urls to check uniqueness of hubs from other networks + $hub_env = []; // per-hub envelope so we don't broadcast the entire envelope to all + $dead = []; // known dead hubs - report them as undeliverable + + foreach ($hubs as $hub) { + if (isset($hub['site_dead']) && intval($hub['site_dead'])) { + $dead[] = $hub; + continue; + } + + if (self::$env_recips) { + foreach (self::$env_recips as $er) { + if ($hub['hubloc_hash'] === $er) { + if (! array_key_exists($hub['hubloc_site_id'], $hub_env)) { + $hub_env[$hub['hubloc_site_id']] = []; + } + $hub_env[$hub['hubloc_site_id']][] = $er; + } + } + } + + if (in_array($hub['hubloc_network'],['nomad','zot6'])) { + if (! in_array($hub['hubloc_sitekey'], $keys)) { + $hublist[] = $hub['hubloc_host'] . ' ' . $hub['hubloc_network']; + $dhubs[] = $hub; + $keys[] = $hub['hubloc_sitekey']; + } + } else { + if (! in_array($hub['hubloc_url'], $urls)) { + $hublist[] = $hub['hubloc_host'] . ' ' . $hub['hubloc_network']; + $dhubs[] = $hub; + $urls[] = $hub['hubloc_url']; + } + } + } + + logger('notifier: will notify/deliver to these hubs: ' . print_r($hublist, true), LOGGER_DEBUG, LOG_DEBUG); + + foreach ($dhubs as $hub) { + logger('notifier_hub: ' . $hub['hubloc_url'], LOGGER_DEBUG, LOG_DEBUG); + + // deliver to any non-zot networks + + if (! in_array($hub['hubloc_network'], ['zot6', 'nomad' ])) { + $narr = [ + 'channel' => self::$channel, + 'upstream' => $upstream, + 'env_recips' => self::$env_recips, + 'recipients' => self::$recipients, + 'item' => $item, + 'target_item' => $target_item, + 'parent_item' => $parent_item, + 'hub' => $hub, + 'top_level_post' => $top_level_post, + 'private' => self::$private, + 'relay_to_owner' => $relay_to_owner, + 'uplink' => $uplink, + 'cmd' => $cmd, + 'single' => (($cmd === 'single_activity') ? true : false), + 'request' => $request, + 'normal_mode' => $normal_mode, + 'packet_type' => self::$packet_type, + 'queued' => [] + ]; + + ActivityPub::notifier_process($narr); + + Hook::call('notifier_hub', $narr); + if ($narr['queued']) { + foreach ($narr['queued'] as $pq) { + self::$deliveries[] = $pq; + } + } + continue; + } + + + // Single deliveries are for non-nomadic federated networks and we're essentially + // delivering only to those that have this site url in their abook_instance + // and only from within a sync operation. This means if you post from a clone, + // and a connection is connected to one of your other clones; assuming that hub + // is running it will receive a sync packet. On receipt of this sync packet it + // will invoke a delivery to those connections which are connected to just that + // hub instance. + + if ($cmd === 'single_activity') { + continue; + } + + // default: zot or nomad protocol + + // Prevent zot6/Nomad delivery of group comment boosts, which are not required for conversational platforms. + // ActivityPub conversational platforms may wish to filter these if they don't want or require them. + // We will assume here that if $target_item exists and has a verb that it is an actual item structure + // so we won't need to check the existence of the other item fields prior to evaluation. + + // This shouldn't produce false positives on comment boosts that were generated on other platforms + // because we won't be delivering them. + + if (isset($target_item) && isset($target_item['verb']) && $target_item['verb'] === 'Announce' && $target_item['author_xchan'] === $target_item['owner_xchan'] && ! intval($target_item['item_thread_top'])) { + continue; + } + + $hash = new_uuid(); + + $env = (($hub_env && $hub_env[$hub['hubloc_site_id']]) ? $hub_env[$hub['hubloc_site_id']] : ''); + if ((self::$private) && (! $env)) { + continue; + } + + $packet = Libzot::build_packet( + self::$channel, + self::$packet_type, + $env, + self::$encoded_item, + self::$encoding, + ((self::$private) ? $hub['hubloc_sitekey'] : null), + $hub['site_crypto'] + ); + + Queue::insert( + [ + 'hash' => $hash, + 'account_id' => self::$channel['channel_account_id'], + 'channel_id' => self::$channel['channel_id'], + 'posturl' => $hub['hubloc_callback'], + 'driver' => $hub['hubloc_network'], + 'notify' => $packet, + 'msg' => EMPTY_STR + ] + ); + + // only create delivery reports for normal undeleted items + if (is_array($target_item) && (! $target_item['item_deleted']) && (! get_config('system', 'disable_dreport'))) { + q( + "insert into dreport ( dreport_mid, dreport_site, dreport_recip, dreport_name, dreport_result, dreport_time, dreport_xchan, dreport_queue, dreport_log ) + values ( '%s', '%s','%s','%s','%s','%s','%s','%s', '%s' ) ", + dbesc($target_item['mid']), + dbesc($hub['hubloc_host']), + dbesc($hub['hubloc_host']), + dbesc($hub['hubloc_host']), + dbesc('queued'), + dbesc(datetime_convert()), + dbesc(self::$channel['channel_hash']), + dbesc($hash), + dbesc(EMPTY_STR) + ); + } + + self::$deliveries[] = $hash; + } + + if ($normal_mode) { + // This wastes a process if there are no delivery hooks configured, so check this before launching the new process + $x = q("select * from hook where hook = 'notifier_normal'"); + if ($x) { + Run::Summon([ 'Deliver_hooks', $target_item['id'] ]); + } + } + + if (self::$deliveries) { + do_delivery(self::$deliveries); + } + + if ($dead) { + foreach ($dead as $deceased) { + if (is_array($target_item) && (! $target_item['item_deleted']) && (! get_config('system', 'disable_dreport'))) { + q( + "insert into dreport ( dreport_mid, dreport_site, dreport_recip, dreport_name, dreport_result, dreport_time, dreport_xchan, dreport_queue, dreport_log ) + values ( '%s', '%s','%s','%s','%s','%s','%s','%s','%s' ) ", + dbesc($target_item['mid']), + dbesc($deceased['hubloc_host']), + dbesc($deceased['hubloc_host']), + dbesc($deceased['hubloc_host']), + dbesc('undeliverable/unresponsive site'), + dbesc(datetime_convert()), + dbesc(self::$channel['channel_hash']), + dbesc(new_uuid()), + dbesc(EMPTY_STR) + ); + } + } + } + + Hook::call('notifier_end', $target_item); + + logger('notifer: complete.'); + return; + } +} diff --git a/Code/Daemon/Onedirsync.php b/Code/Daemon/Onedirsync.php new file mode 100644 index 000000000..3704cf7e1 --- /dev/null +++ b/Code/Daemon/Onedirsync.php @@ -0,0 +1,87 @@ + 1) && (intval($argv[1]))) { + $update_id = intval($argv[1]); + } + + if (! $update_id) { + logger('onedirsync: no update'); + return; + } + + $r = q( + "select * from updates where ud_id = %d limit 1", + intval($update_id) + ); + + if (! $r) { + return; + } + if (($r[0]['ud_flags'] & UPDATE_FLAGS_UPDATED) || (! $r[0]['ud_addr'])) { + return; + } + + // Have we probed this channel more recently than the other directory server + // (where we received this update from) ? + // If we have, we don't need to do anything except mark any older entries updated + + $x = q( + "select * from updates where ud_addr = '%s' and ud_date > '%s' and ( ud_flags & %d )>0 order by ud_date desc limit 1", + dbesc($r[0]['ud_addr']), + dbesc($r[0]['ud_date']), + intval(UPDATE_FLAGS_UPDATED) + ); + if ($x) { + $y = q( + "update updates set ud_flags = ( ud_flags | %d ) where ud_addr = '%s' and ( ud_flags & %d ) = 0 and ud_date != '%s'", + intval(UPDATE_FLAGS_UPDATED), + dbesc($r[0]['ud_addr']), + intval(UPDATE_FLAGS_UPDATED), + dbesc($x[0]['ud_date']) + ); + return; + } + + // ignore doing an update if this ud_addr refers to a known dead hubloc + + $h = q( + "select * from hubloc where hubloc_addr = '%s' limit 1", + dbesc($r[0]['ud_addr']) + ); + if (($h) && ($h[0]['hubloc_status'] & HUBLOC_OFFLINE)) { + $y = q( + "update updates set ud_flags = ( ud_flags | %d ) where ud_addr = '%s' and ( ud_flags & %d ) = 0 ", + intval(UPDATE_FLAGS_UPDATED), + dbesc($r[0]['ud_addr']), + intval(UPDATE_FLAGS_UPDATED) + ); + + return; + } + + // we might have to pull this out some day, but for now update_directory_entry() + // runs zot_finger() and is kind of zot specific + + if ($h && in_array($h[0]['hubloc_network'],['nomad','zot6'])) { + return; + } + + Libzotdir::update_directory_entry($r[0]); + + return; + } +} diff --git a/Code/Daemon/Onepoll.php b/Code/Daemon/Onepoll.php new file mode 100644 index 000000000..c7c2f8027 --- /dev/null +++ b/Code/Daemon/Onepoll.php @@ -0,0 +1,178 @@ + 1) && (intval($argv[1]))) { + $contact_id = intval($argv[1]); + } + + if (! $contact_id) { + logger('onepoll: no contact'); + return; + } + + $d = datetime_convert(); + + $contacts = q( + "SELECT abook.*, xchan.*, account.* + FROM abook LEFT JOIN account on abook_account = account_id left join xchan on xchan_hash = abook_xchan + where abook_id = %d + and abook_pending = 0 and abook_archived = 0 and abook_blocked = 0 and abook_ignored = 0 + AND (( account_flags = %d ) OR ( account_flags = %d )) limit 1", + intval($contact_id), + intval(ACCOUNT_OK), + intval(ACCOUNT_UNVERIFIED) + ); + + if (! $contacts) { + logger('onepoll: abook_id not found: ' . $contact_id); + return; + } + + $contact = array_shift($contacts); + + $t = $contact['abook_updated']; + + $importer_uid = $contact['abook_channel']; + + $r = q( + "SELECT * from channel left join xchan on channel_hash = xchan_hash where channel_id = %d limit 1", + intval($importer_uid) + ); + + if (! $r) { + return; + } + + $importer = $r[0]; + + logger("onepoll: poll: ({$contact['id']}) IMPORTER: {$importer['xchan_name']}, CONTACT: {$contact['xchan_name']}"); + + $last_update = ((($contact['abook_updated'] === $contact['abook_created']) || ($contact['abook_updated'] <= NULL_DATE)) + ? datetime_convert('UTC', 'UTC', 'now - 7 days') + : datetime_convert('UTC', 'UTC', $contact['abook_updated'] . ' - 2 days') + ); + + if (in_array($contact['xchan_network'],['nomad']['zot6'])) { + // update permissions + + $x = Libzot::refresh($contact, $importer); + + $responded = false; + $updated = datetime_convert(); + $connected = datetime_convert(); + if (! $x) { + // mark for death by not updating abook_connected, this is caught in include/poller.php + q( + "update abook set abook_updated = '%s' where abook_id = %d", + dbesc($updated), + intval($contact['abook_id']) + ); + } else { + q( + "update abook set abook_updated = '%s', abook_connected = '%s' where abook_id = %d", + dbesc($updated), + dbesc($connected), + intval($contact['abook_id']) + ); + $responded = true; + } + + if (! $responded) { + return; + } + } + + $fetch_feed = true; + + // They haven't given us permission to see their stream + + $can_view_stream = intval(get_abconfig($importer_uid, $contact['abook_xchan'], 'their_perms', 'view_stream')); + + if (! $can_view_stream) { + $fetch_feed = false; + } + + // we haven't given them permission to send us their stream + + $can_send_stream = intval(get_abconfig($importer_uid, $contact['abook_xchan'], 'my_perms', 'send_stream')); + + if (! $can_send_stream) { + $fetch_feed = false; + } + + if ($contact['abook_created'] < datetime_convert('UTC', 'UTC', 'now - 1 week')) { + $fetch_feed = false; + } + + // In previous releases there was a mechanism to fetch 'external' or public stream posts from a site + // (as opposed to a channel). This mechanism was deprecated as there is no reliable/scalable method + // for informing downstream publishers when/if the content has expired or been deleted. + // We can use the ThreadListener interface to implement this on the owner's outbox, however this is still a + // work in progress and may present scaling issues. Making this work correctly with third-party fetches is + // prohibitive as deletion requests would need to be relayed over potentially hostile networks. + + if ($fetch_feed) { + $max = intval(get_config('system', 'max_imported_posts', 20)); + if (intval($max)) { + $cl = get_xconfig($xchan, 'activitypub', 'collections'); + if (is_array($cl) && $cl) { + $url = ((array_key_exists('outbox', $cl)) ? $cl['outbox'] : ''); + if ($url) { + logger('fetching outbox'); + $url = $url . '?date_begin=' . urlencode($last_update); + $obj = new ASCollection($url, $importer, 0, $max); + $messages = $obj->get(); + if ($messages) { + foreach ($messages as $message) { + if (is_string($message)) { + $message = Activity::fetch($message, $importer); + } + if (is_array($message)) { + $AS = new ActivityStreams($message, null, true); + if ($AS->is_valid() && is_array($AS->obj)) { + $item = Activity::decode_note($AS, true); + if ($item) { + Activity::store($importer, $contact['abook_xchan'], $AS, $item, true, true); + } + } + } + } + } + } + } + } + } + + // update the poco details for this connection + + $r = q( + "SELECT xlink_id from xlink + where xlink_xchan = '%s' and xlink_updated > %s - INTERVAL %s and xlink_static = 0 limit 1", + intval($contact['xchan_hash']), + db_utcnow(), + db_quoteinterval('7 DAY') + ); + if (! $r) { + Socgraph::poco_load($contact['xchan_hash'], $contact['xchan_connurl']); + } + return; + } +} diff --git a/Code/Daemon/Poller.php b/Code/Daemon/Poller.php new file mode 100644 index 000000000..eb5729b3d --- /dev/null +++ b/Code/Daemon/Poller.php @@ -0,0 +1,206 @@ + $maxsysload) { + logger('system: load ' . $load . ' too high. Poller deferred to next scheduled run.'); + return; + } + } + + $interval = intval(get_config('system', 'poll_interval')); + if (! $interval) { + $interval = ((get_config('system', 'delivery_interval') === false) ? 3 : intval(get_config('system', 'delivery_interval'))); + } + + // Check for a lockfile. If it exists, but is over an hour old, it's stale. Ignore it. + $lockfile = 'cache/poller'; + if ( + (file_exists($lockfile)) && (filemtime($lockfile) > (time() - 3600)) + && (! get_config('system', 'override_poll_lockfile')) + ) { + logger("poller: Already running"); + return; + } + + // Create a lockfile. + file_put_contents($lockfile, EMPTY_STR); + + logger('poller: start'); + + $manual_id = 0; + $generation = 0; + + $force = false; + $restart = false; + + if (($argc > 1) && ($argv[1] == 'force')) { + $force = true; + } + + if (($argc > 1) && ($argv[1] == 'restart')) { + $restart = true; + $generation = intval($argv[2]); + if (! $generation) { + return; + } + } + + if (($argc > 1) && intval($argv[1])) { + $manual_id = intval($argv[1]); + $force = true; + } + + + $sql_extra = (($manual_id) ? " AND abook_id = " . intval($manual_id) . " " : ""); + + Addon::reload_all(); + + $d = datetime_convert(); + + // Only poll from those with suitable relationships + +// $abandon_sql = (($abandon_days) +// ? sprintf(" AND account_lastlog > %s - INTERVAL %s ", db_utcnow(), db_quoteinterval(intval($abandon_days).' DAY')) +// : '' +// ); + + $abandon_sql = EMPTY_STR; + + $randfunc = db_getfunc('RAND'); + + $contacts = q( + "SELECT abook.abook_updated, abook.abook_connected, abook.abook_feed, + abook.abook_channel, abook.abook_id, abook.abook_archived, abook.abook_pending, + abook.abook_ignored, abook.abook_blocked, + xchan.xchan_network, + account.account_lastlog, account.account_flags + FROM abook LEFT JOIN xchan on abook_xchan = xchan_hash + LEFT JOIN account on abook_account = account_id + where abook_self = 0 + $sql_extra + AND (( account_flags = %d ) OR ( account_flags = %d )) $abandon_sql ORDER BY $randfunc", + intval(ACCOUNT_OK), + intval(ACCOUNT_UNVERIFIED) // FIXME + ); + + if ($contacts) { + foreach ($contacts as $contact) { + $update = false; + + $t = $contact['abook_updated']; + $c = $contact['abook_connected']; + + if (intval($contact['abook_feed'])) { + $min = ServiceClass::fetch($contact['abook_channel'], 'minimum_feedcheck_minutes'); + if (! $min) { + $min = intval(get_config('system', 'minimum_feedcheck_minutes')); + } + if (! $min) { + $min = 60; + } + $x = datetime_convert('UTC', 'UTC', "now - $min minutes"); + if ($c < $x) { + Run::Summon([ 'Onepoll', $contact['abook_id'] ]); + if ($interval) { + @time_sleep_until(microtime(true) + (float) $interval); + } + } + continue; + } + + if (! in_array($contact['xchan_network'],['nomad','zot6'])) { + continue; + } + + if ($c == $t) { + if (datetime_convert('UTC', 'UTC', 'now') > datetime_convert('UTC', 'UTC', $t . " + 1 day")) { + $update = true; + } + } else { + // if we've never connected with them, start the mark for death countdown from now + + if ($c <= NULL_DATE) { + $r = q( + "update abook set abook_connected = '%s' where abook_id = %d", + dbesc(datetime_convert()), + intval($contact['abook_id']) + ); + $c = datetime_convert(); + $update = true; + } + + // He's dead, Jim + + if (strcmp(datetime_convert('UTC', 'UTC', 'now'), datetime_convert('UTC', 'UTC', $c . " + 30 day")) > 0) { + $r = q( + "update abook set abook_archived = 1 where abook_id = %d", + intval($contact['abook_id']) + ); + $update = false; + continue; + } + + if (intval($contact['abook_archived'])) { + $update = false; + continue; + } + + // might be dead, so maybe don't poll quite so often + + // recently deceased, so keep up the regular schedule for 3 days + + if ( + (strcmp(datetime_convert('UTC', 'UTC', 'now'), datetime_convert('UTC', 'UTC', $c . " + 3 day")) > 0) + && (strcmp(datetime_convert('UTC', 'UTC', 'now'), datetime_convert('UTC', 'UTC', $t . " + 1 day")) > 0) + ) { + $update = true; + } + + // After that back off and put them on a morphine drip + + if (strcmp(datetime_convert('UTC', 'UTC', 'now'), datetime_convert('UTC', 'UTC', $t . " + 2 day")) > 0) { + $update = true; + } + } + + if (intval($contact['abook_pending']) || intval($contact['abook_archived']) || intval($contact['abook_ignored']) || intval($contact['abook_blocked'])) { + continue; + } + + if ((! $update) && (! $force)) { + continue; + } + + Run::Summon([ 'Onepoll',$contact['abook_id'] ]); + if ($interval) { + @time_sleep_until(microtime(true) + (float) $interval); + } + } + } + + set_config('system', 'lastpoll', datetime_convert()); + + //All done - clear the lockfile + @unlink($lockfile); + + return; + } +} diff --git a/Code/Daemon/Queue.php b/Code/Daemon/Queue.php new file mode 100644 index 000000000..14d70f262 --- /dev/null +++ b/Code/Daemon/Queue.php @@ -0,0 +1,94 @@ + 1) { + $queue_id = $argv[1]; + } else { + $queue_id = EMPTY_STR; + } + + logger('queue: start'); + + // delete all queue items more than 3 days old + // but first mark these sites dead if we haven't heard from them in a month + + $r = q( + "select outq_posturl from outq where outq_created < %s - INTERVAL %s", + db_utcnow(), + db_quoteinterval('3 DAY') + ); + if ($r) { + foreach ($r as $rr) { + $site_url = ''; + $h = parse_url($rr['outq_posturl']); + $desturl = $h['scheme'] . '://' . $h['host'] . (($h['port']) ? ':' . $h['port'] : ''); + q( + "update site set site_dead = 1 where site_dead = 0 and site_url = '%s' and site_update < %s - INTERVAL %s", + dbesc($desturl), + db_utcnow(), + db_quoteinterval('1 MONTH') + ); + } + } + + $r = q( + "DELETE FROM outq WHERE outq_created < %s - INTERVAL %s", + db_utcnow(), + db_quoteinterval('3 DAY') + ); + + if ($queue_id) { + $r = q( + "SELECT * FROM outq WHERE outq_hash = '%s' LIMIT 1", + dbesc($queue_id) + ); + } else { + // For the first 12 hours we'll try to deliver every 15 minutes + // After that, we'll only attempt delivery once per hour. + // This currently only handles the default queue drivers ('zot' or '') which we will group by posturl + // so that we don't start off a thousand deliveries for a couple of dead hubs. + // The zot driver will deliver everything destined for a single hub once contact is made (*if* contact is made). + // Other drivers will have to do something different here and may need their own query. + + // Note: this requires some tweaking as new posts to long dead hubs once a day will keep them in the + // "every 15 minutes" category. We probably need to prioritise them when inserted into the queue + // or just prior to this query based on recent and long-term delivery history. If we have good reason to believe + // the site is permanently down, there's no reason to attempt delivery at all, or at most not more than once + // or twice a day. + + $sqlrandfunc = db_getfunc('rand'); + + $r = q( + "SELECT *,$sqlrandfunc as rn FROM outq WHERE outq_delivered = 0 and outq_scheduled < %s order by rn limit 1", + db_utcnow() + ); + while ($r) { + foreach ($r as $rv) { + Zlib\Queue::deliver($rv); + } + $r = q( + "SELECT *,$sqlrandfunc as rn FROM outq WHERE outq_delivered = 0 and outq_scheduled < %s order by rn limit 1", + db_utcnow() + ); + } + } + if (! $r) { + return; + } + + foreach ($r as $rv) { + Zlib\Queue::deliver($rv); + } + } +} diff --git a/Code/Daemon/README.md b/Code/Daemon/README.md new file mode 100644 index 000000000..e8c1d7041 --- /dev/null +++ b/Code/Daemon/README.md @@ -0,0 +1,43 @@ +Daemon (background) Processes +============================= + + +This directory provides background tasks which are executed by a +command-line process and detached from normal web processing. + +Background tasks are invoked by calling + + + Code\Daemon\Run::Summon([ $cmd, $arg1, $argn... ]); + +The Run class loads the desired command file and passes the arguments. + + +To create a background task 'Foo' use the following template. + + $arr, + 'long_running' => self::$long_running + ]; + + Hook::call('daemon_summon', $hookinfo); + + $arr = $hookinfo['argv']; + $argc = count($arr); + + if ((! is_array($arr) || ($argc < 1))) { + logger("Summon handled by hook.", LOGGER_DEBUG); + return; + } + + proc_run('php', 'Code/Daemon/Run.php', $arr); + } + + public static function Release($argc, $argv) + { + cli_startup(); + + $hookinfo = [ + 'argv' => $argv, + 'long_running' => self::$long_running + ]; + + Hook::call('daemon_release', $hookinfo); + + $argv = $hookinfo['argv']; + $argc = count($argv); + + if ((! is_array($argv) || ($argc < 1))) { + logger("Release handled by hook.", LOGGER_DEBUG); + return; + } + + logger('Run: release: ' . print_r($argv, true), LOGGER_ALL, LOG_DEBUG); + $cls = '\\Code\\Daemon\\' . $argv[0]; + $cls::run($argc, $argv); + } +} diff --git a/Code/Daemon/Thumbnail.php b/Code/Daemon/Thumbnail.php new file mode 100644 index 000000000..3caadb6ce --- /dev/null +++ b/Code/Daemon/Thumbnail.php @@ -0,0 +1,87 @@ + $attach, + 'preview_style' => $preview_style, + 'preview_width' => $preview_width, + 'preview_height' => $preview_height, + 'thumbnail' => null + ]; + + /** + * @hooks thumbnail + * * \e array \b attach + * * \e int \b preview_style + * * \e int \b preview_width + * * \e int \b preview_height + * * \e string \b thumbnail + */ + + Hook::call('thumbnail', $p); + if ($p['thumbnail']) { + return; + } + + $default_controller = null; + + $files = glob('Code/Thumbs/*.php'); + if ($files) { + foreach ($files as $f) { + $clsname = '\\Code\\Thumbs\\' . ucfirst(basename($f, '.php')); + if (class_exists($clsname)) { + $x = new $clsname(); + if (method_exists($x, 'Match')) { + $matched = $x->Match($attach['filetype']); + if ($matched) { + $x->Thumb($attach, $preview_style, $preview_width, $preview_height); + } + } + if (method_exists($x, 'MatchDefault')) { + $default_matched = $x->MatchDefault(substr($attach['filetype'], 0, strpos($attach['filetype'], '/'))); + if ($default_matched) { + $default_controller = $x; + } + } + } + } + } + if ( + ($default_controller) + && ((! file_exists(dbunescbin($attach['content']) . '.thumb')) + || (filectime(dbunescbin($attach['content']) . 'thumb') < (time() - 60))) + ) { + $default_controller->Thumb($attach, $preview_style, $preview_width, $preview_height); + } + } +} diff --git a/Code/Daemon/Xchan_photo.php b/Code/Daemon/Xchan_photo.php new file mode 100644 index 000000000..ea24008b5 --- /dev/null +++ b/Code/Daemon/Xchan_photo.php @@ -0,0 +1,39 @@ + $v) { + self::register($k, $file, $v); + } + } + } + + + public static function unregister($hook, $file, $function, $version = 1, $priority = 0) + { + if (is_array($function)) { + $function = serialize($function); + } + $r = q( + "DELETE FROM hook WHERE hook = '%s' AND file = '%s' AND fn = '%s' and priority = %d and hook_version = %d", + dbesc($hook), + dbesc($file), + dbesc($function), + intval($priority), + intval($version) + ); + + return $r; + } + + /** + * @brief Unregister all hooks with this file component. + * + * Useful for addon upgrades where you want to clean out old interfaces. + * + * @param string $file + */ + + public static function unregister_by_file($file) + { + $r = q( + "DELETE FROM hook WHERE file = '%s' ", + dbesc($file) + ); + + return $r; + } + + /** + * @brief Inserts a hook into a page request. + * + * Insert a short-lived hook into the running page request. + * Hooks are normally persistent so that they can be called + * across asynchronous processes such as delivery and poll + * processes. + * + * insert_hook lets you attach a hook callback immediately + * which will not persist beyond the life of this page request + * or the current process. + * + * @param string $hook + * name of hook to attach callback + * @param string $fn + * function name of callback handler + * @param int $version + * hook interface version, 0 uses two callback params, 1 uses one callback param + * @param int $priority + * currently not implemented in this function, would require the hook array to be resorted + */ + public static function insert($hook, $fn, $version = 0, $priority = 0) + { + if (is_array($fn)) { + $fn = serialize($fn); + } + + if (! is_array(App::$hooks)) { + App::$hooks = []; + } + + if (! array_key_exists($hook, App::$hooks)) { + App::$hooks[$hook] = []; + } + + App::$hooks[$hook][] = [ '', $fn, $priority, $version ]; + } + + + /** + * @brief loads all active hooks into memory + * alters: App::$hooks + * Called during initialisation + * Duplicated hooks are removed and the duplicates ignored + * + * It might not be obvious but themes can manually add hooks to the App::$hooks + * array in their theme_init() and use this to customise the app behaviour. + * use insert_hook($hookname,$function_name) to do this. + */ + + + public static function load() + { + + App::$hooks = []; + + $r = q("SELECT * FROM hook WHERE true ORDER BY priority DESC"); + if ($r) { + foreach ($r as $rv) { + $duplicated = false; + if (! array_key_exists($rv['hook'], App::$hooks)) { + App::$hooks[$rv['hook']] = []; + } else { + foreach (App::$hooks[$rv['hook']] as $h) { + if ($h[0] === $rv['file'] && $h[1] === $rv['fn']) { + $duplicated = true; + q( + "delete from hook where id = %d", + intval($rv['id']) + ); + logger('duplicate hook ' . $h[1] . ' removed'); + } + } + } + if (! $duplicated) { + App::$hooks[$rv['hook']][] = [ $rv['file'], $rv['fn'], $rv['priority'], $rv['hook_version']]; + } + } + } + // logger('hooks: ' . print_r(App::$hooks,true)); + } + + /** + * @brief Calls a hook. + * + * Use this function when you want to be able to allow a hook to manipulate + * the provided data. + * + * @param string $name of the hook to call + * @param[in,out] string|array &$data to transmit to the callback handler + */ + static public function call($name, &$data = null) + { + $a = 0; + + if (isset(App::$hooks[$name])) { + foreach (App::$hooks[$name] as $hook) { + $origfn = $hook[1]; + if ($hook[0]) { + @include_once($hook[0]); + } + if (preg_match('|^a:[0-9]+:{.*}$|s', $hook[1])) { + $hook[1] = unserialize($hook[1]); + } elseif (strpos($hook[1], '::')) { + // We shouldn't need to do this, but it appears that PHP + // isn't able to directly execute a string variable with a class + // method in the manner we are attempting it, so we'll + // turn it into an array. + $hook[1] = explode('::', $hook[1]); + } + + if (is_callable($hook[1])) { + $func = $hook[1]; + if ($hook[3]) { + $func($data); + } else { + $func($a, $data); + } + } else { + // Don't do any DB write calls if we're currently logging a possibly failed DB call. + if (! DBA::$logging) { + // The hook should be removed so we don't process it. + q( + "DELETE FROM hook WHERE hook = '%s' AND file = '%s' AND fn = '%s'", + dbesc($name), + dbesc($hook[0]), + dbesc($origfn) + ); + } + } + } + } + } + + + + +} diff --git a/Code/Extend/Route.php b/Code/Extend/Route.php new file mode 100644 index 000000000..55220d888 --- /dev/null +++ b/Code/Extend/Route.php @@ -0,0 +1,52 @@ + true, + 'issuer' => System::get_site_name(), +// 'use_jwt_access_tokens' => true, +// 'enforce_state' => false + ]; + } + + parent::__construct($storage, $config); + + // Add the "Client Credentials" grant type (it is the simplest of the grant types) + $this->addGrantType(new ClientCredentials($storage)); + + // Add the "Authorization Code" grant type (this is where the oauth magic happens) + // Need to use OpenID\GrantType to return id_token + // (see:https://github.com/bshaffer/oauth2-server-php/issues/443) + $this->addGrantType(new AuthorizationCode($storage)); + + $keyStorage = new Memory([ + 'keys' => [ + 'public_key' => get_config('system', 'pubkey'), + 'private_key' => get_config('system', 'prvkey') + ] + ]); + + $this->addStorage($keyStorage, 'public_key'); + } +} diff --git a/Code/Identity/OAuth2Storage.php b/Code/Identity/OAuth2Storage.php new file mode 100644 index 000000000..06e99d652 --- /dev/null +++ b/Code/Identity/OAuth2Storage.php @@ -0,0 +1,171 @@ +getUser($username)) { + return $this->checkPassword($user, $password); + } + + return false; + } + + /** + * @param string $username + * @return array|bool + */ + public function getUserDetails($username) + { + return $this->getUser($username); + } + + + /** + * + * @param array $user + * @param string $password + * @return bool + */ + protected function checkPassword($user, $password) + { + + $x = account_verify_password($user, $password); + return((array_key_exists('channel', $x) && ! empty($x['channel'])) ? true : false); + } + + /** + * @param string $username + * @return array|bool + */ + public function getUser($username) + { + + $x = Channel::from_id($username); + if (! $x) { + return false; + } + + $a = q( + "select * from account where account_id = %d", + intval($x['channel_account_id']) + ); + + $n = explode(' ', $x['channel_name']); + + return( [ + 'webfinger' => Channel::get_webfinger($x), + 'portable_id' => $x['channel_hash'], + 'email' => $a[0]['account_email'], + 'username' => $x['channel_address'], + 'user_id' => $x['channel_id'], + 'name' => $x['channel_name'], + 'firstName' => ((count($n) > 1) ? $n[1] : $n[0]), + 'lastName' => ((count($n) > 2) ? $n[count($n) - 1] : ''), + 'picture' => $x['xchan_photo_l'] + ] ); + } + + public function scopeExists($scope) + { + // Report that the scope is valid even if it's not. + // We will only return a very small subset no matter what. + // @TODO: Truly validate the scope + // see vendor/bshaffer/oauth2-server-php/src/OAuth2/Storage/ScopeInterface.php and + // vendor/bshaffer/oauth2-server-php/src/OAuth2/Storage/Pdo.php + // for more info. + return true; + } + + public function getDefaultScope($client_id = null) + { + // Do not REQUIRE a scope + // see vendor/bshaffer/oauth2-server-php/src/OAuth2/Storage/ScopeInterface.php and + // for more info. + return null; + } + + public function getUserClaims($user_id, $claims) + { + // Populate the CLAIMS requested (if any). + // @TODO: create a more reasonable/comprehensive list. + // @TODO: present claims on the AUTHORIZATION screen + + $userClaims = []; + $claims = explode(' ', trim($claims)); + $validclaims = [ "name", "preferred_username", "webfinger", "portable_id", "email", "picture", "firstName", "lastName" ]; + $claimsmap = [ + "webfinger" => 'webfinger', + "portable_id" => 'portable_id', + "name" => 'name', + "email" => 'email', + "preferred_username" => 'username', + "picture" => 'picture', + "given_name" => 'firstName', + "family_name" => 'lastName' + ]; + $userinfo = $this->getUser($user_id); + foreach ($validclaims as $validclaim) { + if (in_array($validclaim, $claims)) { + $claimkey = $claimsmap[$validclaim]; + $userClaims[$validclaim] = $userinfo[$claimkey]; + } else { + $userClaims[$validclaim] = $validclaim; + } + } + $userClaims["sub"] = $user_id; + return $userClaims; + } + + /** + * plaintext passwords are bad! Override this for your application + * + * @param string $username + * @param string $password + * @param string $firstName + * @param string $lastName + * @return bool + */ + public function setUser($username, $password, $firstName = null, $lastName = null) + { + return true; + } + + public function setClientDetails($client_id, $client_secret = null, $redirect_uri = null, $grant_types = null, $scope = null, $user_id = null, $client_name = null) + { + // if it exists, update it. + if ($this->getClientDetails($client_id)) { + $stmt = $this->db->prepare($sql = sprintf('UPDATE %s SET client_secret=:client_secret, redirect_uri=:redirect_uri, grant_types=:grant_types, scope=:scope, user_id=:user_id, client_name=:client_name where client_id=:client_id', $this->config['client_table'])); + } else { + $stmt = $this->db->prepare(sprintf('INSERT INTO %s (client_id, client_secret, redirect_uri, grant_types, scope, user_id, client_name) VALUES (:client_id, :client_secret, :redirect_uri, :grant_types, :scope, :user_id, :client_name)', $this->config['client_table'])); + } + + return $stmt->execute(compact('client_id', 'client_secret', 'redirect_uri', 'grant_types', 'scope', 'user_id', 'client_name')); + } + + + + + public function checkRestrictedGrantType($client_id, $grant_type) + { + $details = $this->getClientDetails($client_id); + if ($details['grant_types']) { + $grant_types = explode(' ', $details['grant_types']); + return in_array($grant_type, (array) $grant_types); + } + + // if grant_types are not defined, then none are restricted + return true; + } +} diff --git a/Code/Import/Friendica.php b/Code/Import/Friendica.php new file mode 100644 index 000000000..ddc458138 --- /dev/null +++ b/Code/Import/Friendica.php @@ -0,0 +1,368 @@ +data = $data; + $this->settings = $settings; + $this->extract(); + } + + public function extract() + { + + // channel stuff + + $channel = [ + 'channel_name' => escape_tags($this->data['user']['username']), + 'channel_address' => escape_tags($this->data['user']['nickname']), + 'channel_guid' => escape_tags($this->data['user']['guid']), + 'channel_guid_sig' => Libzot::sign($this->data['user']['guid'], $this->data['user']['prvkey']), + 'channel_hash' => Libzot::make_xchan_hash($this->data['user']['guid'], $this->data['user']['pubkey']), + 'channel_prvkey' => $this->data['user']['prvkey'], + 'channel_pubkey' => $this->data['user']['pubkey'], + 'channel_pageflags' => PAGE_NORMAL, + 'channel_expire_days' => intval($this->data['user']['expire']), + 'channel_timezone' => escape_tags($this->data['user']['timezone']), + 'channel_location' => escape_tags($this->data['user']['default-location']) + ]; + + $account_id = $this->settings['account_id']; + + $max_identities = ServiceClass::account_fetch($account_id, 'total_identities'); + + if ($max_identities !== false) { + $r = q( + "select channel_id from channel where channel_account_id = %d and channel_removed = 0 ", + intval($account_id) + ); + if ($r && count($r) > $max_identities) { + notice(sprintf(t('Your service plan only allows %d channels.'), $max_identities) . EOL); + return; + } + } + + // save channel or die + + + $channel = import_channel($channel, $this->settings['account_id'], $this->settings['sieze'], $this->settings['newname']); + if (!$channel) { + logger('no channel'); + return; + } + + + // figure out channel permission roles + + $permissions_role = 'social'; + + $pageflags = ((isset($this->data['user']['page-flags'])) ? intval($this->data['user']['page-flags']) : 0); + + if ($pageflags === 2) { + $permissions_role = 'forum'; + } + if ($pageflags === 5) { + $permissions_role = 'forum_restricted'; + } + + if ($pageflags === 0 && isset($this->data['user']['allow_gid']) && $this->data['user']['allow_gid']) { + $permissions_role = 'social_restricted'; + } + + // Friendica folks only have PERMS_AUTHED and "just me" + + $post_comments = (($pageflags === 1) ? 0 : PERMS_AUTHED); + PermissionLimits::Set(local_channel(), 'post_comments', $post_comments); + + PConfig::Set($channel['channel_id'], 'system', 'permissions_role', $permissions_role); + PConfig::Set($channel['channel_id'], 'system', 'use_browser_location', (string)intval($this->data['user']['allow_location'])); + + // find the self contact + + $self_contact = null; + + if (isset($this->data['contact']) && is_array($this->data['contact'])) { + foreach ($this->data['contact'] as $contact) { + if (isset($contact['self']) && intval($contact['self'])) { + $self_contact = $contact; + break; + } + } + } + + if (!is_array($self_contact)) { + logger('self contact not found.'); + return; + } + + // Create a verified hub location pointing to this site. + + $r = hubloc_store_lowlevel( + [ + 'hubloc_guid' => $channel['channel_guid'], + 'hubloc_guid_sig' => $channel['channel_guid_sig'], + 'hubloc_id_url' => Channel::url($channel), + 'hubloc_hash' => $channel['channel_hash'], + 'hubloc_addr' => Channel::get_webfinger($channel), + 'hubloc_primary' => 1, + 'hubloc_url' => z_root(), + 'hubloc_url_sig' => Libzot::sign(z_root(), $channel['channel_prvkey']), + 'hubloc_site_id' => Libzot::make_xchan_hash(z_root(), get_config('system', 'pubkey')), + 'hubloc_host' => App::get_hostname(), + 'hubloc_callback' => z_root() . '/zot', + 'hubloc_sitekey' => get_config('system', 'pubkey'), + 'hubloc_network' => 'nomad', + 'hubloc_updated' => datetime_convert() + ] + ); + if (!$r) { + logger('Unable to store hub location'); + } + + + if ($self_contact['avatar']) { + $p = z_fetch_url($self_contact['avatar'], true); + if ($p['success']) { + $h = explode("\n", $p['header']); + foreach ($h as $l) { + list($k, $v) = array_map("trim", explode(":", trim($l), 2)); + $hdrs[strtolower($k)] = $v; + } + if (array_key_exists('content-type', $hdrs)) { + $phototype = $hdrs['content-type']; + } else { + $phototype = 'image/jpeg'; + } + + import_channel_photo($p['body'], $phototype, $account_id, $channel['channel_id']); + } + } + + $newuid = $channel['channel_id']; + + $r = xchan_store_lowlevel( + [ + 'xchan_hash' => $channel['channel_hash'], + 'xchan_guid' => $channel['channel_guid'], + 'xchan_guid_sig' => $channel['channel_guid_sig'], + 'xchan_pubkey' => $channel['channel_pubkey'], + 'xchan_photo_mimetype' => (($photo_type) ? $photo_type : 'image/png'), + 'xchan_photo_l' => z_root() . "/photo/profile/l/{$newuid}", + 'xchan_photo_m' => z_root() . "/photo/profile/m/{$newuid}", + 'xchan_photo_s' => z_root() . "/photo/profile/s/{$newuid}", + 'xchan_addr' => Channel::get_webfinger($channel), + 'xchan_url' => Channel::url($channel), + 'xchan_follow' => z_root() . '/follow?f=&url=%s', + 'xchan_connurl' => z_root() . '/poco/' . $channel['channel_address'], + 'xchan_name' => $channel['channel_name'], + 'xchan_network' => 'nomad', + 'xchan_updated' => datetime_convert(), + 'xchan_photo_date' => datetime_convert(), + 'xchan_name_date' => datetime_convert(), + 'xchan_system' => 0 + ] + ); + + $r = Channel::profile_store_lowlevel( + [ + 'aid' => intval($channel['channel_account_id']), + 'uid' => intval($newuid), + 'profile_guid' => new_uuid(), + 'profile_name' => t('Default Profile'), + 'is_default' => 1, + 'publish' => ((isset($this->data['profile']['publish'])) ? $this->data['profile']['publish'] : 1), + 'fullname' => $channel['channel_name'], + 'photo' => z_root() . "/photo/profile/l/{$newuid}", + 'thumb' => z_root() . "/photo/profile/m/{$newuid}", + 'homepage' => ((isset($this->data['profile']['homepage'])) ? $this->data['profile']['homepage'] : EMPTY_STR), + ] + ); + + if ($role_permissions) { + $myperms = ((array_key_exists('perms_connect', $role_permissions)) ? $role_permissions['perms_connect'] : []); + } else { + $x = PermissionRoles::role_perms('social'); + $myperms = $x['perms_connect']; + } + + $r = abook_store_lowlevel( + [ + 'abook_account' => intval($channel['channel_account_id']), + 'abook_channel' => intval($newuid), + 'abook_xchan' => $channel['channel_hash'], + 'abook_closeness' => 0, + 'abook_created' => datetime_convert(), + 'abook_updated' => datetime_convert(), + 'abook_self' => 1 + ] + ); + + + $x = Permissions::serialise(Permissions::FilledPerms($myperms)); + set_abconfig($newuid, $channel['channel_hash'], 'system', 'my_perms', $x); + + if (intval($channel['channel_account_id'])) { + // Save our permissions role so we can perhaps call it up and modify it later. + + if ($role_permissions) { + if (array_key_exists('online', $role_permissions)) { + set_pconfig($newuid, 'system', 'hide_presence', 1 - intval($role_permissions['online'])); + } + if (array_key_exists('perms_auto', $role_permissions)) { + $autoperms = intval($role_permissions['perms_auto']); + set_pconfig($newuid, 'system', 'autoperms', $autoperms); + } + } + + // Create a group with yourself as a member. This allows somebody to use it + // right away as a default group for new contacts. + + AccessList::add($newuid, t('Friends')); + AccessList::member_add($newuid, t('Friends'), $ret['channel']['channel_hash']); + + // if our role_permissions indicate that we're using a default collection ACL, add it. + + if (is_array($role_permissions) && $role_permissions['default_collection']) { + $r = q( + "select hash from pgrp where uid = %d and gname = '%s' limit 1", + intval($newuid), + dbesc(t('Friends')) + ); + if ($r) { + q( + "update channel set channel_default_group = '%s', channel_allow_gid = '%s' where channel_id = %d", + dbesc($r[0]['hash']), + dbesc('<' . $r[0]['hash'] . '>'), + intval($newuid) + ); + } + } + + set_pconfig($channel['channel_id'], 'system', 'photo_path', '%Y/%Y-%m'); + set_pconfig($channel['channel_id'], 'system', 'attach_path', '%Y/%Y-%m'); + + + // auto-follow any of the hub's pre-configured channel choices. + // Only do this if it's the first channel for this account; + // otherwise it could get annoying. Don't make this list too big + // or it will impact registration time. + + $accts = get_config('system', 'auto_follow'); + if (($accts) && (!$total_identities)) { + if (!is_array($accts)) { + $accts = array($accts); + } + + foreach ($accts as $acct) { + if (trim($acct)) { + $f = Channel::connect_and_sync($channel, trim($acct)); + if ($f['success']) { + $can_view_stream = their_perms_contains($channel['channel_id'], $f['abook']['abook_xchan'], 'view_stream'); + + // If we can view their stream, pull in some posts + + if (($can_view_stream) || ($f['abook']['xchan_network'] === 'rss')) { + Run::Summon(['Onepoll', $f['abook']['abook_id']]); + } + } + } + } + } + + Hook::call('create_identity', $newuid); + } + + $this->groups = ((isset($this->data['group'])) ? $this->data['group'] : null); + $this->members = ((isset($this->data['group_member'])) ? $this->data['group_member'] : null); + + // import contacts + + if (isset($this->data['contact']) && is_array($this->data['contact'])) { + foreach ($this->data['contact'] as $contact) { + if (isset($contact['self']) && intval($contact['self'])) { + continue; + } + logger('connecting: ' . $contact['url'], LOGGER_DEBUG); + $result = Connect::connect($channel, (($contact['addr']) ? $contact['addr'] : $contact['url'])); + if ($result['success'] && isset($result['abook'])) { + $contact['xchan_hash'] = $result['abook']['abook_xchan']; + $this->contacts[] = $contact; + } + } + } + + // import pconfig + // it is unlikely we can make use of these unless we recongise them. + + if (isset($this->data['pconfig']) && is_array($this->data['pconfig'])) { + foreach ($this->data['pconfig'] as $pc) { + $entry = [ + 'cat' => escape_tags(str_replace('.', '__', $pc['cat'])), + 'k' => escape_tags(str_replace('.', '__', $pc['k'])), + 'v' => ((preg_match('|^a:[0-9]+:{.*}$|s', $pc['v'])) ? serialise(unserialize($pc['v'])) : $pc['v']), + ]; + PConfig::Set($channel['channel_id'], $entry['cat'], $entry['k'], $entry['v']); + } + } + + // The default 'Friends' group is already created and possibly populated. + // So some of the following code is redundant in that regard. + // Mostly this is used to create and populate any other groups. + + if ($this->groups) { + foreach ($this->groups as $group) { + if (!intval($group['deleted'])) { + AccessList::add($channel['channel_id'], $group['name'], intval($group['visible'])); + if ($this->members) { + foreach ($this->members as $member) { + if (intval($member['gid']) === intval(AccessList::byname($channel['channel_id'], $group['name']))) { + $contact_id = $member['contact-id']; + if ($this->contacts) { + foreach ($this->contacts as $contact) { + if (intval($contact['id']) === intval($contact_id)) { + AccessList::member_add($channel['channel_id'], $group['name'], $contact['xchan_hash']); + break; + } + } + } + } + } + } + } + } + } + + change_channel($channel['channel_id']); + notice(t('Import complete.') . EOL); + + goaway(z_root() . '/stream'); + } +} + diff --git a/Code/Lib/AConfig.php b/Code/Lib/AConfig.php new file mode 100644 index 000000000..5c2f944b0 --- /dev/null +++ b/Code/Lib/AConfig.php @@ -0,0 +1,29 @@ +get() to return an array of collection members. + */ +class ASCollection +{ + + private $channel = null; + private $nextpage = null; + private $limit = 0; + private $direction = 0; // 0 = forward, 1 = reverse + private $data = []; + private $history = []; + + + public function __construct($obj, $channel = null, $direction = 0, $limit = 0) + { + + $this->channel = $channel; + $this->direction = $direction; + $this->limit = $limit; + + if (is_array($obj)) { + $data = $obj; + } + + if (is_string($obj)) { + $data = Activity::fetch($obj, $channel); + $this->history[] = $obj; + } + + if (!is_array($data)) { + return; + } + + if (!in_array($data['type'], ['Collection', 'OrderedCollection'])) { + return false; + } + + if ($this->direction) { + if (array_key_exists('last', $data) && $data['last']) { + $this->nextpage = $data['last']; + } + } else { + if (array_key_exists('first', $data) && $data['first']) { + $this->nextpage = $data['first']; + } + } + + if (isset($data['items']) && is_array($data['items'])) { + $this->data = (($this->direction) ? array_reverse($data['items']) : $data['items']); + } elseif (isset($data['orderedItems']) && is_array($data['orderedItems'])) { + $this->data = (($this->direction) ? array_reverse($data['orderedItems']) : $data['orderedItems']); + } + + if ($limit) { + if (count($this->data) > $limit) { + $this->data = array_slice($this->data, 0, $limit); + return; + } + } + + do { + $x = $this->next(); + } while ($x); + } + + public function get() + { + return $this->data; + } + + public function next() + { + + if (!$this->nextpage) { + return false; + } + + if (is_array($this->nextpage)) { + $data = $this->nextpage; + } + + if (is_string($this->nextpage)) { + if (in_array($this->nextpage, $this->history)) { + // recursion detected + return false; + } + $data = Activity::fetch($this->nextpage, $this->channel); + $this->history[] = $this->nextpage; + } + + if (!is_array($data)) { + return false; + } + + if (!in_array($data['type'], ['CollectionPage', 'OrderedCollectionPage'])) { + return false; + } + + $this->setnext($data); + + if (isset($data['items']) && is_array($data['items'])) { + $this->data = array_merge($this->data, (($this->direction) ? array_reverse($data['items']) : $data['items'])); + } elseif (isset($data['orderedItems']) && is_array($data['orderedItems'])) { + $this->data = array_merge($this->data, (($this->direction) ? array_reverse($data['orderedItems']) : $data['orderedItems'])); + } + + if ($limit) { + if (count($this->data) > $limit) { + $this->data = array_slice($this->data, 0, $limit); + $this->nextpage = false; + return true; + } + } + + return true; + } + + public function setnext($data) + { + if ($this->direction) { + if (array_key_exists('prev', $data) && $data['prev']) { + $this->nextpage = $data['prev']; + } elseif (array_key_exists('first', $data) && $data['first']) { + $this->nextpage = $data['first']; + } else { + $this->nextpage = false; + } + } else { + if (array_key_exists('next', $data) && $data['next']) { + $this->nextpage = $data['next']; + } elseif (array_key_exists('last', $data) && $data['last']) { + $this->nextpage = $data['last']; + } else { + $this->nextpage = false; + } + } + logger('nextpage: ' . $this->nextpage, LOGGER_DEBUG); + } +} diff --git a/Code/Lib/AbConfig.php b/Code/Lib/AbConfig.php new file mode 100644 index 000000000..3a3f05ae7 --- /dev/null +++ b/Code/Lib/AbConfig.php @@ -0,0 +1,84 @@ +may apply to this list and any future members. If this is not what you intended, please create another list with a different name.') . EOL); + } + $hash = self::by_id($uid, $r); + return $hash; + } + + $hash = new_uuid(); + + $r = q( + "INSERT INTO pgrp ( hash, uid, visible, gname, rule ) + VALUES( '%s', %d, %d, '%s', '' ) ", + dbesc($hash), + intval($uid), + intval($public), + dbesc($name) + ); + $ret = $r; + } + + Libsync::build_sync_packet($uid, null, true); + + return (($ret) ? $hash : $ret); + } + + + public static function remove($uid, $name) + { + $ret = false; + if ($uid && $name) { + $r = q( + "SELECT id, hash FROM pgrp WHERE uid = %d AND gname = '%s' LIMIT 1", + intval($uid), + dbesc($name) + ); + if ($r) { + $group_id = $r[0]['id']; + $group_hash = $r[0]['hash']; + } else { + return false; + } + + // remove group from default posting lists + $r = q( + "SELECT channel_default_group, channel_allow_gid, channel_deny_gid FROM channel WHERE channel_id = %d LIMIT 1", + intval($uid) + ); + if ($r) { + $user_info = array_shift($r); + $change = false; + + if ($user_info['channel_default_group'] == $group_hash) { + $user_info['channel_default_group'] = ''; + $change = true; + } + if (strpos($user_info['channel_allow_gid'], '<' . $group_hash . '>') !== false) { + $user_info['channel_allow_gid'] = str_replace('<' . $group_hash . '>', '', $user_info['channel_allow_gid']); + $change = true; + } + if (strpos($user_info['channel_deny_gid'], '<' . $group_hash . '>') !== false) { + $user_info['channel_deny_gid'] = str_replace('<' . $group_hash . '>', '', $user_info['channel_deny_gid']); + $change = true; + } + + if ($change) { + q( + "UPDATE channel SET channel_default_group = '%s', channel_allow_gid = '%s', channel_deny_gid = '%s' + WHERE channel_id = %d", + intval($user_info['channel_default_group']), + dbesc($user_info['channel_allow_gid']), + dbesc($user_info['channel_deny_gid']), + intval($uid) + ); + } + } + + // remove all members + $r = q( + "DELETE FROM pgrp_member WHERE uid = %d AND gid = %d ", + intval($uid), + intval($group_id) + ); + + // remove group + $r = q( + "UPDATE pgrp SET deleted = 1 WHERE uid = %d AND gname = '%s'", + intval($uid), + dbesc($name) + ); + + $ret = $r; + } + + Libsync::build_sync_packet($uid, null, true); + + return $ret; + } + + // returns the integer id of an access group owned by $uid and named $name + // or false. + + public static function byname($uid, $name) + { + if (!($uid && $name)) { + return false; + } + $r = q( + "SELECT id FROM pgrp WHERE uid = %d AND gname = '%s' LIMIT 1", + intval($uid), + dbesc($name) + ); + if ($r) { + return $r[0]['id']; + } + return false; + } + + public static function by_id($uid, $id) + { + if (!($uid && $id)) { + return false; + } + + $r = q( + "SELECT * FROM pgrp WHERE uid = %d AND id = %d and deleted = 0", + intval($uid), + intval($id) + ); + if ($r) { + return array_shift($r); + } + return false; + } + + + public static function rec_byhash($uid, $hash) + { + if (!($uid && $hash)) { + return false; + } + $r = q( + "SELECT * FROM pgrp WHERE uid = %d AND hash = '%s' LIMIT 1", + intval($uid), + dbesc($hash) + ); + if ($r) { + return array_shift($r); + } + return false; + } + + + public static function member_remove($uid, $name, $member) + { + $gid = self::byname($uid, $name); + if (!$gid) { + return false; + } + if (!($uid && $gid && $member)) { + return false; + } + $r = q( + "DELETE FROM pgrp_member WHERE uid = %d AND gid = %d AND xchan = '%s' ", + intval($uid), + intval($gid), + dbesc($member) + ); + + Libsync::build_sync_packet($uid, null, true); + + return $r; + } + + + public static function member_add($uid, $name, $member, $gid = 0) + { + if (!$gid) { + $gid = self::byname($uid, $name); + } + if (!($gid && $uid && $member)) { + return false; + } + + $r = q( + "SELECT * FROM pgrp_member WHERE uid = %d AND gid = %d AND xchan = '%s' LIMIT 1", + intval($uid), + intval($gid), + dbesc($member) + ); + if ($r) { + return true; // You might question this, but + // we indicate success because the group member was in fact created + // -- It was just created at another time + } else { + $r = q( + "INSERT INTO pgrp_member (uid, gid, xchan) + VALUES( %d, %d, '%s' ) ", + intval($uid), + intval($gid), + dbesc($member) + ); + } + Libsync::build_sync_packet($uid, null, true); + return $r; + } + + + public static function members($uid, $gid, $total = false, $start = 0, $records = 0) + { + $ret = []; + if ($records) { + $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval($records), intval($start)); + } + + // process virtual groups + if (strpos($gid, ':') === 0) { + $vg = substr($gid, 1); + switch ($vg) { + case '1': + $sql_extra = EMPTY_STR; + break; + case '2': + $sql_extra = " and xchan_network in ('nomad','zot6') "; + break; + case '3': + $sql_extra = " and xchan_network = 'activitypub' "; + break; + default: + break; + } + if ($total) { + $r = q( + "SELECT count(*) FROM abook left join xchan on xchan_hash = abook_xchan WHERE abook_channel = %d and xchan_deleted = 0 and abook_self = 0 and abook_blocked = 0 and abook_pending = 0 $sql_extra ORDER BY xchan_name ASC $pager_sql", + intval($uid) + ); + return ($r) ? $r[0]['total'] : false; + } + + $r = q( + "SELECT * FROM abook left join xchan on xchan_hash = abook_xchan + WHERE abook_channel = %d and xchan_deleted = 0 and abook_self = 0 and abook_blocked = 0 and abook_pending = 0 $sql_extra ORDER BY xchan_name ASC $pager_sql", + intval($uid) + ); + if ($r) { + for ($x = 0; $x < count($r); $x++) { + $r[$x]['xchan'] = $r[$x]['abook_xchan']; + } + } + return $r; + } + + if (intval($gid)) { + if ($total) { + $r = q( + "SELECT count(xchan) as total FROM pgrp_member + LEFT JOIN abook ON abook_xchan = pgrp_member.xchan left join xchan on xchan_hash = abook_xchan + WHERE gid = %d AND abook_channel = %d and pgrp_member.uid = %d and xchan_deleted = 0 and abook_self = 0 + and abook_blocked = 0 and abook_pending = 0", + intval($gid), + intval($uid), + intval($uid) + ); + if ($r) { + return $r[0]['total']; + } + } + + $r = q( + "SELECT * FROM pgrp_member + LEFT JOIN abook ON abook_xchan = pgrp_member.xchan left join xchan on xchan_hash = abook_xchan + WHERE gid = %d AND abook_channel = %d and pgrp_member.uid = %d and xchan_deleted = 0 and abook_self = 0 and abook_blocked = 0 and abook_pending = 0 ORDER BY xchan_name ASC $pager_sql", + intval($gid), + intval($uid), + intval($uid) + ); + if ($r) { + $ret = $r; + } + } + return $ret; + } + + public static function members_xchan($uid, $gid) + { + $ret = []; + if (intval($gid)) { + $r = q( + "SELECT xchan FROM pgrp_member WHERE gid = %d AND uid = %d", + intval($gid), + intval($uid) + ); + if ($r) { + foreach ($r as $rv) { + $ret[] = $rv['xchan']; + } + } + } + return $ret; + } + + public static function select($uid, $group = '') + { + + $grps = []; + + $r = q( + "SELECT * FROM pgrp WHERE deleted = 0 AND uid = %d ORDER BY gname ASC", + intval($uid) + ); + $grps[] = ['name' => '', 'hash' => '0', 'selected' => '']; + if ($r) { + foreach ($r as $rr) { + $grps[] = ['name' => $rr['gname'], 'id' => $rr['hash'], 'selected' => (($group == $rr['hash']) ? 'true' : '')]; + } + } + + return replace_macros(Theme::get_template('group_selection.tpl'), [ + '$label' => t('Add new connections to this access list'), + '$groups' => $grps + ]); + } + + + public static function widget($every = "connections", $each = "lists", $edit = false, $group_id = 0, $cid = '', $mode = 1) + { + + $o = ''; + + $groups = []; + + $r = q( + "SELECT * FROM pgrp WHERE deleted = 0 AND uid = %d ORDER BY gname ASC", + intval($_SESSION['uid']) + ); + $member_of = []; + if ($cid) { + $member_of = self::containing(local_channel(), $cid); + } + + if ($r) { + foreach ($r as $rr) { + $selected = (($group_id == $rr['id']) ? ' group-selected' : ''); + + if ($edit) { + $groupedit = ['href' => "lists/" . $rr['id'], 'title' => t('edit')]; + } else { + $groupedit = null; + } + + $groups[] = [ + 'id' => $rr['id'], + 'enc_cid' => base64url_encode($cid), + 'cid' => $cid, + 'text' => $rr['gname'], + 'selected' => $selected, + 'href' => (($mode == 0) ? $each . '?f=&gid=' . $rr['id'] : $each . "/" . $rr['id']) . ((x($_GET, 'new')) ? '&new=' . $_GET['new'] : '') . ((x($_GET, 'order')) ? '&order=' . $_GET['order'] : ''), + 'edit' => $groupedit, + 'ismember' => in_array($rr['id'], $member_of), + ]; + } + } + + return replace_macros(Theme::get_template('group_side.tpl'), [ + '$title' => t('Lists'), + '$edittext' => t('Edit list'), + '$createtext' => t('Create new list'), + '$ungrouped' => (($every === 'contacts') ? t('Channels not in any access list') : ''), + '$groups' => $groups, + '$add' => t('add'), + ]); + } + + + public static function expand($g) + { + if (!(is_array($g) && count($g))) { + return []; + } + + $ret = []; + $x = []; + + foreach ($g as $gv) { + // virtual access lists + // connections:abc is all the connection sof the channel with channel_hash abc + // zot:abc is all of abc's zot6 connections + // activitypub:abc is all of abc's activitypub connections + + if (strpos($gv, 'connections:') === 0 || strpos($gv, 'zot:') === 0 || strpos($gv, 'activitypub:') === 0) { + $sql_extra = EMPTY_STR; + $channel_hash = substr($gv, strpos($gv, ':') + 1); + if (strpos($gv, 'zot:') === 0) { + $sql_extra = " and xchan_network in ('nomad','zot6') "; + } + if (strpos($gv, 'activitypub:') === 0) { + $sql_extra = " and xchan_network = 'activitypub' "; + } + $r = q( + "select channel_id from channel where channel_hash = '%s' ", + dbesc($channel_hash) + ); + if ($r) { + foreach ($r as $rv) { + $y = q( + "select abook_xchan from abook left join xchan on abook_xchan = xchan_hash where abook_channel = %d and abook_self = 0 and abook_pending = 0 and abook_archived = 0 $sql_extra", + intval($rv['channel_id']) + ); + if ($y) { + foreach ($y as $yv) { + $ret[] = $yv['abook_xchan']; + } + } + } + } + } else { + $x[] = $gv; + } + } + + if ($x) { + stringify_array_elms($x, true); + $groups = implode(',', $x); + if ($groups) { + $r = q("SELECT xchan FROM pgrp_member WHERE gid IN ( select id from pgrp where hash in ( $groups ))"); + if ($r) { + foreach ($r as $rv) { + $ret[] = $rv['xchan']; + } + } + } + } + return $ret; + } + + + public static function member_of($c) + { + $r = q( + "SELECT pgrp.gname, pgrp.id FROM pgrp LEFT JOIN pgrp_member ON pgrp_member.gid = pgrp.id + WHERE pgrp_member.xchan = '%s' AND pgrp.deleted = 0 ORDER BY pgrp.gname ASC ", + dbesc($c) + ); + + return $r; + } + + public static function containing($uid, $c) + { + + $r = q( + "SELECT gid FROM pgrp_member WHERE uid = %d AND pgrp_member.xchan = '%s' ", + intval($uid), + dbesc($c) + ); + + $ret = []; + if ($r) { + foreach ($r as $rv) { + $ret[] = $rv['gid']; + } + } + + return $ret; + } +} diff --git a/Code/Lib/Account.php b/Code/Lib/Account.php new file mode 100644 index 000000000..b3661d55c --- /dev/null +++ b/Code/Lib/Account.php @@ -0,0 +1,707 @@ + false, 'message' => '' ]; + + // Caution: empty email isn't counted as an error in this function. + // Check for empty value separately. + + if (! strlen($email)) { + return $result; + } + + if (! validate_email($email)) { + $result['message'] .= t('Not a valid email address') . EOL; + } elseif (! allowed_email($email)) { + $result['message'] = t('Your email domain is not among those allowed on this site'); + } else { + $r = q( + "select account_email from account where account_email = '%s' limit 1", + dbesc($email) + ); + if ($r) { + $result['message'] .= t('Your email address is already registered at this site.'); + } + } + if ($result['message']) { + $result['error'] = true; + } + + $arr = array('email' => $email, 'result' => $result); + Hook::call('check_account_email', $arr); + + return $arr['result']; + } + + public static function check_password($password) + { + $result = [ 'error' => false, 'message' => '' ]; + + // The only validation we perform by default is pure Javascript to + // check minimum length and that both entered passwords match. + // Use hooked functions to perform complexity requirement checks. + + $arr = [ 'password' => $password, 'result' => $result ]; + Hook::call('check_account_password', $arr); + + return $arr['result']; + } + + public static function check_invite($invite_code) + { + $result = [ 'error' => false, 'message' => '' ]; + + $using_invites = get_config('system', 'invitation_only'); + + if ($using_invites && defined('INVITE_WORKING')) { + if (! $invite_code) { + $result['message'] .= t('An invitation is required.') . EOL; + } + $r = q("select * from register where hash = '%s' limit 1", dbesc($invite_code)); + if (! $r) { + $result['message'] .= t('Invitation could not be verified.') . EOL; + } + } + if (strlen($result['message'])) { + $result['error'] = true; + } + + $arr = [ 'invite_code' => $invite_code, 'result' => $result ]; + Hook::call('check_account_invite', $arr); + + return $arr['result']; + } + + public static function check_admin($arr) + { + if (is_site_admin()) { + return true; + } + $admin_email = trim(get_config('system', 'admin_email', '')); + if (strlen($admin_email) && $admin_email === trim($arr['email'])) { + return true; + } + return false; + } + + public static function account_total() + { + $r = q("select account_id from account where true"); + // Distinguish between an empty array and an error + if (is_array($r)) { + return count($r); + } + return false; + } + + + public static function account_store_lowlevel($arr) + { + + $store = [ + 'account_parent' => ((array_key_exists('account_parent', $arr)) ? $arr['account_parent'] : '0'), + 'account_default_channel' => ((array_key_exists('account_default_channel', $arr)) ? $arr['account_default_channel'] : '0'), + 'account_salt' => ((array_key_exists('account_salt', $arr)) ? $arr['account_salt'] : ''), + 'account_password' => ((array_key_exists('account_password', $arr)) ? $arr['account_password'] : ''), + 'account_email' => ((array_key_exists('account_email', $arr)) ? $arr['account_email'] : ''), + 'account_external' => ((array_key_exists('account_external', $arr)) ? $arr['account_external'] : ''), + 'account_language' => ((array_key_exists('account_language', $arr)) ? $arr['account_language'] : 'en'), + 'account_created' => ((array_key_exists('account_created', $arr)) ? $arr['account_created'] : '0001-01-01 00:00:00'), + 'account_lastlog' => ((array_key_exists('account_lastlog', $arr)) ? $arr['account_lastlog'] : '0001-01-01 00:00:00'), + 'account_flags' => ((array_key_exists('account_flags', $arr)) ? $arr['account_flags'] : '0'), + 'account_roles' => ((array_key_exists('account_roles', $arr)) ? $arr['account_roles'] : '0'), + 'account_reset' => ((array_key_exists('account_reset', $arr)) ? $arr['account_reset'] : ''), + 'account_expires' => ((array_key_exists('account_expires', $arr)) ? $arr['account_expires'] : '0001-01-01 00:00:00'), + 'account_expire_notified' => ((array_key_exists('account_expire_notified', $arr)) ? $arr['account_expire_notified'] : '0001-01-01 00:00:00'), + 'account_service_class' => ((array_key_exists('account_service_class', $arr)) ? $arr['account_service_class'] : ''), + 'account_level' => ((array_key_exists('account_level', $arr)) ? $arr['account_level'] : '0'), + 'account_password_changed' => ((array_key_exists('account_password_changed', $arr)) ? $arr['account_password_changed'] : '0001-01-01 00:00:00') + ]; + + return create_table_from_array('account', $store); + } + + + public static function create($arr) + { + + // Required: { email, password } + + $result = [ 'success' => false, 'email' => '', 'password' => '', 'message' => '' ]; + + $invite_code = ((isset($arr['invite_code'])) ? notags(trim($arr['invite_code'])) : ''); + $email = ((isset($arr['email'])) ? notags(punify(trim($arr['email']))) : ''); + $password = ((isset($arr['password'])) ? trim($arr['password']) : ''); + $password2 = ((isset($arr['password2'])) ? trim($arr['password2']) : ''); + $parent = ((isset($arr['parent'])) ? intval($arr['parent']) : 0 ); + $flags = ((isset($arr['account_flags'])) ? intval($arr['account_flags']) : ACCOUNT_OK); + $roles = ((isset($arr['account_roles'])) ? intval($arr['account_roles']) : 0 ); + $expires = ((isset($arr['expires'])) ? intval($arr['expires']) : NULL_DATE); + + $default_service_class = get_config('system', 'default_service_class', EMPTY_STR); + + if (! ($email && $password)) { + $result['message'] = t('Please enter the required information.'); + return $result; + } + + // prevent form hackery + + if (($roles & ACCOUNT_ROLE_ADMIN) && (! self::check_admin($arr))) { + $roles = $roles - ACCOUNT_ROLE_ADMIN; + } + + // allow the admin_email account to be admin, but only if it's the first account. + + $c = self::account_total(); + if (($c === 0) && (self::check_admin($arr))) { + $roles |= ACCOUNT_ROLE_ADMIN; + } + + // Ensure that there is a host keypair. + + if ((! get_config('system', 'pubkey')) && (! get_config('system', 'prvkey'))) { + $hostkey = Crypto::new_keypair(4096); + set_config('system', 'pubkey', $hostkey['pubkey']); + set_config('system', 'prvkey', $hostkey['prvkey']); + } + + $invite_result = check_account_invite($invite_code); + if ($invite_result['error']) { + $result['message'] = $invite_result['message']; + return $result; + } + + $email_result = check_account_email($email); + + if ($email_result['error']) { + $result['message'] = $email_result['message']; + return $result; + } + + $password_result = check_account_password($password); + + if ($password_result['error']) { + $result['message'] = $password_result['message']; + return $result; + } + + $salt = random_string(32); + $password_encoded = hash('whirlpool', $salt . $password); + + $r = self::account_store_lowlevel( + [ + 'account_parent' => intval($parent), + 'account_salt' => $salt, + 'account_password' => $password_encoded, + 'account_email' => $email, + 'account_language' => get_best_language(), + 'account_created' => datetime_convert(), + 'account_flags' => intval($flags), + 'account_roles' => intval($roles), + 'account_expires' => $expires, + 'account_service_class' => $default_service_class + ] + ); + if (! $r) { + logger('create_account: DB INSERT failed.'); + $result['message'] = t('Failed to store account information.'); + return($result); + } + + $r = q( + "select * from account where account_email = '%s' and account_password = '%s' limit 1", + dbesc($email), + dbesc($password_encoded) + ); + if ($r && is_array($r) && count($r)) { + $result['account'] = $r[0]; + } else { + logger('create_account: could not retrieve newly created account'); + } + + // Set the parent record to the current record_id if no parent was provided + + if (! $parent) { + $r = q( + "update account set account_parent = %d where account_id = %d", + intval($result['account']['account_id']), + intval($result['account']['account_id']) + ); + if (! $r) { + logger('create_account: failed to set parent'); + } + $result['account']['parent'] = $result['account']['account_id']; + } + + $result['success'] = true; + $result['email'] = $email; + $result['password'] = $password; + + Hook::call('register_account', $result); + + return $result; + } + + + + public static function verify_email_address($arr) + { + + if (array_key_exists('resend', $arr)) { + $email = $arr['email']; + $a = q( + "select * from account where account_email = '%s' limit 1", + dbesc($arr['email']) + ); + if (! ($a && ($a[0]['account_flags'] & ACCOUNT_UNVERIFIED))) { + return false; + } + $account = array_shift($a); + $v = q( + "select * from register where uid = %d and password = 'verify' limit 1", + intval($account['account_id']) + ); + if ($v) { + $hash = $v[0]['hash']; + } else { + return false; + } + } else { + $hash = random_string(24); + + $r = q( + "INSERT INTO register ( hash, created, uid, password, lang ) VALUES ( '%s', '%s', %d, '%s', '%s' ) ", + dbesc($hash), + dbesc(datetime_convert()), + intval($arr['account']['account_id']), + dbesc('verify'), + dbesc($arr['account']['account_language']) + ); + $account = $arr['account']; + } + + push_lang(($account['account_language']) ? $account['account_language'] : 'en'); + + $email_msg = replace_macros( + Theme::get_email_template('register_verify_member.tpl'), + [ + '$sitename' => System::get_site_name(), + '$siteurl' => z_root(), + '$email' => $arr['email'], + '$uid' => $account['account_id'], + '$hash' => $hash, + '$details' => $details + ] + ); + + $res = z_mail( + [ + 'toEmail' => $arr['email'], + 'messageSubject' => sprintf(t('Registration confirmation for %s'), System::get_site_name()), + 'textVersion' => $email_msg, + ] + ); + + pop_lang(); + + if ($res) { + $delivered ++; + } else { + logger('send_reg_approval_email: failed to account_id: ' . $arr['account']['account_id']); + } + return $res; + } + + + + + public static function send_reg_approval_email($arr) + { + + $r = q( + "select * from account where (account_roles & %d) >= 4096", + intval(ACCOUNT_ROLE_ADMIN) + ); + if (! ($r && is_array($r) && count($r))) { + return false; + } + + $admins = []; + + foreach ($r as $rr) { + if (strlen($rr['account_email'])) { + $admins[] = [ 'email' => $rr['account_email'], 'lang' => $rr['account_lang'] ]; + } + } + + if (! count($admins)) { + return false; + } + + $hash = random_string(); + + $r = q( + "INSERT INTO register ( hash, created, uid, password, lang ) VALUES ( '%s', '%s', %d, '%s', '%s' ) ", + dbesc($hash), + dbesc(datetime_convert()), + intval($arr['account']['account_id']), + dbesc(''), + dbesc($arr['account']['account_language']) + ); + + $ip = ((isset($_SERVER['REMOTE_ADDR'])) ? $_SERVER['REMOTE_ADDR'] : EMPTY_STR); + + $details = (($ip) ? $ip . ' [' . gethostbyaddr($ip) . ']' : '[unknown or stealth IP]'); + + $delivered = 0; + + foreach ($admins as $admin) { + if (strlen($admin['lang'])) { + push_lang($admin['lang']); + } else { + push_lang('en'); + } + + $email_msg = replace_macros(Theme::get_email_template('register_verify_eml.tpl'), [ + '$sitename' => get_config('system', 'sitename'), + '$siteurl' => z_root(), + '$email' => $arr['email'], + '$uid' => $arr['account']['account_id'], + '$hash' => $hash, + '$details' => $details + ]); + + $res = z_mail( + [ + 'toEmail' => $admin['email'], + 'messageSubject' => sprintf(t('Registration request at %s'), get_config('system', 'sitename')), + 'textVersion' => $email_msg, + ] + ); + + if ($res) { + $delivered ++; + } else { + logger('send_reg_approval_email: failed to ' . $admin['email'] . 'account_id: ' . $arr['account']['account_id']); + } + + pop_lang(); + } + + return ($delivered ? true : false); + } + + public static function send_register_success_email($email, $password) + { + + $email_msg = replace_macros(Theme::get_email_template('register_open_eml.tpl'), [ + '$sitename' => System::get_site_name(), + '$siteurl' => z_root(), + '$email' => $email, + '$password' => t('your registration password'), + ]); + + $res = z_mail( + [ + 'toEmail' => $email, + 'messageSubject' => sprintf(t('Registration details for %s'), System::get_site_name()), + 'textVersion' => $email_msg, + ] + ); + + return ($res ? true : false); + } + + /** + * @brief Allows a user registration. + * + * @param string $hash + * @return array|bool + */ + public static function allow($hash) + { + + $ret = array('success' => false); + + $register = q( + "SELECT * FROM register WHERE hash = '%s' LIMIT 1", + dbesc($hash) + ); + + if (! $register) { + return $ret; + } + + $account = q( + "SELECT * FROM account WHERE account_id = %d LIMIT 1", + intval($register[0]['uid']) + ); + + if (! $account) { + return $ret; + } + + $r = q( + "DELETE FROM register WHERE hash = '%s'", + dbesc($register[0]['hash']) + ); + + $r = q( + "update account set account_flags = (account_flags & ~%d) where (account_flags & %d) > 0 and account_id = %d", + intval(ACCOUNT_BLOCKED), + intval(ACCOUNT_BLOCKED), + intval($register[0]['uid']) + ); + $r = q( + "update account set account_flags = (account_flags & ~%d) where (account_flags & %d) > 0 and account_id = %d", + intval(ACCOUNT_PENDING), + intval(ACCOUNT_PENDING), + intval($register[0]['uid']) + ); + + push_lang($register[0]['lang']); + + $email_tpl = Theme::get_email_template("register_open_eml.tpl"); + $email_msg = replace_macros($email_tpl, [ + '$sitename' => System::get_site_name(), + '$siteurl' => z_root(), + '$username' => $account[0]['account_email'], + '$email' => $account[0]['account_email'], + '$password' => '', + '$uid' => $account[0]['account_id'] + ]); + + $res = z_mail( + [ + 'toEmail' => $account[0]['account_email'], + 'messageSubject' => sprintf(t('Registration details for %s'), System::get_site_name()), + 'textVersion' => $email_msg, + ] + ); + + pop_lang(); + + if (get_config('system', 'auto_channel_create')) { + Channel::auto_create($register[0]['uid']); + } + + if ($res) { + info(t('Account approved.') . EOL); + return true; + } + } + + + /** + * @brief Denies an account registration. + * + * This does not have to go through user_remove() and save the nickname + * permanently against re-registration, as the person was not yet + * allowed to have friends on this system + * + * @param string $hash + * @return bool + */ + + public static function deny($hash) + { + + $register = q( + "SELECT * FROM register WHERE hash = '%s' LIMIT 1", + dbesc($hash) + ); + + if (! $register) { + return false; + } + + $account = q( + "SELECT account_id, account_email FROM account WHERE account_id = %d LIMIT 1", + intval($register[0]['uid']) + ); + + if (! $account) { + return false; + } + + $r = q( + "DELETE FROM account WHERE account_id = %d", + intval($register[0]['uid']) + ); + + $r = q( + "DELETE FROM register WHERE id = %d", + intval($register[0]['id']) + ); + notice(sprintf(t('Registration revoked for %s'), $account[0]['account_email']) . EOL); + + return true; + } + + // called from regver to activate an account from the email verification link + + public static function approve($hash) + { + + $ret = false; + + // Note: when the password in the register table is 'verify', the uid actually contains the account_id + + $register = q( + "SELECT * FROM register WHERE hash = '%s' and password = 'verify' LIMIT 1", + dbesc($hash) + ); + + if (! $register) { + return $ret; + } + + $account = q( + "SELECT * FROM account WHERE account_id = %d LIMIT 1", + intval($register[0]['uid']) + ); + + if (! $account) { + return $ret; + } + + $r = q( + "DELETE FROM register WHERE hash = '%s' and password = 'verify'", + dbesc($register[0]['hash']) + ); + + $r = q( + "update account set account_flags = (account_flags & ~%d) where (account_flags & %d)>0 and account_id = %d", + intval(ACCOUNT_BLOCKED), + intval(ACCOUNT_BLOCKED), + intval($register[0]['uid']) + ); + $r = q( + "update account set account_flags = (account_flags & ~%d) where (account_flags & %d)>0 and account_id = %d", + intval(ACCOUNT_PENDING), + intval(ACCOUNT_PENDING), + intval($register[0]['uid']) + ); + $r = q( + "update account set account_flags = (account_flags & ~%d) where (account_flags & %d)>0 and account_id = %d", + intval(ACCOUNT_UNVERIFIED), + intval(ACCOUNT_UNVERIFIED), + intval($register[0]['uid']) + ); + + // get a fresh copy after we've modified it. + + $account = q( + "SELECT * FROM account WHERE account_id = %d LIMIT 1", + intval($register[0]['uid']) + ); + + if (! $account) { + return $ret; + } + + if (get_config('system', 'auto_channel_create')) { + Channel::auto_create($register[0]['uid']); + } else { + $_SESSION['login_return_url'] = 'new_channel'; + authenticate_success($account[0], null, true, true, false, true); + } + + return true; + } + + /** + * Included here for completeness, but this is a very dangerous operation. + * It is the caller's responsibility to confirm the requestor's intent and + * authorisation to do this. + * + * @param int $account_id + * @param bool $local (optional) default true + * @param bool $unset_session (optional) default true + * @return bool|array + */ + + public static function remove($account_id, $local = true, $unset_session = true) + { + + logger('account_remove: ' . $account_id); + + // Global removal (all clones) not currently supported + $local = true; + + if (! intval($account_id)) { + logger('No account.'); + return false; + } + + // Don't let anybody nuke the only admin account. + + $r = q( + "select account_id from account where (account_roles & %d) > 0", + intval(ACCOUNT_ROLE_ADMIN) + ); + + if ($r !== false && count($r) == 1 && $r[0]['account_id'] == $account_id) { + logger("Unable to remove the only remaining admin account"); + return false; + } + + $r = q( + "select * from account where account_id = %d limit 1", + intval($account_id) + ); + + if (! $r) { + logger('No account with id: ' . $account_id); + return false; + } + + $account_email = $r[0]['account_email']; + + $x = q( + "select channel_id from channel where channel_account_id = %d", + intval($account_id) + ); + if ($x) { + foreach ($x as $xx) { + Channel::channel_remove($xx['channel_id'], $local, false); + } + } + + $r = q( + "delete from account where account_id = %d", + intval($account_id) + ); + + if ($unset_session) { + App::$session->nuke(); + notice(sprintf(t('Account \'%s\' deleted'), $account_email) . EOL); + goaway(z_root()); + } + + return $r; + } + + +} diff --git a/Code/Lib/Activity.php b/Code/Lib/Activity.php new file mode 100644 index 000000000..df4d75e1b --- /dev/null +++ b/Code/Lib/Activity.php @@ -0,0 +1,4376 @@ + 'application/activity+json, application/x-zot-activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'Host' => $parsed['host'], + 'Date' => datetime_convert('UTC', 'UTC', 'now', 'D, d M Y H:i:s \\G\\M\\T'), + '(request-target)' => 'get ' . get_request_string($url) + ]; + + if (isset($token)) { + $headers['Authorization'] = 'Bearer ' . $token; + } + $h = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel), false); + $x = z_fetch_url($url, true, $redirects, ['headers' => $h]); + } + + if ($x['success']) { + $y = json_decode($x['body'], true); + logger('returned: ' . json_encode($y, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $site_url = unparse_url(['scheme' => $parsed['scheme'], 'host' => $parsed['host'], 'port' => ((array_key_exists('port', $parsed) && intval($parsed['port'])) ? $parsed['port'] : 0)]); + q( + "update site set site_update = '%s' where site_url = '%s' and site_update < %s - INTERVAL %s", + dbesc(datetime_convert()), + dbesc($site_url), + db_utcnow(), + db_quoteinterval('1 DAY') + ); + + // check for a valid signature, but only if this is not an actor object. If it is signed, it must be valid. + // Ignore actors because of the potential for infinite recursion if we perform this step while + // fetching an actor key to validate a signature elsewhere. This should validate relayed activities + // over litepub which arrived at our inbox that do not use LD signatures + + if (($y['type']) && (!ActivityStreams::is_an_actor($y['type']))) { + $sigblock = HTTPSig::verify($x); + + if (($sigblock['header_signed']) && (!$sigblock['header_valid'])) { + return null; + } + } + + return json_decode($x['body'], true); + } else { + logger('fetch failed: ' . $url); + if ($debug) { + return $x; + } + } + return null; + } + + + public static function fetch_person($x) + { + return self::fetch_profile($x); + } + + public static function fetch_profile($x) + { + $r = q( + "select * from xchan left join hubloc on xchan_hash = hubloc_hash where hubloc_id_url = '%s' limit 1", + dbesc($x['id']) + ); + if (!$r) { + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($x['id']) + ); + } + if (!$r) { + return []; + } + + return self::encode_person($r[0], false); + } + + public static function fetch_thing($x) + { + + $r = q( + "select * from obj where obj_type = %d and obj_obj = '%s' limit 1", + intval(TERM_OBJ_THING), + dbesc($x['id']) + ); + + if (!$r) { + return []; + } + + $x = [ + 'type' => 'Object', + 'id' => z_root() . '/thing/' . $r[0]['obj_obj'], + 'name' => $r[0]['obj_term'] + ]; + + if ($r[0]['obj_image']) { + $x['image'] = $r[0]['obj_image']; + } + return $x; + } + + public static function fetch_item($x, $activitypub = false) + { + + if (array_key_exists('source', $x)) { + // This item is already processed and encoded + return $x; + } + + $r = q( + "select * from item where mid = '%s' limit 1", + dbesc($x['id']) + ); + if ($r) { + xchan_query($r, true); + $r = fetch_post_tags($r, true); + if ($r[0]['verb'] === 'Invite') { + return self::encode_activity($r[0], $activitypub); + } + return self::encode_item($r[0], $activitypub); + } + } + + public static function paged_collection_init($total, $id, $type = 'OrderedCollection') + { + + $ret = [ + 'id' => z_root() . '/' . $id, + 'type' => $type, + 'totalItems' => $total, + ]; + + $numpages = $total / App::$pager['itemspage']; + $lastpage = (($numpages > intval($numpages)) ? intval($numpages) + 1 : $numpages); + + $ret['first'] = z_root() . '/' . App::$query_string . '?page=1'; + $ret['last'] = z_root() . '/' . App::$query_string . '?page=' . $lastpage; + + return $ret; + } + + + public static function encode_item_collection($items, $id, $type, $activitypub = false, $total = 0) + { + + if ($total > 100) { + $ret = [ + 'id' => z_root() . '/' . $id, + 'type' => $type . 'Page', + ]; + + $numpages = $total / App::$pager['itemspage']; + $lastpage = (($numpages > intval($numpages)) ? intval($numpages) + 1 : $numpages); + + $url_parts = parse_url($id); + + $ret['partOf'] = z_root() . '/' . $url_parts['path']; + + $extra_query_args = ''; + $query_args = null; + if (isset($url_parts['query'])) { + parse_str($url_parts['query'], $query_args); + } + + if (is_array($query_args)) { + unset($query_args['page']); + foreach ($query_args as $k => $v) { + $extra_query_args .= '&' . urlencode($k) . '=' . urlencode($v); + } + } + + if (App::$pager['page'] < $lastpage) { + $ret['next'] = z_root() . '/' . $url_parts['path'] . '?page=' . (intval(App::$pager['page']) + 1) . $extra_query_args; + } + if (App::$pager['page'] > 1) { + $ret['prev'] = z_root() . '/' . $url_parts['path'] . '?page=' . (intval(App::$pager['page']) - 1) . $extra_query_args; + } + } else { + $ret = [ + 'id' => z_root() . '/' . $id, + 'type' => $type, + 'totalItems' => $total, + ]; + } + + + if ($items) { + $x = []; + foreach ($items as $i) { + $m = get_iconfig($i['id'], 'activitypub', 'rawmsg'); + if ($m) { + $t = json_decode($m, true); + } else { + $t = self::encode_activity($i, $activitypub); + } + if ($t) { + $x[] = $t; + } + } + if ($type === 'OrderedCollection') { + $ret['orderedItems'] = $x; + } else { + $ret['items'] = $x; + } + } + + return $ret; + } + + public static function encode_follow_collection($items, $id, $type, $total = 0, $extra = null) + { + + if ($total > 100) { + $ret = [ + 'id' => z_root() . '/' . $id, + 'type' => $type . 'Page', + ]; + + $numpages = $total / App::$pager['itemspage']; + $lastpage = (($numpages > intval($numpages)) ? intval($numpages) + 1 : $numpages); + + $stripped = preg_replace('/([&|\?]page=[0-9]*)/', '', $id); + $stripped = rtrim($stripped, '/'); + + $ret['partOf'] = z_root() . '/' . $stripped; + + if (App::$pager['page'] < $lastpage) { + $ret['next'] = z_root() . '/' . $stripped . '?page=' . (intval(App::$pager['page']) + 1); + } + if (App::$pager['page'] > 1) { + $ret['prev'] = z_root() . '/' . $stripped . '?page=' . (intval(App::$pager['page']) - 1); + } + } else { + $ret = [ + 'id' => z_root() . '/' . $id, + 'type' => $type, + 'totalItems' => $total, + ]; + } + + if ($extra) { + $ret = array_merge($ret, $extra); + } + + if ($items) { + $x = []; + foreach ($items as $i) { + if ($i['xchan_network'] === 'activitypub') { + $x[] = $i['xchan_hash']; + } else { + $x[] = $i['xchan_url']; + } + } + + if ($type === 'OrderedCollection') { + $ret['orderedItems'] = $x; + } else { + $ret['items'] = $x; + } + } + + return $ret; + } + + + public static function encode_simple_collection($items, $id, $type, $total = 0, $extra = null) + { + + $ret = [ + 'id' => z_root() . '/' . $id, + 'type' => $type, + 'totalItems' => $total, + ]; + + if ($extra) { + $ret = array_merge($ret, $extra); + } + + if ($items) { + if ($type === 'OrderedCollection') { + $ret['orderedItems'] = $items; + } else { + $ret['items'] = $items; + } + } + + return $ret; + } + + + public static function decode_taxonomy($item) + { + + $ret = []; + + if (array_key_exists('tag', $item) && is_array($item['tag'])) { + $ptr = $item['tag']; + if (!array_key_exists(0, $ptr)) { + $ptr = [$ptr]; + } + foreach ($ptr as $t) { + if (!is_array($t)) { + continue; + } + if (!array_key_exists('type', $t)) { + $t['type'] = 'Hashtag'; + } + if (!(array_key_exists('name', $t))) { + continue; + } + if (!(array_path_exists('icon/url', $t) || array_key_exists('href', $t))) { + continue; + } + + switch ($t['type']) { + case 'Hashtag': + $ret[] = ['ttype' => TERM_HASHTAG, 'url' => $t['href'], 'term' => escape_tags((substr($t['name'], 0, 1) === '#') ? substr($t['name'], 1) : $t['name'])]; + break; + + case 'topicalCollection': + $ret[] = ['ttype' => TERM_PCATEGORY, 'url' => $t['href'], 'term' => escape_tags($t['name'])]; + break; + + case 'Category': + $ret[] = ['ttype' => TERM_CATEGORY, 'url' => $t['href'], 'term' => escape_tags($t['name'])]; + break; + + case 'Mention': + $mention_type = substr($t['name'], 0, 1); + if ($mention_type === '!') { + $ret[] = ['ttype' => TERM_FORUM, 'url' => $t['href'], 'term' => escape_tags(substr($t['name'], 1))]; + } else { + $ret[] = ['ttype' => TERM_MENTION, 'url' => $t['href'], 'term' => escape_tags((substr($t['name'], 0, 1) === '@') ? substr($t['name'], 1) : $t['name'])]; + } + break; + + case 'Emoji': + $ret[] = ['ttype' => TERM_EMOJI, 'url' => $t['icon']['url'], 'term' => escape_tags($t['name'])]; + break; + + default: + break; + } + } + } + + return $ret; + } + + + public static function encode_taxonomy($item) + { + + $ret = []; + + if (isset($item['term']) && is_array($item['term']) && $item['term']) { + foreach ($item['term'] as $t) { + switch ($t['ttype']) { + case TERM_HASHTAG: + // An id is required so if we don't have a url in the taxonomy, ignore it and keep going. + if ($t['url']) { + $ret[] = ['id' => $t['url'], 'name' => '#' . $t['term']]; + } + break; + + case TERM_PCATEGORY: + if ($t['url'] && $t['term']) { + $ret[] = ['type' => 'topicalCollection', 'href' => $t['url'], 'name' => $t['term']]; + } + break; + + case TERM_CATEGORY: + if ($t['url'] && $t['term']) { + $ret[] = ['type' => 'Category', 'href' => $t['url'], 'name' => $t['term']]; + } + break; + + case TERM_FORUM: + $term = self::lookup_term_addr($t['url'], $t['term']); + $ret[] = ['type' => 'Mention', 'href' => $t['url'], 'name' => '!' . (($term) ? $term : $t['term'])]; + break; + + case TERM_MENTION: + $term = self::lookup_term_addr($t['url'], $t['term']); + $ret[] = ['type' => 'Mention', 'href' => $t['url'], 'name' => '@' . (($term) ? $term : $t['term'])]; + break; + + default: + break; + } + } + } + + return $ret; + } + + + public static function lookup_term_addr($url, $name) + { + + // The visible mention in our activities is always the full name. + // In the object taxonomy change this to the webfinger handle in case + // platforms expect the Mastodon form in order to generate notifications + // Try a couple of different things in case the url provided isn't the canonical id. + // If all else fails, try to match the name. + + $r = false; + + if ($url) { + $r = q( + "select xchan_addr from xchan where ( xchan_url = '%s' OR xchan_hash = '%s' ) limit 1", + dbesc($url), + dbesc($url) + ); + + if ($r) { + return $r[0]['xchan_addr']; + } + } + if ($name) { + $r = q( + "select xchan_addr from xchan where xchan_name = '%s' limit 1", + dbesc($name) + ); + if ($r) { + return $r[0]['xchan_addr']; + } + } + + return EMPTY_STR; + } + + + public static function lookup_term_url($url) + { + + // The xchan_url for mastodon is a text/html rendering. This is called from map_mentions where we need + // to convert the mention url to an ActivityPub id. If this fails for any reason, return the url we have + + $r = q( + "select * from hubloc where hubloc_id_url = '%s' or hubloc_hash = '%s' limit 1", + dbesc($url), + dbesc($url) + ); + + if ($r) { + if ($r[0]['hubloc_network'] === 'activitypub') { + return $r[0]['hubloc_hash']; + } + return $r[0]['hubloc_id_url']; + } + + return $url; + } + + + public static function encode_attachment($item) + { + + $ret = []; + + if (array_key_exists('attach', $item)) { + $atts = ((is_array($item['attach'])) ? $item['attach'] : json_decode($item['attach'], true)); + if ($atts) { + foreach ($atts as $att) { + if (strpos($att['type'], 'image')) { + $ret[] = ['type' => 'Image', 'url' => $att['href']]; + } else { + $ret[] = ['type' => 'Link', 'mediaType' => $att['type'], 'href' => $att['href']]; + } + } + } + } + if (array_key_exists('iconfig', $item) && is_array($item['iconfig'])) { + foreach ($item['iconfig'] as $att) { + if ($att['sharing']) { + $ret[] = ['type' => 'PropertyValue', 'name' => 'zot.' . $att['cat'] . '.' . $att['k'], 'value' => unserialise($att['v'])]; + } + } + } + + return $ret; + } + + + public static function decode_iconfig($item) + { + + $ret = []; + + if (is_array($item['attachment']) && $item['attachment']) { + $ptr = $item['attachment']; + if (!array_key_exists(0, $ptr)) { + $ptr = [$ptr]; + } + foreach ($ptr as $att) { + $entry = []; + if ($att['type'] === 'PropertyValue') { + if (array_key_exists('name', $att) && $att['name']) { + $key = explode('.', $att['name']); + if (count($key) === 3 && $key[0] === 'zot') { + $entry['cat'] = $key[1]; + $entry['k'] = $key[2]; + $entry['v'] = $att['value']; + $entry['sharing'] = '1'; + $ret[] = $entry; + } + } + } + } + } + return $ret; + } + + + public static function decode_attachment($item) + { + + $ret = []; + + if (array_key_exists('attachment', $item) && is_array($item['attachment'])) { + $ptr = $item['attachment']; + if (!array_key_exists(0, $ptr)) { + $ptr = [$ptr]; + } + foreach ($ptr as $att) { + $entry = []; + if (array_key_exists('href', $att) && $att['href']) { + $entry['href'] = $att['href']; + } elseif (array_key_exists('url', $att) && $att['url']) { + $entry['href'] = $att['url']; + } + if (array_key_exists('mediaType', $att) && $att['mediaType']) { + $entry['type'] = $att['mediaType']; + } elseif (array_key_exists('type', $att) && $att['type'] === 'Image') { + $entry['type'] = 'image/jpeg'; + } + if (array_key_exists('name', $att) && $att['name']) { + $entry['name'] = html2plain(purify_html($att['name']), 256); + } + if ($entry) { + $ret[] = $entry; + } + } + } elseif (is_string($item['attachment'])) { + btlogger('not an array: ' . $item['attachment']); + } + + return $ret; + } + + + // the $recurse flag encodes the original non-deleted object of a deleted activity + + public static function encode_activity($i, $activitypub = false, $recurse = false) + { + + $ret = []; + $reply = false; + + if (intval($i['item_deleted']) && (!$recurse)) { + $is_response = ActivityStreams::is_response_activity($i['verb']); + + if ($is_response) { + $ret['type'] = 'Undo'; + $fragment = '#undo'; + } else { + $ret['type'] = 'Delete'; + $fragment = '#delete'; + } + + $ret['id'] = str_replace('/item/', '/activity/', $i['mid']) . $fragment; + $actor = self::encode_person($i['author'], false); + if ($actor) { + $ret['actor'] = $actor; + } else { + return []; + } + + $obj = (($is_response) ? self::encode_activity($i, $activitypub, true) : self::encode_item($i, $activitypub)); + if ($obj) { + if (array_path_exists('object/id', $obj)) { + $obj['object'] = $obj['object']['id']; + } + if ($obj) { + $ret['object'] = $obj; + } + } else { + return []; + } + + $ret['to'] = [ACTIVITY_PUBLIC_INBOX]; + return $ret; + } + + $ret['type'] = self::activity_mapper($i['verb']); + + if (strpos($i['mid'], z_root() . '/item/') !== false) { + $ret['id'] = str_replace('/item/', '/activity/', $i['mid']); + } elseif (strpos($i['mid'], z_root() . '/event/') !== false) { + $ret['id'] = str_replace('/event/', '/activity/', $i['mid']); + } else { + $ret['id'] = $i['mid']; + } + + if ($i['title']) { + $ret['name'] = $i['title']; + } + + if ($i['summary']) { + $ret['summary'] = bbcode($i['summary'], ['export' => true]); + } + + if ($ret['type'] === 'Announce') { + $tmp = $i['body']; + $ret['content'] = bbcode($tmp, ['export' => true]); + $ret['source'] = [ + 'content' => $i['body'], + 'mediaType' => 'text/x-multicode' + ]; + if ($i['summary']) { + $ret['source']['summary'] = $i['summary']; + } + } + + $ret['published'] = datetime_convert('UTC', 'UTC', $i['created'], ATOM_TIME); + if ($i['created'] !== $i['edited']) { + $ret['updated'] = datetime_convert('UTC', 'UTC', $i['edited'], ATOM_TIME); + if ($ret['type'] === 'Create') { + $ret['type'] = 'Update'; + } + } + if ($i['app']) { + $ret['generator'] = ['type' => 'Application', 'name' => $i['app']]; + } + if ($i['location'] || $i['coord']) { + $ret['location'] = ['type' => 'Place']; + if ($i['location']) { + $ret['location']['name'] = $i['location']; + } + if ($i['coord']) { + $l = explode(' ', $i['coord']); + $ret['location']['latitude'] = $l[0]; + $ret['location']['longitude'] = $l[1]; + } + } + + if ($i['mid'] !== $i['parent_mid']) { + $reply = true; + + // inReplyTo needs to be set in the activity for followup actions (Like, Dislike, Announce, etc.), + // but *not* for comments and RSVPs, where it should only be present in the object + + if (!in_array($ret['type'], ['Create', 'Update', 'Accept', 'Reject', 'TentativeAccept', 'TentativeReject'])) { + $ret['inReplyTo'] = $i['thr_parent']; + $cnv = get_iconfig($i['parent'], 'activitypub', 'context'); + if (!$cnv) { + $cnv = get_iconfig($i['parent'], 'ostatus', 'conversation'); + } + if (!$cnv) { + $cnv = $ret['parent_mid']; + } + } + } + + if (!(isset($cnv) && $cnv)) { + $cnv = get_iconfig($i, 'activitypub', 'context'); + if (!$cnv) { + $cnv = get_iconfig($i, 'ostatus', 'conversation'); + } + if (!$cnv) { + $cnv = $i['parent_mid']; + } + } + if (isset($cnv) && $cnv) { + if (strpos($cnv, z_root()) === 0) { + $cnv = str_replace(['/item/', '/activity/'], ['/conversation/', '/conversation/'], $cnv); + } + $ret['context'] = $cnv; + $ret['conversation'] = $cnv; + } + + if (intval($i['item_private']) === 2) { + $ret['directMessage'] = true; + } + + $actor = self::encode_person($i['author'], false); + if ($actor) { + $ret['actor'] = $actor; + } else { + return []; + } + + + $replyto = unserialise($i['replyto']); + if ($replyto) { + $ret['replyTo'] = $replyto; + } + + + if (!isset($ret['url'])) { + $urls = []; + if (intval($i['item_wall'])) { + $locs = self::nomadic_locations($i); + if ($locs) { + foreach ($locs as $l) { + if (strpos($ret['id'], $l['hubloc_url']) !== false) { + continue; + } + $urls[] = [ + 'type' => 'Link', + 'href' => str_replace(z_root(), $l['hubloc_url'], $ret['id']), + 'rel' => 'alternate', + 'mediaType' => 'text/html' + ]; + $urls[] = [ + 'type' => 'Link', + 'href' => str_replace(z_root(), $l['hubloc_url'], $ret['id']), + 'rel' => 'alternate', + 'mediaType' => 'application/activity+json' + ]; + $urls[] = [ + 'type' => 'Link', + 'href' => str_replace(z_root(), $l['hubloc_url'], $ret['id']), + 'rel' => 'alternate', + 'mediaType' => 'application/x-zot+json' + ]; + $urls[] = [ + 'type' => 'Link', + 'href' => str_replace(z_root(), $l['hubloc_url'], $ret['id']), + 'rel' => 'alternate', + 'mediaType' => 'application/x-nomad+json' + ]; + } + } + } + if ($urls) { + $curr[] = [ + 'type' => 'Link', + 'href' => $ret['id'], + 'rel' => 'alternate', + 'mediaType' => 'text/html' + ]; + $ret['url'] = array_merge($curr, $urls); + } else { + $ret['url'] = $ret['id']; + } + } + + + if ($i['obj']) { + if (is_string($i['obj'])) { + $tmp = json_decode($i['obj'], true); + if ($tmp !== null) { + $i['obj'] = $tmp; + } + } + $obj = self::encode_object($i['obj']); + if ($obj) { + $ret['object'] = $obj; + } else { + return []; + } + } else { + $obj = self::encode_item($i, $activitypub); + if ($obj) { + $ret['object'] = $obj; + } else { + return []; + } + } + + if ($i['target']) { + if (is_string($i['target'])) { + $tmp = json_decode($i['target'], true); + if ($tmp !== null) { + $i['target'] = $tmp; + } + } + $tgt = self::encode_object($i['target']); + if ($tgt) { + $ret['target'] = $tgt; + } + } + + $t = self::encode_taxonomy($i); + if ($t) { + $ret['tag'] = $t; + } + + $a = self::encode_attachment($i); + if ($a) { + $ret['attachment'] = $a; + } + + + // addressing madness + + if ($activitypub) { + $parent_i = []; + $public = (($i['item_private']) ? false : true); + $top_level = (($reply) ? false : true); + $ret['to'] = []; + $ret['cc'] = []; + + if (!$top_level) { + $recips = get_iconfig($i['parent'], 'activitypub', 'recips'); + if ($recips) { + $parent_i['to'] = $recips['to']; + $parent_i['cc'] = $recips['cc']; + } + } + + if ($public) { + $ret['to'] = [ACTIVITY_PUBLIC_INBOX]; + if (isset($parent_i['to']) && is_array($parent_i['to'])) { + $ret['to'] = array_values(array_unique(array_merge($ret['to'], $parent_i['to']))); + } + if ($i['item_origin']) { + $ret['cc'] = [z_root() . '/followers/' . substr($i['author']['xchan_addr'], 0, strpos($i['author']['xchan_addr'], '@'))]; + } + if (isset($parent_i['cc']) && is_array($parent_i['cc'])) { + $ret['cc'] = array_values(array_unique(array_merge($ret['cc'], $parent_i['cc']))); + } + } else { + // private activity + + if ($top_level) { + $ret['to'] = self::map_acl($i); + if (isset($parent_i['to']) && is_array($parent_i['to'])) { + $ret['to'] = array_values(array_unique(array_merge($ret['to'], $parent_i['to']))); + } + } else { + $ret['cc'] = self::map_acl($i); + if (isset($parent_i['cc']) && is_array($parent_i['cc'])) { + $ret['cc'] = array_values(array_unique(array_merge($ret['cc'], $parent_i['cc']))); + } + + if ($ret['tag']) { + foreach ($ret['tag'] as $mention) { + if (is_array($mention) && array_key_exists('ttype', $mention) && in_array($mention['ttype'], [TERM_FORUM, TERM_MENTION]) && array_key_exists('href', $mention) && $mention['href']) { + $h = q( + "select * from hubloc where hubloc_id_url = '%s' limit 1", + dbesc($mention['href']) + ); + if ($h) { + if ($h[0]['hubloc_network'] === 'activitypub') { + $addr = $h[0]['hubloc_hash']; + } else { + $addr = $h[0]['hubloc_id_url']; + } + if (!in_array($addr, $ret['to'])) { + $ret['to'][] = $addr; + } + } + } + } + } + + $d = q( + "select hubloc.* from hubloc left join item on hubloc_hash = owner_xchan where item.parent_mid = '%s' and item.uid = %d limit 1", + dbesc($i['parent_mid']), + intval($i['uid']) + ); + if ($d) { + if ($d[0]['hubloc_network'] === 'activitypub') { + $addr = $d[0]['hubloc_hash']; + } else { + $addr = $d[0]['hubloc_id_url']; + } + $ret['cc'][] = $addr; + } + } + } + + $mentions = self::map_mentions($i); + if (count($mentions) > 0) { + if (!$ret['to']) { + $ret['to'] = $mentions; + } else { + $ret['to'] = array_values(array_unique(array_merge($ret['to'], $mentions))); + } + } + } + + $cc = []; + if ($ret['cc'] && is_array($ret['cc'])) { + foreach ($ret['cc'] as $e) { + if (!is_array($ret['to'])) { + $cc[] = $e; + } elseif (!in_array($e, $ret['to'])) { + $cc[] = $e; + } + } + } + $ret['cc'] = $cc; + + return $ret; + } + + + public static function nomadic_locations($item) + { + $synchubs = []; + $h = q( + "select hubloc.*, site.site_crypto from hubloc left join site on site_url = hubloc_url + where hubloc_hash = '%s' and hubloc_network in ('zot6','nomad') and hubloc_deleted = 0", + dbesc($item['author_xchan']) + ); + + if (!$h) { + return []; + } + + foreach ($h as $x) { + $y = q( + "select site_dead from site where site_url = '%s' limit 1", + dbesc($x['hubloc_url']) + ); + + if ((!$y) || intval($y[0]['site_dead']) === 0) { + $synchubs[] = $x; + } + } + + return $synchubs; + } + + + public static function encode_item($i, $activitypub = false) + { + + $ret = []; + $reply = false; + $is_directmessage = false; + + $bbopts = (($activitypub) ? 'activitypub' : 'export'); + + $objtype = self::activity_obj_mapper($i['obj_type']); + + if (intval($i['item_deleted'])) { + $ret['type'] = 'Tombstone'; + $ret['formerType'] = $objtype; + $ret['id'] = $i['mid']; + $ret['to'] = [ACTIVITY_PUBLIC_INBOX]; + return $ret; + } + + if (isset($i['obj']) && $i['obj']) { + if (is_string($i['obj'])) { + $tmp = json_decode($i['obj'], true); + if ($tmp !== null) { + $i['obj'] = $tmp; + } + } + $ret = $i['obj']; + if (is_string($ret)) { + return $ret; + } + } + + + $ret['type'] = $objtype; + + if ($objtype === 'Question') { + if ($i['obj']) { + if (is_array($i['obj'])) { + $ret = $i['obj']; + } else { + $ret = json_decode($i['obj'], true); + } + + if (array_path_exists('actor/id', $ret)) { + $ret['actor'] = $ret['actor']['id']; + } + } + } + + + $images = false; + $has_images = preg_match_all('/\[[zi]mg(.*?)\](.*?)\[/ism', $i['body'], $images, PREG_SET_ORDER); + + $ret['id'] = $i['mid']; + +// $token = IConfig::get($i,'ocap','relay'); +// if ($token) { +// if (defined('USE_BEARCAPS')) { +// $ret['id'] = 'bear:?u=' . $ret['id'] . '&t=' . $token; +// } +// else { +// $ret['id'] = $ret['id'] . '?token=' . $token; +// } +// } + + $ret['published'] = datetime_convert('UTC', 'UTC', $i['created'], ATOM_TIME); + if ($i['created'] !== $i['edited']) { + $ret['updated'] = datetime_convert('UTC', 'UTC', $i['edited'], ATOM_TIME); + } + if ($i['expires'] > NULL_DATE) { + $ret['expires'] = datetime_convert('UTC', 'UTC', $i['expires'], ATOM_TIME); + } + if ($i['app']) { + $ret['generator'] = ['type' => 'Application', 'name' => $i['app']]; + } + if ($i['location'] || $i['coord']) { + $ret['location'] = ['type' => 'Place']; + if ($i['location']) { + $ret['location']['name'] = $i['location']; + } + if ($i['coord']) { + $l = explode(' ', $i['coord']); + $ret['location']['latitude'] = $l[0]; + $ret['location']['longitude'] = $l[1]; + } + } + + if (intval($i['item_wall']) && $i['mid'] === $i['parent_mid']) { + $ret['commentPolicy'] = $i['comment_policy']; + } + + if (intval($i['item_private']) === 2) { + $ret['directMessage'] = true; + } + + if (intval($i['item_nocomment'])) { + if ($ret['commentPolicy']) { + $ret['commentPolicy'] .= ' '; + } + $ret['commentPolicy'] .= 'until=' . datetime_convert('UTC', 'UTC', $i['created'], ATOM_TIME); + } elseif (array_key_exists('comments_closed', $i) && $i['comments_closed'] !== EMPTY_STR && $i['comments_closed'] > NULL_DATE) { + if ($ret['commentPolicy']) { + $ret['commentPolicy'] .= ' '; + } + $ret['commentPolicy'] .= 'until=' . datetime_convert('UTC', 'UTC', $i['comments_closed'], ATOM_TIME); + } + + $ret['attributedTo'] = ((in_array($i['author']['xchan_network'],['zot6','nomad'])) ? $i['author']['xchan_url'] : $i['author']['xchan_hash']); + + if ($i['mid'] !== $i['parent_mid']) { + $ret['inReplyTo'] = $i['thr_parent']; + $cnv = get_iconfig($i['parent'], 'activitypub', 'context'); + if (!$cnv) { + $cnv = get_iconfig($i['parent'], 'ostatus', 'conversation'); + } + if (!$cnv) { + $cnv = $ret['parent_mid']; + } + + $reply = true; + + if ($i['item_private']) { + $d = q( + "select xchan_url, xchan_addr, xchan_name from item left join xchan on xchan_hash = author_xchan where id = %d limit 1", + intval($i['parent']) + ); + if ($d) { + $recips = get_iconfig($i['parent'], 'activitypub', 'recips'); + + if (is_array($recips) && in_array($i['author']['xchan_url'], $recips['to'])) { + $reply_url = $d[0]['xchan_url']; + $is_directmessage = true; + } else { + $reply_url = z_root() . '/followers/' . substr($i['author']['xchan_addr'], 0, strpos($i['author']['xchan_addr'], '@')); + } + $reply_addr = (($d[0]['xchan_addr']) ? $d[0]['xchan_addr'] : $d[0]['xchan_name']); + } + } + } + if (!isset($cnv)) { + $cnv = get_iconfig($i, 'activitypub', 'context'); + if (!$cnv) { + $cnv = get_iconfig($i, 'ostatus', 'conversation'); + } + if (!$cnv) { + $cnv = $i['parent_mid']; + } + } + if (isset($cnv) && $cnv) { + if (strpos($cnv, z_root()) === 0) { + $cnv = str_replace(['/item/', '/activity/'], ['/conversation/', '/conversation/'], $cnv); + } + $ret['context'] = $cnv; + $ret['conversation'] = $cnv; + } + + // provide ocap access token for private media. + // set this for descendants even if the current item is not private + // because it may have been relayed from a private item. + + $token = get_iconfig($i, 'ocap', 'relay'); + if ($token && $has_images) { + for ($n = 0; $n < count($images); $n++) { + $match = $images[$n]; + if (strpos($match[1], '=http') === 0 && strpos($match[1], '/photo/' !== false)) { + $i['body'] = str_replace($match[1], $match[1] . '?token=' . $token, $i['body']); + $images[$n][2] = substr($match[1], 1) . '?token=' . $token; + } elseif (strpos($match[2], z_root() . '/photo/') !== false) { + $i['body'] = str_replace($match[2], $match[2] . '?token=' . $token, $i['body']); + $images[$n][2] = $match[2] . '?token=' . $token; + } + } + } + + if ($i['title']) { + $ret['name'] = $i['title']; + } + + if (in_array($i['mimetype'], [ 'text/bbcode', 'text/x-multicode' ])) { + if ($i['summary']) { + $ret['summary'] = bbcode($i['summary'], [$bbopts => true]); + } + $opts = [$bbopts => true]; + $ret['content'] = bbcode($i['body'], $opts); + $ret['source'] = ['content' => $i['body'], 'mediaType' => 'text/x-multicode']; + if (isset($ret['summary'])) { + $ret['source']['summary'] = $i['summary']; + } + } else { + $ret['mediaType'] = $i['mimetype']; + $ret['content'] = $i['body']; + } + + if (!(isset($ret['actor']) || isset($ret['attributedTo']))) { + $actor = self::encode_person($i['author'], false); + if ($actor) { + $ret['actor'] = $actor; + } else { + return []; + } + } + + $replyto = unserialise($i['replyto']); + if ($replyto) { + $ret['replyTo'] = $replyto; + } + + if (!isset($ret['url'])) { + $urls = []; + if (intval($i['item_wall'])) { + $locs = self::nomadic_locations($i); + if ($locs) { + foreach ($locs as $l) { + if (strpos($i['mid'], $l['hubloc_url']) !== false) { + continue; + } + $urls[] = [ + 'type' => 'Link', + 'href' => str_replace(z_root(), $l['hubloc_url'], $ret['id']), + 'rel' => 'alternate', + 'mediaType' => 'text/html' + ]; + $urls[] = [ + 'type' => 'Link', + 'href' => str_replace(z_root(), $l['hubloc_url'], $ret['id']), + 'rel' => 'alternate', + 'mediaType' => 'application/activity+json' + ]; + $urls[] = [ + 'type' => 'Link', + 'href' => str_replace(z_root(), $l['hubloc_url'], $ret['id']), + 'rel' => 'alternate', + 'mediaType' => 'application/x-nomad+json' + ]; + } + } + } + if ($urls) { + $curr[] = [ + 'type' => 'Link', + 'href' => $ret['id'], + 'rel' => 'alternate', + 'mediaType' => 'text/html' + ]; + $ret['url'] = array_merge($curr, $urls); + } else { + $ret['url'] = $ret['id']; + } + } + + $t = self::encode_taxonomy($i); + if ($t) { + $ret['tag'] = $t; + } + + $a = self::encode_attachment($i); + if ($a) { + $ret['attachment'] = $a; + } + + + if ($activitypub && $has_images && $ret['type'] === 'Note') { + foreach ($images as $match) { + $img = []; + // handle Friendica/Hubzilla style img links with [img=$url]$alttext[/img] + if (strpos($match[1], '=http') === 0) { + $img[] = ['type' => 'Image', 'url' => substr($match[1], 1), 'name' => $match[2]]; + } // preferred mechanism for adding alt text + elseif (strpos($match[1], 'alt=') !== false) { + $txt = str_replace('"', '"', $match[1]); + $txt = substr($match[1], strpos($match[1], 'alt="') + 5, -1); + $img[] = ['type' => 'Image', 'url' => $match[2], 'name' => $txt]; + } else { + $img[] = ['type' => 'Image', 'url' => $match[2]]; + } + + if (!$ret['attachment']) { + $ret['attachment'] = []; + } + $already_added = false; + if ($img) { + for ($pc = 0; $pc < count($ret['attachment']); $pc++) { + // caution: image attachments use url and links use href, and our own links will be 'attach' links based on the image href + // We could alternatively supply the correct attachment info when item is saved, but by replacing here we will pick up + // any "per-post" or manual changes to the image alt-text before sending. + + if ((isset($ret['attachment'][$pc]['href']) && strpos($img[0]['url'], str_replace('/attach/', '/photo/', $ret['attachment'][$pc]['href'])) !== false) || (isset($ret['attachment'][$pc]['url']) && $ret['attachment'][$pc]['url'] === $img[0]['url'])) { + // if it's already there, replace it with our alt-text aware version + $ret['attachment'][$pc] = $img[0]; + $already_added = true; + } + } + if (!$already_added) { + // add it + $ret['attachment'] = array_merge($img, $ret['attachment']); + } + } + } + } + + // addressing madness + + if ($activitypub) { + $parent_i = []; + $ret['to'] = []; + $ret['cc'] = []; + + $public = (($i['item_private']) ? false : true); + $top_level = (($i['mid'] === $i['parent_mid']) ? true : false); + + if (!$top_level) { + if (intval($i['parent'])) { + $recips = get_iconfig($i['parent'], 'activitypub', 'recips'); + } else { + // if we are encoding this item for storage there won't be a parent. + $p = q( + "select parent from item where parent_mid = '%s' and uid = %d", + dbesc($i['parent_mid']), + intval($i['uid']) + ); + if ($p) { + $recips = get_iconfig($p[0]['parent'], 'activitypub', 'recips'); + } + } + if ($recips) { + $parent_i['to'] = $recips['to']; + $parent_i['cc'] = $recips['cc']; + } + } + + + if ($public) { + $ret['to'] = [ACTIVITY_PUBLIC_INBOX]; + if (isset($parent_i['to']) && is_array($parent_i['to'])) { + $ret['to'] = array_values(array_unique(array_merge($ret['to'], $parent_i['to']))); + } + if ($i['item_origin']) { + $ret['cc'] = [z_root() . '/followers/' . substr($i['author']['xchan_addr'], 0, strpos($i['author']['xchan_addr'], '@'))]; + } + if (isset($parent_i['cc']) && is_array($parent_i['cc'])) { + $ret['cc'] = array_values(array_unique(array_merge($ret['cc'], $parent_i['cc']))); + } + } else { + // private activity + + if ($top_level) { + $ret['to'] = self::map_acl($i); + if (isset($parent_i['to']) && is_array($parent_i['to'])) { + $ret['to'] = array_values(array_unique(array_merge($ret['to'], $parent_i['to']))); + } + } else { + $ret['cc'] = self::map_acl($i); + if (isset($parent_i['cc']) && is_array($parent_i['cc'])) { + $ret['cc'] = array_values(array_unique(array_merge($ret['cc'], $parent_i['cc']))); + } + if ($ret['tag']) { + foreach ($ret['tag'] as $mention) { + if (is_array($mention) && array_key_exists('ttype', $mention) && in_array($mention['ttype'], [TERM_FORUM, TERM_MENTION]) && array_key_exists('href', $mention) && $mention['href']) { + $h = q( + "select * from hubloc where hubloc_id_url = '%s' or hubloc_hash = '%s' limit 1", + dbesc($mention['href']), + dbesc($mention['href']) + ); + if ($h) { + if ($h[0]['hubloc_network'] === 'activitypub') { + $addr = $h[0]['hubloc_hash']; + } else { + $addr = $h[0]['hubloc_id_url']; + } + if (!in_array($addr, $ret['to'])) { + $ret['to'][] = $addr; + } + } + } + } + } + + + $d = q( + "select hubloc.* from hubloc left join item on hubloc_hash = owner_xchan where item.parent_mid = '%s' and item.uid = %d limit 1", + dbesc($i['parent_mid']), + intval($i['uid']) + ); + + if ($d) { + if ($d[0]['hubloc_network'] === 'activitypub') { + $addr = $d[0]['hubloc_hash']; + } else { + $addr = $d[0]['hubloc_id_url']; + } + $ret['cc'][] = $addr; + } + } + } + + $mentions = self::map_mentions($i); + if (count($mentions) > 0) { + if (!$ret['to']) { + $ret['to'] = $mentions; + } else { + $ret['to'] = array_values(array_unique(array_merge($ret['to'], $mentions))); + } + } + } + + // remove any duplicates from 'cc' that are present in 'to' + // as this may indicate that mentions changed the audience from secondary to primary + + $cc = []; + if ($ret['cc'] && is_array($ret['cc'])) { + foreach ($ret['cc'] as $e) { + if (!is_array($ret['to'])) { + $cc[] = $e; + } elseif (!in_array($e, $ret['to'])) { + $cc[] = $e; + } + } + } + $ret['cc'] = $cc; + + return $ret; + } + + + // Returns an array of URLS for any mention tags found in the item array $i. + + public static function map_mentions($i) + { + if (!(array_key_exists('term', $i) && is_array($i['term']))) { + return []; + } + + $list = []; + + foreach ($i['term'] as $t) { + if (!(array_key_exists('url', $t) && $t['url'])) { + continue; + } + if (array_key_exists('ttype', $t) && $t['ttype'] == TERM_MENTION) { + $url = self::lookup_term_url($t['url']); + $list[] = (($url) ? $url : $t['url']); + } + } + + return $list; + } + + // Returns an array of all recipients targeted by private item array $i. + + public static function map_acl($i) + { + $ret = []; + + if (!$i['item_private']) { + return $ret; + } + + if ($i['mid'] !== $i['parent_mid']) { + $i = q( + "select * from item where parent_mid = '%s' and uid = %d", + dbesc($i['parent_mid']), + intval($i['uid']) + ); + if ($i) { + $i = array_shift($i); + } + } + if ($i['allow_gid']) { + $tmp = expand_acl($i['allow_gid']); + if ($tmp) { + foreach ($tmp as $t) { + $ret[] = z_root() . '/lists/' . $t; + } + } + } + + if ($i['allow_cid']) { + $tmp = expand_acl($i['allow_cid']); + $list = stringify_array($tmp, true); + if ($list) { + $details = q("select hubloc_id_url, hubloc_hash, hubloc_network from hubloc where hubloc_hash in (" . $list . ") "); + if ($details) { + foreach ($details as $d) { + if ($d['hubloc_network'] === 'activitypub') { + $ret[] = $d['hubloc_hash']; + } else { + $ret[] = $d['hubloc_id_url']; + } + } + } + } + } + + $x = get_iconfig($i['id'], 'activitypub', 'recips'); + if ($x) { + foreach (['to', 'cc'] as $k) { + if (isset($x[$k])) { + if (is_string($x[$k])) { + $ret[] = $x[$k]; + } else { + $ret = array_merge($ret, $x[$k]); + } + } + } + } + + return array_values(array_unique($ret)); + } + + + public static function encode_person($p, $extended = true, $activitypub = false) + { + + $ret = []; + + if (!$p['xchan_url']) { + return $ret; + } + + if (!$extended) { + return $p['xchan_url']; + } + + $c = ((array_key_exists('channel_id', $p)) ? $p : Channel::from_hash($p['xchan_hash'])); + + $ret['type'] = 'Person'; + $auto_follow = false; + + if ($c) { + $role = PConfig::Get($c['channel_id'], 'system', 'permissions_role'); + if (strpos($role, 'forum') !== false) { + $ret['type'] = 'Group'; + } + $auto_follow = intval(PConfig::Get($c['channel_id'],'system','autoperms')); + } + + if ($c) { + $ret['id'] = Channel::url($c); + } else { + $ret['id'] = ((strpos($p['xchan_hash'], 'http') === 0) ? $p['xchan_hash'] : $p['xchan_url']); + } + if ($p['xchan_addr'] && strpos($p['xchan_addr'], '@')) { + $ret['preferredUsername'] = substr($p['xchan_addr'], 0, strpos($p['xchan_addr'], '@')); + } + $ret['name'] = $p['xchan_name']; + $ret['updated'] = datetime_convert('UTC', 'UTC', $p['xchan_name_date'], ATOM_TIME); + $ret['icon'] = [ + 'type' => 'Image', + 'mediaType' => (($p['xchan_photo_mimetype']) ? $p['xchan_photo_mimetype'] : 'image/png'), + 'updated' => datetime_convert('UTC', 'UTC', $p['xchan_photo_date'], ATOM_TIME), + 'url' => $p['xchan_photo_l'], + 'height' => 300, + 'width' => 300, + ]; + $ret['url'] = $p['xchan_url']; + if (isset($p['channel_location']) && $p['channel_location']) { + $ret['location'] = ['type' => 'Place', 'name' => $p['channel_location']]; + } + + $ret['tag'] = [['type' => 'PropertyValue', 'name' => 'Protocol', 'value' => 'zot6']]; + $ret['tag'][] = ['type' => 'PropertyValue', 'name' => 'Protocol', 'value' => 'nomad']; + + if ($activitypub && get_config('system', 'activitypub', ACTIVITYPUB_ENABLED)) { + if ($c) { + if (get_pconfig($c['channel_id'], 'system', 'activitypub', ACTIVITYPUB_ENABLED)) { + $ret['inbox'] = z_root() . '/inbox/' . $c['channel_address']; + $ret['tag'][] = ['type' => 'PropertyValue', 'name' => 'Protocol', 'value' => 'activitypub']; + } else { + $ret['inbox'] = null; + } + + $ret['outbox'] = z_root() . '/outbox/' . $c['channel_address']; + $ret['followers'] = z_root() . '/followers/' . $c['channel_address']; + $ret['following'] = z_root() . '/following/' . $c['channel_address']; + + $ret['endpoints'] = [ + 'sharedInbox' => z_root() . '/inbox', + 'oauthRegistrationEndpoint' => z_root() . '/api/client/register', + 'oauthAuthorizationEndpoint' => z_root() . '/authorize', + 'oauthTokenEndpoint' => z_root() . '/token' + ]; + + $ret['discoverable'] = ((1 - intval($p['xchan_hidden'])) ? true : false); + $ret['publicKey'] = [ + 'id' => $p['xchan_url'] . '?operation=getkey', + 'owner' => $p['xchan_url'], + 'signatureAlgorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + 'publicKeyPem' => $p['xchan_pubkey'] + ]; + + $ret['manuallyApprovesFollowers'] = (($auto_follow) ? false : true); + if ($ret['type'] === 'Group') { + $ret['capabilities'] = ['acceptsJoins' => true]; + } + // map other nomadic identities linked with this channel + + $locations = []; + $locs = Libzot::encode_locations($c); + if ($locs) { + foreach ($locs as $loc) { + if ($loc['url'] !== z_root()) { + $locations[] = $loc['id_url']; + } + } + } + + if ($locations) { + if (count($locations) === 1) { + $locations = array_shift($locations); + } + $ret['copiedTo'] = $locations; + $ret['alsoKnownAs'] = $locations; + } + + $cp = Channel::get_cover_photo($c['channel_id'], 'array'); + if ($cp) { + $ret['image'] = [ + 'type' => 'Image', + 'mediaType' => $cp['type'], + 'url' => $cp['url'] + ]; + } + // only fill in profile information if the profile is publicly visible + if (perm_is_allowed($c['channel_id'], EMPTY_STR, 'view_profile')) { + $dp = q( + "select * from profile where uid = %d and is_default = 1", + intval($c['channel_id']) + ); + if ($dp) { + if ($dp[0]['about']) { + $ret['summary'] = bbcode($dp[0]['about'], ['export' => true]); + } + foreach ( + ['pdesc', 'address', 'locality', 'region', 'postal_code', 'country_name', + 'hometown', 'gender', 'marital', 'sexual', 'politic', 'religion', 'pronouns', + 'homepage', 'contact', 'dob'] as $k + ) { + if ($dp[0][$k]) { + $key = $k; + if ($key === 'pdesc') { + $key = 'description'; + } + if ($key == 'politic') { + $key = 'political'; + } + if ($key === 'dob') { + $key = 'birthday'; + } + $ret['attachment'][] = ['type' => 'PropertyValue', 'name' => $key, 'value' => $dp[0][$k]]; + } + } + if ($dp[0]['keywords']) { + $kw = explode(' ', $dp[0]['keywords']); + if ($kw) { + foreach ($kw as $k) { + $k = trim($k); + $k = trim($k, '#,'); + $ret['tag'][] = ['id' => z_root() . '/search?tag=' . urlencode($k), 'name' => '#' . urlencode($k)]; + } + } + } + } + } + } else { + $collections = get_xconfig($p['xchan_hash'], 'activitypub', 'collections', []); + if ($collections) { + $ret = array_merge($ret, $collections); + } else { + $ret['inbox'] = null; + $ret['outbox'] = null; + } + } + } else { + $ret['publicKey'] = [ + 'id' => $p['xchan_url'], + 'owner' => $p['xchan_url'], + 'publicKeyPem' => $p['xchan_pubkey'] + ]; + } + + $arr = ['xchan' => $p, 'encoded' => $ret, 'activitypub' => $activitypub]; + Hook::call('encode_person', $arr); + $ret = $arr['encoded']; + + + return $ret; + } + + + public static function encode_site() + { + + + $sys = Channel::get_system(); + + // encode the sys channel information and over-ride with site + // information + $ret = self::encode_person($sys, true, true); + + $ret['type'] = ((Channel::is_group($sys['channel_id'])) ? 'Group' : 'Service'); + $ret['id'] = z_root(); + $ret['alsoKnownAs'] = z_root() . '/channel/sys'; + $auto_follow = false; + + $ret['preferredUsername'] = 'sys'; + $ret['name'] = System::get_site_name(); + + $ret['icon'] = [ + 'type' => 'Image', + 'url' => System::get_site_icon(), + ]; + + $ret['generator'] = ['type' => 'Application', 'name' => System::get_project_name()]; + + $ret['url'] = z_root(); + + $ret['manuallyApprovesFollowers'] = ((get_config('system', 'allowed_sites')) ? true : false); + + $cp = Channel::get_cover_photo($sys['channel_id'], 'array'); + if ($cp) { + $ret['image'] = [ + 'type' => 'Image', + 'mediaType' => $cp['type'], + 'url' => $cp['url'] + ]; + } + + $ret['summary'] = bbcode(get_config('system', 'siteinfo', ''), ['export' => true]); + $ret['source'] = [ + 'mediaType' => 'text/x-multicode', + 'summary' => get_config('system', 'siteinfo', '') + ]; + + $ret['publicKey'] = [ + 'id' => z_root() . '?operation=getkey', + 'owner' => z_root(), + 'publicKeyPem' => get_config('system', 'pubkey') + ]; + + return $ret; + } + + + public static function activity_mapper($verb) + { + + if (strpos($verb, '/') === false) { + return $verb; + } + + $acts = [ + 'http://activitystrea.ms/schema/1.0/post' => 'Create', + 'http://activitystrea.ms/schema/1.0/share' => 'Announce', + 'http://activitystrea.ms/schema/1.0/update' => 'Update', + 'http://activitystrea.ms/schema/1.0/like' => 'Like', + 'http://activitystrea.ms/schema/1.0/favorite' => 'Like', + 'http://purl.org/zot/activity/dislike' => 'Dislike', + 'http://activitystrea.ms/schema/1.0/tag' => 'Add', + 'http://activitystrea.ms/schema/1.0/follow' => 'Follow', + 'http://activitystrea.ms/schema/1.0/unfollow' => 'Ignore', + ]; + + Hook::call('activity_mapper', $acts); + + if (array_key_exists($verb, $acts) && $acts[$verb]) { + return $acts[$verb]; + } + + // Reactions will just map to normal activities + + if (strpos($verb, ACTIVITY_REACT) !== false) { + return 'Create'; + } + if (strpos($verb, ACTIVITY_MOOD) !== false) { + return 'Create'; + } + + if (strpos($verb, ACTIVITY_POKE) !== false) { + return 'Activity'; + } + + // We should return false, however this will trigger an uncaught exception and crash + // the delivery system if encountered by the JSON-LDSignature library + + logger('Unmapped activity: ' . $verb); + return 'Create'; + // return false; + } + + + public static function activity_obj_mapper($obj) + { + + + $objs = [ + 'http://activitystrea.ms/schema/1.0/note' => 'Note', + 'http://activitystrea.ms/schema/1.0/comment' => 'Note', + 'http://activitystrea.ms/schema/1.0/person' => 'Person', + 'http://purl.org/zot/activity/profile' => 'Profile', + 'http://activitystrea.ms/schema/1.0/photo' => 'Image', + 'http://activitystrea.ms/schema/1.0/profile-photo' => 'Icon', + 'http://activitystrea.ms/schema/1.0/event' => 'Event', + 'http://activitystrea.ms/schema/1.0/wiki' => 'Document', + 'http://purl.org/zot/activity/location' => 'Place', + 'http://purl.org/zot/activity/chessgame' => 'Game', + 'http://purl.org/zot/activity/tagterm' => 'zot:Tag', + 'http://purl.org/zot/activity/thing' => 'Object', + 'http://purl.org/zot/activity/file' => 'zot:File', + 'http://purl.org/zot/activity/mood' => 'zot:Mood', + + ]; + + Hook::call('activity_obj_mapper', $objs); + + if ($obj === 'Answer') { + return 'Note'; + } + + if (strpos($obj, '/') === false) { + return $obj; + } + + if (array_key_exists($obj, $objs)) { + return $objs[$obj]; + } + + logger('Unmapped activity object: ' . $obj); + return 'Note'; + + // return false; + } + + + public static function follow($channel, $act) + { + + $contact = null; + $their_follow_id = null; + + if (intval($channel['channel_system'])) { + // The system channel ignores all follow requests + return; + } + + /* + * + * if $act->type === 'Follow', actor is now following $channel + * if $act->type === 'Accept', actor has approved a follow request from $channel + * + */ + + $person_obj = $act->actor; + + if (in_array($act->type, ['Follow', 'Invite', 'Join'])) { + $their_follow_id = $act->id; + } elseif ($act->type === 'Accept') { + $my_follow_id = z_root() . '/follow/' . $contact['id']; + } + + if (is_array($person_obj)) { + // store their xchan and hubloc + + self::actor_store($person_obj['id'], $person_obj); + + // Find any existing abook record + + $r = q( + "select * from abook left join xchan on abook_xchan = xchan_hash where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($person_obj['id']), + intval($channel['channel_id']) + ); + if ($r) { + $contact = $r[0]; + } + } + + $x = PermissionRoles::role_perms('social'); + $p = Permissions::FilledPerms($x['perms_connect']); + + // add tag_deliver permissions to remote groups + + if (is_array($person_obj) && $person_obj['type'] === 'Group') { + $p['tag_deliver'] = 1; + } + + $their_perms = Permissions::serialise($p); + + + if ($contact && $contact['abook_id']) { + // A relationship of some form already exists on this site. + + switch ($act->type) { + case 'Follow': + case 'Invite': + case 'Join': + // A second Follow request, but we haven't approved the first one + + if ($contact['abook_pending']) { + return; + } + + // We've already approved them or followed them first + // Send an Accept back to them + + set_abconfig($channel['channel_id'], $person_obj['id'], 'activitypub', 'their_follow_id', $their_follow_id); + set_abconfig($channel['channel_id'], $person_obj['id'], 'activitypub', 'their_follow_type', $act->type); + Run::Summon(['Notifier', 'permissions_accept', $contact['abook_id']]); + return; + + case 'Accept': + // They accepted our Follow request - set default permissions + + set_abconfig($channel['channel_id'], $contact['abook_xchan'], 'system', 'their_perms', $their_perms); + + $abook_instance = $contact['abook_instance']; + + if (strpos($abook_instance, z_root()) === false) { + if ($abook_instance) { + $abook_instance .= ','; + } + $abook_instance .= z_root(); + + $r = q( + "update abook set abook_instance = '%s', abook_not_here = 0 + where abook_id = %d and abook_channel = %d", + dbesc($abook_instance), + intval($contact['abook_id']), + intval($channel['channel_id']) + ); + } + + return; + default: + return; + } + } + + // No previous relationship exists. + + if ($act->type === 'Accept') { + // This should not happen unless we deleted the connection before it was accepted. + return; + } + + // From here on out we assume a Follow activity to somebody we have no existing relationship with + + set_abconfig($channel['channel_id'], $person_obj['id'], 'activitypub', 'their_follow_id', $their_follow_id); + set_abconfig($channel['channel_id'], $person_obj['id'], 'activitypub', 'their_follow_type', $act->type); + + // The xchan should have been created by actor_store() above + + $r = q( + "select * from xchan where xchan_hash = '%s' and xchan_network = 'activitypub' limit 1", + dbesc($person_obj['id']) + ); + + if (!$r) { + logger('xchan not found for ' . $person_obj['id']); + return; + } + $ret = $r[0]; + + $blocked = LibBlock::fetch($channel['channel_id'], BLOCKTYPE_SERVER); + if ($blocked) { + foreach ($blocked as $b) { + if (strpos($ret['xchan_url'], $b['block_entity']) !== false) { + logger('siteblock - follower denied'); + return; + } + } + } + if (LibBlock::fetch_by_entity($channel['channel_id'], $ret['xchan_hash'])) { + logger('actorblock - follower denied'); + return; + } + + $p = Permissions::connect_perms($channel['channel_id']); + $my_perms = Permissions::serialise($p['perms']); + $automatic = $p['automatic']; + + $closeness = PConfig::Get($channel['channel_id'], 'system', 'new_abook_closeness', 80); + + $r = abook_store_lowlevel( + [ + 'abook_account' => intval($channel['channel_account_id']), + 'abook_channel' => intval($channel['channel_id']), + 'abook_xchan' => $ret['xchan_hash'], + 'abook_closeness' => intval($closeness), + 'abook_created' => datetime_convert(), + 'abook_updated' => datetime_convert(), + 'abook_connected' => datetime_convert(), + 'abook_dob' => NULL_DATE, + 'abook_pending' => intval(($automatic) ? 0 : 1), + 'abook_instance' => z_root() + ] + ); + + if ($my_perms) { + AbConfig::Set($channel['channel_id'], $ret['xchan_hash'], 'system', 'my_perms', $my_perms); + } + + if ($their_perms) { + AbConfig::Set($channel['channel_id'], $ret['xchan_hash'], 'system', 'their_perms', $their_perms); + } + + + if ($r) { + logger("New ActivityPub follower for {$channel['channel_name']}"); + + $new_connection = q( + "select * from abook left join xchan on abook_xchan = xchan_hash left join hubloc on hubloc_hash = xchan_hash where abook_channel = %d and abook_xchan = '%s' order by abook_created desc limit 1", + intval($channel['channel_id']), + dbesc($ret['xchan_hash']) + ); + if ($new_connection) { + Enotify::submit( + [ + 'type' => NOTIFY_INTRO, + 'from_xchan' => $ret['xchan_hash'], + 'to_xchan' => $channel['channel_hash'], + 'link' => z_root() . '/connedit/' . $new_connection[0]['abook_id'], + ] + ); + + if ($my_perms && $automatic) { + // send an Accept for this Follow activity + Run::Summon(['Notifier', 'permissions_accept', $new_connection[0]['abook_id']]); + // Send back a Follow notification to them + Run::Summon(['Notifier', 'permissions_create', $new_connection[0]['abook_id']]); + } + + $clone = []; + foreach ($new_connection[0] as $k => $v) { + if (strpos($k, 'abook_') === 0) { + $clone[$k] = $v; + } + } + unset($clone['abook_id']); + unset($clone['abook_account']); + unset($clone['abook_channel']); + + $abconfig = load_abconfig($channel['channel_id'], $clone['abook_xchan']); + + if ($abconfig) { + $clone['abconfig'] = $abconfig; + } + Libsync::build_sync_packet($channel['channel_id'], ['abook' => [$clone]]); + } + } + + + /* If there is a default group for this channel and permissions are automatic, add this member to it */ + + if ($channel['channel_default_group'] && $automatic) { + $g = AccessList::rec_byhash($channel['channel_id'], $channel['channel_default_group']); + if ($g) { + AccessList::member_add($channel['channel_id'], '', $ret['xchan_hash'], $g['id']); + } + } + + return; + } + + + public static function unfollow($channel, $act) + { + + $contact = null; + + /* actor is unfollowing $channel */ + + $person_obj = $act->actor; + + if (is_array($person_obj)) { + $r = q( + "select * from abook left join xchan on abook_xchan = xchan_hash where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($person_obj['id']), + intval($channel['channel_id']) + ); + if ($r) { + // remove all permissions they provided + del_abconfig($channel['channel_id'], $r[0]['xchan_hash'], 'system', 'their_perms', EMPTY_STR); + } + } + + return; + } + + + public static function actor_store($url, $person_obj, $force = false) + { + + if (!is_array($person_obj)) { + return; + } + +// logger('person_obj: ' . print_r($person_obj,true)); + + if (array_key_exists('movedTo', $person_obj) && $person_obj['movedTo'] && !is_array($person_obj['movedTo'])) { + $tgt = self::fetch($person_obj['movedTo']); + if (is_array($tgt)) { + self::actor_store($person_obj['movedTo'], $tgt); + ActivityPub::move($person_obj['id'], $tgt); + } + return; + } + + + $ap_hubloc = null; + + $hublocs = self::get_actor_hublocs($url); + if ($hublocs) { + foreach ($hublocs as $hub) { + if ($hub['hubloc_network'] === 'activitypub') { + $ap_hubloc = $hub; + } + if (in_array($hub['hubloc_network'],['zot6','nomad'])) { + Libzot::update_cached_hubloc($hub); + } + } + } + + if ($ap_hubloc) { + // we already have a stored record. Determine if it needs updating. + if ($ap_hubloc['hubloc_updated'] < datetime_convert('UTC', 'UTC', ' now - ' . self::$ACTOR_CACHE_DAYS . ' days') || $force) { + $person_obj = self::fetch($url); + // ensure we received something + if (!is_array($person_obj)) { + return; + } + } else { + return; + } + } + + + if (isset($person_obj['id'])) { + $url = $person_obj['id']; + } + + if (!$url) { + return; + } + + // store the actor record in XConfig + XConfig::Set($url, 'system', 'actor_record', $person_obj); + + $name = escape_tags($person_obj['name']); + if (!$name) { + $name = escape_tags($person_obj['preferredUsername']); + } + if (!$name) { + $name = escape_tags(t('Unknown')); + } + + $username = escape_tags($person_obj['preferredUsername']); + $h = parse_url($url); + if ($h && $h['host']) { + $username .= '@' . $h['host']; + } + + if ($person_obj['icon']) { + if (is_array($person_obj['icon'])) { + if (array_key_exists('url', $person_obj['icon'])) { + $icon = $person_obj['icon']['url']; + } else { + if (is_string($person_obj['icon'][0])) { + $icon = $person_obj['icon'][0]; + } elseif (array_key_exists('url', $person_obj['icon'][0])) { + $icon = $person_obj['icon'][0]['url']; + } + } + } else { + $icon = $person_obj['icon']; + } + } + if (!(isset($icon) && $icon)) { + $icon = z_root() . '/' . Channel::get_default_profile_photo(); + } + + $cover_photo = false; + + if (isset($person_obj['image'])) { + if (is_string($person_obj['image'])) { + $cover_photo = $person_obj['image']; + } + if (isset($person_obj['image']['url'])) { + $cover_photo = $person_obj['image']['url']; + } + } + + $hidden = false; + if (array_key_exists('discoverable', $person_obj) && (!intval($person_obj['discoverable']))) { + $hidden = true; + } + + $links = false; + $profile = false; + + if (is_array($person_obj['url'])) { + if (!array_key_exists(0, $person_obj['url'])) { + $links = [$person_obj['url']]; + } else { + $links = $person_obj['url']; + } + } + + if (is_array($links) && $links) { + foreach ($links as $link) { + if (is_array($link) && array_key_exists('mediaType', $link) && $link['mediaType'] === 'text/html') { + $profile = $link['href']; + } + } + if (!$profile) { + $profile = $links[0]['href']; + } + } elseif (isset($person_obj['url']) && is_string($person_obj['url'])) { + $profile = $person_obj['url']; + } + + if (!$profile) { + $profile = $url; + } + + $inbox = ((array_key_exists('inbox', $person_obj)) ? $person_obj['inbox'] : null); + + // either an invalid identity or a cached entry of some kind which didn't get caught above + + if ((!$inbox) || strpos($inbox, z_root()) !== false) { + return; + } + + + $collections = []; + + if ($inbox) { + $collections['inbox'] = $inbox; + if (array_key_exists('outbox', $person_obj) && is_string($person_obj['outbox'])) { + $collections['outbox'] = $person_obj['outbox']; + } + if (array_key_exists('followers', $person_obj) && is_string($person_obj['followers'])) { + $collections['followers'] = $person_obj['followers']; + } + if (array_key_exists('following', $person_obj) && is_string($person_obj['following'])) { + $collections['following'] = $person_obj['following']; + } + if (array_key_exists('wall', $person_obj) && is_string($person_obj['wall'])) { + $collections['wall'] = $person_obj['wall']; + } + if (array_path_exists('endpoints/sharedInbox', $person_obj) && is_string($person_obj['endpoints']['sharedInbox'])) { + $collections['sharedInbox'] = $person_obj['endpoints']['sharedInbox']; + } + } + + if (isset($person_obj['publicKey']['publicKeyPem'])) { + if ($person_obj['id'] === $person_obj['publicKey']['owner']) { + $pubkey = $person_obj['publicKey']['publicKeyPem']; + if (strstr($pubkey, 'RSA ')) { + $pubkey = Keyutils::rsatopem($pubkey); + } + } + } + + $keywords = []; + + if (isset($person_obj['tag']) && is_array($person_obj['tag'])) { + foreach ($person_obj['tag'] as $t) { + if (is_array($t) && isset($t['type']) && $t['type'] === 'Hashtag') { + if (isset($t['name'])) { + $tag = escape_tags((substr($t['name'], 0, 1) === '#') ? substr($t['name'], 1) : $t['name']); + if ($tag) { + $keywords[] = $tag; + } + } + } + if (is_array($t) && isset($t['type']) && $t['type'] === 'PropertyValue') { + if (isset($t['name']) && isset($t['value']) && $t['name'] === 'Protocol') { + self::update_protocols($url, trim($t['value'])); + } + } + } + } + + $xchan_type = self::get_xchan_type($person_obj['type']); + $about = ((isset($person_obj['summary'])) ? html2bbcode(purify_html($person_obj['summary'])) : EMPTY_STR); + + $p = q( + "select * from xchan where xchan_url = '%s' and xchan_network in ('zot6','nomad') limit 1", + dbesc($url) + ); + if ($p) { + set_xconfig($url, 'system', 'protocols', 'nomad,zot6,activitypub'); + } + + // there is no standard way to represent an 'instance actor' but this will at least subdue the multiple + // pages of Mastodon and Pleroma instance actors in the directory. + // @TODO - (2021-08-27) remove this if they provide a non-person xchan_type + // once extended xchan_type directory filtering is implemented. + $censored = ((strpos($profile, 'instance_actor') || strpos($profile, '/internal/fetch')) ? 1 : 0); + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($url) + ); + if (!$r) { + // create a new record + $r = xchan_store_lowlevel( + [ + 'xchan_hash' => $url, + 'xchan_guid' => $url, + 'xchan_pubkey' => $pubkey, + 'xchan_addr' => ((strpos($username, '@')) ? $username : ''), + 'xchan_url' => $profile, + 'xchan_name' => $name, + 'xchan_hidden' => intval($hidden), + 'xchan_updated' => datetime_convert(), + 'xchan_name_date' => datetime_convert(), + 'xchan_network' => 'activitypub', + 'xchan_type' => $xchan_type, + 'xchan_photo_date' => datetime_convert('UTC', 'UTC', '1968-01-01'), + 'xchan_photo_l' => z_root() . '/' . Channel::get_default_profile_photo(), + 'xchan_photo_m' => z_root() . '/' . Channel::get_default_profile_photo(80), + 'xchan_photo_s' => z_root() . '/' . Channel::get_default_profile_photo(48), + 'xchan_photo_mimetype' => 'image/png', + 'xchan_censored' => $censored + + ] + ); + } else { + // Record exists. Cache existing records for a set number of days + // then refetch to catch updated profile photos, names, etc. + + if ($r[0]['xchan_name_date'] >= datetime_convert('UTC', 'UTC', 'now - ' . self::$ACTOR_CACHE_DAYS . ' days') && (!$force)) { + return; + } + + // update existing record + $u = q( + "update xchan set xchan_updated = '%s', xchan_name = '%s', xchan_pubkey = '%s', xchan_network = '%s', xchan_name_date = '%s', xchan_hidden = %d, xchan_type = %d, xchan_censored = %d where xchan_hash = '%s'", + dbesc(datetime_convert()), + dbesc($name), + dbesc($pubkey), + dbesc('activitypub'), + dbesc(datetime_convert()), + intval($hidden), + intval($xchan_type), + intval($censored), + dbesc($url) + ); + + if (strpos($username, '@') && ($r[0]['xchan_addr'] !== $username)) { + $r = q( + "update xchan set xchan_addr = '%s' where xchan_hash = '%s'", + dbesc($username), + dbesc($url) + ); + } + } + + if ($cover_photo) { + set_xconfig($url, 'system', 'cover_photo', $cover_photo); + } + + + $m = parse_url($url); + if ($m['scheme'] && $m['host']) { + $site_url = $m['scheme'] . '://' . $m['host']; + $ni = Nodeinfo::fetch($site_url); + if ($ni && is_array($ni)) { + $software = ((array_path_exists('software/name', $ni)) ? $ni['software']['name'] : ''); + $version = ((array_path_exists('software/version', $ni)) ? $ni['software']['version'] : ''); + $register = $ni['openRegistrations']; + + $site = q( + "select * from site where site_url = '%s'", + dbesc($site_url) + ); + if ($site) { + q( + "update site set site_project = '%s', site_update = '%s', site_version = '%s' where site_url = '%s'", + dbesc($software), + dbesc(datetime_convert()), + dbesc($version), + dbesc($site_url) + ); + // it may have been saved originally as an unknown type, but we now know what it is + if (intval($site[0]['site_type']) === SITE_TYPE_UNKNOWN) { + q( + "update site set site_type = %d where site_url = '%s'", + intval(SITE_TYPE_NOTZOT), + dbesc($site_url) + ); + } + } else { + site_store_lowlevel( + [ + 'site_url' => $site_url, + 'site_update' => datetime_convert(), + 'site_dead' => 0, + 'site_type' => SITE_TYPE_NOTZOT, + 'site_project' => $software, + 'site_version' => $version, + 'site_access' => (($register) ? ACCESS_FREE : ACCESS_PRIVATE), + 'site_register' => (($register) ? REGISTER_OPEN : REGISTER_CLOSED) + ] + ); + } + } + } + + Libzotdir::import_directory_profile($url, ['about' => $about, 'keywords' => $keywords, 'dob' => '0000-00-00'], null, 0, true); + + if ($collections) { + set_xconfig($url, 'activitypub', 'collections', $collections); + } + + $h = q( + "select * from hubloc where hubloc_hash = '%s' limit 1", + dbesc($url) + ); + + + $m = parse_url($url); + if ($m) { + $hostname = $m['host']; + $baseurl = $m['scheme'] . '://' . $m['host'] . ((isset($m['port']) && intval($m['port'])) ? ':' . $m['port'] : ''); + } + + if (!$h) { + $r = hubloc_store_lowlevel( + [ + 'hubloc_guid' => $url, + 'hubloc_hash' => $url, + 'hubloc_id_url' => $profile, + 'hubloc_addr' => ((strpos($username, '@')) ? $username : ''), + 'hubloc_network' => 'activitypub', + 'hubloc_url' => $baseurl, + 'hubloc_host' => $hostname, + 'hubloc_callback' => $inbox, + 'hubloc_updated' => datetime_convert(), + 'hubloc_primary' => 1 + ] + ); + } else { + if (strpos($username, '@') && ($h[0]['hubloc_addr'] !== $username)) { + $r = q( + "update hubloc set hubloc_addr = '%s' where hubloc_hash = '%s'", + dbesc($username), + dbesc($url) + ); + } + if ($inbox !== $h[0]['hubloc_callback']) { + $r = q( + "update hubloc set hubloc_callback = '%s' where hubloc_hash = '%s'", + dbesc($inbox), + dbesc($url) + ); + } + if ($profile !== $h[0]['hubloc_id_url']) { + $r = q( + "update hubloc set hubloc_id_url = '%s' where hubloc_hash = '%s'", + dbesc($profile), + dbesc($url) + ); + } + $r = q( + "update hubloc set hubloc_updated = '%s' where hubloc_hash = '%s'", + dbesc(datetime_convert()), + dbesc($url) + ); + } + + if (!$icon) { + $icon = z_root() . '/' . Channel::get_default_profile_photo(300); + } + + // We store all ActivityPub actors we can resolve. Some of them may be able to communicate over Zot6. Find them. + // Only probe if it looks like it looks something like a zot6 URL as there isn't anything in the actor record which we can reliably use for this purpose + // and adding zot discovery urls to the actor record will cause federation to fail with the 20-30 projects which don't accept arrays in the url field. + + if (strpos($url, '/channel/') !== false) { + $zx = q( + "select * from hubloc where hubloc_id_url = '%s' and hubloc_network in ('zot6','nomad')", + dbesc($url) + ); + if (($username) && strpos($username, '@') && (!$zx)) { + Run::Summon(['Gprobe', bin2hex($username)]); + } + } + + Run::Summon(['Xchan_photo', bin2hex($icon), bin2hex($url)]); + } + + public static function update_protocols($xchan, $str) + { + $existing = explode(',', get_xconfig($xchan, 'system', 'protocols', EMPTY_STR)); + if (!in_array($str, $existing)) { + $existing[] = $str; + set_xconfig($xchan, 'system', 'protocols', implode(',', $existing)); + } + } + + + public static function drop($channel, $observer, $act) + { + $r = q( + "select * from item where mid = '%s' and uid = %d limit 1", + dbesc((is_array($act->obj)) ? $act->obj['id'] : $act->obj), + intval($channel['channel_id']) + ); + + if (!$r) { + return; + } + + if (in_array($observer, [$r[0]['author_xchan'], $r[0]['owner_xchan']])) { + drop_item($r[0]['id'], false); + } elseif (in_array($act->actor['id'], [$r[0]['author_xchan'], $r[0]['owner_xchan']])) { + drop_item($r[0]['id'], false); + } + } + + + // sort function width decreasing + + public static function vid_sort($a, $b) + { + if ($a['width'] === $b['width']) { + return 0; + } + return (($a['width'] > $b['width']) ? -1 : 1); + } + + public static function share_bb($obj) + { + // @fixme - error check and set defaults + + $name = urlencode($obj['actor']['name']); + $profile = $obj['actor']['id']; + $photo = $obj['icon']['url']; + + $s = "\r\n[share author='" . $name . + "' profile='" . $profile . + "' avatar='" . $photo . + "' link='" . $act->obj['id'] . + "' auth='" . ((is_matrix_url($act->obj['id'])) ? 'true' : 'false') . + "' posted='" . $act->obj['published'] . + "' message_id='" . $act->obj['id'] . + "']"; + + return $s; + } + + public static function get_actor_bbmention($id) + { + + $x = q( + "select * from hubloc left join xchan on hubloc_hash = xchan_hash where hubloc_hash = '%s' or hubloc_id_url = '%s' limit 1", + dbesc($id), + dbesc($id) + ); + + if ($x) { + // a name starting with a left paren can trick the markdown parser into creating a link so insert a zero-width space + if (substr($x[0]['xchan_name'], 0, 1) === '(') { + $x[0]['xchan_name'] = htmlspecialchars_decode('​', ENT_QUOTES) . $x[0]['xchan_name']; + } + + return sprintf('@[zrl=%s]%s[/zrl]', $x[0]['xchan_url'], $x[0]['xchan_name']); + } + return '@{' . $id . '}'; + } + + public static function update_poll($item, $post) + { + + logger('updating poll'); + + $multi = false; + $mid = $post['mid']; + $content = $post['title']; + + if (!$item) { + return false; + } + + $o = json_decode($item['obj'], true); + if ($o && array_key_exists('anyOf', $o)) { + $multi = true; + } + + $r = q( + "select mid, title from item where parent_mid = '%s' and author_xchan = '%s' and mid != parent_mid ", + dbesc($item['mid']), + dbesc($post['author_xchan']) + ); + + // prevent any duplicate votes by same author for oneOf and duplicate votes with same author and same answer for anyOf + + if ($r) { + if ($multi) { + foreach ($r as $rv) { + if ($rv['title'] === $content && $rv['mid'] !== $mid) { + return false; + } + } + } else { + foreach ($r as $rv) { + if ($rv['mid'] !== $mid) { + return false; + } + } + } + } + + $answer_found = false; + $found = false; + if ($multi) { + for ($c = 0; $c < count($o['anyOf']); $c++) { + if ($o['anyOf'][$c]['name'] === $content) { + $answer_found = true; + if (is_array($o['anyOf'][$c]['replies'])) { + foreach ($o['anyOf'][$c]['replies'] as $reply) { + if (is_array($reply) && array_key_exists('id', $reply) && $reply['id'] === $mid) { + $found = true; + } + } + } + + if (!$found) { + $o['anyOf'][$c]['replies']['totalItems']++; + $o['anyOf'][$c]['replies']['items'][] = ['id' => $mid, 'type' => 'Note']; + } + } + } + } else { + for ($c = 0; $c < count($o['oneOf']); $c++) { + if ($o['oneOf'][$c]['name'] === $content) { + $answer_found = true; + if (is_array($o['oneOf'][$c]['replies'])) { + foreach ($o['oneOf'][$c]['replies'] as $reply) { + if (is_array($reply) && array_key_exists('id', $reply) && $reply['id'] === $mid) { + $found = true; + } + } + } + + if (!$found) { + $o['oneOf'][$c]['replies']['totalItems']++; + $o['oneOf'][$c]['replies']['items'][] = ['id' => $mid, 'type' => 'Note']; + } + } + } + } + + if ($item['comments_closed'] > NULL_DATE) { + if ($item['comments_closed'] > datetime_convert()) { + $o['closed'] = datetime_convert('UTC', 'UTC', $item['comments_closed'], ATOM_TIME); + // set this to force an update + $answer_found = true; + } + } + + logger('updated_poll: ' . print_r($o, true), LOGGER_DATA); + if ($answer_found && !$found) { + $x = q( + "update item set obj = '%s', edited = '%s' where id = %d", + dbesc(json_encode($o)), + dbesc(datetime_convert()), + intval($item['id']) + ); + Run::Summon(['Notifier', 'wall-new', $item['id']]); + return true; + } + + return false; + } + + + public static function decode_note($act, $cacheable = false) + { + + $response_activity = false; + $poll_handled = false; + + $s = []; + + if (is_array($act->obj)) { + $binary = false; + $markdown = false; + + if (array_key_exists('mediaType', $act->obj) && $act->obj['mediaType'] !== 'text/html') { + if ($act->obj['mediaType'] === 'text/markdown') { + $markdown = true; + } else { + $s['mimetype'] = escape_tags($act->obj['mediaType']); + $binary = true; + } + } + + $content = self::get_content($act->obj, $binary); + + if ($cacheable) { + // Zot6 activities will all be rendered from bbcode source in order to generate dynamic content. + // If the activity came from ActivityPub (hence $cacheable is set), use the HTML rendering + // and discard the bbcode source since it is unlikely that it is compatible with our implementation. + // Friendica for example. + + unset($content['bbcode']); + } + + // handle markdown conversion inline (peertube) + + if ($markdown) { + foreach (['summary', 'content'] as $t) { + $content[$t] = Markdown::to_bbcode($content[$t], true, ['preserve_lf' => true]); + } + } + } + + // These activities should have been handled separately in the Inbox module and should not be turned into posts + + if ( + in_array($act->type, ['Follow', 'Accept', 'Reject', 'Create', 'Update']) && is_array($act->obj) && array_key_exists('type', $act->obj) + && ($act->obj['type'] === 'Follow' || ActivityStreams::is_an_actor($act->obj['type'])) + ) { + return false; + } + + // Within our family of projects, Follow/Unfollow of a thread is an internal activity which should not be transmitted, + // hence if we receive it - ignore or reject it. + // This may have to be revisited if AP projects start using Follow for objects other than actors. + + if (in_array($act->type, [ACTIVITY_FOLLOW, ACTIVITY_IGNORE])) { + return false; + } + + // Do not proceed further if there is no actor. + + if (!isset($act->actor['id'])) { + logger('No actor!'); + return false; + } + + $s['owner_xchan'] = $act->actor['id']; + $s['author_xchan'] = $act->actor['id']; + + // ensure we store the original actor + self::actor_store($act->actor['id'], $act->actor); + + $s['mid'] = ((is_array($act->obj) && isset($act->obj['id'])) ? $act->obj['id'] : $act->obj); + + if (!$s['mid']) { + return false; + } + + $s['parent_mid'] = $act->parent_id; + + if (array_key_exists('published', $act->data) && $act->data['published']) { + $s['created'] = datetime_convert('UTC', 'UTC', $act->data['published']); + } elseif (array_key_exists('published', $act->obj) && $act->obj['published']) { + $s['created'] = datetime_convert('UTC', 'UTC', $act->obj['published']); + } + if (array_key_exists('updated', $act->data) && $act->data['updated']) { + $s['edited'] = datetime_convert('UTC', 'UTC', $act->data['updated']); + } elseif (array_key_exists('updated', $act->obj) && $act->obj['updated']) { + $s['edited'] = datetime_convert('UTC', 'UTC', $act->obj['updated']); + } + if (array_key_exists('expires', $act->data) && $act->data['expires']) { + $s['expires'] = datetime_convert('UTC', 'UTC', $act->data['expires']); + } elseif (array_key_exists('expires', $act->obj) && $act->obj['expires']) { + $s['expires'] = datetime_convert('UTC', 'UTC', $act->obj['expires']); + } + + if ($act->type === 'Invite' && is_array($act->obj) && array_key_exists('type', $act->obj) && $act->obj['type'] === 'Event') { + $s['mid'] = $s['parent_mid'] = $act->id; + } + + if (isset($act->replyto) && !empty($act->replyto)) { + if (is_array($act->replyto) && isset($act->replyto['id'])) { + $s['replyto'] = $act->replyto['id']; + } else { + $s['replyto'] = $act->replyto; + } + } + + if (ActivityStreams::is_response_activity($act->type)) { + $response_activity = true; + + $s['mid'] = $act->id; + $s['parent_mid'] = ((is_array($act->obj) && isset($act->obj['id'])) ? $act->obj['id'] : $act->obj); + + + // over-ride the object timestamp with the activity + + if (isset($act->data['published']) && $act->data['published']) { + $s['created'] = datetime_convert('UTC', 'UTC', $act->data['published']); + } + + if (isset($act->data['updated']) && $act->data['updated']) { + $s['edited'] = datetime_convert('UTC', 'UTC', $act->data['updated']); + } + + $obj_actor = ((isset($act->obj['actor'])) ? $act->obj['actor'] : $act->get_actor('attributedTo', $act->obj)); + + // Actor records themselves do not have an actor or attributedTo + if ((!$obj_actor) && isset($act->obj['type']) && Activitystreams::is_an_actor($act->obj['type'])) { + $obj_actor = $act->obj; + } + + // We already check for admin blocks of third-party objects when fetching them explicitly. + // Repeat here just in case the entire object was supplied inline and did not require fetching + + if ($obj_actor && array_key_exists('id', $obj_actor)) { + $m = parse_url($obj_actor['id']); + if ($m && $m['scheme'] && $m['host']) { + if (!check_siteallowed($m['scheme'] . '://' . $m['host'])) { + return; + } + } + if (!check_channelallowed($obj_actor['id'])) { + return; + } + } + + // if the object is an actor, it is not really a response activity, so reset it to a top level post + + if (ActivityStreams::is_an_actor($act->obj['type'])) { + $s['parent_mid'] = $s['mid']; + } + + + // ensure we store the original actor of the associated (parent) object + self::actor_store($obj_actor['id'], $obj_actor); + + $mention = self::get_actor_bbmention($obj_actor['id']); + + $quoted_content = '[quote]' . $content['content'] . '[/quote]'; + + if ($act->type === 'Like') { + $content['content'] = sprintf(t('Likes %1$s\'s %2$s'), $mention, ((ActivityStreams::is_an_actor($act->obj['type'])) ? t('Profile') : $act->obj['type'])) . EOL . EOL . $quoted_content; + } + if ($act->type === 'Dislike') { + $content['content'] = sprintf(t('Doesn\'t like %1$s\'s %2$s'), $mention, ((ActivityStreams::is_an_actor($act->obj['type'])) ? t('Profile') : $act->obj['type'])) . EOL . EOL . $quoted_content; + } + + // handle event RSVPs + if (($act->obj['type'] === 'Event') || ($act->obj['type'] === 'Invite' && array_path_exists('object/type', $act->obj) && $act->obj['object']['type'] === 'Event')) { + if ($act->type === 'Accept') { + $content['content'] = sprintf(t('Will attend %s\'s event'), $mention) . EOL . EOL . $quoted_content; + } + if ($act->type === 'Reject') { + $content['content'] = sprintf(t('Will not attend %s\'s event'), $mention) . EOL . EOL . $quoted_content; + } + if ($act->type === 'TentativeAccept') { + $content['content'] = sprintf(t('May attend %s\'s event'), $mention) . EOL . EOL . $quoted_content; + } + if ($act->type === 'TentativeReject') { + $content['content'] = sprintf(t('May not attend %s\'s event'), $mention) . EOL . EOL . $quoted_content; + } + } + + if ($act->type === 'Announce') { + $content['content'] = sprintf(t('🔁 Repeated %1$s\'s %2$s'), $mention, ((ActivityStreams::is_an_actor($act->obj['type'])) ? t('Profile') : $act->obj['type'])); + } + + if ($act->type === 'emojiReaction') { + // Hubzilla reactions + $content['content'] = (($act->tgt && $act->tgt['type'] === 'Image') ? '[img=32x32]' . $act->tgt['url'] . '[/img]' : '&#x' . $act->tgt['name'] . ';'); + } + + if (in_array($act->type, ['EmojiReaction', 'EmojiReact'])) { + // Pleroma reactions + $t = trim(self::get_textfield($act->data, 'content')); + $e = Emoji\is_single_emoji($t) || mb_strlen($t) === 1; + if ($e) { + $content['content'] = $t; + } + } + + $a = self::decode_taxonomy($act->data); + if ($a) { + $s['term'] = $a; + foreach ($a as $b) { + if ($b['ttype'] === TERM_EMOJI) { + $s['summary'] = str_replace($b['term'], '[img=16x16]' . $b['url'] . '[/img]', $s['summary']); + + // @todo - @bug + // The emoji reference in the body might be inside a code block. In that case we shouldn't replace it. + // Currently we do. + + $s['body'] = str_replace($b['term'], '[img=16x16]' . $b['url'] . '[/img]', $s['body']); + } + } + } + + $a = self::decode_attachment($act->data); + if ($a) { + $s['attach'] = $a; + } + + $a = self::decode_iconfig($act->data); + if ($a) { + $s['iconfig'] = $a; + } + } + + $s['comment_policy'] = 'authenticated'; + + if ($s['mid'] === $s['parent_mid']) { + // it is a parent node - decode the comment policy info if present + if (isset($act->obj['commentPolicy'])) { + $until = strpos($act->obj['commentPolicy'], 'until='); + if ($until !== false) { + $s['comments_closed'] = datetime_convert('UTC', 'UTC', substr($act->obj['commentPolicy'], $until + 6)); + if ($s['comments_closed'] < datetime_convert()) { + $s['nocomment'] = true; + } + } + $remainder = substr($act->obj['commentPolicy'], 0, (($until) ? $until : strlen($act->obj['commentPolicy']))); + if ($remainder) { + $s['comment_policy'] = $remainder; + } + if (!(isset($item['comment_policy']) && strlen($item['comment_policy']))) { + $s['comment_policy'] = 'contacts'; + } + } + } + + if (!(array_key_exists('created', $s) && $s['created'])) { + $s['created'] = datetime_convert(); + } + if (!(array_key_exists('edited', $s) && $s['edited'])) { + $s['edited'] = $s['created']; + } + $s['title'] = (($response_activity) ? EMPTY_STR : self::bb_content($content, 'name')); + $s['summary'] = self::bb_content($content, 'summary'); + + if (array_key_exists('mimetype', $s) && (!in_array($s['mimetype'], ['text/bbcode', 'text/x-multicode']))) { + $s['body'] = $content['content']; + } else { + $s['body'] = ((self::bb_content($content, 'bbcode') && (!$response_activity)) ? self::bb_content($content, 'bbcode') : self::bb_content($content, 'content')); + } + + + // handle some of the more widely used of the numerous and varied ways of deleting something + + if (in_array($act->type, ['Delete', 'Undo', 'Tombstone'])) { + $s['item_deleted'] = 1; + } + + if ($act->type === 'Create' && $act->obj['type'] === 'Tombstone') { + $s['item_deleted'] = 1; + } + + if ($act->obj && array_key_exists('sensitive', $act->obj) && boolval($act->obj['sensitive'])) { + $s['item_nsfw'] = 1; + } + + $s['verb'] = self::activity_mapper($act->type); + + // Mastodon does not provide update timestamps when updating poll tallies which means race conditions may occur here. + if (in_array($act->type,['Create','Update']) && $act->obj['type'] === 'Question' && $s['edited'] === $s['created']) { + if (isset($act->obj['votersCount']) && intval($act->obj['votersCount'])) { + $s['edited'] = datetime_convert(); + } + } + + + $s['obj_type'] = self::activity_obj_mapper($act->obj['type']); + $s['obj'] = $act->obj; + if (is_array($s['obj']) && array_path_exists('actor/id', $s['obj'])) { + $s['obj']['actor'] = $s['obj']['actor']['id']; + } + + if (is_array($act->tgt) && $act->tgt) { + if (array_key_exists('type', $act->tgt)) { + $s['tgt_type'] = self::activity_obj_mapper($act->tgt['type']); + } + // We shouldn't need to store collection contents which could be large. We will often only require the meta-data + if (isset($s['tgt_type']) && strpos($s['tgt_type'], 'Collection') !== false) { + $s['target'] = ['id' => $act->tgt['id'], 'type' => $s['tgt_type'], 'attributedTo' => ((isset($act->tgt['attributedTo'])) ? $act->tgt['attributedTo'] : $act->tgt['actor'])]; + } + } + + $generator = $act->get_property_obj('generator'); + if ((!$generator) && (!$response_activity)) { + $generator = $act->get_property_obj('generator', $act->obj); + } + + if ( + $generator && array_key_exists('type', $generator) + && in_array($generator['type'], ['Application', 'Service']) && array_key_exists('name', $generator) + ) { + $s['app'] = escape_tags($generator['name']); + } + + $location = $act->get_property_obj('location'); + if (is_array($location) && array_key_exists('type', $location) && $location['type'] === 'Place') { + if (array_key_exists('name', $location)) { + $s['location'] = escape_tags($location['name']); + } + if (array_key_exists('content', $location)) { + $s['location'] = html2plain(purify_html($location['content']), 256); + } + + if (array_key_exists('latitude', $location) && array_key_exists('longitude', $location)) { + $s['coord'] = escape_tags($location['latitude']) . ' ' . escape_tags($location['longitude']); + } + } + + if (!$response_activity) { + $a = self::decode_taxonomy($act->obj); + if ($a) { + $s['term'] = $a; + foreach ($a as $b) { + if ($b['ttype'] === TERM_EMOJI) { + $s['summary'] = str_replace($b['term'], '[img=16x16]' . $b['url'] . '[/img]', $s['summary']); + + // @todo - @bug + // The emoji reference in the body might be inside a code block. In that case we shouldn't replace it. + // Currently we do. + + $s['body'] = str_replace($b['term'], '[img=16x16]' . $b['url'] . '[/img]', $s['body']); + } + } + } + + $a = self::decode_attachment($act->obj); + if ($a) { + $s['attach'] = $a; + } + + $a = self::decode_iconfig($act->obj); + if ($a) { + $s['iconfig'] = $a; + } + } + + // Objects that might have media attachments which aren't already provided in the content element. + // We'll check specific media objects separately. + + if (in_array($act->obj['type'], ['Article', 'Document', 'Event', 'Note', 'Page', 'Place', 'Question']) && isset($s['attach']) && $s['attach']) { + $s['body'] .= self::bb_attach($s['attach'], $s['body']); + } + + if ($act->obj['type'] === 'Question' && in_array($act->type, ['Create', 'Update'])) { + if ($act->obj['endTime']) { + $s['comments_closed'] = datetime_convert('UTC', 'UTC', $act->obj['endTime']); + } + } + + if (array_key_exists('closed', $act->obj) && $act->obj['closed']) { + $s['comments_closed'] = datetime_convert('UTC', 'UTC', $act->obj['closed']); + } + + + // we will need a hook here to extract magnet links e.g. peertube + // right now just link to the largest mp4 we find that will fit in our + // standard content region + + if (!$response_activity) { + if ($act->obj['type'] === 'Video') { + $vtypes = [ + 'video/mp4', + 'video/ogg', + 'video/webm' + ]; + + $mps = []; + $poster = null; + $ptr = null; + + // try to find a poster to display on the video element + + if (array_key_exists('icon', $act->obj)) { + if (is_array($act->obj['icon'])) { + if (array_key_exists(0, $act->obj['icon'])) { + $ptr = $act->obj['icon']; + } else { + $ptr = [$act->obj['icon']]; + } + } + if ($ptr) { + foreach ($ptr as $foo) { + if (is_array($foo) && array_key_exists('type', $foo) && $foo['type'] === 'Image' && is_string($foo['url'])) { + $poster = $foo['url']; + } + } + } + } + + $tag = (($poster) ? '[video poster="' . $poster . '"]' : '[video]'); + $ptr = null; + + if (array_key_exists('url', $act->obj)) { + if (is_array($act->obj['url'])) { + if (array_key_exists(0, $act->obj['url'])) { + $ptr = $act->obj['url']; + } else { + $ptr = [$act->obj['url']]; + } + // handle peertube's weird url link tree if we find it here + // 0 => html link, 1 => application/x-mpegURL with 'tag' set to an array of actual media links + foreach ($ptr as $idex) { + if (is_array($idex) && array_key_exists('mediaType', $idex)) { + if ($idex['mediaType'] === 'application/x-mpegURL' && isset($idex['tag']) && is_array($idex['tag'])) { + $ptr = $idex['tag']; + break; + } + } + } + foreach ($ptr as $vurl) { + if (array_key_exists('mediaType', $vurl)) { + if (in_array($vurl['mediaType'], $vtypes)) { + if (!array_key_exists('width', $vurl)) { + $vurl['width'] = 0; + } + $mps[] = $vurl; + } + } + } + } + if ($mps) { + usort($mps, [__CLASS__, 'vid_sort']); + foreach ($mps as $m) { + if (intval($m['width']) < 500 && self::media_not_in_body($m['href'], $s['body'])) { + $s['body'] .= "\n\n" . $tag . $m['href'] . '[/video]'; + break; + } + } + } elseif (is_string($act->obj['url']) && self::media_not_in_body($act->obj['url'], $s['body'])) { + $s['body'] .= "\n\n" . $tag . $act->obj['url'] . '[/video]'; + } + } + } + + if ($act->obj['type'] === 'Audio') { + $atypes = [ + 'audio/mpeg', + 'audio/ogg', + 'audio/wav' + ]; + + $ptr = null; + + if (array_key_exists('url', $act->obj)) { + if (is_array($act->obj['url'])) { + if (array_key_exists(0, $act->obj['url'])) { + $ptr = $act->obj['url']; + } else { + $ptr = [$act->obj['url']]; + } + foreach ($ptr as $vurl) { + if (isset($vurl['mediaType']) && in_array($vurl['mediaType'], $atypes) && self::media_not_in_body($vurl['href'], $s['body'])) { + $s['body'] .= "\n\n" . '[audio]' . $vurl['href'] . '[/audio]'; + break; + } + } + } elseif (is_string($act->obj['url']) && self::media_not_in_body($act->obj['url'], $s['body'])) { + $s['body'] .= "\n\n" . '[audio]' . $act->obj['url'] . '[/audio]'; + } + } // Pleroma audio scrobbler + elseif ($act->type === 'Listen' && array_key_exists('artist', $act->obj) && array_key_exists('title', $act->obj) && $s['body'] === EMPTY_STR) { + $s['body'] .= "\n\n" . sprintf('Listening to \"%1$s\" by %2$s', escape_tags($act->obj['title']), escape_tags($act->obj['artist'])); + if (isset($act->obj['album'])) { + $s['body'] .= "\n" . sprintf('(%s)', escape_tags($act->obj['album'])); + } + } + } + + if ($act->obj['type'] === 'Image' && strpos($s['body'], 'zrl=') === false) { + $ptr = null; + + if (array_key_exists('url', $act->obj)) { + if (is_array($act->obj['url'])) { + if (array_key_exists(0, $act->obj['url'])) { + $ptr = $act->obj['url']; + } else { + $ptr = [$act->obj['url']]; + } + foreach ($ptr as $vurl) { + if (is_array($vurl) && isset($vurl['href']) && strpos($s['body'], $vurl['href']) === false) { + $s['body'] .= "\n\n" . '[zmg]' . $vurl['href'] . '[/zmg]'; + break; + } + } + } elseif (is_string($act->obj['url'])) { + if (strpos($s['body'], $act->obj['url']) === false) { + $s['body'] .= "\n\n" . '[zmg]' . $act->obj['url'] . '[/zmg]'; + } + } + } + } + + + if ($act->obj['type'] === 'Page' && !$s['body']) { + $ptr = null; + $purl = EMPTY_STR; + + if (array_key_exists('url', $act->obj)) { + if (is_array($act->obj['url'])) { + if (array_key_exists(0, $act->obj['url'])) { + $ptr = $act->obj['url']; + } else { + $ptr = [$act->obj['url']]; + } + foreach ($ptr as $vurl) { + if (is_array($vurl) && array_key_exists('mediaType', $vurl) && $vurl['mediaType'] === 'text/html') { + $purl = $vurl['href']; + break; + } elseif (array_key_exists('mimeType', $vurl) && $vurl['mimeType'] === 'text/html') { + $purl = $vurl['href']; + break; + } + } + } elseif (is_string($act->obj['url'])) { + $purl = $act->obj['url']; + } + if ($purl) { + $li = z_fetch_url(z_root() . '/linkinfo?binurl=' . bin2hex($purl)); + if ($li['success'] && $li['body']) { + $s['body'] .= "\n" . $li['body']; + } else { + $s['body'] .= "\n\n" . $purl; + } + } + } + } + } + + + if (in_array($act->obj['type'], ['Note', 'Article', 'Page'])) { + $ptr = null; + + if (array_key_exists('url', $act->obj)) { + if (is_array($act->obj['url'])) { + if (array_key_exists(0, $act->obj['url'])) { + $ptr = $act->obj['url']; + } else { + $ptr = [$act->obj['url']]; + } + foreach ($ptr as $vurl) { + if (is_array($vurl) && array_key_exists('mediaType', $vurl) && $vurl['mediaType'] === 'text/html') { + $s['plink'] = $vurl['href']; + break; + } + } + } elseif (is_string($act->obj['url'])) { + $s['plink'] = $act->obj['url']; + } + } + } + + if (!(isset($s['plink']) && $s['plink'])) { + $s['plink'] = $s['mid']; + } + + // assume this is private unless specifically told otherwise. + + $s['item_private'] = 1; + + if ($act->recips && (in_array(ACTIVITY_PUBLIC_INBOX, $act->recips) || in_array('Public', $act->recips) || in_array('as:Public', $act->recips))) { + $s['item_private'] = 0; + } + + if (is_array($act->obj)) { + if (array_key_exists('directMessage', $act->obj) && intval($act->obj['directMessage'])) { + $s['item_private'] = 2; + } + } + + set_iconfig($s, 'activitypub', 'recips', $act->raw_recips); + + if (array_key_exists('directMessage', $act->data) && intval($act->data['directMessage'])) { + $s['item_private'] = 2; + } + + + set_iconfig($s, 'activitypub', 'rawmsg', $act->raw, 1); + + // Restrict html caching to ActivityPub senders. + // Zot has dynamic content and this library is used by both. + + if ($cacheable) { + if ((!array_key_exists('mimetype', $s)) || (in_array($s['mimetype'], ['text/bbcode', 'text/x-multicode']))) { + // preserve the original purified HTML content *unless* we've modified $s['body'] + // within this function (to add attachments or reaction descriptions or mention rewrites). + // This avoids/bypasses some markdown rendering issues which can occur when + // converting to our markdown-enhanced bbcode and then back to HTML again. + // Also if we do need bbcode, use the 'bbonly' flag to ignore markdown and only + // interpret bbcode; which is much less susceptible to false positives in the + // conversion regexes. + + if ($s['body'] === self::bb_content($content, 'content')) { + $s['html'] = $content['content']; + } else { + $s['html'] = bbcode($s['body'], ['bbonly' => true]); + } + } + } + + $hookinfo = [ + 'act' => $act, + 's' => $s + ]; + + Hook::call('decode_note', $hookinfo); + + $s = $hookinfo['s']; + + return $s; + } + + public static function rewrite_mentions_sub(&$s, $pref, &$obj = null) + { + + if (isset($s['term']) && is_array($s['term'])) { + foreach ($s['term'] as $tag) { + $txt = EMPTY_STR; + if (intval($tag['ttype']) === TERM_MENTION) { + // some platforms put the identity url into href rather than the profile url. Accept either form. + $x = q( + "select * from xchan where xchan_url = '%s' or xchan_hash = '%s' limit 1", + dbesc($tag['url']), + dbesc($tag['url']) + ); + if ($x) { + switch ($pref) { + case 0: + $txt = $x[0]['xchan_name']; + break; + case 1: + $txt = (($x[0]['xchan_addr']) ? $x[0]['xchan_addr'] : $x[0]['xchan_name']); + break; + case 2: + default; + if ($x[0]['xchan_addr']) { + $txt = sprintf(t('%1$s (%2$s)'), $x[0]['xchan_name'], $x[0]['xchan_addr']); + } else { + $txt = $x[0]['xchan_name']; + } + break; + } + } + } + + if ($txt) { + // the Markdown filter will get tripped up and think this is a markdown link + // if $txt begins with parens so put it behind a zero-width space + if (substr($txt, 0, 1) === '(') { + $txt = htmlspecialchars_decode('​', ENT_QUOTES) . $txt; + } + $s['body'] = preg_replace( + '/\@\[zrl\=' . preg_quote($x[0]['xchan_url'], '/') . '\](.*?)\[\/zrl\]/ism', + '@[zrl=' . $x[0]['xchan_url'] . ']' . $txt . '[/zrl]', + $s['body'] + ); + $s['body'] = preg_replace( + '/\@\[url\=' . preg_quote($x[0]['xchan_url'], '/') . '\](.*?)\[\/url\]/ism', + '@[url=' . $x[0]['xchan_url'] . ']' . $txt . '[/url]', + $s['body'] + ); + $s['body'] = preg_replace( + '/\[zrl\=' . preg_quote($x[0]['xchan_url'], '/') . '\]@(.*?)\[\/zrl\]/ism', + '@[zrl=' . $x[0]['xchan_url'] . ']' . $txt . '[/zrl]', + $s['body'] + ); + $s['body'] = preg_replace( + '/\[url\=' . preg_quote($x[0]['xchan_url'], '/') . '\]@(.*?)\[\/url\]/ism', + '@[url=' . $x[0]['xchan_url'] . ']' . $txt . '[/url]', + $s['body'] + ); + + // replace these just in case the sender (in this case Friendica) got it wrong + $s['body'] = preg_replace( + '/\@\[zrl\=' . preg_quote($x[0]['xchan_hash'], '/') . '\](.*?)\[\/zrl\]/ism', + '@[zrl=' . $x[0]['xchan_url'] . ']' . $txt . '[/zrl]', + $s['body'] + ); + $s['body'] = preg_replace( + '/\@\[url\=' . preg_quote($x[0]['xchan_hash'], '/') . '\](.*?)\[\/url\]/ism', + '@[url=' . $x[0]['xchan_url'] . ']' . $txt . '[/url]', + $s['body'] + ); + $s['body'] = preg_replace( + '/\[zrl\=' . preg_quote($x[0]['xchan_hash'], '/') . '\]@(.*?)\[\/zrl\]/ism', + '@[zrl=' . $x[0]['xchan_url'] . ']' . $txt . '[/zrl]', + $s['body'] + ); + $s['body'] = preg_replace( + '/\[url\=' . preg_quote($x[0]['xchan_hash'], '/') . '\]@(.*?)\[\/url\]/ism', + '@[url=' . $x[0]['xchan_url'] . ']' . $txt . '[/url]', + $s['body'] + ); + + if ($obj && $txt) { + if (!is_array($obj)) { + $obj = json_decode($obj, true); + } + if (array_path_exists('source/content', $obj)) { + $obj['source']['content'] = preg_replace( + '/\@\[zrl\=' . preg_quote($x[0]['xchan_url'], '/') . '\](.*?)\[\/zrl\]/ism', + '@[zrl=' . $x[0]['xchan_url'] . ']' . $txt . '[/zrl]', + $obj['source']['content'] + ); + $obj['source']['content'] = preg_replace( + '/\@\[url\=' . preg_quote($x[0]['xchan_url'], '/') . '\](.*?)\[\/url\]/ism', + '@[url=' . $x[0]['xchan_url'] . ']' . $txt . '[/url]', + $obj['source']['content'] + ); + } + $obj['content'] = preg_replace( + '/\@(.*?)\(.*?)\<\/a\>/ism', + '@$1' . $txt . '', + $obj['content'] + ); + } + } + } + } + + // $s['html'] will be populated if caching was enabled. + // This is usually the case for ActivityPub sourced content, while Zot6 content is not cached. + + if (isset($s['html']) && $s['html']) { + $s['html'] = bbcode($s['body'], ['bbonly' => true]); + } + + return; + } + + public static function rewrite_mentions(&$s) + { + // rewrite incoming mentions in accordance with system.tag_username setting + // 0 - displayname + // 1 - username + // 2 - displayname (username) + // 127 - default + + $pref = intval(PConfig::Get($s['uid'], 'system', 'tag_username', Config::Get('system', 'tag_username', false))); + + if ($pref === 127) { + return; + } + + self::rewrite_mentions_sub($s, $pref); + + + return; + } + + // $force is used when manually fetching a remote item - it assumes you are granting one-time + // permission for the selected item/conversation regardless of your relationship with the author and + // assumes that you are in fact the sender. Please do not use it for anything else. The only permission + // checking that is performed is that the author isn't blocked by the site admin. + + public static function store($channel, $observer_hash, $act, $item, $fetch_parents = true, $force = false) + { + + if ($act && $act->implied_create && !$force) { + // This is originally a S2S object with no associated activity + logger('Not storing implied create activity!'); + return; + } + + $is_system = Channel::is_system($channel['channel_id']); + $is_child_node = false; + + // Pleroma scrobbles can be really noisy and contain lots of duplicate activities. Disable them by default. + + if (($act->type === 'Listen') && ($is_system || get_pconfig($channel['channel_id'], 'system', 'allow_scrobbles', false))) { + return; + } + + // Mastodon only allows visibility in public timelines if the public inbox is listed in the 'to' field. + // They are hidden in the public timeline if the public inbox is listed in the 'cc' field. + // This is not part of the activitypub protocol - we might change this to show all public posts in pubstream at some point. + + $pubstream = ((is_array($act->obj) && array_key_exists('to', $act->obj) && is_array($act->obj['to']) && (in_array(ACTIVITY_PUBLIC_INBOX, $act->obj['to']) || in_array('Public', $act->obj['to']) || in_array('as:Public', $act->obj['to']))) ? true : false); + + // very unpleasant and imperfect way of determining a Mastodon DM + + if ($act->raw_recips && array_key_exists('to', $act->raw_recips) && is_array($act->raw_recips['to']) && count($act->raw_recips['to']) === 1 && $act->raw_recips['to'][0] === Channel::url($channel) && !$act->raw_recips['cc']) { + $item['item_private'] = 2; + } + + + if ($item['parent_mid'] && $item['parent_mid'] !== $item['mid']) { + $is_child_node = true; + } + + $allowed = false; + $reason = ['init']; + $permit_mentions = intval(PConfig::Get($channel['channel_id'], 'system', 'permit_all_mentions') && i_am_mentioned($channel, $item)); + + if ($is_child_node) { + $p = q( + "select * from item where mid = '%s' and uid = %d and item_wall = 1", + dbesc($item['parent_mid']), + intval($channel['channel_id']) + ); + if ($p) { + // set the owner to the owner of the parent + $item['owner_xchan'] = $p[0]['owner_xchan']; + + // quietly reject group comment boosts by group owner + // (usually only sent via ActivityPub so groups will work on microblog platforms) + // This catches those activities if they slipped in via a conversation fetch + + if ($p[0]['parent_mid'] !== $item['parent_mid']) { + if ($item['verb'] === 'Announce' && $item['author_xchan'] === $item['owner_xchan']) { + logger('group boost activity by group owner rejected'); + return; + } + } + + // check permissions against the author, not the sender + $allowed = perm_is_allowed($channel['channel_id'], $item['author_xchan'], 'post_comments'); + if (!$allowed) { + $reason[] = 'post_comments perm'; + } + if ((!$allowed) && $permit_mentions) { + if ($p[0]['owner_xchan'] === $channel['channel_hash']) { + $allowed = false; + $reason[] = 'ownership'; + } else { + $allowed = true; + } + } + if (absolutely_no_comments($p[0])) { + $allowed = false; + $reason[] = 'absolutely'; + } + + if (!$allowed) { + logger('rejected comment from ' . $item['author_xchan'] . ' for ' . $channel['channel_address']); + logger('rejected reason ' . print_r($reason, true)); + logger('rejected: ' . print_r($item, true), LOGGER_DATA); + // let the sender know we received their comment but we don't permit spam here. + self::send_rejection_activity($channel, $item['author_xchan'], $item); + return; + } + + if (perm_is_allowed($channel['channel_id'], $item['author_xchan'], 'moderated')) { + $item['item_blocked'] = ITEM_MODERATED; + } + } else { + // By default if we allow you to send_stream and comments and this is a comment, it is allowed. + // A side effect of this action is that if you take away send_stream permission, comments to those + // posts you previously allowed will still be accepted. It is possible but might be difficult to fix this. + + $allowed = true; + + // reject public stream comments that weren't sent by the conversation owner + // but only on remote message deliveries to our site ($fetch_parents === true) + + if ($is_system && $pubstream && $item['owner_xchan'] !== $observer_hash && !$fetch_parents) { + $allowed = false; + $reason[] = 'sender ' . $observer_hash . ' not owner ' . $item['owner_xchan']; + } + } + + if ($p && $p[0]['obj_type'] === 'Question') { + if ($item['obj_type'] === 'Note' && $item['title'] && (!$item['content'])) { + $item['obj_type'] = 'Answer'; + } + } + } else { + if (perm_is_allowed($channel['channel_id'], $observer_hash, 'send_stream') || ($is_system && $pubstream)) { + logger('allowed: permission allowed', LOGGER_DATA); + $allowed = true; + } + if ($permit_mentions) { + logger('allowed: permitted mention', LOGGER_DATA); + $allowed = true; + } + } + + if (tgroup_check($channel['channel_id'], $item) && (!$is_child_node)) { + // for forum deliveries, make sure we keep a copy of the signed original + set_iconfig($item, 'activitypub', 'rawmsg', $act->raw, 1); + logger('allowed: tgroup'); + $allowed = true; + } + + if (get_abconfig($channel['channel_id'], $observer_hash, 'system', 'block_announce', false)) { + if ($item['verb'] === 'Announce' || strpos($item['body'], '[/share]')) { + $allowed = false; + } + } + + if (intval($item['item_private']) === 2) { + if (!perm_is_allowed($channel['channel_id'], $observer_hash, 'post_mail')) { + $allowed = false; + } + } + + if ($is_system) { + if (!check_pubstream_channelallowed($observer_hash)) { + $allowed = false; + $reason[] = 'pubstream channel blocked'; + } + + // don't allow pubstream posts if the sender even has a clone on a pubstream denied site + + $h = q( + "select hubloc_url from hubloc where hubloc_hash = '%s'", + dbesc($observer_hash) + ); + if ($h) { + foreach ($h as $hub) { + if (!check_pubstream_siteallowed($hub['hubloc_url'])) { + $allowed = false; + $reason = 'pubstream site blocked'; + break; + } + } + } + if (intval($item['item_private'])) { + $allowed = false; + $reason[] = 'private item'; + } + } + + $blocked = LibBlock::fetch($channel['channel_id'], BLOCKTYPE_SERVER); + if ($blocked) { + foreach ($blocked as $b) { + if (strpos($observer_hash, $b['block_entity']) !== false) { + $allowed = false; + $reason[] = 'blocked'; + } + } + } + + if (!$allowed && !$force) { + logger('no permission: channel ' . $channel['channel_address'] . ', id = ' . $item['mid']); + logger('no permission: reason ' . print_r($reason, true)); + return; + } + + $item['aid'] = $channel['channel_account_id']; + $item['uid'] = $channel['channel_id']; + + + // Some authors may be zot6 authors in which case we want to store their nomadic identity + // instead of their ActivityPub identity + + $item['author_xchan'] = self::find_best_identity($item['author_xchan']); + $item['owner_xchan'] = self::find_best_identity($item['owner_xchan']); + + if (!($item['author_xchan'] && $item['owner_xchan'])) { + logger('owner or author missing.'); + return; + } + + if ($channel['channel_system']) { + if (!MessageFilter::evaluate($item, get_config('system', 'pubstream_incl'), get_config('system', 'pubstream_excl'))) { + logger('post is filtered'); + return; + } + } + + // fetch allow/deny lists for the sender, author, or both + // if you have them. post_is_importable() assumes true + // and only fails if there was intentional rejection + // due to this channel's filtering rules for content + // provided by either of these entities. + + $abook = q( + "select * from abook where ( abook_xchan = '%s' OR abook_xchan = '%s') and abook_channel = %d ", + dbesc($item['author_xchan']), + dbesc($item['owner_xchan']), + intval($channel['channel_id']) + ); + + + if (!post_is_importable($channel['channel_id'], $item, $abook)) { + logger('post is filtered'); + return; + } + + $maxlen = get_max_import_size(); + + if ($maxlen && mb_strlen($item['body']) > $maxlen) { + $item['body'] = mb_substr($item['body'], 0, $maxlen, 'UTF-8'); + logger('message length exceeds max_import_size: truncated'); + } + + if ($maxlen && mb_strlen($item['summary']) > $maxlen) { + $item['summary'] = mb_substr($item['summary'], 0, $maxlen, 'UTF-8'); + logger('message summary length exceeds max_import_size: truncated'); + } + + if ($act->obj['context']) { + set_iconfig($item, 'activitypub', 'context', $act->obj['context'], 1); + } + + if ($act->obj['conversation']) { + set_iconfig($item, 'ostatus', 'conversation', $act->obj['conversation'], 1); + } + + set_iconfig($item, 'activitypub', 'recips', $act->raw_recips); + + if (intval($act->sigok)) { + $item['item_verified'] = 1; + } + + $parent = null; + + if ($is_child_node) { + $parent = q( + "select * from item where mid = '%s' and uid = %d limit 1", + dbesc($item['parent_mid']), + intval($item['uid']) + ); + if (!$parent) { + if (!get_config('system', 'activitypub', ACTIVITYPUB_ENABLED)) { + return; + } else { + $fetch = false; + if (intval($channel['channel_system']) || (perm_is_allowed($channel['channel_id'], $observer_hash, 'send_stream') && (PConfig::Get($channel['channel_id'], 'system', 'hyperdrive', true) || $act->type === 'Announce'))) { + $fetch = (($fetch_parents) ? self::fetch_and_store_parents($channel, $observer_hash, $act, $item) : false); + } + if ($fetch) { + $parent = q( + "select * from item where mid = '%s' and uid = %d limit 1", + dbesc($item['parent_mid']), + intval($item['uid']) + ); + } else { + logger('no parent'); + return; + } + } + } + + $item['comment_policy'] = $parent[0]['comment_policy']; + $item['item_nocomment'] = $parent[0]['item_nocomment']; + $item['comments_closed'] = $parent[0]['comments_closed']; + + if ($parent[0]['parent_mid'] !== $item['parent_mid']) { + $item['thr_parent'] = $item['parent_mid']; + } else { + $item['thr_parent'] = $parent[0]['parent_mid']; + } + $item['parent_mid'] = $parent[0]['parent_mid']; + + /* + * + * Check for conversation privacy mismatches + * We can only do this if we have a channel and we have fetched the parent + * + */ + + // public conversation, but this comment went rogue and was published privately + // hide it from everybody except the channel owner + + if (intval($parent[0]['item_private']) === 0) { + if (intval($item['item_private'])) { + $item['item_restrict'] = $item['item_restrict'] | 1; + $item['allow_cid'] = '<' . $channel['channel_hash'] . '>'; + $item['allow_gid'] = $item['deny_cid'] = $item['deny_gid'] = ''; + } + } + + // Private conversation, but this comment went rogue and was published publicly + // Set item_restrict to indicate this condition so we can flag it in the UI + + if (intval($parent[0]['item_private']) !== 0 && $act->recips && (in_array(ACTIVITY_PUBLIC_INBOX, $act->recips) || in_array('Public', $act->recips) || in_array('as:Public', $act->recips))) { + $item['item_restrict'] = $item['item_restrict'] | 2; + } + } + + self::rewrite_mentions($item); + + $r = q( + "select id, created, edited from item where mid = '%s' and uid = %d limit 1", + dbesc($item['mid']), + intval($item['uid']) + ); + if ($r) { + if ($item['edited'] > $r[0]['edited']) { + $item['id'] = $r[0]['id']; + $x = item_store_update($item); + } else { + return; + } + } else { + $x = item_store($item); + } + + +// experimental code that needs more work. What this did was once we fetched a conversation to find the root node, +// start at that root node and fetch children so you get all the branches and not just the branch related to the current node. +// Unfortunately there is no standard method for achieving this. Mastodon provides a 'replies' collection and Nomad projects +// can fetch the 'context'. For other platforms it's a wild guess. Additionally when we tested this, it started an infinite +// recursion and has been disabled until the recursive behaviour is tracked down and fixed. + +// if ($fetch_parents && $parent && ! intval($parent[0]['item_private'])) { +// logger('topfetch', LOGGER_DEBUG); +// // if the thread owner is a connnection, we will already receive any additional comments to their posts +// // but if they are not we can try to fetch others in the background +// $x = q("SELECT abook.*, xchan.* FROM abook left join xchan on abook_xchan = xchan_hash +// WHERE abook_channel = %d and abook_xchan = '%s' LIMIT 1", +// intval($channel['channel_id']), +// dbesc($parent[0]['owner_xchan']) +// ); +// if (! $x) { +// // determine if the top-level post provides a replies collection +// if ($parent[0]['obj']) { +// $parent[0]['obj'] = json_decode($parent[0]['obj'],true); +// } +// logger('topfetch: ' . print_r($parent[0],true), LOGGER_ALL); +// $id = ((array_path_exists('obj/replies/id',$parent[0])) ? $parent[0]['obj']['replies']['id'] : false); +// if (! $id) { +// $id = ((array_path_exists('obj/replies',$parent[0]) && is_string($parent[0]['obj']['replies'])) ? $parent[0]['obj']['replies'] : false); +// } +// if ($id) { +// Run::Summon( [ 'Convo', $id, $channel['channel_id'], $observer_hash ] ); +// } +// } +// } + + if (is_array($x) && $x['item_id']) { + if ($is_child_node) { + if ($item['owner_xchan'] === $channel['channel_hash']) { + // We are the owner of this conversation, so send all received comments back downstream + Run::Summon(['Notifier', 'comment-import', $x['item_id']]); + } + $r = q( + "select * from item where id = %d limit 1", + intval($x['item_id']) + ); + if ($r) { + send_status_notifications($x['item_id'], $r[0]); + } + } + sync_an_item($channel['channel_id'], $x['item_id']); + } + } + + public static function find_best_identity($xchan) + { + + $r = q( + "select hubloc_hash from hubloc where hubloc_id_url = '%s' limit 1", + dbesc($xchan) + ); + if ($r) { + return $r[0]['hubloc_hash']; + } + return $xchan; + } + + + public static function fetch_and_store_parents($channel, $observer_hash, $act, $item) + { + + logger('fetching parents'); + + $p = []; + + $current_act = $act; + $current_item = $item; + + while ($current_item['parent_mid'] !== $current_item['mid']) { + $n = self::fetch($current_item['parent_mid']); + if (!$n) { + break; + } + // set client flag to convert objects to implied activities + $a = new ActivityStreams($n, null, true); + if ( + $a->type === 'Announce' && is_array($a->obj) + && array_key_exists('object', $a->obj) && array_key_exists('actor', $a->obj) + ) { + // This is a relayed/forwarded Activity (as opposed to a shared/boosted object) + // Reparse the encapsulated Activity and use that instead + logger('relayed activity', LOGGER_DEBUG); + $a = new ActivityStreams($a->obj, null, true); + } + + logger($a->debug(), LOGGER_DATA); + + if (!$a->is_valid()) { + logger('not a valid activity'); + break; + } + if (is_array($a->actor) && array_key_exists('id', $a->actor)) { + self::actor_store($a->actor['id'], $a->actor); + } + + // ActivityPub sourced items are cacheable + $item = self::decode_note($a, true); + + if (!$item) { + break; + } + + $hookinfo = [ + 'a' => $a, + 'item' => $item + ]; + + Hook::call('fetch_and_store', $hookinfo); + + $item = $hookinfo['item']; + + if ($item) { + // don't leak any private conversations to the public stream + // even if they contain publicly addressed comments/reactions + + if (intval($channel['channel_system']) && intval($item['item_private'])) { + logger('private conversation ignored'); + $p = []; + break; + } + + if (count($p) > 100) { + logger('Conversation overflow'); + $p = []; + break; + } + + array_unshift($p, [$a, $item]); + + if ($item['parent_mid'] === $item['mid']) { + break; + } + } + + $current_act = $a; + $current_item = $item; + } + + + if ($p) { + foreach ($p as $pv) { + if ($pv[0]->is_valid()) { + self::store($channel, $observer_hash, $pv[0], $pv[1], false); + } + } + return true; + } + + return false; + } + + + // This function is designed to work with Zot attachments and item body + + public static function bb_attach($attach, $body) + { + + $ret = false; + + if (!(is_array($attach) && $attach)) { + return EMPTY_STR; + } + + foreach ($attach as $a) { + if (array_key_exists('type', $a) && stripos($a['type'], 'image') !== false) { + // don't add inline image if it's an svg and we already have an inline svg + if ($a['type'] === 'image/svg+xml' && strpos($body, '[/svg]')) { + continue; + } + if (self::media_not_in_body($a['href'], $body)) { + if (isset($a['name']) && $a['name']) { + $alt = htmlspecialchars($a['name'], ENT_QUOTES); + $ret .= "\n\n" . '[img alt="' . $alt . '"]' . $a['href'] . '[/img]'; + } else { + $ret .= "\n\n" . '[img]' . $a['href'] . '[/img]'; + } + } + } + if (array_key_exists('type', $a) && stripos($a['type'], 'video') !== false) { + if (self::media_not_in_body($a['href'], $body)) { + $ret .= "\n\n" . '[video]' . $a['href'] . '[/video]'; + } + } + if (array_key_exists('type', $a) && stripos($a['type'], 'audio') !== false) { + if (self::media_not_in_body($a['href'], $body)) { + $ret .= "\n\n" . '[audio]' . $a['href'] . '[/audio]'; + } + } + } + + return $ret; + } + + + // check for the existence of existing media link in body + + public static function media_not_in_body($s, $body) + { + + $s_alt = htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); + + if ( + (strpos($body, ']' . $s . '[/img]') === false) && + (strpos($body, ']' . $s . '[/zmg]') === false) && + (strpos($body, ']' . $s . '[/video]') === false) && + (strpos($body, ']' . $s . '[/zvideo]') === false) && + (strpos($body, ']' . $s . '[/audio]') === false) && + (strpos($body, ']' . $s . '[/zaudio]') === false) && + (strpos($body, ']' . $s_alt . '[/img]') === false) && + (strpos($body, ']' . $s_alt . '[/zmg]') === false) && + (strpos($body, ']' . $s_alt . '[/video]') === false) && + (strpos($body, ']' . $s_alt . '[/zvideo]') === false) && + (strpos($body, ']' . $s_alt . '[/audio]') === false) && + (strpos($body, ']' . $s_alt . '[/zaudio]') === false) + ) { + return true; + } + return false; + } + + + public static function bb_content($content, $field) + { + + $ret = false; + + if (!is_array($content)) { + btlogger('content not initialised'); + return $ret; + } + + if (array_key_exists($field, $content) && is_array($content[$field])) { + foreach ($content[$field] as $k => $v) { + $ret .= html2bbcode($v); + // save this for auto-translate or dynamic filtering + // $ret .= '[language=' . $k . ']' . html2bbcode($v) . '[/language]'; + } + } elseif (isset($content[$field])) { + if ($field === 'bbcode' && array_key_exists('bbcode', $content)) { + $ret = $content[$field]; + } else { + $ret = html2bbcode($content[$field]); + } + } else { + $ret = EMPTY_STR; + } + if ($field === 'content' && isset($content['event']) && (!strpos($ret, '[event'))) { + $ret .= format_event_bbcode($content['event']); + } + + return $ret; + } + + + public static function get_content($act, $binary = false) + { + + $content = []; + $event = null; + + if ((!$act) || (!is_array($act))) { + return $content; + } + + + if ($act['type'] === 'Event') { + $adjust = false; + $event = []; + $event['event_hash'] = $act['id']; + if (array_key_exists('startTime', $act) && strpos($act['startTime'], -1, 1) === 'Z') { + $adjust = true; + $event['adjust'] = 1; + $event['dtstart'] = datetime_convert('UTC', 'UTC', $event['startTime'] . (($adjust) ? '' : 'Z')); + } + if (array_key_exists('endTime', $act)) { + $event['dtend'] = datetime_convert('UTC', 'UTC', $event['endTime'] . (($adjust) ? '' : 'Z')); + } else { + $event['nofinish'] = true; + } + + if (array_key_exists('eventRepeat', $act)) { + $event['event_repeat'] = $act['eventRepeat']; + } + } + + foreach (['name', 'summary', 'content'] as $a) { + if (($x = self::get_textfield($act, $a, $binary)) !== false) { + $content[$a] = $x; + } + if (isset($content['name'])) { + $content['name'] = html2plain(purify_html($content['name']), 256); + } + } + + if ($event && !$binary) { + $event['summary'] = html2plain(purify_html($content['summary']), 256); + if (!$event['summary']) { + if ($content['name']) { + $event['summary'] = html2plain(purify_html($content['name']), 256); + } + } + if (!$event['summary']) { + if ($content['content']) { + $event['summary'] = html2plain(purify_html($content['content']), 256); + } + } + if ($event['summary']) { + $event['summary'] = substr($event['summary'], 0, 256); + } + $event['description'] = html2bbcode($content['content']); + if ($event['summary'] && $event['dtstart']) { + $content['event'] = $event; + } + } + + if (array_path_exists('source/mediaType', $act) && array_path_exists('source/content', $act)) { + if (in_array($act['source']['mediaType'], ['text/bbcode', 'text/x-multicode'])) { + if (is_string($act['source']['content']) && strpos($act['source']['content'], '<') !== false) { + $content['bbcode'] = multicode_purify($act['source']['content']); + } else { + $content['bbcode'] = purify_html($act['source']['content'], ['escape']); + } + } + } + + return $content; + } + + + public static function get_textfield($act, $field, $binary = false) + { + + $content = false; + + if (array_key_exists($field, $act) && $act[$field]) { + $content = (($binary) ? $act[$field] : purify_html($act[$field])); + } elseif (array_key_exists($field . 'Map', $act) && $act[$field . 'Map']) { + foreach ($act[$field . 'Map'] as $k => $v) { + $content[escape_tags($k)] = (($binary) ? $v : purify_html($v)); + } + } + return $content; + } + + public static function send_rejection_activity($channel, $observer_hash, $item) + { + + $recip = q( + "select * from hubloc where hubloc_hash = '%s' limit 1", + dbesc($observer_hash) + ); + if (!$recip) { + return; + } + + $arr = [ + 'id' => z_root() . '/bounces/' . new_uuid(), + 'to' => [$observer_hash], + 'type' => 'Reject', + 'actor' => Channel::url($channel), + 'name' => 'Permission denied', + 'object' => $item['message_id'] + ]; + + $msg = array_merge(['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + self::ap_schema() + ]], $arr); + + $queue_id = ActivityPub::queue_message(json_encode($msg, JSON_UNESCAPED_SLASHES), $channel, $recip[0]); + do_delivery([$queue_id]); + } + + // Find either an Authorization: Bearer token or 'token' request variable + // in the current web request and return it + + public static function token_from_request() + { + + foreach (['REDIRECT_REMOTE_USER', 'HTTP_AUTHORIZATION'] as $s) { + $auth = ((array_key_exists($s, $_SERVER) && strpos($_SERVER[$s], 'Bearer ') === 0) + ? str_replace('Bearer ', EMPTY_STR, $_SERVER[$s]) + : EMPTY_STR + ); + if ($auth) { + break; + } + } + + if (!$auth) { + if (array_key_exists('token', $_REQUEST) && $_REQUEST['token']) { + $auth = $_REQUEST['token']; + } + } + + return $auth; + } + + public static function get_xchan_type($type) + { + switch ($type) { + case 'Person': + return XCHAN_TYPE_PERSON; + case 'Group': + return XCHAN_TYPE_GROUP; + case 'Service': + return XCHAN_TYPE_SERVICE; + case 'Organization': + return XCHAN_TYPE_ORGANIZATION; + case 'Application': + return XCHAN_TYPE_APPLICATION; + default: + return XCHAN_TYPE_UNKNOWN; + } + } + + public static function get_cached_actor($id) + { + return (XConfig::Get($id, 'system', 'actor_record')); + } + + + public static function get_actor_hublocs($url, $options = 'all,not_deleted') + { + + $hublocs = false; + $sql_options = EMPTY_STR; + + $options_arr = explode(',', $options); + if (count($options_arr) > 1) { + for ($x = 1; $x < count($options_arr); $x++) { + switch (trim($options_arr[$x])) { + case 'not_deleted': + $sql_options .= ' and hubloc_deleted = 0 '; + break; + default: + break; + } + } + } + + switch (trim($options_arr[0])) { + case 'activitypub': + $hublocs = q( + "select * from hubloc left join xchan on hubloc_hash = xchan_hash where hubloc_hash = '%s' $sql_options ", + dbesc($url) + ); + break; + case 'zot6': + case 'nomad': + $hublocs = q( + "select * from hubloc left join xchan on hubloc_hash = xchan_hash where hubloc_id_url = '%s' $sql_options ", + dbesc($url) + ); + break; + case 'all': + default: + $hublocs = q( + "select * from hubloc left join xchan on hubloc_hash = xchan_hash where ( hubloc_id_url = '%s' OR hubloc_hash = '%s' ) $sql_options ", + dbesc($url), + dbesc($url) + ); + break; + } + + return $hublocs; + } + + public static function get_actor_collections($url) + { + $ret = []; + $actor_record = XConfig::Get($url, 'system', 'actor_record'); + if (!$actor_record) { + return $ret; + } + + foreach (['inbox', 'outbox', 'followers', 'following'] as $collection) { + if (isset($actor_record[$collection]) && $actor_record[$collection]) { + $ret[$collection] = $actor_record[$collection]; + } + } + if (array_path_exists('endpoints/sharedInbox', $actor_record) && $actor_record['endpoints']['sharedInbox']) { + $ret['sharedInbox'] = $actor_record['endpoints']['sharedInbox']; + } + + return $ret; + } + + public static function ap_schema() + { + + return [ + 'zot' => z_root() . '/apschema#', + 'toot' => 'http://joinmastodon.org/ns#', + 'ostatus' => 'http://ostatus.org#', + 'schema' => 'http://schema.org#', + 'litepub' => 'http://litepub.social/ns#', + 'sm' => 'http://smithereen.software/ns#', + 'conversation' => 'ostatus:conversation', + 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', + 'oauthRegistrationEndpoint' => 'litepub:oauthRegistrationEndpoint', + 'sensitive' => 'as:sensitive', + 'movedTo' => 'as:movedTo', + 'copiedTo' => 'as:copiedTo', + 'alsoKnownAs' => 'as:alsoKnownAs', + 'EmojiReact' => 'as:EmojiReact', + 'commentPolicy' => 'zot:commentPolicy', + 'topicalCollection' => 'zot:topicalCollection', + 'eventRepeat' => 'zot:eventRepeat', + 'emojiReaction' => 'zot:emojiReaction', + 'expires' => 'zot:expires', + 'directMessage' => 'zot:directMessage', + 'Category' => 'zot:Category', + 'replyTo' => 'zot:replyTo', + 'PropertyValue' => 'schema:PropertyValue', + 'value' => 'schema:value', + 'discoverable' => 'toot:discoverable', + 'wall' => 'sm:wall', + 'capabilities' => 'litepub:capabilities', + 'acceptsJoins' => 'litepub:acceptsJoins', + ]; + } +} diff --git a/Code/Lib/ActivityPub.php b/Code/Lib/ActivityPub.php new file mode 100644 index 000000000..bce2390e2 --- /dev/null +++ b/Code/Lib/ActivityPub.php @@ -0,0 +1,600 @@ + Channel::url($arr['channel']) . '?operation=delete', + 'actor' => Channel::url($arr['channel']), + 'type' => 'Delete', + 'object' => Channel::url($arr['channel']), + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'cc' => [] + ]; + + $msg = array_merge(['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], $ti); + + $msg['signature'] = LDSignatures::sign($msg, $arr['channel']); + + logger('ActivityPub_encoded (purge_all): ' . json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + + $jmsg = json_encode($msg, JSON_UNESCAPED_SLASHES); + } else { + $target_item = $arr['target_item']; + + if (!$target_item['mid']) { + return; + } + + $prv_recips = $arr['env_recips']; + + + if ($signed_msg) { + $jmsg = $signed_msg; + } else { + // Rewrite outbound mentions so they match the ActivityPub convention, which + // is to pretend that the preferred display name doesn't exist and instead use + // the username or webfinger address when displaying names. This is likely to + // only cause confusion on nomadic networks where there could be any number + // of applicable webfinger addresses for a given identity. + + + Activity::rewrite_mentions_sub($target_item, 1, $target_item['obj']); + + $ti = Activity::encode_activity($target_item, true); + + if (!$ti) { + return; + } + +// $token = IConfig::get($target_item['id'],'ocap','relay'); +// if ($token) { +// if (defined('USE_BEARCAPS')) { +// $ti['id'] = 'bear:?u=' . $ti['id'] . '&t=' . $token; +// } +// else { +// $ti['id'] = $ti['id'] . '?token=' . $token; +// } +// if ($ti['url'] && is_string($ti['url'])) { +// $ti['url'] .= '?token=' . $token; +// } +// } + + $msg = array_merge(['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], $ti); + + $msg['signature'] = LDSignatures::sign($msg, $arr['channel']); + + logger('ActivityPub_encoded: ' . json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + + $jmsg = json_encode($msg, JSON_UNESCAPED_SLASHES); + } + } + if ($prv_recips) { + $hashes = []; + + // re-explode the recipients, but only for this hub/pod + + foreach ($prv_recips as $recip) { + $hashes[] = "'" . $recip . "'"; + } + + $r = q( + "select * from xchan left join hubloc on xchan_hash = hubloc_hash where hubloc_url = '%s' + and xchan_hash in (" . implode(',', $hashes) . ") and xchan_network = 'activitypub' ", + dbesc($arr['hub']['hubloc_url']) + ); + + if (!$r) { + logger('activitypub_process_outbound: no recipients'); + return; + } + + foreach ($r as $contact) { + // is $contact connected with this channel - and if the channel is cloned, also on this hub? + // 2018-10-19 this probably doesn't apply to activitypub anymore, just send the thing. + // They'll reject it if they don't like it. + // $single = deliverable_singleton($arr['channel']['channel_id'],$contact); + + if (!$arr['normal_mode']) { + continue; + } + + $qi = self::queue_message($jmsg, $arr['channel'], $contact, $target_item['mid']); + if ($qi) { + $arr['queued'][] = $qi; + } + continue; + } + } else { + // public message + + // See if we can deliver all of them at once + + $x = get_xconfig($arr['hub']['hubloc_hash'], 'activitypub', 'collections'); + if ($x && $x['sharedInbox']) { + logger('using publicInbox delivery for ' . $arr['hub']['hubloc_url'], LOGGER_DEBUG); + $contact['hubloc_callback'] = $x['sharedInbox']; + $qi = self::queue_message($jmsg, $arr['channel'], $contact, $target_item['mid']); + if ($qi) { + $arr['queued'][] = $qi; + } + } else { + $r = q( + "select * from xchan left join hubloc on xchan_hash = hubloc_hash where hubloc_url = '%s' and xchan_network = 'activitypub' ", + dbesc($arr['hub']['hubloc_url']) + ); + + if (!$r) { + logger('activitypub_process_outbound: no recipients'); + return; + } + + foreach ($r as $contact) { + // $single = deliverable_singleton($arr['channel']['channel_id'],$contact); + + $qi = self::queue_message($jmsg, $arr['channel'], $contact, $target_item['mid']); + if ($qi) { + $arr['queued'][] = $qi; + } + } + } + } + + return; + } + + + public static function queue_message($msg, $sender, $recip, $message_id = '') + { + + $dest_url = $recip['hubloc_callback']; + + logger('URL: ' . $dest_url, LOGGER_DEBUG); + logger('DATA: ' . jindent($msg), LOGGER_DATA); + + if (intval(get_config('system', 'activitypub_test')) || intval(get_pconfig($sender['channel_id'], 'system', 'activitypub_test'))) { + logger('test mode - delivery disabled'); + return false; + } + + $hash = random_string(); + + logger('queue: ' . $hash . ' ' . $dest_url, LOGGER_DEBUG); + Queue::insert([ + 'hash' => $hash, + 'account_id' => $sender['channel_account_id'], + 'channel_id' => $sender['channel_id'], + 'driver' => 'activitypub', + 'posturl' => $dest_url, + 'notify' => '', + 'msg' => $msg + ]); + + if ($message_id && (!get_config('system', 'disable_dreport'))) { + q( + "insert into dreport ( dreport_mid, dreport_site, dreport_recip, dreport_result, dreport_time, dreport_xchan, dreport_queue, dreport_log ) values ( '%s','%s','%s','%s','%s','%s','%s','%s' ) ", + dbesc($message_id), + dbesc($dest_url), + dbesc($dest_url), + dbesc('queued'), + dbesc(datetime_convert()), + dbesc($sender['channel_hash']), + dbesc($hash), + dbesc(EMPTY_STR) + ); + } + + return $hash; + } + + + public static function permissions_update(&$x) + { + + if ($x['recipient']['xchan_network'] !== 'activitypub') { + return; + } + self::discover($x['recipient']['xchan_hash'], true); + $x['success'] = true; + } + + + public static function permissions_create(&$x) + { + + // send a follow activity to the followee's inbox + + if ($x['recipient']['xchan_network'] !== 'activitypub') { + return; + } + + $p = Activity::encode_person($x['sender'], false); + if (!$p) { + return; + } + + $orig_follow = get_abconfig($x['sender']['channel_id'], $x['recipient']['xchan_hash'], 'activitypub', 'their_follow_id'); + $orig_follow_type = get_abconfig($x['sender']['channel_id'], $x['recipient']['xchan_hash'], 'activitypub', 'their_follow_type'); + + $msg = array_merge( + ['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], + [ + 'id' => z_root() . '/follow/' . $x['recipient']['abook_id'] . (($orig_follow) ? '/' . md5($orig_follow) : EMPTY_STR), + 'type' => (($orig_follow_type) ? $orig_follow_type : 'Follow'), + 'actor' => $p, + 'object' => $x['recipient']['xchan_hash'], + 'to' => [$x['recipient']['xchan_hash']], + 'cc' => [] + ] + ); + + // for Group actors, send both a Follow and a Join because some platforms only support one and there's + // no way of discovering/knowing in advance which type they support + + $join_msg = null; + + if (intval($x['recipient']['xchan_type']) === XCHAN_TYPE_GROUP) { + $join_msg = $msg; + $join_msg['type'] = 'Join'; + $join_msg['signature'] = LDSignatures::sign($join_msg, $x['sender']); + $jmsg2 = json_encode($join_msg, JSON_UNESCAPED_SLASHES); + } + + $msg['signature'] = LDSignatures::sign($msg, $x['sender']); + $jmsg = json_encode($msg, JSON_UNESCAPED_SLASHES); + + $h = q( + "select * from hubloc where hubloc_hash = '%s' limit 1", + dbesc($x['recipient']['xchan_hash']) + ); + + if ($h) { + $qi = self::queue_message($jmsg, $x['sender'], $h[0]); + if ($qi) { + $x['deliveries'] = $qi; + } + if ($join_msg) { + $qi = self::queue_message($jmsg2, $x['sender'], $h[0]); + if ($qi) { + $x['deliveries'] = $qi; + } + } + } + + $x['success'] = true; + } + + + public static function permissions_accept(&$x) + { + + // send an accept activity to the followee's inbox + + if ($x['recipient']['xchan_network'] !== 'activitypub') { + return; + } + + // we currently are not handling send of reject follow activities; this is permitted by protocol + + $accept = get_abconfig($x['recipient']['abook_channel'], $x['recipient']['xchan_hash'], 'activitypub', 'their_follow_id'); + $follow_type = get_abconfig($x['recipient']['abook_channel'], $x['recipient']['xchan_hash'], 'activitypub', 'their_follow_type'); + if (!$accept) { + return; + } + + $p = Activity::encode_person($x['sender'], false); + if (!$p) { + return; + } + + $msg = array_merge( + ['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], + [ + 'id' => z_root() . '/follow/' . $x['recipient']['abook_id'] . '/' . md5($accept), + 'type' => 'Accept', + 'actor' => $p, + 'object' => [ + 'type' => (($follow_type) ? $follow_type : 'Follow'), + 'id' => $accept, + 'actor' => $x['recipient']['xchan_hash'], + 'object' => z_root() . '/channel/' . $x['sender']['channel_address'] + ], + 'to' => [$x['recipient']['xchan_hash']], + 'cc' => [] + ] + ); + + $msg['signature'] = LDSignatures::sign($msg, $x['sender']); + + $jmsg = json_encode($msg, JSON_UNESCAPED_SLASHES); + + $h = q( + "select * from hubloc where hubloc_hash = '%s' limit 1", + dbesc($x['recipient']['xchan_hash']) + ); + + if ($h) { + $qi = self::queue_message($jmsg, $x['sender'], $h[0]); + if ($qi) { + $x['deliveries'] = $qi; + } + } + + $x['success'] = true; + } + + public static function contact_remove($channel_id, $abook) + { + + $recip = q( + "select * from abook left join xchan on abook_xchan = xchan_hash where abook_id = %d", + intval($abook['abook_id']) + ); + + if ((!$recip) || $recip[0]['xchan_network'] !== 'activitypub') { + return; + } + + $channel = Channel::from_id($recip[0]['abook_channel']); + if (!$channel) { + return; + } + + $p = Activity::encode_person($channel, true, true); + if (!$p) { + return; + } + + // send an unfollow activity to the followee's inbox + + $orig_activity = get_abconfig($recip[0]['abook_channel'], $recip[0]['xchan_hash'], 'activitypub', 'follow_id'); + + if ($orig_activity && $recip[0]['abook_pending']) { + // was never approved + + $msg = array_merge( + ['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], + [ + 'id' => z_root() . '/follow/' . $recip[0]['abook_id'] . '/' . md5($orig_activity) . '?operation=reject', + 'type' => 'Reject', + 'actor' => $p, + 'object' => [ + 'type' => 'Follow', + 'id' => $orig_activity, + 'actor' => $recip[0]['xchan_hash'], + 'object' => $p + ], + 'to' => [$recip[0]['xchan_hash']], + 'cc' => [] + ] + ); + del_abconfig($recip[0]['abook_channel'], $recip[0]['xchan_hash'], 'activitypub', 'follow_id'); + } else { + // send an unfollow + + $msg = array_merge( + ['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], + [ + 'id' => z_root() . '/follow/' . $recip[0]['abook_id'] . (($orig_activity) ? '/' . md5($orig_activity) : EMPTY_STR) . '?operation=unfollow', + 'type' => 'Undo', + 'actor' => $p, + 'object' => [ + 'id' => z_root() . '/follow/' . $recip[0]['abook_id'] . (($orig_activity) ? '/' . md5($orig_activity) : EMPTY_STR), + 'type' => 'Follow', + 'actor' => $p, + 'object' => $recip[0]['xchan_hash'] + ], + 'to' => [$recip[0]['xchan_hash']], + 'cc' => [] + ] + ); + } + + $msg['signature'] = LDSignatures::sign($msg, $channel); + + $jmsg = json_encode($msg, JSON_UNESCAPED_SLASHES); + + $h = q( + "select * from hubloc where hubloc_hash = '%s' limit 1", + dbesc($recip[0]['xchan_hash']) + ); + + if ($h) { + $qi = self::queue_message($jmsg, $channel, $h[0]); + if ($qi) { + Run::Summon(['Deliver', $qi]); + } + } + } + + public static function discover($apurl, $force = false) + { + + $person_obj = null; + $ap = Activity::fetch($apurl); + if ($ap) { + $AS = new ActivityStreams($ap); + if ($AS->is_valid()) { + if (ActivityStreams::is_an_actor($AS->type)) { + $person_obj = $AS->data; + } elseif ($AS->obj && ActivityStreams::is_an_actor($AS->obj['type'])) { + $person_obj = $AS->obj; + } + } + } + + if (isset($person_obj)) { + Activity::actor_store($person_obj['id'], $person_obj, $force); + return $person_obj['id']; + } + return false; + } + + public static function move($src, $dst) + { + + if (!($src && $dst)) { + return; + } + + if ($src && !is_array($src)) { + $src = Activity::fetch($src); + if (is_array($src)) { + $src_xchan = $src['id']; + } + } + + $approvals = null; + + if ($dst && !is_array($dst)) { + $dst = Activity::fetch($dst); + if (is_array($dst)) { + $dst_xchan = $dst['id']; + if (array_key_exists('alsoKnownAs', $dst)) { + if (!is_array($dst['alsoKnownAs'])) { + $dst['alsoKnownAs'] = [$dst['alsoKnownAs']]; + } + $approvals = $dst['alsoKnownAs']; + } + } + } + + if (!($src_xchan && $dst_xchan)) { + return; + } + + if ($approvals) { + foreach ($approvals as $approval) { + if ($approval === $src_xchan) { + $abooks = q( + "select abook_channel from abook where abook_xchan = '%s'", + dbesc($src_xchan) + ); + if ($abooks) { + foreach ($abooks as $abook) { + // check to see if we already performed this action + $x = q( + "select * from abook where abook_xchan = '%s' and abook_channel = %d", + dbesc($dst_xchan), + intval($abook['abook_channel']) + ); + if ($x) { + continue; + } + // update the local abook + q( + "update abconfig set xchan = '%s' where chan = %d and xchan = '%s'", + dbesc($dst_xchan), + intval($abook['abook_channel']), + dbesc($src_xchan) + ); + q( + "update pgrp_member set xchan = '%s' where uid = %d and xchan = '%s'", + dbesc($dst_xchan), + intval($abook['abook_channel']), + dbesc($src_xchan) + ); + $r = q( + "update abook set abook_xchan = '%s' where abook_xchan = '%s' and abook_channel = %d ", + dbesc($dst_xchan), + dbesc($src_xchan), + intval($abook['abook_channel']) + ); + + $r = q( + "SELECT abook.*, xchan.* + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d and abook_id = %d LIMIT 1", + intval(abook['abook_channel']), + intval($dst_xchan) + ); + if ($r) { + $clone = array_shift($r); + unset($clone['abook_id']); + unset($clone['abook_account']); + unset($clone['abook_channel']); + $abconfig = load_abconfig($abook['abook_channel'], $clone['abook_xchan']); + if ($abconfig) { + $clone['abconfig'] = $abconfig; + } + Libsync::build_sync_packet($abook['abook_channel'], ['abook' => [$clone]]); + } + } + } + } + } + } + } +} diff --git a/Code/Lib/ActivityStreams.php b/Code/Lib/ActivityStreams.php new file mode 100644 index 000000000..b0f64ae6b --- /dev/null +++ b/Code/Lib/ActivityStreams.php @@ -0,0 +1,502 @@ +raw = $string; + $this->hub = $hub; + + if (is_array($string)) { + $this->data = $string; + $this->raw = json_encode($string, JSON_UNESCAPED_SLASHES); + } else { + $this->data = json_decode($string, true); + } + + if ($this->data) { + // verify and unpack JSalmon signature if present + // This will only be the case for Zot6 packets + + if (is_array($this->data) && array_key_exists('signed', $this->data)) { + $ret = JSalmon::verify($this->data); + $tmp = JSalmon::unpack($this->data['data']); + if ($ret && $ret['success']) { + if ($ret['signer']) { + logger('Unpacked: ' . json_encode($tmp, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOGGER_DATA, LOG_DEBUG); + $saved = json_encode($this->data, JSON_UNESCAPED_SLASHES); + $this->data = $tmp; + $this->meta['signer'] = $ret['signer']; + $this->meta['signed_data'] = $saved; + if ($ret['hubloc']) { + $this->meta['hubloc'] = $ret['hubloc']; + } + } + } + } + + // This indicates only that we have sucessfully decoded JSON. + $this->valid = true; + + // Special handling for Mastodon "delete actor" activities which will often fail to verify + // because the key cannot be fetched. We will catch this condition elsewhere. + + if (array_key_exists('type', $this->data) && array_key_exists('actor', $this->data) && array_key_exists('object', $this->data)) { + if ($this->data['type'] === 'Delete' && $this->data['actor'] === $this->data['object']) { + $this->deleted = $this->data['actor']; + $this->valid = false; + } + } + } + + // Attempt to assemble an Activity from what we were given. + + if ($this->is_valid()) { + $this->id = $this->get_property_obj('id'); + $this->type = $this->get_primary_type(); + $this->actor = $this->get_actor('actor', '', ''); + $this->obj = $this->get_compound_property('object'); + $this->tgt = $this->get_compound_property('target'); + $this->origin = $this->get_compound_property('origin'); + $this->recips = $this->collect_recips(); + $this->replyto = $this->get_property_obj('replyTo'); + + $this->ldsig = $this->get_compound_property('signature'); + if ($this->ldsig) { + $this->signer = $this->get_compound_property('creator', $this->ldsig); + if ( + $this->signer && is_array($this->signer) && array_key_exists('publicKey', $this->signer) + && is_array($this->signer['publicKey']) && $this->signer['publicKey']['publicKeyPem'] + ) { + $this->sigok = LDSignatures::verify($this->data, $this->signer['publicKey']['publicKeyPem']); + } + } + + // Implied create activity required by C2S specification if no object is present + + if (!$this->obj) { + if (!$client) { + $this->implied_create = true; + } + $this->obj = $this->data; + $this->type = 'Create'; + if (!$this->actor) { + $this->actor = $this->get_actor('attributedTo', $this->obj); + } + } + + // fetch recursive or embedded activities + + if ($this->obj && is_array($this->obj) && array_key_exists('object', $this->obj)) { + $this->obj['object'] = $this->get_compound_property($this->obj['object']); + } + + // Enumerate and store actors in referenced objects + + if ($this->obj && is_array($this->obj) && isset($this->obj['actor'])) { + $this->obj['actor'] = $this->get_actor('actor', $this->obj); + } + if ($this->tgt && is_array($this->tgt) && isset($this->tgt['actor'])) { + $this->tgt['actor'] = $this->get_actor('actor', $this->tgt); + } + + // Determine if this is a followup or response activity + + $this->parent_id = $this->get_property_obj('inReplyTo'); + + if ((!$this->parent_id) && is_array($this->obj)) { + $this->parent_id = $this->obj['inReplyTo']; + } + if ((!$this->parent_id) && is_array($this->obj)) { + $this->parent_id = $this->obj['id']; + } + } + } + + /** + * @brief Return if instantiated ActivityStream is valid. + * + * @return bool Return true if the JSON string could be decoded. + */ + + public function is_valid() + { + return $this->valid; + } + + public function set_recips($arr) + { + $this->saved_recips = $arr; + } + + /** + * @brief Collects all recipients. + * + * @param string $base + * @param string $namespace (optional) default empty + * @return array + */ + public function collect_recips($base = '', $namespace = '') + { + $x = []; + + $fields = ['to', 'cc', 'bto', 'bcc', 'audience']; + foreach ($fields as $f) { + // don't expand these yet + $y = $this->get_property_obj($f, $base, $namespace); + if ($y) { + if (!is_array($this->raw_recips)) { + $this->raw_recips = []; + } + if (!is_array($y)) { + $y = [$y]; + } + $this->raw_recips[$f] = $y; + $x = array_merge($x, $y); + } + } + +// not yet ready for prime time +// $x = $this->expand($x,$base,$namespace); + return $x; + } + + public function expand($arr, $base = '', $namespace = '') + { + $ret = []; + + // right now use a hardwired recursion depth of 5 + + for ($z = 0; $z < 5; $z++) { + if (is_array($arr) && $arr) { + foreach ($arr as $a) { + if (is_array($a)) { + $ret[] = $a; + } else { + $x = $this->get_compound_property($a, $base, $namespace); + if ($x) { + $ret = array_merge($ret, $x); + } + } + } + } + } + + /// @fixme de-duplicate + + return $ret; + } + + /** + * @brief + * + * @param array $base + * @param string $namespace if not set return empty string + * @return string|NULL + */ + + public function get_namespace($base, $namespace) + { + + if (!$namespace) { + return EMPTY_STR; + } + + $key = null; + + foreach ([$this->data, $base] as $b) { + if (!$b) { + continue; + } + + if (array_key_exists('@context', $b)) { + if (is_array($b['@context'])) { + foreach ($b['@context'] as $ns) { + if (is_array($ns)) { + foreach ($ns as $k => $v) { + if ($namespace === $v) { + $key = $k; + } + } + } else { + if ($namespace === $ns) { + $key = ''; + } + } + } + } else { + if ($namespace === $b['@context']) { + $key = ''; + } + } + } + } + + return $key; + } + + /** + * @brief + * + * @param string $property + * @param array $base (optional) + * @param string $namespace (optional) default empty + * @return NULL|mixed + */ + + public function get_property_obj($property, $base = '', $namespace = '') + { + $prefix = $this->get_namespace($base, $namespace); + if ($prefix === null) { + return null; + } + + $base = (($base) ? $base : $this->data); + $propname = (($prefix) ? $prefix . ':' : '') . $property; + + if (!is_array($base)) { + btlogger('not an array: ' . print_r($base, true)); + return null; + } + + return ((array_key_exists($propname, $base)) ? $base[$propname] : null); + } + + + /** + * @brief Fetches a property from an URL. + * + * @param string $url + * @param array $channel (signing channel, default system channel) + * @return NULL|mixed + */ + + public function fetch_property($url, $channel = null, $hub = null) + { + $x = Activity::fetch($url, $channel, $hub); + if ($x === null && strpos($url, '/channel/')) { + // look for other nomadic channels which might be alive + $zf = Zotfinger::exec($url, $channel); + + $url = $zf['signature']['signer']; + $x = Activity::fetch($url, $channel); + } + + return $x; + } + + /** + * @brief given a type, determine if this object represents an actor + * + * If $type is an array, recurse through each element and return true if any + * of the elements are a known actor type + * + * @param string|array $type + * @return boolean + */ + + public static function is_an_actor($type) + { + if (!$type) { + return false; + } + if (is_array($type)) { + foreach ($type as $x) { + if (self::is_an_actor($x)) { + return true; + } + } + return false; + } + return (in_array($type, ['Application', 'Group', 'Organization', 'Person', 'Service'])); + } + + public static function is_response_activity($s) + { + if (!$s) { + return false; + } + return (in_array($s, ['Like', 'Dislike', 'Flag', 'Block', 'Announce', 'Accept', 'Reject', 'TentativeAccept', 'TentativeReject', 'emojiReaction', 'EmojiReaction', 'EmojiReact'])); + } + + + /** + * @brief + * + * @param string $property + * @param array $base + * @param string $namespace (optional) default empty + * @return NULL|mixed + */ + + public function get_actor($property, $base = '', $namespace = '') + { + $x = $this->get_property_obj($property, $base, $namespace); + if (self::is_url($x)) { + $y = Activity::get_cached_actor($x); + if ($y) { + return $y; + } + } + + $actor = $this->get_compound_property($property, $base, $namespace, true); + if (is_array($actor) && self::is_an_actor($actor['type'])) { + if (array_key_exists('id', $actor) && (!array_key_exists('inbox', $actor))) { + $actor = $this->fetch_property($actor['id']); + } + return $actor; + } + return null; + } + + + /** + * @brief + * + * @param string $property + * @param array $base + * @param string $namespace (optional) default empty + * @param bool $first (optional) default false, if true and result is a sequential array return only the first element + * @return NULL|mixed + */ + + public function get_compound_property($property, $base = '', $namespace = '', $first = false) + { + $x = $this->get_property_obj($property, $base, $namespace); + if (self::is_url($x)) { + $y = $this->fetch_property($x); + if (is_array($y)) { + $x = $y; + } + } + + // verify and unpack JSalmon signature if present + // This may be present in Zot6 packets + + if (is_array($x) && array_key_exists('signed', $x)) { + $ret = JSalmon::verify($x); + $tmp = JSalmon::unpack($x['data']); + if ($ret && $ret['success']) { + if ($ret['signer']) { + logger('Unpacked: ' . json_encode($tmp, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOGGER_DATA, LOG_DEBUG); + $saved = json_encode($x, JSON_UNESCAPED_SLASHES); + $x = $tmp; + $x['meta']['signer'] = $ret['signer']; + $x['meta']['signed_data'] = $saved; + if ($ret['hubloc']) { + $x['meta']['hubloc'] = $ret['hubloc']; + } + } + } + } + if ($first && is_array($x) && array_key_exists(0, $x)) { + return $x[0]; + } + + return $x; + } + + /** + * @brief Check if string starts with http. + * + * @param string $url + * @return bool + */ + + public static function is_url($url) + { + if (($url) && (!is_array($url)) && ((strpos($url, 'http') === 0) || (strpos($url, 'x-zot') === 0) || (strpos($url, 'bear') === 0))) { + return true; + } + + return false; + } + + /** + * @brief Gets the type property. + * + * @param array $base + * @param string $namespace (optional) default empty + * @return NULL|mixed + */ + + public function get_primary_type($base = '', $namespace = '') + { + if (!$base) { + $base = $this->data; + } + $x = $this->get_property_obj('type', $base, $namespace); + if (is_array($x)) { + foreach ($x as $y) { + if (strpos($y, ':') === false) { + return $y; + } + } + } + + return $x; + } + + public function debug() + { + $x = var_export($this, true); + return $x; + } + + + public static function is_as_request() + { + + $x = getBestSupportedMimeType([ + 'application/ld+json;profile="https://www.w3.org/ns/activitystreams"', + 'application/activity+json', + 'application/ld+json;profile="http://www.w3.org/ns/activitystreams"', + 'application/ld+json', // required for Friendica ~2021-09, can possibly be removed after next release of that project + 'application/x-zot-activity+json' + ]); + + return (($x) ? true : false); + } +} diff --git a/Code/Lib/Addon.php b/Code/Lib/Addon.php new file mode 100644 index 000000000..58973c940 --- /dev/null +++ b/Code/Lib/Addon.php @@ -0,0 +1,370 @@ +getMessage()); + } + } + } + + /** + * @brief Uninstalls an addon. + * + * @param string $addon name of the addon + * @return bool + */ + public static function uninstall($addon) + { + + self::unload($addon); + + if (! file_exists('addon/' . $addon . '/' . $addon . '.php')) { + q( + "DELETE FROM addon WHERE aname = '%s' ", + dbesc($addon) + ); + return false; + } + + logger("Addons: uninstalling " . $addon); + //$t = @filemtime('addon/' . $addon . '/' . $addon . '.php'); + @include_once('addon/' . $addon . '/' . $addon . '.php'); + if (function_exists($addon . '_uninstall')) { + $func = $addon . '_uninstall'; + try { + $func(); + } catch (Exception $e) { + self::ErrorHandler($addon, "Unable to uninstall.", "Unable to run _uninstall : ".$e->getMessage()); + } + } + + q( + "DELETE FROM addon WHERE aname = '%s' ", + dbesc($addon) + ); + } + + /** + * @brief Installs an addon. + * + * This function is called once to install the addon (either from the cli or via + * the web admin). This will also call load_plugin() once. + * + * @param string $addon name of the addon + * @return bool + */ + public static function install($addon) + { + if (! file_exists('addon/' . $addon . '/' . $addon . '.php')) { + return false; + } + + logger("Addons: installing " . $addon); + $t = @filemtime('addon/' . $addon . '/' . $addon . '.php'); + @include_once('addon/' . $addon . '/' . $addon . '.php'); + if (function_exists($addon . '_install')) { + $func = $addon . '_install'; + try { + $func(); + } catch (Exception $e) { + self::ErrorHandler($addon, "Install failed.", "Install failed : ".$e->getMessage()); + return; + } + } + + $addon_admin = (function_exists($addon . '_plugin_admin') ? 1 : 0); + + $d = q( + "select * from addon where aname = '%s' limit 1", + dbesc($addon) + ); + if (! $d) { + q( + "INSERT INTO addon (aname, installed, tstamp, plugin_admin) VALUES ( '%s', 1, %d , %d ) ", + dbesc($addon), + intval($t), + $addon_admin + ); + } + + self::load($addon); + } + + /** + * @brief loads an addon by it's name. + * + * @param string $addon name of the addon + * @return bool + */ + public static function load($addon) + { + // silently fail if plugin was removed + if (! file_exists('addon/' . $addon . '/' . $addon . '.php')) { + return false; + } + + logger("Addons: loading " . $addon, LOGGER_DEBUG); + //$t = @filemtime('addon/' . $addon . '/' . $addon . '.php'); + @include_once('addon/' . $addon . '/' . $addon . '.php'); + if (function_exists($addon . '_load')) { + $func = $addon . '_load'; + try { + $func(); + } catch (Exception $e) { + self::ErrorHandler($addon, "Unable to load.", "FAILED loading : ".$e->getMessage(), true); + return; + } + + // we can add the following with the previous SQL + // once most site tables have been updated. + // This way the system won't fall over dead during the update. + + if (file_exists('addon/' . $addon . '/.hidden')) { + q( + "update addon set hidden = 1 where name = '%s'", + dbesc($addon) + ); + } + return true; + } else { + logger("Addons: FAILED loading " . $addon . " (missing _load function)"); + return false; + } + } + + /** + * @brief Check if addon is installed. + * + * @param string $name + * @return bool + */ + + public static function is_installed($name) + { + $r = q( + "select aname from addon where aname = '%s' and installed = 1 limit 1", + dbesc($name) + ); + if ($r) { + return true; + } + + return false; + } + + + /** + * @brief Reload all updated plugins. + */ + public static function reload_all() + { + $addons = get_config('system', 'addon'); + if (strlen($addons)) { + $r = q("SELECT * FROM addon WHERE installed = 1"); + if (count($r)) { + $installed = $r; + } else { + $installed = []; + } + + $parr = explode(',', $addons); + + if (count($parr)) { + foreach ($parr as $pl) { + $pl = trim($pl); + + $fname = 'addon/' . $pl . '/' . $pl . '.php'; + + if (file_exists($fname)) { + $t = @filemtime($fname); + foreach ($installed as $i) { + if (($i['aname'] == $pl) && ($i['tstamp'] != $t)) { + logger('Reloading plugin: ' . $i['aname']); + @include_once($fname); + + if (function_exists($pl . '_unload')) { + $func = $pl . '_unload'; + try { + $func(); + } catch (Exception $e) { + self::ErrorHandler($addon, "", "UNLOAD FAILED (uninstalling) : ".$e->getMessage(), true); + continue; + } + } + if (function_exists($pl . '_load')) { + $func = $pl . '_load'; + try { + $func(); + } catch (Exception $e) { + self::ErrorHandler($addon, "", "LOAD FAILED (uninstalling): ".$e->getMessage(), true); + continue; + } + } + q( + "UPDATE addon SET tstamp = %d WHERE id = %d", + intval($t), + intval($i['id']) + ); + } + } + } + } + } + } + } + + + public static function list_installed() + { + + $r = q("select * from addon where installed = 1 order by aname asc"); + return(($r) ? ids_to_array($r, 'aname') : []); + } + + + /** + * @brief Get a list of non hidden addons. + * + * @return array + */ + public static function list_visible() + { + + $r = q("select * from addon where hidden = 0 order by aname asc"); + $x = (($r) ? ids_to_array($r, 'aname') : []); + $y = []; + if ($x) { + foreach ($x as $xv) { + if (is_dir('addon/' . $xv)) { + $y[] = $xv; + } + } + } + return $y; + } + + /** + * @brief Parse plugin comment in search of plugin infos. + * + * like + * \code + * * Name: Plugin + * * Description: A plugin which plugs in + * * Version: 1.2.3 + * * Author: John + * * Author: Jane + * * + *\endcode + * @param string $plugin the name of the plugin + * @return array with the plugin information + */ + public static function get_info($plugin) + { + + $info = null; + if (is_file("addon/$plugin/$plugin.yml")) { + $info = Infocon::from_file("addon/$plugin/$plugin.yml"); + } + elseif (is_file("addon/$plugin/$plugin.php")) { + $info = Infocon::from_c_comment("addon/$plugin/$plugin.php"); + } + return $info ? $info : [ 'name' => $plugin ] ; + } + + + public static function check_versions($info) + { + + if (! is_array($info)) { + return true; + } + + if (array_key_exists('minversion', $info) && $info['minversion']) { + if (! version_compare(STD_VERSION, trim($info['minversion']), '>=')) { + logger('minversion limit: ' . $info['name'], LOGGER_NORMAL, LOG_WARNING); + return false; + } + } + if (array_key_exists('maxversion', $info) && $info['maxversion']) { + if (! version_compare(STD_VERSION, trim($info['maxversion']), '<')) { + logger('maxversion limit: ' . $info['name'], LOGGER_NORMAL, LOG_WARNING); + return false; + } + } + if (array_key_exists('minphpversion', $info) && $info['minphpversion']) { + if (! version_compare(PHP_VERSION, trim($info['minphpversion']), '>=')) { + logger('minphpversion limit: ' . $info['name'], LOGGER_NORMAL, LOG_WARNING); + return false; + } + } + + if (array_key_exists('requires', $info)) { + $arr = explode(',', $info['requires']); + $found = true; + if ($arr) { + foreach ($arr as $test) { + $test = trim($test); + if (! $test) { + continue; + } + if (strpos($test, '.')) { + $conf = explode('.', $test); + if (get_config(trim($conf[0]), trim($conf[1]))) { + return true; + } else { + return false; + } + } + if (! in_array($test, Addon::list_installed())) { + $found = false; + } + } + } + if (! $found) { + return false; + } + } + + return true; + } + +} \ No newline at end of file diff --git a/Code/Lib/Api_router.php b/Code/Lib/Api_router.php new file mode 100644 index 000000000..0a17928e1 --- /dev/null +++ b/Code/Lib/Api_router.php @@ -0,0 +1,34 @@ + $fn, 'auth' => $auth_required]; + } + + public static function find($path) + { + if (array_key_exists($path, self::$routes)) { + return self::$routes[$path]; + } + + $with_params = dirname($path) . '/[id]'; + + if (array_key_exists($with_params, self::$routes)) { + return self::$routes[$with_params]; + } + + return null; + } + + public static function dbg() + { + return self::$routes; + } +} diff --git a/Code/Lib/Apps.php b/Code/Lib/Apps.php new file mode 100644 index 000000000..65d3f0063 --- /dev/null +++ b/Code/Lib/Apps.php @@ -0,0 +1,1404 @@ + $v) { + if (strpos($v, 'http') === 0) { + if (!(local_channel() && strpos($v, z_root()) === 0)) { + $ret[$k] = zid($v); + } + } + } + + if (array_key_exists('desc', $ret)) { + $ret['desc'] = str_replace(array('\'', '"'), array(''', '&dquot;'), $ret['desc']); + } + if (array_key_exists('target', $ret)) { + $ret['target'] = str_replace(array('\'', '"'), array(''', '&dquot;'), $ret['target']); + } + if (array_key_exists('version', $ret)) { + $ret['version'] = str_replace(array('\'', '"'), array(''', '&dquot;'), $ret['version']); + } + if (array_key_exists('categories', $ret)) { + $ret['categories'] = str_replace(array('\'', '"'), array(''', '&dquot;'), $ret['categories']); + } + if (array_key_exists('requires', $ret)) { + $requires = explode(',', $ret['requires']); + foreach ($requires as $require) { + $require = trim(strtolower($require)); + $config = false; + + if (substr($require, 0, 7) == 'config:') { + $config = true; + $require = ltrim($require, 'config:'); + $require = explode('=', $require); + } + + switch ($require) { + case 'nologin': + if (local_channel()) { + unset($ret); + } + break; + case 'admin': + if (!is_site_admin()) { + unset($ret); + } + break; + case 'local_channel': + if (!local_channel()) { + unset($ret); + } + break; + case 'public_profile': + if (!Channel::is_public_profile()) { + unset($ret); + } + break; + case 'public_stream': + if (!can_view_public_stream()) { + unset($ret); + } + break; + case 'custom_role': + if (get_pconfig(local_channel(), 'system', 'permissions_role') !== 'custom') { + unset($ret); + } + break; + case 'observer': + if (!$observer) { + unset($ret); + } + break; + default: + if ($config) { + $unset = ((get_config('system', $require[0]) == $require[1]) ? false : true); + } else { + $unset = ((local_channel() && Features::enabled(local_channel(), $require)) ? false : true); + } + if ($unset) { + unset($ret); + } + break; + } + } + } + if (isset($ret)) { + if ($translate) { + self::translate_system_apps($ret); + } + return $ret; + } + return false; + } + + + public static function translate_system_apps(&$arr) + { + $apps = array( + 'Admin' => t('Site Admin'), + 'Apps' => t('Apps'), + 'Articles' => t('Articles'), + 'CalDAV' => t('CalDAV'), + 'CardDAV' => t('CardDAV'), + 'Cards' => t('Cards'), + 'Calendar' => t('Calendar'), + 'Categories' => t('Categories'), + 'Channel Home' => t('Channel Home'), + 'Channel Manager' => t('Channel Manager'), + 'Channel Sources' => t('Channel Sources'), + 'Chat' => t('Chat'), + 'Chatrooms' => t('Chatrooms'), + 'Clients' => t('Clients'), + 'Comment Control' => t('Comment Control'), + 'Connections' => t('Connections'), + 'Content Filter' => t('Content Filter'), + 'Content Import' => t('Content Import'), + 'Directory' => t('Directory'), + 'Drafts' => t('Drafts'), + 'Events' => t('Events'), + 'Expire Posts' => t('Expire Posts'), + 'Features' => t('Features'), + 'Files' => t('Files'), + 'Followlist' => t('Followlist'), + 'Friend Zoom' => t('Friend Zoom'), + 'Future Posting' => t('Future Posting'), + 'Gallery' => t('Gallery'), + 'Guest Pass' => t('Guest Pass'), + 'Help' => t('Help'), + 'Invite' => t('Invite'), + 'Language' => t('Language'), + 'Lists' => t('Lists'), + 'Login' => t('Login'), + 'Mail' => t('Mail'), + 'Markup' => t('Markup'), + 'Mood' => t('Mood'), + 'My Chatrooms' => t('My Chatrooms'), + 'No Comment' => t('No Comment'), + 'Notes' => t('Notes'), + 'Notifications' => t('Notifications'), + 'OAuth Apps Manager' => t('OAuth Apps Manager'), + 'OAuth2 Apps Manager' => t('OAuth2 Apps Manager'), + 'Order Apps' => t('Order Apps'), + 'PDL Editor' => t('PDL Editor'), + 'Permission Categories' => t('Permission Categories'), + 'Photos' => t('Photos'), + 'Photomap' => t('Photomap'), + 'Poke' => t('Poke'), + 'Post' => t('Post'), + 'Premium Channel' => t('Premium Channel'), + 'Probe' => t('Probe'), + 'Profile' => t('Profile'), + 'Profile Photo' => t('Profile Photo'), + 'Profiles' => t('Profiles'), + 'Public Stream' => t('Public Stream'), + 'Random Channel' => t('Random Channel'), + 'Remote Diagnostics' => t('Remote Diagnostics'), + 'Report Bug' => t('Report Bug'), + 'Roles' => t('Roles'), + 'Search' => t('Search'), + 'Secrets' => t('Secrets'), + 'Settings' => t('Settings'), + 'Sites' => t('Sites'), + 'Stream' => t('Stream'), + 'Stream Order' => t('Stream Order'), + 'Suggest' => t('Suggest'), + 'Suggest Channels' => t('Suggest Channels'), + 'Tagadelic' => t('Tagadelic'), + 'Tasks' => t('Tasks'), + 'View Bookmarks' => t('View Bookmarks'), + 'View Profile' => t('View Profile'), + 'Virtual Lists' => t('Virtual Lists'), + 'Webpages' => t('Webpages'), + 'Wiki' => t('Wiki'), + 'ZotPost' => t('ZotPost'), + ); + + if (array_key_exists('name', $arr)) { + if (array_key_exists($arr['name'], $apps)) { + $arr['name'] = $apps[$arr['name']]; + } + } else { + for ($x = 0; $x < count($arr); $x++) { + if (array_key_exists($arr[$x]['name'], $apps)) { + $arr[$x]['name'] = $apps[$arr[$x]['name']]; + } else { + // Try to guess by app name if not in list + $arr[$x]['name'] = t(trim($arr[$x]['name'])); + } + } + } + } + + + // papp is a portable app + + public static function app_render($papp, $mode = 'view') + { + + /** + * modes: + * view: normal mode for viewing an app via bbcode from a conversation or page + * provides install/update button if you're logged in locally + * install: like view but does not display app-bin options if they are present + * list: normal mode for viewing an app on the app page + * no buttons are shown + * edit: viewing the app page in editing mode provides a delete button + * nav: render apps for app-bin + */ + + $channel_id = local_channel(); + $sys_channel = Channel::is_system($channel_id); + + $installed = false; + + if (!$papp) { + return; + } + + if (!$papp['photo']) { + $papp['photo'] = 'icon:gear'; + } + + self::translate_system_apps($papp); + + if (isset($papp['plugin']) && trim($papp['plugin']) && (!Addon::is_installed(trim($papp['plugin'])))) { + return ''; + } + + $papp['papp'] = self::papp_encode($papp); + + // This will catch somebody clicking on a system "available" app that hasn't had the path macros replaced + // and they are allowed to see the app + + + if (strpos($papp['url'], '$baseurl') !== false || strpos($papp['url'], '$nick') !== false || strpos($papp['photo'], '$baseurl') !== false || strpos($papp['photo'], '$nick') !== false) { + $view_channel = $channel_id; + if (!$view_channel) { + $sys = Channel::get_system(); + $view_channel = $sys['channel_id']; + } + self::app_macros($view_channel, $papp); + } + + if (strpos($papp['url'], ',')) { + $urls = explode(',', $papp['url']); + $papp['url'] = trim($urls[0]); + $papp['settings_url'] = trim($urls[1]); + } + + if (!strstr($papp['url'], '://')) { + $papp['url'] = z_root() . ((strpos($papp['url'], '/') === 0) ? '' : '/') . $papp['url']; + } + + + foreach ($papp as $k => $v) { + if (strpos($v, 'http') === 0 && $k != 'papp') { + if (!($channel_id && strpos($v, z_root()) === 0)) { + $papp[$k] = zid($v); + } + } + if ($k === 'desc') { + $papp['desc'] = str_replace(array('\'', '"'), array(''', '&dquot;'), $papp['desc']); + } + + if ($k === 'requires') { + $requires = explode(',', $v); + + foreach ($requires as $require) { + $require = trim(strtolower($require)); + $config = false; + + if (substr($require, 0, 7) == 'config:') { + $config = true; + $require = ltrim($require, 'config:'); + $require = explode('=', $require); + } + + switch ($require) { + case 'nologin': + if ($channel_id) { + return ''; + } + break; + case 'admin': + if (!(is_site_admin() || $sys_channel)) { + return ''; + } + break; + case 'local_channel': + if (!$channel_id) { + return ''; + } + break; + case 'public_profile': + if (!Channel::is_public_profile()) { + return ''; + } + break; + case 'public_stream': + if (!can_view_public_stream()) { + return ''; + } + break; + case 'custom_role': + if (get_pconfig($channel_id, 'system', 'permissions_role') != 'custom') { + return ''; + } + break; + case 'observer': + $observer = App::get_observer(); + if (!$observer) { + return ''; + } + break; + default: + if ($config) { + $unset = ((get_config('system', $require[0]) === $require[1]) ? false : true); + } else { + $unset = (($channel_id && Features::enabled($channnel_id, $require)) ? false : true); + } + if ($unset) { + return ''; + } + break; + } + } + } + } + + $hosturl = ''; + + if ($channel_id || $sys_channel) { + if (self::app_installed(($sys_channel) ? 0 : $channel_id, $papp)) { + $installed = true; + if ($mode === 'install') { + return ''; + } + } + $hosturl = z_root() . '/'; + } elseif (remote_channel()) { + $observer = App::get_observer(); + if ($observer && in_array($observer['xchan_network'],['nomad','zot6'])) { + // some folks might have xchan_url redirected offsite, use the connurl + $x = parse_url($observer['xchan_connurl']); + if ($x) { + $hosturl = $x['scheme'] . '://' . $x['host'] . '/'; + } + } + } + + $install_action = (($installed) ? t('Installed') : t('Install')); + $icon = ((strpos($papp['photo'], 'icon:') === 0) ? substr($papp['photo'], 5) : ''); + + if ($mode === 'navbar') { + return replace_macros(Theme::get_template('app_nav.tpl'), [ + '$app' => $papp, + '$icon' => $icon, + ]); + } + + if ($mode === 'install') { + $papp['embed'] = true; + } + + $featured = $pinned = false; + if (isset($papp['categories'])) { + $featured = ((strpos($papp['categories'], 'nav_featured_app') !== false) ? true : false); + $pinned = ((strpos($papp['categories'], 'nav_pinned_app') !== false) ? true : false); + } + + return replace_macros(Theme::get_template('app.tpl'), [ + '$app' => $papp, + '$icon' => $icon, + '$hosturl' => $hosturl, + '$purchase' => ((isset($papp['page']) && $papp['page'] && (!$installed)) ? t('Purchase') : ''), + '$installed' => $installed, + '$action_label' => (($hosturl && in_array($mode, ['view', 'install'])) ? $install_action : ''), + '$edit' => (($channel_id && $installed && $mode === 'edit') ? t('Edit') : ''), + '$delete' => (($channel_id && $installed && $mode === 'edit') ? t('Delete') : ''), + '$undelete' => (($channel_id && $installed && $mode === 'edit') ? t('Undelete') : ''), + '$settings_url' => (($channel_id && $installed && $mode === 'list' && isset($papp['settings_url'])) ? $papp['settings_url'] : ''), + '$deleted' => ((isset($papp['deleted'])) ? intval($papp['deleted']) : false), + '$feature' => (((isset($papp['embed']) && $papp['embed']) || $mode === 'edit') ? false : true), + '$pin' => (((isset($papp['embed']) && $papp['embed']) || $mode === 'edit') ? false : true), + '$featured' => $featured, + '$pinned' => $pinned, + '$navapps' => (($mode === 'nav') ? true : false), + '$order' => (($mode === 'nav-order' || $mode === 'nav-order-pinned') ? true : false), + '$mode' => $mode, + '$add' => t('Add to app-tray'), + '$remove' => t('Remove from app-tray'), + '$add_nav' => t('Pin to navbar'), + '$remove_nav' => t('Unpin from navbar') + ]); + } + + public static function app_install($uid, $app) + { + + if (!is_array($app)) { + $r = q( + "select * from app where app_name = '%s' and app_channel = 0", + dbesc($app) + ); + if (!$r) { + return false; + } + + $app = self::app_encode($r[0]); + } + + $app['uid'] = $uid; + + if (self::app_installed($uid, $app, true)) { + // preserve the existing deleted status across app updates + if (isset($app['guid'])) { + $check = q("select * from app where app_id = '%s' and app_channel = %d", + dbesc($app['guid']), + intval($uid) + ); + if ($check) { + $app['deleted'] = intval($check[0]['app_deleted']); + } + } + $x = self::app_update($app); + } else { + $x = self::app_store($app); + } + + if ($x['success']) { + $r = q( + "select * from app where app_id = '%s' and app_channel = %d limit 1", + dbesc($x['app_id']), + intval($uid) + ); + if ($r) { + if (($app['uid']) && (!$r[0]['app_system'])) { + if ($app['categories'] && (!$app['term'])) { + $r[0]['term'] = q( + "select * from term where otype = %d and oid = %d", + intval(TERM_OBJ_APP), + intval($r[0]['id']) + ); + if (intval($r[0]['app_system'])) { + Libsync::build_sync_packet($uid, array('sysapp' => $r[0])); + } else { + Libsync::build_sync_packet($uid, array('app' => $r[0])); + } + } + } + } + return $x['app_id']; + } + return false; + } + + + public static function can_delete($uid, $app) + { + // $uid 0 cannot delete, only archive + + if (!$uid) { + return false; + } + + $base_apps = self::get_base_apps(); + if ($base_apps) { + foreach ($base_apps as $b) { + if ($app['guid'] === hash('whirlpool', $b)) { + return false; + } + } + } + return true; + } + + + public static function app_destroy($uid, $app) + { + + if ($app['guid']) { + $x = q( + "select * from app where app_id = '%s' and app_channel = %d limit 1", + dbesc($app['guid']), + intval($target_uid) + ); + if ($x) { + if (!intval($x[0]['app_deleted'])) { + $x[0]['app_deleted'] = 1; + if (self::can_delete($uid, $app)) { + $r = q( + "delete from app where app_id = '%s' and app_channel = %d", + dbesc($app['guid']), + intval($uid) + ); + q( + "delete from term where otype = %d and oid = %d", + intval(TERM_OBJ_APP), + intval($x[0]['id']) + ); + Hook::call('app_destroy', $x[0]); + } else { + $r = q( + "update app set app_deleted = 1 where app_id = '%s' and app_channel = %d", + dbesc($app['guid']), + intval($target_uid) + ); + } + if ($uid) { + if (intval($x[0]['app_system'])) { + Libsync::build_sync_packet($uid, array('sysapp' => $x)); + } else { + Libsync::build_sync_packet($uid, array('app' => $x)); + } + } + } else { + self::app_undestroy($uid, $app); + } + } + } + } + + public static function app_undestroy($uid, $app) + { + + // undelete a system app + + if ($app['guid']) { + $x = q( + "select * from app where app_id = '%s' and app_channel = %d limit 1", + dbesc($app['guid']), + intval($uid) + ); + if ($x) { + if ($x[0]['app_system']) { + $r = q( + "update app set app_deleted = 0 where app_id = '%s' and app_channel = %d", + dbesc($app['guid']), + intval($uid) + ); + } + } + } + } + + public static function app_feature($uid, $app, $term) + { + $r = q( + "select id from app where app_id = '%s' and app_channel = %d limit 1", + dbesc($app['guid']), + intval($uid) + ); + + $x = q( + "select * from term where otype = %d and oid = %d and term = '%s' limit 1", + intval(TERM_OBJ_APP), + intval($r[0]['id']), + dbesc($term) + ); + + if ($x) { + q( + "delete from term where otype = %d and oid = %d and term = '%s'", + intval(TERM_OBJ_APP), + intval($x[0]['oid']), + dbesc($term) + ); + } else { + store_item_tag($uid, $r[0]['id'], TERM_OBJ_APP, TERM_CATEGORY, $term, escape_tags(z_root() . '/apps/?f=&cat=' . $term)); + } + } + + public static function app_installed($uid, $app, $bypass_filter = false) + { + $r = q( + "select id from app where app_id = '%s' and app_channel = %d limit 1", + dbesc((array_key_exists('guid', $app)) ? $app['guid'] : ''), + intval($uid) + ); + if (!$bypass_filter) { + $filter_arr = [ + 'uid' => $uid, + 'app' => $app, + 'installed' => $r + ]; + Hook::call('app_installed_filter', $filter_arr); + $r = $filter_arr['installed']; + } + + return (($r) ? true : false); + } + + public static function addon_app_installed($uid, $app, $bypass_filter = false) + { + + $r = q( + "select id from app where app_plugin = '%s' and app_channel = %d limit 1", + dbesc($app), + intval($uid) + ); + if (!$bypass_filter) { + $filter_arr = [ + 'uid' => $uid, + 'app' => $app, + 'installed' => $r + ]; + Hook::call('addon_app_installed_filter', $filter_arr); + $r = $filter_arr['installed']; + } + + return (($r) ? true : false); + } + + public static function system_app_installed($uid, $app, $bypass_filter = false) + { + + $r = q( + "select id from app where app_id = '%s' and app_channel = %d and app_deleted = 0 limit 1", + dbesc(hash('whirlpool', $app)), + intval($uid) + ); + if (!$bypass_filter) { + $filter_arr = [ + 'uid' => $uid, + 'app' => $app, + 'installed' => $r + ]; + Hook::call('system_app_installed_filter', $filter_arr); + $r = $filter_arr['installed']; + } + + return (($r) ? true : false); + } + + public static function app_list($uid, $deleted = false, $cats = []) + { + if ($deleted) { + $sql_extra = ""; + } else { + $sql_extra = " and app_deleted = 0 "; + } + if ($cats) { + $cat_sql_extra = " and ( "; + + foreach ($cats as $cat) { + if (strpos($cat_sql_extra, 'term')) { + $cat_sql_extra .= "or "; + } + + $cat_sql_extra .= "term = '" . dbesc($cat) . "' "; + } + + $cat_sql_extra .= ") "; + + $r = q( + "select oid from term where otype = %d $cat_sql_extra", + intval(TERM_OBJ_APP) + ); + if (!$r) { + return $r; + } + $sql_extra .= " and app.id in ( " . array_elm_to_str($r, 'oid') . ') '; + } + + $r = q( + "select * from app where app_channel = %d $sql_extra order by app_name asc", + intval($uid) + ); + + if ($r) { + $hookinfo = ['uid' => $uid, 'deleted' => $deleted, 'cats' => $cats, 'apps' => $r]; + Hook::call('app_list', $hookinfo); + $r = $hookinfo['apps']; + for ($x = 0; $x < count($r); $x++) { + if (!$r[$x]['app_system']) { + $r[$x]['type'] = 'personal'; + } + $r[$x]['term'] = q( + "select * from term where otype = %d and oid = %d", + intval(TERM_OBJ_APP), + intval($r[$x]['id']) + ); + } + } + + return ($r); + } + + + public static function app_search_available($str) + { + + // not yet finished + // somehow need to account for translations + + $r = q( + "select * from app where app_channel = 0 $sql_extra order by app_name asc", + intval($uid) + ); + + return ($r); + } + + + public static function app_order($uid, $apps, $menu) + { + + if (!$apps) { + return $apps; + } + + $conf = (($menu === 'nav_featured_app') ? 'app_order' : 'app_pin_order'); + + $x = (($uid) ? get_pconfig($uid, 'system', $conf) : get_config('system', $conf)); + if (($x) && (!is_array($x))) { + $y = explode(',', $x); + $y = array_map('trim', $y); + $x = $y; + } + + if (!(is_array($x) && ($x))) { + return $apps; + } + + $ret = []; + foreach ($x as $xx) { + $y = self::find_app_in_array($xx, $apps); + if ($y) { + $ret[] = $y; + } + } + foreach ($apps as $ap) { + if (!self::find_app_in_array($ap['name'], $ret)) { + $ret[] = $ap; + } + } + return $ret; + } + + public static function find_app_in_array($name, $arr) + { + if (!$arr) { + return false; + } + foreach ($arr as $x) { + if ($x['name'] === $name) { + return $x; + } + } + return false; + } + + public static function moveup($uid, $guid, $menu) + { + $syslist = []; + + $conf = (($menu === 'nav_featured_app') ? 'app_order' : 'app_pin_order'); + + $list = self::app_list($uid, false, [$menu]); + if ($list) { + foreach ($list as $li) { + $papp = self::app_encode($li); + $syslist[] = $papp; + } + } + self::translate_system_apps($syslist); + + usort($syslist, 'self::app_name_compare'); + + $syslist = self::app_order($uid, $syslist, $menu); + + if (!$syslist) { + return; + } + + $newlist = []; + + foreach ($syslist as $k => $li) { + if ($li['guid'] === $guid) { + $position = $k; + break; + } + } + if (!$position) { + return; + } + $dest_position = $position - 1; + $saved = $syslist[$dest_position]; + $syslist[$dest_position] = $syslist[$position]; + $syslist[$position] = $saved; + + $narr = []; + foreach ($syslist as $x) { + $narr[] = $x['name']; + } + + set_pconfig($uid, 'system', $conf, implode(',', $narr)); + } + + public static function movedown($uid, $guid, $menu) + { + $syslist = []; + + $conf = (($menu === 'nav_featured_app') ? 'app_order' : 'app_pin_order'); + + $list = self::app_list($uid, false, [$menu]); + if ($list) { + foreach ($list as $li) { + $papp = self::app_encode($li); + $syslist[] = $papp; + } + } + self::translate_system_apps($syslist); + + usort($syslist, 'self::app_name_compare'); + + $syslist = self::app_order($uid, $syslist, $menu); + + if (!$syslist) { + return; + } + + $newlist = []; + + foreach ($syslist as $k => $li) { + if ($li['guid'] === $guid) { + $position = $k; + break; + } + } + if ($position >= count($syslist) - 1) { + return; + } + $dest_position = $position + 1; + $saved = $syslist[$dest_position]; + $syslist[$dest_position] = $syslist[$position]; + $syslist[$position] = $saved; + + $narr = []; + foreach ($syslist as $x) { + $narr[] = $x['name']; + } + + set_pconfig($uid, 'system', $conf, implode(',', $narr)); + } + + public static function app_decode($s) + { + $x = base64_decode(str_replace(array('
', "\r", "\n", ' '), array('', '', '', ''), $s)); + return json_decode($x, true); + } + + + public static function app_macros($uid, &$arr) + { + + if (!intval($uid)) { + return; + } + + $baseurl = z_root(); + $channel = Channel::from_id($uid); + $address = (($channel) ? $channel['channel_address'] : ''); + + // future expansion + + $observer = App::get_observer(); + + $arr['url'] = str_replace(array('$baseurl', '$nick'), array($baseurl, $address), $arr['url']); + $arr['photo'] = str_replace(array('$baseurl', '$nick'), array($baseurl, $address), $arr['photo']); + } + + + public static function app_store($arr) + { + + // logger('app_store: ' . print_r($arr,true)); + + $darray = []; + $ret = ['success' => false]; + + $sys = Channel::get_system(); + + self::app_macros($arr['uid'], $arr); + + $darray['app_url'] = ((x($arr, 'url')) ? $arr['url'] : ''); + $darray['app_channel'] = ((x($arr, 'uid')) ? $arr['uid'] : 0); + + if (!$darray['app_url']) { + return $ret; + } + + if ((!$arr['uid']) && (!$arr['author'])) { + $arr['author'] = $sys['channel_hash']; + } + + if ($arr['photo'] && (strpos($arr['photo'], 'icon:') === false) && (strpos($arr['photo'], z_root()) === false)) { + $x = import_remote_xchan_photo(str_replace('$baseurl', z_root(), $arr['photo']), get_observer_hash(), true); + if ((!$x) || ($x[4])) { + // $x[4] = true indicates storage failure of our cached/resized copy. If this failed, just keep the original url. + $arr['photo'] = $x[1]; + } + } + + + $darray['app_id'] = ((x($arr, 'guid')) ? $arr['guid'] : random_string() . '.' . App::get_hostname()); + $darray['app_sig'] = ((x($arr, 'sig')) ? $arr['sig'] : ''); + $darray['app_author'] = ((x($arr, 'author')) ? $arr['author'] : get_observer_hash()); + $darray['app_name'] = ((x($arr, 'name')) ? escape_tags($arr['name']) : t('Unknown')); + $darray['app_desc'] = ((x($arr, 'desc')) ? escape_tags($arr['desc']) : ''); + $darray['app_photo'] = ((x($arr, 'photo')) ? $arr['photo'] : z_root() . '/' . Channel::get_default_profile_photo(80)); + $darray['app_version'] = ((x($arr, 'version')) ? escape_tags($arr['version']) : ''); + $darray['app_addr'] = ((x($arr, 'addr')) ? escape_tags($arr['addr']) : ''); + $darray['app_price'] = ((x($arr, 'price')) ? escape_tags($arr['price']) : ''); + $darray['app_page'] = ((x($arr, 'page')) ? escape_tags($arr['page']) : ''); + $darray['app_plugin'] = ((x($arr, 'plugin')) ? escape_tags(trim($arr['plugin'])) : ''); + $darray['app_requires'] = ((x($arr, 'requires')) ? escape_tags($arr['requires']) : ''); + $darray['app_system'] = ((x($arr, 'system')) ? intval($arr['system']) : 0); + $darray['app_deleted'] = ((x($arr, 'deleted')) ? intval($arr['deleted']) : 0); + $darray['app_options'] = ((x($arr, 'options')) ? intval($arr['options']) : 0); + + $created = datetime_convert(); + + $r = q( + "insert into app ( app_id, app_sig, app_author, app_name, app_desc, app_url, app_photo, app_version, app_channel, app_addr, app_price, app_page, app_requires, app_created, app_edited, app_system, app_plugin, app_deleted, app_options ) values ( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %d, '%s', '%s', '%s', '%s', '%s', '%s', %d, '%s', %d, %d )", + dbesc($darray['app_id']), + dbesc($darray['app_sig']), + dbesc($darray['app_author']), + dbesc($darray['app_name']), + dbesc($darray['app_desc']), + dbesc($darray['app_url']), + dbesc($darray['app_photo']), + dbesc($darray['app_version']), + intval($darray['app_channel']), + dbesc($darray['app_addr']), + dbesc($darray['app_price']), + dbesc($darray['app_page']), + dbesc($darray['app_requires']), + dbesc($created), + dbesc($created), + intval($darray['app_system']), + dbesc($darray['app_plugin']), + intval($darray['app_deleted']), + intval($darray['app_options']) + ); + + if ($r) { + $ret['success'] = true; + $ret['app_id'] = $darray['app_id']; + } + + if ($arr['categories']) { + $x = q( + "select id from app where app_id = '%s' and app_channel = %d limit 1", + dbesc($darray['app_id']), + intval($darray['app_channel']) + ); + $y = explode(',', $arr['categories']); + if ($y) { + foreach ($y as $t) { + $t = trim($t); + if ($t) { + store_item_tag($darray['app_channel'], $x[0]['id'], TERM_OBJ_APP, TERM_CATEGORY, escape_tags($t), escape_tags(z_root() . '/apps/?f=&cat=' . escape_tags($t))); + } + } + } + } + + return $ret; + } + + + public static function app_update($arr) + { + + // logger('app_update: ' . print_r($arr,true)); + $darray = []; + $ret = ['success' => false]; + + self::app_macros($arr['uid'], $arr); + + + $darray['app_url'] = ((x($arr, 'url')) ? $arr['url'] : ''); + $darray['app_channel'] = ((x($arr, 'uid')) ? $arr['uid'] : 0); + $darray['app_id'] = ((x($arr, 'guid')) ? $arr['guid'] : 0); + + if ((!$darray['app_url']) || (!$darray['app_id'])) { + return $ret; + } + + if ($arr['photo'] && (strpos($arr['photo'], 'icon:') === false) && (strpos($arr['photo'], z_root()) === false)) { + $x = import_remote_xchan_photo(str_replace('$baseurl', z_root(), $arr['photo']), get_observer_hash(), true); + if ((!$x) || ($x[4])) { + // $x[4] = true indicates storage failure of our cached/resized copy. If this failed, just keep the original url. + $arr['photo'] = $x[1]; + } + } + + $darray['app_sig'] = ((x($arr, 'sig')) ? $arr['sig'] : ''); + $darray['app_author'] = ((x($arr, 'author')) ? $arr['author'] : get_observer_hash()); + $darray['app_name'] = ((x($arr, 'name')) ? escape_tags($arr['name']) : t('Unknown')); + $darray['app_desc'] = ((x($arr, 'desc')) ? escape_tags($arr['desc']) : ''); + $darray['app_photo'] = ((x($arr, 'photo')) ? $arr['photo'] : z_root() . '/' . Channel::get_default_profile_photo(80)); + $darray['app_version'] = ((x($arr, 'version')) ? escape_tags($arr['version']) : ''); + $darray['app_addr'] = ((x($arr, 'addr')) ? escape_tags($arr['addr']) : ''); + $darray['app_price'] = ((x($arr, 'price')) ? escape_tags($arr['price']) : ''); + $darray['app_page'] = ((x($arr, 'page')) ? escape_tags($arr['page']) : ''); + $darray['app_plugin'] = ((x($arr, 'plugin')) ? escape_tags(trim($arr['plugin'])) : ''); + $darray['app_requires'] = ((x($arr, 'requires')) ? escape_tags($arr['requires']) : ''); + $darray['app_system'] = ((x($arr, 'system')) ? intval($arr['system']) : 0); + $darray['app_deleted'] = ((x($arr, 'deleted')) ? intval($arr['deleted']) : 0); + $darray['app_options'] = ((x($arr, 'options')) ? intval($arr['options']) : 0); + + $edited = datetime_convert(); + + $r = q( + "update app set app_sig = '%s', app_author = '%s', app_name = '%s', app_desc = '%s', app_url = '%s', app_photo = '%s', app_version = '%s', app_addr = '%s', app_price = '%s', app_page = '%s', app_requires = '%s', app_edited = '%s', app_system = %d, app_plugin = '%s', app_deleted = %d, app_options = %d where app_id = '%s' and app_channel = %d", + dbesc($darray['app_sig']), + dbesc($darray['app_author']), + dbesc($darray['app_name']), + dbesc($darray['app_desc']), + dbesc($darray['app_url']), + dbesc($darray['app_photo']), + dbesc($darray['app_version']), + dbesc($darray['app_addr']), + dbesc($darray['app_price']), + dbesc($darray['app_page']), + dbesc($darray['app_requires']), + dbesc($edited), + intval($darray['app_system']), + dbesc($darray['app_plugin']), + intval($darray['app_deleted']), + intval($darray['app_options']), + dbesc($darray['app_id']), + intval($darray['app_channel']) + ); + if ($r) { + $ret['success'] = true; + $ret['app_id'] = $darray['app_id']; + } + + $x = q( + "select id from app where app_id = '%s' and app_channel = %d limit 1", + dbesc($darray['app_id']), + intval($darray['app_channel']) + ); + + // if updating an embed app and we don't have a 0 channel_id don't mess with any existing categories + + if (array_key_exists('embed', $arr) && intval($arr['embed']) && (intval($darray['app_channel']))) { + return $ret; + } + + if ($x) { + q( + "delete from term where otype = %d and oid = %d", + intval(TERM_OBJ_APP), + intval($x[0]['id']) + ); + if (isset($arr['categories']) && $arr['categories']) { + $y = explode(',', $arr['categories']); + if ($y) { + foreach ($y as $t) { + $t = trim($t); + if ($t) { + store_item_tag($darray['app_channel'], $x[0]['id'], TERM_OBJ_APP, TERM_CATEGORY, escape_tags($t), escape_tags(z_root() . '/apps/?f=&cat=' . escape_tags($t))); + } + } + } + } + } + + return $ret; + } + + + public static function app_encode($app, $embed = false) + { + + $ret = []; + + $ret['type'] = 'personal'; + + if (isset($app['app_id']) && $app['app_id']) { + $ret['guid'] = $app['app_id']; + } + if (isset($app['app_sig']) && $app['app_sig']) { + $ret['sig'] = $app['app_sig']; + } + if (isset($app['app_author']) && $app['app_author']) { + $ret['author'] = $app['app_author']; + } + if (isset($app['app_name']) && $app['app_name']) { + $ret['name'] = $app['app_name']; + } + if (isset($app['app_desc']) && $app['app_desc']) { + $ret['desc'] = $app['app_desc']; + } + if (isset($app['app_url']) && $app['app_url']) { + $ret['url'] = $app['app_url']; + } + if (isset($app['app_photo']) && $app['app_photo']) { + $ret['photo'] = $app['app_photo']; + } + if (isset($app['app_icon']) && $app['app_icon']) { + $ret['icon'] = $app['app_icon']; + } + if (isset($app['app_version']) && $app['app_version']) { + $ret['version'] = $app['app_version']; + } + if (isset($app['app_addr']) && $app['app_addr']) { + $ret['addr'] = $app['app_addr']; + } + if (isset($app['app_price']) && $app['app_price']) { + $ret['price'] = $app['app_price']; + } + if (isset($app['app_page']) && $app['app_page']) { + $ret['page'] = $app['app_page']; + } + if (isset($app['app_requires']) && $app['app_requires']) { + $ret['requires'] = $app['app_requires']; + } + if (isset($app['app_system']) && $app['app_system']) { + $ret['system'] = $app['app_system']; + } + if (isset($app['app_options']) && $app['app_options']) { + $ret['options'] = $app['app_options']; + } + if (isset($app['app_plugin']) && $app['app_plugin']) { + $ret['plugin'] = trim($app['app_plugin']); + } + if (isset($app['app_deleted']) && $app['app_deleted']) { + $ret['deleted'] = $app['app_deleted']; + } + if (isset($app['term']) && $app['term']) { + $ret['categories'] = array_elm_to_str($app['term'], 'term'); + } + + + if (!$embed) { + return $ret; + } + $ret['embed'] = true; + + if (array_key_exists('categories', $ret)) { + unset($ret['categories']); + } + + $j = json_encode($ret); + return '[app]' . chunk_split(base64_encode($j), 72, "\n") . '[/app]'; + } + + + public static function papp_encode($papp) + { + return chunk_split(base64_encode(json_encode($papp)), 72, "\n"); + } +} diff --git a/Code/Lib/Cache.php b/Code/Lib/Cache.php new file mode 100644 index 000000000..fb35158f3 --- /dev/null +++ b/Code/Lib/Cache.php @@ -0,0 +1,63 @@ + 191) { + return t('Name too long'); + } + + $arr = ['name' => $name]; + /** + * @hooks validate_channelname + * Used to validate the names used by a channel. + * * \e string \b name + * * \e string \b message - return error message + */ + Hook::call('validate_channelname', $arr); + + if (x($arr, 'message')) { + return $arr['message']; + } + } + + + /** + * @brief Create a system channel - which has no account attached. + * + */ + public static function create_system() + { + + // Ensure that there is a host keypair. + + if ((! get_config('system', 'pubkey')) || (! get_config('system', 'prvkey'))) { + $hostkey = Crypto::new_keypair(4096); + set_config('system', 'pubkey', $hostkey['pubkey']); + set_config('system', 'prvkey', $hostkey['prvkey']); + } + + $sys = self::get_system(); + + if ($sys) { + if (isset($sys['channel_pubkey']) && $sys['channel_pubkey'] === get_config('system', 'pubkey')) { + return; + } else { + // upgrade the sys channel and return + $pubkey = get_config('system', 'pubkey'); + $prvkey = get_config('system', 'prvkey'); + $guid_sig = Libzot::sign($sys['channel_guid'], $prvkey); + $hash = Libzot::make_xchan_hash($sys['channel_guid'], $pubkey); + + q( + "update channel set channel_guid_sig = '%s', channel_hash = '%s', channel_pubkey = '%s', channel_prvkey = '%s' where channel_id = %d", + dbesc($guid_sig), + dbesc($hash), + dbesc($pubkey), + dbesc($prvkey), + dbesc($sys['channel_id']) + ); + + q( + "update xchan set xchan_guid_sig = '%s', xchan_hash = '%s', xchan_pubkey = '%s', xchan_url = '%s' where xchan_hash = '%s'", + dbesc($guid_sig), + dbesc($hash), + dbesc($pubkey), + dbesc(z_root()), + dbesc($sys['channel_hash']) + ); + q( + "update hubloc set hubloc_guid_sig = '%s', hubloc_hash = '%s', hubloc_id_url = '%s', hubloc_url_sig = '%s', hubloc_url = '%s', hubloc_callback = '%s', hubloc_site_id = '%s', hubloc_orphancheck = 0, hubloc_error = 0, hubloc_deleted = 0 where hubloc_hash = '%s'", + dbesc($guid_sig), + dbesc($hash), + dbesc(z_root()), + dbesc(Libzot::sign(z_root(), $prvkey)), + dbesc(z_root()), + dbesc(z_root() . '/zot'), + dbesc(Libzot::make_xchan_hash(z_root(), $pubkey)), + dbesc($sys['channel_hash']) + ); + + q( + "update abook set abook_xchan = '%s' where abook_xchan = '%s'", + dbesc($hash), + dbesc($sys['channel_hash']) + ); + + q( + "update abconfig set xchan = '%s' where xchan = '%s'", + dbesc($hash), + dbesc($sys['channel_hash']) + ); + + return; + } + } + + self::create([ + 'account_id' => 'xxx', // Typecast trickery: account_id is required. This will create an identity with an (integer) account_id of 0 + 'nickname' => 'sys', + 'name' => 'System', + 'permissions_role' => 'social', + 'pageflags' => 0, + 'publish' => 0, + 'system' => 1 + ]); + } + + + /** + * @brief Returns the sys channel. + * + * @return array|bool + */ + public static function get_system() + { + + // App::$sys_channel caches this lookup + + if (is_array(App::$sys_channel)) { + return App::$sys_channel; + } + + $r = q("select * from channel left join xchan on channel_hash = xchan_hash where channel_system = 1 limit 1"); + + if ($r) { + App::$sys_channel = array_shift($r); + return App::$sys_channel; + } + return false; + } + + + /** + * @brief Checks if $channel_id is sys channel. + * + * @param int $channel_id + * @return bool + */ + public static function is_system($channel_id) + { + $s = self::get_system(); + if ($s) { + return (intval($s['channel_id']) === intval($channel_id)); + } + return false; + } + + + /** + * @brief Return the total number of channels on this site. + * + * No filtering is performed except to check PAGE_REMOVED. + * + * @returns int|booleean + * on error returns boolean false + */ + public static function channel_total() + { + $r = q("select channel_id from channel where channel_removed = 0"); + + if (is_array($r)) { + return count($r); + } + + return false; + } + + + /** + * @brief Create a new channel. + * + * Also creates the related xchan, hubloc, profile, and "self" abook records, + * and an empty "Friends" group/collection for the new channel. + * + * @param array $arr associative array with: + * * \e string \b name full name of channel + * * \e string \b nickname "email/url-compliant" nickname + * * \e int \b account_id to attach with this channel + * * [other identity fields as desired] + * + * @returns array + * 'success' => boolean true or false + * 'message' => optional error text if success is false + * 'channel' => if successful the created channel array + */ + public static function create($arr) + { + + $ret = array('success' => false); + + if (! $arr['account_id']) { + $ret['message'] = t('No account identifier'); + return $ret; + } + $ret = ServiceClass::identity_check_service_class($arr['account_id']); + if (!$ret['success']) { + return $ret; + } + // save this for auto_friending + $total_identities = $ret['total_identities']; + + $nick = mb_strtolower(trim($arr['nickname'])); + if (! $nick) { + $ret['message'] = t('Nickname is required.'); + return $ret; + } + + $name = escape_tags($arr['name']); + $pageflags = ((x($arr, 'pageflags')) ? intval($arr['pageflags']) : PAGE_NORMAL); + $system = ((x($arr, 'system')) ? intval($arr['system']) : 0); + $name_error = self::validate_channelname($arr['name']); + if ($name_error) { + $ret['message'] = $name_error; + return $ret; + } + + if ($nick === 'sys' && (! $system)) { + $ret['message'] = t('Reserved nickname. Please choose another.'); + return $ret; + } + + if (check_webbie(array($nick)) !== $nick) { + $ret['message'] = t('Nickname has unsupported characters or is already being used on this site.'); + return $ret; + } + + $guid = Libzot::new_uid($nick); + + if ($system) { + $key = [ 'pubkey' => get_config('system', 'pubkey'), 'prvkey' => get_config('system', 'prvkey') ]; + } else { + $key = Crypto::new_keypair(4096); + } + + $sig = Libzot::sign($guid, $key['prvkey']); + $hash = Libzot::make_xchan_hash($guid, $key['pubkey']); + + // Force a few things on the short term until we can provide a theme or app with choice + + $publish = 1; + + if (array_key_exists('publish', $arr)) { + $publish = intval($arr['publish']); + } + + $role_permissions = null; + $parent_channel_hash = EMPTY_STR; + + if (array_key_exists('permissions_role', $arr) && $arr['permissions_role']) { + $role_permissions = PermissionRoles::role_perms($arr['permissions_role']); + if (isset($role_permissions['channel_type']) && $role_permissions['channel_type'] === 'collection') { + $parent_channel_hash = $arr['parent_hash']; + } + } + + if ($role_permissions && array_key_exists('directory_publish', $role_permissions)) { + $publish = intval($role_permissions['directory_publish']); + } + + $primary = true; + + if (array_key_exists('primary', $arr)) { + $primary = intval($arr['primary']); + } + + $expire = 0; + + $r = self::channel_store_lowlevel( + [ + 'channel_account_id' => intval($arr['account_id']), + 'channel_primary' => intval($primary), + 'channel_name' => $name, + 'channel_parent' => $parent_channel_hash, + 'channel_address' => $nick, + 'channel_guid' => $guid, + 'channel_guid_sig' => $sig, + 'channel_hash' => $hash, + 'channel_prvkey' => $key['prvkey'], + 'channel_pubkey' => $key['pubkey'], + 'channel_pageflags' => intval($pageflags), + 'channel_system' => intval($system), + 'channel_expire_days' => intval($expire), + 'channel_timezone' => App::$timezone + ] + ); + + $r = q( + "select * from channel where channel_account_id = %d + and channel_guid = '%s' limit 1", + intval($arr['account_id']), + dbesc($guid) + ); + + if (! $r) { + $ret['message'] = t('Unable to retrieve created identity'); + return $ret; + } + + $a = q( + "select * from account where account_id = %d", + intval($arr['account_id']) + ); + + $photo_type = null; + + $z = [ + 'account' => $a[0], + 'channel' => $r[0], + 'photo_url' => '' + ]; + /** + * @hooks create_channel_photo + * * \e array \b account + * * \e array \b channel + * * \e string \b photo_url - Return value + */ + Hook::call('create_channel_photo', $z); + + // The site channel gets the project logo as a profile photo. + if ($arr['account_id'] === 'xxx') { + $photo_type = import_channel_photo_from_url(z_root() . '/images/' . PLATFORM_NAME . '.png', 0, $r[0]['channel_id']); + } + elseif ($z['photo_url']) { + $photo_type = import_channel_photo_from_url($z['photo_url'], $arr['account_id'], $r[0]['channel_id']); + } + + if ($role_permissions && array_key_exists('limits', $role_permissions)) { + $perm_limits = $role_permissions['limits']; + } else { + $perm_limits = site_default_perms(); + } + + foreach ($perm_limits as $p => $v) { + PermissionLimits::Set($r[0]['channel_id'], $p, $v); + } + + if ($role_permissions && array_key_exists('perms_auto', $role_permissions)) { + set_pconfig($r[0]['channel_id'], 'system', 'autoperms', intval($role_permissions['perms_auto'])); + } + + $ret['channel'] = $r[0]; + + if (intval($arr['account_id'])) { + self::set_default($arr['account_id'], $ret['channel']['channel_id'], false); + } + + // Create a verified hub location pointing to this site. + + $r = hubloc_store_lowlevel( + [ + 'hubloc_guid' => $guid, + 'hubloc_guid_sig' => $sig, + 'hubloc_id_url' => (($system) ? z_root() : Channel::url($ret['channel'])), + 'hubloc_hash' => $hash, + 'hubloc_addr' => self::get_webfinger($ret['channel']), + 'hubloc_primary' => intval($primary), + 'hubloc_url' => z_root(), + 'hubloc_url_sig' => Libzot::sign(z_root(), $ret['channel']['channel_prvkey']), + 'hubloc_site_id' => Libzot::make_xchan_hash(z_root(), get_config('system', 'pubkey')), + 'hubloc_host' => App::get_hostname(), + 'hubloc_callback' => z_root() . '/zot', + 'hubloc_sitekey' => get_config('system', 'pubkey'), + 'hubloc_network' => 'nomad', + 'hubloc_updated' => datetime_convert() + ] + ); + if (! $r) { + logger('Unable to store hub location'); + } + + $newuid = $ret['channel']['channel_id']; + + $r = xchan_store_lowlevel( + [ + 'xchan_hash' => $hash, + 'xchan_guid' => $guid, + 'xchan_guid_sig' => $sig, + 'xchan_pubkey' => $key['pubkey'], + 'xchan_photo_mimetype' => (($photo_type) ? $photo_type : 'image/png'), + 'xchan_photo_l' => z_root() . "/photo/profile/l/{$newuid}", + 'xchan_photo_m' => z_root() . "/photo/profile/m/{$newuid}", + 'xchan_photo_s' => z_root() . "/photo/profile/s/{$newuid}", + 'xchan_addr' => self::get_webfinger($ret['channel']), + 'xchan_url' => (($system) ? z_root() : Channel::url($ret['channel'])), + 'xchan_follow' => z_root() . '/follow?f=&url=%s', + 'xchan_connurl' => z_root() . '/poco/' . $ret['channel']['channel_address'], + 'xchan_name' => $ret['channel']['channel_name'], + 'xchan_network' => 'nomad', + 'xchan_updated' => datetime_convert(), + 'xchan_photo_date' => datetime_convert(), + 'xchan_name_date' => datetime_convert(), + 'xchan_system' => $system + ] + ); + + // Not checking return value. + // It's ok for this to fail if it's an imported channel, and therefore the hash is a duplicate + + $r = self::profile_store_lowlevel( + [ + 'aid' => intval($ret['channel']['channel_account_id']), + 'uid' => intval($newuid), + 'profile_guid' => new_uuid(), + 'profile_name' => t('Default Profile'), + 'is_default' => 1, + 'publish' => $publish, + 'fullname' => $ret['channel']['channel_name'], + 'photo' => z_root() . "/photo/profile/l/{$newuid}", + 'thumb' => z_root() . "/photo/profile/m/{$newuid}" + ] + ); + + if ($role_permissions) { + $myperms = ((array_key_exists('perms_connect', $role_permissions)) ? $role_permissions['perms_connect'] : [] ); + } else { + $x = PermissionRoles::role_perms('social'); + $myperms = $x['perms_connect']; + } + + $r = abook_store_lowlevel( + [ + 'abook_account' => intval($ret['channel']['channel_account_id']), + 'abook_channel' => intval($newuid), + 'abook_xchan' => $hash, + 'abook_closeness' => 0, + 'abook_created' => datetime_convert(), + 'abook_updated' => datetime_convert(), + 'abook_self' => 1 + ] + ); + + + $x = Permissions::serialise(Permissions::FilledPerms($myperms)); + set_abconfig($newuid, $hash, 'system', 'my_perms', $x); + + if (intval($ret['channel']['channel_account_id'])) { + // Save our permissions role so we can perhaps call it up and modify it later. + + if ($role_permissions) { + set_pconfig($newuid, 'system', 'permissions_role', $arr['permissions_role']); + if (array_key_exists('online', $role_permissions)) { + set_pconfig($newuid, 'system', 'hide_presence', 1 - intval($role_permissions['online'])); + } + if (array_key_exists('perms_auto', $role_permissions)) { + $autoperms = intval($role_permissions['perms_auto']); + set_pconfig($newuid, 'system', 'autoperms', $autoperms); + } + } + + // Create a group with yourself as a member. This allows somebody to use it + // right away as a default group for new contacts. + + $group_hash = AccessList::add($newuid, t('Friends')); + if ($group_hash) { + AccessList::member_add($newuid, t('Friends'), $ret['channel']['channel_hash']); + + // if our role_permissions indicate that we're using a default collection ACL, add it. + + if (is_array($role_permissions) && $role_permissions['default_collection']) { + $default_collection_str = '<' . $group_hash . '>'; + } + q( + "update channel set channel_default_group = '%s', channel_allow_gid = '%s' where channel_id = %d", + dbesc($group_hash), + dbesc(($default_collection_str) ? $default_collection_str : EMPTY_STR), + intval($newuid) + ); + } + + set_pconfig($ret['channel']['channel_id'], 'system', 'photo_path', '%Y/%Y-%m'); + set_pconfig($ret['channel']['channel_id'], 'system', 'attach_path', '%Y/%Y-%m'); + + // If this channel has a parent, auto follow them. + + if ($parent_channel_hash) { + $ch = self::from_hash($parent_channel_hash); + if ($ch) { + self::connect_and_sync($ret['channel'], self::get_webfinger($ch), true); + } + } + + // auto-follow any of the hub's pre-configured channel choices. + // Only do this if it's the first channel for this account; + // otherwise it could get annoying. Don't make this list too big + // or it will impact registration time. + + $accts = get_config('system', 'auto_follow'); + if (($accts) && (! $total_identities)) { + if (! is_array($accts)) { + $accts = array($accts); + } + + foreach ($accts as $acct) { + if (trim($acct)) { + $f = self::connect_and_sync($ret['channel'], trim($acct)); + if ($f['success']) { + $can_view_stream = their_perms_contains($ret['channel']['channel_id'], $f['abook']['abook_xchan'], 'view_stream'); + + // If we can view their stream, pull in some posts + + if (($can_view_stream) || ($f['abook']['xchan_network'] === 'rss')) { + Run::Summon([ 'Onepoll',$f['abook']['abook_id'] ]); + } + } + } + } + } + + /** + * @hooks create_identity + * Called when creating a channel. + * * \e int - The UID of the created identity + */ + + Hook::call('create_identity', $newuid); + + Run::Summon([ 'Directory', $ret['channel']['channel_id'] ]); + } + + $ret['success'] = true; + return $ret; + } + + + + public static function connect_and_sync($channel, $address, $sub_channel = false) + { + + if ((! $channel) || (! $address)) { + return false; + } + + $f = Connect::connect($channel, $address, $sub_channel); + if ($f['success']) { + $clone = []; + foreach ($f['abook'] as $k => $v) { + if (strpos($k, 'abook_') === 0) { + $clone[$k] = $v; + } + } + unset($clone['abook_id']); + unset($clone['abook_account']); + unset($clone['abook_channel']); + + $abconfig = load_abconfig($channel['channel_id'], $clone['abook_xchan']); + if ($abconfig) { + $clone['abconfig'] = $abconfig; + } + + Libsync::build_sync_packet($channel['channel_id'], [ 'abook' => [ $clone ] ], true); + return $f; + } + return false; + } + + + public static function change_channel_keys($channel) + { + + $ret = array('success' => false); + + $stored = []; + + $key = Crypto::new_keypair(4096); + + $sig = Libzot::sign($channel['channel_guid'], $key['prvkey']); + $hash = Libzot::make_xchan_hash($channel['channel_guid'], $channel['channel_pubkey']); + + $stored['old_guid'] = $channel['channel_guid']; + $stored['old_guid_sig'] = $channel['channel_guid_sig']; + $stored['old_key'] = $channel['channel_pubkey']; + $stored['old_hash'] = $channel['channel_hash']; + + $stored['new_key'] = $key['pubkey']; + $stored['new_sig'] = Libzot::sign($key['pubkey'], $channel['channel_prvkey']); + + // Save this info for the notifier to collect + + set_pconfig($channel['channel_id'], 'system', 'keychange', $stored); + + $r = q( + "update channel set channel_prvkey = '%s', channel_pubkey = '%s', channel_guid_sig = '%s', channel_hash = '%s' where channel_id = %d", + dbesc($key['prvkey']), + dbesc($key['pubkey']), + dbesc($sig), + dbesc($hash), + intval($channel['channel_id']) + ); + if (! $r) { + return $ret; + } + + $r = q( + "select * from channel where channel_id = %d", + intval($channel['channel_id']) + ); + + if (! $r) { + $ret['message'] = t('Unable to retrieve modified identity'); + return $ret; + } + + $modified = $r[0]; + + $h = q( + "select * from hubloc where hubloc_hash = '%s' and hubloc_url = '%s' ", + dbesc($stored['old_hash']), + dbesc(z_root()) + ); + + if ($h) { + foreach ($h as $hv) { + $hv['hubloc_guid_sig'] = $sig; + $hv['hubloc_hash'] = $hash; + $hv['hubloc_url_sig'] = Libzot::sign(z_root(), $modifed['channel_prvkey']); + hubloc_store_lowlevel($hv); + } + } + + $x = q( + "select * from xchan where xchan_hash = '%s' ", + dbesc($stored['old_hash']) + ); + + $check = q( + "select * from xchan where xchan_hash = '%s'", + dbesc($hash) + ); + + if (($x) && (! $check)) { + $oldxchan = $x[0]; + foreach ($x as $xv) { + $xv['xchan_guid_sig'] = $sig; + $xv['xchan_hash'] = $hash; + $xv['xchan_pubkey'] = $key['pubkey']; + $xv['xchan_updated'] = datetime_convert(); + xchan_store_lowlevel($xv); + $newxchan = $xv; + } + } + + Libsync::build_sync_packet($channel['channel_id'], [ 'keychange' => $stored ]); + + $a = q( + "select * from abook where abook_xchan = '%s' and abook_self = 1", + dbesc($stored['old_hash']) + ); + + if ($a) { + q( + "update abook set abook_xchan = '%s' where abook_id = %d", + dbesc($hash), + intval($a[0]['abook_id']) + ); + } + + xchan_change_key($oldxchan, $newxchan, $stored); + + Run::Summon([ 'Notifier', 'keychange', $channel['channel_id'] ]); + + $ret['success'] = true; + return $ret; + } + + public static function change_address($channel, $new_address) + { + + $ret = array('success' => false); + + $old_address = $channel['channel_address']; + + if ($new_address === 'sys') { + $ret['message'] = t('Reserved nickname. Please choose another.'); + return $ret; + } + + if (check_webbie(array($new_address)) !== $new_address) { + $ret['message'] = t('Nickname has unsupported characters or is already being used on this site.'); + return $ret; + } + + $r = q( + "update channel set channel_address = '%s' where channel_id = %d", + dbesc($new_address), + intval($channel['channel_id']) + ); + if (! $r) { + return $ret; + } + + $r = q( + "select * from channel where channel_id = %d", + intval($channel['channel_id']) + ); + + if (! $r) { + $ret['message'] = t('Unable to retrieve modified identity'); + return $ret; + } + + $r = q( + "update xchan set xchan_addr = '%s' where xchan_hash = '%s'", + dbesc($new_address . '@' . App::get_hostname()), + dbesc($channel['channel_hash']) + ); + + $h = q( + "select * from hubloc where hubloc_hash = '%s' and hubloc_url = '%s' ", + dbesc($channel['channel_hash']), + dbesc(z_root()) + ); + + if ($h) { + foreach ($h as $hv) { + if ($hv['hubloc_primary']) { + q( + "update hubloc set hubloc_primary = 0 where hubloc_id = %d", + intval($hv['hubloc_id']) + ); + } + q( + "update hubloc set hubloc_deleted = 1 where hubloc_id = %d", + intval($hv['hubloc_id']) + ); + + unset($hv['hubloc_id']); + $hv['hubloc_addr'] = $new_address . '@' . App::get_hostname(); + hubloc_store_lowlevel($hv); + } + } + + // fix apps which were stored with the actual name rather than a macro + + $r = q( + "select * from app where app_channel = %d and app_system = 1", + intval($channel['channel_id']) + ); + if ($r) { + foreach ($r as $rv) { + $replace = preg_replace('/([\=\/])(' . $old_address . ')($|[\%\/])/ism', '$1' . $new_address . '$3', $rv['app_url']); + if ($replace != $rv['app_url']) { + q( + "update app set app_url = '%s' where id = %d", + dbesc($replace), + intval($rv['id']) + ); + } + } + } + + Run::Summon([ 'Notifier', 'refresh_all', $channel['channel_id'] ]); + + $ret['success'] = true; + return $ret; + } + + + /** + * @brief Set default channel to be used on login. + * + * @param int $account_id + * login account + * @param int $channel_id + * channel id to set as default for this account + * @param bool $force (optional) default true + * - if true, set this default unconditionally + * - if $force is false only do this if there is no existing default + */ + public static function set_default($account_id, $channel_id, $force = true) + { + $r = q( + "select account_default_channel from account where account_id = %d limit 1", + intval($account_id) + ); + if ($r) { + if ((intval($r[0]['account_default_channel']) == 0) || ($force)) { + $r = q( + "update account set account_default_channel = %d where account_id = %d", + intval($channel_id), + intval($account_id) + ); + } + } + } + + /** + * @brief Return an array with default list of sections to export. + * + * @return array with default section names to export + */ + public static function get_default_export_sections() + { + $sections = [ + 'channel', + 'connections', + 'config', + 'apps', + 'chatrooms', + 'events' + ]; + + $cb = [ 'sections' => $sections ]; + /** + * @hooks get_default_export_sections + * Called to get the default list of functional data groups to export in Channel::basic_export(). + * * \e array \b sections - return value + */ + Hook::call('get_default_export_sections', $cb); + + return $cb['sections']; + } + + + /** + * @brief Create an array representing the important channel information + * which would be necessary to create a nomadic identity clone. This includes + * most channel resources and connection information with the exception of content. + * + * @param int $channel_id + * Channel_id to export + * @param array $sections (optional) + * Which sections to include in the export, default see get_default_export_sections() + * @return array + * See function for details + */ + public static function basic_export($channel_id, $sections = null) + { + + /* + * basic channel export + */ + + if (! $sections) { + $sections = self::get_default_export_sections(); + } + + $ret = []; + + // use constants here as otherwise we will have no idea if we can import from a site + // with a non-standard platform and version. + + $ret['compatibility'] = [ + 'project' => PLATFORM_NAME, + 'codebase' => 'zap', + 'version' => STD_VERSION, + 'database' => DB_UPDATE_VERSION + ]; + + /* + * Process channel information regardless of it is one of the sections desired + * because we need the channel relocation information in all export files/streams. + */ + + $r = q( + "select * from channel where channel_id = %d limit 1", + intval($channel_id) + ); + if ($r) { + $ret['relocate'] = [ 'channel_address' => $r[0]['channel_address'], 'url' => z_root()]; + if (in_array('channel', $sections)) { + $ret['channel'] = $r[0]; + unset($ret['channel']['channel_password']); + unset($ret['channel']['channel_salt']); + } + } + + if (in_array('channel', $sections) || in_array('profile', $sections)) { + $r = q( + "select * from profile where uid = %d", + intval($channel_id) + ); + if ($r) { + $ret['profile'] = $r; + } + + $r = q( + "select mimetype, content, os_storage from photo + where imgscale = 4 and photo_usage = %d and uid = %d limit 1", + intval(PHOTO_PROFILE), + intval($channel_id) + ); + + if ($r) { + $ret['photo'] = [ + 'type' => $r[0]['mimetype'], + 'data' => (($r[0]['os_storage']) + ? base64url_encode(file_get_contents($r[0]['content'])) : base64url_encode(dbunescbin($r[0]['content']))) + ]; + } + } + + if (in_array('connections', $sections)) { + $r = q( + "select * from atoken where atoken_uid = %d", + intval($channel_id) + ); + if ($r) { + $ret['atoken'] = $r; + } + + $xchans = []; + $r = q( + "select * from abook where abook_channel = %d ", + intval($channel_id) + ); + if ($r) { + $ret['abook'] = $r; + + for ($x = 0; $x < count($ret['abook']); $x++) { + $xchans[] = $ret['abook'][$x]['abook_xchan']; + $abconfig = load_abconfig($channel_id, $ret['abook'][$x]['abook_xchan']); + if ($abconfig) { + $ret['abook'][$x]['abconfig'] = $abconfig; + } + } + stringify_array_elms($xchans); + } + + if ($xchans) { + $r = q("select * from xchan where xchan_hash in ( " . implode(',', $xchans) . " ) "); + if ($r) { + $ret['xchan'] = $r; + } + + $r = q("select * from hubloc where hubloc_hash in ( " . implode(',', $xchans) . " ) "); + if ($r) { + $ret['hubloc'] = $r; + } + } + + $r = q( + "select * from pgrp where uid = %d ", + intval($channel_id) + ); + + if ($r) { + $ret['group'] = $r; + } + + $r = q( + "select * from pgrp_member where uid = %d ", + intval($channel_id) + ); + if ($r) { + $ret['group_member'] = $r; + } + + $r = q( + "select * from xign where uid = %d ", + intval($channel_id) + ); + if ($r) { + $ret['xign'] = $r; + } + + $r = q( + "select * from block where block_channel_id = %d ", + intval($channel_id) + ); + if ($r) { + $ret['block'] = $r; + } + } + + if (in_array('config', $sections)) { + $r = q( + "select * from pconfig where uid = %d", + intval($channel_id) + ); + if ($r) { + $ret['config'] = $r; + } + + // All other term types will be included in items, if requested. + + $r = q( + "select * from term where ttype in (%d,%d) and uid = %d", + intval(TERM_SAVEDSEARCH), + intval(TERM_THING), + intval($channel_id) + ); + if ($r) { + $ret['term'] = $r; + } + // add psuedo-column obj_baseurl to aid in relocations + + $r = q( + "select obj.*, '%s' as obj_baseurl from obj where obj_channel = %d", + dbesc(z_root()), + intval($channel_id) + ); + + if ($r) { + $ret['obj'] = $r; + } + + $r = q( + "select * from likes where channel_id = %d", + intval($channel_id) + ); + + if ($r) { + $ret['likes'] = $r; + } + } + + if (in_array('apps', $sections)) { + $r = q( + "select * from app where app_channel = %d and app_system = 0", + intval($channel_id) + ); + if ($r) { + for ($x = 0; $x < count($r); $x++) { + $r[$x]['term'] = q( + "select * from term where otype = %d and oid = %d", + intval(TERM_OBJ_APP), + intval($r[$x]['id']) + ); + } + $ret['app'] = $r; + } + $r = q( + "select * from app where app_channel = %d and app_system = 1", + intval($channel_id) + ); + if ($r) { + for ($x = 0; $x < count($r); $x++) { + $r[$x]['term'] = q( + "select * from term where otype = %d and oid = %d", + intval(TERM_OBJ_APP), + intval($r[$x]['id']) + ); + } + $ret['sysapp'] = $r; + } + } + + if (in_array('chatrooms', $sections)) { + $r = q( + "select * from chatroom where cr_uid = %d", + intval($channel_id) + ); + if ($r) { + $ret['chatroom'] = $r; + } + } + + if (in_array('events', $sections)) { + $r = q( + "select * from event where uid = %d", + intval($channel_id) + ); + if ($r) { + $ret['event'] = $r; + } + + $r = q( + "select * from item where resource_type = 'event' and uid = %d", + intval($channel_id) + ); + if ($r) { + $ret['event_item'] = []; + xchan_query($r); + $r = fetch_post_tags($r, true); + foreach ($r as $rr) { + $ret['event_item'][] = encode_item($rr, true); + } + } + } + + if (in_array('items', $sections)) { + /** @warning this may run into memory limits on smaller systems */ + + /** export three months of posts. If you want to export and import all posts you have to start with + * the first year and export/import them in ascending order. + * + * Don't export linked resource items. we'll have to pull those out separately. + */ + + $r = q( + "select * from item where item_wall = 1 and item_deleted = 0 and uid = %d + and created > %s - INTERVAL %s and resource_type = '' order by created", + intval($channel_id), + db_utcnow(), + db_quoteinterval('3 MONTH') + ); + if ($r) { + $ret['item'] = []; + xchan_query($r); + $r = fetch_post_tags($r, true); + foreach ($r as $rr) { + $ret['item'][] = encode_item($rr, true); + } + } + } + + $addon = [ + 'channel_id' => $channel_id, + 'sections' => $sections, + 'data' => $ret + ]; + /** + * @hooks identity_basic_export + * Called when exporting a channel's basic information for backup or transfer. + * * \e number \b channel_id - The channel ID + * * \e array \b sections + * * \e array \b data - The data will get returned + */ + Hook::call('identity_basic_export', $addon); + $ret = $addon['data']; + + return $ret; + } + + /** + * @brief Export items for a year, or a month of a year. + * + * @param int $channel_id The channel ID + * @param number $year YYYY + * @param number $month (optional) 0-12, default 0 complete year + * @return array An associative array + * * \e array \b relocate - (optional) + * * \e array \b item - array with items encoded_item() + */ + public static function export_year($channel_id, $year, $month = 0) + { + + if (! $year) { + return []; + } + + if ($month && $month <= 12) { + $target_month = sprintf('%02d', $month); + $target_month_plus = sprintf('%02d', $month + 1); + } else { + $target_month = '01'; + } + + $mindate = datetime_convert('UTC', 'UTC', $year . '-' . $target_month . '-01 00:00:00'); + if ($month && $month < 12) { + $maxdate = datetime_convert('UTC', 'UTC', $year . '-' . $target_month_plus . '-01 00:00:00'); + } else { + $maxdate = datetime_convert('UTC', 'UTC', $year + 1 . '-01-01 00:00:00'); + } + + return self::export_items_date($channel_id, $mindate, $maxdate); + } + + /** + * @brief Export items within an arbitrary date range. + * + * Date/time is in UTC. + * + * @param int $channel_id The channel ID + * @param string $start + * @param string $finish + * @return array + */ + + public static function export_items_date($channel_id, $start, $finish) + { + + if (! $start) { + return []; + } else { + $start = datetime_convert('UTC', 'UTC', $start); + } + + $finish = datetime_convert('UTC', 'UTC', (($finish) ? $finish : 'now')); + if ($finish < $start) { + return []; + } + + $ret = []; + + $ch = self::from_id($channel_id); + if ($ch) { + $ret['relocate'] = [ 'channel_address' => $ch['channel_address'], 'url' => z_root()]; + } + + $ret['compatibility']['codebase'] = 'zap'; + + $r = q( + "select * from item where ( item_wall = 1 or item_type != %d ) and item_deleted = 0 and uid = %d and created >= '%s' and created <= '%s' and resource_type = '' order by created", + intval(ITEM_TYPE_POST), + intval($channel_id), + dbesc($start), + dbesc($finish) + ); + + if ($r) { + $ret['item'] = []; + xchan_query($r); + $r = fetch_post_tags($r, true); + foreach ($r as $rr) { + $ret['item'][] = encode_item($rr, true); + } + } + + return $ret; + } + + + + /** + * @brief Export items with pagination + * + * + * @param int $channel_id The channel ID + * @param int $page + * @param int $limit (default 50) + * @return array + */ + + public static function export_items_page($channel_id, $start, $finish, $page = 0, $limit = 50) + { + + if (intval($page) < 1) { + $page = 0; + } + + if (intval($limit) < 1) { + $limit = 1; + } + + if (intval($limit) > 5000) { + $limit = 5000; + } + + if (! $start) { + $start = NULL_DATE; + } else { + $start = datetime_convert('UTC', 'UTC', $start); + } + + $finish = datetime_convert('UTC', 'UTC', (($finish) ? $finish : 'now')); + if ($finish < $start) { + return []; + } + + $offset = intval($limit) * intval($page); + + $ret = []; + + $ch = self::from_id($channel_id); + if ($ch) { + $ret['relocate'] = [ 'channel_address' => $ch['channel_address'], 'url' => z_root()]; + } + + $ret['compatibility']['codebase'] = 'zap'; + + + $r = q( + "select * from item where ( item_wall = 1 or item_type != %d ) and item_deleted = 0 and uid = %d and resource_type = '' and created >= '%s' and created <= '%s' order by created limit %d offset %d", + intval(ITEM_TYPE_POST), + intval($channel_id), + dbesc($start), + dbesc($finish), + intval($limit), + intval($offset) + ); + + if ($r) { + $ret['item'] = []; + xchan_query($r); + $r = fetch_post_tags($r, true); + foreach ($r as $rr) { + $ret['item'][] = encode_item($rr, true); + } + } + + return $ret; + } + + + + public static function get_my_url() + { + if (x($_SESSION, 'zrl_override')) { + return $_SESSION['zrl_override']; + } + if (x($_SESSION, 'my_url')) { + return $_SESSION['my_url']; + } + + return false; + } + + public static function get_my_address() + { + if (x($_SESSION, 'zid_override')) { + return $_SESSION['zid_override']; + } + if (x($_SESSION, 'my_address')) { + return $_SESSION['my_address']; + } + + return false; + } + + /** + * @brief Add visitor's zid to our xchan and attempt authentication. + * + * If somebody arrives at our site using a zid, add their xchan to our DB if we + * don't have it already. + * And if they aren't already authenticated here, attempt reverse magic auth. + */ + public static function zid_init() + { + $tmp_str = self::get_my_address(); + if (validate_email($tmp_str)) { + $arr = [ + 'zid' => $tmp_str, + 'url' => App::$cmd + ]; + /** + * @hooks zid_init + * * \e string \b zid - their zid + * * \e string \b url - the destination url + */ + Hook::call('zid_init', $arr); + + if (! local_channel()) { + $r = q( + "select * from hubloc where hubloc_addr = '%s' order by hubloc_connected desc limit 1", + dbesc($tmp_str) + ); + if (! $r) { + Run::Summon([ 'Gprobe', bin2hex($tmp_str) ]); + } + if ($r && remote_channel() && remote_channel() === $r[0]['hubloc_hash']) { + return; + } + + logger('Not authenticated. Invoking reverse magic-auth for ' . $tmp_str); + // try to avoid recursion - but send them home to do a proper magic auth + $query = App::$query_string; + $query = str_replace(array('?zid=','&zid='), array('?rzid=','&rzid='), $query); + $dest = '/' . $query; + if ($r && ($r[0]['hubloc_url'] != z_root()) && (! strstr($dest, '/magic')) && (! strstr($dest, '/rmagic'))) { + goaway($r[0]['hubloc_url'] . '/magic' . '?f=&rev=1&owa=1&bdest=' . bin2hex(z_root() . $dest)); + } else { + logger('No hubloc found.'); + } + } + } + } + + /** + * @brief If somebody arrives at our site using a zat, authenticate them. + * + */ + public static function zat_init() + { + if (local_channel() || remote_channel()) { + return; + } + + $r = q( + "select * from atoken where atoken_token = '%s' limit 1", + dbesc($_REQUEST['zat']) + ); + if ($r) { + $xchan = atoken_xchan($r[0]); + atoken_login($xchan); + } + } + + public static function atoken_delete_and_sync($channel_id, $atoken_guid) + { + $r = q( + "select * from atoken where atoken_guid = '%s' and atoken_uid = %d", + dbesc($atoken_guid), + intval($channel_id) + ); + if ($r) { + $atok = array_shift($r); + $atok['deleted'] = true; + atoken_delete($atok['atoken_id']); + Libsync::build_sync_packet($channel_id, [ 'atoken' => [ $atok ] ]); + } + } + + /** + * @brief Used from within PCSS themes to set theme parameters. + * + * If there's a puid request variable, that is the "page owner" and normally + * their theme settings take precedence; unless a local user sets the "always_my_theme" + * system pconfig, which means they don't want to see anybody else's theme + * settings except their own while on this site. + * + * @return int + */ + public static function get_theme_uid() + { + $uid = ((isset($_REQUEST['puid'])) ? intval($_REQUEST['puid']) : 0); + if (local_channel()) { + if ((get_pconfig(local_channel(), 'system', 'always_my_theme')) || (! $uid)) { + return local_channel(); + } + } + if (! $uid) { + $x = self::get_system(); + if ($x) { + return $x['channel_id']; + } + } + + return $uid; + } + + /** + * @brief Retrieves the path of the default_profile_photo for this system + * with the specified size. + * + * @param int $size (optional) default 300 + * one of (300, 80, 48) + * @return string with path to profile photo + */ + public static function get_default_profile_photo($size = 300) + { + $scheme = get_config('system', 'default_profile_photo'); + if (! $scheme) { + $scheme = 'rainbow_man'; + } + + if (! is_dir('images/default_profile_photos/' . $scheme)) { + $x = [ 'scheme' => $scheme, 'size' => $size, 'url' => '' ]; + Hook::call('default_profile_photo', $x); + if ($x['url']) { + return $x['url']; + } else { + $scheme = 'rainbow_man'; + } + } + + return 'images/default_profile_photos/' . $scheme . '/' . $size . '.png'; + } + + /** + * @brief Test whether a given identity is NOT a member of the Hubzilla. + * + * @param string $s + * xchan_hash of the identity in question + * @return bool true or false + */ + public static function is_foreigner($s) + { + return((strpbrk($s, '.:@')) ? true : false); + } + + /** + * @brief Test whether a given identity is a member of the Hubzilla. + * + * @param string $s + * xchan_hash of the identity in question + * @return bool true or false + */ + public static function is_member($s) + { + return((self::is_foreigner($s)) ? false : true); + } + + /** + * @brief Get chatpresence status for nick. + * + * @param string $nick + * @return array An associative array with + * * \e bool \b result + */ + public static function get_online_status($nick) + { + + $ret = array('result' => false); + + if (observer_prohibited()) { + return $ret; + } + + $r = q( + "select channel_id, channel_hash from channel where channel_address = '%s' limit 1", + dbesc($nick) + ); + if ($r) { + $hide = get_pconfig($r[0]['channel_id'], 'system', 'hide_online_status'); + if ($hide) { + return $ret; + } + $x = q( + "select cp_status from chatpresence where cp_xchan = '%s' and cp_room = 0 limit 1", + dbesc($r[0]['channel_hash']) + ); + if ($x) { + $ret['result'] = $x[0]['cp_status']; + } + } + + return $ret; + } + + + /** + * @brief + * + * @param string $webbie + * @return array|bool|string + */ + public static function remote_online_status($webbie) + { + + $result = false; + $r = q( + "select * from hubloc where hubloc_addr = '%s' limit 1", + dbesc($webbie) + ); + if (! $r) { + return $result; + } + $url = $r[0]['hubloc_url'] . '/online/' . substr($webbie, 0, strpos($webbie, '@')); + + $x = z_fetch_url($url); + if ($x['success']) { + $j = json_decode($x['body'], true); + if ($j) { + $result = (($j['result']) ? $j['result'] : false); + } + } + + return $result; + } + + + /** + * @brief Return the parsed identity selector HTML template. + * + * @return string with parsed HTML channel_id_selet template + */ + public static function identity_selector() + { + if (local_channel()) { + $r = q( + "select channel.*, xchan.* from channel left join xchan on channel.channel_hash = xchan.xchan_hash where channel.channel_account_id = %d and channel_removed = 0 order by channel_name ", + intval(get_account_id()) + ); + if ($r && count($r) > 1) { + $o = replace_macros(Theme::get_template('channel_id_select.tpl'), array( + '$channels' => $r, + '$selected' => local_channel() + )); + + return $o; + } + } + + return ''; + } + + + public static function is_public_profile() + { + if (! local_channel()) { + return false; + } + + if (intval(get_config('system', 'block_public'))) { + return false; + } + + $channel = App::get_channel(); + if ($channel) { + $perm = PermissionLimits::Get($channel['channel_id'], 'view_profile'); + if ($perm == PERMS_PUBLIC) { + return true; + } + } + + return false; + } + + public static function get_profile_fields_basic($filter = 0) + { + + $profile_fields_basic = (($filter == 0) ? get_config('system', 'profile_fields_basic') : null); + + if (! $profile_fields_basic) { + $profile_fields_basic = array('fullname','pdesc','chandesc','basic_gender','pronouns','dob','dob_tz','region','country_name','marital','sexual','homepage','hometown','keywords','about','contact'); + } + + $x = []; + if ($profile_fields_basic) { + foreach ($profile_fields_basic as $f) { + $x[$f] = 1; + } + } + + return $x; + } + + + public static function get_profile_fields_advanced($filter = 0) + { + $basic = self::get_profile_fields_basic($filter); + $profile_fields_advanced = (($filter == 0) ? get_config('system', 'profile_fields_advanced') : null); + if (! $profile_fields_advanced) { + $profile_fields_advanced = array('comms', 'address','locality','postal_code','advanced_gender', 'partner','howlong','politic','religion','likes','dislikes','interest','channels','music','book','film','tv','romance','employment','education'); + } + $x = []; + if ($basic) { + foreach ($basic as $f => $v) { + $x[$f] = $v; + } + } + + if ($profile_fields_advanced) { + foreach ($profile_fields_advanced as $f) { + $x[$f] = 1; + } + } + + return $x; + } + + /** + * @brief Clear notifyflags for a channel. + * + * Most likely during bulk import of content or other activity that is likely + * to generate huge amounts of undesired notifications. + * + * @param int $channel_id + * The channel to disable notifications for + * @return int + * Current notification flag value. Send this to notifications_on() to restore the channel settings when finished + * with the activity requiring notifications_off(); + */ + public static function notifications_off($channel_id) + { + $r = q( + "select channel_notifyflags from channel where channel_id = %d limit 1", + intval($channel_id) + ); + q( + "update channel set channel_notifyflags = 0 where channel_id = %d", + intval($channel_id) + ); + + return intval($r[0]['channel_notifyflags']); + } + + + public static function notifications_on($channel_id, $value) + { + $x = q( + "update channel set channel_notifyflags = %d where channel_id = %d", + intval($value), + intval($channel_id) + ); + + return $x; + } + + + public static function get_default_perms($uid) + { + + $ret = []; + + $r = q( + "select abook_xchan from abook where abook_channel = %d and abook_self = 1 limit 1", + intval($uid) + ); + if ($r) { + $ret = Permissions::FilledPerms(get_abconfig($uid, $r[0]['abook_xchan'], 'system', 'my_perms', EMPTY_STR)); + } + + return $ret; + } + + + public static function profiles_build_sync($channel_id, $send = true) + { + $r = q( + "select * from profile where uid = %d", + intval($channel_id) + ); + if ($r) { + if ($send) { + Libsync::build_sync_packet($channel_id, array('profile' => $r)); + } else { + return $r; + } + } + } + + + public static function auto_create($account_id) + { + + if (! $account_id) { + return false; + } + + $arr = []; + $arr['account_id'] = $account_id; + $arr['name'] = get_aconfig($account_id, 'register', 'channel_name'); + $arr['nickname'] = legal_webbie(get_aconfig($account_id, 'register', 'channel_address')); + $arr['permissions_role'] = get_aconfig($account_id, 'register', 'permissions_role'); + + del_aconfig($account_id, 'register', 'channel_name'); + del_aconfig($account_id, 'register', 'channel_address'); + del_aconfig($account_id, 'register', 'permissions_role'); + + if ((! $arr['name']) || (! $arr['nickname'])) { + $x = q( + "select * from account where account_id = %d limit 1", + intval($account_id) + ); + if ($x) { + if (! $arr['name']) { + $arr['name'] = substr($x[0]['account_email'], 0, strpos($x[0]['account_email'], '@')); + } + if (! $arr['nickname']) { + $arr['nickname'] = legal_webbie(substr($x[0]['account_email'], 0, strpos($x[0]['account_email'], '@'))); + } + } + } + if (! $arr['permissions_role']) { + $arr['permissions_role'] = 'social'; + } + + if (self::validate_channelname($arr['name'])) { + return false; + } + if ($arr['nickname'] === 'sys') { + $arr['nickname'] = $arr['nickname'] . mt_rand(1000, 9999); + } + + $arr['nickname'] = check_webbie(array($arr['nickname'], $arr['nickname'] . mt_rand(1000, 9999))); + + return self::create($arr); + } + + public static function get_cover_photo($channel_id, $format = 'bbcode', $res = PHOTO_RES_COVER_1200) + { + + $r = q( + "select height, width, resource_id, edited, mimetype from photo where uid = %d and imgscale = %d and photo_usage = %d", + intval($channel_id), + intval($res), + intval(PHOTO_COVER) + ); + if (! $r) { + return false; + } + + $output = false; + + $url = z_root() . '/photo/' . $r[0]['resource_id'] . '-' . $res ; + + switch ($format) { + case 'bbcode': + $output = '[zrl=' . $r[0]['width'] . 'x' . $r[0]['height'] . ']' . $url . '[/zrl]'; + break; + case 'html': + $output = '' . t('cover photo') . ''; + break; + case 'array': + default: + $output = array( + 'width' => $r[0]['width'], + 'height' => $r[0]['height'], + 'type' => $r[0]['mimetype'], + 'updated' => $r[0]['edited'], + 'url' => $url + ); + break; + } + + return $output; + } + + + /** + * @brief Return parsed HTML zcard template. + * + * @param array $channel + * @param string $observer_hash (optional) + * @param array $args (optional) + * @return string parsed HTML from \e zcard template + */ + public static function get_zcard($channel, $observer_hash = '', $args = []) + { + + logger('get_zcard'); + + $maxwidth = (($args['width']) ? intval($args['width']) : 0); + $maxheight = (($args['height']) ? intval($args['height']) : 0); + + if (($maxwidth > 1200) || ($maxwidth < 1)) { + $maxwidth = 1200; + $cover_width = 1200; + } + + if ($maxwidth <= 425) { + $width = 425; + $cover_width = 425; + $size = 'hz_small'; + $cover_size = PHOTO_RES_COVER_425; + $pphoto = array('mimetype' => $channel['xchan_photo_mimetype'], 'width' => 80 , 'height' => 80, 'href' => $channel['xchan_photo_m'] . '?rev=' . strtotime($channel['xchan_photo_date'])); + } elseif ($maxwidth <= 900) { + $width = 900; + $cover_width = 850; + $size = 'hz_medium'; + $cover_size = PHOTO_RES_COVER_850; + $pphoto = array('mimetype' => $channel['xchan_photo_mimetype'], 'width' => 160 , 'height' => 160, 'href' => $channel['xchan_photo_l'] . '?rev=' . strtotime($channel['xchan_photo_date'])); + } elseif ($maxwidth <= 1200) { + $width = 1200; + $cover_width = 1200; + $size = 'hz_large'; + $cover_size = PHOTO_RES_COVER_1200; + $pphoto = array('mimetype' => $channel['xchan_photo_mimetype'], 'width' => 300 , 'height' => 300, 'href' => $channel['xchan_photo_l'] . '?rev=' . strtotime($channel['xchan_photo_date'])); + } + + // $scale = (float) $maxwidth / $width; + // $translate = intval(($scale / 1.0) * 100); + + $channel['channel_addr'] = self::get_webfinger($channel); + $zcard = array('chan' => $channel); + + $r = q( + "select height, width, resource_id, imgscale, mimetype from photo where uid = %d and imgscale = %d and photo_usage = %d", + intval($channel['channel_id']), + intval($cover_size), + intval(PHOTO_COVER) + ); + + if ($r) { + $cover = $r[0]; + $cover['href'] = z_root() . '/photo/' . $r[0]['resource_id'] . '-' . $r[0]['imgscale']; + } else { + $default_cover = get_config('system', 'default_cover_photo', 'pexels-94622'); + $cover = [ 'href' => z_root() . '/images/default_cover_photos/' . $default_cover . '/' . $cover_width . '.jpg' ]; + } + + $o .= replace_macros(Theme::get_template('zcard.tpl'), array( + '$maxwidth' => $maxwidth, + '$scale' => $scale, + '$translate' => $translate, + '$size' => $size, + '$cover' => $cover, + '$pphoto' => $pphoto, + '$zcard' => $zcard + )); + + return $o; + } + + + /** + * @brief Return parsed HTML embed zcard template. + * + * @param array $channel + * @param string $observer_hash (optional) + * @param array $args (optional) + * @return string parsed HTML from \e zcard_embed template + */ + public static function get_zcard_embed($channel, $observer_hash = '', $args = []) + { + + logger('get_zcard_embed'); + + $maxwidth = (($args['width']) ? intval($args['width']) : 0); + $maxheight = (($args['height']) ? intval($args['height']) : 0); + + if (($maxwidth > 1200) || ($maxwidth < 1)) { + $maxwidth = 1200; + $cover_width = 1200; + } + + if ($maxwidth <= 425) { + $width = 425; + $cover_width = 425; + $size = 'hz_small'; + $cover_size = PHOTO_RES_COVER_425; + $pphoto = array('mimetype' => $channel['xchan_photo_mimetype'], 'width' => 80 , 'height' => 80, 'href' => $channel['xchan_photo_m']); + } elseif ($maxwidth <= 900) { + $width = 900; + $cover_width = 850; + $size = 'hz_medium'; + $cover_size = PHOTO_RES_COVER_850; + $pphoto = array('mimetype' => $channel['xchan_photo_mimetype'], 'width' => 160 , 'height' => 160, 'href' => $channel['xchan_photo_l']); + } elseif ($maxwidth <= 1200) { + $width = 1200; + $cover_width = 1200; + $size = 'hz_large'; + $cover_size = PHOTO_RES_COVER_1200; + $pphoto = array('mimetype' => $channel['xchan_photo_mimetype'], 'width' => 300 , 'height' => 300, 'href' => $channel['xchan_photo_l']); + } + + $channel['channel_addr'] = self::get_webfinger($channel); + $zcard = array('chan' => $channel); + + $r = q( + "select height, width, resource_id, imgscale, mimetype from photo where uid = %d and imgscale = %d and photo_usage = %d", + intval($channel['channel_id']), + intval($cover_size), + intval(PHOTO_COVER) + ); + + if ($r) { + $cover = $r[0]; + $cover['href'] = z_root() . '/photo/' . $r[0]['resource_id'] . '-' . $r[0]['imgscale']; + } else { + $default_cover = get_config('system', 'default_cover_photo', 'pexels-94622'); + $cover = [ 'href' => z_root() . '/images/default_cover_photos/' . $default_cover . '/' . $cover_width . '.jpg' ]; + } + + $o .= replace_macros(Theme::get_template('zcard_embed.tpl'), array( + '$maxwidth' => $maxwidth, + '$scale' => $scale, + '$translate' => $translate, + '$size' => $size, + '$cover' => $cover, + '$pphoto' => $pphoto, + '$zcard' => $zcard + )); + + return $o; + } + + /** + * @brief Get a channel array from a channel nickname. + * + * @param string $nick - A channel_address nickname + * @return array|bool + * - array with channel entry + * - false if no channel with $nick was found + */ + + public static function from_username($nick, $include_removed = false) + { + + $sql_extra = (($include_removed) ? "" : " and channel_removed = 0 "); + + // If we are provided a Unicode nickname convert to IDN + + $nick = punify($nick); + + // return a cached copy if there is a cached copy and it's a match. + // Also check that there is an xchan_hash to validate the App::$channel data is complete + // and that columns from both joined tables are present + + if ( + App::$channel && is_array(App::$channel) && array_key_exists('channel_address', App::$channel) + && array_key_exists('xchan_hash', App::$channel) && App::$channel['channel_address'] === $nick + ) { + return App::$channel; + } + + $r = q( + "SELECT * FROM channel left join xchan on channel_hash = xchan_hash WHERE channel_address = '%s' $sql_extra LIMIT 1", + dbesc($nick) + ); + + return(($r) ? array_shift($r) : false); + } + + /** + * @brief Get a channel array by a channel_hash. + * + * @param string $hash + * @return array|bool false if channel ID not found, otherwise the channel array + */ + + public static function from_hash($hash, $include_removed = false) + { + + $sql_extra = (($include_removed) ? "" : " and channel_removed = 0 "); + + if ( + App::$channel && is_array(App::$channel) && array_key_exists('channel_hash', App::$channel) + && array_key_exists('xchan_hash', App::$channel) && App::$channel['channel_hash'] === $hash + ) { + return App::$channel; + } + + $r = q( + "SELECT * FROM channel left join xchan on channel_hash = xchan_hash WHERE channel_hash = '%s' $sql_extra LIMIT 1", + dbesc($hash) + ); + + return(($r) ? array_shift($r) : false); + } + + + /** + * @brief Get a channel array by a channel ID. + * + * @param int $id A channel ID + * @return array|bool false if channel ID not found, otherwise the channel array + */ + + public static function from_id($id, $include_removed = false) + { + + $sql_extra = (($include_removed) ? "" : " and channel_removed = 0 "); + + if ( + App::$channel && is_array(App::$channel) && array_key_exists('channel_id', App::$channel) + && array_key_exists('xchan_hash', App::$channel) && intval(App::$channel['channel_id']) === intval($id) + ) { + return App::$channel; + } + + $r = q( + "SELECT * FROM channel LEFT JOIN xchan ON channel_hash = xchan_hash WHERE channel_id = %d $sql_extra LIMIT 1", + dbesc($id) + ); + + return(($r) ? array_shift($r) : false); + } + + /** + * @brief + * + * @param array $channel + * @return string + */ + + public static function get_webfinger($channel) + { + if (! ($channel && array_key_exists('channel_address', $channel))) { + return ''; + } + + return strtolower($channel['channel_address'] . '@' . App::get_hostname()); + } + + + /** + * @brief Get manual channel conversation update config. + * + * Check the channel config \e manual_conversation_update, if not set fall back + * to global system config, defaults to 1 if nothing set. + * + * @param int $channel_id + * @return int + */ + + public static function manual_conv_update($channel_id) + { + + $x = get_pconfig($channel_id, 'system', 'manual_conversation_update', get_config('system', 'manual_conversation_update', 1)); + return intval($x); + } + + + /** + * @brief Return parsed HTML remote_login template. + * + * @return string with parsed HTML from \e remote_login template + */ + public static function remote_login() + { + $o = replace_macros(Theme::get_template('remote_login.tpl'), array( + '$title' => t('Remote Authentication'), + '$desc' => t('Enter your channel address (e.g. channel@example.com)'), + '$submit' => t('Authenticate') + )); + + return $o; + } + + public static function channel_store_lowlevel($arr) + { + $store = [ + 'channel_account_id' => ((array_key_exists('channel_account_id', $arr)) ? $arr['channel_account_id'] : '0'), + 'channel_primary' => ((array_key_exists('channel_primary', $arr)) ? $arr['channel_primary'] : '0'), + 'channel_name' => ((array_key_exists('channel_name', $arr)) ? $arr['channel_name'] : ''), + 'channel_parent' => ((array_key_exists('channel_parent', $arr)) ? $arr['channel_parent'] : ''), + 'channel_address' => ((array_key_exists('channel_address', $arr)) ? $arr['channel_address'] : ''), + 'channel_guid' => ((array_key_exists('channel_guid', $arr)) ? $arr['channel_guid'] : ''), + 'channel_guid_sig' => ((array_key_exists('channel_guid_sig', $arr)) ? $arr['channel_guid_sig'] : ''), + 'channel_hash' => ((array_key_exists('channel_hash', $arr)) ? $arr['channel_hash'] : ''), + 'channel_timezone' => ((array_key_exists('channel_timezone', $arr)) ? $arr['channel_timezone'] : 'UTC'), + 'channel_location' => ((array_key_exists('channel_location', $arr)) ? $arr['channel_location'] : ''), + 'channel_theme' => ((array_key_exists('channel_theme', $arr)) ? $arr['channel_theme'] : ''), + 'channel_startpage' => ((array_key_exists('channel_startpage', $arr)) ? $arr['channel_startpage'] : ''), + 'channel_pubkey' => ((array_key_exists('channel_pubkey', $arr)) ? $arr['channel_pubkey'] : ''), + 'channel_prvkey' => ((array_key_exists('channel_prvkey', $arr)) ? $arr['channel_prvkey'] : ''), + 'channel_notifyflags' => ((array_key_exists('channel_notifyflags', $arr)) ? $arr['channel_notifyflags'] : '65535'), + 'channel_pageflags' => ((array_key_exists('channel_pageflags', $arr)) ? $arr['channel_pageflags'] : '0'), + 'channel_dirdate' => ((array_key_exists('channel_dirdate', $arr)) ? $arr['channel_dirdate'] : NULL_DATE), + 'channel_lastpost' => ((array_key_exists('channel_lastpost', $arr)) ? $arr['channel_lastpost'] : NULL_DATE), + 'channel_deleted' => ((array_key_exists('channel_deleted', $arr)) ? $arr['channel_deleted'] : NULL_DATE), + 'channel_active' => ((array_key_exists('channel_active', $arr)) ? $arr['channel_active'] : NULL_DATE), + 'channel_max_anon_mail' => ((array_key_exists('channel_max_anon_mail', $arr)) ? $arr['channel_max_anon_mail'] : '10'), + 'channel_max_friend_req' => ((array_key_exists('channel_max_friend_req', $arr)) ? $arr['channel_max_friend_req'] : '10'), + 'channel_expire_days' => ((array_key_exists('channel_expire_days', $arr)) ? $arr['channel_expire_days'] : '0'), + 'channel_passwd_reset' => ((array_key_exists('channel_passwd_reset', $arr)) ? $arr['channel_passwd_reset'] : ''), + 'channel_default_group' => ((array_key_exists('channel_default_group', $arr)) ? $arr['channel_default_group'] : ''), + 'channel_allow_cid' => ((array_key_exists('channel_allow_cid', $arr)) ? $arr['channel_allow_cid'] : ''), + 'channel_allow_gid' => ((array_key_exists('channel_allow_gid', $arr)) ? $arr['channel_allow_gid'] : ''), + 'channel_deny_cid' => ((array_key_exists('channel_deny_cid', $arr)) ? $arr['channel_deny_cid'] : ''), + 'channel_deny_gid' => ((array_key_exists('channel_deny_gid', $arr)) ? $arr['channel_deny_gid'] : ''), + 'channel_removed' => ((array_key_exists('channel_removed', $arr)) ? $arr['channel_removed'] : '0'), + 'channel_system' => ((array_key_exists('channel_system', $arr)) ? $arr['channel_system'] : '0'), + 'channel_moved' => ((array_key_exists('channel_moved', $arr)) ? $arr['channel_moved'] : ''), + 'channel_password' => ((array_key_exists('channel_password', $arr)) ? $arr['channel_password'] : ''), + 'channel_salt' => ((array_key_exists('channel_salt', $arr)) ? $arr['channel_salt'] : '') + ]; + + return create_table_from_array('channel', $store); + } + + public static function profile_store_lowlevel($arr) + { + + $store = [ + 'profile_guid' => ((array_key_exists('profile_guid', $arr)) ? $arr['profile_guid'] : ''), + 'aid' => ((array_key_exists('aid', $arr)) ? $arr['aid'] : 0), + 'uid' => ((array_key_exists('uid', $arr)) ? $arr['uid'] : 0), + 'profile_name' => ((array_key_exists('profile_name', $arr)) ? $arr['profile_name'] : ''), + 'is_default' => ((array_key_exists('is_default', $arr)) ? $arr['is_default'] : 0), + 'hide_friends' => ((array_key_exists('hide_friends', $arr)) ? $arr['hide_friends'] : 0), + 'fullname' => ((array_key_exists('fullname', $arr)) ? $arr['fullname'] : ''), + 'pdesc' => ((array_key_exists('pdesc', $arr)) ? $arr['pdesc'] : ''), + 'chandesc' => ((array_key_exists('chandesc', $arr)) ? $arr['chandesc'] : ''), + 'dob' => ((array_key_exists('dob', $arr)) ? $arr['dob'] : ''), + 'dob_tz' => ((array_key_exists('dob_tz', $arr)) ? $arr['dob_tz'] : ''), + 'address' => ((array_key_exists('address', $arr)) ? $arr['address'] : ''), + 'locality' => ((array_key_exists('locality', $arr)) ? $arr['locality'] : ''), + 'region' => ((array_key_exists('region', $arr)) ? $arr['region'] : ''), + 'postal_code' => ((array_key_exists('postal_code', $arr)) ? $arr['postal_code'] : ''), + 'country_name' => ((array_key_exists('country_name', $arr)) ? $arr['country_name'] : ''), + 'hometown' => ((array_key_exists('hometown', $arr)) ? $arr['hometown'] : ''), + 'gender' => ((array_key_exists('gender', $arr)) ? $arr['gender'] : ''), + 'marital' => ((array_key_exists('marital', $arr)) ? $arr['marital'] : ''), + 'partner' => ((array_key_exists('partner', $arr)) ? $arr['partner'] : ''), + 'howlong' => ((array_key_exists('howlong', $arr)) ? $arr['howlong'] : NULL_DATE), + 'sexual' => ((array_key_exists('sexual', $arr)) ? $arr['sexual'] : ''), + 'pronouns' => ((array_key_exists('pronouns', $arr)) ? $arr['pronouns'] : ''), + 'politic' => ((array_key_exists('politic', $arr)) ? $arr['politic'] : ''), + 'religion' => ((array_key_exists('religion', $arr)) ? $arr['religion'] : ''), + 'keywords' => ((array_key_exists('keywords', $arr)) ? $arr['keywords'] : ''), + 'likes' => ((array_key_exists('likes', $arr)) ? $arr['likes'] : ''), + 'dislikes' => ((array_key_exists('dislikes', $arr)) ? $arr['dislikes'] : ''), + 'about' => ((array_key_exists('about', $arr)) ? $arr['about'] : ''), + 'summary' => ((array_key_exists('summary', $arr)) ? $arr['summary'] : ''), + 'music' => ((array_key_exists('music', $arr)) ? $arr['music'] : ''), + 'book' => ((array_key_exists('book', $arr)) ? $arr['book'] : ''), + 'tv' => ((array_key_exists('tv', $arr)) ? $arr['tv'] : ''), + 'film' => ((array_key_exists('film', $arr)) ? $arr['film'] : ''), + 'interest' => ((array_key_exists('interest', $arr)) ? $arr['interest'] : ''), + 'romance' => ((array_key_exists('romance', $arr)) ? $arr['romance'] : ''), + 'employment' => ((array_key_exists('employment', $arr)) ? $arr['employment'] : ''), + 'education' => ((array_key_exists('education', $arr)) ? $arr['education'] : ''), + 'contact' => ((array_key_exists('contact', $arr)) ? $arr['contact'] : ''), + 'channels' => ((array_key_exists('channels', $arr)) ? $arr['channels'] : ''), + 'homepage' => ((array_key_exists('homepage', $arr)) ? $arr['homepage'] : ''), + 'photo' => ((array_key_exists('photo', $arr)) ? $arr['photo'] : ''), + 'thumb' => ((array_key_exists('thumb', $arr)) ? $arr['thumb'] : ''), + 'publish' => ((array_key_exists('publish', $arr)) ? $arr['publish'] : 0), + 'profile_vcard' => ((array_key_exists('profile_vcard', $arr)) ? $arr['profile_vcard'] : '') + ]; + + return create_table_from_array('profile', $store); + } + + + + /** + * @brief Removes a channel. + * + * @param int $channel_id + * @param bool $local default true + * @param bool $unset_session default false + */ + + public static function channel_remove($channel_id, $local = true, $unset_session = false) + { + + if (! $channel_id) { + return; + } + + // global removal (all clones) not currently supported + // hence this operation _may_ leave orphan data on remote servers + + $local = true; + + logger('Removing channel: ' . $channel_id); + logger('local only: ' . intval($local)); + + + $r = q("select * from channel where channel_id = %d limit 1", intval($channel_id)); + if (! $r) { + logger('channel not found: ' . $channel_id); + return; + } + + $channel = $r[0]; + + /** + * @hooks channel_remove + * Called when removing a channel. + * * \e array with entry from channel tabel for $channel_id + */ + + Hook::call('channel_remove', $channel); + + $r = q( + "select iid from iconfig left join item on item.id = iconfig.iid + where item.uid = %d", + intval($channel_id) + ); + if ($r) { + foreach ($r as $rr) { + q( + "delete from iconfig where iid = %d", + intval($rr['iid']) + ); + } + } + + q("DELETE FROM app WHERE app_channel = %d", intval($channel_id)); + q("DELETE FROM atoken WHERE atoken_uid = %d", intval($channel_id)); + q("DELETE FROM chatroom WHERE cr_uid = %d", intval($channel_id)); + q("DELETE FROM conv WHERE uid = %d", intval($channel_id)); + + q("DELETE FROM pgrp WHERE uid = %d", intval($channel_id)); + q("DELETE FROM pgrp_member WHERE uid = %d", intval($channel_id)); + q("DELETE FROM event WHERE uid = %d", intval($channel_id)); + q("DELETE FROM menu WHERE menu_channel_id = %d", intval($channel_id)); + q("DELETE FROM menu_item WHERE mitem_channel_id = %d", intval($channel_id)); + + q("DELETE FROM notify WHERE uid = %d", intval($channel_id)); + q("DELETE FROM obj WHERE obj_channel = %d", intval($channel_id)); + + + q("DELETE FROM photo WHERE uid = %d", intval($channel_id)); + q("DELETE FROM attach WHERE uid = %d", intval($channel_id)); + q("DELETE FROM profile WHERE uid = %d", intval($channel_id)); + q("DELETE FROM source WHERE src_channel_id = %d", intval($channel_id)); + + $r = q("select hash FROM attach WHERE uid = %d", intval($channel_id)); + if ($r) { + foreach ($r as $rv) { + attach_delete($channel_id, $rv['hash']); + } + } + + + q( + "delete from abook where abook_xchan = '%s' and abook_self = 1 ", + dbesc($channel['channel_hash']) + ); + + $r = q( + "update channel set channel_deleted = '%s', channel_removed = 1 where channel_id = %d", + dbesc(datetime_convert()), + intval($channel_id) + ); + + // remove items + + Run::Summon([ 'Channel_purge', $channel_id ]); + + // if this was the default channel, set another one as default + + if (App::$account['account_default_channel'] == $channel_id) { + $r = q( + "select channel_id from channel where channel_account_id = %d and channel_removed = 0 limit 1", + intval(App::$account['account_id']), + intval(PAGE_REMOVED) + ); + if ($r) { + $rr = q( + "update account set account_default_channel = %d where account_id = %d", + intval($r[0]['channel_id']), + intval(App::$account['account_id']) + ); + logger("Default channel deleted, changing default to channel_id " . $r[0]['channel_id']); + } else { + $rr = q( + "update account set account_default_channel = 0 where account_id = %d", + intval(App::$account['account_id']) + ); + } + } + + logger('deleting hublocs', LOGGER_DEBUG); + + $r = q( + "update hubloc set hubloc_deleted = 1 where hubloc_hash = '%s' and hubloc_url = '%s' ", + dbesc($channel['channel_hash']), + dbesc(z_root()) + ); + + // Do we have any valid hublocs remaining? + + $hublocs = 0; + + $r = q( + "select hubloc_id from hubloc where hubloc_hash = '%s' and hubloc_deleted = 0", + dbesc($channel['channel_hash']) + ); + if ($r) { + $hublocs = count($r); + } + + if (! $hublocs) { + $r = q( + "update xchan set xchan_deleted = 1 where xchan_hash = '%s' ", + dbesc($channel['channel_hash']) + ); + // send a cleanup message to other servers + Run::Summon([ 'Notifier', 'purge_all', $channel_id ]); + } + + //remove from file system + + $f = 'store/' . $channel['channel_address']; + // This shouldn't happen but make sure the address isn't empty because that could do bad things + if (is_dir($f) && $channel['channel_address']) { + @rrmdir($f); + } + + Run::Summon([ 'Directory', $channel_id ]); + + if ($channel_id == local_channel() && $unset_session) { + App::$session->nuke(); + goaway(z_root()); + } + } + + // execute this at least a week after removing a channel + + public static function channel_remove_final($channel_id) + { + + q("delete from abook where abook_channel = %d", intval($channel_id)); + q("delete from abconfig where chan = %d", intval($channel_id)); + q("delete from pconfig where uid = %d", intval($channel_id)); + q("delete from channel where channel_id = %d", intval($channel_id)); + } + + + /** + * @brief This checks if a channel is allowed to publish executable code. + * + * It is up to the caller to determine if the observer or local_channel + * is in fact the resource owner whose channel_id is being checked. + * + * @param int $channel_id + * @return bool + */ + public static function codeallowed($channel_id) + { + if (! intval($channel_id)) { + return false; + } + + $x = self::from_id($channel_id); + if (($x) && ($x['channel_pageflags'] & PAGE_ALLOWCODE)) { + return true; + } + + return false; + } + + public static function anon_identity_init($reqvars) + { + + $x = [ + 'request_vars' => $reqvars, + 'xchan' => null, + 'success' => 'unset' + ]; + /** + * @hooks anon_identity_init + * * \e array \b request_vars + * * \e string \b xchan - return value + * * \e string|int \b success - Must be a number, so xchan return value gets used + */ + Hook::call('anon_identity_init', $x); + + if ($x['success'] !== 'unset' && intval($x['success']) && $x['xchan']) { + return $x['xchan']; + } + + // allow a captcha handler to over-ride + if ($x['success'] !== 'unset' && (intval($x['success']) === 0)) { + return false; + } + + + $anon_name = strip_tags(trim($reqvars['anonname'])); + $anon_email = strip_tags(trim($reqvars['anonmail'])); + $anon_url = strip_tags(trim($reqvars['anonurl'])); + + if (! ($anon_name && $anon_email)) { + logger('anonymous commenter did not complete form'); + return false; + } + + if (! validate_email($anon_email)) { + logger('enonymous email not valid'); + return false; + } + + if (! $anon_url) { + $anon_url = z_root(); + } + + $hash = hash('md5', $anon_email); + + $x = q( + "select * from xchan where xchan_guid = '%s' and xchan_hash = '%s' and xchan_network = 'anon' limit 1", + dbesc($anon_email), + dbesc($hash) + ); + + if (! $x) { + xchan_store_lowlevel([ + 'xchan_guid' => $anon_email, + 'xchan_hash' => $hash, + 'xchan_name' => $anon_name, + 'xchan_url' => $anon_url, + 'xchan_network' => 'anon', + 'xchan_updated' => datetime_convert(), + 'xchan_name_date' => datetime_convert() + ]); + + + $x = q( + "select * from xchan where xchan_guid = '%s' and xchan_hash = '%s' and xchan_network = 'anon' limit 1", + dbesc($anon_email), + dbesc($hash) + ); + + $photo = z_root() . '/' . self::get_default_profile_photo(300); + $photos = import_remote_xchan_photo($photo, $hash); + 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_guid = '%s' and xchan_hash = '%s' and xchan_network = 'anon' ", + dbesc(datetime_convert()), + dbesc(datetime_convert()), + dbesc($photos[0]), + dbesc($photos[1]), + dbesc($photos[2]), + dbesc($photos[3]), + dbesc($anon_email), + dbesc($hash) + ); + } + } + + return $x[0]; + } + + public static function url($channel) + { + + // data validation - if this is wrong, log the call stack so we can find the issue + + if (! is_array($channel)) { + btlogger('not a channel array: ' . print_r($channel, true)); + } + + if ($channel['channel_address'] === App::get_hostname() || intval($channel['channel_system'])) { + return z_root(); + } + + return (($channel) ? z_root() . '/channel/' . $channel['channel_address'] : z_root()); + } + + public static function is_group($uid) + { + $role = get_pconfig($uid, 'system', 'permissions_role'); + $rolesettings = PermissionRoles::role_perms($role); + return ((isset($rolesettings['channel_type']) && $rolesettings['channel_type'] === 'group') ? true : false); + } +} diff --git a/Code/Lib/Chatroom.php b/Code/Lib/Chatroom.php new file mode 100644 index 000000000..caa5c2ec1 --- /dev/null +++ b/Code/Lib/Chatroom.php @@ -0,0 +1,315 @@ + false); + + $name = trim($arr['name']); + if (!$name) { + $ret['message'] = t('Missing room name'); + return $ret; + } + + $r = q( + "select cr_id from chatroom where cr_uid = %d and cr_name = '%s' limit 1", + intval($channel['channel_id']), + dbesc($name) + ); + if ($r) { + $ret['message'] = t('Duplicate room name'); + return $ret; + } + + $r = q( + "select count(cr_id) as total from chatroom where cr_aid = %d", + intval($channel['channel_account_id']) + ); + if ($r) { + $limit = ServiceClass::fetch($channel['channel_id'], 'chatrooms'); + } + + if (($r) && ($limit !== false) && ($r[0]['total'] >= $limit)) { + $ret['message'] = ServiceClass::upgrade_message(); + return $ret; + } + + if (!array_key_exists('expire', $arr)) { + $arr['expire'] = 120; // minutes, e.g. 2 hours + } + + $created = datetime_convert(); + + $x = q( + "insert into chatroom ( cr_aid, cr_uid, cr_name, cr_created, cr_edited, cr_expire, allow_cid, allow_gid, deny_cid, deny_gid ) + values ( %d, %d , '%s', '%s', '%s', %d, '%s', '%s', '%s', '%s' ) ", + intval($channel['channel_account_id']), + intval($channel['channel_id']), + dbesc($name), + dbesc($created), + dbesc($created), + intval($arr['expire']), + dbesc($arr['allow_cid']), + dbesc($arr['allow_gid']), + dbesc($arr['deny_cid']), + dbesc($arr['deny_gid']) + ); + + if ($x) { + $ret['success'] = true; + } + + return $ret; + } + + + public static function destroy($channel, $arr) + { + + $ret = array('success' => false); + + if (intval($arr['cr_id'])) { + $sql_extra = " and cr_id = " . intval($arr['cr_id']) . " "; + } elseif (trim($arr['cr_name'])) { + $sql_extra = " and cr_name = '" . protect_sprintf(dbesc(trim($arr['cr_name']))) . "' "; + } else { + $ret['message'] = t('Invalid room specifier.'); + return $ret; + } + + $r = q( + "select * from chatroom where cr_uid = %d $sql_extra limit 1", + intval($channel['channel_id']) + ); + if (!$r) { + $ret['message'] = t('Invalid room specifier.'); + return $ret; + } + + Libsync::build_sync_packet($channel['channel_id'], array('chatroom' => $r)); + + q( + "delete from chatroom where cr_id = %d", + intval($r[0]['cr_id']) + ); + if ($r[0]['cr_id']) { + q( + "delete from chatpresence where cp_room = %d", + intval($r[0]['cr_id']) + ); + q( + "delete from chat where chat_room = %d", + intval($r[0]['cr_id']) + ); + } + + $ret['success'] = true; + return $ret; + } + + + public static function enter($observer_xchan, $room_id, $status, $client) + { + + if (!$room_id || !$observer_xchan) { + return; + } + + $r = q( + "select * from chatroom where cr_id = %d limit 1", + intval($room_id) + ); + if (!$r) { + notice(t('Room not found.') . EOL); + return false; + } + require_once('include/security.php'); + $sql_extra = permissions_sql($r[0]['cr_uid']); + + $x = q( + "select * from chatroom where cr_id = %d and cr_uid = %d $sql_extra limit 1", + intval($room_id), + intval($r[0]['cr_uid']) + ); + if (!$x) { + notice(t('Permission denied.') . EOL); + return false; + } + + $limit = ServiceClass::fetch($r[0]['cr_uid'], 'chatters_inroom'); + if ($limit !== false) { + $y = q( + "select count(*) as total from chatpresence where cp_room = %d", + intval($room_id) + ); + if ($y && $y[0]['total'] > $limit) { + notice(t('Room is full') . EOL); + return false; + } + } + + if (intval($x[0]['cr_expire'])) { + $r = q( + "delete from chat where created < %s - INTERVAL %s and chat_room = %d", + db_utcnow(), + db_quoteinterval(intval($x[0]['cr_expire']) . ' MINUTE'), + intval($x[0]['cr_id']) + ); + } + + $r = q( + "select * from chatpresence where cp_xchan = '%s' and cp_room = %d limit 1", + dbesc($observer_xchan), + intval($room_id) + ); + if ($r) { + q( + "update chatpresence set cp_last = '%s' where cp_id = %d and cp_client = '%s'", + dbesc(datetime_convert()), + intval($r[0]['cp_id']), + dbesc($client) + ); + return true; + } + + $r = q( + "insert into chatpresence ( cp_room, cp_xchan, cp_last, cp_status, cp_client ) + values ( %d, '%s', '%s', '%s', '%s' )", + intval($room_id), + dbesc($observer_xchan), + dbesc(datetime_convert()), + dbesc($status), + dbesc($client) + ); + + return $r; + } + + + public function leave($observer_xchan, $room_id, $client) + { + if (!$room_id || !$observer_xchan) { + return; + } + + $r = q( + "select * from chatpresence where cp_xchan = '%s' and cp_room = %d and cp_client = '%s' limit 1", + dbesc($observer_xchan), + intval($room_id), + dbesc($client) + ); + if ($r) { + q( + "delete from chatpresence where cp_id = %d", + intval($r[0]['cp_id']) + ); + } + + return true; + } + + + public static function roomlist($uid) + { + require_once('include/security.php'); + $sql_extra = permissions_sql($uid); + + $r = q( + "select allow_cid, allow_gid, deny_cid, deny_gid, cr_name, cr_expire, cr_id, count(cp_id) as cr_inroom from chatroom left join chatpresence on cr_id = cp_room where cr_uid = %d $sql_extra group by cr_name, cr_id order by cr_name", + intval($uid) + ); + + return $r; + } + + public static function list_count($uid) + { + require_once('include/security.php'); + $sql_extra = permissions_sql($uid); + + $r = q( + "select count(*) as total from chatroom where cr_uid = %d $sql_extra", + intval($uid) + ); + + return $r[0]['total']; + } + + /** + * @brief Create a chat message via API. + * + * It is the caller's responsibility to enter the room. + * + * @param int $uid + * @param int $room_id + * @param string $xchan + * @param string $text + * @return array + */ + public static function message($uid, $room_id, $xchan, $text) + { + + $ret = array('success' => false); + + if (!$text) { + return; + } + + $sql_extra = permissions_sql($uid); + + $r = q( + "select * from chatroom where cr_uid = %d and cr_id = %d $sql_extra", + intval($uid), + intval($room_id) + ); + if (!$r) { + return $ret; + } + + $arr = [ + 'chat_room' => $room_id, + 'chat_xchan' => $xchan, + 'chat_text' => $text + ]; + /** + * @hooks chat_message + * Called to create a chat message. + * * \e int \b chat_room + * * \e string \b chat_xchan + * * \e string \b chat_text + */ + Hook::call('chat_message', $arr); + + $x = q( + "insert into chat ( chat_room, chat_xchan, created, chat_text ) + values( %d, '%s', '%s', '%s' )", + intval($room_id), + dbesc($xchan), + dbesc(datetime_convert()), + dbesc(str_rot47(base64url_encode($arr['chat_text']))) + ); + + $ret['success'] = true; + return $ret; + } +} diff --git a/Code/Lib/Config.php b/Code/Lib/Config.php new file mode 100644 index 000000000..261569dd9 --- /dev/null +++ b/Code/Lib/Config.php @@ -0,0 +1,175 @@ + false, 'message' => '']; + + $my_perms = false; + $protocol = ''; + + $ap_allowed = get_config('system', 'activitypub', ACTIVITYPUB_ENABLED) && get_pconfig($uid, 'system', 'activitypub', ACTIVITYPUB_ENABLED); + + if (substr($url, 0, 1) === '[') { + $x = strpos($url, ']'); + if ($x) { + $protocol = substr($url, 1, $x - 1); + $url = substr($url, $x + 1); + } + } + + if (!check_siteallowed($url)) { + $result['message'] = t('Channel is blocked on this site.'); + return $result; + } + + if (!$url) { + $result['message'] = t('Channel location missing.'); + return $result; + } + + // check service class limits + + $r = q( + "select count(*) as total from abook where abook_channel = %d and abook_self = 0 ", + intval($uid) + ); + if ($r) { + $total_channels = $r[0]['total']; + } + + if (!ServiceClass::allows($uid, 'total_channels', $total_channels)) { + $result['message'] = ServiceClass::upgrade_message(); + return $result; + } + + $xchan_hash = ''; + $sql_options = (($protocol) ? " and xchan_network = '" . dbesc($protocol) . "' " : ''); + + $r = q( + "select * from xchan where ( xchan_hash = '%s' or xchan_url = '%s' or xchan_addr = '%s') $sql_options ", + dbesc($url), + dbesc($url), + dbesc($url) + ); + + if ($r) { + // reset results to the best record or the first if we don't have the best + // note: this returns a single record and not an array of records + + $r = Libzot::zot_record_preferred($r, 'xchan_network'); + + // ensure there's a valid hubloc for this xchan before proceeding - you cannot connect without it + + if (in_array($r['xchan_network'], ['nomad', 'zot6', 'activitypub'])) { + $h = q( + "select * from hubloc where hubloc_hash = '%s'", + dbesc($r['xchan_hash']) + ); + if (!$h) { + $r = null; + } + } + + // we may have nulled out this record so check again + + if ($r) { + // Check the site table to see if we should have a zot6 hubloc, + // If so, clear the xchan and start fresh + + if ($r['xchan_network'] === 'activitypub') { + $m = parse_url($r['xchan_hash']); + unset($m['path']); + $h = unparse_url($m); + $s = q( + "select * from site where site_url = '%s'", + dbesc($h) + ); + if (intval($s['site_type']) === SITE_TYPE_ZOT) { + logger('got zot - ignore activitypub entry'); + $r = null; + } + } + } + } + + + $singleton = false; + + if (!$r) { + // not in cache - try discovery + + $wf = discover_by_webbie($url, $protocol, false); + + if (!$wf) { + $result['message'] = t('Remote channel or protocol unavailable.'); + return $result; + } + } + + if ($wf) { + // something was discovered - find the record which was just created. + + $r = q( + "select * from xchan where ( xchan_hash = '%s' or xchan_url = '%s' or xchan_addr = '%s' ) $sql_options", + dbesc(($wf) ? $wf : $url), + dbesc($url), + dbesc($url) + ); + + // convert to a single record (once again preferring a zot solution in the case of multiples) + + if ($r) { + $r = Libzot::zot_record_preferred($r, 'xchan_network'); + } + } + + // if discovery was a success or the channel was already cached we should have an xchan record in $r + + if ($r) { + $xchan = $r; + $xchan_hash = $r['xchan_hash']; + $their_perms = EMPTY_STR; + } + + // failure case + + if (!$xchan_hash) { + $result['message'] = t('Channel discovery failed.'); + logger('follow: ' . $result['message']); + return $result; + } + + if (!check_channelallowed($xchan_hash)) { + $result['message'] = t('Channel is blocked on this site.'); + logger('follow: ' . $result['message']); + return $result; + } + + + if ($r['xchan_network'] === 'activitypub') { + if (!$ap_allowed) { + $result['message'] = t('Protocol not supported'); + return $result; + } + $singleton = true; + } + + // Now start processing the new connection + + $aid = $channel['channel_account_id']; + $hash = $channel['channel_hash']; + $default_group = $channel['channel_default_group']; + + if ($hash === $xchan_hash) { + $result['message'] = t('Cannot connect to yourself.'); + return $result; + } + + $p = Permissions::connect_perms($uid); + + // parent channels have unencumbered write permission + + if ($sub_channel) { + $p['perms']['post_wall'] = 1; + $p['perms']['post_comments'] = 1; + $p['perms']['write_storage'] = 1; + $p['perms']['post_like'] = 1; + $p['perms']['delegate'] = 0; + $p['perms']['moderated'] = 0; + } + + $my_perms = Permissions::serialise($p['perms']); + + $profile_assign = get_pconfig($uid, 'system', 'profile_assign', ''); + + // See if we are already connected by virtue of having an abook record + + $r = q( + "select abook_id, abook_xchan, abook_pending, abook_instance from abook + where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($xchan_hash), + intval($uid) + ); + + if ($r) { + $abook_instance = $r[0]['abook_instance']; + + // If they are on a non-nomadic network, add them to this location + + if (($singleton) && strpos($abook_instance, z_root()) === false) { + if ($abook_instance) { + $abook_instance .= ','; + } + $abook_instance .= z_root(); + + $x = q( + "update abook set abook_instance = '%s', abook_not_here = 0 where abook_id = %d", + dbesc($abook_instance), + intval($r[0]['abook_id']) + ); + } + + // if they have a pending connection, we just followed them so approve the connection request + + if (intval($r[0]['abook_pending'])) { + $x = q( + "update abook set abook_pending = 0 where abook_id = %d", + intval($r[0]['abook_id']) + ); + } + } else { + // create a new abook record + + $closeness = get_pconfig($uid, 'system', 'new_abook_closeness', 80); + + $r = abook_store_lowlevel( + [ + 'abook_account' => intval($aid), + 'abook_channel' => intval($uid), + 'abook_closeness' => intval($closeness), + 'abook_xchan' => $xchan_hash, + 'abook_profile' => $profile_assign, + 'abook_feed' => intval(($xchan['xchan_network'] === 'rss') ? 1 : 0), + 'abook_created' => datetime_convert(), + 'abook_updated' => datetime_convert(), + 'abook_instance' => (($singleton) ? z_root() : '') + ] + ); + } + + if (!$r) { + logger('abook creation failed'); + $result['message'] = t('error saving data'); + return $result; + } + + // Set suitable permissions to the connection + + if ($my_perms) { + set_abconfig($uid, $xchan_hash, 'system', 'my_perms', $my_perms); + } + + // fetch the entire record + + $r = q( + "select abook.*, xchan.* from abook left join xchan on abook_xchan = xchan_hash + where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($xchan_hash), + intval($uid) + ); + + if ($r) { + $result['abook'] = array_shift($r); + Run::Summon(['Notifier', 'permissions_create', $result['abook']['abook_id']]); + } + + $arr = ['channel_id' => $uid, 'channel' => $channel, 'abook' => $result['abook']]; + + Hook::call('follow', $arr); + + /** If there is a default group for this channel, add this connection to it */ + + if ($default_group) { + $g = AccessList::rec_byhash($uid, $default_group); + if ($g) { + AccessList::member_add($uid, '', $xchan_hash, $g['id']); + } + } + + $result['success'] = true; + return $result; + } +} diff --git a/Code/Lib/Crypto.php b/Code/Lib/Crypto.php new file mode 100644 index 000000000..ea273a6b6 --- /dev/null +++ b/Code/Lib/Crypto.php @@ -0,0 +1,214 @@ + 'sha1', + 'private_key_bits' => $bits, + 'encrypt_key' => false + ]; + + $conf = get_config('system', 'openssl_conf_file'); + + if ($conf) { + $openssl_options['config'] = $conf; + } + + $result = openssl_pkey_new($openssl_options); + + if (empty($result)) { + return false; + } + + // Get private key + + $response = [ 'prvkey' => '', 'pubkey' => '' ]; + + openssl_pkey_export($result, $response['prvkey']); + + // Get public key + $pkey = openssl_pkey_get_details($result); + $response['pubkey'] = $pkey["key"]; + + return $response; + } + + + public static function sign($data, $key, $alg = 'sha256') + { + + if (! $key) { + return false; + } + + $sig = ''; + openssl_sign($data, $sig, $key, $alg); + return $sig; + } + + + public static function verify($data, $sig, $key, $alg = 'sha256') + { + + if (! $key) { + return false; + } + + try { + $verify = openssl_verify($data, $sig, $key, $alg); + } catch (Exception $e) { + $verify = (-1); + } + + if ($verify === (-1)) { + while ($msg = openssl_error_string()) { + logger('openssl_verify: ' . $msg, LOGGER_NORMAL, LOG_ERR); + } + btlogger('openssl_verify: key: ' . $key, LOGGER_DEBUG, LOG_ERR); + } + + return (($verify > 0) ? true : false); + } + + public static function encapsulate($data, $pubkey, $alg) + { + + if (! ($alg && $pubkey)) { + return $data; + } + + $alg_base = $alg; + $padding = OPENSSL_PKCS1_PADDING; + + $exts = explode('.', $alg); + if (count($exts) > 1) { + switch ($exts[1]) { + case 'oaep': + $padding = OPENSSL_PKCS1_OAEP_PADDING; + break; + default: + break; + } + $alg_base = $exts[0]; + } + + $method = null; + + foreach (self::$openssl_algorithms as $ossl) { + if ($ossl[0] === $alg_base) { + $method = $ossl; + break; + } + } + + if ($method) { + $result = [ 'encrypted' => true ]; + + $key = openssl_random_pseudo_bytes(256); + $iv = openssl_random_pseudo_bytes(256); + + $key1 = substr($key, 0, $method[2]); + $iv1 = substr($iv, 0, $method[3]); + + $result['data'] = base64url_encode(openssl_encrypt($data, $method[1], $key1, OPENSSL_RAW_DATA, $iv1), true); + + openssl_public_encrypt($key, $k, $pubkey, $padding); + openssl_public_encrypt($iv, $i, $pubkey, $padding); + + $result['alg'] = $alg; + $result['key'] = base64url_encode($k, true); + $result['iv'] = base64url_encode($i, true); + return $result; + } else { + $x = [ 'data' => $data, 'pubkey' => $pubkey, 'alg' => $alg, 'result' => $data ]; + Hook::call('crypto_encapsulate', $x); + return $x['result']; + } + } + + public static function unencapsulate($data, $prvkey) + { + + if (! (is_array($data) && array_key_exists('encrypted', $data) && array_key_exists('alg', $data) && $data['alg'])) { + logger('not encrypted'); + + return $data; + } + + $alg_base = $data['alg']; + $padding = OPENSSL_PKCS1_PADDING; + + $exts = explode('.', $data['alg']); + if (count($exts) > 1) { + switch ($exts[1]) { + case 'oaep': + $padding = OPENSSL_PKCS1_OAEP_PADDING; + break; + default: + break; + } + $alg_base = $exts[0]; + } + + $method = null; + + foreach (self::$openssl_algorithms as $ossl) { + if ($ossl[0] === $alg_base) { + $method = $ossl; + break; + } + } + + if ($method) { + openssl_private_decrypt(base64url_decode($data['key']), $k, $prvkey, $padding); + openssl_private_decrypt(base64url_decode($data['iv']), $i, $prvkey, $padding); + return openssl_decrypt(base64url_decode($data['data']), $method[1], substr($k, 0, $method[2]), OPENSSL_RAW_DATA, substr($i, 0, $method[3])); + } else { + $x = [ 'data' => $data, 'prvkey' => $prvkey, 'alg' => $data['alg'], 'result' => $data ]; + Hook::call('crypto_unencapsulate', $x); + return $x['result']; + } + } +} diff --git a/Code/Lib/DB_Upgrade.php b/Code/Lib/DB_Upgrade.php new file mode 100644 index 000000000..1e35a323c --- /dev/null +++ b/Code/Lib/DB_Upgrade.php @@ -0,0 +1,119 @@ +config_name = 'db_version'; + $this->func_prefix = '_'; + + $build = get_config('system', 'db_version', 0); + if (!intval($build)) { + $build = set_config('system', 'db_version', $db_revision); + } + + if ($build == $db_revision) { + // Nothing to be done. + return; + } else { + $stored = intval($build); + if (!$stored) { + logger('Critical: check_config unable to determine database schema version'); + return; + } + + $current = intval($db_revision); + + if ($stored < $current) { + // The last update we performed was $stored. + // Start at $stored + 1 and continue until we have completed $current + + for ($x = $stored + 1; $x <= $current; $x++) { + $s = '_' . $x; + $cls = '\\Code\Update\\' . $s; + if (!class_exists($cls)) { + return; + } + + // There could be a lot of processes running or about to run. + // We want exactly one process to run the update command. + // So store the fact that we're taking responsibility + // after first checking to see if somebody else already has. + + // If the update fails or times-out completely you may need to + // delete the config entry to try again. + + Config::Load('database'); + + if (get_config('database', $s)) { + break; + } + set_config('database', $s, '1'); + + + $c = new $cls(); + + $retval = $c->run(); + + if ($retval != UPDATE_SUCCESS) { + $source = t('Source code of failed update: ') . "\n\n" . @file_get_contents('Code/Update/' . $s . '.php'); + + + // Prevent sending hundreds of thousands of emails by creating + // a lockfile. + + $lockfile = 'cache/mailsent'; + + if ((file_exists($lockfile)) && (filemtime($lockfile) > (time() - 86400))) { + return; + } + @unlink($lockfile); + //send the administrator an e-mail + file_put_contents($lockfile, $x); + + $r = q( + "select account_language from account where account_email = '%s' limit 1", + dbesc(App::$config['system']['admin_email']) + ); + push_lang(($r) ? $r[0]['account_language'] : 'en'); + z_mail( + [ + 'toEmail' => App::$config['system']['admin_email'], + 'messageSubject' => sprintf(t('Update Error at %s'), z_root()), + 'textVersion' => replace_macros( + Theme::get_email_template('update_fail_eml.tpl'), + [ + '$sitename' => App::$config['system']['sitename'], + '$siteurl' => z_root(), + '$update' => $x, + '$error' => sprintf(t('Update %s failed. See error logs.'), $x), + '$baseurl' => z_root(), + '$source' => $source + ] + ) + ] + ); + + //try the logger + logger('CRITICAL: Update Failed: ' . $x); + pop_lang(); + } else { + set_config('database', $s, 'success'); + } + } + } + set_config('system', 'db_version', $db_revision); + } + } +} diff --git a/Code/Lib/DReport.php b/Code/Lib/DReport.php new file mode 100644 index 000000000..22d3851fd --- /dev/null +++ b/Code/Lib/DReport.php @@ -0,0 +1,156 @@ +location = $location; + $this->sender = $sender; + $this->recipient = $recipient; + $this->name = EMPTY_STR; + $this->message_id = $message_id; + $this->status = $status; + $this->date = datetime_convert(); + } + + public function update($status) + { + $this->status = $status; + $this->date = datetime_convert(); + } + + public function set_name($name) + { + $this->name = $name; + } + + public function addto_update($status) + { + $this->status = $this->status . ' ' . $status; + } + + + public function set($arr) + { + $this->location = $arr['location']; + $this->sender = $arr['sender']; + $this->recipient = $arr['recipient']; + $this->name = $arr['name']; + $this->message_id = $arr['message_id']; + $this->status = $arr['status']; + $this->date = $arr['date']; + } + + public function get() + { + return array( + 'location' => $this->location, + 'sender' => $this->sender, + 'recipient' => $this->recipient, + 'name' => $this->name, + 'message_id' => $this->message_id, + 'status' => $this->status, + 'date' => $this->date + ); + } + + /** + * @brief decide whether to store a returned delivery report + * + * @param array $dr + * @return bool + */ + + public static function is_storable($dr) + { + + if (get_config('system', 'disable_dreport')) { + return false; + } + + /** + * @hooks dreport_is_storable + * Called before storing a dreport record to determine whether to store it. + * * \e array + */ + + Hook::call('dreport_is_storable', $dr); + + // let plugins accept or reject - if neither, continue on + if (array_key_exists('accept', $dr) && intval($dr['accept'])) { + return true; + } + if (array_key_exists('reject', $dr) && intval($dr['reject'])) { + return false; + } + + if (!($dr['sender'])) { + return false; + } + + // Is the sender one of our channels? + + $c = q( + "select channel_id from channel where channel_hash = '%s' limit 1", + dbesc($dr['sender']) + ); + if (!$c) { + return false; + } + + + // is the recipient one of our connections, or do we want to store every report? + + + $rxchan = $dr['recipient']; + $pcf = get_pconfig($c[0]['channel_id'], 'system', 'dreport_store_all'); + if ($pcf) { + return true; + } + + // We always add ourself as a recipient to private and relayed posts + // So if a remote site says they can't find us, that's no big surprise + // and just creates a lot of extra report noise + + if (($dr['location'] !== z_root()) && ($dr['sender'] === $rxchan) && ($dr['status'] === 'recipient not found')) { + return false; + } + + // If you have a private post with a recipient list, every single site is going to report + // back a failed delivery for anybody on that list that isn't local to them. We're only + // concerned about this if we have a local hubloc record which says we expected them to + // have a channel on that site. + + $r = q( + "select hubloc_id from hubloc where hubloc_hash = '%s' and hubloc_url = '%s'", + dbesc($rxchan), + dbesc($dr['location']) + ); + if ((!$r) && ($dr['status'] === 'recipient not found')) { + return false; + } + + $r = q( + "select abook_id from abook where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($rxchan), + intval($c[0]['channel_id']) + ); + if ($r) { + return true; + } + + return false; + } +} diff --git a/Code/Lib/Enotify.php b/Code/Lib/Enotify.php new file mode 100644 index 000000000..7af194791 --- /dev/null +++ b/Code/Lib/Enotify.php @@ -0,0 +1,985 @@ +' . t('Notification Settings') . ''); + $sender_name = $product; + $hostname = App::get_hostname(); + if (strpos($hostname, ':')) { + $hostname = substr($hostname, 0, strpos($hostname, ':')); + } + + // Do not translate 'noreply' as it must be a legal 7-bit email address + + $reply_email = get_config('system', 'reply_address'); + if (! $reply_email) { + $reply_email = 'noreply' . '@' . $hostname; + } + + $sender_email = get_config('system', 'from_email'); + if (! $sender_email) { + $sender_email = 'Administrator' . '@' . $hostname; + } + + $sender_name = get_config('system', 'from_email_name'); + if (! $sender_name) { + $sender_name = System::get_site_name(); + } + + + $additional_mail_header = ""; + + if (array_key_exists('item', $params)) { + require_once('include/conversation.php'); + // if it's a normal item... + if (array_key_exists('verb', $params['item'])) { + // localize_item() alters the original item so make a copy first + $i = $params['item']; + logger('calling localize'); + localize_item($i); + $title = $i['title']; + $body = $i['body']; + $private = (($i['item_private']) || intval($i['item_obscured'])); + } else { + $title = $params['item']['title']; + $body = $params['item']['body']; + } + if ($params['item']['created'] < datetime_convert('UTC', 'UTC', 'now - 1 month')) { + logger('notification invoked for an old item which may have been refetched.', LOGGER_DEBUG, LOG_INFO); + return; + } + } else { + $title = $body = ''; + } + + + $always_show_in_notices = get_pconfig($recip['channel_id'], 'system', 'always_show_in_notices'); + $vnotify = get_pconfig($recip['channel_id'], 'system', 'vnotify'); + + $salutation = $recip['channel_name']; + + // e.g. "your post", "David's photo", etc. + $possess_desc = t('%s '); + + if ($params['type'] == NOTIFY_MAIL) { + logger('notification: mail'); + $subject = sprintf(t('[$Projectname:Notify] New mail received at %s'), $sitename); + + if ($params['item']['mid'] === $params['item']['parent_mid']) { + $preamble = sprintf(t('%1$s sent you a new private message at %2$s.'), $sender['xchan_name'], $sitename); + $epreamble = sprintf(t('%1$s sent you %2$s.'), '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', '[zrl=$itemlink]' . t('a private message') . '[/zrl]'); + } else { + $preamble = sprintf(t('%1$s replied to a private message at %2$s.'), $sender['xchan_name'], $sitename); + $epreamble = sprintf(t('%1$s replied to %2$s.'), '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', '[zrl=$itemlink]' . t('a private message') . '[/zrl]'); + } + $sitelink = t('Please visit %s to view and/or reply to your private messages.'); + + $tsitelink = sprintf($sitelink, $siteurl . '/display/' . gen_link_id($params['item']['mid'])); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + $itemlink = z_root() . '/display/' . gen_link_id($params['item']['mid']); + } + + if (in_array(intval($params['type']), [ NOTIFY_COMMENT, NOTIFY_RESHARE ])) { + // logger("notification: params = " . print_r($params, true), LOGGER_DEBUG); + + $moderated = (($params['item']['item_blocked'] == ITEM_MODERATED) ? true : false); + + $itemlink = $params['link']; + + $action = t('commented on'); + + if (array_key_exists('item', $params) && in_array($params['item']['verb'], [ACTIVITY_LIKE, ACTIVITY_DISLIKE])) { + if (! $always_show_in_notices || !($vnotify & VNOTIFY_LIKE)) { + logger('notification: not a visible activity. Ignoring.'); + pop_lang(); + return; + } + + if (activity_match($params['verb'], ACTIVITY_LIKE)) { + $action = t('liked'); + } + + if (activity_match($params['verb'], ACTIVITY_DISLIKE)) { + $action = t('disliked'); + } + } + + $parent_mid = $params['parent_mid']; + + // Check to see if there was already a notify for this post. + // If so don't create a second notification + + $p = null; + $p = q( + "select id from notify where link = '%s' and uid = %d limit 1", + dbesc($params['link']), + intval($recip['channel_id']) + ); + if ($p) { + logger('notification: comment already notified'); + pop_lang(); + return; + } + + + // if it's a post figure out who's post it is. + + $p = null; + + if ($params['otype'] === 'item' && $parent_mid) { + $p = q( + "select * from item where mid = '%s' and uid = %d limit 1", + dbesc($parent_mid), + intval($recip['channel_id']) + ); + } + + xchan_query($p); + + $item_post_type = item_post_type($p[0]); + // $private = $p[0]['item_private']; + $parent_id = $p[0]['id']; + + $parent_item = $p[0]; + + //$possess_desc = str_replace('',$possess_desc); + + // "a post" + $dest_str = sprintf( + t('%1$s %2$s [zrl=%3$s]a %4$s[/zrl]'), + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', + $action, + $itemlink, + $item_post_type + ); + + // "George Bull's post" + if ($p) { + $dest_str = sprintf( + t('%1$s %2$s [zrl=%3$s]%4$s\'s %5$s[/zrl]'), + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', + $action, + $itemlink, + $p[0]['author']['xchan_name'], + $item_post_type + ); + } + + // "your post" + if ($p[0]['owner']['xchan_name'] == $p[0]['author']['xchan_name'] && intval($p[0]['item_wall'])) { + $dest_str = sprintf( + t('%1$s %2$s [zrl=%3$s]your %4$s[/zrl]'), + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', + $action, + $itemlink, + $item_post_type + ); + } + + // Some mail softwares relies on subject field for threading. + // So, we cannot have different subjects for notifications of the same thread. + // Before this we have the name of the replier on the subject rendering + // differents subjects for messages on the same thread. + + if ($moderated) { + $subject = sprintf(t('[$Projectname:Notify] Moderated Comment to conversation #%1$d by %2$s'), $parent_id, $sender['xchan_name']); + $itemlink = z_root() . '/moderate/' . gen_link_id($params['item']['mid']); + } else { + $subject = sprintf(t('[$Projectname:Notify] Comment to conversation #%1$d by %2$s'), $parent_id, $sender['xchan_name']); + } + $preamble = sprintf(t('%1$s commented on an item/conversation you have been following.'), $sender['xchan_name']); + $epreamble = $dest_str; + + if ($moderated) { + $epreamble .= ' ' . t('(Moderated)'); + } + + $sitelink = t('Please visit %s to view and/or reply to the conversation.'); + $tsitelink = sprintf($sitelink, $siteurl); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + if ($moderated) { + $tsitelink .= "\n\n" . sprintf(t('Please visit %s to approve or reject this comment.'), z_root() . '/moderate'); + $hsitelink .= "

" . sprintf(t('Please visit %s to approve or reject this comment.'), '' . z_root() . '/moderate'); + } + } + + if ($params['type'] == NOTIFY_LIKE) { + // logger("notification: params = " . print_r($params, true), LOGGER_DEBUG); + + $itemlink = $params['link']; + + if (array_key_exists('item', $params) && (! activity_match($params['item']['verb'], ACTIVITY_LIKE))) { + if (! $always_show_in_notices || !($vnotify & VNOTIFY_LIKE)) { + logger('notification: not a visible activity. Ignoring.'); + pop_lang(); + return; + } + } + + $parent_mid = $params['parent_mid']; + + // Check to see if there was already a notify for this post. + // If so don't create a second notification + + $p = null; + $p = q( + "select id from notify where link = '%s' and uid = %d limit 1", + dbesc($params['link']), + intval($recip['channel_id']) + ); + if ($p) { + logger('notification: like already notified'); + pop_lang(); + return; + } + + + // if it's a post figure out who's post it is. + + $p = null; + + if ($params['otype'] === 'item' && $parent_mid) { + $p = q( + "select * from item where mid = '%s' and uid = %d limit 1", + dbesc($parent_mid), + intval($recip['channel_id']) + ); + } + + xchan_query($p); + + + $item_post_type = item_post_type($p[0]); + // $private = $p[0]['item_private']; + $parent_id = $p[0]['id']; + + $parent_item = $p[0]; + + + // "your post" + if ($p[0]['owner']['xchan_name'] == $p[0]['author']['xchan_name'] && intval($p[0]['item_wall'])) { + $dest_str = sprintf( + t('%1$s liked [zrl=%2$s]your %3$s[/zrl]'), + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', + $itemlink, + $item_post_type + ); + } else { + pop_lang(); + return; + } + + // Some mail softwares relies on subject field for threading. + // So, we cannot have different subjects for notifications of the same thread. + // Before this we have the name of the replier on the subject rendering + // differents subjects for messages on the same thread. + + $subject = sprintf(t('[$Projectname:Notify] Like received to conversation #%1$d by %2$s'), $parent_id, $sender['xchan_name']); + $preamble = sprintf(t('%1$s liked an item/conversation you created.'), $sender['xchan_name']); + $epreamble = $dest_str; + + $sitelink = t('Please visit %s to view and/or reply to the conversation.'); + $tsitelink = sprintf($sitelink, $siteurl); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + } + + + + if ($params['type'] == NOTIFY_WALL) { + $subject = sprintf(t('[$Projectname:Notify] %s posted to your profile wall'), $sender['xchan_name']); + + $moderated = (($params['item']['item_blocked'] == ITEM_MODERATED) ? true : false); + + $itemlink = (($moderated) ? z_root() . '/moderate/' . gen_link_id($params['item']['mid']) : $params['link']); + + $preamble = sprintf(t('%1$s posted to your profile wall at %2$s'), $sender['xchan_name'], $sitename); + + $epreamble = sprintf( + t('%1$s posted to [zrl=%2$s]your wall[/zrl]'), + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', + $itemlink + ); + + + if ($moderated) { + $subject .= t(' - ') . t('Moderated'); + $epreamble .= t(' - ') . t('Moderated'); + } + + $sitelink = t('Please visit %s to view and/or reply to the conversation.'); + $tsitelink = sprintf($sitelink, $siteurl); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + + if ($moderated) { + $tsitelink .= "\n\n" . sprintf(t('Please visit %s to approve or reject this post.'), z_root() . '/moderate'); + $hsitelink .= "

" . sprintf(t('Please visit %s to approve or reject this post.'), '' . z_root() . '/moderate'); + } + } + + if ($params['type'] == NOTIFY_TAGSELF) { + $p = null; + $p = q( + "select id from notify where link = '%s' and uid = %d limit 1", + dbesc($params['link']), + intval($recip['channel_id']) + ); + if ($p) { + logger('enotify: tag: already notified about this post'); + pop_lang(); + return; + } + + $subject = sprintf(t('[$Projectname:Notify] %s tagged you'), $sender['xchan_name']); + $preamble = sprintf(t('%1$s tagged you at %2$s'), $sender['xchan_name'], $sitename); + $epreamble = sprintf( + t('%1$s [zrl=%2$s]tagged you[/zrl].'), + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', + $params['link'] + ); + + $sitelink = t('Please visit %s to view and/or reply to the conversation.'); + $tsitelink = sprintf($sitelink, $siteurl); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + $itemlink = $params['link']; + } + + if ($params['type'] == NOTIFY_POKE) { + $subject = sprintf(t('[$Projectname:Notify] %1$s poked you'), $sender['xchan_name']); + $preamble = sprintf(t('%1$s poked you at %2$s'), $sender['xchan_name'], $sitename); + $epreamble = sprintf( + t('%1$s [zrl=%2$s]poked you[/zrl].'), + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', + $params['link'] + ); + + $subject = str_replace('poked', t($params['activity']), $subject); + $preamble = str_replace('poked', t($params['activity']), $preamble); + $epreamble = str_replace('poked', t($params['activity']), $epreamble); + + $sitelink = t('Please visit %s to view and/or reply to the conversation.'); + $tsitelink = sprintf($sitelink, $siteurl); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + $itemlink = $params['link']; + } + + if ($params['type'] == NOTIFY_TAGSHARE) { + $subject = sprintf(t('[$Projectname:Notify] %s tagged your post'), $sender['xchan_name']); + $preamble = sprintf(t('%1$s tagged your post at %2$s'), $sender['xchan_name'], $sitename); + $epreamble = sprintf( + t('%1$s tagged [zrl=%2$s]your post[/zrl]'), + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]', + $itemlink + ); + + $sitelink = t('Please visit %s to view and/or reply to the conversation.'); + $tsitelink = sprintf($sitelink, $siteurl); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + $itemlink = $params['link']; + } + + if ($params['type'] == NOTIFY_INTRO) { + $subject = sprintf(t('[$Projectname:Notify] Introduction received')); + $preamble = sprintf(t('You\'ve received an new connection request from \'%1$s\' at %2$s'), $sender['xchan_name'], $sitename); + $epreamble = sprintf( + t('You\'ve received [zrl=%1$s]a new connection request[/zrl] from %2$s.'), + $siteurl . '/connections/ifpending', + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]' + ); + $body = sprintf(t('You may visit their profile at %s'), $sender['xchan_url']); + + $sitelink = t('Please visit %s to approve or reject the connection request.'); + $tsitelink = sprintf($sitelink, $siteurl . '/connections/ifpending'); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + $itemlink = $params['link']; + } + + if ($params['type'] == NOTIFY_SUGGEST) { + $subject = sprintf(t('[$Projectname:Notify] Friend suggestion received')); + $preamble = sprintf(t('You\'ve received a friend suggestion from \'%1$s\' at %2$s'), $sender['xchan_name'], $sitename); + $epreamble = sprintf( + t('You\'ve received [zrl=%1$s]a friend suggestion[/zrl] for %2$s from %3$s.'), + $itemlink, + '[zrl=' . $params['item']['url'] . ']' . $params['item']['name'] . '[/zrl]', + '[zrl=' . $sender['xchan_url'] . ']' . $sender['xchan_name'] . '[/zrl]' + ); + + $body = t('Name:') . ' ' . $params['item']['name'] . "\n"; + $body .= t('Photo:') . ' ' . $params['item']['photo'] . "\n"; + $body .= sprintf(t('You may visit their profile at %s'), $params['item']['url']); + + $sitelink = t('Please visit %s to approve or reject the suggestion.'); + $tsitelink = sprintf($sitelink, $siteurl); + $hsitelink = sprintf($sitelink, '' . $sitename . ''); + $itemlink = $params['link']; + } + + if ($params['type'] == NOTIFY_CONFIRM) { + // ? + } + + if ($params['type'] == NOTIFY_SYSTEM) { + // ? + } + + $h = array( + 'params' => $params, + 'subject' => $subject, + 'preamble' => $preamble, + 'epreamble' => $epreamble, + 'body' => $body, + 'sitelink' => $sitelink, + 'sitename' => $sitename, + 'tsitelink' => $tsitelink, + 'hsitelink' => $hsitelink, + 'itemlink' => $itemlink, + 'sender' => $sender, + 'recipient' => $recip + ); + + Hook::call('enotify', $h); + + $subject = $h['subject']; + $preamble = $h['preamble']; + $epreamble = $h['epreamble']; + $body = $h['body']; + $sitelink = $h['sitelink']; + $tsitelink = $h['tsitelink']; + $hsitelink = $h['hsitelink']; + $itemlink = $h['itemlink']; + + + require_once('include/html2bbcode.php'); + + do { + $dups = false; + $hash = random_string(); + $r = q( + "SELECT id FROM notify WHERE hash = '%s' LIMIT 1", + dbesc($hash) + ); + if ($r) { + $dups = true; + } + } while ($dups === true); + + + $datarray = []; + $datarray['hash'] = $hash; + $datarray['sender_hash'] = $sender['xchan_hash']; + $datarray['xname'] = $sender['xchan_name']; + $datarray['url'] = $sender['xchan_url']; + $datarray['photo'] = $sender['xchan_photo_s']; + $datarray['created'] = datetime_convert(); + $datarray['aid'] = $recip['channel_account_id']; + $datarray['uid'] = $recip['channel_id']; + $datarray['link'] = $itemlink; + $datarray['parent'] = $parent_mid; + $datarray['parent_item'] = $parent_item; + $datarray['ntype'] = $params['type']; + $datarray['verb'] = $params['verb']; + $datarray['otype'] = $params['otype']; + $datarray['abort'] = false; + + $datarray['item'] = $params['item']; + + if (LibBlock::fetch_by_entity($datarray['uid'], $datarray['sender_hash'])) { + pop_lang(); + return; + } + + if (is_array($datarray['parent_item'])) { + if (LibBlock::fetch_by_entity($datarray['uid'], $datarray['parent_item']['author_xchan']) || LibBlock::fetch_by_entity($datarray['uid'], $datarray['parent_item']['owner_xchan'])) { + pop_lang(); + return; + } + } + + Hook::call('enotify_store', $datarray); + + if ($datarray['abort']) { + pop_lang(); + return; + } + + + // create notification entry in DB + $seen = 0; + + // Mark some notifications as seen right away + // Note! The notification have to be created, because they are used to send emails + // So easiest solution to hide them from Notices is to mark them as seen right away. + // Another option would be to not add them to the DB, and change how emails are handled + // (probably would be better that way) + + if (!$always_show_in_notices) { + if (($params['type'] == NOTIFY_WALL) || ($params['type'] == NOTIFY_INTRO)) { + $seen = 1; + } + // set back to unseen for moderated wall posts + if ($params['type'] == NOTIFY_WALL && $params['item']['item_blocked'] == ITEM_MODERATED) { + $seen = 0; + } + } + + $e = q( + "select * from notify where otype = '%s' and xname = '%s' and verb = '%s' and link = '%s' and ntype = %d limit 1", + dbesc($datarray['otype']), + dbesc($datarray['xname']), + dbesc($datarray['verb']), + dbesc($datarray['link']), + intval($datarray['ntype']) + ); + if ($e) { + logger('duplicated notification'); + pop_lang(); + return; + } + + $r = q( + "insert into notify (hash,xname,url,photo,created,msg,aid,uid,link,parent,seen,ntype,verb,otype) + values('%s','%s','%s','%s','%s','%s',%d,%d,'%s','%s',%d,%d,'%s','%s')", + dbesc($datarray['hash']), + dbesc($datarray['xname']), + dbesc($datarray['url']), + dbesc($datarray['photo']), + dbesc($datarray['created']), + dbesc(''), // will fill this in below after the record is created + intval($datarray['aid']), + intval($datarray['uid']), + dbesc($datarray['link']), + dbesc($datarray['parent']), + intval($seen), + intval($datarray['ntype']), + dbesc($datarray['verb']), + dbesc($datarray['otype']) + ); + + $r = q( + "select id from notify where hash = '%s' and uid = %d limit 1", + dbesc($hash), + intval($recip['channel_id']) + ); + if ($r) { + $notify_id = $r[0]['id']; + } else { + logger('notification not found.'); + pop_lang(); + return; + } + + $itemlink = z_root() . '/notify/view/' . $notify_id; + $msg = str_replace('$itemlink', $itemlink, $epreamble); + + // wretched hack, but we don't want to duplicate all the preamble variations and we also don't want to screw up a translation + + if ((App::$language === 'en' || (! App::$language)) && strpos($msg, ', ')) { + $msg = substr($msg, strpos($msg, ', ') + 1); + } + + $r = q( + "update notify set msg = '%s' where id = %d and uid = %d", + dbesc($msg), + intval($notify_id), + intval($datarray['uid']) + ); + + // send email notification if notification preferences permit + + require_once('bbcode.php'); + if ((intval($recip['channel_notifyflags']) & intval($params['type'])) || $params['type'] == NOTIFY_SYSTEM) { + logger('notification: sending notification email'); + + $hn = get_pconfig($recip['channel_id'], 'system', 'email_notify_host'); + if ($hn && (! stristr(App::get_hostname(), $hn))) { + // this isn't the email notification host + pop_lang(); + return; + } + + $textversion = strip_tags(html_entity_decode(bbcode(stripslashes(str_replace(array("\\r", "\\n"), array( "", "\n"), $body))), ENT_QUOTES, 'UTF-8')); + + $htmlversion = bbcode(stripslashes(str_replace(array("\\r","\\n"), array("","
\n"), $body))); + + + // use $_SESSION['zid_override'] to force zid() to use + // the recipient address instead of the current observer + + $_SESSION['zid_override'] = Channel::get_webfinger($recip); + $_SESSION['zrl_override'] = z_root() . '/channel/' . $recip['channel_address']; + + $textversion = zidify_links($textversion); + $htmlversion = zidify_links($htmlversion); + + // unset when done to revert to normal behaviour + + unset($_SESSION['zid_override']); + unset($_SESSION['zrl_override']); + + $datarray = []; + $datarray['banner'] = $banner; + $datarray['product'] = $product; + $datarray['preamble'] = $preamble; + $datarray['sitename'] = $sitename; + $datarray['siteurl'] = $siteurl; + $datarray['type'] = $params['type']; + $datarray['parent'] = $params['parent_mid']; + $datarray['source_name'] = $sender['xchan_name']; + $datarray['source_link'] = $sender['xchan_url']; + $datarray['source_photo'] = $sender['xchan_photo_s']; + $datarray['uid'] = $recip['channel_id']; + $datarray['username'] = $recip['channel_name']; + $datarray['hsitelink'] = $hsitelink; + $datarray['tsitelink'] = $tsitelink; + $datarray['hitemlink'] = '' . $itemlink . ''; + $datarray['titemlink'] = $itemlink; + $datarray['thanks'] = $thanks; + $datarray['site_admin'] = $site_admin; + $datarray['opt_out1'] = $opt_out1; + $datarray['opt_out2'] = $opt_out2; + $datarray['hopt_out2'] = $hopt_out2; + $datarray['title'] = stripslashes($title); + $datarray['htmlversion'] = $htmlversion; + $datarray['textversion'] = $textversion; + $datarray['subject'] = $subject; + $datarray['headers'] = $additional_mail_header; + $datarray['email_secure'] = false; + + Hook::call('enotify_mail', $datarray); + + // Default to private - don't disclose message contents over insecure channels (such as email) + // Might be interesting to use GPG,PGP,S/MIME encryption instead + // but we'll save that for a clever plugin developer to implement + + $private_activity = false; + + if (! $datarray['email_secure']) { + switch ($params['type']) { + case NOTIFY_WALL: + case NOTIFY_TAGSELF: + case NOTIFY_POKE: + case NOTIFY_RESHARE: + case NOTIFY_COMMENT: + if (! $private) { + break; + } + $private_activity = true; + case NOTIFY_MAIL: + $datarray['textversion'] = $datarray['htmlversion'] = $datarray['title'] = ''; + $datarray['subject'] = preg_replace('/' . preg_quote(t('[$Projectname:Notify]'), '/') . '/', '$0*', $datarray['subject']); + break; + default: + break; + } + } + + if ( + $private_activity + && intval(get_pconfig($datarray['uid'], 'system', 'ignore_private_notifications')) + ) { + pop_lang(); + return; + } + + // load the template for private message notifications + $tpl = Theme::get_template('email_notify_html.tpl'); + $email_html_body = replace_macros($tpl, array( + '$banner' => $datarray['banner'], + '$notify_icon' => System::get_site_icon(), + '$product' => $datarray['product'], + '$preamble' => $salutation . '

' . $datarray['preamble'], + '$sitename' => $datarray['sitename'], + '$siteurl' => $datarray['siteurl'], + '$source_name' => $datarray['source_name'], + '$source_link' => $datarray['source_link'], + '$source_photo' => $datarray['source_photo'], + '$username' => $datarray['to_name'], + '$hsitelink' => $datarray['hsitelink'], + '$hitemlink' => $datarray['hitemlink'], + '$thanks' => $datarray['thanks'], + '$site_admin' => $datarray['site_admin'], + '$opt_out1' => $datarray['opt_out1'], + '$opt_out2' => $datarray['hopt_out2'], + '$title' => $datarray['title'], + '$htmlversion' => $datarray['htmlversion'], + )); + + // load the template for private message notifications + $tpl = Theme::get_template('email_notify_text.tpl'); + $email_text_body = replace_macros($tpl, array( + '$banner' => $datarray['banner'], + '$product' => $datarray['product'], + '$preamble' => $salutation . "\n\n" . $datarray['preamble'], + '$sitename' => $datarray['sitename'], + '$siteurl' => $datarray['siteurl'], + '$source_name' => $datarray['source_name'], + '$source_link' => $datarray['source_link'], + '$source_photo' => $datarray['source_photo'], + '$username' => $datarray['to_name'], + '$tsitelink' => $datarray['tsitelink'], + '$titemlink' => $datarray['titemlink'], + '$thanks' => $datarray['thanks'], + '$site_admin' => $datarray['site_admin'], + '$opt_out1' => $datarray['opt_out1'], + '$opt_out2' => $datarray['opt_out2'], + '$title' => $datarray['title'], + '$textversion' => $datarray['textversion'], + )); + + // logger('text: ' . $email_text_body); + + // use the EmailNotification library to send the message + + $to_email = $recip['account_email']; + + $e = get_pconfig($recip['channel_id'], 'system', 'notification_email', false); + if ($e) { + $to_email = $e; + } + + $addrs = explode(',', $to_email); + + foreach ($addrs as $addr) { + self::send(array( + 'fromName' => $sender_name, + 'fromEmail' => $sender_email, + 'replyTo' => $reply_email, + 'toEmail' => $addr, + 'messageSubject' => $datarray['subject'], + 'htmlVersion' => $email_html_body, + 'textVersion' => $email_text_body, + 'additionalMailHeader' => $datarray['headers'], + )); + } + } + + pop_lang(); + } + + + /** + * @brief Send a multipart/alternative message with Text and HTML versions. + * + * @param array $params an assoziative array with: + * * \e string \b fromName name of the sender + * * \e string \b fromEmail email of the sender + * * \e string \b replyTo replyTo address to direct responses + * * \e string \b toEmail destination email address + * * \e string \b messageSubject subject of the message + * * \e string \b htmlVersion html version of the message + * * \e string \b textVersion text only version of the message + * * \e string \b additionalMailHeader additions to the smtp mail header + */ + public static function send($params) + { + + $params['sent'] = false; + $params['result'] = false; + + Hook::call('email_send', $params); + + if ($params['sent']) { + logger("notification: enotify::send (addon) returns " . (($params['result']) ? 'success' : 'failure'), LOGGER_DEBUG); + return $params['result']; + } + + $fromName = email_header_encode(html_entity_decode($params['fromName'], ENT_QUOTES, 'UTF-8'), 'UTF-8'); + $messageSubject = email_header_encode(html_entity_decode($params['messageSubject'], ENT_QUOTES, 'UTF-8'), 'UTF-8'); + + // generate a mime boundary + $mimeBoundary = rand(0, 9) . "-" + . rand(100000000, 999999999) . "-" + . rand(100000000, 999999999) . "=:" + . rand(10000, 99999); + + // generate a multipart/alternative message header + $messageHeader = + $params['additionalMailHeader'] . + "From: $fromName <{$params['fromEmail']}>" . PHP_EOL . + "Reply-To: $fromName <{$params['replyTo']}>" . PHP_EOL . + "MIME-Version: 1.0" . PHP_EOL . + "Content-Type: multipart/alternative; boundary=\"{$mimeBoundary}\""; + + // assemble the final multipart message body with the text and html types included + $textBody = chunk_split(base64_encode($params['textVersion'])); + $htmlBody = chunk_split(base64_encode($params['htmlVersion'])); + + $multipartMessageBody = + "--" . $mimeBoundary . PHP_EOL . // plain text section + "Content-Type: text/plain; charset=UTF-8" . PHP_EOL . + "Content-Transfer-Encoding: base64" . PHP_EOL . PHP_EOL . + $textBody . PHP_EOL . + "--" . $mimeBoundary . PHP_EOL . // text/html section + "Content-Type: text/html; charset=UTF-8" . PHP_EOL . + "Content-Transfer-Encoding: base64" . PHP_EOL . PHP_EOL . + $htmlBody . PHP_EOL . + "--" . $mimeBoundary . "--" . PHP_EOL; // message ending + + // send the message + $res = mail( + $params['toEmail'], // send to address + $messageSubject, // subject + $multipartMessageBody, // message body + $messageHeader // message headers + ); + logger("notification: enotify::send returns " . (($res) ? 'success' : 'failure'), LOGGER_DEBUG); + return $res; + } + + public static function format($item) + { + + $ret = ''; + + $expire = intval(get_config('system', 'default_expire_days')); + $expire_date = (($expire) ? datetime_convert('UTC', 'UTC', 'now - ' . $expire . ' days') : NULL_DATE); + + require_once('include/conversation.php'); + + // Call localize_item to get a one line status for activities. + // This should set $item['localize'] to indicate we have a brief summary. + // and perhaps $item['shortlocalize'] for an even briefer summary + + localize_item($item); + + if ($item['shortlocalize']) { + $itemem_text = $item['shortlocalize']; + } elseif ($item['localize']) { + $itemem_text = $item['localize']; + } else { + $itemem_text = (($item['item_thread_top']) + ? t('created a new post') + : sprintf(t('reacted to %s\'s conversation'), $item['owner']['xchan_name'])); + if ($item['verb'] === 'Announce') { + $itemem_text = sprintf(t('shared %s\'s post'), $item['owner']['xchan_name']); + } + } + if ($item['item_private'] == 2) { + $itemem_text = t('sent a direct message'); + } + + $edit = false; + + if ($item['edited'] > $item['created']) { + if ($item['item_thread_top']) { + $itemem_text = sprintf(t('edited a post dated %s'), relative_date($item['created'])); + $edit = true; + } else { + $itemem_text = sprintf(t('edited a comment dated %s'), relative_date($item['created'])); + $edit = true; + } + } + + if (LibBlock::fetch_by_entity(local_channel(), $item['author']['xchan_hash'])) { + return []; + } + + // convert this logic into a json array just like the system notifications + + $x = array( + 'notify_link' => $item['llink'], + 'name' => $item['author']['xchan_name'], + 'addr' => $item['author']['xchan_addr'], + 'url' => $item['author']['xchan_url'], + 'photo' => $item['author']['xchan_photo_s'], + 'when' => relative_date(($edit) ? $item['edited'] : $item['created']), + 'class' => (intval($item['item_unseen']) ? 'notify-unseen' : 'notify-seen'), + 'b64mid' => ((in_array($item['verb'], [ACTIVITY_LIKE, ACTIVITY_DISLIKE])) ? gen_link_id($item['thr_parent']) : gen_link_id($item['mid'])), + 'notify_id' => 'undefined', + 'thread_top' => (($item['item_thread_top']) ? true : false), + 'message' => strip_tags(bbcode($itemem_text)), + // these are for the superblock addon + 'hash' => $item['author']['xchan_hash'], + 'uid' => local_channel(), + 'display' => true + ); + + $post_date = (($edit) ? $item['edited'] : $item['created']); + if ($post_date && $post_date < $expire_date) { + return []; + } + + Hook::call('enotify_format', $x); + if (! $x['display']) { + return []; + } + + return $x; + } +} diff --git a/Code/Lib/ExtendedZip.php b/Code/Lib/ExtendedZip.php new file mode 100644 index 000000000..b0f9f5845 --- /dev/null +++ b/Code/Lib/ExtendedZip.php @@ -0,0 +1,63 @@ +addEmptyDir($localname); + } + $this->_addTree($dirname, $localname); + } + + // Internal function, to recurse + protected function _addTree($dirname, $localname) + { + $dir = opendir($dirname); + while ($filename = readdir($dir)) { + // Discard . and .. + if ($filename == '.' || $filename == '..') { + continue; + } + + // Proceed according to type + $path = $dirname . '/' . $filename; + $localpath = $localname ? ($localname . '/' . $filename) : $filename; + if (is_dir($path)) { + // Directory: add & recurse + $this->addEmptyDir($localpath); + $this->_addTree($path, $localpath); + } elseif (is_file($path)) { + // File: just add + $this->addFile($path, $localpath); + } + } + closedir($dir); + } + + // Helper function + public static function zipTree($dirname, $zipFilename, $flags = 0, $localname = '') + { + $zip = new self(); + $zip->open($zipFilename, $flags); + $zip->addTree($dirname, $localname); + $zip->close(); + } +} diff --git a/Code/Lib/Features.php b/Code/Lib/Features.php new file mode 100644 index 000000000..9317d9dcb --- /dev/null +++ b/Code/Lib/Features.php @@ -0,0 +1,572 @@ + $uid, 'feature' => $feature, 'enabled' => $x); + Hook::call('feature_enabled', $arr); + return($arr['enabled']); + } + + public static function get_default($feature) + { + $f = Features::get(false); + foreach ($f as $cat) { + foreach ($cat as $feat) { + if (is_array($feat) && $feat[0] === $feature) { + return $feat[3]; + } + } + } + return false; + } + + + public static function level($feature, $def) + { + $x = get_config('feature_level', $feature); + if ($x !== false) { + return intval($x); + } + return $def; + } + + public static function get($filtered = true, $level = (-1)) + { + + $account = App::get_account(); + + $arr = [ + + // General + 'general' => [ + + t('General Features'), + + [ + 'start_menu', + t('New Member Links'), + t('Display new member quick links menu'), + (($account && $account['account_created'] > datetime_convert('', '', 'now - 30 days')) ? true : false), + get_config('feature_lock', 'start_menu'), + self::level('start_menu', 1), + ], + + [ + 'advanced_profiles', + t('Advanced Profiles'), + t('Additional profile sections and selections'), + false, + get_config('feature_lock', 'advanced_profiles'), + self::level('advanced_profiles', 1), + ], + + + // [ + // 'profile_export', + // t('Profile Import/Export'), + // t('Save and load profile details across sites/channels'), + // false, + // get_config('feature_lock','profile_export'), + // self::level('profile_export',3), + // ], + + // [ + // 'webpages', + // t('Web Pages'), + // t('Provide managed web pages on your channel'), + // false, + // get_config('feature_lock','webpages'), + // self::level('webpages',3), + // ], + + // [ + // 'wiki', + // t('Wiki'), + // t('Provide a wiki for your channel'), + // false, + // get_config('feature_lock','wiki'), + // self::level('wiki',2), + // ], + + /* + [ + 'hide_rating', + t('Hide Rating'), + t('Hide the rating buttons on your channel and profile pages. Note: People can still rate you somewhere else.'), + false, + get_config('feature_lock','hide_rating'), + self::level('hide_rating',3), + ], + */ + [ + 'private_notes', + t('Private Notes'), + t('Enables a tool to store notes and reminders (note: not encrypted)'), + false, + get_config('feature_lock', 'private_notes'), + self::level('private_notes', 1), + ], + + + // [ + // 'cards', + // t('Cards'), + // t('Create personal planning cards'), + // false, + // get_config('feature_lock','cards'), + // self::level('cards',1), + // ], + + + [ + 'articles', + t('Articles'), + t('Create interactive articles'), + false, + get_config('feature_lock', 'articles'), + self::level('articles', 1), + ], + + // [ + // 'nav_channel_select', + // t('Navigation Channel Select'), + // t('Change channels directly from within the navigation dropdown menu'), + // false, + // get_config('feature_lock','nav_channel_select'), + // self::level('nav_channel_select',3), + // ], + + [ + 'photo_location', + t('Photo Location'), + t('If location data is available on uploaded photos, link this to a map.'), + false, + get_config('feature_lock', 'photo_location'), + self::level('photo_location', 2), + ], + + + // [ + // 'ajaxchat', + // t('Access Controlled Chatrooms'), + // t('Provide chatrooms and chat services with access control.'), + // true, + // get_config('feature_lock','ajaxchat'), + // self::level('ajaxchat',1), + // ], + + + // [ + // 'smart_birthdays', + // t('Smart Birthdays'), + // t('Make birthday events timezone aware in case your friends are scattered across the planet.'), + // true, + // get_config('feature_lock','smart_birthdays'), + // self::level('smart_birthdays',2), + // ], + + [ + 'event_tz_select', + t('Event Timezone Selection'), + t('Allow event creation in timezones other than your own.'), + false, + get_config('feature_lock', 'event_tz_select'), + self::level('event_tz_select', 2), + ], + + + // [ + // 'premium_channel', + // t('Premium Channel'), + // t('Allows you to set restrictions and terms on those that connect with your channel'), + // false, + // get_config('feature_lock','premium_channel'), + // self::level('premium_channel',4), + // ], + + [ + 'advanced_dirsearch', + t('Advanced Directory Search'), + t('Allows creation of complex directory search queries'), + false, + get_config('feature_lock', 'advanced_dirsearch'), + self::level('advanced_dirsearch', 4), + ], + + [ + 'advanced_theming', + t('Advanced Theme and Layout Settings'), + t('Allows fine tuning of themes and page layouts'), + false, + get_config('feature_lock', 'advanced_theming'), + self::level('advanced_theming', 4), + ], + ], + + + 'access_control' => [ + t('Access Control and Permissions'), + + [ + 'groups', + t('Privacy Groups'), + t('Enable management and selection of privacy groups'), + false, + get_config('feature_lock', 'groups'), + self::level('groups', 0), + ], + + // [ + // 'multi_profiles', + // t('Multiple Profiles'), + // t('Ability to create multiple profiles'), + // false, + // get_config('feature_lock','multi_profiles'), + // self::level('multi_profiles',3), + // ], + + + [ + 'permcats', + t('Permission Categories'), + t('Create custom connection permission limits'), + true, + get_config('feature_lock','permcats'), + self::level('permcats',2), + ], + + // [ + // 'oauth_clients', + // t('OAuth1 Clients'), + // t('Manage OAuth1 authenticatication tokens for mobile and remote apps.'), + // false, + // get_config('feature_lock','oauth_clients'), + // self::level('oauth_clients',1), + // ], + + [ + 'oauth2_clients', + t('OAuth2 Clients'), + t('Manage OAuth2 authenticatication tokens for mobile and remote apps.'), + false, + get_config('feature_lock', 'oauth2_clients'), + self::level('oauth2_clients', 1), + ], + + // [ + // 'access_tokens', + // t('Access Tokens'), + // t('Create access tokens so that non-members can access private content.'), + // false, + // get_config('feature_lock','access_tokens'), + // self::level('access_tokens',2), + // ], + + ], + + // Post composition + 'composition' => [ + + t('Post Composition Features'), + + // [ + // 'large_photos', + // t('Large Photos'), + // t('Include large (1024px) photo thumbnails in posts. If not enabled, use small (640px) photo thumbnails'), + // false, + // get_config('feature_lock','large_photos'), + // self::level('large_photos',1), + // ], + + // [ + // 'channel_sources', + // t('Channel Sources'), + // t('Automatically import channel content from other channels or feeds'), + // false, + // get_config('feature_lock','channel_sources'), + // self::level('channel_sources',3), + // ], + + [ + 'content_encrypt', + t('Browser Encryption'), + t('Provide optional browser-to-browser encryption of content with a shared secret key'), + true, + get_config('feature_lock', 'content_encrypt'), + self::level('content_encrypt', 3), + ], + + // [ + // 'consensus_tools', + // t('Enable Voting Tools'), + // t('Provide a class of post which others can vote on'), + // false, + // get_config('feature_lock','consensus_tools'), + // self::level('consensus_tools',3), + // ], + + // [ + // 'disable_comments', + // t('Disable Comments'), + // t('Provide the option to disable comments for a post'), + // false, + // get_config('feature_lock','disable_comments'), + // self::level('disable_comments',2), + // ], + + // [ + // 'delayed_posting', + // t('Delayed Posting'), + // t('Allow posts to be published at a later date'), + // false, + // get_config('feature_lock','delayed_posting'), + // self::level('delayed_posting',2), + // ], + + // [ + // 'content_expire', + // t('Content Expiration'), + // t('Remove posts/comments and/or private messages at a future time'), + // false, + // get_config('feature_lock','content_expire'), + // self::level('content_expire',1), + // ], + + [ + 'suppress_duplicates', + t('Suppress Duplicate Posts/Comments'), + t('Prevent posts with identical content to be published with less than two minutes in between submissions.'), + true, + get_config('feature_lock', 'suppress_duplicates'), + self::level('suppress_duplicates', 1), + ], + + [ + 'auto_save_draft', + t('Auto-save drafts of posts and comments'), + t('Automatically saves post and comment drafts in local browser storage to help prevent accidental loss of compositions'), + true, + get_config('feature_lock', 'auto_save_draft'), + self::level('auto_save_draft', 1), + ], + + ], + + // Network Tools + 'net_module' => [ + + t('Network and Stream Filtering'), + + [ + 'archives', + t('Search by Date'), + t('Ability to select posts by date ranges'), + false, + get_config('feature_lock', 'archives'), + self::level('archives', 1), + ], + + + [ + 'savedsearch', + t('Saved Searches'), + t('Save search terms for re-use'), + false, + get_config('feature_lock', 'savedsearch'), + self::level('savedsearch', 2), + ], + + [ + 'order_tab', + t('Alternate Stream Order'), + t('Ability to order the stream by last post date, last comment date or unthreaded activities'), + false, + get_config('feature_lock', 'order_tab'), + self::level('order_tab', 2), + ], + + [ + 'name_tab', + t('Contact Filter'), + t('Ability to display only posts of a selected contact'), + false, + get_config('feature_lock', 'name_tab'), + self::level('name_tab', 1), + ], + + [ + 'forums_tab', + t('Forum Filter'), + t('Ability to display only posts of a specific forum'), + false, + get_config('feature_lock', 'forums_tab'), + self::level('forums_tab', 1), + ], + + [ + 'personal_tab', + t('Personal Posts Filter'), + t('Ability to display only posts that you\'ve interacted on'), + false, + get_config('feature_lock', 'personal_tab'), + self::level('personal_tab', 1), + ], + + [ + 'affinity', + t('Affinity Tool'), + t('Filter stream activity by depth of relationships'), + false, + get_config('feature_lock', 'affinity'), + self::level('affinity', 1), + ], + + [ + 'suggest', + t('Suggest Channels'), + t('Show friend and connection suggestions'), + false, + get_config('feature_lock', 'suggest'), + self::level('suggest', 1), + ], + + [ + 'connfilter', + t('Connection Filtering'), + t('Filter incoming posts from connections based on keywords/content'), + false, + get_config('feature_lock', 'connfilter'), + self::level('connfilter', 3), + ], + + + ], + + // Item tools + 'tools' => [ + + t('Post/Comment Tools'), + + [ + 'commtag', + t('Community Tagging'), + t('Ability to tag existing posts'), + false, + get_config('feature_lock', 'commtag'), + self::level('commtag', 1), + ], + + [ + 'categories', + t('Post Categories'), + t('Add categories to your posts'), + false, + get_config('feature_lock', 'categories'), + self::level('categories', 1), + ], + + [ + 'emojis', + t('Emoji Reactions'), + t('Add emoji reaction ability to posts'), + true, + get_config('feature_lock', 'emojis'), + self::level('emojis', 1), + ], + + [ + 'filing', + t('Saved Folders'), + t('Ability to file posts under folders'), + false, + get_config('feature_lock', 'filing'), + self::level('filing', 2), + ], + + [ + 'dislike', + t('Dislike Posts'), + t('Ability to dislike posts/comments'), + false, + get_config('feature_lock', 'dislike'), + self::level('dislike', 1), + ], + + // [ + // 'star_posts', + // t('Star Posts'), + // t('Ability to mark special posts with a star indicator'), + // false, + // get_config('feature_lock','star_posts'), + // self::level('star_posts',1), + // ], + // + [ + 'tagadelic', + t('Tag Cloud'), + t('Provide a personal tag cloud on your channel page'), + false, + get_config('feature_lock', 'tagadelic'), + self::level('tagadelic', 2), + ], + ], + ]; + + $x = [ 'features' => $arr, ]; + Hook::call('get_features', $x); + + $arr = $x['features']; + + // removed any locked features and remove the entire category if this makes it empty + + if ($filtered) { + $narr = []; + foreach ($arr as $k => $x) { + $narr[$k] = [ $arr[$k][0] ]; + $has_items = false; + for ($y = 0; $y < count($arr[$k]); $y++) { + $disabled = false; + if (is_array($arr[$k][$y])) { + if ($arr[$k][$y][4] !== false) { + $disabled = true; + } + if (! $disabled) { + $has_items = true; + $narr[$k][$y] = $arr[$k][$y]; + } + } + } + if (! $has_items) { + unset($narr[$k]); + } + } + } else { + $narr = $arr; + } + + return $narr; + } + + + + +} diff --git a/Code/Lib/Hashpath.php b/Code/Lib/Hashpath.php new file mode 100644 index 000000000..b8d9fbe91 --- /dev/null +++ b/Code/Lib/Hashpath.php @@ -0,0 +1,57 @@ + + * + * @param string $src + * @param string $media change media attribute (default to 'screen') + */ + public static function add_css($src, $media = 'screen') + { + App::$css_sources[] = [ $src, $media ]; + } + + public static function remove_css($src, $media = 'screen') + { + + $index = array_search([$src, $media], App::$css_sources); + if ($index !== false) { + unset(App::$css_sources[$index]); + } + // re-index the array + App::$css_sources = array_values(App::$css_sources); + } + + public static function get_css() + { + $str = EMPTY_STR; + $sources = App::$css_sources; + if (is_array($sources) && $sources) { + foreach ($sources as $source) { + $str .= self::format_css_if_exists($source); + } + } + + return $str; + } + + public static function add_link($arr) + { + if ($arr) { + App::$linkrel[] = $arr; + } + } + + public static function get_links() + { + $str = ''; + $sources = App::$linkrel; + if (is_array($sources) && $sources) { + foreach ($sources as $source) { + if (is_array($source) && $source) { + $str .= ' $v) { + $str .= ' ' . $k . '="' . $v . '"'; + } + $str .= ' />' . "\r\n"; + } + } + } + + return $str; + } + + public static function format_css_if_exists($source) + { + + // script_path() returns https://yoursite.tld + + $path_prefix = self::script_path(); + + $script = $source[0]; + + if (strpos($script, '/') !== false) { + // The script is a path relative to the server root + $path = $script; + // If the url starts with // then it's an absolute URL + if (substr($script, 0, 2) === '//') { + $path_prefix = ''; + } + } else { + // It's a file from the theme + $path = '/' . Theme::include($script); + } + + if ($path) { + $qstring = ((parse_url($path, PHP_URL_QUERY)) ? '&' : '?') . 'v=' . STD_VERSION; + return '' . "\r\n"; + } + } + + /** + * This basically calculates the baseurl. We have other functions to do that, but + * there was an issue with script paths and mixed-content whose details are arcane + * and perhaps lost in the message archives. The short answer is that we're ignoring + * the URL which we are "supposed" to use, and generating script paths relative to + * the URL which we are currently using; in order to ensure they are found and aren't + * blocked due to mixed content issues. + * + * @return string + */ + public static function script_path() + { + if (x($_SERVER, 'HTTPS') && $_SERVER['HTTPS']) { + $scheme = 'https'; + } elseif (x($_SERVER, 'SERVER_PORT') && (intval($_SERVER['SERVER_PORT']) == 443)) { + $scheme = 'https'; + } elseif (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https' || !empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] == 'on') { + $scheme = 'https'; + } else { + $scheme = 'http'; + } + + // Some proxy setups may require using http_host + + if (isset(App::$config['system']['script_path_use_http_host']) && intval(App::$config['system']['script_path_use_http_host'])) { + $server_var = 'HTTP_HOST'; + } else { + $server_var = 'SERVER_NAME'; + } + + + if (x($_SERVER, $server_var)) { + $hostname = $_SERVER[$server_var]; + } else { + return z_root(); + } + + return $scheme . '://' . $hostname; + } + + public static function add_js($src, $priority = 0) + { + if (! (isset(App::$js_sources[$priority]) && is_array(App::$js_sources[$priority]))) { + App::$js_sources[$priority] = []; + } + App::$js_sources[$priority][] = $src; + } + + public static function remove_js($src, $priority = 0) + { + + $index = array_search($src, App::$js_sources[$priority]); + if ($index !== false) { + unset(App::$js_sources[$priority][$index]); + } + } + + /** + * We should probably try to register main.js with a high priority, but currently + * we handle it separately and put it at the end of the html head block in case + * any other javascript is added outside the head_add_js construct. + * + * @return string + */ + public static function get_js() + { + + $str = ''; + if (App::$js_sources) { + ksort(App::$js_sources, SORT_NUMERIC); + foreach (App::$js_sources as $sources) { + if (count($sources)) { + foreach ($sources as $source) { + if ($source === 'main.js') { + continue; + } + $str .= self::format_js_if_exists($source); + } + } + } + } + + return $str; + } + + public static function get_main_js() + { + return self::format_js_if_exists('main.js', true); + } + + public static function format_js_if_exists($source) + { + $path_prefix = self::script_path(); + + if (strpos($source, '/') !== false) { + // The source is a known path on the system + $path = $source; + // If the url starts with // then it's an absolute URL + if (substr($source, 0, 2) === '//') { + $path_prefix = ''; + } + } else { + // It's a file from the theme + $path = '/' . Theme::include($source); + } + if ($path) { + $qstring = ((parse_url($path, PHP_URL_QUERY)) ? '&' : '?') . 'v=' . STD_VERSION; + return '' . "\r\n" ; + } + } + +} \ No newline at end of file diff --git a/Code/Lib/IConfig.php b/Code/Lib/IConfig.php new file mode 100644 index 000000000..de016c8f0 --- /dev/null +++ b/Code/Lib/IConfig.php @@ -0,0 +1,182 @@ + $family, 'k' => $key, 'v' => $value, 'sharing' => $sharing); + + if (is_null($idx)) { + $item['iconfig'][] = $entry; + } else { + $item['iconfig'][$idx] = $entry; + } + return $value; + } + + if (intval($item)) { + $iid = intval($item); + } + + if (! $iid) { + return false; + } + + if (self::Get($item, $family, $key) === false) { + $r = q( + "insert into iconfig( iid, cat, k, v, sharing ) values ( %d, '%s', '%s', '%s', %d ) ", + intval($iid), + dbesc($family), + dbesc($key), + dbesc($dbvalue), + intval($sharing) + ); + } else { + $r = q( + "update iconfig set v = '%s', sharing = %d where iid = %d and cat = '%s' and k = '%s' ", + dbesc($dbvalue), + intval($sharing), + intval($iid), + dbesc($family), + dbesc($key) + ); + } + + if (! $r) { + return false; + } + + return $value; + } + + + + public static function Delete(&$item, $family, $key) + { + + + $is_item = false; + $idx = null; + + if (is_array($item)) { + $is_item = true; + if (is_array($item['iconfig'])) { + for ($x = 0; $x < count($item['iconfig']); $x++) { + if ($item['iconfig'][$x]['cat'] == $family && $item['iconfig'][$x]['k'] == $key) { + unset($item['iconfig'][$x]); + } + } + // re-order the array index + $item['iconfig'] = array_values($item['iconfig']); + } + return true; + } + + if (intval($item)) { + $iid = intval($item); + } + + if (! $iid) { + return false; + } + + return q( + "delete from iconfig where iid = %d and cat = '%s' and k = '%s' ", + intval($iid), + dbesc($family), + dbesc($key) + ); + } +} diff --git a/Code/Lib/Img_cache.php b/Code/Lib/Img_cache.php new file mode 100644 index 000000000..1c6058c13 --- /dev/null +++ b/Code/Lib/Img_cache.php @@ -0,0 +1,83 @@ += self::$cache_life) { + Run::Summon(['Cache_image', $url, $path]); + return false; + } else { + return ((filesize($path)) ? true : false); + } + } + + // Cache_image invokes url_to_cache() as a background task + + Run::Summon(['Cache_image', $url, $path]); + return false; + } + + public static function url_to_cache($url, $file) + { + + $fp = fopen($file, 'wb'); + + if (!$fp) { + logger('failed to open storage file: ' . $file, LOGGER_NORMAL, LOG_ERR); + return false; + } + + // don't check certs, and since we're running in the background, + // allow a two-minute timeout rather than the default one minute. + // This is a compromise. We want to cache all the slow sites we can, + // but don't want to rack up too many processes doing so. + + $redirects = 0; + $x = z_fetch_url($url, true, $redirects, ['filep' => $fp, 'novalidate' => true, 'timeout' => 120]); + + fclose($fp); + + if ($x['success'] && file_exists($file)) { + $i = @getimagesize($file); + if ($i && $i[2]) { // looking for non-zero imagetype + Run::Summon(['CacheThumb', basename($file)]); + return true; + } + } + + // We could not cache the image for some reason. Leave an empty file here + // to provide a record of the attempt. We'll use this as a flag to avoid + // doing it again repeatedly. + + file_put_contents($file, EMPTY_STR); + logger('cache failed from ' . $url); + return false; + } +} diff --git a/Code/Lib/Img_filesize.php b/Code/Lib/Img_filesize.php new file mode 100644 index 000000000..1ee2d4127 --- /dev/null +++ b/Code/Lib/Img_filesize.php @@ -0,0 +1,127 @@ +url = $url; + } + + public function getSize() + { + $size = null; + + if (stripos($this->url, z_root() . '/photo') !== false) { + $size = self::getLocalFileSize($this->url); + } + if (!$size) { + $size = getRemoteFileSize($this->url); + } + + return $size; + } + + + public static function getLocalFileSize($url) + { + + $fname = basename($url); + $resolution = 0; + + if (strpos($fname, '.') !== false) { + $fname = substr($fname, 0, strpos($fname, '.')); + } + + if (substr($fname, -2, 1) == '-') { + $resolution = intval(substr($fname, -1, 1)); + $fname = substr($fname, 0, -2); + } + + $r = q( + "SELECT filesize FROM photo WHERE resource_id = '%s' AND imgscale = %d LIMIT 1", + dbesc($fname), + intval($resolution) + ); + if ($r) { + return $r[0]['filesize']; + } + return null; + } +} + +/** + * Try to determine the size of a remote file by making an HTTP request for + * a byte range, or look for the content-length header in the response. + * The function aborts the transfer as soon as the size is found, or if no + * length headers are returned, it aborts the transfer. + * + * @return int|null null if size could not be determined, or length of content + */ +function getRemoteFileSize($url) +{ + $ch = curl_init($url); + + $headers = array( + 'Range: bytes=0-1', + 'Connection: close', + ); + + $in_headers = true; + $size = null; + + curl_setopt($ch, CURLOPT_HEADER, 1); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2450.0 Iron/46.0.2450.0'); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); + curl_setopt($ch, CURLOPT_VERBOSE, 0); // set to 1 to debug + curl_setopt($ch, CURLOPT_STDERR, fopen('php://output', 'r')); + + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $line) use (&$in_headers, &$size) { + $length = strlen($line); + + if (trim($line) == '') { + $in_headers = false; + } + + list($header, $content) = explode(':', $line, 2); + $header = strtolower(trim($header)); + + if ($header == 'content-range') { + // found a content-range header + list($rng, $s) = explode('/', $content, 2); + $size = (int)$s; + return 0; // aborts transfer + } elseif ($header == 'content-length' && 206 != curl_getinfo($curl, CURLINFO_HTTP_CODE)) { + // found content-length header and this is not a 206 Partial Content response (range response) + $size = (int)$content; + return 0; + } else { + // continue + return $length; + } + }); + + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) use ($in_headers) { + if (!$in_headers) { + // shouldn't be here unless we couldn't determine file size + // abort transfer + return 0; + } + + // write function is also called when reading headers + return strlen($data); + }); + + curl_exec($ch); + curl_getinfo($ch); + curl_close($ch); + + return $size; +} diff --git a/Code/Lib/Infocon.php b/Code/Lib/Infocon.php new file mode 100644 index 000000000..5e77717b6 --- /dev/null +++ b/Code/Lib/Infocon.php @@ -0,0 +1,76 @@ + true, + 'data' => $data, + 'data_type' => $data_type, + 'encoding' => $encoding, + 'alg' => $algorithm, + 'sigs' => [ + 'value' => $signature, + 'key_id' => base64url_encode($key_id, true) + ] + ]); + } + + public static function verify($x) + { + + logger('verify'); + $ret = ['results' => []]; + + if (!is_array($x)) { + return false; + } + if (!(array_key_exists('signed', $x) && $x['signed'])) { + return false; + } + + $signed_data = preg_replace('/\s+/', '', $x['data']) . '.' + . base64url_encode($x['data_type'], true) . '.' + . base64url_encode($x['encoding'], true) . '.' + . base64url_encode($x['alg'], true); + + $key = HTTPSig::get_key(EMPTY_STR, 'zot6', base64url_decode($x['sigs']['key_id'])); + logger('key: ' . print_r($key, true)); + if ($key['portable_id'] && $key['public_key']) { + if (Crypto::verify($signed_data, base64url_decode($x['sigs']['value']), $key['public_key'])) { + logger('verified'); + $ret = ['success' => true, 'signer' => $key['portable_id'], 'hubloc' => $key['hubloc']]; + } + } + + return $ret; + } + + public static function unpack($data) + { + return json_decode(base64url_decode($data), true); + } +} + diff --git a/Code/Lib/Keyutils.php b/Code/Lib/Keyutils.php new file mode 100644 index 000000000..aee7fd33f --- /dev/null +++ b/Code/Lib/Keyutils.php @@ -0,0 +1,102 @@ +loadKey([ + 'e' => new BigInteger($e, 256), + 'n' => new BigInteger($m, 256) + ]); + return $rsa->getPublicKey(); + } + + /** + * @param string key + * @return string + */ + public static function rsaToPem($key) + { + + $rsa = new RSA(); + $rsa->setPublicKey($key); + + return $rsa->getPublicKey(RSA::PUBLIC_FORMAT_PKCS8); + } + + /** + * @param string key + * @return string + */ + public static function pemToRsa($key) + { + + $rsa = new RSA(); + $rsa->setPublicKey($key); + + return $rsa->getPublicKey(RSA::PUBLIC_FORMAT_PKCS1); + } + + /** + * @param string $key key + * @param string $m reference modulo + * @param string $e reference exponent + */ + public static function pemToMe($key, &$m, &$e) + { + + $rsa = new RSA(); + $rsa->loadKey($key); + $rsa->setPublicKey(); + + $m = $rsa->modulus->toBytes(); + $e = $rsa->exponent->toBytes(); + } + + /** + * @param string $pubkey + * @return string + */ + public static function salmonKey($pubkey) + { + self::pemToMe($pubkey, $m, $e); + return 'RSA' . '.' . base64url_encode($m, true) . '.' . base64url_encode($e, true); + } + + /** + * @param string $key + * @return string + */ + public static function convertSalmonKey($key) + { + if (strstr($key, ',')) { + $rawkey = substr($key, strpos($key, ',') + 1); + } else { + $rawkey = substr($key, 5); + } + + $key_info = explode('.', $rawkey); + + $m = base64url_decode($key_info[1]); + $e = base64url_decode($key_info[2]); + + return self::meToPem($m, $e); + } +} diff --git a/Code/Lib/LDSignatures.php b/Code/Lib/LDSignatures.php new file mode 100644 index 000000000..99a8dd372 --- /dev/null +++ b/Code/Lib/LDSignatures.php @@ -0,0 +1,139 @@ + 'RsaSignature2017', + 'nonce' => random_string(64), + 'creator' => Channel::url($channel), + 'created' => datetime_convert('UTC', 'UTC', 'now', 'Y-m-d\TH:i:s\Z') + ]; + + $ohash = self::hash(self::signable_options($options)); + $dhash = self::hash(self::signable_data($data)); + $options['signatureValue'] = base64_encode(Crypto::sign($ohash . $dhash, $channel['channel_prvkey'])); + + return $options; + } + + + public static function signable_data($data) + { + + $newdata = []; + if ($data) { + foreach ($data as $k => $v) { + if (!in_array($k, ['signature'])) { + $newdata[$k] = $v; + } + } + } + return json_encode($newdata, JSON_UNESCAPED_SLASHES); + } + + + public static function signable_options($options) + { + + $newopts = ['@context' => 'https://w3id.org/identity/v1']; + if ($options) { + foreach ($options as $k => $v) { + if (!in_array($k, ['type', 'id', 'signatureValue'])) { + $newopts[$k] = $v; + } + } + } + return json_encode($newopts, JSON_UNESCAPED_SLASHES); + } + + public static function hash($obj) + { + + return hash('sha256', self::normalise($obj)); + } + + public static function normalise($data) + { + if (is_string($data)) { + $data = json_decode($data); + } + + if (!is_object($data)) { + return ''; + } + + jsonld_set_document_loader('jsonld_document_loader'); + + try { + $d = jsonld_normalize($data, ['algorithm' => 'URDNA2015', 'format' => 'application/nquads']); + } catch (Exception $e) { + // Don't log the exception - this can exhaust memory + // logger('normalise error:' . print_r($e,true)); + logger('normalise error: ' . print_r($data, true)); + } + + return $d; + } + + public static function salmon_sign($data, $channel) + { + + $arr = $data; + $data = json_encode($data, JSON_UNESCAPED_SLASHES); + $data = base64url_encode($data, false); // do not strip padding + $data_type = 'application/activity+json'; + $encoding = 'base64url'; + $algorithm = 'RSA-SHA256'; + $keyhash = base64url_encode(Channel::url($channel)); + + $data = str_replace(array(" ", "\t", "\r", "\n"), array("", "", "", ""), $data); + + // precomputed base64url encoding of data_type, encoding, algorithm concatenated with periods + + $precomputed = '.' . base64url_encode($data_type, false) . '.YmFzZTY0dXJs.UlNBLVNIQTI1Ng=='; + + $signature = base64url_encode(Crypto::sign($data . $precomputed, $channel['channel_prvkey'])); + + return ([ + 'id' => $arr['id'], + 'meData' => $data, + 'meDataType' => $data_type, + 'meEncoding' => $encoding, + 'meAlgorithm' => $algorithm, + 'meCreator' => Channel::url($channel), + 'meSignatureValue' => $signature + ]); + } +} diff --git a/Code/Lib/LibBlock.php b/Code/Lib/LibBlock.php new file mode 100644 index 000000000..bce0de6e9 --- /dev/null +++ b/Code/Lib/LibBlock.php @@ -0,0 +1,118 @@ +' ], [ '', '' ], $item); + } + + /** + * Builds a modal dialog for editing permissions, using acl_selector.tpl as the template. + * + * @param array $defaults Optional access control list for the initial state of the dialog. + * @param bool $show_jotnets Whether plugins for federated networks should be included in the permissions dialog + * @param PermissionDescription $emptyACL_description - An optional description for the permission implied by selecting an empty ACL. Preferably an instance of PermissionDescription. + * @param string $dialog_description Optional message to include at the top of the dialog. E.g. "Warning: Post permissions cannot be changed once sent". + * @param string $context_help Allows the dialog to present a help icon. E.g. "acl_dialog_post" + * @param bool $readonly Not implemented yet. When implemented, the dialog will use acl_readonly.tpl instead, so that permissions may be viewed for posts that can no longer have their permissions changed. + * + * @return string html modal dialog built from acl_selector.tpl + */ + public static function populate($defaults = null, $show_jotnets = true, $emptyACL_description = '', $dialog_description = '', $context_help = '', $readonly = false) + { + + $allow_cid = $allow_gid = $deny_cid = $deny_gid = false; + $showall_origin = ''; + $showall_icon = 'fa-globe'; + $role = get_pconfig(local_channel(), 'system', 'permissions_role'); + + if (! $emptyACL_description) { + $showall_caption = t('Visible to your default audience'); + } elseif (is_a($emptyACL_description, '\\Code\\Lib\\PermissionDescription')) { + $showall_caption = $emptyACL_description->get_permission_description(); + $showall_origin = (($role === 'custom') ? $emptyACL_description->get_permission_origin_description() : ''); + $showall_icon = $emptyACL_description->get_permission_icon(); + } else { + // For backwards compatibility we still accept a string... for now! + $showall_caption = $emptyACL_description; + } + + + if (is_array($defaults)) { + $allow_cid = ((strlen($defaults['allow_cid'])) + ? explode('><', $defaults['allow_cid']) : [] ); + $allow_gid = ((strlen($defaults['allow_gid'])) + ? explode('><', $defaults['allow_gid']) : [] ); + $deny_cid = ((strlen($defaults['deny_cid'])) + ? explode('><', $defaults['deny_cid']) : [] ); + $deny_gid = ((strlen($defaults['deny_gid'])) + ? explode('><', $defaults['deny_gid']) : [] ); + array_walk($allow_cid, ['\\Code\\Lib\\Libacl', 'fixacl']); + array_walk($allow_gid, ['\\Code\\Lib\\Libacl', 'fixacl']); + array_walk($deny_cid, ['\\Code\\Lib\\Libacl','fixacl']); + array_walk($deny_gid, ['\\Code\\Lib\\Libacl','fixacl']); + } + + $channel = ((local_channel()) ? App::get_channel() : ''); + $has_acl = false; + $single_group = false; + $just_me = false; + $custom = false; + + if ($allow_cid || $allow_gid || $deny_gid || $deny_cid) { + $has_acl = true; + $custom = true; + } + + if (count($allow_gid) === 1 && (! $allow_cid) && (! $deny_gid) && (! $deny_cid)) { + $single_group = true; + $custom = false; + } + + if (count($allow_cid) === 1 && $channel && $allow_cid[0] === $channel['channel_hash'] && (! $allow_gid) && (! $deny_gid) && (! $deny_cid)) { + $just_me = true; + $custom = false; + } + + $groups = EMPTY_STR; + + $r = q( + "SELECT id, hash, gname FROM pgrp WHERE deleted = 0 AND uid = %d ORDER BY gname ASC", + intval(local_channel()) + ); + + if ($r) { + foreach ($r as $rr) { + $selected = (($single_group && $rr['hash'] === $allow_gid[0]) ? ' selected = "selected" ' : ''); + $groups .= '' . "\r\n"; + } + } + + if ($channel && Apps::system_app_installed($channel['channel_id'], 'Virtual Lists')) { + $selected = (($single_group && 'connections:' . $channel['channel_hash'] === $allow_gid[0]) ? ' selected = "selected" ' : ''); + $groups .= '' . "\r\n"; + if (get_pconfig($channel['channel_id'], 'system', 'activitypub', get_config('system', 'activitypub', ACTIVITYPUB_ENABLED))) { + $selected = (($single_group && 'activitypub:' . $channel['channel_hash'] === $allow_gid[0]) ? ' selected = "selected" ' : ''); + $groups .= '' . "\r\n"; + } + $selected = (($single_group && 'zot:' . $channel['channel_hash'] === $allow_gid[0]) ? ' selected = "selected" ' : ''); + $groups .= '' . "\r\n"; + } + + + $forums = get_forum_channels(local_channel(), 1); + $selected = false; + if ($forums) { + foreach ($forums as $f) { + $selected = (($single_group && $f['hash'] === $allow_cid[0]) ? ' selected = "selected" ' : ''); + $groups .= '' . "\r\n"; + } + } + + // preset acl with DM to a single xchan (not a group) + if ($selected === false && count($allow_cid) === 1 && $channel && $allow_cid[0] !== $channel['channel_hash'] && (! $allow_gid) && (! $deny_gid) && (! $deny_cid)) { + $f = q( + "select * from xchan where xchan_hash = '%s'", + dbesc($allow_cid[0]) + ); + if ($f) { + $custom = false; + $selected = ' selected="selected" '; + $groups .= '' . "\r\n"; + } + } + + $tpl = Theme::get_template("acl_selector.tpl"); + $o = replace_macros($tpl, array( + '$showall' => $showall_caption, + '$onlyme' => t('Only me'), + '$groups' => $groups, + '$public_selected' => (($has_acl) ? false : ' selected="selected" '), + '$justme_selected' => (($just_me) ? ' selected="selected" ' : ''), + '$custom_selected' => (($custom) ? ' selected="selected" ' : ''), + '$showallOrigin' => $showall_origin, + '$showallIcon' => $showall_icon, + '$select_label' => t('Who can see this?'), + '$custom' => t('Custom selection'), + '$showlimitedDesc' => t('Select "Show" to allow viewing. "Don\'t show" lets you override and limit the scope of "Show".'), + '$show' => t('Show'), + '$hide' => t("Don't show"), + '$search' => t('Search'), + '$allowcid' => json_encode($allow_cid), + '$allowgid' => json_encode($allow_gid), + '$denycid' => json_encode($deny_cid), + '$denygid' => json_encode($deny_gid), + '$aclModalTitle' => t('Permissions'), + '$aclModalDesc' => $dialog_description, + '$aclModalDismiss' => t('Close'), + // '$helpUrl' => (($context_help == '') ? '' : (z_root() . '/help/' . $context_help)) + )); + + return $o; + } + + /** + * Returns a string that's suitable for passing as the $dialog_description argument to a + * populate() call for wall posts or network posts. + * + * This string is needed in 3 different files, and our .po translation system currently + * cannot be used as a string table (because the value is always the key in english) so + * I've centralized the value here (making this function name the "key") until we have a + * better way. + * + * @return string Description to present to user in modal permissions dialog + */ + public static function get_post_aclDialogDescription() + { + + // I'm trying to make two points in this description text - warn about finality of wall + // post permissions, and try to clear up confusion that these permissions set who is + // *shown* the post, istead of who is able to see the post, i.e. make it clear that clicking + // the "Show" button on a group does not post it to the feed of people in that group, it + // mearly allows those people to view the post if they are viewing/following this channel. + $description = t('Post permissions cannot be changed after a post is shared.
These permissions set who is allowed to view the post.'); + + // Lets keep the emphasis styling seperate from the translation. It may change. + //$emphasisOpen = ''; + //$emphasisClose = ''; + + return $description; + } + + +} diff --git a/Code/Lib/Libprofile.php b/Code/Lib/Libprofile.php new file mode 100644 index 000000000..3ac88dea1 --- /dev/null +++ b/Code/Lib/Libprofile.php @@ -0,0 +1,697 @@ + $f) { + if ($k == $qq['k']) { + $p[0][$k] = $qq['v']; + $extra_fields[] = $k; + break; + } + } + } + } + + $p[0]['extra_fields'] = ((isset($extra_fields)) ? $extra_fields : []); + + $z = q( + "select xchan_photo_date, xchan_addr from xchan where xchan_hash = '%s' limit 1", + dbesc($p[0]['channel_hash']) + ); + if ($z) { + $p[0]['picdate'] = $z[0]['xchan_photo_date']; + $p[0]['reddress'] = str_replace('@', '@', unpunify($z[0]['xchan_addr'])); + } + + // fetch user tags if this isn't the default profile + + if (!$p[0]['is_default']) { + $x = q( + "select keywords from profile where uid = %d and is_default = 1 limit 1", + intval($p[0]['profile_uid']) + ); + if ($x && $can_view_profile) { + $p[0]['keywords'] = $x[0]['keywords']; + } + } + + if ($p[0]['keywords']) { + $keywords = str_replace(array('#', ',', ' ', ',,'), array('', ' ', ',', ','), $p[0]['keywords']); + if (strlen($keywords) && $can_view_profile) { + if (!isset(App::$page['htmlhead'])) { + App::$page['htmlhead'] = EMPTY_STR; + } + App::$page['htmlhead'] .= '' . "\r\n"; + } + } + + App::$profile = $p[0]; + App::$profile_uid = $p[0]['profile_uid']; + App::$page['title'] = App::$profile['channel_name'] . " - " . unpunify(Channel::get_webfinger(App::$profile)); + + App::$profile['permission_to_view'] = $can_view_profile; + + if ($can_view_profile) { + $online = Channel::get_online_status($nickname); + App::$profile['online_status'] = $online['result']; + } + + if (local_channel()) { + App::$profile['channel_mobile_theme'] = get_pconfig(local_channel(), 'system', 'mobile_theme'); + $_SESSION['mobile_theme'] = App::$profile['channel_mobile_theme']; + } + + /* + * load/reload current theme info + */ + + // $_SESSION['theme'] = $p[0]['channel_theme']; + } + + public static function edit_menu($uid) + { + + $ret = []; + + $is_owner = (($uid == local_channel()) ? true : false); + + // show edit profile to profile owner + if ($is_owner) { + $ret['menu'] = array( + 'chg_photo' => t('Change profile photo'), + 'entries' => [], + ); + + $multi_profiles = Features::enabled(local_channel(), 'multi_profiles'); + if ($multi_profiles) { + $ret['multi'] = 1; + $ret['edit'] = [z_root() . '/profiles', t('Edit Profiles'), '', t('Edit')]; + $ret['menu']['cr_new'] = t('Create New Profile'); + } else { + $ret['edit'] = [z_root() . '/profiles/' . $uid, t('Edit Profile'), '', t('Edit')]; + } + + $r = q( + "SELECT * FROM profile WHERE uid = %d", + local_channel() + ); + + if ($r) { + foreach ($r as $rr) { + if (!($multi_profiles || $rr['is_default'])) { + continue; + } + + $ret['menu']['entries'][] = [ + 'photo' => $rr['thumb'], + 'id' => $rr['id'], + 'alt' => t('Profile Image'), + 'profile_name' => $rr['profile_name'], + 'isdefault' => $rr['is_default'], + 'visible_to_everybody' => t('Visible to everybody'), + 'edit_visibility' => t('Edit visibility'), + ]; + } + } + } + + return $ret; + } + + /** + * @brief Formats a profile for display in the sidebar. + * + * It is very difficult to templatise the HTML completely + * because of all the conditional logic. + * + * @param array $profile + * @param int $block + * @param bool $show_connect (optional) default true + * @param mixed $zcard (optional) default false + * + * @return HTML string suitable for sidebar inclusion + * Exceptions: Returns empty string if passed $profile is wrong type or not populated + */ + + public static function widget($profile, $block = 0, $show_connect = true, $zcard = false) + { + + $observer = App::get_observer(); + + $o = ''; + $location = false; + $pdesc = true; + $reddress = true; + + if (!perm_is_allowed($profile['uid'], ((is_array($observer)) ? $observer['xchan_hash'] : ''), 'view_profile')) { + $block = true; + } + + if ((!is_array($profile)) && (!count($profile))) { + return $o; + } + + head_set_icon($profile['thumb']); + + if (Channel::is_system($profile['uid'])) { + $show_connect = false; + } + + $profile['picdate'] = urlencode($profile['picdate']); + + /** + * @hooks profile_sidebar_enter + * Called before generating the 'channel sidebar' or mini-profile. + */ + Hook::call('profile_sidebar_enter', $profile); + + $profdm = EMPTY_STR; + $profdm_url = EMPTY_STR; + + $can_dm = perm_is_allowed($profile['uid'], (is_array($observer)) ? $observer['xchan_hash'] : EMPTY_STR, 'post_mail') && intval($observer['xchan_type']) !== XCHAN_TYPE_GROUP ; + + if (intval($profile['uid']) === local_channel()) { + $can_dm = false; + } + + if ($can_dm) { + $dm_path = Libzot::get_rpost_path($observer); + if ($dm_path) { + $profdm = t('Direct Message'); + $profdm_url = $dm_path + . '&to=' + . urlencode($profile['channel_hash']) + . '&body=' + . urlencode('@!{' . $profile['channel_address'] . '@' . App::get_hostname() . '}'); + } + } + + if ($show_connect) { + // This will return an empty string if we're already connected. + + $connect_url = rconnect_url($profile['uid'], get_observer_hash()); + $connect = (($connect_url) ? t('Connect') : ''); + if ($connect_url) { + $connect_url = sprintf($connect_url, urlencode(Channel::get_webfinger($profile))); + } + + // premium channel - over-ride + + if ($profile['channel_pageflags'] & PAGE_PREMIUM) { + $connect_url = z_root() . '/connect/' . $profile['channel_address']; + } + } + + if ( + (x($profile, 'address') == 1) + || (x($profile, 'locality') == 1) + || (x($profile, 'region') == 1) + || (x($profile, 'postal_code') == 1) + || (x($profile, 'country_name') == 1) + ) { + $location = t('Location:'); + } + + $profile['homepage'] = linkify($profile['homepage'], true); + + $gender = ((x($profile, 'gender') == 1) ? t('Gender:') : false); + $marital = ((x($profile, 'marital') == 1) ? t('Status:') : false); + $homepage = ((x($profile, 'homepage') == 1) ? t('Homepage:') : false); + $pronouns = ((x($profile, 'pronouns') == 1) ? t('Pronouns:') : false); + + // zap/osada do not have a realtime chat system at this time so don't show online state + // $profile['online'] = (($profile['online_status'] === 'online') ? t('Online Now') : False); + // logger('online: ' . $profile['online']); + + $profile['online'] = false; + + if (($profile['hidewall'] && (!local_channel()) && (!remote_channel())) || $block) { + $location = $reddress = $pdesc = $gender = $marital = $homepage = false; + } + + if ($profile['gender']) { + $profile['gender_icon'] = self::gender_icon($profile['gender']); + } + + if ($profile['pronouns']) { + $profile['pronouns_icon'] = self::pronouns_icon($profile['pronouns']); + } + + $firstname = ((strpos($profile['channel_name'], ' ')) + ? trim(substr($profile['channel_name'], 0, strpos($profile['channel_name'], ' '))) : $profile['channel_name']); + $lastname = (($firstname === $profile['channel_name']) ? '' : trim(substr($profile['channel_name'], strlen($firstname)))); + + + $contact_block = contact_block(); + + $channel_menu = false; + $menu = get_pconfig($profile['uid'], 'system', 'channel_menu'); + if ($menu && !$block) { + $m = Menu::fetch($menu, $profile['uid'], $observer['xchan_hash']); + if ($m) { + $channel_menu = Menu::render($m); + } + } + $menublock = get_pconfig($profile['uid'], 'system', 'channel_menublock'); + if ($menublock && (!$block)) { + $comanche = new Comanche(); + $channel_menu .= $comanche->block($menublock); + } + + if ($zcard) { + $tpl = Theme::get_template('profile_vcard_short.tpl'); + } else { + $tpl = Theme::get_template('profile_vcard.tpl'); + } + + $o .= replace_macros($tpl, array( + '$zcard' => $zcard, + '$profile' => $profile, + '$connect' => $connect, + '$connect_url' => $connect_url, + '$profdm' => $profdm, + '$profdm_url' => $profdm_url, + '$location' => $location, + '$gender' => $gender, + '$pronouns' => $pronouns, + '$pdesc' => $pdesc, + '$marital' => $marital, + '$homepage' => $homepage, + '$chanmenu' => $channel_menu, + '$reddress' => $reddress, + '$active' => t('Active'), + '$activewhen' => relative_date($profile['channel_lastpost']), + '$rating' => '', + '$contact_block' => $contact_block, + '$change_photo' => t('Change your profile photo'), + '$copyto' => t('Copy to clipboard'), + '$copied' => t('Address copied to clipboard'), + '$editmenu' => self::edit_menu($profile['uid']) + )); + + $arr = [ + 'profile' => $profile, + 'entry' => $o + ]; + + /** + * @hooks profile_sidebar + * Called when generating the 'channel sidebar' or mini-profile. + * * \e array \b profile + * * \e string \b entry - The parsed HTML template + */ + Hook::call('profile_sidebar', $arr); + + return $arr['entry']; + } + + public static function gender_icon($gender) + { + + // logger('gender: ' . $gender); + + // This can easily get throw off if the observer language is different + // than the channel owner language. + + if (strpos(strtolower($gender), strtolower(t('Female'))) !== false) { + return 'venus'; + } + if (strpos(strtolower($gender), strtolower(t('Male'))) !== false) { + return 'mars'; + } + if (strpos(strtolower($gender), strtolower(t('Trans'))) !== false) { + return 'transgender'; + } + if (strpos(strtolower($gender), strtolower(t('Inter'))) !== false) { + return 'transgender'; + } + if (strpos(strtolower($gender), strtolower(t('Neuter'))) !== false) { + return 'neuter'; + } + if (strpos(strtolower($gender), strtolower(t('Non-specific'))) !== false) { + return 'genderless'; + } + + return ''; + } + + public static function pronouns_icon($pronouns) + { + + + // This can easily get throw off if the observer language is different + // than the channel owner language. + + if (strpos(strtolower($pronouns), strtolower(t('She'))) !== false) { + return 'venus'; + } + if (strpos(strtolower($pronouns), strtolower(t('Him'))) !== false) { + return 'mars'; + } + if (strpos(strtolower($pronouns), strtolower(t('Them'))) !== false) { + return 'users'; + } + + return ''; + } + + + public static function advanced() + { + + if (!perm_is_allowed(App::$profile['profile_uid'], get_observer_hash(), 'view_profile')) { + return ''; + } + + if (App::$profile['fullname']) { + $profile_fields_basic = Channel::get_profile_fields_basic(); + $profile_fields_advanced = Channel::get_profile_fields_advanced(); + + $advanced = ((Features::enabled(App::$profile['profile_uid'], 'advanced_profiles')) ? true : false); + if ($advanced) { + $fields = $profile_fields_advanced; + } else { + $fields = $profile_fields_basic; + } + + $clean_fields = []; + if ($fields) { + foreach ($fields as $k => $v) { + $clean_fields[] = trim($k); + } + } + + + $tpl = Theme::get_template('profile_advanced.tpl'); + + $profile = []; + + $profile['fullname'] = array(t('Full Name:'), App::$profile['fullname']); + + if (App::$profile['gender']) { + $profile['gender'] = array(t('Gender:'), App::$profile['gender']); + } + + + $ob_hash = get_observer_hash(); +// this may not work at all any more, but definitely won't work correctly if the liked profile belongs to a group +// comment out until we are able to look at it much closer +// if($ob_hash && perm_is_allowed(App::$profile['profile_uid'],$ob_hash,'post_like')) { +// $profile['canlike'] = true; +// $profile['likethis'] = t('Like this channel'); +// $profile['profile_guid'] = App::$profile['profile_guid']; +// } + +// $likers = q("select liker, xchan.* from likes left join xchan on liker = xchan_hash where channel_id = %d and target_type = '%s' and verb = '%s'", +// intval(App::$profile['profile_uid']), +// dbesc(ACTIVITY_OBJ_PROFILE), +// dbesc(ACTIVITY_LIKE) +// ); +// $profile['likers'] = []; +// $profile['like_count'] = count($likers); +// $profile['like_button_label'] = tt('Like','Likes',$profile['like_count'],'noun'); + +// if($likers) { +// foreach($likers as $l) +// $profile['likers'][] = array('name' => $l['xchan_name'],'photo' => zid($l['xchan_photo_s']), 'url' => zid($l['xchan_url'])); +// } + + if ((App::$profile['dob']) && (App::$profile['dob'] != '0000-00-00')) { + $val = ''; + + if ((substr(App::$profile['dob'], 5, 2) === '00') || (substr(App::$profile['dob'], 8, 2) === '00')) { + $val = substr(App::$profile['dob'], 0, 4); + } + + $year_bd_format = t('j F, Y'); + $short_bd_format = t('j F'); + + if (!$val) { + $val = ((intval(App::$profile['dob'])) + ? day_translate(datetime_convert('UTC', 'UTC', App::$profile['dob'] . ' 00:00 +00:00', $year_bd_format)) + : day_translate(datetime_convert('UTC', 'UTC', '2001-' . substr(App::$profile['dob'], 5) . ' 00:00 +00:00', $short_bd_format))); + } + $profile['birthday'] = array(t('Birthday:'), $val); + } + + if ($age = age(App::$profile['dob'], App::$profile['timezone'], '')) { + $profile['age'] = array(t('Age:'), $age); + } + + if (App::$profile['marital']) { + $profile['marital'] = array(t('Status:'), App::$profile['marital']); + } + + if (App::$profile['partner']) { + $profile['marital']['partner'] = zidify_links(bbcode(App::$profile['partner'])); + } + + if (strlen(App::$profile['howlong']) && App::$profile['howlong'] > NULL_DATE) { + $profile['howlong'] = relative_date(App::$profile['howlong'], t('for %1$d %2$s')); + } + + if (App::$profile['keywords']) { + $keywords = str_replace(',', ' ', App::$profile['keywords']); + $keywords = str_replace(' ', ' ', $keywords); + $karr = explode(' ', $keywords); + if ($karr) { + for ($cnt = 0; $cnt < count($karr); $cnt++) { + $karr[$cnt] = '' . $karr[$cnt] . ''; + } + } + $profile['keywords'] = array(t('Tags:'), implode(' ', $karr)); + } + + + if (App::$profile['sexual']) { + $profile['sexual'] = array(t('Sexual Preference:'), App::$profile['sexual']); + } + + if (App::$profile['pronouns']) { + $profile['pronouns'] = array(t('Pronouns:'), App::$profile['pronouns']); + } + + if (App::$profile['homepage']) { + $profile['homepage'] = array(t('Homepage:'), linkify(App::$profile['homepage'])); + } + + if (App::$profile['hometown']) { + $profile['hometown'] = array(t('Hometown:'), linkify(App::$profile['hometown'])); + } + + if (App::$profile['politic']) { + $profile['politic'] = array(t('Political Views:'), App::$profile['politic']); + } + + if (App::$profile['religion']) { + $profile['religion'] = array(t('Religion:'), App::$profile['religion']); + } + + if ($txt = prepare_text(App::$profile['about'])) { + $profile['about'] = array(t('About:'), $txt); + } + + if ($txt = prepare_text(App::$profile['interest'])) { + $profile['interest'] = array(t('Hobbies/Interests:'), $txt); + } + + if ($txt = prepare_text(App::$profile['likes'])) { + $profile['likes'] = array(t('Likes:'), $txt); + } + + if ($txt = prepare_text(App::$profile['dislikes'])) { + $profile['dislikes'] = array(t('Dislikes:'), $txt); + } + + if ($txt = prepare_text(App::$profile['contact'])) { + $profile['contact'] = array(t('Contact information and Social Networks:'), $txt); + } + + if ($txt = prepare_text(App::$profile['channels'])) { + $profile['channels'] = array(t('My other channels:'), $txt); + } + + if ($txt = prepare_text(App::$profile['music'])) { + $profile['music'] = array(t('Musical interests:'), $txt); + } + + if ($txt = prepare_text(App::$profile['book'])) { + $profile['book'] = array(t('Books, literature:'), $txt); + } + + if ($txt = prepare_text(App::$profile['tv'])) { + $profile['tv'] = array(t('Television:'), $txt); + } + + if ($txt = prepare_text(App::$profile['film'])) { + $profile['film'] = array(t('Film/dance/culture/entertainment:'), $txt); + } + + if ($txt = prepare_text(App::$profile['romance'])) { + $profile['romance'] = array(t('Love/Romance:'), $txt); + } + + if ($txt = prepare_text(App::$profile['employment'])) { + $profile['employment'] = array(t('Work/employment:'), $txt); + } + + if ($txt = prepare_text(App::$profile['education'])) { + $profile['education'] = array(t('School/education:'), $txt); + } + + if (App::$profile['extra_fields']) { + foreach (App::$profile['extra_fields'] as $f) { + $x = q( + "select * from profdef where field_name = '%s' limit 1", + dbesc($f) + ); + if ($x && $txt = prepare_text(App::$profile[$f])) { + $profile[$f] = array($x[0]['field_desc'] . ':', $txt); + } + } + $profile['extra_fields'] = App::$profile['extra_fields']; + } + + $things = get_things(App::$profile['profile_guid'], App::$profile['profile_uid']); + + + // logger('mod_profile: things: ' . print_r($things,true), LOGGER_DATA); + + // $exportlink = ((App::$profile['profile_vcard']) ? zid(z_root() . '/profile/' . App::$profile['channel_address'] . '/vcard') : ''); + + return replace_macros($tpl, array( + '$title' => t('Profile'), + '$canlike' => (($profile['canlike']) ? true : false), + '$likethis' => t('Like this thing'), + '$export' => t('Export'), + '$exportlink' => '', // $exportlink, + '$profile' => $profile, + '$fields' => $clean_fields, + '$editmenu' => self::edit_menu(App::$profile['profile_uid']), + '$things' => $things + )); + } + + return ''; + } +} diff --git a/Code/Lib/Libsync.php b/Code/Lib/Libsync.php new file mode 100644 index 000000000..5277bc62a --- /dev/null +++ b/Code/Lib/Libsync.php @@ -0,0 +1,1303 @@ + $channel['channel_address'], 'url' => z_root()]; + + if (array_key_exists($uid, App::$config) && array_key_exists('transient', App::$config[$uid])) { + $settings = App::$config[$uid]['transient']; + if ($settings) { + $info['config'] = $settings; + } + } + + if ($channel) { + $info['channel'] = []; + foreach ($channel as $k => $v) { + // filter out any joined tables like xchan + + if (strpos($k, 'channel_') !== 0) { + continue; + } + + // don't pass these elements, they should not be synchronised + + + $disallowed = ['channel_id', 'channel_account_id', 'channel_primary', 'channel_address', + 'channel_deleted', 'channel_removed', 'channel_system']; + + if (!$keychange) { + $disallowed[] = 'channel_prvkey'; + } + + if (in_array($k, $disallowed)) { + continue; + } + + $info['channel'][$k] = $v; + } + } + + if ($groups_changed) { + $r = q( + "select hash as collection, visible, deleted, rule, gname as name from pgrp where uid = %d ", + intval($uid) + ); + if ($r) { + $info['collections'] = $r; + } + + $r = q( + "select pgrp.hash as collection, pgrp_member.xchan as member from pgrp left join pgrp_member on pgrp.id = pgrp_member.gid + where pgrp_member.uid = %d ", + intval($uid) + ); + if ($r) { + $info['collection_members'] = $r; + } + } + + $interval = get_config('system', 'delivery_interval', 2); + + logger('Packet: ' . print_r($info, true), LOGGER_DATA, LOG_DEBUG); + + $total = count($synchubs); + + foreach ($synchubs as $hub) { + $hash = random_string(); + $n = Libzot::build_packet($channel, 'sync', $env_recips, json_encode($info), 'red', $hub['hubloc_sitekey'], $hub['site_crypto']); + Queue::insert([ + 'hash' => $hash, + 'account_id' => $channel['channel_account_id'], + 'channel_id' => $channel['channel_id'], + 'posturl' => $hub['hubloc_callback'], + 'notify' => $n, + 'msg' => EMPTY_STR + ]); + + + $x = q("select count(outq_hash) as total from outq where outq_delivered = 0"); + if (intval($x[0]['total']) > intval(get_config('system', 'force_queue_threshold', 3000))) { + logger('immediate delivery deferred.', LOGGER_DEBUG, LOG_INFO); + Queue::update($hash); + continue; + } + + + Run::Summon(['Deliver', $hash]); + $total = $total - 1; + + if ($interval && $total) { + @time_sleep_until(microtime(true) + (float)$interval); + } + } + } + + + public static function build_link_packet($uid = 0, $packet = null) + { + + // logger('build_link_packet'); + + if (!$uid) { + $uid = local_channel(); + } + + if (!$uid) { + return; + } + + $channel = Channel::from_id($uid); + if (!$channel) { + return; + } + + $l = q( + "select link from linkid where ident = '%s' and sigtype = 2", + dbesc($channel['channel_hash']) + ); + + if (!$l) { + return; + } + + $hashes = ids_to_querystr($l, 'link', true); + + $h = q("select hubloc.*, site.site_crypto from hubloc left join site on site_url = hubloc_url where hubloc_hash in (" . protect_sprintf($hashes) . ") and hubloc_network in ('nomad','zot6') and hubloc_deleted = 0"); + + if (!$h) { + return; + } + + $interval = get_config('system', 'delivery_interval', 2); + + + foreach ($h as $x) { + if ($x['hubloc_host'] == App::get_hostname()) { + continue; + } + + $y = q( + "select site_dead from site where site_url = '%s' limit 1", + dbesc($x['hubloc_url']) + ); + + if (($y) && (intval($y[0]['site_dead']) == 1)) { + $continue; + } + + $env_recips = [$x['hubloc_hash']]; + + if ($packet) { + logger('packet: ' . print_r($packet, true), LOGGER_DATA, LOG_DEBUG); + } + + $info = (($packet) ? $packet : []); + $info['type'] = 'sync'; + $info['encoding'] = 'red'; // note: not zot, this packet is very platform specific + + logger('Packet: ' . print_r($info, true), LOGGER_DATA, LOG_DEBUG); + + + $hash = random_string(); + $n = Libzot::build_packet($channel, 'sync', $env_recips, json_encode($info), 'red', $x['hubloc_sitekey'], $x['site_crypto']); + Queue::insert([ + 'hash' => $hash, + 'account_id' => $channel['channel_account_id'], + 'channel_id' => $channel['channel_id'], + 'posturl' => $x['hubloc_callback'], + 'notify' => $n, + 'msg' => EMPTY_STR + ]); + + $y = q("select count(outq_hash) as total from outq where outq_delivered = 0"); + if (intval($y[0]['total']) > intval(get_config('system', 'force_queue_threshold', 3000))) { + logger('immediate delivery deferred.', LOGGER_DEBUG, LOG_INFO); + Queue::update($hash); + continue; + } + + Run::Summon(['Deliver', $hash]); + + if ($interval && count($h) > 1) { + @time_sleep_until(microtime(true) + (float)$interval); + } + } + } + + + /** + * @brief + * + * @param array $sender + * @param array $arr + * @param array $deliveries + * @return array + */ + + public static function process_channel_sync_delivery($sender, $arr, $deliveries) + { + + require_once('include/import.php'); + + $result = []; + + $keychange = ((array_key_exists('keychange', $arr)) ? true : false); + + foreach ($deliveries as $d) { + $linked_channel = false; + + $r = q( + "select * from channel where channel_hash = '%s' limit 1", + dbesc($sender) + ); + + $DR = new DReport(z_root(), $sender, $d, 'sync'); + + if (!$r) { + $l = q( + "select ident from linkid where link = '%s' and sigtype = 2 limit 1", + dbesc($sender) + ); + if ($l) { + $linked_channel = true; + $r = q( + "select * from channel where channel_hash = '%s' limit 1", + dbesc($l[0]['ident']) + ); + } + } + + if (!$r) { + $DR->update('recipient not found'); + $result[] = $DR->get(); + continue; + } + + $channel = $r[0]; + + $DR->set_name($channel['channel_name'] . ' <' . Channel::get_webfinger($channel) . '>'); + + $max_friends = ServiceClass::fetch($channel['channel_id'], 'total_channels'); + $max_feeds = ServiceClass::account_fetch($channel['channel_account_id'], 'total_feeds'); + + if ($channel['channel_hash'] != $sender && (!$linked_channel)) { + logger('Possible forgery. Sender ' . $sender . ' is not ' . $channel['channel_hash']); + $DR->update('channel mismatch'); + $result[] = $DR->get(); + continue; + } + + if ($keychange) { + self::keychange($channel, $arr); + continue; + } + + // if the clone is active, so are we + + if (substr($channel['channel_active'], 0, 10) !== substr(datetime_convert(), 0, 10)) { + q( + "UPDATE channel set channel_active = '%s' where channel_id = %d", + dbesc(datetime_convert()), + intval($channel['channel_id']) + ); + } + + if (array_key_exists('config', $arr) && is_array($arr['config']) && count($arr['config'])) { + foreach ($arr['config'] as $cat => $k) { + foreach ($arr['config'][$cat] as $k => $v) { + set_pconfig($channel['channel_id'], $cat, $k, $v); + } + } + } + + if (array_key_exists('atoken', $arr) && $arr['atoken']) { + sync_atoken($channel, $arr['atoken']); + } + + if (array_key_exists('xign', $arr) && $arr['xign']) { + sync_xign($channel, $arr['xign']); + } + + if (array_key_exists('block', $arr) && $arr['block']) { + sync_block($channel, $arr['block']); + } + + if (array_key_exists('obj', $arr) && $arr['obj']) { + sync_objs($channel, $arr['obj']); + } + + if (array_key_exists('likes', $arr) && $arr['likes']) { + import_likes($channel, $arr['likes']); + } + + if (array_key_exists('app', $arr) && $arr['app']) { + sync_apps($channel, $arr['app']); + } + + if (array_key_exists('sysapp', $arr) && $arr['sysapp']) { + sync_sysapps($channel, $arr['sysapp']); + } + + if (array_key_exists('chatroom', $arr) && $arr['chatroom']) { + sync_chatrooms($channel, $arr['chatroom']); + } + + if (array_key_exists('conv', $arr) && $arr['conv']) { + import_conv($channel, $arr['conv']); + } + + if (array_key_exists('mail', $arr) && $arr['mail']) { + sync_mail($channel, $arr['mail']); + } + + if (array_key_exists('event', $arr) && $arr['event']) { + sync_events($channel, $arr['event']); + } + + if (array_key_exists('event_item', $arr) && $arr['event_item']) { + sync_items($channel, $arr['event_item'], ((array_key_exists('relocate', $arr)) ? $arr['relocate'] : null)); + } + + if (array_key_exists('item', $arr) && $arr['item']) { + sync_items($channel, $arr['item'], ((array_key_exists('relocate', $arr)) ? $arr['relocate'] : null)); + } + + if (array_key_exists('menu', $arr) && $arr['menu']) { + sync_menus($channel, $arr['menu']); + } + + if (array_key_exists('file', $arr) && $arr['file']) { + sync_files($channel, $arr['file']); + } + + if (array_key_exists('wiki', $arr) && $arr['wiki']) { + sync_items($channel, $arr['wiki'], ((array_key_exists('relocate', $arr)) ? $arr['relocate'] : null)); + } + + if (array_key_exists('channel', $arr) && is_array($arr['channel']) && count($arr['channel'])) { + $remote_channel = $arr['channel']; + $remote_channel['channel_id'] = $channel['channel_id']; + + if (array_key_exists('channel_pageflags', $arr['channel']) && intval($arr['channel']['channel_pageflags'])) { + // Several pageflags are site-specific and cannot be sync'd. + // Only allow those bits which are shareable from the remote and then + // logically OR with the local flags + + $arr['channel']['channel_pageflags'] = $arr['channel']['channel_pageflags'] & (PAGE_HIDDEN | PAGE_AUTOCONNECT | PAGE_APPLICATION | PAGE_PREMIUM | PAGE_ADULT); + $arr['channel']['channel_pageflags'] = $arr['channel']['channel_pageflags'] | $channel['channel_pageflags']; + } + + $columns = db_columns('channel'); + + $disallowed = [ + 'channel_id', 'channel_account_id', 'channel_primary', 'channel_prvkey', + 'channel_address', 'channel_notifyflags', 'channel_removed', 'channel_deleted', + 'channel_system', 'channel_r_stream', 'channel_r_profile', 'channel_r_abook', + 'channel_r_storage', 'channel_r_pages', 'channel_w_stream', 'channel_w_wall', + 'channel_w_comment', 'channel_w_mail', 'channel_w_like', 'channel_w_tagwall', + 'channel_w_chat', 'channel_w_storage', 'channel_w_pages', 'channel_a_republish', + 'channel_a_delegate', 'channel_moved' + ]; + + foreach ($arr['channel'] as $k => $v) { + if (in_array($k, $disallowed)) { + continue; + } + if (!in_array($k, $columns)) { + continue; + } + $r = dbq("UPDATE channel set " . dbesc($k) . " = '" . dbesc($v) + . "' where channel_id = " . intval($channel['channel_id'])); + } + } + + if (array_key_exists('abook', $arr) && is_array($arr['abook']) && count($arr['abook'])) { + $total_friends = 0; + $total_feeds = 0; + + $r = q( + "select abook_id, abook_feed from abook where abook_channel = %d", + intval($channel['channel_id']) + ); + if ($r) { + // don't count yourself + $total_friends = ((count($r) > 0) ? count($r) - 1 : 0); + foreach ($r as $rr) { + if (intval($rr['abook_feed'])) { + $total_feeds++; + } + } + } + + + $disallowed = ['abook_id', 'abook_account', 'abook_channel', 'abook_rating', 'abook_rating_text', 'abook_not_here']; + + $fields = db_columns('abook'); + + foreach ($arr['abook'] as $abook) { + // this is here for debugging so we can find the issue source + + if (!is_array($abook)) { + btlogger('abook is not an array'); + continue; + } + + $abconfig = null; + + if (array_key_exists('abconfig', $abook) && is_array($abook['abconfig']) && count($abook['abconfig'])) { + $abconfig = $abook['abconfig']; + } + + $clean = []; + + if ($abook['abook_xchan'] && $abook['entry_deleted']) { + logger('Removing abook entry for ' . $abook['abook_xchan']); + + $r = q( + "select abook_id, abook_feed from abook where abook_xchan = '%s' and abook_channel = %d and abook_self = 0 limit 1", + dbesc($abook['abook_xchan']), + intval($channel['channel_id']) + ); + if ($r) { + contact_remove($channel['channel_id'], $r[0]['abook_id']); + if ($total_friends) { + $total_friends--; + } + if (intval($r[0]['abook_feed'])) { + $total_feeds--; + } + } + continue; + } + + // Perform discovery if the referenced xchan hasn't ever been seen on this hub. + // This relies on the undocumented behaviour that red sites send xchan info with the abook + // and import_author_xchan will look them up on all federated networks + + $found = false; + if ($abook['abook_xchan'] && $abook['xchan_addr'] && (!in_array($abook['xchan_network'], ['token', 'unknown']))) { + $h = Libzot::get_hublocs($abook['abook_xchan']); + if ($h) { + $found = true; + } else { + $xhash = import_author_xchan(encode_item_xchan($abook)); + if ($xhash) { + $found = true; + } else { + logger('Import of ' . $abook['xchan_addr'] . ' failed.'); + } + } + } + + if ((!$found) && (!in_array($abook['xchan_network'], ['nomad', 'zot6', 'activitypub']))) { + // just import the record. + $xc = []; + foreach ($abook as $k => $v) { + if (strpos($k, 'xchan_') === 0) { + $xc[$k] = $v; + } + } + $r = q( + "select * from xchan where xchan_hash = '%s'", + dbesc($xc['xchan_hash']) + ); + if (!$r) { + xchan_store_lowlevel($xc); + } + } + + foreach ($abook as $k => $v) { + if (in_array($k, $disallowed) || (strpos($k, 'abook_') !== 0)) { + continue; + } + if (!in_array($k, $fields)) { + continue; + } + $clean[$k] = $v; + } + + if (!array_key_exists('abook_xchan', $clean)) { + continue; + } + + $reconnect = false; + if (array_key_exists('abook_instance', $clean) && $clean['abook_instance'] && strpos($clean['abook_instance'], z_root()) === false) { + // guest pass or access token - don't try to probe since it is one-way + // we are relying on the undocumented behaviour that the abook record also contains the xchan + if ($abook['xchan_network'] === 'token') { + $clean['abook_instance'] .= ','; + $clean['abook_instance'] .= z_root(); + $clean['abook_not_here'] = 0; + } else { + $clean['abook_not_here'] = 1; + if (!($abook['abook_pending'] || $abook['abook_blocked'])) { + $reconnect = true; + } + } + } + + $r = q( + "select * from abook where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($clean['abook_xchan']), + intval($channel['channel_id']) + ); + + // make sure we have an abook entry for this xchan on this system + + if (!$r) { + if ($max_friends !== false && $total_friends > $max_friends) { + logger('total_channels service class limit exceeded'); + continue; + } + if ($max_feeds !== false && intval($clean['abook_feed']) && $total_feeds > $max_feeds) { + logger('total_feeds service class limit exceeded'); + continue; + } + abook_store_lowlevel( + [ + 'abook_xchan' => $clean['abook_xchan'], + 'abook_account' => $channel['channel_account_id'], + 'abook_channel' => $channel['channel_id'] + ] + ); + $total_friends++; + if (intval($clean['abook_feed'])) { + $total_feeds++; + } + } + + if (count($clean)) { + foreach ($clean as $k => $v) { + if ($k == 'abook_dob') { + $v = dbescdate($v); + } + + $r = dbq("UPDATE abook set " . dbesc($k) . " = '" . dbesc($v) + . "' where abook_xchan = '" . dbesc($clean['abook_xchan']) . "' and abook_channel = " . intval($channel['channel_id'])); + } + } + + // This will set abconfig vars if the sender is using old-style fixed permissions + // using the raw abook record as passed to us. New-style permissions will fall through + // and be set using abconfig + + // translate_abook_perms_inbound($channel,$abook); + + if ($abconfig) { + /// @fixme does not handle sync of del_abconfig + foreach ($abconfig as $abc) { + set_abconfig($channel['channel_id'], $abc['xchan'], $abc['cat'], $abc['k'], $abc['v']); + } + } + if ($reconnect) { + Connect::connect($channel, $abook['abook_xchan']); + } + } + } + + // sync collections (privacy groups) oh joy... + + if (array_key_exists('collections', $arr) && is_array($arr['collections']) && count($arr['collections'])) { + $x = q( + "select * from pgrp where uid = %d ", + intval($channel['channel_id']) + ); + foreach ($arr['collections'] as $cl) { + $found = false; + if ($x) { + foreach ($x as $y) { + if ($cl['collection'] == $y['hash']) { + $found = true; + break; + } + } + if ($found) { + if ( + ($y['gname'] != $cl['name']) + || ($y['visible'] != $cl['visible']) + || ($y['deleted'] != $cl['deleted']) + ) { + q( + "update pgrp set gname = '%s', visible = %d, deleted = %d where hash = '%s' and uid = %d", + dbesc($cl['name']), + intval($cl['visible']), + intval($cl['deleted']), + dbesc($cl['collection']), + intval($channel['channel_id']) + ); + } + if (intval($cl['deleted']) && (!intval($y['deleted']))) { + q( + "delete from pgrp_member where gid = %d", + intval($y['id']) + ); + } + } + } + if (!$found) { + $r = q( + "INSERT INTO pgrp ( hash, uid, visible, deleted, gname, rule ) + VALUES( '%s', %d, %d, %d, '%s', '%s' ) ", + dbesc($cl['collection']), + intval($channel['channel_id']), + intval($cl['visible']), + intval($cl['deleted']), + dbesc($cl['name']), + dbesc($cl['rule']) + ); + } + + // now look for any collections locally which weren't in the list we just received. + // They need to be removed by marking deleted and removing the members. + // This shouldn't happen except for clones created before this function was written. + + if ($x) { + $found_local = false; + foreach ($x as $y) { + foreach ($arr['collections'] as $cl) { + if ($cl['collection'] == $y['hash']) { + $found_local = true; + break; + } + } + if (!$found_local) { + q( + "delete from pgrp_member where gid = %d", + intval($y['id']) + ); + q( + "update pgrp set deleted = 1 where id = %d and uid = %d", + intval($y['id']), + intval($channel['channel_id']) + ); + } + } + } + } + + // reload the group list with any updates + $x = q( + "select * from pgrp where uid = %d", + intval($channel['channel_id']) + ); + + // now sync the members + + if ( + array_key_exists('collection_members', $arr) + && is_array($arr['collection_members']) + && count($arr['collection_members']) + ) { + // first sort into groups keyed by the group hash + $members = []; + foreach ($arr['collection_members'] as $cm) { + if (!array_key_exists($cm['collection'], $members)) { + $members[$cm['collection']] = []; + } + + $members[$cm['collection']][] = $cm['member']; + } + + // our group list is already synchronised + if ($x) { + foreach ($x as $y) { + // for each group, loop on members list we just received + if (isset($y['hash']) && isset($members[$y['hash']])) { + foreach ($members[$y['hash']] as $member) { + $found = false; + $z = q( + "select xchan from pgrp_member where gid = %d and uid = %d and xchan = '%s' limit 1", + intval($y['id']), + intval($channel['channel_id']), + dbesc($member) + ); + if ($z) { + $found = true; + } + + // if somebody is in the group that wasn't before - add them + + if (!$found) { + q( + "INSERT INTO pgrp_member (uid, gid, xchan) + VALUES( %d, %d, '%s' ) ", + intval($channel['channel_id']), + intval($y['id']), + dbesc($member) + ); + } + } + } + + // now retrieve a list of members we have on this site + $m = q( + "select xchan from pgrp_member where gid = %d and uid = %d", + intval($y['id']), + intval($channel['channel_id']) + ); + if ($m) { + foreach ($m as $mm) { + // if the local existing member isn't in the list we just received - remove them + if (!in_array($mm['xchan'], $members[$y['hash']])) { + q( + "delete from pgrp_member where xchan = '%s' and gid = %d and uid = %d", + dbesc($mm['xchan']), + intval($y['id']), + intval($channel['channel_id']) + ); + } + } + } + } + } + } + } + + if (array_key_exists('profile', $arr) && is_array($arr['profile']) && count($arr['profile'])) { + $disallowed = array('id', 'aid', 'uid', 'guid'); + + foreach ($arr['profile'] as $profile) { + $x = q( + "select * from profile where profile_guid = '%s' and uid = %d limit 1", + dbesc($profile['profile_guid']), + intval($channel['channel_id']) + ); + if (!$x) { + Channel::profile_store_lowlevel( + [ + 'aid' => $channel['channel_account_id'], + 'uid' => $channel['channel_id'], + 'profile_guid' => $profile['profile_guid'], + ] + ); + + $x = q( + "select * from profile where profile_guid = '%s' and uid = %d limit 1", + dbesc($profile['profile_guid']), + intval($channel['channel_id']) + ); + if (!$x) { + continue; + } + } + $clean = []; + foreach ($profile as $k => $v) { + if (in_array($k, $disallowed)) { + continue; + } + + if ($profile['is_default'] && in_array($k, ['photo', 'thumb'])) { + continue; + } + + if ($k === 'name') { + $clean['fullname'] = $v; + } elseif ($k === 'with') { + $clean['partner'] = $v; + } elseif ($k === 'work') { + $clean['employment'] = $v; + } elseif (array_key_exists($k, $x[0])) { + $clean[$k] = $v; + } + + /** + * @TODO + * We also need to import local photos if a custom photo is selected + */ + + if ((strpos($profile['thumb'], '/photo/profile/l/') !== false) || intval($profile['is_default'])) { + $profile['photo'] = z_root() . '/photo/profile/l/' . $channel['channel_id']; + $profile['thumb'] = z_root() . '/photo/profile/m/' . $channel['channel_id']; + } else { + $profile['photo'] = z_root() . '/photo/' . basename($profile['photo']); + $profile['thumb'] = z_root() . '/photo/' . basename($profile['thumb']); + } + } + + if (count($clean)) { + foreach ($clean as $k => $v) { + $r = dbq("UPDATE profile set " . TQUOT . dbesc($k) . TQUOT . " = '" . dbesc($v) + . "' where profile_guid = '" . dbesc($profile['profile_guid']) + . "' and uid = " . intval($channel['channel_id'])); + } + } + } + } + + $addon = ['channel' => $channel, 'data' => $arr]; + /** + * @hooks process_channel_sync_delivery + * Called when accepting delivery of a 'sync packet' containing structure and table updates from a channel clone. + * * \e array \b channel + * * \e array \b data + */ + Hook::call('process_channel_sync_delivery', $addon); + + $DR = new DReport(z_root(), $d, $d, 'sync', 'channel sync delivered'); + + $DR->set_name($channel['channel_name'] . ' <' . Channel::get_webfinger($channel) . '>'); + + $result[] = $DR->get(); + } + + return $result; + } + + /** + * @brief Synchronises locations. + * + * @param array $sender + * @param array $arr + * @param bool $absolute (optional) default false + * @return array + */ + + public static function sync_locations($sender, $arr, $absolute = false) + { + + $ret = []; + $what = EMPTY_STR; + $changed = false; + + // If a sender reports that the channel has been deleted, delete its hubloc + + if (isset($arr['deleted_locally']) && intval($arr['deleted_locally'])) { + q( + "UPDATE hubloc SET hubloc_deleted = 1, hubloc_updated = '%s' WHERE hubloc_hash = '%s' AND hubloc_url = '%s'", + dbesc(datetime_convert()), + dbesc($sender['hash']), + dbesc($sender['site']['url']) + ); + } + + if ($arr['locations']) { + $x = q( + "select * from xchan where xchan_hash = '%s'", + dbesc($sender['hash']) + ); + if ($x) { + $xchan = array_shift($x); + } + + if ($absolute) { + Libzot::check_location_move($sender['hash'], $arr['locations']); + } + + $xisting = q( + "select * from hubloc where hubloc_hash = '%s'", + dbesc($sender['hash']) + ); + + if (!$xisting) { + $xisting = []; + } + + // See if a primary is specified + + $has_primary = false; + foreach ($arr['locations'] as $location) { + if ($location['primary']) { + $has_primary = true; + break; + } + } + + // Ensure that they have one primary hub + + if (!$has_primary) { + $arr['locations'][0]['primary'] = true; + } + + foreach ($arr['locations'] as $location) { + + $network = isset($location['driver']) ? $location['driver'] : 'zot6'; + // only set nomad if the location info is coming from the same site as the original zotinfo packet + if (isset($sender['site']) && isset($sender['site']['url']) && $sender['site']['url'] === $location['url']) { + if (isset($sender['site']['protocol_version']) && intval($sender['site']['protocol_version']) > 10) { + $network = 'nomad'; + } + } + + if (!Libzot::verify($location['url'], $location['url_sig'], $sender['public_key'])) { + logger('Unable to verify site signature for ' . $location['url']); + $ret['message'] .= sprintf(t('Unable to verify site signature for %s'), $location['url']) . EOL; + continue; + } + + for ($x = 0; $x < count($xisting); $x++) { + if ( + ($xisting[$x]['hubloc_url'] === $location['url']) + && ($xisting[$x]['hubloc_sitekey'] === $location['sitekey']) + ) { + $xisting[$x]['updated'] = true; + } + } + + if (!$location['sitekey']) { + logger('Empty hubloc sitekey. ' . print_r($location, true)); + continue; + } + + // match as many fields as possible in case anything at all changed. + + $r = q( + "select * from hubloc where hubloc_hash = '%s' and hubloc_guid = '%s' and hubloc_guid_sig = '%s' and hubloc_id_url = '%s' and hubloc_url = '%s' and hubloc_url_sig = '%s' and hubloc_host = '%s' and hubloc_addr = '%s' and hubloc_callback = '%s' and hubloc_sitekey = '%s' ", + dbesc($sender['hash']), + dbesc($sender['id']), + dbesc($sender['id_sig']), + dbesc($location['id_url']), + dbesc($location['url']), + dbesc($location['url_sig']), + dbesc($location['host']), + dbesc($location['address']), + dbesc($location['callback']), + dbesc($location['sitekey']) + ); + if ($r) { + logger('Hub exists: ' . $location['url'], LOGGER_DEBUG); + + // generate a new hubloc_site_id if it's wrong due to historical bugs 2021-11-30 + + if ($r[0]['hubloc_site_id'] !== $location['site_id']) { + q( + "update hubloc set hubloc_site_id = '%s' where hubloc_id = %d", + dbesc(Libzot::make_xchan_hash($location['url'], $location['sitekey'])), + intval($r[0]['hubloc_id']) + ); + } + + // update connection timestamp if this is the site we're talking to + // This only happens when called from import_xchan + + $current_site = false; + + $t = datetime_convert('UTC', 'UTC', 'now - 15 minutes'); + + if (isset($location['driver']) && $location['driver'] === 'nomad' && $location['driver'] !== $r[0]['hubloc_network']) { + q("update hubloc set hubloc_network = '%s' where hubloc_id = %d", + dbesc($location['driver']), + intval($r[0]['hubloc_id']) + ); + } + + if (array_key_exists('site', $arr) && $location['url'] == $arr['site']['url']) { + q( + "update hubloc set hubloc_connected = '%s', hubloc_updated = '%s' where hubloc_id = %d and hubloc_updated < '%s'", + dbesc(datetime_convert()), + dbesc(datetime_convert()), + intval($r[0]['hubloc_id']), + dbesc($t) + ); + $current_site = true; + } + + if ($current_site && (intval($r[0]['hubloc_error']) || intval($r[0]['hubloc_deleted']))) { + q( + "update hubloc set hubloc_error = 0, hubloc_deleted = 0 where hubloc_id = %d", + intval($r[0]['hubloc_id']) + ); + if (intval($r[0]['hubloc_orphancheck'])) { + q( + "update hubloc set hubloc_orphancheck = 0 where hubloc_id = %d", + intval($r[0]['hubloc_id']) + ); + } + q( + "update xchan set xchan_orphan = 0 where xchan_orphan = 1 and xchan_hash = '%s'", + dbesc($sender['hash']) + ); + } + + // Remove pure duplicates + if (count($r) > 1) { + for ($h = 1; $h < count($r); $h++) { + q( + "delete from hubloc where hubloc_id = %d", + intval($r[$h]['hubloc_id']) + ); + $what .= 'duplicate_hubloc_removed '; + $changed = true; + } + } + + if (intval($r[0]['hubloc_primary']) && (!$location['primary'])) { + $m = q( + "update hubloc set hubloc_primary = 0, hubloc_updated = '%s' where hubloc_id = %d", + dbesc(datetime_convert()), + intval($r[0]['hubloc_id']) + ); + $r[0]['hubloc_primary'] = intval($location['primary']); + hubloc_change_primary($r[0]); + $what .= 'primary_hub '; + $changed = true; + } elseif ((!intval($r[0]['hubloc_primary'])) && ($location['primary'])) { + $m = q( + "update hubloc set hubloc_primary = 1, hubloc_updated = '%s' where hubloc_id = %d", + dbesc(datetime_convert()), + intval($r[0]['hubloc_id']) + ); + // make sure hubloc_change_primary() has current data + $r[0]['hubloc_primary'] = intval($location['primary']); + hubloc_change_primary($r[0]); + $what .= 'primary_hub '; + $changed = true; + } elseif ($absolute) { + // Absolute sync - make sure the current primary is correctly reflected in the xchan + $pr = hubloc_change_primary($r[0]); + if ($pr) { + $what .= 'xchan_primary '; + $changed = true; + } + } elseif (intval($r[0]['hubloc_primary']) && $xchan && $xchan['xchan_url'] !== $r[0]['hubloc_id_url']) { + $pr = hubloc_change_primary($r[0]); + if ($pr) { + $what .= 'xchan_primary '; + $changed = true; + } + } + + if (intval($r[0]['hubloc_deleted']) && (!intval($location['deleted']))) { + $n = q( + "update hubloc set hubloc_deleted = 0, hubloc_updated = '%s' where hubloc_id = %d", + dbesc(datetime_convert()), + intval($r[0]['hubloc_id']) + ); + $what .= 'undelete_hub '; + $changed = true; + } elseif ((!intval($r[0]['hubloc_deleted'])) && (intval($location['deleted']))) { + logger('deleting hubloc: ' . $r[0]['hubloc_addr']); + $n = q( + "update hubloc set hubloc_deleted = 1, hubloc_updated = '%s' where hubloc_id = %d", + dbesc(datetime_convert()), + intval($r[0]['hubloc_id']) + ); + $what .= 'delete_hub '; + $changed = true; + } + continue; + } + + // Existing hubs are dealt with. Now let's process any new ones. + // New hub claiming to be primary. Make it so by removing any existing primaries. + + if (intval($location['primary'])) { + $r = q( + "update hubloc set hubloc_primary = 0, hubloc_updated = '%s' where hubloc_hash = '%s' and hubloc_primary = 1", + dbesc(datetime_convert()), + dbesc($sender['hash']) + ); + } + + logger('New hub: ' . $location['url']); + + $r = hubloc_store_lowlevel( + [ + 'hubloc_guid' => $sender['id'], + 'hubloc_guid_sig' => $sender['id_sig'], + 'hubloc_id_url' => $location['id_url'], + 'hubloc_hash' => $sender['hash'], + 'hubloc_addr' => $location['address'], + 'hubloc_network' => $network, + 'hubloc_primary' => intval($location['primary']), + 'hubloc_url' => $location['url'], + 'hubloc_url_sig' => $location['url_sig'], + 'hubloc_site_id' => Libzot::make_xchan_hash($location['url'], $location['sitekey']), + 'hubloc_host' => $location['host'], + 'hubloc_callback' => $location['callback'], + 'hubloc_sitekey' => $location['sitekey'], + 'hubloc_updated' => datetime_convert(), + 'hubloc_connected' => datetime_convert() + ] + ); + + $what .= 'newhub '; + $changed = true; + + if ($location['primary']) { + $r = q( + "select * from hubloc where hubloc_addr = '%s' and hubloc_sitekey = '%s' limit 1", + dbesc($location['address']), + dbesc($location['sitekey']) + ); + if ($r) { + hubloc_change_primary($r[0]); + } + } + } + + // get rid of any hubs we have for this channel which weren't reported. + + if ($absolute && $xisting) { + foreach ($xisting as $x) { + if (!array_key_exists('updated', $x)) { + logger('Deleting unreferenced hub location ' . $x['hubloc_addr']); + $r = q( + "update hubloc set hubloc_deleted = 1, hubloc_updated = '%s' where hubloc_id = %d", + dbesc(datetime_convert()), + intval($x['hubloc_id']) + ); + $what .= 'removed_hub '; + $changed = true; + } + } + } + } else { + logger('No locations to sync!'); + } + + $ret['change_message'] = $what; + $ret['changed'] = $changed; + + return $ret; + } + + + public static function keychange($channel, $arr) + { + + // verify the keychange operation + if (!Libzot::verify($arr['channel']['channel_pubkey'], $arr['keychange']['new_sig'], $channel['channel_prvkey'])) { + logger('sync keychange: verification failed'); + return; + } + + $sig = Libzot::sign($channel['channel_guid'], $arr['channel']['channel_prvkey']); + $hash = Libzot::make_xchan_hash($channel['channel_guid'], $arr['channel']['channel_pubkey']); + + + $r = q( + "update channel set channel_prvkey = '%s', channel_pubkey = '%s', channel_guid_sig = '%s', + channel_hash = '%s' where channel_id = %d", + dbesc($arr['channel']['channel_prvkey']), + dbesc($arr['channel']['channel_pubkey']), + dbesc($sig), + dbesc($hash), + intval($channel['channel_id']) + ); + if (!$r) { + logger('keychange sync: channel update failed'); + return; + } + + $r = q( + "select * from channel where channel_id = %d", + intval($channel['channel_id']) + ); + + if (!$r) { + logger('keychange sync: channel retrieve failed'); + return; + } + + $channel = $r[0]; + + $h = q( + "select * from hubloc where hubloc_hash = '%s' and hubloc_url = '%s' ", + dbesc($arr['keychange']['old_hash']), + dbesc(z_root()) + ); + + if ($h) { + foreach ($h as $hv) { + $hv['hubloc_guid_sig'] = $sig; + $hv['hubloc_hash'] = $hash; + $hv['hubloc_url_sig'] = Libzot::sign(z_root(), $channel['channel_prvkey']); + hubloc_store_lowlevel($hv); + } + } + + $x = q( + "select * from xchan where xchan_hash = '%s' ", + dbesc($arr['keychange']['old_hash']) + ); + + $check = q( + "select * from xchan where xchan_hash = '%s'", + dbesc($hash) + ); + + if (($x) && (!$check)) { + $oldxchan = $x[0]; + foreach ($x as $xv) { + $xv['xchan_guid_sig'] = $sig; + $xv['xchan_hash'] = $hash; + $xv['xchan_pubkey'] = $channel['channel_pubkey']; + $xv['xchan_updated'] = datetime_convert(); + xchan_store_lowlevel($xv); + $newxchan = $xv; + } + } + + $a = q( + "select * from abook where abook_xchan = '%s' and abook_self = 1", + dbesc($arr['keychange']['old_hash']) + ); + + if ($a) { + q( + "update abook set abook_xchan = '%s' where abook_id = %d", + dbesc($hash), + intval($a[0]['abook_id']) + ); + } + + xchan_change_key($oldxchan, $newxchan, $arr['keychange']); + } +} diff --git a/Code/Lib/Libzot.php b/Code/Lib/Libzot.php new file mode 100644 index 000000000..c6e17d431 --- /dev/null +++ b/Code/Lib/Libzot.php @@ -0,0 +1,3645 @@ + $type, + 'encoding' => $encoding, + 'sender' => $channel['channel_hash'], + 'site_id' => self::make_xchan_hash(z_root(), get_config('system', 'pubkey')), + 'version' => System::get_zot_revision(), + ]; + + if ($recipients) { + $data['recipients'] = $recipients; + } + + if ($msg) { + $actor = Channel::url($channel); + if ($encoding === 'activitystreams' && array_key_exists('actor', $msg) && is_string($msg['actor']) && $actor === $msg['actor']) { + $msg = JSalmon::sign($msg, $actor, $channel['channel_prvkey']); + } + $data['data'] = $msg; + } else { + unset($data['encoding']); + } + + logger('packet: ' . print_r($data, true), LOGGER_DATA, LOG_DEBUG); + + if ($remote_key) { + $algorithm = self::best_algorithm($methods); + if ($algorithm) { + $data = Crypto::encapsulate(json_encode($data), $remote_key, $algorithm); + } + } + + return json_encode($data); + } + + + /** + * @brief Choose best encryption function from those available on both sites. + * + * @param string $methods + * comma separated list of encryption methods + * @return string first match from our site method preferences crypto_methods() array + * of a method which is common to both sites; or an empty string if no matches are found. + * + * Failure to find a common algorithm is not an issue as our communications + * take place primarily over https, so this is just redundant encryption in many cases. + * + * In any case, the receiver is free to reject unencrypted private content if they have + * reason to distrust https. + * + * We are not using array_intersect() here because the specification for that function + * does not guarantee the order of results. It probably returns entries in the correct + * order for our needs and would simplify this function dramatically, but we cannot be + * certain that it will always do so on all operating systems. + * + */ + + public static function best_algorithm($methods) + { + + $x = [ + 'methods' => $methods, + 'result' => '' + ]; + + /** + * @hooks zot_best_algorithm + * Called when negotiating crypto algorithms with remote sites. + * * \e string \b methods - comma separated list of encryption methods + * * \e string \b result - the algorithm to return + */ + + Hook::call('zot_best_algorithm', $x); + + if ($x['result']) { + return $x['result']; + } + + if ($methods) { + // $x = their methods as an array + $x = explode(',', $methods); + if ($x) { + // $y = our methods as an array + $y = Crypto::methods(); + if ($y) { + foreach ($y as $yv) { + $yv = trim($yv); + if (in_array($yv, $x)) { + return ($yv); + } + } + } + } + } + + return EMPTY_STR; + } + + + /** + * @brief send a zot message + * + * @param string $url + * @param array $data + * @param array $channel (required if using zot6 delivery) + * @param array $crypto (required if encrypted httpsig, requires hubloc_sitekey and site_crypto elements) + * @return array see z_post_url() for returned data format + * @see z_post_url() + * + */ + + public static function zot($url, $data, $channel = null, $crypto = null) + { + + if ($channel) { + $headers = [ + 'X-Zot-Token' => random_string(), + 'Digest' => HTTPSig::generate_digest_header($data), + 'Content-type' => 'application/x-zot+json', + '(request-target)' => 'post ' . get_request_string($url) + ]; + + $h = HTTPSig::create_sig( + $headers, + $channel['channel_prvkey'], + Channel::url($channel), + false, + 'sha512', + (($crypto) ? ['key' => $crypto['hubloc_sitekey'], 'algorithm' => self::best_algorithm($crypto['site_crypto'])] : false) + ); + } else { + $h = []; + } + + $redirects = 0; + + return z_post_url($url, $data, $redirects, ((empty($h)) ? [] : ['headers' => $h])); + } + + public static function nomad($url, $data, $channel = null,$crypto = null) { + + if ($channel) { + $headers = [ + 'X-Nomad-Token' => random_string(), + 'Digest' => HTTPSig::generate_digest_header($data), + 'Content-type' => 'application/x-nomad+json', + '(request-target)' => 'post ' . get_request_string($url) + ]; + + $h = HTTPSig::create_sig($headers,$channel['channel_prvkey'],Channel::url($channel),false,'sha512', + (($crypto) ? [ 'key' => $crypto['hubloc_sitekey'], 'algorithm' => self::best_algorithm($crypto['site_crypto']) ] : false)); + } + else { + $h = []; + } + + $redirects = 0; + + return z_post_url($url,$data,$redirects,((empty($h)) ? [] : [ 'headers' => $h ])); + } + + + + + /** + * @brief Refreshes after permission changed or friending, etc. + * + * + * refresh is typically invoked when somebody has changed permissions of a channel and they are notified + * to fetch new permissions via a finger/discovery operation. This may result in a new connection + * (abook entry) being added to a local channel and it may result in auto-permissions being granted. + * + * Friending in zot is accomplished by sending a refresh packet to a specific channel which indicates a + * permission change has been made by the sender which affects the target channel. The hub controlling + * the target channel does targeted discovery (in which a zot discovery request contains permissions for + * the sender which were set by the local channel). These are decoded here, and if necessary an abook + * structure (addressbook) is created to store the permissions assigned to this channel. + * + * Initially these abook structures are created with a 'pending' flag, so that no reverse permissions are + * implied until this is approved by the owner channel. A channel can also auto-populate permissions in + * return and send back a refresh packet of its own. This is used by forum and group communication channels + * so that friending and membership in the channel's "club" is automatic. + * + * If $force is set when calling this function, this operation will be attempted even if our records indicate + * the remote site is permanently down. + * + * @param array $them => xchan structure of sender + * @param array $channel => local channel structure of target recipient, required for "friending" operations + * @param array $force (optional) default false + * + * @return boolean + * * \b true if successful + * * otherwise \b false + */ + + public static function refresh($them, $channel = null, $force = false) + { + + $hsig_valid = false; + + logger('them: ' . print_r($them, true), LOGGER_DATA, LOG_DEBUG); + if ($channel) { + logger('channel: ' . print_r($channel, true), LOGGER_DATA, LOG_DEBUG); + } + + $url = null; + + if ($them['hubloc_id_url']) { + $url = $them['hubloc_id_url']; + } else { + $r = null; + + // if they re-installed the server we could end up with the wrong record - pointing to a hash generated by the old install. + // We'll order by reverse id to try and pick off the most recently created ones first and hopefully end up with the correct hubloc. + // We are looking for the most recently created primary hub, and the most recently created if for some reason we do not have a primary. + // hubloc_id_url is set to the channel home, which corresponds to an ActivityStreams actor id. + + $r = q( + "select hubloc_id_url, hubloc_primary from hubloc where hubloc_hash = '%s' and hubloc_network in ('zot6','nomad') order by hubloc_id desc", + dbesc($them['xchan_hash']) + ); + + if ($r) { + foreach ($r as $rr) { + if (intval($rr['hubloc_primary'])) { + $url = $rr['hubloc_id_url']; + break; + } + } + if (!$url) { + $url = $r[0]['hubloc_id_url']; + } + } + } + + if (!$url) { + logger('zot_refresh: no url'); + return false; + } + + $m = parse_url($url); + $site_url = unparse_url(['scheme' => $m['scheme'], 'host' => $m['host']]); + + + $s = q( + "select site_dead from site where site_url = '%s' limit 1", + dbesc($site_url) + ); + + if ($s && intval($s[0]['site_dead']) && (!$force)) { + logger('zot_refresh: site ' . $site_url . ' is marked dead and force flag is not set. Cancelling operation.'); + return false; + } + + $record = Zotfinger::exec($url, $channel); + + // Check the HTTP signature + + $hsig = $record['signature']; + if ($hsig && $hsig['signer'] === $url && $hsig['header_valid'] === true && $hsig['content_valid'] === true) { + $hsig_valid = true; + } + + if (!$hsig_valid) { + logger('http signature not valid: ' . (($record['data']) ? print_r($hsig, true) : 'fetch failed')); + return false; + } + + // If we reach this point, the signature is valid and we can trust the channel discovery data, so try and store + // the generic information in the returned discovery packet. + + logger('zot-info: ' . print_r($record, true), LOGGER_DATA, LOG_DEBUG); + + $x = self::import_xchan($record['data'], (($force) ? UPDATE_FLAGS_FORCED : UPDATE_FLAGS_UPDATED)); + + if (!$x['success']) { + return false; + } + + // Here we handle discovery packets that return targeted permissions and require an abook (either an existing connection + // or a new one) + + if ($channel && $record['data']['permissions']) { + $old_read_stream_perm = their_perms_contains($channel['channel_id'], $x['hash'], 'view_stream'); + set_abconfig($channel['channel_id'], $x['hash'], 'system', 'their_perms', $record['data']['permissions']); + + if (array_key_exists('profile', $record['data']) && array_key_exists('next_birthday', $record['data']['profile'])) { + $next_birthday = datetime_convert('UTC', 'UTC', $record['data']['profile']['next_birthday']); + } else { + $next_birthday = NULL_DATE; + } + + $profile_assign = get_pconfig($channel['channel_id'], 'system', 'profile_assign', ''); + + // Keep original perms to check if we need to notify them + $previous_perms = get_all_perms($channel['channel_id'], $x['hash']); + + $r = q( + "select * from abook where abook_xchan = '%s' and abook_channel = %d and abook_self = 0 limit 1", + dbesc($x['hash']), + intval($channel['channel_id']) + ); + + if ($r) { + // connection exists + + // if the dob is the same as what we have stored (disregarding the year), keep the one + // we have as we may have updated the year after sending a notification; and resetting + // to the one we just received would cause us to create duplicated events. + + if (substr($r[0]['abook_dob'], 5) == substr($next_birthday, 5)) { + $next_birthday = $r[0]['abook_dob']; + } + + $y = q( + "update abook set abook_dob = '%s' + where abook_xchan = '%s' and abook_channel = %d + and abook_self = 0 ", + dbescdate($next_birthday), + dbesc($x['hash']), + intval($channel['channel_id']) + ); + + if (!$y) { + logger('abook update failed'); + } else { + // if we were just granted read stream permission and didn't have it before, try to pull in some posts + if ((!$old_read_stream_perm) && (intval($permissions['view_stream']))) { + Run::Summon(['Onepoll', $r[0]['abook_id']]); + } + } + } else { + // limit the ability to do connection spamming, this limit is per channel + $lim = intval(get_config('system', 'max_connections_per_day', 50)); + if ($lim) { + $n = q( + "select count(abook_id) as total from abook where abook_channel = %d and abook_created > '%s'", + intval($channel['channel_id']), + dbesc(datetime_convert('UTC', 'UTC', 'now - 24 hours')) + ); + if ($n && intval($n['total']) > $lim) { + logger('channel: ' . $channel['channel_id'] . ' too many new connections per day. This one from ' . $hsig['signer'], LOGGER_NORMAL, LOG_WARNING); + return false; + } + } + + // check personal blocklists + + $blocked = LibBlock::fetch($channel['channel_id'], BLOCKTYPE_SERVER); + if ($blocked) { + foreach ($blocked as $b) { + if (strpos($url, $b['block_entity']) !== false) { + logger('siteblock - follower denied'); + return; + } + } + } + if (LibBlock::fetch_by_entity($channel['channel_id'], $x['hash'])) { + logger('actorblock - follower denied'); + return; + } + + $p = Permissions::connect_perms($channel['channel_id']); + $my_perms = Permissions::serialise($p['perms']); + + $automatic = $p['automatic']; + + // new connection + + if ($my_perms) { + set_abconfig($channel['channel_id'], $x['hash'], 'system', 'my_perms', $my_perms); + } + + $closeness = get_pconfig($channel['channel_id'], 'system', 'new_abook_closeness', 80); + + // check if it is a sub-channel (collection) and auto-friend if it is + + $is_collection = false; + + $cl = q( + "select channel_id from channel where channel_hash = '%s' and channel_parent = '%s' and channel_account_id = %d limit 1", + dbesc($x['hash']), + dbesc($channel['channel_hash']), + intval($channel['channel_account_id']) + ); + if ($cl) { + $is_collection = true; + $automatic = true; + $closeness = 10; + } + + $y = abook_store_lowlevel( + [ + 'abook_account' => intval($channel['channel_account_id']), + 'abook_channel' => intval($channel['channel_id']), + 'abook_closeness' => intval($closeness), + 'abook_xchan' => $x['hash'], + 'abook_profile' => $profile_assign, + 'abook_created' => datetime_convert(), + 'abook_updated' => datetime_convert(), + 'abook_dob' => $next_birthday, + 'abook_pending' => intval(($automatic) ? 0 : 1) + ] + ); + + if ($y) { + logger("New introduction received for {$channel['channel_name']}"); + $new_perms = get_all_perms($channel['channel_id'], $x['hash']); + + // Send a clone sync packet and a permissions update if permissions have changed + + $new_connection = q( + "select * from abook left join xchan on abook_xchan = xchan_hash where abook_xchan = '%s' and abook_channel = %d and abook_self = 0 order by abook_created desc limit 1", + dbesc($x['hash']), + intval($channel['channel_id']) + ); + + if ($new_connection) { + if (!Permissions::PermsCompare($new_perms, $previous_perms)) { + Run::Summon(['Notifier', 'permissions_create', $new_connection[0]['abook_id']]); + } + + if (!$is_collection) { + Enotify::submit( + [ + 'type' => NOTIFY_INTRO, + 'from_xchan' => $x['hash'], + 'to_xchan' => $channel['channel_hash'], + 'link' => z_root() . '/connedit/' . $new_connection[0]['abook_id'] + ] + ); + } + + if (intval($permissions['view_stream'])) { + if ( + intval(get_pconfig($channel['channel_id'], 'perm_limits', 'send_stream') & PERMS_PENDING) + || (!intval($new_connection[0]['abook_pending'])) + ) { + Run::Summon(['Onepoll', $new_connection[0]['abook_id']]); + } + } + + + // If there is a default group for this channel, add this connection to it + // for pending connections this will happens at acceptance time. + + if (!intval($new_connection[0]['abook_pending'])) { + $default_group = $channel['channel_default_group']; + if ($default_group) { + $g = AccessList::rec_byhash($channel['channel_id'], $default_group); + if ($g) { + AccessList::member_add($channel['channel_id'], '', $x['hash'], $g['id']); + } + } + } + + unset($new_connection[0]['abook_id']); + unset($new_connection[0]['abook_account']); + unset($new_connection[0]['abook_channel']); + $abconfig = load_abconfig($channel['channel_id'], $new_connection[0]['abook_xchan']); + if ($abconfig) { + $new_connection[0]['abconfig'] = $abconfig; + } + Libsync::build_sync_packet($channel['channel_id'], ['abook' => $new_connection]); + } + } + } + return true; + } else { + return true; + } + return false; + } + + /** + * @brief Look up if channel is known and previously verified. + * + * A guid and a url, both signed by the sender, distinguish a known sender at a + * known location. + * This function looks these up to see if the channel is known and therefore + * previously verified. If not, we will need to verify it. + * + * @param array $arr an associative array which must contain: + * * \e string \b id => id of conversant + * * \e string \b id_sig => id signed with conversant's private key + * * \e string \b location => URL of the origination hub of this communication + * * \e string \b location_sig => URL signed with conversant's private key + * @param bool $multiple (optional) default false + * + * @return array|null + * * null if site is denied or not found + * * otherwise an array with an hubloc record + */ + + public static function gethub($arr, $multiple = false) + { + + if ($arr['id'] && $arr['id_sig'] && $arr['location'] && $arr['location_sig']) { + if (!check_siteallowed($arr['location'])) { + logger('denied site: ' . $arr['location']); + return null; + } + + $limit = (($multiple) ? '' : ' limit 1 '); + + $r = q( + "select hubloc.*, site.site_crypto from hubloc left join site on hubloc_url = site_url + where hubloc_guid = '%s' and hubloc_guid_sig = '%s' + and hubloc_url = '%s' and hubloc_url_sig = '%s' + and hubloc_site_id = '%s' and hubloc_network in ('nomad','zot6') + $limit", + dbesc($arr['id']), + dbesc($arr['id_sig']), + dbesc($arr['location']), + dbesc($arr['location_sig']), + dbesc($arr['site_id']) + ); + if ($r) { + logger('Found', LOGGER_DEBUG); + return (($multiple) ? $r : $r[0]); + } + } + logger('Not found: ' . print_r($arr, true), LOGGER_DEBUG); + + return false; + } + + + public static function valid_hub($sender, $site_id) + { + + $r = q( + "select hubloc.*, site.site_crypto from hubloc left join site on hubloc_url = site_url where hubloc_hash = '%s' and hubloc_site_id = '%s' limit 1", + dbesc($sender), + dbesc($site_id) + ); + if (!$r) { + return null; + } + + if (!check_siteallowed($r[0]['hubloc_url'])) { + logger('denied site: ' . $r[0]['hubloc_url']); + return null; + } + + if (!check_channelallowed($r[0]['hubloc_hash'])) { + logger('denied channel: ' . $r[0]['hubloc_hash']); + return null; + } + + return $r[0]; + } + + /** + * @brief Registers an unknown hub. + * + * A communication has been received which has an unknown (to us) sender. + * Perform discovery based on our calculated hash of the sender at the + * origination address. This will fetch the discovery packet of the sender, + * which contains the public key we need to verify our guid and url signatures. + * + * @param array $arr an associative array which must contain: + * * \e string \b guid => guid of conversant + * * \e string \b guid_sig => guid signed with conversant's private key + * * \e string \b url => URL of the origination hub of this communication + * * \e string \b url_sig => URL signed with conversant's private key + * + * @return array An associative array with + * * \b success boolean true or false + * * \b message (optional) error string only if success is false + */ + + public static function register_hub($id) + { + + $id_hash = false; + $valid = false; + $hsig_valid = false; + + $result = ['success' => false]; + + if (!$id) { + return $result; + } + + $record = Zotfinger::exec($id); + + // Check the HTTP signature + + $hsig = $record['signature']; + if ($hsig['signer'] === $id && $hsig['header_valid'] === true && $hsig['content_valid'] === true) { + $hsig_valid = true; + } + if (!$hsig_valid) { + logger('http signature not valid: ' . print_r($hsig, true)); + return $result; + } + + $c = self::import_xchan($record['data']); + if ($c['success']) { + $result['success'] = true; + } else { + logger('Failure to verify zot packet'); + } + + return $result; + } + + /** + * @brief Takes an associative array of a fetch discovery packet and updates + * all internal data structures which need to be updated as a result. + * + * @param array $arr => json_decoded discovery packet + * @param int $ud_flags + * Determines whether to create a directory update record if any changes occur, default is UPDATE_FLAGS_UPDATED + * $ud_flags = UPDATE_FLAGS_FORCED indicates a forced refresh where we unconditionally create a directory update record + * this typically occurs once a month for each channel as part of a scheduled ping to notify the directory + * that the channel still exists + * @param array $ud_arr + * If set [typically by update_directory_entry()] indicates a specific update table row and more particularly + * contains a particular address (ud_addr) which needs to be updated in that table. + * + * @return array An associative array with: + * * \e boolean \b success boolean true or false + * * \e string \b message (optional) error string only if success is false + */ + + public static function import_xchan($arr, $ud_flags = UPDATE_FLAGS_UPDATED, $ud_arr = null) + { + + /** + * @hooks import_xchan + * Called when processing the result of zot_finger() to store the result + * * \e array + */ + Hook::call('import_xchan', $arr); + + $ret = array('success' => false); + $dirmode = intval(get_config('system', 'directory_mode')); + + $changed = false; + $what = ''; + + if (!is_array($arr)) { + logger('Not an array: ' . print_r($arr, true), LOGGER_DEBUG); + return $ret; + } + + if (!($arr['id'] && $arr['id_sig'])) { + logger('No identity information provided. ' . print_r($arr, true)); + return $ret; + } + + $xchan_hash = self::make_xchan_hash($arr['id'], $arr['public_key']); + $arr['hash'] = $xchan_hash; + + $import_photos = false; + + $sig_methods = ((array_key_exists('signing', $arr) && is_array($arr['signing'])) ? $arr['signing'] : ['sha256']); + $verified = false; + + if (!self::verify($arr['id'], $arr['id_sig'], $arr['public_key'])) { + logger('Unable to verify channel signature for ' . $arr['address']); + return $ret; + } else { + $verified = true; + } + + if (!$verified) { + $ret['message'] = t('Unable to verify channel signature'); + return $ret; + } + + logger('import_xchan: ' . $xchan_hash, LOGGER_DEBUG); + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($xchan_hash) + ); + + if (!array_key_exists('connect_url', $arr)) { + $arr['connect_url'] = ''; + } + + if ($r) { + if ($arr['photo'] && array_key_exists('updated', $arr['photo']) && $arr['photo']['updated'] > $r[0]['xchan_photo_date']) { + $import_photos = true; + } + + // if we import an entry from a site that's not ours and either or both of us is off the grid - hide the entry. + /** @TODO: check if we're the same directory realm, which would mean we are allowed to see it */ + + $dirmode = get_config('system', 'directory_mode'); + + if ((($arr['site']['directory_mode'] === 'standalone') || ($dirmode & DIRECTORY_MODE_STANDALONE)) && ($arr['site']['url'] != z_root())) { + $arr['searchable'] = false; + } + + $hidden = (1 - intval($arr['searchable'])); + + $hidden_changed = $adult_changed = $deleted_changed = $type_changed = 0; + + if (intval($r[0]['xchan_hidden']) != (1 - intval($arr['searchable']))) { + $hidden_changed = 1; + } + if (intval($r[0]['xchan_selfcensored']) != intval($arr['adult_content'])) { + $adult_changed = 1; + } + if (isset($arr['deleted']) && intval($r[0]['xchan_deleted']) != intval($arr['deleted'])) { + $deleted_changed = 1; + } + + $px = 0; + if (isset($arr['channel_type'])) { + if ($arr['channel_type'] === 'collection') { + $px = 2; + } elseif ($arr['channel_type'] === 'group') { + $px = 1; + } + } + if (array_key_exists('public_forum', $arr) && intval($arr['public_forum'])) { + $px = 1; + } + + if (intval($r[0]['xchan_type']) !== $px) { + $type_changed = true; + } + + if ($arr['protocols']) { + set_xconfig($xchan_hash, 'system', 'protocols', implode(',',$arr['protocols'])); + } + $collections = []; + if (isset($arr['primary_location']['following'])) { + $collections['following'] = $arr['primary_location']['following']; + } + if (isset($arr['primary_location']['followers'])) { + $collections['followers'] = $arr['primary_location']['followers']; + } + if (isset($arr['primary_location']['wall'])) { + $collections['wall'] = $arr['primary_location']['wall']; + } + if ($collections) { + set_xconfig($xchan_hash, 'activitypub', 'collections', $collections); + } + + if (isset($arr['cover_photo']) && isset($arr['cover_photo']['url']) && strlen($arr['cover_photo']['url'])) { + set_xconfig($xchan_hash, 'system', 'cover_photo', $arr['cover_photo']['url']); + } + + if (isset($arr['signing_algorithm']) && strlen($arr['signing_algorithm'])) { + set_xconfig($xchan_hash, 'system', 'signing_algorithm', $arr['signing_algorithm']); + } + + + if ( + ($r[0]['xchan_name_date'] != $arr['name_updated']) + || ($r[0]['xchan_connurl'] != $arr['primary_location']['connections_url']) + || ($r[0]['xchan_addr'] != $arr['primary_location']['address']) + || ($r[0]['xchan_follow'] != $arr['primary_location']['follow_url']) + || ($r[0]['xchan_connpage'] != $arr['connect_url']) + || ($r[0]['xchan_url'] != $arr['primary_location']['url']) + || ($r[0]['xchan_updated'] < datetime_convert('UTC', 'UTC', 'now - 7 days')) + || $hidden_changed || $adult_changed || $deleted_changed || $type_changed + ) { + $rup = q( + "update xchan set xchan_updated = '%s', xchan_name = '%s', xchan_name_date = '%s', xchan_connurl = '%s', xchan_follow = '%s', + xchan_connpage = '%s', xchan_hidden = %d, xchan_selfcensored = %d, xchan_deleted = %d, xchan_type = %d, + xchan_addr = '%s', xchan_url = '%s' where xchan_hash = '%s'", + dbesc(datetime_convert()), + dbesc(($arr['name']) ? escape_tags($arr['name']) : '-'), + dbesc($arr['name_updated']), + dbesc($arr['primary_location']['connections_url']), + dbesc($arr['primary_location']['follow_url']), + dbesc($arr['primary_location']['connect_url']), + intval(1 - intval($arr['searchable'])), + intval($arr['adult_content']), + intval($arr['deleted']), + intval($px), + dbesc(escape_tags($arr['primary_location']['address'])), + dbesc(escape_tags($arr['primary_location']['url'])), + dbesc($xchan_hash) + ); + + logger('Update: existing: ' . print_r($r[0], true), LOGGER_DATA, LOG_DEBUG); + logger('Update: new: ' . print_r($arr, true), LOGGER_DATA, LOG_DEBUG); + $what .= 'xchan '; + $changed = true; + } + } else { + $import_photos = true; + + if ( + (($arr['site']['directory_mode'] === 'standalone') + || ($dirmode & DIRECTORY_MODE_STANDALONE)) + && ($arr['site']['url'] != z_root()) + ) { + $arr['searchable'] = false; + } + + + if ($arr['channel_type'] === 'collection') { + $px = 2; + } elseif ($arr['channel_type'] === 'group') { + $px = 1; + } else { + $px = 0; + } + + if (array_key_exists('public_forum', $arr) && intval($arr['public_forum'])) { + $px = 1; + } + + $network = isset($arr['site']['protocol_version']) && intval($arr['site']['protocol_version']) > 10 ? 'nomad' : 'zot6'; + + + $x = xchan_store_lowlevel( + [ + 'xchan_hash' => $xchan_hash, + 'xchan_guid' => $arr['id'], + 'xchan_guid_sig' => $arr['id_sig'], + 'xchan_pubkey' => $arr['public_key'], + 'xchan_photo_mimetype' => $arr['photo']['type'], + 'xchan_photo_l' => $arr['photo']['url'], + 'xchan_addr' => escape_tags($arr['primary_location']['address']), + 'xchan_url' => escape_tags($arr['primary_location']['url']), + 'xchan_connurl' => $arr['primary_location']['connections_url'], + 'xchan_follow' => $arr['primary_location']['follow_url'], + 'xchan_connpage' => $arr['connect_url'], + 'xchan_name' => (($arr['name']) ? escape_tags($arr['name']) : '-'), + 'xchan_network' => $network, + 'xchan_updated' => datetime_convert(), + 'xchan_photo_date' => $arr['photo']['updated'], + 'xchan_name_date' => $arr['name_updated'], + 'xchan_hidden' => intval(1 - intval($arr['searchable'])), + 'xchan_selfcensored' => $arr['adult_content'], + 'xchan_deleted' => $arr['deleted'], + 'xchan_type' => $px + ] + ); + + $what .= 'new_xchan'; + $changed = true; + } + + if (isset($arr['cover_photo']) && isset($arr['cover_photo']['url']) && strlen($arr['cover_photo']['url'])) { + set_xconfig($xchan_hash, 'system', 'cover_photo', $arr['cover_photo']['url']); + } + + if ($import_photos) { + require_once('include/photo_factory.php'); + + // see if this is a channel clone that's hosted locally - which we treat different from other xchans/connections + + $local = q( + "select channel_account_id, channel_id from channel where channel_hash = '%s' limit 1", + dbesc($xchan_hash) + ); + if ($local) { + $ph = false; + if (strpos($arr['photo']['url'], z_root()) === false) { + $ph = z_fetch_url($arr['photo']['url'], true); + } + if ($ph && $ph['success']) { + $hash = import_channel_photo($ph['body'], $arr['photo']['type'], $local[0]['channel_account_id'], $local[0]['channel_id']); + + if ($hash) { + // unless proven otherwise + $is_default_profile = 1; + + $profile = q( + "select is_default from profile where aid = %d and uid = %d limit 1", + intval($local[0]['channel_account_id']), + intval($local[0]['channel_id']) + ); + if ($profile) { + if (!intval($profile[0]['is_default'])) { + $is_default_profile = 0; + } + } + + // If setting for the default profile, unset the profile photo flag from any other photos I own + if ($is_default_profile) { + q( + "UPDATE photo SET photo_usage = %d WHERE photo_usage = %d AND resource_id != '%s' AND aid = %d AND uid = %d", + intval(PHOTO_NORMAL), + intval(PHOTO_PROFILE), + dbesc($hash), + intval($local[0]['channel_account_id']), + intval($local[0]['channel_id']) + ); + } + } + + // reset the names in case they got messed up when we had a bug in this function + $photos = array( + z_root() . '/photo/profile/l/' . $local[0]['channel_id'], + z_root() . '/photo/profile/m/' . $local[0]['channel_id'], + z_root() . '/photo/profile/s/' . $local[0]['channel_id'], + $arr['photo_mimetype'], + false + ); + } + } else { + $photos = import_remote_xchan_photo($arr['photo']['url'], $xchan_hash); + } + if ($photos) { + if ($photos[4]) { + // importing the photo failed somehow. Leave the photo_date alone so we can try again at a later date. + // This often happens when somebody joins the matrix with a bad cert. + $r = q( + "update xchan set xchan_updated = '%s', xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s', xchan_photo_mimetype = '%s' + where xchan_hash = '%s'", + dbesc(datetime_convert()), + dbesc($photos[0]), + dbesc($photos[1]), + dbesc($photos[2]), + dbesc($photos[3]), + dbesc($xchan_hash) + ); + } else { + $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'", + dbesc(datetime_convert()), + dbesc(datetime_convert('UTC', 'UTC', ((isset($arr['photo_updated'])) ? $arr['photo_updated'] : 'now'))), + dbesc($photos[0]), + dbesc($photos[1]), + dbesc($photos[2]), + dbesc($photos[3]), + dbesc($xchan_hash) + ); + } + $what .= 'photo '; + $changed = true; + } + } + + // what we are missing for true hub independence is for any changes in the primary hub to + // get reflected not only in the hublocs, but also to update the URLs and addr in the appropriate xchan + + $s = Libsync::sync_locations($arr, $arr); + + if ($s) { + if (isset($s['change_message']) && $s['change_message']) { + $what .= $s['change_message']; + } + if (isset($s['changed']) && $s['changed']) { + $changed = $s['changed']; + } + if (isset($s['message']) && $s['message']) { + $ret['message'] .= $s['message']; + } + } + + // Which entries in the update table are we interested in updating? + $address = ((isset($arr['address']) && $arr['address']) ? $arr['address'] : EMPTY_STR); + if (isset($ud_arr) && isset($ud_arr['ud_addr'])) { + $address = $ud_arr['ud_addr']; + } + + // Are we a directory server of some kind? + + $other_realm = false; + +// $realm = get_directory_realm(); +// if (array_key_exists('site',$arr) +// && array_key_exists('realm',$arr['site']) +// && (strpos($arr['site']['realm'],$realm) === false)) +// $other_realm = true; + + +// if ($dirmode != DIRECTORY_MODE_NORMAL) { + + // We're some kind of directory server. However we can only add directory information + // if the entry is in the same realm (or is a sub-realm). Sub-realms are denoted by + // including the parent realm in the name. e.g. 'RED_GLOBAL:foo' would allow an entry to + // be in directories for the local realm (foo) and also the RED_GLOBAL realm. + + if (array_key_exists('profile', $arr) && is_array($arr['profile']) && (!$other_realm)) { + $profile_changed = Libzotdir::import_directory_profile($xchan_hash, $arr['profile'], $address, $ud_flags, 1); + if ($profile_changed) { + $what .= 'profile '; + $changed = true; + } + } else { + logger('Profile not available - hiding'); + // they may have made it private + $r = q( + "delete from xprof where xprof_hash = '%s'", + dbesc($xchan_hash) + ); + $r = q( + "delete from xtag where xtag_hash = '%s' and xtag_flags = 0", + dbesc($xchan_hash) + ); + } +// } + + if (array_key_exists('site', $arr) && is_array($arr['site'])) { + $profile_changed = self::import_site($arr['site']); + if ($profile_changed) { + $what .= 'site '; + $changed = true; + } + } + + if (($changed) || ($ud_flags == UPDATE_FLAGS_FORCED)) { + $guid = random_string() . '@' . App::get_hostname(); + Libzotdir::update_modtime($xchan_hash, $guid, $address, $ud_flags); + logger('Changed: ' . $what, LOGGER_DEBUG); + } elseif (!$ud_flags) { + // nothing changed but we still need to update the updates record + q( + "update updates set ud_flags = ( ud_flags | %d ) where ud_addr = '%s' and not (ud_flags & %d) > 0 ", + intval(UPDATE_FLAGS_UPDATED), + dbesc($address), + intval(UPDATE_FLAGS_UPDATED) + ); + } + + if (!x($ret, 'message')) { + $ret['success'] = true; + $ret['hash'] = $xchan_hash; + } + + logger('Result: ' . print_r($ret, true), LOGGER_DATA, LOG_DEBUG); + return $ret; + } + + /** + * @brief Called immediately after sending a zot message which is using queue processing. + * + * Updates the queue item according to the response result and logs any information + * returned to aid communications troubleshooting. + * + * @param string $hub - url of site we just contacted + * @param array $arr - output of z_post_url() + * @param array $outq - The queue structure attached to this request + */ + + public static function process_response($hub, $arr, $outq) + { + + logger('remote: ' . print_r($arr, true), LOGGER_DATA); + + if (!$arr['success']) { + logger('Failed: ' . $hub); + return; + } + + $x = json_decode($arr['body'], true); + + if (!$x) { + logger('No json from ' . $hub); + logger('Headers: ' . print_r($arr['header'], true), LOGGER_DATA, LOG_DEBUG); + } + + $x = Crypto::unencapsulate($x, get_config('system', 'prvkey')); + if (!is_array($x)) { + $x = json_decode($x, true); + } + + if (!is_array($x)) { + logger('no useful response: ' . $x); + } + + if ($x) { + if (!$x['success']) { + // handle remote validation issues + + $b = q( + "update dreport set dreport_result = '%s', dreport_time = '%s' where dreport_queue = '%s'", + dbesc(($x['message']) ? $x['message'] : 'unknown delivery error'), + dbesc(datetime_convert()), + dbesc($outq['outq_hash']) + ); + } + + if (is_array($x) && array_key_exists('delivery_report', $x) && is_array($x['delivery_report'])) { + foreach ($x['delivery_report'] as $xx) { + Hook::call('dreport_process', $xx); + if (is_array($xx) && array_key_exists('message_id', $xx) && DReport::is_storable($xx)) { + q( + "insert into dreport ( dreport_mid, dreport_site, dreport_recip, dreport_name, dreport_result, dreport_time, dreport_xchan, dreport_log ) values ( '%s', '%s', '%s','%s','%s','%s','%s','%s' ) ", + dbesc($xx['message_id']), + dbesc($xx['location']), + dbesc($xx['recipient']), + dbesc($xx['name']), + dbesc($xx['status']), + dbesc(datetime_convert('UTC', 'UTC', $xx['date'])), + dbesc($xx['sender']), + dbesc(EMPTY_STR) + ); + } + } + + // we have a more descriptive delivery report, so discard the per hub 'queue' report. + + q( + "delete from dreport where dreport_queue = '%s' ", + dbesc($outq['outq_hash']) + ); + } + } + // update the timestamp for this site + + q( + "update site set site_dead = 0, site_update = '%s' where site_url = '%s'", + dbesc(datetime_convert()), + dbesc(dirname($hub)) + ); + + // synchronous message types are handled immediately + // async messages remain in the queue until processed. + + if (intval($outq['outq_async'])) { + Queue::remove($outq['outq_hash'], $outq['outq_channel']); + } + + logger('zot_process_response: ' . print_r($x, true), LOGGER_DEBUG); + } + + /** + * @brief + * + * We received a notification packet (in mod_post) that a message is waiting for us, and we've verified the sender. + * Check if the site is using zot6 delivery and includes a verified HTTP Signature, signed content, and a 'msg' field, + * and also that the signer and the sender match. + * If that happens, we do not need to fetch/pickup the message - we have it already and it is verified. + * Translate it into the form we need for zot_import() and import it. + * + * Otherwise send back a pickup message, using our message tracking ID ($arr['secret']), which we will sign with our site + * private key. + * The entire pickup message is encrypted with the remote site's public key. + * If everything checks out on the remote end, we will receive back a packet containing one or more messages, + * which will be processed and delivered before this function ultimately returns. + * + * @param array $arr + * decrypted and json decoded notify packet from remote site + * @return array from zot_import() + * @see zot_import() + * + */ + + public static function fetch($arr, $hub = null) + { + + logger('zot_fetch: ' . print_r($arr, true), LOGGER_DATA, LOG_DEBUG); + + return self::import($arr, $hub); + } + + /** + * @brief Process incoming messages. + * + * Process incoming messages and import, update, delete as directed + * + * The message types handled here are 'activity' (e.g. posts), and 'sync'. + * + * @param array $arr + * 'pickup' structure returned from remote site + * @param string $sender_url + * the url specified by the sender in the initial communication. + * We will verify the sender and url in each returned message structure and + * also verify that all the messages returned match the site url that we are + * currently processing. + * + * @returns array + * Suitable for logging remotely, enumerating the processing results of each message/recipient combination + * * [0] => \e string $channel_hash + * * [1] => \e string $delivery_status + * * [2] => \e string $address + */ + + public static function import($arr, $hub = null) + { + + $env = $arr; + $private = false; + $return = []; + + $result = null; + + logger('Notify: ' . print_r($env, true), LOGGER_DATA, LOG_DEBUG); + + if (!is_array($env)) { + logger('decode error'); + return; + } + + $message_request = false; + + $has_data = array_key_exists('data', $env) && $env['data']; + $data = (($has_data) ? $env['data'] : false); + + $AS = null; + + if ($env['encoding'] === 'activitystreams') { + $AS = new ActivityStreams($data); + if ( + $AS->is_valid() && $AS->type === 'Announce' && is_array($AS->obj) + && array_key_exists('object', $AS->obj) && array_key_exists('actor', $AS->obj) + ) { + // This is a relayed/forwarded Activity (as opposed to a shared/boosted object) + // Reparse the encapsulated Activity and use that instead + logger('relayed activity', LOGGER_DEBUG); + $AS = new ActivityStreams($AS->obj); + } + + if (!$AS->is_valid()) { + logger('Activity rejected: ' . print_r($data, true)); + return; + } + + // compatibility issue with like of Hubzilla "new friend" activities which is very difficult to fix + + if ($AS->implied_create && is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStreams::is_an_actor($AS->obj['type'])) { + logger('create/person activity rejected. ' . print_r($data, true)); + return false; + } + + if (is_array($AS->obj)) { + $arr = Activity::decode_note($AS); + } else { + $arr = []; + } + + logger($AS->debug(), LOGGER_DATA); + } + + // There is nothing inherently wrong with getting a message-id which isn't a canonical URI/URL, but + // at the present time (2019/02) during the Hubzilla transition to zot6 it is likely to cause lots of duplicates for + // messages arriving from different protocols and sources with different message-id semantics. This + // restriction can be relaxed once most Hubzilla sites are upgraded to > 4.0. + // Don't check sync packets since they have a different encoding + + if ($arr && $env['type'] !== 'sync') { + if (strpos($arr['mid'], 'http') === false && strpos($arr['mid'], 'x-zot') === false) { + if (strpos($arr['mid'], 'bear:') === false) { + logger('activity rejected: legacy message-id'); + return; + } + } + + if ($arr['verb'] === 'Create' && ActivityStreams::is_an_actor($arr['obj_type'])) { + logger('activity rejected: create actor'); + return; + } + } + + $deliveries = null; + + if (array_key_exists('recipients', $env) && count($env['recipients'])) { + logger('specific recipients'); + logger('recipients: ' . print_r($env['recipients'], true), LOGGER_DEBUG); + + $recip_arr = []; + foreach ($env['recipients'] as $recip) { + $recip_arr[] = $recip; + } + + $r = false; + if ($recip_arr) { + stringify_array_elms($recip_arr, true); + $recips = implode(',', $recip_arr); + $r = q("select channel_hash as hash from channel where channel_hash in ( " . $recips . " ) and channel_removed = 0 "); + } + + if (!$r) { + logger('recips: no recipients on this site'); + return; + } + + // Response messages will inherit the privacy of the parent + + if ($env['type'] !== 'response') { + $private = true; + } + + $deliveries = ids_to_array($r, 'hash'); + + // We found somebody on this site that's in the recipient list. + } else { + logger('public post'); + + + // Public post. look for any site members who are or may be accepting posts from this sender + // and who are allowed to see them based on the sender's permissions + // @fixme; + + $deliveries = self::public_recips($env, $AS); + } + + $deliveries = array_unique($deliveries); + + if (!$deliveries) { + logger('No deliveries on this site'); + return; + } + + if ($has_data) { + if (in_array($env['type'], ['activity', 'response'])) { + if (!(is_array($AS->actor) && isset($AS->actor['id']))) { + logger('No author!'); + return; + } + + $r = q( + "select hubloc_hash, hubloc_network, hubloc_url from hubloc where hubloc_id_url = '%s'", + dbesc($AS->actor['id']) + ); + + if ($r) { + $r = self::zot_record_preferred($r); + $arr['author_xchan'] = $r['hubloc_hash']; + } + + if (!$arr['author_xchan']) { + logger('No author!'); + return; + } + + $s = q( + "select hubloc_hash, hubloc_url from hubloc where hubloc_id_url = '%s' and hubloc_network in ('zot6','nomad') limit 1", + dbesc($env['sender']) + ); + + // in individual delivery, change owner if needed + if ($s) { + $arr['owner_xchan'] = $s[0]['hubloc_hash']; + } else { + $arr['owner_xchan'] = $env['sender']; + } + + if ($private && (!intval($arr['item_private']))) { + $arr['item_private'] = 1; + } + if ($arr['mid'] === $arr['parent_mid']) { + if (is_array($AS->obj) && array_key_exists('commentPolicy', $AS->obj)) { + $p = strstr($AS->obj['commentPolicy'], 'until='); + + // if until= is the same as the creation date, set the item_nocomment flag + // as comments were already closed before the post was even sent. + + if ($p !== false) { + $comments_closed_at = datetime_convert('UTC', 'UTC', substr($p, 6)); + if ($comments_closed_at === $arr['created']) { + $arr['item_nocomment'] = 1; + } else { + $arr['comments_closed'] = $comments_closed_at; + $arr['comment_policy'] = trim(str_replace($p, '', $AS->obj['commentPolicy'])); + } + } else { + $arr['comment_policy'] = $AS->obj['commentPolicy']; + } + } + } + if ($AS->data['hubloc']) { + $arr['item_verified'] = true; + + if (!array_key_exists('comment_policy', $arr)) { + // set comment policy based on type of site. + $s = q( + "select site_type from site where site_url = '%s' limit 1", + dbesc($r[0]['hubloc_url']) + ); + + if ($s && intval($s[0]['site_type']) === SITE_TYPE_ZOT) { + $arr['comment_policy'] = 'contacts'; + } else { + $arr['comment_policy'] = 'authenticated'; + } + } + } + if ($AS->meta['signed_data']) { + IConfig::Set($arr, 'activitypub', 'signed_data', $AS->meta['signed_data'], false); + $j = json_decode($AS->meta['signed_data'], true); + if ($j) { + IConfig::Set($arr, 'activitypub', 'rawmsg', json_encode(JSalmon::unpack($j['data'])), true); + } + } + + logger('Activity received: ' . print_r($arr, true), LOGGER_DATA, LOG_DEBUG); + logger('Activity recipients: ' . print_r($deliveries, true), LOGGER_DATA, LOG_DEBUG); + + $relay = (($env['type'] === 'response') ? true : false); + + $result = self::process_delivery($env['sender'], $AS, $arr, $deliveries, $relay, false, $message_request); + } elseif ($env['type'] === 'sync') { + $arr = json_decode($data, true); + + logger('Channel sync received: ' . print_r($arr, true), LOGGER_DATA, LOG_DEBUG); + logger('Channel sync recipients: ' . print_r($deliveries, true), LOGGER_DATA, LOG_DEBUG); + + if ($env['encoding'] === 'red') { + $result = Libsync::process_channel_sync_delivery($env['sender'], $arr, $deliveries); + } else { + logger('unsupported sync packet encoding ignored.'); + } + } + } + if ($result) { + $return = array_merge($return, $result); + } + return $return; + } + + + public static function is_top_level($env, $act) + { + if ($env['encoding'] === 'zot' && array_key_exists('flags', $env) && in_array('thread_parent', $env['flags'])) { + return true; + } + if ($act) { + if (in_array($act->type, ['Like', 'Dislike'])) { + return false; + } + $x = self::find_parent($env, $act); + if ($x === $act->id || (is_array($act->obj) && array_key_exists('id', $act->obj) && $x === $act->obj['id'])) { + return true; + } + } + return false; + } + + + public static function find_parent($env, $act) + { + if ($act) { + if (in_array($act->type, ['Like', 'Dislike']) && is_array($act->obj)) { + return $act->obj['id']; + } + if ($act->parent_id) { + return $act->parent_id; + } + } + return false; + } + + + /** + * @brief + * + * A public message with no listed recipients can be delivered to anybody who + * has PERMS_NETWORK for that type of post, PERMS_AUTHED (in-network senders are + * by definition authenticated) or PERMS_SITE and is one the same site, + * or PERMS_SPECIFIC and the sender is a contact who is granted permissions via + * their connection permissions in the address book. + * Here we take a given message and construct a list of hashes of everybody + * on the site that we should try and deliver to. + * Some of these will be rejected, but this gives us a place to start. + * + * @param array $msg + * @return NULL|array + */ + + public static function public_recips($msg, $act) + { + + $check_mentions = false; + $include_sys = false; + + if ($msg['type'] === 'activity') { + $public_stream_mode = intval(get_config('system', 'public_stream_mode', PUBLIC_STREAM_NONE)); + if ($public_stream_mode === PUBLIC_STREAM_FULL) { + $include_sys = true; + } + + $perm = 'send_stream'; + + if (self::is_top_level($msg, $act)) { + $check_mentions = true; + } + } elseif ($msg['type'] === 'mail') { + $perm = 'post_mail'; + } + + // channels which we will deliver this post to + $r = []; + + $c = q("select channel_id, channel_hash from channel where channel_removed = 0"); + + if ($c) { + foreach ($c as $cc) { + // top level activity sent to ourself: ignore. Clones will get a sync activity + // which is a true clone of the original item. Everything else is a duplicate. + + if ($check_mentions && $cc['channel_hash'] === $msg['sender']) { + continue; + } + + if (perm_is_allowed($cc['channel_id'], $msg['sender'], $perm)) { + $r[] = $cc['channel_hash']; + } + } + } + + if ($include_sys) { + $sys = Channel::get_system(); + if ($sys) { + $r[] = $sys['channel_hash']; + } + } + + // add channels that are following tags + // these will be enumerated and validated in tgroup_check() + + $ft = q( + "select channel_hash as hash from channel left join pconfig on pconfig.uid = channel_id where cat = 'system' and k = 'followed_tags' and channel_hash != '%s' and channel_removed = 0", + dbesc($msg['sender']) + ); + if ($ft) { + foreach ($ft as $t) { + $r[] = $t['hash']; + } + } + + // look for any public mentions on this site + // They will get filtered by tgroup_check() so we don't need to check permissions now + + if ($check_mentions) { + // It's a top level post. Look at the tags. See if any of them are mentions and are on this hub. + if ($act && $act->obj) { + if (is_array($act->obj['tag']) && $act->obj['tag']) { + foreach ($act->obj['tag'] as $tag) { + if ($tag['type'] === 'Mention' && (strpos($tag['href'], z_root()) !== false)) { + $address = basename($tag['href']); + if ($address) { + $z = q( + "select channel_hash as hash from channel where channel_address = '%s' + and channel_hash != '%s' and channel_removed = 0 limit 1", + dbesc($address), + dbesc($msg['sender']) + ); + if ($z) { + $r[] = $z[0]['hash']; + } + } + } + if ($tag['type'] === 'topicalCollection' && strpos($tag['name'], App::get_hostname())) { + $address = substr($tag['name'], 0, strpos($tag['name'], '@')); + if ($address) { + $z = q( + "select channel_hash as hash from channel where channel_address = '%s' + and channel_hash != '%s' and channel_removed = 0 limit 1", + dbesc($address), + dbesc($msg['sender']) + ); + if ($z) { + $r[] = $z[0]['hash']; + } + } + } + } + } + } + } else { + // This is a comment. We need to find any parent with ITEM_UPLINK set. But in fact, let's just return + // everybody that stored a copy of the parent. This way we know we're covered. We'll check the + // comment permissions when we deliver them. + + $thread_parent = self::find_parent($msg, $act); + + if ($thread_parent) { + $z = q( + "select channel_hash as hash from channel left join item on channel.channel_id = item.uid where ( item.thr_parent = '%s' OR item.parent_mid = '%s' ) ", + dbesc($thread_parent), + dbesc($thread_parent) + ); + if ($z) { + foreach ($z as $zv) { + $r[] = $zv['hash']; + } + } + } + } + + // There are probably a lot of duplicates in $r at this point. We need to filter those out. + + if ($r) { + $r = array_values(array_unique($r)); + } + + logger('public_recips: ' . print_r($r, true), LOGGER_DATA, LOG_DEBUG); + return $r; + } + + + /** + * @brief + * + * @param array $sender + * @param ActivityStreams object $act + * @param array $msg_arr + * @param array $deliveries + * @param bool $relay + * @param bool $public (optional) default false + * @param bool $request (optional) default false + * @return array + */ + + public static function process_delivery($sender, $act, $msg_arr, $deliveries, $relay, $public = false, $request = false) + { + + $result = []; + + // logger('msg_arr: ' . print_r($msg_arr,true),LOGGER_ALL); + + // If an upstream hop used ActivityPub, set the identities to zot6 nomadic identities where applicable + // else things could easily get confused + + $msg_arr['author_xchan'] = Activity::find_best_identity($msg_arr['author_xchan']); + $msg_arr['owner_xchan'] = Activity::find_best_identity($msg_arr['owner_xchan']); + + // We've validated the sender. Now make sure that the sender is the owner or author + + if (!$public) { + if ($sender != $msg_arr['owner_xchan'] && $sender != $msg_arr['author_xchan']) { + logger("Sender $sender is not owner {$msg_arr['owner_xchan']} or author {$msg_arr['author_xchan']} - mid {$msg_arr['mid']}"); + return; + } + } + + if ($act->implied_create) { + logger('implied create activity. Not delivering/storing.'); + return; + } + + foreach ($deliveries as $d) { + $local_public = $public; + + // if any further changes are to be made, change a copy and not the original + $arr = $msg_arr; + +// if (! $msg_arr['mid']) { +// logger('no mid2: ' . print_r($msg_arr,true)); +// logger('recip: ' . $d); +// } + + + $DR = new DReport(z_root(), $sender, $d, $arr['mid']); + + $channel = Channel::from_hash($d); + + if (!$channel) { + $DR->update('recipient not found'); + $result[] = $DR->get(); + continue; + } + + $DR->set_name($channel['channel_name'] . ' <' . Channel::get_webfinger($channel) . '>'); + +// if ($act->type === 'Tombstone') { +// $r = q("select * from item where mid in ( '%s', '%s' ) and uid = %d", +// dbesc($act->id), +// dbesc(str_replace('/activity/','/item/',$act->id)) +// intval($channel['channel_id']) +// ); +// if ($r) { +// if (($r[0]['author_xchan'] === $sender) || ($r[0]['owner_xchan'] === $sender)) { +// drop_item($r[0]['id'],false); +// } +// $DR->update('item deleted'); +// $result[] = $DR->get(); +// continue; +// } +// $DR->update('deleted item not found'); +// $result[] = $DR->get(); +// continue; +// } + + if (($act) && ($act->obj) && (!is_array($act->obj))) { + // The initial object fetch failed using the sys channel credentials. + // Try again using the delivery channel credentials. + // We will also need to re-parse the $item array, + // but preserve any values that were set during anonymous parsing. + + $o = Activity::fetch($act->obj, $channel); + if ($o) { + $act->obj = $o; + $arr = array_merge(Activity::decode_note($act), $arr); + } else { + $DR->update('Incomplete or corrupt activity'); + $result[] = $DR->get(); + continue; + } + } + + /** + * We need to block normal top-level message delivery from our clones, as the delivered + * message doesn't have ACL information in it as the cloned copy does. That copy + * will normally arrive first via sync delivery, but this isn't guaranteed. + * There's a chance the current delivery could take place before the cloned copy arrives + * hence the item could have the wrong ACL and *could* be used in subsequent deliveries or + * access checks. + */ + + if ($sender === $channel['channel_hash'] && $arr['author_xchan'] === $channel['channel_hash'] && $arr['mid'] === $arr['parent_mid']) { + $DR->update('self delivery ignored'); + $result[] = $DR->get(); + continue; + } + + // allow public postings to the sys channel regardless of permissions, but not + // for comments travelling upstream. Wait and catch them on the way down. + // They may have been blocked by the owner. + + if (intval($channel['channel_system']) && (!$arr['item_private']) && (!$relay)) { + $local_public = true; + + if (!check_pubstream_channelallowed($sender)) { + $local_public = false; + continue; + } + + // don't allow pubstream posts if the sender even has a clone on a pubstream denied site + + $siteallowed = true; + $h = q( + "select hubloc_url from hubloc where hubloc_hash = '%s'", + dbesc($sender) + ); + if ($h) { + foreach ($h as $hub) { + if (!check_pubstream_siteallowed($hub['hubloc_url'])) { + $siteallowed = false; + break; + } + } + } + if (!$siteallowed) { + $local_public = false; + continue; + } + + $r = q( + "select xchan_selfcensored from xchan where xchan_hash = '%s' limit 1", + dbesc($sender) + ); + // don't import sys channel posts from selfcensored authors + if ($r && (intval($r[0]['xchan_selfcensored']))) { + $local_public = false; + continue; + } + if (!MessageFilter::evaluate($arr, get_config('system', 'pubstream_incl'), get_config('system', 'pubstream_excl'))) { + $local_public = false; + continue; + } + } + + // perform pre-storage check to see if it's "likely" that this is a group or collection post + + $tag_delivery = tgroup_check($channel['channel_id'], $arr); + + $perm = 'send_stream'; + if (($arr['mid'] !== $arr['parent_mid']) && ($relay)) { + $perm = 'post_comments'; + } + + // This is our own post, possibly coming from a channel clone + + if ($arr['owner_xchan'] == $d) { + $arr['item_wall'] = 1; + } else { + $arr['item_wall'] = 0; + } + + $friendofriend = false; + + if ((!$tag_delivery) && (!$local_public)) { + $allowed = (perm_is_allowed($channel['channel_id'], $sender, $perm)); + + $blocked = LibBlock::fetch($channel['channel_id'], BLOCKTYPE_SERVER); + if ($blocked) { + $h = q( + "select hubloc_url from hubloc where hubloc_hash = '%s'", + dbesc($sender) + ); + if ($h) { + foreach ($h as $hub) { + foreach ($blocked as $b) { + if (strpos($hub['hubloc_url'], $b['block_entity']) !== false) { + $allowed = false; + } + } + } + } + } + + $permit_mentions = intval(PConfig::Get($channel['channel_id'], 'system', 'permit_all_mentions') && i_am_mentioned($channel, $arr)); + + if (!$allowed) { + if ($perm === 'post_comments') { + $parent = q( + "select * from item where mid = '%s' and uid = %d limit 1", + dbesc($arr['parent_mid']), + intval($channel['channel_id']) + ); + if ($parent) { + $allowed = can_comment_on_post($sender, $parent[0]); + } + if ((!$allowed) && $permit_mentions) { + if ($parent && $parent[0]['owner_xchan'] === $channel['channel_hash']) { + $allowed = false; + } else { + $allowed = true; + } + } + if ($parent && absolutely_no_comments($parent[0])) { + $allowed = false; + } + } elseif ($permit_mentions) { + $allowed = true; + } + } + if ($request) { + // Conversation fetches (e.g. $request == true) take place for + // a) new comments on expired posts + // b) hyperdrive (friend-of-friend) conversations + + + // over-ride normal connection permissions for hyperdrive (friend-of-friend) conversations + // (if hyperdrive is enabled). + // If $allowed is already true, this is probably the conversation of a direct friend or a + // conversation fetch for a new comment on an expired post + // Comments of all these activities are allowed and will only be rejected (later) if the parent + // doesn't exist. + + if ($perm === 'send_stream') { + if (get_pconfig($channel['channel_id'], 'system', 'hyperdrive', true)) { + $allowed = true; + } + } else { + $allowed = true; + } + + $friendofriend = true; + } + + if (intval($arr['item_private']) === 2) { + if (!perm_is_allowed($channel['channel_id'], $sender, 'post_mail')) { + $allowed = false; + } + } + + if (get_abconfig($channel['channel_id'], $sender, 'system', 'block_announce', false)) { + if ($arr['verb'] === 'Announce' || strpos($arr['body'], '[/share]')) { + $allowed = false; + } + } + + if (!$allowed) { + logger("permission denied for delivery to channel {$channel['channel_id']} {$channel['channel_address']}"); + $DR->update('permission denied'); + $result[] = $DR->get(); + continue; + } + } + + if ($arr['mid'] !== $arr['parent_mid']) { + if (perm_is_allowed($channel['channel_id'], $sender, 'moderated') && $relay) { + $arr['item_blocked'] = ITEM_MODERATED; + } + + // check source route. + // We are only going to accept comments from this sender if the comment has the same route as the top-level-post, + // this is so that permissions mismatches between senders apply to the entire conversation + // As a side effect we will also do a preliminary check that we have the top-level-post, otherwise + // processing it is pointless. + + // The original author won't have a token in their copy of the message + + $prnt = ((strpos($arr['parent_mid'], 'token=') !== false) ? substr($arr['parent_mid'], 0, strpos($arr['parent_mid'], '?')) : ''); + + $r = q( + "select route, id, parent_mid, mid, owner_xchan, item_private, obj_type from item where mid = '%s' and uid = %d limit 1", + dbesc($arr['parent_mid']), + intval($channel['channel_id']) + ); + if (!$r) { + $r = q( + "select route, id, parent_mid, mid, owner_xchan, item_private, obj_type from item where mid = '%s' and uid = %d limit 1", + dbesc($prnt), + intval($channel['channel_id']) + ); + } + + if ($r) { + // if this is a multi-threaded conversation, preserve the threading information + if ($r[0]['parent_mid'] !== $r[0]['mid']) { + $arr['thr_parent'] = $arr['parent_mid']; + $arr['parent_mid'] = $r[0]['parent_mid']; + if ($act->replyto) { + q( + "update item set replyto = '%s' where id = %d", + dbesc($act->replyto), + intval($r[0]['id']) + ); + } + } + + if ($r[0]['obj_type'] === 'Question') { + // route checking doesn't work correctly here because we've changed the privacy + $r[0]['route'] = EMPTY_STR; + // If this is a poll response, convert the obj_type to our (internal-only) "Answer" type + if ($arr['obj_type'] === 'Note' && $arr['title'] && (!$arr['content'])) { + $arr['obj_type'] = 'Answer'; + } + } + } else { + // We don't seem to have a copy of this conversation or at least the parent + // - so request a copy of the entire conversation to date. + // Don't do this if it's a relay post as we're the ones who are supposed to + // have the copy and we don't want the request to loop. + // Also don't do this if this comment came from a conversation request packet. + // It's possible that comments are allowed but posting isn't and that could + // cause a conversation fetch loop. + // We'll also check the send_stream permission - because if it isn't allowed, + // the top level post is unlikely to be imported and + // this is just an exercise in futility. + + if ( + (!$relay) && (!$request) && (!$local_public) + && perm_is_allowed($channel['channel_id'], $sender, 'send_stream') + ) { + $reports = self::fetch_conversation($channel, $arr['mid']); + + // extract our delivery report from the fetched conversation + // if we can find it. + logger('fetch_report for ' . $arr['mid'], LOGGER_ALL); + logger('fetch_report: ' . print_r($reports, true), LOGGER_ALL); + + if ($reports && is_array($reports)) { + $found_report = false; + foreach ($reports as $report) { + if ($report['message_id'] === $arr['mid']) { + $found_report = true; + $DR->update($report['status']); + } + } + if (!$found_report) { + $DR->update('conversation fetch failed'); + } + } else { + $DR->update('conversation fetch failed'); + } + } else { + $DR->update('comment parent not found'); + } + $result[] = $DR->get(); + continue; + } + + + if ($relay || $friendofriend || (intval($r[0]['item_private']) === 0 && intval($arr['item_private']) === 0)) { + // reset the route in case it travelled a great distance upstream + // use our parent's route so when we go back downstream we'll match + // with whatever route our parent has. + // Also friend-of-friend conversations may have been imported without a route, + // but we are now getting comments via listener delivery + // and if there is no privacy on this or the parent, we don't care about the route, + // so just set the owner and route accordingly. + $arr['route'] = $r[0]['route']; + $arr['owner_xchan'] = $r[0]['owner_xchan']; + } else { + // going downstream check that we have the same upstream provider that + // sent it to us originally. Ignore it if it came from another source + // (with potentially different permissions). + // only compare the last hop since it could have arrived at the last location any number of ways. + // Always accept empty routes and firehose items (route contains 'undefined') . + + $existing_route = explode(',', $r[0]['route']); + $routes = count($existing_route); + if ($routes) { + $last_hop = array_pop($existing_route); + $last_prior_route = implode(',', $existing_route); + } else { + $last_hop = ''; + $last_prior_route = ''; + } + + if (in_array('undefined', $existing_route) || $last_hop == 'undefined' || $sender == 'undefined') { + $last_hop = ''; + } + + $current_route = (($arr['route']) ? $arr['route'] . ',' : '') . $sender; + + if ($last_hop && $last_hop != $sender) { + logger('comment route mismatch: parent route = ' . $r[0]['route'] . ' expected = ' . $current_route, LOGGER_DEBUG); + logger('comment route mismatch: parent msg = ' . $r[0]['id'], LOGGER_DEBUG); + $DR->update('comment route mismatch'); + $result[] = $DR->get(); + continue; + } + + // we'll add sender onto this when we deliver it. $last_prior_route now has the previously stored route + // *except* for the sender which would've been the last hop before it got to us. + + $arr['route'] = $last_prior_route; + } + } + + // This is used to fetch allow/deny rules if either the sender + // or owner is a connection. post_is_importable() evaluates all of them + $abook = q( + "select * from abook where abook_channel = %d and ( abook_xchan = '%s' OR abook_xchan = '%s' )", + intval($channel['channel_id']), + dbesc($arr['owner_xchan']), + dbesc($arr['author_xchan']) + ); + + if (isset($arr['item_deleted']) && intval($arr['item_deleted'])) { + // set these just in case we need to store a fresh copy of the deleted post. + // This could happen if the delete got here before the original post did. + + $arr['aid'] = $channel['channel_account_id']; + $arr['uid'] = $channel['channel_id']; + + $item_id = self::delete_imported_item($sender, $act, $arr, $channel['channel_id'], $relay); + $DR->update(($item_id) ? 'deleted' : 'delete_failed'); + $result[] = $DR->get(); + + if ($relay && $item_id) { + logger('process_delivery: invoking relay'); + Run::Summon(['Notifier', 'relay', intval($item_id)]); + $DR->update('relayed'); + $result[] = $DR->get(); + } + continue; + } + + // reactions such as like and dislike could have an mid with /activity/ in it. + // Check for both forms in order to prevent duplicates. + + $r = q( + "select * from item where mid in ('%s','%s') and uid = %d limit 1", + dbesc($arr['mid']), + dbesc(str_replace(z_root() . '/activity/', z_root() . '/item/', $arr['mid'])), + intval($channel['channel_id']) + ); + + if ($r) { + // We already have this post. + $item_id = $r[0]['id']; + + if (intval($r[0]['item_deleted'])) { + // It was deleted locally. + $DR->update('update ignored'); + $result[] = $DR->get(); + + continue; + } // Maybe it has been edited? + elseif ($arr['edited'] > $r[0]['edited']) { + $arr['id'] = $r[0]['id']; + $arr['uid'] = $channel['channel_id']; + if (post_is_importable($channel['channel_id'], $arr, $abook)) { + $item_result = self::update_imported_item($sender, $arr, $r[0], $channel['channel_id'], $tag_delivery); + $DR->update('updated'); + $result[] = $DR->get(); + if (!$relay) { + add_source_route($item_id, $sender); + } + } else { + $DR->update('update ignored'); + $result[] = $DR->get(); + } + } else { + $DR->update('update ignored'); + $result[] = $DR->get(); + + // We need this line to ensure wall-to-wall comments are relayed (by falling through to the relay bit), + // and at the same time not relay any other relayable posts more than once, because to do so is very wasteful. + if (!intval($r[0]['item_origin'])) { + continue; + } + } + } else { + $arr['aid'] = $channel['channel_account_id']; + $arr['uid'] = $channel['channel_id']; + + // if it's a sourced post, call the post_local hooks as if it were + // posted locally so that crosspost connectors will be triggered. + + if (check_item_source($arr['uid'], $arr)) { + /** + * @hooks post_local + * Called when an item has been posted on this machine via mod/item.php (also via API). + * * \e array with an item + */ + Hook::call('post_local', $arr); + } + + $item_id = 0; + + Activity::rewrite_mentions($arr); + + + $maxlen = get_max_import_size(); + + if ($maxlen && mb_strlen($arr['body']) > $maxlen) { + $arr['body'] = mb_substr($arr['body'], 0, $maxlen, 'UTF-8'); + logger('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('message summary length exceeds max_import_size: truncated'); + } + + if (post_is_importable($arr['uid'], $arr, $abook)) { + // Strip old-style hubzilla bookmarks + if (strpos($arr['body'], "#^[") !== false) { + $arr['body'] = str_replace("#^[", "[", $arr['body']); + } + + $item_result = item_store($arr); + if ($item_result['success']) { + $item_id = $item_result['item_id']; + $parr = [ + 'item_id' => $item_id, + 'item' => $arr, + 'sender' => $sender, + 'channel' => $channel + ]; + /** + * @hooks activity_received + * Called when an activity (post, comment, like, etc.) has been received from a zot source. + * * \e int \b item_id + * * \e array \b item + * * \e array \b sender + * * \e array \b channel + */ + Hook::call('activity_received', $parr); + // don't add a source route if it's a relay or later recipients will get a route mismatch + if (!$relay) { + add_source_route($item_id, $sender); + } + } + $DR->update(($item_id) ? 'posted' : 'storage failed: ' . $item_result['message']); + $result[] = $DR->get(); + } else { + $DR->update('post ignored'); + $result[] = $DR->get(); + } + } + + // preserve conversations with which you are involved from expiration + + $stored = (($item_result && $item_result['item']) ? $item_result['item'] : false); + if ( + (is_array($stored)) && ($stored['id'] != $stored['parent']) + && ($stored['author_xchan'] === $channel['channel_hash']) + ) { + retain_item($stored['item']['parent']); + } + + if ($relay && $item_id) { + logger('Invoking relay'); + Run::Summon(['Notifier', 'relay', intval($item_id)]); + $DR->addto_update('relayed'); + $result[] = $DR->get(); + } + } + + if (!$deliveries) { + $result[] = array('', 'no recipients', '', $arr['mid']); + } + + logger('Local results: ' . print_r($result, true), LOGGER_DEBUG); + + return $result; + } + + public static function hyperdrive_enabled($channel, $item) + { + + if (get_pconfig($channel['channel_id'], 'system', 'hyperdrive', true)) { + return true; + } + return false; + } + + public static function fetch_conversation($channel, $mid) + { + + // Use Zotfinger to create a signed request + + logger('fetching conversation: ' . $mid, LOGGER_DEBUG); + + $a = Zotfinger::exec($mid, $channel); + + logger('received conversation: ' . print_r($a, true), LOGGER_DATA); + + if (!$a) { + return false; + } + + if ($a['data']['type'] !== 'OrderedCollection') { + return false; + } + + $obj = new ASCollection($a['data'], $channel); + $items = $obj->get(); + + if (!$items) { + return false; + } + + $ret = []; + + + $signer = q( + "select hubloc_hash, hubloc_url from hubloc where hubloc_id_url = '%s' and hubloc_network in ('zot6','nomad') limit 1", + dbesc($a['signature']['signer']) + ); + + + foreach ($items as $activity) { + $AS = new ActivityStreams($activity); + if ( + $AS->is_valid() && $AS->type === 'Announce' && is_array($AS->obj) + && array_key_exists('object', $AS->obj) && array_key_exists('actor', $AS->obj) + ) { + // This is a relayed/forwarded Activity (as opposed to a shared/boosted object) + // Reparse the encapsulated Activity and use that instead + logger('relayed activity', LOGGER_DEBUG); + $AS = new ActivityStreams($AS->obj); + } + + if (!$AS->is_valid()) { + logger('FOF Activity rejected: ' . print_r($activity, true)); + continue; + } + $arr = Activity::decode_note($AS); + + // logger($AS->debug()); + + $r = q( + "select hubloc_hash from hubloc where hubloc_id_url = '%s' or hubloc_hash = '%s' limit 1", + dbesc($AS->actor['id']), + dbesc($AS->actor['id']) + ); + + if (!$r) { + $y = import_author_xchan(['url' => $AS->actor['id']]); + if ($y) { + $r = q( + "select hubloc_hash from hubloc where hubloc_id_url = '%s' or hubloc_hash = '%s' limit 1", + dbesc($AS->actor['id']), + dbesc($AS->actor['id']) + ); + } + if (!$r) { + logger('FOF Activity: no actor'); + continue; + } + } + + if ($AS->obj['actor'] && $AS->obj['actor']['id'] && $AS->obj['actor']['id'] !== $AS->actor['id']) { + $y = import_author_xchan(['url' => $AS->obj['actor']['id']]); + if (!$y) { + logger('FOF Activity: no object actor'); + continue; + } + } + + + if ($r) { + $arr['author_xchan'] = $r[0]['hubloc_hash']; + } + + if ($signer) { + $arr['owner_xchan'] = $signer[0]['hubloc_hash']; + } else { + $arr['owner_xchan'] = $a['signature']['signer']; + } + + if ($AS->meta['hubloc'] || $arr['author_xchan'] === $arr['owner_xchan']) { + $arr['item_verified'] = true; + } + + if ($AS->meta['signed_data']) { + IConfig::Set($arr, 'activitystreams', 'signed_data', $AS->meta['signed_data'], false); + } + + logger('FOF Activity received: ' . print_r($arr, true), LOGGER_DATA, LOG_DEBUG); + logger('FOF Activity recipient: ' . $channel['channel_hash'], LOGGER_DATA, LOG_DEBUG); + + $result = self::process_delivery($arr['owner_xchan'], $AS, $arr, [$channel['channel_hash']], false, false, true); + if ($result) { + $ret = array_merge($ret, $result); + } + } + + return $ret; + } + + + /** + * @brief Remove community tag. + * + * @param array $sender an associative array with + * * \e string \b hash a xchan_hash + * @param array $arr an associative array + * * \e int \b verb + * * \e int \b obj_type + * * \e int \b mid + * @param int $uid + */ + + public static function remove_community_tag($sender, $arr, $uid) + { + + if (!(activity_match($arr['verb'], ACTIVITY_TAG) && ($arr['obj_type'] == ACTIVITY_OBJ_TAGTERM))) { + return; + } + + logger('remove_community_tag: invoked'); + + if (!get_pconfig($uid, 'system', 'blocktags')) { + logger('Permission denied.'); + return; + } + + $r = q( + "select * from item where mid = '%s' and uid = %d limit 1", + dbesc($arr['mid']), + intval($uid) + ); + if (!$r) { + logger('No item'); + return; + } + + if (($sender != $r[0]['owner_xchan']) && ($sender != $r[0]['author_xchan'])) { + logger('Sender not authorised.'); + return; + } + + $i = $r[0]; + + if ($i['target']) { + $i['target'] = json_decode($i['target'], true); + } + if ($i['object']) { + $i['object'] = json_decode($i['object'], true); + } + if (!($i['target'] && $i['object'])) { + logger('No target/object'); + return; + } + + $message_id = $i['target']['id']; + + $r = q( + "select id from item where mid = '%s' and uid = %d limit 1", + dbesc($message_id), + intval($uid) + ); + if (!$r) { + logger('No parent message'); + return; + } + + q( + "delete from term where uid = %d and oid = %d and otype = %d and ttype in ( %d, %d ) and term = '%s' and url = '%s'", + intval($uid), + intval($r[0]['id']), + intval(TERM_OBJ_POST), + intval(TERM_HASHTAG), + intval(TERM_COMMUNITYTAG), + dbesc($i['object']['title']), + dbesc(get_rel_link($i['object']['link'], 'alternate')) + ); + } + + /** + * @brief Updates an imported item. + * + * @param array $sender + * @param array $item + * @param array $orig + * @param int $uid + * @param bool $tag_delivery + * @see item_store_update() + * + */ + + public static function update_imported_item($sender, $item, $orig, $uid, $tag_delivery) + { + + // If this is a comment being updated, remove any privacy information + // so that item_store_update will set it from the original. + + if ($item['mid'] !== $item['parent_mid']) { + unset($item['allow_cid']); + unset($item['allow_gid']); + unset($item['deny_cid']); + unset($item['deny_gid']); + unset($item['item_private']); + } + + // we need the tag_delivery check for downstream flowing posts as the stored post + // may have a different owner than the one being transmitted. + + if (($sender != $orig['owner_xchan'] && $sender != $orig['author_xchan']) && (!$tag_delivery)) { + logger('sender is not owner or author'); + return; + } + + + $x = item_store_update($item); + + // If we're updating an event that we've saved locally, we store the item info first + // because event_addtocal will parse the body to get the 'new' event details + + if ($orig['resource_type'] === 'event') { + $res = event_addtocal($orig['id'], $uid); + if (!$res) { + logger('update event: failed'); + } + } + + if (!$x['item_id']) { + logger('update_imported_item: failed: ' . $x['message']); + } else { + logger('update_imported_item'); + } + + return $x; + } + + /** + * @brief Deletes an imported item. + * + * @param array $sender + * * \e string \b hash a xchan_hash + * @param array $item + * @param int $uid + * @param bool $relay + * @return bool|int post_id + */ + + public static function delete_imported_item($sender, $act, $item, $uid, $relay) + { + + logger('invoked', LOGGER_DEBUG); + + $ownership_valid = false; + $item_found = false; + $post_id = 0; + + if ($item['verb'] === 'Tombstone') { + // The id of the deleted thing is the item mid (activity id) + $mid = $item['mid']; + } else { + // The id is the object id if the type is Undo or Delete + $mid = ((is_array($act->obj)) ? $act->obj['id'] : $act->obj); + } + + // we may have stored either the object id or the activity id if it was a response activity (like, dislike, etc.) + + $r = q( + "select * from item where ( author_xchan = '%s' or owner_xchan = '%s' or source_xchan = '%s' ) + and mid in ('%s','%s') and uid = %d limit 1", + dbesc($sender), + dbesc($sender), + dbesc($sender), + dbesc($mid), + dbesc(str_replace('/activity/', '/item/', $mid)), + intval($uid) + ); + + if ($r) { + $stored = $r[0]; + // we proved ownership in the sql query + $ownership_valid = true; + + $post_id = $stored['id']; + $item_found = true; + } else { + // this will fail with an ownership issue, so explain the real reason + logger('delete received for non-existent item or not owned by sender - ignoring.'); + } + + if ($ownership_valid === false) { + logger('delete_imported_item: failed: ownership issue'); + return false; + } + + if ($stored['resource_type'] === 'event') { + $i = q( + "SELECT * FROM event WHERE event_hash = '%s' AND uid = %d LIMIT 1", + dbesc($stored['resource_id']), + intval($uid) + ); + if ($i) { + if ($i[0]['event_xchan'] === $sender) { + q( + "delete from event where event_hash = '%s' and uid = %d", + dbesc($stored['resource_id']), + intval($uid) + ); + } else { + logger('delete linked event: not owner'); + return; + } + } + } + if ($item_found) { + if (intval($stored['item_deleted'])) { + logger('delete_imported_item: item was already deleted'); + if (!$relay) { + return false; + } + + // This is a bit hackish, but may have to suffice until the notification/delivery loop is optimised + // a bit further. We're going to strip the ITEM_ORIGIN on this item if it's a comment, because + // it was already deleted, and we're already relaying, and this ensures that no other process or + // code path downstream can relay it again (causing a loop). Since it's already gone it's not coming + // back, and we aren't going to (or shouldn't at any rate) delete it again in the future - so losing + // this information from the metadata should have no other discernible impact. + + if (($stored['id'] != $stored['parent']) && intval($stored['item_origin'])) { + q( + "update item set item_origin = 0 where id = %d and uid = %d", + intval($stored['id']), + intval($stored['uid']) + ); + } + } else { + if ($stored['id'] !== $stored['parent']) { + q( + "update item set commented = '%s', changed = '%s' where id = %d", + dbesc(datetime_convert()), + dbesc(datetime_convert()), + intval($stored['parent']) + ); + } + } + + // Use phased deletion to set the deleted flag, call both tag_deliver and the notifier to notify downstream channels + // and then clean up after ourselves with a cron job after several days to do the delete_item_lowlevel() (DROPITEM_PHASE2). + + drop_item($post_id, false, DROPITEM_PHASE1); + tag_deliver($uid, $post_id); + } + + return $post_id; + } + + + /** + * @brief Processes delivery of profile. + * + * @param array $sender an associative array + * * \e string \b hash a xchan_hash + * @param array $arr + * @param array $deliveries (unused) + * @see import_directory_profile() + */ + + public static function process_profile_delivery($sender, $arr, $deliveries) + { + + logger('process_profile_delivery', LOGGER_DEBUG); + + $r = q( + "select xchan_addr from xchan where xchan_hash = '%s' limit 1", + dbesc($sender['hash']) + ); + if ($r) { + Libzotdir::import_directory_profile($sender, $arr, $r[0]['xchan_addr'], UPDATE_FLAGS_UPDATED, 0); + } + } + + + /** + * @brief + * + * @param array $sender an associative array + * * \e string \b hash a xchan_hash + * @param array $arr + * @param array $deliveries (unused) deliveries is irrelevant + */ + public static function process_location_delivery($sender, $arr, $deliveries) + { + + // deliveries is irrelevant + logger('process_location_delivery', LOGGER_DEBUG); + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($sender) + ); + if ($r) { + $xchan = ['id' => $r[0]['xchan_guid'], 'id_sig' => $r[0]['xchan_guid_sig'], + 'hash' => $r[0]['xchan_hash'], 'public_key' => $r[0]['xchan_pubkey']]; + } + if (array_key_exists('locations', $arr) && $arr['locations']) { + $x = Libsync::sync_locations($xchan, $arr, true); + logger('results: ' . print_r($x, true), LOGGER_DEBUG); + if ($x['changed']) { + $guid = random_string() . '@' . App::get_hostname(); + Libzotdir::update_modtime($sender, $r[0]['xchan_guid'], $arr['locations'][0]['address'], UPDATE_FLAGS_UPDATED); + } + } + } + + /** + * @brief Checks for a moved channel and sets the channel_moved flag. + * + * Currently the effect of this flag is to turn the channel into 'read-only' mode. + * New content will not be processed (there was still an issue with blocking the + * ability to post comments as of 10-Mar-2016). + * We do not physically remove the channel at this time. The hub admin may choose + * to do so, but is encouraged to allow a grace period of several days in case there + * are any issues migrating content. This packet will generally be received by the + * original site when the basic channel import has been processed. + * + * This will only be executed on the old location + * if a new location is reported and there is only one location record. + * The rest of the hubloc syncronisation will be handled within + * sync_locations + * + * @param string $sender_hash A channel hash + * @param array $locations + */ + + public static function check_location_move($sender_hash, $locations) + { + + if (!$locations) { + return; + } + + if (count($locations) != 1) { + return; + } + + $loc = $locations[0]; + + $r = q( + "select * from channel where channel_hash = '%s' limit 1", + dbesc($sender_hash) + ); + + if (!$r) { + return; + } + + if ($loc['url'] !== z_root()) { + $x = q( + "update channel set channel_moved = '%s' where channel_hash = '%s' limit 1", + dbesc($loc['url']), + dbesc($sender_hash) + ); + + // federation plugins may wish to notify connections + // of the move on singleton networks + + $arr = [ + 'channel' => $r[0], + 'locations' => $locations + ]; + /** + * @hooks location_move + * Called when a new location has been provided to a UNO channel (indicating a move rather than a clone). + * * \e array \b channel + * * \e array \b locations + */ + Hook::call('location_move', $arr); + } + } + + + /** + * @brief Returns an array with all known distinct hubs for this channel. + * + * @param array $channel an associative array which must contain + * * \e string \b channel_hash the hash of the channel + * @return array an array with associative arrays + * @see self::get_hublocs() + */ + + public static function encode_locations($channel) + { + $ret = []; + + $x = self::get_hublocs($channel['channel_hash']); + + if ($x && count($x)) { + foreach ($x as $hub) { + // if this is a local channel that has been deleted, the hubloc is no good + // - make sure it is marked deleted so that nobody tries to use it. + + if (intval($channel['channel_removed']) && $hub['hubloc_url'] === z_root()) { + $hub['hubloc_deleted'] = 1; + } + + $ret[] = [ + 'host' => $hub['hubloc_host'], + 'address' => $hub['hubloc_addr'], + 'id_url' => $hub['hubloc_id_url'], + 'primary' => (intval($hub['hubloc_primary']) ? true : false), + 'url' => $hub['hubloc_url'], + 'url_sig' => $hub['hubloc_url_sig'], + 'site_id' => $hub['hubloc_site_id'], + 'callback' => $hub['hubloc_callback'], + 'sitekey' => $hub['hubloc_sitekey'], + 'deleted' => (intval($hub['hubloc_deleted']) ? true : false) + ]; + } + } + + return $ret; + } + + + /** + * @brief + * + * @param array $arr + * @param string $pubkey + * @return bool true if updated or inserted + */ + + public static function import_site($arr) + { + + if ((!is_array($arr)) || (!$arr['url']) || (!$arr['site_sig'])) { + return false; + } + + if (!self::verify($arr['url'], $arr['site_sig'], $arr['sitekey'])) { + logger('Bad url_sig'); + return false; + } + + $update = false; + $exists = false; + + $r = q( + "select * from site where site_url = '%s' limit 1", + dbesc($arr['url']) + ); + if ($r) { + $exists = true; + $siterecord = $r[0]; + } + + $site_directory = 0; + if ($arr['directory_mode'] == 'normal') { + $site_directory = DIRECTORY_MODE_NORMAL; + } + if ($arr['directory_mode'] == 'primary') { + $site_directory = DIRECTORY_MODE_PRIMARY; + } + if ($arr['directory_mode'] == 'secondary') { + $site_directory = DIRECTORY_MODE_SECONDARY; + } + if ($arr['directory_mode'] == 'standalone') { + $site_directory = DIRECTORY_MODE_STANDALONE; + } + + $register_policy = 0; + if ($arr['register_policy'] == 'closed') { + $register_policy = REGISTER_CLOSED; + } + if ($arr['register_policy'] == 'open') { + $register_policy = REGISTER_OPEN; + } + if ($arr['register_policy'] == 'approve') { + $register_policy = REGISTER_APPROVE; + } + + $access_policy = 0; + if (array_key_exists('access_policy', $arr)) { + if ($arr['access_policy'] === 'private') { + $access_policy = ACCESS_PRIVATE; + } + if ($arr['access_policy'] === 'paid') { + $access_policy = ACCESS_PAID; + } + if ($arr['access_policy'] === 'free') { + $access_policy = ACCESS_FREE; + } + if ($arr['access_policy'] === 'tiered') { + $access_policy = ACCESS_TIERED; + } + } + + // don't let insecure sites register as public hubs + + if (strpos($arr['url'], 'https://') === false) { + $access_policy = ACCESS_PRIVATE; + } + + if ($access_policy != ACCESS_PRIVATE) { + $x = z_fetch_url($arr['url'] . '/siteinfo.json'); + if (!$x['success']) { + $access_policy = ACCESS_PRIVATE; + } + } + + $site_about = EMPTY_STR; + $site_logo = EMPTY_STR; + $sitename = EMPTY_STR; + + $directory_url = htmlspecialchars(isset($arr['directory_url']) ? $arr['directory_url'] : EMPTY_STR, ENT_COMPAT, 'UTF-8', false); + $url = htmlspecialchars(strtolower($arr['url']), ENT_COMPAT, 'UTF-8', false); + $sellpage = htmlspecialchars($arr['sellpage'], ENT_COMPAT, 'UTF-8', false); + $site_location = htmlspecialchars($arr['location'], ENT_COMPAT, 'UTF-8', false); + $site_realm = htmlspecialchars($arr['realm'], ENT_COMPAT, 'UTF-8', false); + $sitename = htmlspecialchars($arr['sitename'], ENT_COMPAT, 'UTF-8', false); + $site_project = htmlspecialchars($arr['project'], ENT_COMPAT, 'UTF-8', false); + $site_crypto = ((array_key_exists('encryption', $arr) && is_array($arr['encryption'])) ? htmlspecialchars(implode(',', $arr['encryption']), ENT_COMPAT, 'UTF-8', false) : ''); + $site_version = ((array_key_exists('version', $arr)) ? htmlspecialchars($arr['version'], ENT_COMPAT, 'UTF-8', false) : ''); + if (array_key_exists('about', $arr) && $arr['about']) { + $site_about = html2bbcode(purify_html($arr['about'])); + } + if (array_key_exists('logo', $arr) && $arr['logo']) { + $site_logo = $arr['logo']; + } elseif (file_exists('images/' . strtolower($site_project) . '.png')) { + $site_logo = z_root() . '/images/' . strtolower($site_project) . '.png'; + } else { + $site_logo = z_root() . '/images/default_profile_photos/red_koala_trans/300.png'; + } + + set_sconfig($url, 'system', 'about', $site_about); + set_sconfig($url, 'system', 'logo', $site_logo); + set_sconfig($url, 'system', 'sitename', $sitename); + + $site_flags = $site_directory; + + if (array_key_exists('zot', $arr)) { + set_sconfig($arr['url'], 'system', 'zot_version', $arr['zot']); + } + + if ($exists) { + if ( + ($siterecord['site_flags'] != $site_flags) + || ($siterecord['site_access'] != $access_policy) + || ($siterecord['site_directory'] != $directory_url) + || ($siterecord['site_sellpage'] != $sellpage) + || ($siterecord['site_location'] != $site_location) + || ($siterecord['site_register'] != $register_policy) + || ($siterecord['site_project'] != $site_project) + || ($siterecord['site_realm'] != $site_realm) + || ($siterecord['site_crypto'] != $site_crypto) + || ($siterecord['site_version'] != $site_version) + ) { + $update = true; + + // logger('import_site: input: ' . print_r($arr,true)); + // logger('import_site: stored: ' . print_r($siterecord,true)); + + $r = q( + "update site set site_dead = 0, site_location = '%s', site_flags = %d, site_access = %d, site_directory = '%s', site_register = %d, site_update = '%s', site_sellpage = '%s', site_realm = '%s', site_type = %d, site_project = '%s', site_version = '%s', site_crypto = '%s' + where site_url = '%s'", + dbesc($site_location), + intval($site_flags), + intval($access_policy), + dbesc($directory_url), + intval($register_policy), + dbesc(datetime_convert()), + dbesc($sellpage), + dbesc($site_realm), + intval(SITE_TYPE_ZOT), + dbesc($site_project), + dbesc($site_version), + dbesc($site_crypto), + dbesc($url) + ); + if (!$r) { + logger('Update failed. ' . print_r($arr, true)); + } + } else { + // update the timestamp to indicate we communicated with this site + q( + "update site set site_dead = 0, site_update = '%s' where site_url = '%s'", + dbesc(datetime_convert()), + dbesc($url) + ); + } + } else { + $update = true; + + $r = site_store_lowlevel( + [ + 'site_location' => $site_location, + 'site_url' => $url, + 'site_access' => intval($access_policy), + 'site_flags' => intval($site_flags), + 'site_update' => datetime_convert(), + 'site_directory' => $directory_url, + 'site_register' => intval($register_policy), + 'site_sellpage' => $sellpage, + 'site_realm' => $site_realm, + 'site_type' => intval(SITE_TYPE_ZOT), + 'site_project' => $site_project, + 'site_version' => $site_version, + 'site_crypto' => $site_crypto + ] + ); + + if (!$r) { + logger('Record create failed. ' . print_r($arr, true)); + } + } + + return $update; + } + + /** + * @brief Returns path to /rpost + * + * @param array $observer + * * \e string \b xchan_url + * @return string + * @todo We probably should make rpost discoverable. + * + */ + public static function get_rpost_path($observer) + { + if (!$observer) { + return EMPTY_STR; + } + + if (in_array($observer['xchan_network'],['zot6','nomad'])) { + $parsed = parse_url($observer['xchan_url']); + return $parsed['scheme'] . '://' . $parsed['host'] . (($parsed['port']) ? ':' . $parsed['port'] : '') . '/rpost?f='; + } + return EMPTY_STR; + } + + /** + * @brief + * + * @param array $x + * @return bool|string return false or a hash + */ + + public static function import_author_zot($x) + { + + // Check that we have both a hubloc and xchan record - as occasionally storage calls will fail and + // we may only end up with one; which results in posts with no author name or photo and are a bit + // of a hassle to repair. If either or both are missing, do a full discovery probe. + + if (!array_key_exists('id', $x)) { + return import_author_activitypub($x); + } + + $hash = self::make_xchan_hash($x['id'], $x['key']); + + $desturl = $x['url']; + + $found_primary = false; + + $r1 = q( + "select hubloc_url, hubloc_updated, site_dead from hubloc left join site on + hubloc_url = site_url where hubloc_guid = '%s' and hubloc_guid_sig = '%s' and hubloc_primary = 1 limit 1", + dbesc($x['id']), + dbesc($x['id_sig']) + ); + if ($r1) { + $found_primary = true; + } + + $r2 = q( + "select xchan_hash from xchan where xchan_guid = '%s' and xchan_guid_sig = '%s' limit 1", + dbesc($x['id']), + dbesc($x['id_sig']) + ); + + $primary_dead = false; + + if ($r1 && intval($r1[0]['site_dead'])) { + $primary_dead = true; + } + + // We have valid and somewhat fresh information. Always true if it is our own site. + + if ($r1 && $r2 && ($r1[0]['hubloc_updated'] > datetime_convert('UTC', 'UTC', 'now - 1 week') || $r1[0]['hubloc_url'] === z_root())) { + logger('in cache', LOGGER_DEBUG); + return $hash; + } + + logger('not in cache or cache stale - probing: ' . print_r($x, true), LOGGER_DEBUG, LOG_INFO); + + // The primary hub may be dead. Try to find another one associated with this identity that is + // still alive. If we find one, use that url for the discovery/refresh probe. Otherwise, the dead site + // is all we have and there is no point probing it. Just return the hash indicating we have a + // cached entry and the identity is valid. It's just unreachable until they bring back their + // server from the grave or create another clone elsewhere. + + if ($primary_dead || !$found_primary) { + logger('dead or site - ignoring', LOGGER_DEBUG, LOG_INFO); + + $r = q( + "select hubloc_id_url from hubloc left join site on hubloc_url = site_url + where hubloc_hash = '%s' and site_dead = 0", + dbesc($hash) + ); + if ($r) { + logger('found another site that is not dead: ' . $r[0]['hubloc_id_url'], LOGGER_DEBUG, LOG_INFO); + $desturl = $r[0]['hubloc_id_url']; + } else { + return $hash; + } + } + + $them = ['hubloc_id_url' => $desturl]; + if (self::refresh($them)) { + return $hash; + } + + return false; + } + + public static function zotinfo($arr) + { + + $ret = []; + + $zhash = ((x($arr, 'guid_hash')) ? $arr['guid_hash'] : ''); + $zguid = ((x($arr, 'guid')) ? $arr['guid'] : ''); + $zguid_sig = ((x($arr, 'guid_sig')) ? $arr['guid_sig'] : ''); + $zaddr = ((x($arr, 'address')) ? $arr['address'] : ''); + $ztarget = ((x($arr, 'target_url')) ? $arr['target_url'] : ''); + $zsig = ((x($arr, 'target_sig')) ? $arr['target_sig'] : ''); + $zkey = ((x($arr, 'key')) ? $arr['key'] : ''); + $mindate = ((x($arr, 'mindate')) ? $arr['mindate'] : ''); + $token = ((x($arr, 'token')) ? $arr['token'] : ''); + $feed = ((x($arr, 'feed')) ? intval($arr['feed']) : 0); + + if ($ztarget) { + $t = q("select * from hubloc where hubloc_id_url = '%s' and hubloc_network in ('nomad','zot6') limit 1", + dbesc($ztarget) + ); + if ($t) { + $ztarget_hash = $t[0]['hubloc_hash']; + } else { + // should probably perform discovery of the requestor (target) but if they actually had + // permissions we would know about them and we only want to know who they are to + // enumerate their specific permissions + + $ztarget_hash = EMPTY_STR; + } + } + + $r = null; + + if (strlen($zhash)) { + $r = q( + "select channel.*, xchan.* from channel left join xchan on channel_hash = xchan_hash + where channel_hash = '%s' limit 1", + dbesc($zhash) + ); + } elseif (strlen($zguid) && strlen($zguid_sig)) { + $r = q( + "select channel.*, xchan.* from channel left join xchan on channel_hash = xchan_hash + where channel_guid = '%s' and channel_guid_sig = '%s' limit 1", + dbesc($zguid), + dbesc($zguid_sig) + ); + } elseif (strlen($zaddr)) { + if (strpos($zaddr, '[system]') === false) { /* normal address lookup */ + $r = q( + "select channel.*, xchan.* from channel left join xchan on channel_hash = xchan_hash + where ( channel_address = '%s' or xchan_addr = '%s' ) limit 1", + dbesc($zaddr), + dbesc($zaddr) + ); + } else { + + /** + * The special address '[system]' will return a system channel if one has been defined, + * Or the first valid channel we find if there are no system channels. + * + * This is used by magic-auth if we have no prior communications with this site - and + * returns an identity on this site which we can use to create a valid hub record so that + * we can exchange signed messages. The precise identity is irrelevant. It's the hub + * information that we really need at the other end - and this will return it. + * + */ + + $r = q("select channel.*, xchan.* from channel left join xchan on channel_hash = xchan_hash + where channel_system = 1 order by channel_id limit 1"); + if (!$r) { + $r = q("select channel.*, xchan.* from channel left join xchan on channel_hash = xchan_hash + where channel_removed = 0 order by channel_id limit 1"); + } + } + } else { + $ret['message'] = 'Invalid request'; + return ($ret); + } + + if (!$r) { + $ret['message'] = 'Item not found.'; + return ($ret); + } + + $e = $r[0]; + + $id = $e['channel_id']; + + $sys_channel = (intval($e['channel_system']) ? true : false); + $special_channel = (($e['channel_pageflags'] & PAGE_PREMIUM) ? true : false); + $adult_channel = (($e['channel_pageflags'] & PAGE_ADULT) ? true : false); + $censored = (($e['channel_pageflags'] & PAGE_CENSORED) ? true : false); + $searchable = (($e['channel_pageflags'] & PAGE_HIDDEN) ? false : true); + $deleted = (intval($e['xchan_deleted']) ? true : false); + + if ($deleted || $censored || $sys_channel) { + $searchable = false; + } + + // now all forums (public, restricted, and private) set the public_forum flag. So it really means "is a group" + // and has nothing to do with accessibility. + + $role = get_pconfig($e['channel_id'], 'system', 'permissions_role'); + $rolesettings = PermissionRoles::role_perms($role); + + $channel_type = isset($rolesettings['channel_type']) ? $rolesettings['channel_type'] : 'normal'; + + // This is for birthdays and keywords, but must check access permissions + $p = q( + "select * from profile where uid = %d and is_default = 1", + intval($e['channel_id']) + ); + + $profile = []; + + if ($p) { + if (!intval($p[0]['publish'])) { + $searchable = false; + } + + $profile['description'] = $p[0]['pdesc']; + $profile['birthday'] = $p[0]['dob']; + if (($profile['birthday'] != '0000-00-00') && (($bd = z_birthday($p[0]['dob'], $e['channel_timezone'])) !== '')) { + $profile['next_birthday'] = $bd; + } + + if ($age = age($p[0]['dob'], $e['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'] = ((Channel::is_system($e['channel_id'])) ? get_config('system', 'siteinfo') : $p[0]['about']); + $profile['homepage'] = $p[0]['homepage']; + $profile['hometown'] = $p[0]['hometown']; + + if ($p[0]['keywords']) { + $tags = []; + $k = explode(' ', $p[0]['keywords']); + if ($k) { + foreach ($k as $kk) { + if (trim($kk, " \t\n\r\0\x0B,")) { + $tags[] = trim($kk, " \t\n\r\0\x0B,"); + } + } + } + if ($tags) { + $profile['keywords'] = $tags; + } + } + } + + $cover_photo = Channel::get_cover_photo($e['channel_id'], 'array'); + + // Communication details + + $ret['id'] = $e['xchan_guid']; + $ret['id_sig'] = self::sign($e['xchan_guid'], $e['channel_prvkey']); + + $ret['primary_location'] = [ + 'address' => $e['xchan_addr'], + 'url' => $e['xchan_url'], + 'connections_url' => $e['xchan_connurl'], + 'follow_url' => $e['xchan_follow'], + 'wall' => z_root() . '/outbox/' . $e['channel_address'], + 'followers' => z_root() . '/followers/' . $e['channel_address'], + 'following' => z_root() . '/following/' . $e['channel_address'] + ]; + + $ret['public_key'] = $e['xchan_pubkey']; + $ret['signing_algorithm'] = 'rsa-sha256'; + $ret['username'] = $e['channel_address']; + $ret['name'] = $e['xchan_name']; + $ret['name_updated'] = $e['xchan_name_date']; + $ret['photo'] = [ + 'url' => $e['xchan_photo_l'], + 'type' => $e['xchan_photo_mimetype'], + 'updated' => $e['xchan_photo_date'] + ]; + + if ($cover_photo) { + $ret['cover_photo'] = [ + 'url' => $cover_photo['url'], + 'type' => $cover_photo['type'], + 'updated' => $cover_photo['updated'] + ]; + } + + $ret['channel_role'] = get_pconfig($e['channel_id'], 'system', 'permissions_role', 'custom'); + $ret['channel_type'] = $channel_type; + $ret['protocols'] = ['nomad','zot6']; + if (get_pconfig($e['channel_id'], 'system', 'activitypub', get_config('system', 'activitypub', ACTIVITYPUB_ENABLED))) { + $ret['protocols'][] = 'activitypub'; + } + $ret['searchable'] = $searchable; + $ret['adult_content'] = $adult_channel; + + $ret['comments'] = map_scope(PermissionLimits::Get($e['channel_id'], 'post_comments')); + + if ($deleted) { + $ret['deleted'] = $deleted; + } + + if (intval($e['channel_removed'])) { + $ret['deleted_locally'] = true; + } + + // premium or other channel desiring some contact with potential followers before connecting. + // This is a template - %s will be replaced with the follow_url we discover for the return channel. + + if ($special_channel) { + $ret['connect_url'] = (($e['xchan_connpage']) ? $e['xchan_connpage'] : z_root() . '/connect/' . $e['channel_address']); + } + + // This is a template for our follow url, %s will be replaced with a webbie + if (!isset($ret['follow_url'])) { + $ret['follow_url'] = z_root() . '/follow?f=&url=%s'; + } + + $permissions = get_all_perms($e['channel_id'], $ztarget_hash, false); + + if ($ztarget_hash) { + $permissions['connected'] = false; + $b = q( + "select * from abook where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($ztarget_hash), + intval($e['channel_id']) + ); + if ($b) { + $permissions['connected'] = true; + } + } + + if ($permissions['view_profile']) { + $ret['profile'] = $profile; + } + + + $concise_perms = []; + if ($permissions) { + foreach ($permissions as $k => $v) { + if ($v) { + $concise_perms[] = $k; + } + } + $permissions = implode(',', $concise_perms); + } + + $ret['permissions'] = $permissions; + $ret['permissions_for'] = $ztarget; + + + // array of (verified) hubs this channel uses + + $x = self::encode_locations($e); + if ($x) { + $ret['locations'] = $x; + } + $ret['site'] = self::site_info(); + + Hook::call('zotinfo', $ret); + + return ($ret); + } + + + public static function site_info($force = false) + { + + $signing_key = get_config('system', 'prvkey'); + $sig_method = get_config('system', 'signature_algorithm', 'sha256'); + + $ret = []; + $ret['site'] = []; + $ret['site']['url'] = z_root(); + $ret['site']['site_sig'] = self::sign(z_root(), $signing_key); + $ret['site']['post'] = z_root() . '/zot'; + $ret['site']['openWebAuth'] = z_root() . '/owa'; + $ret['site']['authRedirect'] = z_root() . '/magic'; + $ret['site']['sitekey'] = get_config('system', 'pubkey'); + + $dirmode = get_config('system', 'directory_mode'); + if (($dirmode === false) || ($dirmode == DIRECTORY_MODE_NORMAL)) { + $ret['site']['directory_mode'] = 'normal'; + } + + if ($dirmode == DIRECTORY_MODE_PRIMARY) { + $ret['site']['directory_mode'] = 'primary'; + } elseif ($dirmode == DIRECTORY_MODE_SECONDARY) { + $ret['site']['directory_mode'] = 'secondary'; + } elseif ($dirmode == DIRECTORY_MODE_STANDALONE) { + $ret['site']['directory_mode'] = 'standalone'; + } + if ($dirmode != DIRECTORY_MODE_NORMAL) { + $ret['site']['directory_url'] = z_root() . '/dirsearch'; + } + + + $ret['site']['encryption'] = Crypto::methods(); + $ret['site']['zot'] = System::get_zot_revision(); + + // hide detailed site information if you're off the grid + + if ($dirmode != DIRECTORY_MODE_STANDALONE || $force) { + $register_policy = intval(get_config('system', 'register_policy')); + + if ($register_policy == REGISTER_CLOSED) { + $ret['site']['register_policy'] = 'closed'; + } + if ($register_policy == REGISTER_APPROVE) { + $ret['site']['register_policy'] = 'approve'; + } + if ($register_policy == REGISTER_OPEN) { + $ret['site']['register_policy'] = 'open'; + } + + $access_policy = intval(get_config('system', 'access_policy')); + + if ($access_policy == ACCESS_PRIVATE) { + $ret['site']['access_policy'] = 'private'; + } + if ($access_policy == ACCESS_PAID) { + $ret['site']['access_policy'] = 'paid'; + } + if ($access_policy == ACCESS_FREE) { + $ret['site']['access_policy'] = 'free'; + } + if ($access_policy == ACCESS_TIERED) { + $ret['site']['access_policy'] = 'tiered'; + } + + $ret['site']['admin'] = get_config('system', 'admin_email'); + + $visible_plugins = []; + + $r = q("select * from addon where hidden = 0"); + if ($r) { + foreach ($r as $rr) { + $visible_plugins[] = $rr['aname']; + } + } + + + $ret['site']['about'] = bbcode(get_config('system', 'siteinfo'), ['export' => true]); + $ret['site']['plugins'] = $visible_plugins; + $ret['site']['sitehash'] = get_config('system', 'location_hash'); + $ret['site']['sellpage'] = get_config('system', 'sellpage'); + $ret['site']['location'] = get_config('system', 'site_location'); + $ret['site']['realm'] = get_directory_realm(); + $ret['site']['sitename'] = System::get_site_name(); + $ret['site']['logo'] = System::get_site_icon(); + $ret['site']['project'] = System::get_project_name(); + $ret['site']['version'] = System::get_project_version(); + } + + return $ret['site']; + } + + /** + * @brief + * + * @param array $hub + * @param string $sitekey (optional, default empty) + * + * @return string hubloc_url + */ + + public static function update_hub_connected($hub, $site_id = '') + { + + if ($site_id) { + /* + * This hub has now been proven to be valid. + * Any hub with the same URL and a different sitekey cannot be valid. + * Get rid of them (mark them deleted). There's a good chance they were re-installs. + */ + + q( + "update hubloc set hubloc_deleted = 1, hubloc_error = 1 where hubloc_hash = '%s' and hubloc_url = '%s' and hubloc_site_id != '%s' ", + dbesc($hub['hubloc_hash']), + dbesc($hub['hubloc_url']), + dbesc($site_id) + ); + } else { + $site_id = $hub['hubloc_site_id']; + } + + // $sender['sitekey'] is a new addition to the protocol to distinguish + // hublocs coming from re-installed sites. Older sites will not provide + // this field and we have to still mark them valid, since we can't tell + // if this hubloc has the same sitekey as the packet we received. + // Update our DB to show when we last communicated successfully with this hub + // This will allow us to prune dead hubs from using up resources + + $t = datetime_convert('UTC', 'UTC', 'now - 15 minutes'); + + $r = q( + "update hubloc set hubloc_connected = '%s' where hubloc_id = %d and hubloc_site_id = '%s' and hubloc_connected < '%s' ", + dbesc(datetime_convert()), + intval($hub['hubloc_id']), + dbesc($site_id), + dbesc($t) + ); + + // a dead hub came back to life - reset any tombstones we might have + + if (intval($hub['hubloc_error']) || intval($hub['hubloc_deleted'])) { + q( + "update hubloc set hubloc_error = 0, hubloc_deleted = 0 where hubloc_id = %d and hubloc_site_id = '%s' ", + intval($hub['hubloc_id']), + dbesc($site_id) + ); + if (intval($hub['hubloc_orphancheck'])) { + q( + "update hubloc set hubloc_orphancheck = 0 where hubloc_id = %d and hubloc_site_id = '%s' ", + intval($hub['hubloc_id']), + dbesc($site_id) + ); + } + q( + "update xchan set xchan_orphan = 0 where xchan_orphan = 1 and xchan_hash = '%s'", + dbesc($hub['hubloc_hash']) + ); + } + + // this site obviously isn't dead because they are trying to communicate with us. + q( + "update site set site_dead = 0 where site_dead = 1 and site_url = '%s' ", + dbesc($hub['hubloc_url']) + ); + + return $hub['hubloc_url']; + } + + + public static function sign($data, $key, $alg = 'sha256') + { + if (!$key) { + return 'no key'; + } + $sig = ''; + openssl_sign($data, $sig, $key, $alg); + return $alg . '.' . base64url_encode($sig); + } + + public static function verify($data, $sig, $key) + { + + $verify = 0; + + $x = explode('.', $sig, 2); + + if ($key && count($x) === 2) { + $alg = $x[0]; + $signature = base64url_decode($x[1]); + + $verify = @openssl_verify($data, $signature, $key, $alg); + + if ($verify === (-1)) { + while ($msg = openssl_error_string()) { + logger('openssl_verify: ' . $msg, LOGGER_NORMAL, LOG_ERR); + } + btlogger('openssl_verify: key: ' . $key, LOGGER_DEBUG, LOG_ERR); + } + } + return (($verify > 0) ? true : false); + } + + + public static function is_zot_request() + { + $x = getBestSupportedMimeType([ 'application/x-zot+json', 'application/x-nomad+json' ]); + return (($x) ? true : false); + } + + + public static function zot_record_preferred($arr, $check = 'hubloc_network') + { + + if (!$arr) { + return $arr; + } + foreach ($arr as $v) { + if($v[$check] === 'nomad') { + return $v; + } + } + foreach ($arr as $v) { + if ($v[$check] === 'zot6') { + return $v; + } + } + return $arr[0]; + } + + public static function update_cached_hubloc($hubloc) + { + if ($hubloc['hubloc_updated'] > datetime_convert('UTC', 'UTC', 'now - 1 week') || $hubloc['hubloc_url'] === z_root()) { + return; + } + self::refresh(['hubloc_id_url' => $hubloc['hubloc_id_url']]); + } +} diff --git a/Code/Lib/Libzotdir.php b/Code/Lib/Libzotdir.php new file mode 100644 index 000000000..3e3c49860 --- /dev/null +++ b/Code/Lib/Libzotdir.php @@ -0,0 +1,543 @@ + 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('Groups Only'), (($pubforums == 1) ? true : false), '', array(t('No'), t('Yes')), ' onchange=\'window.location.href="' . $forumsurl . '&type="+(this.checked ? 1 : 0)\''), +// '$collections' => array('collections', t('Collections Only'),(($pubforums == 2) ? true : false),'',array(t('No'), t('Yes')),' onchange=\'window.location.href="' . $forumsurl . '&type="+(this.checked ? 2 : 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)\''), + '$activedir' => array('activedir', t('Recently Updated'), intval($activedir), '', array(t('No'), t('Yes')), ' onchange=\'window.location.href="' . $forumsurl . '&active="+(this.checked ? 1 : 0)\''), + ]); + + return $o; + } + + /** + * @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_last in the DB to the current datetime for this + * reddress/webbie. + * + * @param array $ud Entry from update table + */ + + public static function update_directory_entry($ud) + { + + logger('update_directory_entry: ' . print_r($ud, true), LOGGER_DATA); + + if ($ud['ud_addr'] && (!($ud['ud_flags'] & UPDATE_FLAGS_DELETED))) { + $success = false; + + $href = Webfinger::zot_url(punify($ud['ud_addr'])); + if ($href) { + $zf = Zotfinger::exec($href); + } + if (is_array($zf) && array_path_exists('signature/signer', $zf) && $zf['signature']['signer'] === $href && intval($zf['signature']['header_valid'])) { + $xc = Libzot::import_xchan($zf['data'], 0, $ud); + } else { + q( + "update updates set ud_last = '%s' where ud_addr = '%s'", + dbesc(datetime_convert()), + dbesc($ud['ud_addr']) + ); + } + } + } + + + /** + * @brief Push local channel updates to a local directory server. + * + * This is called from Code/Daemon/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 bool $force + */ + + public static function local_dir_update($uid, $force) + { + + + logger('local_dir_update: uid: ' . $uid, LOGGER_DEBUG); + + $p = q( + "select channel_hash, channel_address, channel_timezone, profile.* from profile left join channel on channel_id = uid where uid = %d and is_default = 1", + intval($uid) + ); + + $profile = []; + $profile['encoding'] = 'zot'; + + if ($p) { + $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 = []; + $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); + + $r = q( + "select xchan_hidden from xchan where xchan_hash = '%s' limit 1", + dbesc($p[0]['channel_hash']) + ); + + if (intval($r[0]['xchan_hidden']) != $hidden) { + $r = q( + "update xchan set xchan_hidden = %d where xchan_hash = '%s'", + intval($hidden), + dbesc($p[0]['channel_hash']) + ); + } + + $arr = ['channel_id' => $uid, 'hash' => $hash, 'profile' => $profile]; + Hook::call('local_dir_update', $arr); + + $address = Channel::get_webfinger($p[0]); + + if (perm_is_allowed($uid, '', 'view_profile')) { + self::import_directory_profile($hash, $arr['profile'], $address, 0); + } else { + // they may have made it private + $r = q( + "delete from xprof where xprof_hash = '%s'", + dbesc($hash) + ); + $r = q( + "delete from xtag where xtag_hash = '%s'", + dbesc($hash) + ); + } + } + + $ud_hash = random_string() . '@' . App::get_hostname(); + self::update_modtime($hash, $ud_hash, Channel::get_webfinger($p[0]), (($force) ? UPDATE_FLAGS_FORCED : UPDATE_FLAGS_UPDATED)); + } + + + /** + * @brief Imports a directory profile. + * + * @param string $hash + * @param array $profile + * @param string $addr + * @param number $ud_flags (optional) UPDATE_FLAGS_UPDATED + * @param number $suppress_update (optional) default 0 + * @return bool $updated if something changed + */ + + public static function import_directory_profile($hash, $profile, $addr, $ud_flags = UPDATE_FLAGS_UPDATED, $suppress_update = 0) + { + + logger('import_directory_profile', LOGGER_DEBUG); + if (!$hash) { + return false; + } + + + $maxlen = get_max_import_size(); + + if ($maxlen && mb_strlen($profile['about']) > $maxlen) { + $profile['about'] = mb_substr($profile['about'], 0, $maxlen, 'UTF-8'); + } + + $arr = []; + + $arr['xprof_hash'] = $hash; + 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']) ? 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_pronouns'] = ((isset($profile['pronouns']) && $profile['pronouns']) ? htmlspecialchars($profile['pronouns'], 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 = []; + if (array_key_exists('keywords', $profile) and is_array($profile['keywords'])) { + self::import_directory_keywords($hash, $profile['keywords']); + foreach ($profile['keywords'] as $kw) { + $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', + xprof_pronouns = '%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_pronouns']), + 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, xprof_pronouns) values ('%s', '%s', '%s', %d, '%s', '%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']), + dbesc($arr['xprof_pronouns']) + ); + } + + $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 + */ + + Hook::call('import_directory_profile', $d); + + if (($d['update']) && (!$suppress_update)) { + self::update_modtime($arr['xprof_hash'], new_uuid(), $addr, $ud_flags); + } + + q( + "update xchan set xchan_updated = '%s' where xchan_hash = '%s'", + dbesc(datetime_convert()), + dbesc($arr['xprof_hash']) + ); + + return $d['update']; + } + + /** + * @brief + * + * @param string $hash An xtag_hash + * @param array $keywords + */ + + public static function import_directory_keywords($hash, $keywords) + { + + $existing = []; + $r = q( + "select * from xtag where xtag_hash = '%s' and xtag_flags = 0", + dbesc($hash) + ); + + if ($r) { + foreach ($r as $rr) { + $existing[] = $rr['xtag_term']; + } + } + + $clean = []; + foreach ($keywords as $kw) { + $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', 0 )", + dbesc($hash), + dbesc($x) + ); + } + } + } + + + /** + * @brief + * + * @param string $hash + * @param string $guid + * @param string $addr + * @param int $flags (optional) default 0 + */ + + public static function update_modtime($hash, $guid, $addr, $flags = 0) + { + + $dirmode = intval(get_config('system', 'directory_mode')); + + if ($dirmode == DIRECTORY_MODE_NORMAL) { + return; + } + + if ($flags) { + q( + "insert into updates (ud_hash, ud_guid, ud_date, ud_flags, ud_addr ) values ( '%s', '%s', '%s', %d, '%s' )", + dbesc($hash), + dbesc($guid), + dbesc(datetime_convert()), + intval($flags), + dbesc($addr) + ); + } else { + q( + "update updates set ud_flags = ( ud_flags | %d ) where ud_addr = '%s' and (ud_flags & %d) = 0 ", + intval(UPDATE_FLAGS_UPDATED), + dbesc($addr), + intval(UPDATE_FLAGS_UPDATED) + ); + } + } +} diff --git a/Code/Lib/Markdown.php b/Code/Lib/Markdown.php new file mode 100644 index 000000000..3070024ca --- /dev/null +++ b/Code/Lib/Markdown.php @@ -0,0 +1,414 @@ + $s, + 'zrl' => $use_zrl, + 'options' => $options + ]; + + /** + * @hooks markdown_to_bb_init + * * \e string \b text - The message as Markdown and what will get returned + * * \e boolean \b zrl + * * \e array \b options + */ + Hook::call('markdown_to_bb_init', $x); + + $s = $x['text']; + + // Escaping the hash tags + $s = preg_replace('/\#([^\s\#])/', '#$1', $s); + + $s = MarkdownExtra::defaultTransform($s); + + if ($options && $options['preserve_lf']) { + $s = str_replace(["\r", "\n"], ["", '
'], $s); + } else { + $s = str_replace("\r", "", $s); + } + + $s = str_replace('#', '#', $s); + + $s = html2bbcode($s); + + // Convert everything that looks like a link to a link + if ($use_zrl) { + if (strpos($s, '[/img]') !== false) { + $s = preg_replace_callback("/\[img\](.*?)\[\/img\]/ism", ['\\Code\\Lib\\Markdown', 'use_zrl_cb_img'], $s); + $s = preg_replace_callback("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", ['\\Code\\Lib\\Markdown', 'use_zrl_cb_img_x'], $s); + } + $s = preg_replace_callback("/([^\]\=\{\/]|^)(https?\:\/\/)([a-zA-Z0-9\pL\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,\@\(\)]+)/ismu", ['\\Code\\Lib\\Markdown', 'use_zrl_cb_link'], $s); + } else { + $s = preg_replace("/([^\]\=\{\/]|^)(https?\:\/\/)([a-zA-Z0-9\pL\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,\@\(\)]+)/ismu", '$1[url=$2$3]$2$3[/url]', $s); + } + + // remove duplicate adjacent code tags + $s = preg_replace("/(\[code\])+(.*?)(\[\/code\])+/ism", "[code]$2[/code]", $s); + + /** + * @hooks markdown_to_bb + * * \e string - The already converted message as bbcode + */ + Hook::call('markdown_to_bb', $s); + + return $s; + } + + public static function use_zrl_cb_link($match) + { + $res = ''; + $is_zid = is_matrix_url(trim($match[0])); + + if ($is_zid) { + $res = $match[1] . '[zrl=' . $match[2] . $match[3] . ']' . $match[2] . $match[3] . '[/zrl]'; + } else { + $res = $match[1] . '[url=' . $match[2] . $match[3] . ']' . $match[2] . $match[3] . '[/url]'; + } + + return $res; + } + + public static function use_zrl_cb_img($match) + { + $res = ''; + $is_zid = is_matrix_url(trim($match[1])); + + if ($is_zid) { + $res = '[zmg]' . $match[1] . '[/zmg]'; + } else { + $res = $match[0]; + } + + return $res; + } + + public static function use_zrl_cb_img_x($match) + { + $res = ''; + $is_zid = is_matrix_url(trim($match[3])); + + if ($is_zid) { + $res = '[zmg=' . $match[1] . 'x' . $match[2] . ']' . $match[3] . '[/zmg]'; + } else { + $res = $match[0]; + } + + return $res; + } + + /** + * @brief + * + * @param array $match + * @return string + */ + + public static function from_bbcode_share($match) + { + + $matches = []; + $attributes = $match[1]; + + $author = ""; + preg_match("/author='(.*?)'/ism", $attributes, $matches); + if ($matches[1] != "") { + $author = urldecode($matches[1]); + } + + $link = ""; + preg_match("/link='(.*?)'/ism", $attributes, $matches); + if ($matches[1] != "") { + $link = $matches[1]; + } + + $avatar = ""; + preg_match("/avatar='(.*?)'/ism", $attributes, $matches); + if ($matches[1] != "") { + $avatar = $matches[1]; + } + + $profile = ""; + preg_match("/profile='(.*?)'/ism", $attributes, $matches); + if ($matches[1] != "") { + $profile = $matches[1]; + } + + $posted = ""; + preg_match("/posted='(.*?)'/ism", $attributes, $matches); + if ($matches[1] != "") { + $posted = $matches[1]; + } + + // message_id is never used, do we still need it? + $message_id = ""; + preg_match("/message_id='(.*?)'/ism", $attributes, $matches); + if ($matches[1] != "") { + $message_id = $matches[1]; + } + + if (!$message_id) { + preg_match("/guid='(.*?)'/ism", $attributes, $matches); + if ($matches[1] != "") { + $message_id = $matches[1]; + } + } + + + $reldate = datetime_convert('UTC', date_default_timezone_get(), $posted, 'r'); + + $headline = ''; + + if ($avatar != "") { + $headline .= '[url=' . zid($profile) . '][img]' . $avatar . '[/img][/url]'; + } + + // Bob Smith wrote the following post 2 hours ago + + $fmt = sprintf( + t('%1$s wrote the following %2$s %3$s'), + '[url=' . zid($profile) . ']' . $author . '[/url]', + '[url=' . zid($link) . ']' . t('post') . '[/url]', + $reldate + ); + + $headline .= $fmt . "\n\n"; + + $text = $headline . trim($match[2]); + + return $text; + } + + + /** + * @brief Convert bbcode to Markdown. + * + * @param string $Text The message as bbcode + * @param array $options default empty + * @return string The message converted to Markdown + */ + + public static function from_bbcode($Text, $options = []) + { + + /* + * Transform #tags, strip off the [url] and replace spaces with underscore + */ + + $Text = preg_replace_callback( + '/#\[([zu])rl\=(.*?)\](.*?)\[\/[(zu)]rl\]/i', + create_function('$match', 'return \'#\'. str_replace(\' \', \'_\', $match[3]);'), + $Text + ); + + $Text = preg_replace('/#\^\[([zu])rl\=(.*?)\](.*?)\[\/([zu])rl\]/i', '[$1rl=$2]$3[/$4rl]', $Text); + + // Converting images with size parameters to simple images. Markdown doesn't know it. + $Text = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $Text); + + $Text = preg_replace_callback("/\[share(.*?)\](.*?)\[\/share\]/ism", ['\\Code\\Lib\\Markdown', 'from_bbcode_share'], $Text); + + $x = ['bbcode' => $Text, 'options' => $options]; + + /** + * @hooks bb_to_markdown_bb + * * \e string \b bbcode - The message as bbcode and what will get returned + * * \e array \b options + */ + Hook::call('bb_to_markdown_bb', $x); + + $Text = $x['bbcode']; + + // Convert it to HTML - don't try oembed + $Text = bbcode($Text, ['tryoembed' => false]); + + // Now convert HTML to Markdown + + $Text = self::from_html($Text); + + //html2markdown adds backslashes infront of hashes after a new line. remove them + $Text = str_replace("\n\#", "\n#", $Text); + + + // If the text going into bbcode() has a plain URL in it, i.e. + // with no [url] tags around it, it will come out of parseString() + // looking like: , which gets removed by strip_tags(). + // So take off the angle brackets of any such URL + $Text = preg_replace("//is", "http$1", $Text); + + // Remove empty zrl links + $Text = preg_replace("/\[zrl\=\].*?\[\/zrl\]/is", "", $Text); + + $Text = trim($Text); + + /** + * @hooks bb_to_markdown + * * \e string - The already converted message as bbcode and what will get returned + */ + Hook::call('bb_to_markdown', $Text); + + return $Text; + } + + + /** + * @brief Convert a HTML text into Markdown. + * + * This function uses the library league/html-to-markdown for this task. + * + * If the HTML text can not get parsed it will return an empty string. + * + * @param string $html The HTML code to convert + * @return string Markdown representation of the given HTML text, empty on error + */ + + public static function from_html($html, $options = []) + { + $markdown = ''; + + if (!$options) { + $options = [ + 'header_style' => 'setext', // Set to 'atx' to output H1 and H2 headers as # Header1 and ## Header2 + 'suppress_errors' => true, // Set to false to show warnings when loading malformed HTML + 'strip_tags' => false, // Set to true to strip tags that don't have markdown equivalents. N.B. Strips tags, not their content. Useful to clean MS Word HTML output. + 'bold_style' => '**', // DEPRECATED: Set to '__' if you prefer the underlined style + 'italic_style' => '*', // DEPRECATED: Set to '_' if you prefer the underlined style + 'remove_nodes' => '', // space-separated list of dom nodes that should be removed. example: 'meta style script' + 'hard_break' => false, // Set to true to turn
into `\n` instead of ` \n` + 'list_item_style' => '-', // Set the default character for each
  • in a
      . Can be '-', '*', or '+' + ]; + } + + $environment = Environment::createDefaultEnvironment($options); + $environment->addConverter(new TableConverter()); + $converter = new HtmlConverter($environment); + + try { + $markdown = $converter->convert($html); + } catch (InvalidArgumentException $e) { + logger("Invalid HTML. HTMLToMarkdown library threw an exception."); + } + + return $markdown; + } +} + +// Tables are not an official part of the markdown specification. +// This interface was suggested as a workaround. +// author: Mark Hamstra +// https://github.com/Mark-H/Docs + + +class TableConverter implements ConverterInterface +{ + /** + * @param ElementInterface $element + * + * @return string + */ + public function convert(ElementInterface $element) + { + switch ($element->getTagName()) { + case 'tr': + $line = []; + $i = 1; + foreach ($element->getChildren() as $td) { + $i++; + $v = $td->getValue(); + $v = trim($v); + if ($i % 2 === 0 || $v !== '') { + $line[] = $v; + } + } + return '| ' . implode(' | ', $line) . " |\n"; + case 'td': + case 'th': + return trim($element->getValue()); + case 'tbody': + return trim($element->getValue()); + case 'thead': + $headerLine = reset($element->getChildren())->getValue(); + $headers = explode(' | ', trim(trim($headerLine, "\n"), '|')); + $hr = []; + foreach ($headers as $td) { + $length = strlen(trim($td)) + 2; + $hr[] = str_repeat('-', $length > 3 ? $length : 3); + } + $hr = '|' . implode('|', $hr) . '|'; + return $headerLine . $hr . "\n"; + case 'table': + $inner = $element->getValue(); + if (strpos($inner, '-----') === false) { + $inner = explode("\n", $inner); + $single = explode(' | ', trim($inner[0], '|')); + $hr = []; + foreach ($single as $td) { + $length = strlen(trim($td)) + 2; + $hr[] = str_repeat('-', $length > 3 ? $length : 3); + } + $hr = '|' . implode('|', $hr) . '|'; + array_splice($inner, 1, 0, $hr); + $inner = implode("\n", $inner); + } + return trim($inner) . "\n\n"; + } + return $element->getValue(); + } + /** + * @return string[] + */ + public function getSupportedTags() + { + return array('table', 'tr', 'thead', 'td', 'tbody'); + } +} diff --git a/Code/Lib/MarkdownSoap.php b/Code/Lib/MarkdownSoap.php new file mode 100644 index 000000000..de59234f0 --- /dev/null +++ b/Code/Lib/MarkdownSoap.php @@ -0,0 +1,147 @@ +clean(); + * @endcode + * What this does: + * 1. extracts code blocks and privately escapes them from processing + * 2. Run html purifier on the content + * 3. put back the code blocks + * 4. run htmlspecialchars on the entire content for safe storage + * + * At render time: + * @code{.php} + * $markdown = \Code\Lib\MarkdownSoap::unescape($text); + * $html = \Michelf\MarkdownExtra::DefaultTransform($markdown); + * @endcode + */ +class MarkdownSoap +{ + + /** + * @var string + */ + private $str; + /** + * @var string + */ + private $token; + + + public function __construct($s) + { + $this->str = $s; + $this->token = random_string(20); + } + + public function clean() + { + + $x = $this->extract_code($this->str); + + $x = $this->purify($x); + + $x = $this->putback_code($x); + + $x = $this->escape($x); + + return $x; + } + + /** + * @brief Extracts code blocks and privately escapes them from processing. + * + * @param string $s + * @return string + * @see encode_code() + * @see putback_code() + * + */ + public function extract_code($s) + { + + $text = preg_replace_callback( + '{ + (?:\n\n|\A\n?) + ( # $1 = the code block -- one or more lines, starting with a space/tab + (?> + [ ]{' . '4' . '} # Lines must start with a tab or a tab-width of spaces + .*\n+ + )+ + ) + ((?=^[ ]{0,' . '4' . '}\S)|\Z) # Lookahead for non-space at line-start, or end of doc + }xm', + [$this, 'encode_code'], + $s + ); + + return $text; + } + + public function encode_code($matches) + { + return $this->token . ';' . base64_encode($matches[0]) . ';'; + } + + public function decode_code($matches) + { + return base64_decode($matches[1]); + } + + /** + * @brief Put back the code blocks. + * + * @param string $s + * @return string + * @see extract_code() + * @see decode_code() + * + */ + public function putback_code($s) + { + $text = preg_replace_callback('{' . $this->token . '\;(.*?)\;}xm', [$this, 'decode_code'], $s); + return $text; + } + + public function purify($s) + { + $s = $this->protect_autolinks($s); + $s = purify_html($s); + $s = $this->unprotect_autolinks($s); + return $s; + } + + public function protect_autolinks($s) + { + $s = preg_replace('/\<(https?\:\/\/)(.*?)\>/', '[$1$2]($1$2)', $s); + return $s; + } + + public function unprotect_autolinks($s) + { + return $s; + } + + public function escape($s) + { + return htmlspecialchars($s, ENT_QUOTES, 'UTF-8', false); + } + + /** + * @brief Converts special HTML entities back to characters. + * + * @param string $s + * @return string + */ + public static function unescape($s) + { + return htmlspecialchars_decode($s, ENT_QUOTES); + } +} diff --git a/Code/Lib/MastAPI.php b/Code/Lib/MastAPI.php new file mode 100644 index 000000000..7c30de0d6 --- /dev/null +++ b/Code/Lib/MastAPI.php @@ -0,0 +1,109 @@ + true]); + $ret['url'] = Channel::url($channel); + $ret['avatar'] = $channel['xchan_photo_l']; + $ret['avatar_static'] = $channel['xchan_photo_l']; + if ($cover_photo) { + $ret['header'] = $cover_photo['url']; + $ret['header_static'] = $cover_photo['url']; + } + $ret['followers_count'] = intval($followers[0]['total']); + $ret['following_count'] = intval($following[0]['total']); + $ret['statuses_count'] = intval($statuses[0]['total']); + $ret['last_status_at'] = datetime_convert('UTC', 'UTC', $channel['lastpost'], ATOM_TIME); + + + return $ret; + } + + public static function format_site() + { + + $register = intval(get_config('system', 'register_policy')); + + $u = q("select count(channel_id) as total from channel where channel_removed = 0"); + $i = q("select count(id) as total from item where item_origin = 1"); + $s = q("select count(site_url) as total from site"); + + $admins = q("select * from channel left join account on account_id = channel_account_id where ( account_roles & 4096 ) > 0 and account_default_channel = channel_id"); + $adminsx = Channel::from_id($admins[0]['channel_id']); + + + $ret = []; + $ret['uri'] = z_root(); + $ret['title'] = System::get_site_name(); + $ret['description'] = bbcode(get_config('system', 'siteinfo', ''), ['export' => true]); + $ret['email'] = get_config('system', 'admin_email'); + $ret['version'] = System::get_project_version(); + $ret['registrations'] = (($register) ? true : false); + $ret['approval_required'] = (($register === REGISTER_APPROVE) ? true : false); + $ret['invites_enabled'] = false; + $ret['urls'] = []; + $ret['stats'] = [ + 'user_count' => intval($u[0]['total']), + 'status_count' => intval($i[0]['total']), + 'domain_count' => intval($s[0]['total']), + ]; + + $ret['contact_account'] = self::format_channel($adminsx); + + return $ret; + } +} diff --git a/Code/Lib/Menu.php b/Code/Lib/Menu.php new file mode 100644 index 000000000..0f0543667 --- /dev/null +++ b/Code/Lib/Menu.php @@ -0,0 +1,367 @@ + $r[0], 'items' => $x ); + } + + return null; + } + + public static function element($channel, $menu) + { + + $arr = []; + $arr['type'] = 'menu'; + $arr['pagetitle'] = $menu['menu']['menu_name']; + $arr['desc'] = $menu['menu']['menu_desc']; + $arr['created'] = $menu['menu']['menu_created']; + $arr['edited'] = $menu['menu']['menu_edited']; + + $arr['baseurl'] = z_root(); + if ($menu['menu']['menu_flags']) { + $arr['flags'] = []; + if ($menu['menu']['menu_flags'] & MENU_BOOKMARK) { + $arr['flags'][] = 'bookmark'; + } + if ($menu['menu']['menu_flags'] & MENU_SYSTEM) { + $arr['flags'][] = 'system'; + } + } + if ($menu['items']) { + $arr['items'] = []; + foreach ($menu['items'] as $it) { + $entry = []; + + $entry['link'] = str_replace(z_root() . '/channel/' . $channel['channel_address'], '[channelurl]', $it['mitem_link']); + $entry['link'] = str_replace(z_root() . '/page/' . $channel['channel_address'], '[pageurl]', $it['mitem_link']); + $entry['link'] = str_replace(z_root() . '/cloud/' . $channel['channel_address'], '[cloudurl]', $it['mitem_link']); + $entry['link'] = str_replace(z_root(), '[baseurl]', $it['mitem_link']); + + $entry['desc'] = $it['mitem_desc']; + $entry['order'] = $it['mitem_order']; + if ($it['mitem_flags']) { + $entry['flags'] = []; + if ($it['mitem_flags'] & MENU_ITEM_ZID) { + $entry['flags'][] = 'zid'; + } + if ($it['mitem_flags'] & MENU_ITEM_NEWWIN) { + $entry['flags'][] = 'new-window'; + } + if ($it['mitem_flags'] & MENU_ITEM_CHATROOM) { + $entry['flags'][] = 'chatroom'; + } + } + $arr['items'][] = $entry; + } + } + + return $arr; + } + + + + public static function render($menu, $class = '', $edit = false, $var = []) + { + + if (! $menu) { + return ''; + } + + $channel_id = ((is_array(App::$profile)) ? App::$profile['profile_uid'] : 0); + if ((! $channel_id) && (local_channel())) { + $channel_id = local_channel(); + } + + $chan = Channel::from_id($channel_id); + if (! $chan) { + return ''; + } + + $menu_list = self::list($channel_id); + $menu_names = []; + + foreach ($menu_list as $menus) { + if ($menus['menu_name'] != $menu['menu']['menu_name']) { + $menu_names[] = $menus['menu_name']; + } + } + + for ($x = 0; $x < count($menu['items']); $x++) { + if (in_array($menu['items'][$x]['mitem_link'], $menu_names)) { + $m = self::fetch($menu['items'][$x]['mitem_link'], $channel_id, get_observer_hash()); + $submenu = self::render($m, 'dropdown-menu', $edit = false, array('wrap' => 'none')); + $menu['items'][$x]['submenu'] = $submenu; + } + + if ($menu['items'][$x]['mitem_flags'] & MENU_ITEM_ZID) { + $menu['items'][$x]['mitem_link'] = zid($menu['items'][$x]['mitem_link']); + } + + if ($menu['items'][$x]['mitem_flags'] & MENU_ITEM_NEWWIN) { + $menu['items'][$x]['newwin'] = '1'; + } + + $menu['items'][$x]['mitem_desc'] = zidify_links(smilies(bbcode($menu['items'][$x]['mitem_desc']))); + } + + $wrap = (($var['wrap'] === 'none') ? false : true); + + $ret = replace_macros(Theme::get_template('usermenu.tpl'), array( + '$menu' => $menu['menu'], + '$class' => $class, + '$nick' => $chan['channel_address'], + '$edit' => (($edit) ? t("Edit") : ''), + '$id' => $menu['menu']['menu_id'], + '$items' => $menu['items'], + '$wrap' => $wrap + )); + + return $ret; + } + + + + public static function fetch_id($menu_id, $channel_id) + { + + $r = q( + "select * from menu where menu_id = %d and menu_channel_id = %d limit 1", + intval($menu_id), + intval($channel_id) + ); + + return (($r) ? $r[0] : false); + } + + + + public static function create($arr) + { + $menu_name = trim(escape_tags($arr['menu_name'])); + $menu_desc = trim(escape_tags($arr['menu_desc'])); + $menu_flags = intval($arr['menu_flags']); + + //allow menu_desc (title) to be empty + //if(! $menu_desc) + // $menu_desc = $menu_name; + + if (! $menu_name) { + return false; + } + + if (! $menu_flags) { + $menu_flags = 0; + } + + + $menu_channel_id = intval($arr['menu_channel_id']); + + $r = q( + "select * from menu where menu_name = '%s' and menu_channel_id = %d limit 1", + dbesc($menu_name), + intval($menu_channel_id) + ); + + if ($r) { + return false; + } + + $t = datetime_convert(); + + $r = q( + "insert into menu ( menu_name, menu_desc, menu_flags, menu_channel_id, menu_created, menu_edited ) + values( '%s', '%s', %d, %d, '%s', '%s' )", + dbesc($menu_name), + dbesc($menu_desc), + intval($menu_flags), + intval($menu_channel_id), + dbesc(datetime_convert('UTC', 'UTC', (($arr['menu_created']) ? $arr['menu_created'] : $t))), + dbesc(datetime_convert('UTC', 'UTC', (($arr['menu_edited']) ? $arr['menu_edited'] : $t))) + ); + if (! $r) { + return false; + } + + $r = q( + "select menu_id from menu where menu_name = '%s' and menu_channel_id = %d limit 1", + dbesc($menu_name), + intval($menu_channel_id) + ); + if ($r) { + return $r[0]['menu_id']; + } + return false; + } + + /** + * If $flags is present, check that all the bits in $flags are set + * so that MENU_SYSTEM|MENU_BOOKMARK will return entries with both + * bits set. We will use this to find system generated bookmarks. + */ + + public static function list($channel_id, $name = '', $flags = 0) + { + + $sel_options = ''; + $sel_options .= (($name) ? " and menu_name = '" . protect_sprintf(dbesc($name)) . "' " : ''); + $sel_options .= (($flags) ? " and menu_flags = " . intval($flags) . " " : ''); + + $r = q( + "select * from menu where menu_channel_id = %d $sel_options order by menu_desc", + intval($channel_id) + ); + return $r; + } + + public static function list_count($channel_id, $name = '', $flags = 0) + { + + $sel_options = ''; + $sel_options .= (($name) ? " and menu_name = '" . protect_sprintf(dbesc($name)) . "' " : ''); + $sel_options .= (($flags) ? " and menu_flags = " . intval($flags) . " " : ''); + + $r = q( + "select count(*) as total from menu where menu_channel_id = %d $sel_options", + intval($channel_id) + ); + return $r[0]['total']; + } + + public static function edit($arr) + { + + $menu_id = intval($arr['menu_id']); + + $menu_name = trim(escape_tags($arr['menu_name'])); + $menu_desc = trim(escape_tags($arr['menu_desc'])); + $menu_flags = intval($arr['menu_flags']); + + //allow menu_desc (title) to be empty + //if(! $menu_desc) + // $menu_desc = $menu_name; + + if (! $menu_name) { + return false; + } + + if (! $menu_flags) { + $menu_flags = 0; + } + + + $menu_channel_id = intval($arr['menu_channel_id']); + + $r = q( + "select menu_id from menu where menu_name = '%s' and menu_channel_id = %d limit 1", + dbesc($menu_name), + intval($menu_channel_id) + ); + if (($r) && ($r[0]['menu_id'] != $menu_id)) { + logger('menu_edit: duplicate menu name for channel ' . $menu_channel_id); + return false; + } + + $r = q( + "select * from menu where menu_id = %d and menu_channel_id = %d limit 1", + intval($menu_id), + intval($menu_channel_id) + ); + if (! $r) { + logger('menu_edit: not found: ' . print_r($arr, true)); + return false; + } + + return q( + "update menu set menu_name = '%s', menu_desc = '%s', menu_flags = %d, menu_edited = '%s' + where menu_id = %d and menu_channel_id = %d", + dbesc($menu_name), + dbesc($menu_desc), + intval($menu_flags), + dbesc(datetime_convert()), + intval($menu_id), + intval($menu_channel_id) + ); + } + + public static function delete($menu_name, $uid) + { + $r = q( + "select menu_id from menu where menu_name = '%s' and menu_channel_id = %d limit 1", + dbesc($menu_name), + intval($uid) + ); + + if ($r) { + return self::delete_id($r[0]['menu_id'], $uid); + } + return false; + } + + public static function delete_id($menu_id, $uid) + { + $r = q( + "select menu_id from menu where menu_id = %d and menu_channel_id = %d limit 1", + intval($menu_id), + intval($uid) + ); + if ($r) { + $x = q( + "delete from menu_item where mitem_menu_id = %d and mitem_channel_id = %d", + intval($menu_id), + intval($uid) + ); + return q( + "delete from menu where menu_id = %d and menu_channel_id = %d", + intval($menu_id), + intval($uid) + ); + } + return false; + } + + public static function sync_packet($uid, $observer_hash, $menu_id, $delete = false) + { + $r = self::fetch_id($menu_id, $uid); + $c = Channel::from_id($uid); + if ($r) { + $m = self::fetch($r['menu_name'], $uid, $observer_hash); + if ($m) { + if ($delete) { + $m['menu_delete'] = 1; + } + Libsync::build_sync_packet($uid, array('menu' => array(menu_element($c, $m)))); + } + } + } + +} diff --git a/Code/Lib/MenuItem.php b/Code/Lib/MenuItem.php new file mode 100644 index 000000000..b566c8936 --- /dev/null +++ b/Code/Lib/MenuItem.php @@ -0,0 +1,122 @@ +set_from_array($arr); + $p = $acl->get(); + + $r = q( + "insert into menu_item ( mitem_link, mitem_desc, mitem_flags, allow_cid, allow_gid, deny_cid, deny_gid, mitem_channel_id, mitem_menu_id, mitem_order ) values ( '%s', '%s', %d, '%s', '%s', '%s', '%s', %d, %d, %d ) ", + dbesc($mitem_link), + dbesc($mitem_desc), + intval($mitem_flags), + dbesc($p['allow_cid']), + dbesc($p['allow_gid']), + dbesc($p['deny_cid']), + dbesc($p['deny_gid']), + intval($uid), + intval($menu_id), + intval($mitem_order) + ); + + $x = q( + "update menu set menu_edited = '%s' where menu_id = %d and menu_channel_id = %d", + dbesc(datetime_convert()), + intval($menu_id), + intval($uid) + ); + + return $r; + } + + public static function edit($menu_id, $uid, $arr) + { + + + $mitem_id = intval($arr['mitem_id']); + $mitem_link = escape_tags($arr['mitem_link']); + $mitem_desc = escape_tags($arr['mitem_desc']); + $mitem_order = intval($arr['mitem_order']); + $mitem_flags = intval($arr['mitem_flags']); + + + if (local_channel() == $uid) { + $channel = App::get_channel(); + } + + $acl = new AccessControl($channel); + $acl->set_from_array($arr); + $p = $acl->get(); + + + $r = q( + "update menu_item set mitem_link = '%s', mitem_desc = '%s', mitem_flags = %d, allow_cid = '%s', allow_gid = '%s', deny_cid = '%s', deny_gid = '%s', mitem_order = %d where mitem_channel_id = %d and mitem_menu_id = %d and mitem_id = %d", + dbesc($mitem_link), + dbesc($mitem_desc), + intval($mitem_flags), + dbesc($p['allow_cid']), + dbesc($p['allow_gid']), + dbesc($p['deny_cid']), + dbesc($p['deny_gid']), + intval($mitem_order), + intval($uid), + intval($menu_id), + intval($mitem_id) + ); + + $x = q( + "update menu set menu_edited = '%s' where menu_id = %d and menu_channel_id = %d", + dbesc(datetime_convert()), + intval($menu_id), + intval($uid) + ); + + return $r; + } + + + + + public static function delete($menu_id, $uid, $item_id) + { + $r = q( + "delete from menu_item where mitem_menu_id = %d and mitem_channel_id = %d and mitem_id = %d", + intval($menu_id), + intval($uid), + intval($item_id) + ); + + $x = q( + "update menu set menu_edited = '%s' where menu_id = %d and menu_channel_id = %d", + dbesc(datetime_convert()), + intval($menu_id), + intval($uid) + ); + + return $r; + } + +} diff --git a/Code/Lib/MessageFilter.php b/Code/Lib/MessageFilter.php new file mode 100644 index 000000000..97b049b02 --- /dev/null +++ b/Code/Lib/MessageFilter.php @@ -0,0 +1,96 @@ +$(document).ready(function() { $("#nav-search-text").search_autocomplete(\'' . z_root() . '/acloader' . '\');});'; + + $is_owner = (((local_channel()) && ((App::$profile_uid == local_channel()) || (App::$profile_uid == 0))) ? true : false); + + if (local_channel()) { + $channel = App::get_channel(); + $observer = App::get_observer(); + $prof = q( + "select id from profile where uid = %d and is_default = 1", + intval($channel['channel_id']) + ); + + if (! (isset($_SESSION['delegate']) && $_SESSION['delegate'])) { + $chans = q( + "select channel_name, channel_id from channel left join pconfig on channel_id = pconfig.uid where channel_account_id = %d and channel_removed = 0 and pconfig.cat = 'system' and pconfig.k = 'include_in_menu' and pconfig.v = '1' order by channel_name ", + intval(get_account_id()) + ); + if (is_site_admin() && intval(get_pconfig($site_channel['channel_id'], 'system', 'include_in_menu'))) { + $chans = array_merge([$site_channel], $chans); + } + } + + $sitelocation = (($is_owner) ? '' : App::$profile['reddress']); + } elseif (remote_channel()) { + $observer = App::get_observer(); + $sitelocation = ((App::$profile['reddress']) ? App::$profile['reddress'] : '@' . App::get_hostname()); + } + + require_once('include/conversation.php'); + + + $channel_apps[] = ((isset(App::$profile)) ? self::channel_apps($is_owner, App::$profile['channel_address']) : []); + + $site_icon = System::get_site_icon(); + + $banner = EMPTY_STR; + +// $banner = System::get_site_name(); +// if (! isset(App::$page['header'])) { +// App::$page['header'] = EMPTY_STR; +// } + App::$page['header'] .= replace_macros(Theme::get_template('hdr.tpl'), array( + //we could additionally use this to display important system notifications e.g. for updates + )); + + + // nav links: array of array('href', 'text', 'extra css classes', 'title') + $nav = []; + + if (can_view_public_stream()) { + $nav['pubs'] = true; + } + + /** + * Display login or logout + */ + + $nav['usermenu'] = []; + $userinfo = null; + $nav['loginmenu'] = []; + + if ($observer) { + $userinfo = [ + 'icon' => $observer['xchan_photo_m'] . '?rev=' . strtotime($observer['xchan_photo_date']), + 'name' => $observer['xchan_addr'], + ]; + } elseif (! $_SESSION['authenticated']) { + $nav['remote_login'] = Channel::remote_login(); + $nav['loginmenu'][] = array('rmagic',t('Remote authentication'),'',t('Click to authenticate to your home hub'),'rmagic_nav_btn'); + } + + if (local_channel()) { + if (! (isset($_SESSION['delegate']) && $_SESSION['delegate'])) { + $nav['manage'] = array('manage', t('Channels'), "", t('Manage your channels'),'manage_nav_btn'); + } + + $nav['group'] = array('lists', t('Lists'),"", t('Manage your access lists'),'group_nav_btn'); + + $nav['settings'] = array('settings', t('Settings'),"", t('Account/Channel Settings'),'settings_nav_btn'); + + $nav['safe'] = array('safe', t('Safe Mode'), ((get_safemode()) ? t('(is on)') : t('(is off)')) , t('Content filtering'),'safe_nav_btn'); + + + if ($chans && count($chans) > 0) { + $nav['channels'] = $chans; + } + + $nav['logout'] = ['logout',t('Logout'), "", t('End this session'),'logout_nav_btn']; + + // user menu + $nav['usermenu'][] = ['profile/' . $channel['channel_address'], t('View Profile'), ((App::$nav_sel['raw_name'] == 'Profile') ? 'active' : ''), t('Your profile page'),'profile_nav_btn']; + + if (Features::enabled(local_channel(), 'multi_profiles')) { + $nav['usermenu'][] = ['profiles', t('Edit Profiles'), ((App::$nav_sel['raw_name'] == 'Profiles') ? 'active' : '') , t('Manage/Edit profiles'),'profiles_nav_btn']; + } else { + $nav['usermenu'][] = ['profiles/' . $prof[0]['id'], t('Edit Profile'), ((App::$nav_sel['raw_name'] == 'Profiles') ? 'active' : ''), t('Edit your profile'),'profiles_nav_btn']; + } + } else { + if (! get_account_id()) { + if (App::$module === 'channel') { + $nav['login'] = login(true, 'navbar-login', false, false); + $nav['loginmenu'][] = ['login',t('Login'),'',t('Sign in'),'']; + } else { + $nav['login'] = login(true, 'navbar-login', false, false); + $nav['loginmenu'][] = ['login',t('Login'),'',t('Sign in'),'login_nav_btn']; + App::$page['content'] .= replace_macros( + Theme::get_template('nav_login.tpl'), + [ + '$nav' => $nav, + 'userinfo' => $userinfo + ] + ); + } + } else { + $nav['alogout'] = ['logout',t('Logout'), "", t('End this session'),'logout_nav_btn']; + } + } + + $my_url = Channel::get_my_url(); + if (! $my_url) { + $observer = App::get_observer(); + $my_url = (($observer) ? $observer['xchan_url'] : ''); + } + + $homelink_arr = parse_url($my_url); + $homelink = $homelink_arr['scheme'] . '://' . $homelink_arr['host']; + + if (! $is_owner) { + $nav['rusermenu'] = array( + $homelink, + t('Take me home'), + 'logout', + ((local_channel()) ? t('Logout') : t('Log me out of this site')) + ); + } + + if (((get_config('system', 'register_policy') == REGISTER_OPEN) || (get_config('system', 'register_policy') == REGISTER_APPROVE)) && (! $_SESSION['authenticated'])) { + $nav['register'] = ['register',t('Register'), "", t('Create an account'),'register_nav_btn']; + } + + if (! get_config('system', 'hide_help', true)) { + $help_url = z_root() . '/help?f=&cmd=' . App::$cmd; + $context_help = ''; + $enable_context_help = ((intval(get_config('system', 'enable_context_help')) === 1 || get_config('system', 'enable_context_help') === false) ? true : false); + if ($enable_context_help === true) { + require_once('include/help.php'); + $context_help = load_context_help(); + //point directly to /help if $context_help is empty - this can be removed once we have context help for all modules + $enable_context_help = (($context_help) ? true : false); + } + $nav['help'] = [$help_url, t('Help'), "", t('Help and documentation'), 'help_nav_btn', $context_help, $enable_context_help]; + } + + + $search_form_action = 'search'; + + + $nav['search'] = ['search', t('Search'), "", t('Search site @name, #tag, ?doc, content'), $search_form_action]; + + /** + * Admin page + */ + if (is_site_admin()) { + $nav['admin'] = array('admin/', t('Admin'), "", t('Site Setup and Configuration'),'admin_nav_btn'); + } + + $x = array('nav' => $nav, 'usermenu' => $userinfo ); + + Hook::call('nav', $x); + + // Not sure the best place to put this on the page. So I'm implementing it but leaving it + // turned off until somebody discovers this and figures out a good location for it. + $powered_by = ''; + + if (App::$profile_uid && App::$nav_sel['raw_name']) { + $active_app = q( + "SELECT app_url FROM app WHERE app_channel = %d AND app_name = '%s' LIMIT 1", + intval(App::$profile_uid), + dbesc(App::$nav_sel['raw_name']) + ); + + if ($active_app) { + $url = $active_app[0]['app_url']; + } + } + + $pinned_list = []; + $syslist = []; + + //app bin + if ($is_owner) { + if (get_pconfig(local_channel(), 'system', 'import_system_apps') !== datetime_convert('UTC', 'UTC', 'now', 'Y-m-d')) { + Apps::import_system_apps(); + set_pconfig(local_channel(), 'system', 'import_system_apps', datetime_convert('UTC', 'UTC', 'now', 'Y-m-d')); + } + + $list = Apps::app_list(local_channel(), false, [ 'nav_pinned_app' ]); + if ($list) { + foreach ($list as $li) { + $pinned_list[] = Apps::app_encode($li); + } + } + Apps::translate_system_apps($pinned_list); + + usort($pinned_list, 'Code\\Lib\\Apps::app_name_compare'); + + $pinned_list = Apps::app_order(local_channel(), $pinned_list, 'nav_pinned_app'); + + + $syslist = []; + $list = Apps::app_list(local_channel(), false, [ 'nav_featured_app' ]); + + if ($list) { + foreach ($list as $li) { + $syslist[] = Apps::app_encode($li); + } + } + Apps::translate_system_apps($syslist); + } else { + $syslist = Apps::get_system_apps(true); + } + + usort($syslist, 'Code\\Lib\\Apps::app_name_compare'); + + $syslist = Apps::app_order(local_channel(), $syslist, 'nav_featured_app'); + + + if ($pinned_list) { + foreach ($pinned_list as $app) { + if (App::$nav_sel['name'] == $app['name']) { + $app['active'] = true; + } + + if ($is_owner) { + $navbar_apps[] = Apps::app_render($app, 'navbar'); + } elseif (! $is_owner && strpos($app['requires'], 'local_channel') === false) { + $navbar_apps[] = Apps::app_render($app, 'navbar'); + } + } + } + + if ($syslist) { + foreach ($syslist as $app) { + if (App::$nav_sel['name'] == $app['name']) { + $app['active'] = true; + } + + if ($is_owner) { + $nav_apps[] = Apps::app_render($app, 'nav'); + } elseif (! $is_owner && strpos($app['requires'], 'local_channel') === false) { + $nav_apps[] = Apps::app_render($app, 'nav'); + } + } + } + + $c = Theme::include('navbar_' . purify_filename($template) . '.css'); + $tpl = Theme::get_template('navbar_' . purify_filename($template) . '.tpl'); + + if ($c && $tpl) { + Head::add_css('navbar_' . $template . '.css'); + } + + if (! $tpl) { + $tpl = Theme::get_template('navbar_default.tpl'); + } + + App::$page['nav'] .= replace_macros($tpl, array( + '$baseurl' => z_root(), + '$site_home' => Channel::url($site_channel), + '$project_icon' => $site_icon, + '$project_title' => t('Powered by $Projectname'), + '$fulldocs' => t('Help'), + '$sitelocation' => $sitelocation, + '$nav' => $x['nav'], + '$banner' => $banner, + '$emptynotifications' => t('Loading'), + '$userinfo' => $x['usermenu'], + '$localuser' => local_channel(), + '$is_owner' => $is_owner, + '$sel' => App::$nav_sel, + '$powered_by' => $powered_by, + '$asidetitle' => t('Side Panel'), + '$help' => t('@name, #tag, ?doc, content'), + '$pleasewait' => t('Please wait...'), + '$nav_apps' => ((isset($nav_apps)) ? $nav_apps : []), + '$navbar_apps' => ((isset($navbar_apps)) ? $navbar_apps : []), + '$channel_menu' => get_pconfig(App::$profile_uid, 'system', 'channel_menu', get_config('system', 'channel_menu')), + '$channel_thumb' => ((App::$profile) ? App::$profile['thumb'] : ''), + '$channel_apps' => ((isset($channel_apps)) ? $channel_apps : []), + '$manageapps' => t('Installed Apps'), + '$appstitle' => t('Apps'), + '$addapps' => t('Available Apps'), + '$orderapps' => t('Arrange Apps'), + '$sysapps_toggle' => t('Toggle System Apps'), + '$notificationstitle' => t('Notifications'), + '$url' => ((isset($url) && $url) ? $url : App::$cmd) + )); + + if (x($_SESSION, 'reload_avatar') && $observer) { + // The avatar has been changed on the server but the browser doesn't know that, + // force the browser to reload the image from the server instead of its cache. + $tpl = Theme::get_template('force_image_reload.tpl'); + + App::$page['nav'] .= replace_macros($tpl, array( + '$imgUrl' => $observer['xchan_photo_m'] + )); + unset($_SESSION['reload_avatar']); + } + + Hook::call('page_header', App::$page['nav']); + } + + /* + * Set a menu item in navbar as selected + * + */ + public static function set_selected($item) + { + App::$nav_sel['raw_name'] = $item; + $item = ['name' => $item]; + Apps::translate_system_apps($item); + App::$nav_sel['name'] = $item['name']; + } + + public static function channel_apps($is_owner = false, $nickname = null) + { + + // Don't provide any channel apps if we're running as the sys channel + + if (App::$is_sys) { + return ''; + } + + + $channel = App::get_channel(); + + if ($channel && is_null($nickname)) { + $nickname = $channel['channel_address']; + } + + $uid = ((App::$profile['profile_uid']) ? App::$profile['profile_uid'] : local_channel()); + $account_id = ((App::$profile['profile_uid']) ? App::$profile['channel_account_id'] : App::$channel['channel_account_id']); + + if (! get_pconfig($uid, 'system', 'channelapps', '1')) { + return ''; + } + + if ($uid == local_channel()) { + return; + } else { + $cal_link = '/cal/' . $nickname; + } + + $sql_options = item_permissions_sql($uid); + + $r = q( + "select item.* from item left join iconfig on item.id = iconfig.iid + where item.uid = %d and iconfig.cat = 'system' and iconfig.v = '%s' + and item.item_delayed = 0 and item.item_deleted = 0 + and ( iconfig.k = 'WEBPAGE' and item_type = %d ) + $sql_options limit 1", + intval($uid), + dbesc('home'), + intval(ITEM_TYPE_WEBPAGE) + ); + + $has_webpages = (($r) ? true : false); + + if (x($_GET, 'tab')) { + $tab = notags(trim($_GET['tab'])); + } + + $url = z_root() . '/channel/' . $nickname; + $pr = z_root() . '/profile/' . $nickname; + + $tabs = [ + [ + 'label' => t('Channel'), + 'url' => $url, + 'sel' => ((argv(0) == 'channel') ? 'active' : ''), + 'title' => t('Status Messages and Posts'), + 'id' => 'status-tab', + 'icon' => 'home' + ], + ]; + + $p = get_all_perms($uid, get_observer_hash()); + + if ($p['view_profile']) { + $tabs[] = [ + 'label' => t('About'), + 'url' => $pr, + 'sel' => ((argv(0) == 'profile') ? 'active' : ''), + 'title' => t('Profile Details'), + 'id' => 'profile-tab', + 'icon' => 'user' + ]; + } + if ($p['view_storage']) { + $tabs[] = [ + 'label' => t('Photos'), + 'url' => z_root() . '/photos/' . $nickname, + 'sel' => ((argv(0) == 'photos') ? 'active' : ''), + 'title' => t('Photo Albums'), + 'id' => 'photo-tab', + 'icon' => 'photo' + ]; + $tabs[] = [ + 'label' => t('Files'), + 'url' => z_root() . '/cloud/' . $nickname, + 'sel' => ((argv(0) == 'cloud' || argv(0) == 'sharedwithme') ? 'active' : ''), + 'title' => t('Files and Storage'), + 'id' => 'files-tab', + 'icon' => 'folder-open' + ]; + } + + if ($p['view_stream'] && $cal_link) { + $tabs[] = [ + 'label' => t('Calendar'), + 'url' => z_root() . $cal_link, + 'sel' => ((argv(0) == 'cal') ? 'active' : ''), + 'title' => t('Calendar'), + 'id' => 'event-tab', + 'icon' => 'calendar' + ]; + } + + + if ($p['chat'] && Apps::system_app_installed($uid, 'Chatrooms')) { + $has_chats = Chatroom::list_count($uid); + if ($has_chats) { + $tabs[] = [ + 'label' => t('Chatrooms'), + 'url' => z_root() . '/chat/' . $nickname, + 'sel' => ((argv(0) == 'chat') ? 'active' : '' ), + 'title' => t('Chatrooms'), + 'id' => 'chat-tab', + 'icon' => 'comments-o' + ]; + } + } + + $has_bookmarks = Menu::list_count(local_channel(), '', MENU_BOOKMARK) + Menu::list_count(local_channel(), '', MENU_SYSTEM | MENU_BOOKMARK); + if ($is_owner && $has_bookmarks) { + $tabs[] = [ + 'label' => t('Bookmarks'), + 'url' => z_root() . '/bookmarks', + 'sel' => ((argv(0) == 'bookmarks') ? 'active' : ''), + 'title' => t('Saved Bookmarks'), + 'id' => 'bookmarks-tab', + 'icon' => 'bookmark' + ]; + } + + if ($p['view_pages'] && Apps::system_app_installed($uid, 'Cards')) { + $tabs[] = [ + 'label' => t('Cards'), + 'url' => z_root() . '/cards/' . $nickname , + 'sel' => ((argv(0) == 'cards') ? 'active' : ''), + 'title' => t('View Cards'), + 'id' => 'cards-tab', + 'icon' => 'list' + ]; + } + + if ($p['view_pages'] && Apps::system_app_installed($uid, 'Articles')) { + $tabs[] = [ + 'label' => t('Articles'), + 'url' => z_root() . '/articles/' . $nickname , + 'sel' => ((argv(0) == 'articles') ? 'active' : ''), + 'title' => t('View Articles'), + 'id' => 'articles-tab', + 'icon' => 'file-text-o' + ]; + } + + + if ($has_webpages && Apps::system_app_installed($uid, 'Webpages')) { + $tabs[] = [ + 'label' => t('Webpages'), + 'url' => z_root() . '/page/' . $nickname . '/home', + 'sel' => ((argv(0) == 'webpages') ? 'active' : ''), + 'title' => t('View Webpages'), + 'id' => 'webpages-tab', + 'icon' => 'newspaper-o' + ]; + } + + + if ($p['view_wiki'] && Apps::system_app_installed($uid, 'Wiki')) { + $tabs[] = [ + 'label' => t('Wikis'), + 'url' => z_root() . '/wiki/' . $nickname, + 'sel' => ((argv(0) == 'wiki') ? 'active' : ''), + 'title' => t('Wiki'), + 'id' => 'wiki-tab', + 'icon' => 'pencil-square-o' + ]; + } + + $arr = array('is_owner' => $is_owner, 'nickname' => $nickname, 'tab' => (($tab) ? $tab : false), 'tabs' => $tabs); + + Hook::call('channel_apps', $arr); + + return replace_macros( + Theme::get_template('profile_tabs.tpl'), + [ + '$tabs' => $arr['tabs'], + '$name' => App::$profile['channel_name'], + '$thumb' => App::$profile['thumb'], + ] + ); + } + + +} diff --git a/Code/Lib/Nodeinfo.php b/Code/Lib/Nodeinfo.php new file mode 100644 index 000000000..862393a1e --- /dev/null +++ b/Code/Lib/Nodeinfo.php @@ -0,0 +1,41 @@ +' . $result['url'] . ''; + } + + $j = self::fetch_url($result['url']); + $s = self::format_object($j); + return $s; + } + + + public static function action($embedurl) + { + + $host = ''; + $action = 'filter'; + + $embedurl = trim(str_replace('&', '&', $embedurl)); + + //logger('oembed_action: ' . $embedurl, LOGGER_DEBUG, LOG_INFO); + + if (strpos($embedurl, 'http://') === 0) { + if (intval(get_config('system', 'embed_sslonly'))) { + $action = 'block'; + } + } + + if (strpos($embedurl, '.well-known') !== false) { + $action = 'block'; + } + + + // site allow/deny list + + if (($x = get_config('system', 'embed_deny'))) { + if (($x) && (! is_array($x))) { + $x = explode("\n", $x); + } + if ($x) { + foreach ($x as $ll) { + $t = trim($ll); + if (($t) && (strpos($embedurl, $t) !== false)) { + $action = 'block'; + break; + } + } + } + } + + $found = false; + + if (($x = get_config('system', 'embed_allow'))) { + if (($x) && (! is_array($x))) { + $x = explode("\n", $x); + } + if ($x) { + foreach ($x as $ll) { + $t = trim($ll); + if (($t) && (strpos($embedurl, $t) !== false) && ($action !== 'block')) { + $found = true; + $action = 'allow'; + break; + } + } + } + if ((! $found) && ($action !== 'block')) { + $action = 'filter'; + } + } + + // allow individual members to block something that wasn't blocked already. + // They cannot over-ride the site to allow or change the filtering on an + // embed that is not allowed by the site admin. + + if (local_channel()) { + if (($x = get_pconfig(local_channel(), 'system', 'embed_deny'))) { + if (($x) && (! is_array($x))) { + $x = explode("\n", $x); + } + if ($x) { + foreach ($x as $ll) { + $t = trim($ll); + if (($t) && (strpos($embedurl, $t) !== false)) { + $action = 'block'; + break; + } + } + } + } + } + + $arr = array('url' => $embedurl, 'action' => $action); + Hook::call('oembed_action', $arr); + + //logger('action: ' . $arr['action'] . ' url: ' . $arr['url'], LOGGER_DEBUG,LOG_DEBUG); + + return $arr; + } + + // if the url is embeddable with oembed, return the bbcode link. + + public static function process($url) + { + + $j = self::fetch_url($url); + logger('oembed_process: ' . print_r($j, true), LOGGER_DATA, LOG_DEBUG); + if ($j && $j['type'] !== 'error') { + return '[embed]' . $url . '[/embed]'; + } + return false; + } + + + + public static function fetch_url($embedurl) + { + + $noexts = [ '.mp3', '.mp4', '.ogg', '.ogv', '.oga', '.ogm', '.webm', '.opus', '.m4a', '.mov' ]; + + $result = self::action($embedurl); + + $embedurl = $result['url']; + $action = $result['action']; + + foreach ($noexts as $ext) { + if (strpos(strtolower($embedurl), $ext) !== false) { + $action = 'block'; + } + } + + $txt = null; + + // we should try to cache this and avoid a lookup on each render + $is_matrix = is_matrix_url($embedurl); + + $zrl = ((get_config('system', 'oembed_zrl')) ? $is_matrix : false); + + $furl = ((local_channel() && $zrl) ? zid($embedurl) : $embedurl); + + if ($action !== 'block' && (! get_config('system', 'oembed_cache_disable'))) { + $txt = Cache::get('[' . App::$videowidth . '] ' . $furl); + } + + if (strpos(strtolower($embedurl), '.pdf') !== false && get_config('system', 'inline_pdf')) { + $action = 'allow'; + $j = [ + 'html' => '', + 'title' => t('View PDF'), + 'type' => 'pdf' + ]; + + // set $txt to something so that we don't attempt to fetch what could be a lengthy pdf. + $txt = EMPTY_STR; + } + + if (is_null($txt)) { + $txt = ""; + + if ($action !== 'block') { + // try oembed autodiscovery + $redirects = 0; + $result = z_fetch_url( + $furl, + false, + $redirects, + [ + 'timeout' => 30, + 'accept_content' => "text/*", + 'novalidate' => true, + 'session' => ((local_channel() && $zrl) ? true : false) + ] + ); + + if ($result['success']) { + $html_text = $result['body']; + } else { + logger('fetch failure: ' . $furl); + } + + if ($html_text) { + $dom = new DOMDocument(); + @$dom->loadHTML($html_text); + if ($dom) { + $xpath = new DOMXPath($dom); + $attr = "oembed"; + $xattr = self::build_xpath("class", "oembed"); + + $entries = $xpath->query("//link[@type='application/json+oembed']"); + foreach ($entries as $e) { + $href = $e->getAttributeNode("href")->nodeValue; + + $x = z_fetch_url($href . '&maxwidth=' . App::$videowidth); + if ($x['success']) { + $txt = $x['body']; + } else { + logger('fetch failed: ' . $href); + } + break; + } + // soundcloud is now using text/json+oembed instead of application/json+oembed, + // others may be also + $entries = $xpath->query("//link[@type='text/json+oembed']"); + foreach ($entries as $e) { + $href = $e->getAttributeNode("href")->nodeValue; + $x = z_fetch_url($href . '&maxwidth=' . App::$videowidth); + if ($x['success']) { + $txt = $x['body']; + } else { + logger('json fetch failed: ' . $href); + } + break; + } + } + } + } + + if ($txt == false || $txt == "") { + $x = array('url' => $embedurl,'videowidth' => App::$videowidth); + Hook::call('oembed_probe', $x); + if (array_key_exists('embed', $x)) { + $txt = $x['embed']; + } + } + + $txt = trim($txt); + + if ($txt[0] != "{") { + $txt = '{"type":"error"}'; + } + + // save in cache + + if (! get_config('system', 'oembed_cache_disable')) { + Cache::set('[' . App::$videowidth . '] ' . $furl, $txt); + } + } + + + if (! $j) { + $j = json_decode($txt, true); + } + + if (! $j) { + $j = []; + } + + if ($action === 'filter') { + if ($j['html']) { + $orig = $j['html']; + $allow_position = (($is_matrix) ? true : false); + + // some sites (e.g. Mastodon) wrap their entire embed in an iframe + // which we will purify away and which we provide anyway. + // So if we see this, grab the frame src url and use that + // as the embed content - which will still need to be purified. + + if (preg_match('#\"; + switch ($j['type']) { + case "video": { + if (isset($j['thumbnail_url'])) { + $tw = (isset($j['thumbnail_width'])) ? $j['thumbnail_width'] : 200; + $th = (isset($j['thumbnail_height'])) ? $j['thumbnail_height'] : 180; + $tr = $tw / $th; + + $th = 120; + $tw = $th * $tr; + $tpl = Theme::get_template('oembed_video.tpl'); + + $ret .= replace_macros($tpl, array( + '$baseurl' => z_root(), + '$embedurl' => $embedurl, + '$escapedhtml' => base64_encode($jhtml), + '$tw' => $tw, + '$th' => $th, + '$turl' => $j['thumbnail_url'], + )); + } else { + $ret = $jhtml; + } + $ret .= "
      "; + } + break; + case "photo": { + $ret .= ""; + $ret .= "
      "; + } + break; + case "link": { + if ($j['thumbnail_url']) { + if (is_matrix_url($embedurl)) { + $embedurl = zid($embedurl); + $j['thumbnail_url'] = zid($j['thumbnail_url']); + } + $ret = 'thumbnail

      '; + } + + //$ret = "".$j['title'].""; + } + break; + case 'pdf': { + $ret = $j['html']; + break; + } + + case "rich": + if ($j['zrl']) { + $ret = ((preg_match('/^]+>(.*?)<\/div>$/is', $j['html'], $o)) ? $o[1] : $j['html']); + } else { + $ret .= $jhtml; + } + break; + } + + // add link to source if not present in "rich" type + if ($j['type'] != 'rich' || !strpos($j['html'], $embedurl)) { + $embedlink = (isset($j['title'])) ? $j['title'] : $embedurl; + $ret .= '
      ' . "$embedlink"; + $ret .= "
      "; + if (isset($j['author_name'])) { + $ret .= t(' by ') . $j['author_name']; + } + if (isset($j['provider_name'])) { + $ret .= t(' on ') . $j['provider_name']; + } + } else { + // add for html2bbcode conversion + $ret .= "
      $embedurl"; + } + $ret .= "
      "; + return mb_convert_encoding($ret, 'HTML-ENTITIES', mb_detect_encoding($ret)); + } + + public static function iframe($src, $width, $height) + { + $scroll = ' scrolling="no" '; + if (! $width || strstr($width, '%')) { + $width = '640'; + $scroll = ' scrolling="auto" '; + } + if (! $height || strstr($height, '%')) { + $height = '300'; + $scroll = ' scrolling="auto" '; + } + + // try and leave some room for the description line. + $height = intval($height) + 80; + $width = intval($width) + 40; + + $s = z_root() . '/oembed/' . base64url_encode($src); + + // Make sure any children are sandboxed within their own iframe. + + return ''; + } + + + + public static function bbcode2html($text) + { + $stopoembed = get_config("system", "no_oembed"); + if ($stopoembed == true) { + return preg_replace("/\[embed\](.+?)\[\/embed\]/is", "" . t('Embedding disabled') . " : $1", $text); + } + return preg_replace_callback("/\[embed\](.+?)\[\/embed\]/is", ['\\Code\\Lib\\Oembed','replacecb'], $text); + } + + + public static function build_xpath($attr, $value) + { + // http://westhoffswelt.de/blog/0036_xpath_to_select_html_by_class.html + return "contains( normalize-space( @$attr ), ' $value ' ) or substring( normalize-space( @$attr ), 1, string-length( '$value' ) + 1 ) = '$value ' or substring( normalize-space( @$attr ), string-length( @$attr ) - string-length( '$value' ) ) = ' $value' or @$attr = '$value'"; + } + + public static function get_inner_html($node) + { + $innerHTML = ''; + $children = $node->childNodes; + foreach ($children as $child) { + $innerHTML .= $child->ownerDocument->saveXML($child); + } + return $innerHTML; + } + + /** + * Find .... + * and replace it with [embed]url[/embed] + */ + public static function html2bbcode($text) + { + // start parser only if 'oembed' is in text + if (strpos($text, "oembed")) { + // convert non ascii chars to html entities + $html_text = mb_convert_encoding($text, 'HTML-ENTITIES', mb_detect_encoding($text)); + + // If it doesn't parse at all, just return the text. + $dom = new DOMDocument(); + @$dom->loadHTML($html_text); + if ($dom) { + $xpath = new DOMXPath($dom); + $attr = "oembed"; + + $xattr = self::build_xpath("class", "oembed"); + $entries = $xpath->query("//span[$xattr]"); + + $xattr = "@rel='oembed'";//self::build_xpath("rel","oembed"); + foreach ($entries as $e) { + $href = $xpath->evaluate("a[$xattr]/@href", $e)->item(0)->nodeValue; + if (!is_null($href)) { + $e->parentNode->replaceChild(new DOMText("[embed]" . $href . "[/embed]"), $e); + } + } + return self::get_inner_html($dom->getElementsByTagName("body")->item(0)); + } + } + return $text; + } + + + +} diff --git a/Code/Lib/PConfig.php b/Code/Lib/PConfig.php new file mode 100644 index 000000000..884788eb1 --- /dev/null +++ b/Code/Lib/PConfig.php @@ -0,0 +1,229 @@ +PConfig is used for channel specific configurations and takes a + * channel_id as identifier. It stores for example which features are + * enabled per channel. The storage is of size MEDIUMTEXT. + * + * @code{.php}$var = Code\Lib\PConfig::Get('uid', 'category', 'key'); + * // with default value for non existent key + * $var = Code\Lib\PConfig::Get('uid', 'category', 'unsetkey', 'defaultvalue');@endcode + * + * The old (deprecated?) way to access a PConfig value is: + * @code{.php}$var = get_pconfig(local_channel(), 'category', 'key');@endcode + */ +class PConfig +{ + + /** + * @brief Loads all configuration values of a channel into a cached storage. + * + * All configuration values of the given channel are stored in global cache + * which is available under the global variable App::$config[$uid]. + * + * @param string $uid + * The channel_id + * @return void|false Nothing or false if $uid is null or false + */ + public static function Load($uid) + { + if (is_null($uid) || $uid === false) { + return false; + } + + if (! is_array(App::$config)) { + btlogger('App::$config not an array'); + } + + if (! array_key_exists($uid, App::$config)) { + App::$config[$uid] = []; + } + + if (! is_array(App::$config[$uid])) { + btlogger('App::$config[$uid] not an array: ' . $uid); + } + + $r = q( + "SELECT * FROM pconfig WHERE uid = %d", + intval($uid) + ); + + if ($r) { + foreach ($r as $rr) { + $k = $rr['k']; + $c = $rr['cat']; + if (! array_key_exists($c, App::$config[$uid])) { + App::$config[$uid][$c] = []; + App::$config[$uid][$c]['config_loaded'] = true; + } + App::$config[$uid][$c][$k] = $rr['v']; + } + } + } + + /** + * @brief Get a particular channel's config variable given the category name + * ($family) and a key. + * + * Get a particular channel's config value from the given category ($family) + * and the $key from a cached storage in App::$config[$uid]. + * + * Returns false if not set. + * + * @param string $uid + * The channel_id + * @param string $family + * The category of the configuration value + * @param string $key + * The configuration key to query + * @param mixed $default (optional, default false) + * Default value to return if key does not exist + * @return mixed Stored value or false if it does not exist + */ + public static function Get($uid, $family, $key, $default = false) + { + + if (is_null($uid) || $uid === false) { + return $default; + } + + if (! array_key_exists($uid, App::$config)) { + self::Load($uid); + } + + if ((! array_key_exists($family, App::$config[$uid])) || (! array_key_exists($key, App::$config[$uid][$family]))) { + return $default; + } + + return unserialise(App::$config[$uid][$family][$key]); + } + + /** + * @brief Sets a configuration value for a channel. + * + * Stores a config value ($value) in the category ($family) under the key ($key) + * for the channel_id $uid. + * + * @param string $uid + * The channel_id + * @param string $family + * The category of the configuration value + * @param string $key + * The configuration key to set + * @param string $value + * The value to store + * @return mixed Stored $value or false + */ + public static function Set($uid, $family, $key, $value) + { + + // this catches subtle errors where this function has been called + // with local_channel() when not logged in (which returns false) + // and throws an error in array_key_exists below. + // we provide a function backtrace in the logs so that we can find + // and fix the calling function. + + if (is_null($uid) || $uid === false) { + btlogger('UID is FALSE!', LOGGER_NORMAL, LOG_ERR); + return; + } + + // manage array value + $dbvalue = ((is_array($value)) ? serialise($value) : $value); + $dbvalue = ((is_bool($dbvalue)) ? intval($dbvalue) : $dbvalue); + + if (self::Get($uid, $family, $key) === false) { + if (! array_key_exists($uid, App::$config)) { + App::$config[$uid] = []; + } + if (! array_key_exists($family, App::$config[$uid])) { + App::$config[$uid][$family] = []; + } + + $ret = q( + "INSERT INTO pconfig ( uid, cat, k, v ) VALUES ( %d, '%s', '%s', '%s' ) ", + intval($uid), + dbesc($family), + dbesc($key), + dbesc($dbvalue) + ); + } else { + $ret = q( + "UPDATE pconfig SET v = '%s' WHERE uid = %d and cat = '%s' AND k = '%s'", + dbesc($dbvalue), + intval($uid), + dbesc($family), + dbesc($key) + ); + } + + // keep a separate copy for all variables which were + // set in the life of this page. We need this to + // synchronise channel clones. + + if (! array_key_exists('transient', App::$config[$uid])) { + App::$config[$uid]['transient'] = []; + } + if (! array_key_exists($family, App::$config[$uid]['transient'])) { + App::$config[$uid]['transient'][$family] = []; + } + + App::$config[$uid][$family][$key] = $value; + App::$config[$uid]['transient'][$family][$key] = $value; + + if ($ret) { + return $value; + } + + return $ret; + } + + + /** + * @brief Deletes the given key from the channel's configuration. + * + * Removes the configured value from the stored cache in App::$config[$uid] + * and removes it from the database. + * + * @param string $uid + * The channel_id + * @param string $family + * The category of the configuration value + * @param string $key + * The configuration key to delete + * @return mixed + */ + public static function Delete($uid, $family, $key) + { + + if (is_null($uid) || $uid === false) { + return false; + } + + $ret = false; + + if ( + array_key_exists($uid, App::$config) + && is_array(App::$config['uid']) + && array_key_exists($family, App::$config['uid']) + && array_key_exists($key, App::$config[$uid][$family]) + ) { + unset(App::$config[$uid][$family][$key]); + } + + $ret = q( + "DELETE FROM pconfig WHERE uid = %d AND cat = '%s' AND k = '%s'", + intval($uid), + dbesc($family), + dbesc($key) + ); + + return $ret; + } +} diff --git a/Code/Lib/Permcat.php b/Code/Lib/Permcat.php new file mode 100644 index 000000000..b66fe337b --- /dev/null +++ b/Code/Lib/Permcat.php @@ -0,0 +1,226 @@ +permcats[] = [ + 'name' => 'default', + 'localname' => t('default', 'permcat'), + 'perms' => Permissions::Operms($perms), + 'system' => 1 + ]; + + + $p = $this->load_permcats($channel_id, $abook_id); + if ($p) { + for ($x = 0; $x < count($p); $x++) { + $this->permcats[] = [ + 'name' => $p[$x][0], + 'localname' => $p[$x][1], + 'perms' => Permissions::Operms(Permissions::FilledPerms($p[$x][2])), + 'system' => intval($p[$x][3]) + ]; + } + } + } + + public function match($current) { + if ($current) { + $perms = Permissions::FilledPerms($current); + $operms = Permissions::Operms($perms); + } + + if ($this->permcats && $operms) { + foreach($this->permcats as $permcat) { + $pp = $permcat['perms']; + $matching = 0; + foreach ($pp as $rp) { + foreach ($operms as $op) { + if ($rp['name'] === $op['name'] && intval($rp['value']) === intval($op['value'])) { + $matching ++; + break; + } + } + } + if ($matching === count($pp)) { + return $permcat['name']; + } + } + } + return 'custom'; + } + + /** + * @brief Return array with permcats. + * + * @return array + */ + public function listing() + { + return $this->permcats; + } + + /** + * @brief + * + * @param string $name + * @return array + * * \e array with permcats + * * \e bool \b error if $name not found in permcats true + */ + public function fetch($name) + { + if ($name && $this->permcats) { + foreach ($this->permcats as $permcat) { + if (strcasecmp($permcat['name'], $name) === 0) { + return $permcat; + } + } + } + return ['error' => true]; + } + + public function load_permcats($uid, $abook_id = 0) + { + + $permcats = [ + [ 'follower', t('follower', 'permcat'), + [ 'view_stream','view_profile','view_contacts','view_storage','view_pages','view_wiki', + 'post_like' ], 1 + ], + [ 'contributor', t('contributor', 'permcat'), + [ 'view_stream','view_profile','view_contacts','view_storage','view_pages','view_wiki', + 'post_wall','post_comments','write_wiki','post_like','tag_deliver','chat' ], 1 + ], + [ 'publisher', t('publisher', 'permcat'), + [ 'view_stream','view_profile','view_contacts','view_storage','view_pages', + 'write_storage','post_wall','write_pages','write_wiki','post_comments','post_like','tag_deliver', + 'chat', 'republish' ], 1 + ] + ]; + + if ($uid) { + $x = q( + "select * from pconfig where uid = %d and cat = 'permcat'", + intval($uid) + ); + if ($x) { + foreach ($x as $xv) { + $value = unserialise($xv['v']); + $permcats[] = [ $xv['k'], $xv['k'], $value, 0 ]; + } + } + } + + if ($abook_id) { + $r = q("select * from abook left join xchan on abook_xchan = xchan_hash where abook_id = %d and abook_channel = %d", + intval($abook_id), + intval($uid) + ); + if ($r) { + $my_perms = explode(',', get_abconfig($uid, $r[0]['xchan_hash'], 'system', 'my_perms', EMPTY_STR)); + $permcats[] = [ 'custom', t('custom'), $my_perms, 1]; + } + + } + /** + * @hooks permcats + * * \e array + */ + Hook::call('permcats', $permcats); + + return $permcats; + } + + public static function find_permcat($arr, $name) + { + if ((! $arr) || (! $name)) { + return false; + } + + foreach ($arr as $p) { + if ($p['name'] == $name) { + return $p['value']; + } + } + } + + public static function update($channel_id, $name, $permarr) + { + PConfig::Set($channel_id, 'permcat', $name, $permarr); + } + + public static function delete($channel_id, $name) + { + PConfig::Delete($channel_id, 'permcat', $name); + } +} diff --git a/Code/Lib/PermissionDescription.php b/Code/Lib/PermissionDescription.php new file mode 100644 index 000000000..7fbed65f2 --- /dev/null +++ b/Code/Lib/PermissionDescription.php @@ -0,0 +1,193 @@ +global_perm = $global_perm; + $this->channel_perm = $channel_perm; + $this->fallback_description = ($description == '') ? t('Visible to your default audience') : $description; + } + + /** + * If the interpretation of an empty ACL can't be summarised with a global default permission + * or a specific permission setting then use this method and describe what it means instead. + * Remember to localize the description first. + * + * @param string $description - the localized caption for the no-ACL option in the ACL dialog. + * @return a new instance of PermissionDescription + */ + public static function fromDescription($description) + { + return new PermissionDescription('', 0x80000, $description); + } + + /** + * Use this method only if the interpretation of an empty ACL doesn't fall back to a global + * default permission. You should pass one of the constants from boot.php - PERMS_PUBLIC, + * PERMS_NETWORK etc. + * + * @param int $perm - a single enumerated constant permission - PERMS_PUBLIC, PERMS_NETWORK etc. + * @return a new instance of PermissionDescription + */ + public static function fromStandalonePermission($perm) + { + + $result = new PermissionDescription('', $perm); + + $checkPerm = $result->get_permission_description(); + if ($checkPerm == $result->fallback_description) { + $result = null; + logger('null PermissionDescription from unknown standalone permission: ' . $perm, LOGGER_DEBUG, LOG_ERR); + } + + return $result; + } + + /** + * This is the preferred way to create a PermissionDescription, as it provides the most details. + * Use this method if you know an empty ACL will result in one of the global default permissions + * being used, such as channel_r_stream (for which you would pass 'view_stream'). + * + * @param string $permname - a key for the global perms array from get_perms() in permissions.php, + * e.g. 'view_stream', 'view_profile', etc. + * @return a new instance of PermissionDescription + */ + public static function fromGlobalPermission($permname) + { + + $result = null; + + $global_perms = Permissions::Perms(); + + if (array_key_exists($permname, $global_perms)) { + $channelPerm = PermissionLimits::Get(App::$channel['channel_id'], $permname); + + $result = new PermissionDescription('', $channelPerm); + } else { + // The acl dialog can handle null arguments, but it shouldn't happen + logger('null PermissionDescription from unknown global permission: ' . $permname, LOGGER_DEBUG, LOG_ERR); + } + + return $result; + } + + /** + * Gets a localized description of the permission, or a generic message if the permission + * is unknown. + * + * @return string description + */ + public function get_permission_description() + { + + switch ($this->channel_perm) { + case 0: + return t('Only me'); + case PERMS_PUBLIC: + return t('Public'); + case PERMS_NETWORK: + return t('Anybody in the $Projectname network'); + case PERMS_SITE: + return sprintf(t('Any account on %s'), App::get_hostname()); + case PERMS_CONTACTS: + return t('Any of my connections'); + case PERMS_SPECIFIC: + return t('Only connections I specifically allow'); + case PERMS_AUTHED: + return t('Anybody authenticated (could include visitors from other networks)'); + case PERMS_PENDING: + return t('Any connections including those who haven\'t yet been approved'); + default: + return $this->fallback_description; + } + } + + /** + * Returns an icon css class name if an appropriate one is available, e.g. "fa-globe" for Public, + * otherwise returns empty string. + * + * @return string icon css class name (often FontAwesome) + */ + public function get_permission_icon() + { + + switch ($this->channel_perm) { + case 0: + return 'fa-eye-slash'; + case PERMS_PUBLIC: + return 'fa-globe'; + case PERMS_NETWORK: + return 'fa-share-alt-square'; // fa-share-alt-square is very similiar to the hubzilla logo, but we should create our own logo class to use + case PERMS_SITE: + return 'fa-sitemap'; + case PERMS_CONTACTS: + return 'fa-group'; + case PERMS_SPECIFIC: + return 'fa-list'; + case PERMS_AUTHED: + return ''; + case PERMS_PENDING: + return ''; + default: + return ''; + } + } + + /** + * Returns a localized description of where the permission came from, if this is known. + * If it's not know, or if the permission is standalone and didn't come from a default + * permission setting, then empty string is returned. + * + * @return string description or empty string + */ + public function get_permission_origin_description() + { + + switch ($this->global_perm) { + case PERMS_R_STREAM: + return t('This is your default setting for the audience of your normal stream, and posts.'); + case PERMS_R_PROFILE: + return t('This is your default setting for who can view your default channel profile'); + case PERMS_R_ABOOK: + return t('This is your default setting for who can view your connections'); + case PERMS_R_STORAGE: + return t('This is your default setting for who can view your file storage and photos'); + case PERMS_R_PAGES: + return t('This is your default setting for the audience of your webpages'); + default: + return ''; + } + } +} diff --git a/Code/Lib/Queue.php b/Code/Lib/Queue.php new file mode 100644 index 000000000..273ba0231 --- /dev/null +++ b/Code/Lib/Queue.php @@ -0,0 +1,522 @@ + $base, + 'site_update' => datetime_convert(), + 'site_dead' => 0, + 'site_type' => ((in_array($outq['outq_driver'], ['post', 'activitypub'])) ? SITE_TYPE_NOTZOT : SITE_TYPE_UNKNOWN), + 'site_crypto' => '' + ] + ); + } + } + + $arr = array('outq' => $outq, 'base' => $base, 'handled' => false, 'immediate' => $immediate); + Hook::call('queue_deliver', $arr); + if ($arr['handled']) { + return; + } + + // "post" queue driver - used for diaspora and friendica-over-diaspora communications. + + if ($outq['outq_driver'] === 'post') { + $result = z_post_url($outq['outq_posturl'], $outq['outq_msg']); + if ($result['success'] && $result['return_code'] < 300) { + logger('deliver: queue post success to ' . $outq['outq_posturl'], LOGGER_DEBUG); + if ($base) { + q( + "update site set site_update = '%s', site_dead = 0 where site_url = '%s' ", + dbesc(datetime_convert()), + dbesc($base) + ); + } + q( + "update dreport set dreport_result = '%s', dreport_time = '%s' where dreport_queue = '%s'", + dbesc('accepted for delivery'), + dbesc(datetime_convert()), + dbesc($outq['outq_hash']) + ); + self::remove($outq['outq_hash']); + + // server is responding - see if anything else is going to this destination and is piled up + // and try to send some more. We're relying on the fact that do_delivery() results in an + // immediate delivery otherwise we could get into a queue loop. + + if (!$immediate) { + $x = q( + "select outq_hash from outq where outq_posturl = '%s' and outq_delivered = 0", + dbesc($outq['outq_posturl']) + ); + + $piled_up = []; + if ($x) { + foreach ($x as $xx) { + $piled_up[] = $xx['outq_hash']; + } + } + if ($piled_up) { + // call do_delivery() with the force flag + do_delivery($piled_up, true); + } + } + } else { + logger('deliver: queue post returned ' . $result['return_code'] + . ' from ' . $outq['outq_posturl'], LOGGER_DEBUG); + self::update($outq['outq_hash'], 10); + } + return; + } + + if ($outq['outq_driver'] === 'asfetch') { + $channel = Channel::from_id($outq['outq_channel']); + if (!$channel) { + logger('missing channel: ' . $outq['outq_channel']); + return; + } + + if (!ActivityStreams::is_url($outq['outq_posturl'])) { + logger('fetch item is not url: ' . $outq['outq_posturl']); + self::remove($outq['outq_hash']); + return; + } + + $j = Activity::fetch($outq['outq_posturl'], $channel); + if ($j) { + $AS = new ActivityStreams($j, null, true); + if ($AS->is_valid() && isset($AS->data['type'])) { + if (ActivityStreams::is_an_actor($AS->data['type'])) { + Activity::actor_store($AS->data['id'], $AS->data); + } + if (strpos($AS->data['type'], 'Collection') !== false) { + // we are probably fetching a collection already - and do not support collection recursion at this time + self::remove($outq['outq_hash']); + return; + } + $item = Activity::decode_note($AS, true); + if ($item) { + Activity::store($channel, $channel['channnel_hash'], $AS, $item, true, true); + } + } + logger('deliver: queue fetch success from ' . $outq['outq_posturl'], LOGGER_DEBUG); + self::remove($outq['outq_hash']); + + // server is responding - see if anything else is going to this destination and is piled up + // and try to send some more. We're relying on the fact that do_delivery() results in an + // immediate delivery otherwise we could get into a queue loop. + + if (!$immediate) { + $x = q( + "select outq_hash from outq where outq_driver = 'asfetch' and outq_channel = %d and outq_delivered = 0", + dbesc($outq['outq_channel']) + ); + + $piled_up = []; + if ($x) { + foreach ($x as $xx) { + $piled_up[] = $xx['outq_hash']; + } + } + if ($piled_up) { + do_delivery($piled_up, true); + } + } + } else { + logger('deliver: queue fetch failed' . ' from ' . $outq['outq_posturl'], LOGGER_DEBUG); + self::update($outq['outq_hash'], 10); + } + return; + } + + if ($outq['outq_driver'] === 'activitypub') { + $channel = Channel::from_id($outq['outq_channel']); + if (!$channel) { + logger('missing channel: ' . $outq['outq_channel']); + return; + } + + + $retries = 0; + $m = parse_url($outq['outq_posturl']); + + $headers = []; + $headers['Content-Type'] = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'; + $ret = $outq['outq_msg']; + logger('ActivityPub send: ' . jindent($ret), LOGGER_DATA); + $headers['Date'] = datetime_convert('UTC', 'UTC', 'now', 'D, d M Y H:i:s \\G\\M\\T'); + $headers['Digest'] = HTTPSig::generate_digest_header($ret); + $headers['Host'] = $m['host']; + $headers['(request-target)'] = 'post ' . get_request_string($outq['outq_posturl']); + + $xhead = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel)); + if (strpos($outq['outq_posturl'], 'http') !== 0) { + logger('bad url: ' . $outq['outq_posturl']); + self::remove($outq['outq_hash']); + } + + $result = z_post_url($outq['outq_posturl'], $outq['outq_msg'], $retries, ['headers' => $xhead]); + + if ($result['success'] && $result['return_code'] < 300) { + logger('deliver: queue post success to ' . $outq['outq_posturl'], LOGGER_DEBUG); + if ($base) { + q( + "update site set site_update = '%s', site_dead = 0 where site_url = '%s' ", + dbesc(datetime_convert()), + dbesc($base) + ); + } + q( + "update dreport set dreport_result = '%s', dreport_time = '%s' where dreport_queue = '%s'", + dbesc('accepted for delivery'), + dbesc(datetime_convert()), + dbesc($outq['outq_hash']) + ); + self::remove($outq['outq_hash']); + + // server is responding - see if anything else is going to this destination and is piled up + // and try to send some more. We're relying on the fact that do_delivery() results in an + // immediate delivery otherwise we could get into a queue loop. + + if (!$immediate) { + $x = q( + "select outq_hash from outq where outq_posturl = '%s' and outq_delivered = 0", + dbesc($outq['outq_posturl']) + ); + + $piled_up = []; + if ($x) { + foreach ($x as $xx) { + $piled_up[] = $xx['outq_hash']; + } + } + if ($piled_up) { + do_delivery($piled_up, true); + } + } + } + elseif ($result['return_code'] >= 400 && $result['return_code'] < 500) { + q( + "update dreport set dreport_result = '%s', dreport_time = '%s' where dreport_queue = '%s'", + dbesc('delivery rejected' . ' ' . $result['return_code']), + dbesc(datetime_convert()), + dbesc($outq['outq_hash']) + ); + self::remove($outq['outq_hash']); + + } + else { + $dr = q( + "select * from dreport where dreport_queue = '%s'", + dbesc($outq['outq_hash']) + ); + if ($dr) { + // update every queue entry going to this site with the most recent communication error + q( + "update dreport set dreport_log = '%s' where dreport_site = '%s'", + dbesc(z_curl_error($result)), + dbesc($dr[0]['dreport_site']) + ); + } + self::update($outq['outq_hash'], 10); + } + + logger('deliver: queue post returned ' . $result['return_code'] . ' from ' . $outq['outq_posturl'], LOGGER_DEBUG); + return; + } + + // normal zot delivery + + logger('deliver: dest: ' . $outq['outq_posturl'], LOGGER_DEBUG); + + + if ($outq['outq_posturl'] === z_root() . '/zot') { + // local delivery + $zot = new Receiver(new Zot6Handler(), $outq['outq_notify']); + $result = $zot->run(); + logger('returned_json: ' . json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOGGER_DATA); + logger('deliver: local zot delivery succeeded to ' . $outq['outq_posturl']); + Libzot::process_response($outq['outq_posturl'], ['success' => true, 'body' => json_encode($result)], $outq); + + if (!$immediate) { + $x = q( + "select outq_hash from outq where outq_posturl = '%s' and outq_delivered = 0", + dbesc($outq['outq_posturl']) + ); + + $piled_up = []; + if ($x) { + foreach ($x as $xx) { + $piled_up[] = $xx['outq_hash']; + } + } + if ($piled_up) { + do_delivery($piled_up, true); + } + } + } else { + logger('remote'); + $channel = null; + + if ($outq['outq_channel']) { + $channel = Channel::from_id($outq['outq_channel'], true); + } + + $host_crypto = null; + + if ($channel && $base) { + $h = q( + "select hubloc_sitekey, site_crypto from hubloc left join site on hubloc_url = site_url where site_url = '%s' and hubloc_network in ('zot6','nomad') order by hubloc_id desc limit 1", + dbesc($base) + ); + if ($h) { + $host_crypto = $h[0]; + } + } + + $msg = $outq['outq_notify']; + + if ($outq['outq_driver'] === 'nomad') { + $result = Libzot::nomad($outq['outq_posturl'],$msg,$channel,$host_crypto); + } + else { + $result = Libzot::zot($outq['outq_posturl'],$msg,$channel,$host_crypto); + } + + if ($result['success']) { + logger('deliver: remote nomad/zot delivery succeeded to ' . $outq['outq_posturl']); + Libzot::process_response($outq['outq_posturl'], $result, $outq); + } else { + $dr = q( + "select * from dreport where dreport_queue = '%s'", + dbesc($outq['outq_hash']) + ); + + // update every queue entry going to this site with the most recent communication error + q( + "update dreport set dreport_log = '%s' where dreport_site = '%s'", + dbesc(z_curl_error($result)), + dbesc($dr[0]['dreport_site']) + ); + + logger('deliver: remote nomad/zot delivery failed to ' . $outq['outq_posturl']); + logger('deliver: remote nomad/zot delivery fail data: ' . print_r($result, true), LOGGER_DATA); + self::update($outq['outq_hash'], 10); + } + } + return; + } +} diff --git a/Code/Lib/SConfig.php b/Code/Lib/SConfig.php new file mode 100644 index 000000000..09d65e4db --- /dev/null +++ b/Code/Lib/SConfig.php @@ -0,0 +1,33 @@ + false, 'message' => ''); + + $r = q( + "select count(channel_id) as total from channel where channel_account_id = %d and channel_removed = 0 ", + intval($account_id) + ); + if (! ($r && count($r))) { + $ret['total_identities'] = 0; + $ret['message'] = t('Unable to obtain identity information from database'); + return $ret; + } + + $ret['total_identities'] = intval($r[0]['total']); + + if (! self::account_allows($account_id, 'total_identities', $r[0]['total'])) { + $ret['message'] .= self::upgrade_message(); + return $ret; + } + + $ret['success'] = true; + + return $ret; + } + + + + /** + * @brief Checks for accounts that have past their expiration date. + * + * If the account has a service class which is not the site default, + * the service class is reset to the site default and expiration reset to never. + * If the account has no service class it is expired and subsequently disabled. + * called from include/poller.php as a scheduled task. + * + * Reclaiming resources which are no longer within the service class limits is + * not the job of this function, but this can be implemented by plugin if desired. + * Default behaviour is to stop allowing additional resources to be consumed. + */ + public static function downgrade_accounts() + { + + $r = q( + "select * from account where not ( account_flags & %d ) > 0 + and account_expires > '%s' + and account_expires < %s ", + intval(ACCOUNT_EXPIRED), + dbesc(NULL_DATE), + db_getfunc('UTC_TIMESTAMP') + ); + + if (! $r) { + return; + } + + $basic = get_config('system', 'default_service_class'); + + foreach ($r as $rr) { + if (($basic) && ($rr['account_service_class']) && ($rr['account_service_class'] != $basic)) { + $x = q( + "UPDATE account set account_service_class = '%s', account_expires = '%s' + where account_id = %d", + dbesc($basic), + dbesc(NULL_DATE), + intval($rr['account_id']) + ); + $ret = [ 'account' => $rr ]; + Hook::call('account_downgrade', $ret); + logger('downgrade_accounts: Account id ' . $rr['account_id'] . ' downgraded.'); + } else { + $x = q( + "UPDATE account SET account_flags = (account_flags | %d) where account_id = %d", + intval(ACCOUNT_EXPIRED), + intval($rr['account_id']) + ); + $ret = [ 'account' => $rr ]; + Hook::call('account_downgrade', $ret); + logger('downgrade_accounts: Account id ' . $rr['account_id'] . ' expired.'); + } + } + } + + + /** + * @brief Check service_class restrictions. + * + * If there are no service_classes defined, everything is allowed. + * If $usage is supplied, we check against a maximum count and return true if + * the current usage is less than the subscriber plan allows. Otherwise we + * return boolean true or false if the property is allowed (or not) in this + * subscriber plan. An unset property for this service plan means the property + * is allowed, so it is only necessary to provide negative properties for each + * plan, or what the subscriber is not allowed to do. + * + * Like account_service_class_allows() but queries directly by account rather + * than channel. Service classes are set for accounts, so we look up the + * account for the channel and fetch the service class restrictions of the + * account. + * + * @see account_service_class_allows() if you have a channel_id already + * @see service_class_fetch() + * + * @param int $uid The channel_id to check + * @param string $property The service class property to check for + * @param string|bool $usage (optional) The value to check against + * @return bool + */ + public static function allows($uid, $property, $usage = false) + { + $limit = self::fetch($uid, $property); + + if ($limit === false) { + return true; // No service class set => everything is allowed + } + + $limit = engr_units_to_bytes($limit); + if ($usage === false) { + // We use negative values for not allowed properties in a subscriber plan + return (($limit) ? (bool) $limit : true); + } else { + return (((intval($usage)) < intval($limit)) ? true : false); + } + } + + /** + * @brief Check service class restrictions by account. + * + * If there are no service_classes defined, everything is allowed. + * If $usage is supplied, we check against a maximum count and return true if + * the current usage is less than the subscriber plan allows. Otherwise we + * return boolean true or false if the property is allowed (or not) in this + * subscriber plan. An unset property for this service plan means the property + * is allowed, so it is only necessary to provide negative properties for each + * plan, or what the subscriber is not allowed to do. + * + * Like service_class_allows() but queries directly by account rather than channel. + * + * @see service_class_allows() if you have a channel_id instead of an account_id + * @see account_service_class_fetch() + * + * @param int $aid The account_id to check + * @param string $property The service class property to check for + * @param int|bool $usage (optional) The value to check against + * @return bool + */ + public static function account_allows($aid, $property, $usage = false) + { + + $limit = self::account_fetch($aid, $property); + + if ($limit === false) { + return true; // No service class is set => everything is allowed + } + + $limit = engr_units_to_bytes($limit); + + if ($usage === false) { + // We use negative values for not allowed properties in a subscriber plan + return (($limit) ? (bool) $limit : true); + } else { + return (((intval($usage)) < intval($limit)) ? true : false); + } + } + + /** + * @brief Queries a service class value for a channel and property. + * + * Service classes are set for accounts, so look up the account for this channel + * and fetch the service classe of the account. + * + * If no service class is available it returns false and everything should be + * allowed. + * + * @see account_service_class_fetch() + * + * @param int $uid The channel_id to query + * @param string $property The service property name to check for + * @return bool|int + * + * @todo Should we merge this with account_service_class_fetch()? + */ + public static function fetch($uid, $property) + { + + + if ($uid == local_channel()) { + $service_class = App::$account['account_service_class']; + } else { + $r = q( + "select account_service_class + from channel c, account a + where c.channel_account_id = a.account_id and c.channel_id = %d limit 1", + intval($uid) + ); + if ($r) { + $service_class = $r[0]['account_service_class']; + } + } + if (! $service_class) { + return false; // everything is allowed + } + $arr = get_config('service_class', $service_class); + + if (! is_array($arr) || (! count($arr))) { + return false; + } + + return((array_key_exists($property, $arr)) ? $arr[$property] : false); + } + + /** + * @brief Queries a service class value for an account and property. + * + * Like service_class_fetch() but queries by account rather than channel. + * + * @see service_class_fetch() if you have channel_id. + * @see account_service_class_allows() + * + * @param int $aid The account_id to query + * @param string $property The service property name to check for + * @return bool|int + */ + public static function account_fetch($aid, $property) + { + + $r = q( + "select account_service_class as service_class from account where account_id = %d limit 1", + intval($aid) + ); + if ($r !== false && count($r)) { + $service_class = $r[0]['service_class']; + } + + if (! x($service_class)) { + return false; // everything is allowed + } + + $arr = get_config('service_class', $service_class); + + if (! is_array($arr) || (! count($arr))) { + return false; + } + + return((array_key_exists($property, $arr)) ? $arr[$property] : false); + } + + + public static function upgrade_link($bbcode = false) + { + $l = get_config('service_class', 'upgrade_link'); + if (! $l) { + return ''; + } + if ($bbcode) { + $t = sprintf('[zrl=%s]' . t('Click here to upgrade.') . '[/zrl]', $l); + } else { + $t = sprintf('' . t('Click here to upgrade.') . '', $l); + } + return $t; + } + + public static function upgrade_message($bbcode = false) + { + $x = self::upgrade_link($bbcode); + return t('This action exceeds the limits set by your subscription plan.') . (($x) ? ' ' . $x : '') ; + } + + public static function upgrade_bool_message($bbcode = false) + { + $x = self::upgrade_link($bbcode); + return t('This action is not available under your subscription plan.') . (($x) ? ' ' . $x : '') ; + } +} diff --git a/Code/Lib/Share.php b/Code/Lib/Share.php new file mode 100644 index 000000000..0641373c4 --- /dev/null +++ b/Code/Lib/Share.php @@ -0,0 +1,249 @@ +item = $post_id; + return; + } + + if (! (local_channel() || remote_channel())) { + return; + } + + $r = q( + "SELECT * from item left join xchan on author_xchan = xchan_hash WHERE id = %d LIMIT 1", + intval($post_id) + ); + if (! $r) { + return; + } + + if (($r[0]['item_private']) && ($r[0]['xchan_network'] !== 'rss')) { + return; + } + + $sql_extra = item_permissions_sql($r[0]['uid']); + + $r = q( + "select * from item where id = %d $sql_extra", + intval($post_id) + ); + if (! $r) { + return; + } + + if (! in_array($r[0]['mimetype'], [ 'text/bbcode', 'text/x-multicode' ])) { + return; + } + + /** @FIXME eventually we want to post remotely via rpost on your home site */ + // When that works remove this next bit: + + if (! local_channel()) { + return; + } + + xchan_query($r); + + $this->item = array_shift($r); + + $arr = []; + + $owner_uid = $this->item['uid']; + $owner_aid = $this->item['aid']; + + $channel = Channel::from_id($this->item['uid']); + $observer = App::get_observer(); + + $can_comment = false; + if ((array_key_exists('owner', $this->item)) && intval($this->item['owner']['abook_self'])) { + $can_comment = perm_is_allowed($this->item['uid'], $observer['xchan_hash'], 'post_comments'); + } else { + $can_comment = can_comment_on_post($observer['xchan_hash'], $this->item); + } + + if (! $can_comment) { + return; + } + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($this->item['owner_xchan']) + ); + + if ($r) { + $thread_owner = array_shift($r); + } else { + return; + } + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($this->item['author_xchan']) + ); + if ($r) { + $item_author = array_shift($r); + } else { + return; + } + + if (! $this->attach) { + $this->attach = []; + } + + $this->attach[] = [ + 'href' => $this->item['mid'], + 'type' => 'application/activity+json', + 'title' => $this->item['mid'] + ]; + + if ($item_author['network'] === 'activitypub') { + // for Mastodon compatibility, send back an ActivityPub Announce activity. + // We don't need or want these on our own network as there is no mechanism for providing + // a fair-use defense to copyright claims and frivolous lawsuits. + + $arr['aid'] = $owner_aid; + $arr['uid'] = $owner_uid; + + $arr['item_origin'] = 1; + $arr['item_wall'] = $this->item['item_wall']; + $arr['uuid'] = new_uuid(); + $arr['mid'] = z_root() . '/item/' . $arr['uuid']; + $arr['mid'] = str_replace('/item/', '/activity/', $arr['mid']); + $arr['parent_mid'] = $this->item['mid']; + + $mention = '@[zrl=' . $this->item['author']['xchan_url'] . ']' . $this->item['author']['xchan_name'] . '[/zrl]'; + $arr['body'] = sprintf(t('🔁 Repeated %1$s\'s %2$s'), $mention, $this->item['obj_type']); + + $arr['author_xchan'] = $observer['xchan_hash']; + $arr['owner_xchan'] = $this->item['author_xchan']; + $arr['obj'] = $this->item['obj']; + $arr['obj_type'] = $this->item['obj_type']; + $arr['verb'] = 'Announce'; + + $post = item_store($arr); + + $post_id = $post['item_id']; + + $arr['id'] = $post_id; + + Hook::call('post_local_end', $arr); + + $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($channel['channel_id'], [ 'item' => [ encode_item($sync_item[0], true) ] ]); + } + + Run::Summon([ 'Notifier','like',$post_id ]); + } + + return; + } + + public function obj() + { + $obj = []; + + if (! $this->item) { + return $obj; + } + + $obj['type'] = $this->item['obj_type']; + $obj['id'] = $this->item['mid']; + $obj['content'] = bbcode($this->item['body']); + $obj['source'] = [ + 'mediaType' => $this->item['mimetype'], + 'content' => $this->item['body'] + ]; + + $obj['name'] = $this->item['title']; + $obj['published'] = $this->item['created']; + $obj['updated'] = $this->item['edited']; + $obj['attributedTo'] = ((strpos($this->item['author']['xchan_hash'], 'http') === 0) + ? $this->item['author']['xchan_hash'] + : $this->item['author']['xchan_url']); + + return $obj; + } + + public function get_attach() + { + return $this->attach; + } + + public function bbcode() + { + $bb = EMPTY_STR; + + if (! $this->item) { + return $bb; + } + + if (! $this->item['author']) { + $author = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($this->item['author_xchan']) + ); + if ($author) { + $this->item['author'] = array_shift($author); + } + } + + $special_object = (in_array($this->item['obj_type'], [ ACTIVITY_OBJ_PHOTO, 'Event', 'Question' ]) ? true : false); + if ($special_object) { + $object = json_decode($this->item['obj'], true); + $special = (($object['source']) ? $object['source']['content'] : $object['body']); + } + + if (strpos($this->item['body'], "[/share]") !== false) { + $pos = strpos($this->item['body'], "[share"); + $bb = substr($this->item['body'], $pos); + } else { + $bb = "[share author='" . urlencode($this->item['author']['xchan_name']) . + "' profile='" . $this->item['author']['xchan_url'] . + "' portable_id='" . $this->item['author']['xchan_hash'] . + "' avatar='" . $this->item['author']['xchan_photo_s'] . + "' link='" . $this->item['plink'] . + "' auth='" . (in_array($this->item['author']['network'],['nomad','zot6']) ? 'true' : 'false') . + "' posted='" . $this->item['created'] . + "' message_id='" . $this->item['mid'] . + "']"; + if ($this->item['title']) { + $bb .= '[b]' . $this->item['title'] . '[/b]' . "\r\n"; + } + if ($this->item['summary']) { + $bb .= $this->item['summary'] . "\r\n"; + } + + $bb .= (($special_object) ? $special . "\r\n" . $this->item['body'] : $this->item['body']); + $bb .= "[/share]"; + } + + return $bb; + } +} diff --git a/Code/Lib/Socgraph.php b/Code/Lib/Socgraph.php new file mode 100644 index 000000000..293f49ed7 --- /dev/null +++ b/Code/Lib/Socgraph.php @@ -0,0 +1,696 @@ + $max) { + break; + } + } + logger("poco_load: loaded $total entries", LOGGER_DEBUG); + + q( + "delete from xlink where xlink_xchan = '%s' and xlink_updated < %s - INTERVAL %s and xlink_static = 0", + dbesc($xchan), + db_utcnow(), + db_quoteinterval('7 DAY') + ); + } + + + + + public static function ap_poco_load($xchan) + { + + $max = intval(get_config('system', 'max_imported_follow', MAX_IMPORTED_FOLLOW)); + if (! intval($max)) { + return; + } + + + if ($xchan) { + $cl = get_xconfig($xchan, 'activitypub', 'collections'); + if (is_array($cl) && $cl) { + $url = ((array_key_exists('following', $cl)) ? $cl['following'] : ''); + } else { + return false; + } + } + + if (! $url) { + logger('ap_poco_load: no url'); + return; + } + + $obj = new ASCollection($url, '', 0, $max); + + $friends = $obj->get(); + + if (! $friends) { + return; + } + + foreach ($friends as $entry) { + $hash = EMPTY_STR; + + $x = q( + "select xchan_hash from xchan where (xchan_hash = '%s' or xchan_url = '%s') order by xchan_network desc limit 1", + dbesc($entry), + dbesc($entry) + ); + + + if ($x) { + $hash = $x[0]['xchan_hash']; + } else { + // We've never seen this person before. Import them. + + $wf = discover_by_webbie($entry); + if ($wf) { + $x = q( + "select xchan_hash from xchan where (xchan_hash = '%s' or xchan_url = '%s') order by xchan_network desc limit 1", + dbesc($wf), + dbesc($wf) + ); + if ($x) { + $hash = $x[0]['xchan_hash']; + } + } + } + + if (! $hash) { + continue; + } + + $total++; + + $r = q( + "select * from xlink where xlink_xchan = '%s' and xlink_link = '%s' and xlink_static = 0 limit 1", + dbesc($xchan), + dbesc($hash) + ); + + if (! $r) { + q( + "insert into xlink ( xlink_xchan, xlink_link, xlink_rating, xlink_rating_text, xlink_sig, xlink_updated, xlink_static ) values ( '%s', '%s', %d, '%s', '%s', '%s', 0 ) ", + dbesc($xchan), + dbesc($hash), + intval(0), + dbesc(''), + dbesc(''), + dbesc(datetime_convert()) + ); + } else { + q( + "update xlink set xlink_updated = '%s' where xlink_id = %d", + dbesc(datetime_convert()), + intval($r[0]['xlink_id']) + ); + } + } + + logger("ap_poco_load: loaded $total entries", LOGGER_DEBUG); + + q( + "delete from xlink where xlink_xchan = '%s' and xlink_updated < %s - INTERVAL %s and xlink_static = 0", + dbesc($xchan), + db_utcnow(), + db_quoteinterval('7 DAY') + ); + + return true; + } + + + public static function count_common_friends($uid, $xchan) + { + + $r = q( + "SELECT count(xlink_id) as total from xlink where xlink_xchan = '%s' and xlink_static = 0 and xlink_link in + (select abook_xchan from abook where abook_xchan != '%s' and abook_channel = %d and abook_self = 0 )", + dbesc($xchan), + dbesc($xchan), + intval($uid) + ); + + if ($r) { + return $r[0]['total']; + } + return 0; + } + + + public static function common_friends($uid, $xchan, $start = 0, $limit = 100000000, $shuffle = false) + { + + $rand = db_getfunc('rand'); + if ($shuffle) { + $sql_extra = " order by $rand "; + } else { + $sql_extra = " order by xchan_name asc "; + } + + $r = q( + "SELECT * from xchan left join xlink on xlink_link = xchan_hash where xlink_xchan = '%s' and xlink_static = 0 and xlink_link in + (select abook_xchan from abook where abook_xchan != '%s' and abook_channel = %d and abook_self = 0 ) $sql_extra limit %d offset %d", + dbesc($xchan), + dbesc($xchan), + intval($uid), + intval($limit), + intval($start) + ); + + return $r; + } + + + public static function suggestion_query($uid, $myxchan, $start = 0, $limit = 120) + { + + if ((! $uid) || (! $myxchan)) { + return []; + } + + $r1 = q( + "SELECT count(xlink_xchan) as total, xchan.* from xchan + left join xlink on xlink_link = xchan_hash + where xlink_xchan in ( select abook_xchan from abook where abook_channel = %d ) + and not xlink_link in ( select abook_xchan from abook where abook_channel = %d ) + and not xlink_link in ( select xchan from xign where uid = %d ) + and xlink_xchan != '' + and xchan_hidden = 0 + and xchan_deleted = 0 + and xlink_static = 0 + group by xchan_hash order by total desc limit %d offset %d ", + intval($uid), + intval($uid), + intval($uid), + intval($limit), + intval($start) + ); + + if (! $r1) { + $r1 = []; + } + + $r2 = q( + "SELECT count(xtag_hash) as total, xchan.* from xchan + left join xtag on xtag_hash = xchan_hash + where xtag_hash != '%s' + and not xtag_hash in ( select abook_xchan from abook where abook_channel = %d ) + and xtag_term in ( select xtag_term from xtag where xtag_hash = '%s' ) + and not xtag_hash in ( select xchan from xign where uid = %d ) + and xchan_hidden = 0 + and xchan_deleted = 0 + group by xchan_hash order by total desc limit %d offset %d ", + dbesc($myxchan), + intval($uid), + dbesc($myxchan), + intval($uid), + intval($limit), + intval($start) + ); + + if (! $r2) { + $r2 = []; + } + + foreach ($r2 as $r) { + $found = false; + for ($x = 0; $x < count($r1); $x++) { + if ($r['xchan_hash'] === $r1[$x]['xchan_hash']) { + $r1[$x]['total'] = intval($r1[$x]['total']) + intval($r['total']); + $found = true; + continue; + } + } + if (! $found) { + $r1[] = $r; + } + } + + usort($r1, 'self::socgraph_total_sort'); + return ($r1); + } + + public static function socgraph_total_sort($a, $b) + { + if ($a['total'] === $b['total']) { + return 0; + } + + return((intval($a['total']) < intval($b['total'])) ? 1 : -1 ); + } + + + public static function poco() + { + + $system_mode = false; + + if (observer_prohibited()) { + logger('mod_poco: block_public'); + http_status_exit(401); + } + + $observer = App::get_observer(); + + if (argc() > 1) { + $user = notags(trim(argv(1))); + } + if (! (isset($user) && $user)) { + $c = q("select * from pconfig where cat = 'system' and k = 'suggestme' and v = '1'"); + if (! $c) { + logger('mod_poco: system mode. No candidates.', LOGGER_DEBUG); + http_status_exit(404); + } + $system_mode = true; + } + + $format = ((isset($_REQUEST['format']) && $_REQUEST['format']) ? $_REQUEST['format'] : 'json'); + + $justme = false; + + if (argc() > 2 && argv(2) === '@me') { + $justme = true; + } + if (argc() > 3) { + if (argv(3) === '@all') { + $justme = false; + } elseif (argv(3) === '@self') { + $justme = true; + } + } + if (argc() > 4 && intval(argv(4)) && $justme == false) { + $cid = intval(argv(4)); + } + + if (! $system_mode) { + $r = q( + "SELECT channel_id from channel where channel_address = '%s' limit 1", + dbesc($user) + ); + if (! $r) { + logger('mod_poco: user mode. Account not found. ' . $user); + http_status_exit(404); + } + + $channel_id = $r[0]['channel_id']; + $ohash = (($observer) ? $observer['xchan_hash'] : ''); + + if (! perm_is_allowed($channel_id, $ohash, 'view_contacts')) { + logger('mod_poco: user mode. Permission denied for ' . $ohash . ' user: ' . $user); + http_status_exit(401); + } + } + + if (isset($justme) && $justme) { + $sql_extra = " and abook_self = 1 "; + } else { + $sql_extra = " and abook_self = 0 "; + } + + if (isset($cid) && $cid) { + $sql_extra = sprintf(" and abook_id = %d and abook_archived = 0 and abook_hidden = 0 and abook_pending = 0 ", intval($cid)); + } + + if (isset($system_mode) && $system_mode) { + $r = q("SELECT count(*) as total from abook where abook_self = 1 + and abook_channel in (select uid from pconfig where cat = 'system' and k = 'suggestme' and v = '1') "); + } else { + $r = q( + "SELECT count(*) as total from abook where abook_channel = %d + $sql_extra ", + intval($channel_id) + ); + $rooms = q( + "select * from menu_item where ( mitem_flags & " . intval(MENU_ITEM_CHATROOM) . " ) > 0 and allow_cid = '' and allow_gid = '' and deny_cid = '' and deny_gid = '' and mitem_channel_id = %d", + intval($channel_id) + ); + } + if ($r) { + $totalResults = intval($r[0]['total']); + } else { + $totalResults = 0; + } + + $startIndex = ((isset($_GET['startIndex'])) ? intval($_GET['startIndex']) : 0); + if ($startIndex < 0) { + $startIndex = 0; + } + + $itemsPerPage = ((isset($_GET['count']) && intval($_GET['count'])) ? intval($_GET['count']) : $totalResults); + + if ($system_mode) { + $r = q( + "SELECT abook.*, xchan.* from abook left join xchan on abook_xchan = xchan_hash where abook_self = 1 + and abook_channel in (select uid from pconfig where cat = 'system' and k = 'suggestme' and v = '1') + limit %d offset %d ", + intval($itemsPerPage), + intval($startIndex) + ); + } else { + $r = q( + "SELECT abook.*, xchan.* from abook left join xchan on abook_xchan = xchan_hash where abook_channel = %d + $sql_extra LIMIT %d OFFSET %d", + intval($channel_id), + intval($itemsPerPage), + intval($startIndex) + ); + } + + $ret = []; + if (x($_GET, 'sorted')) { + $ret['sorted'] = 'false'; + } + if (x($_GET, 'filtered')) { + $ret['filtered'] = 'false'; + } + if (x($_GET, 'updatedSince')) { + $ret['updateSince'] = 'false'; + } + + $ret['startIndex'] = (string) $startIndex; + $ret['itemsPerPage'] = (string) $itemsPerPage; + $ret['totalResults'] = (string) $totalResults; + + if ($rooms) { + $ret['chatrooms'] = []; + foreach ($rooms as $room) { + $ret['chatrooms'][] = array('url' => $room['mitem_link'], 'desc' => $room['mitem_desc']); + } + } + + $ret['entry'] = []; + + $fields_ret = array( + 'id' => false, + 'guid' => false, + 'guid_sig' => false, + 'hash' => false, + 'displayName' => false, + 'urls' => false, + 'preferredUsername' => false, + 'photos' => false, + 'rating' => false + ); + + if ((! x($_GET, 'fields')) || ($_GET['fields'] === '@all')) { + foreach ($fields_ret as $k => $v) { + $fields_ret[$k] = true; + } + } else { + $fields_req = explode(',', $_GET['fields']); + foreach ($fields_req as $f) { + $fields_ret[trim($f)] = true; + } + } + + if (is_array($r)) { + if (count($r)) { + foreach ($r as $rr) { + $entry = []; + if ($fields_ret['id']) { + $entry['id'] = $rr['abook_id']; + } + if ($fields_ret['guid']) { + $entry['guid'] = $rr['xchan_guid']; + } + if ($fields_ret['guid_sig']) { + $entry['guid_sig'] = $rr['xchan_guid_sig']; + } + if ($fields_ret['hash']) { + $entry['hash'] = $rr['xchan_hash']; + } + + if ($fields_ret['displayName']) { + $entry['displayName'] = $rr['xchan_name']; + } + if ($fields_ret['urls']) { + $entry['urls'] = array(array('value' => $rr['xchan_url'], 'type' => 'profile')); + $network = $rr['xchan_network']; + if ($rr['xchan_addr']) { + $entry['urls'][] = array('value' => 'acct:' . $rr['xchan_addr'], 'type' => $network); + } + } + if ($fields_ret['preferredUsername']) { + $entry['preferredUsername'] = substr($rr['xchan_addr'], 0, strpos($rr['xchan_addr'], '@')); + } + if ($fields_ret['photos']) { + $entry['photos'] = array(array('value' => $rr['xchan_photo_l'], 'mimetype' => $rr['xchan_photo_mimetype'], 'type' => 'profile')); + } + $ret['entry'][] = $entry; + } + } else { + $ret['entry'][] = []; + } + } else { + http_status_exit(500); + } + + if ($format === 'xml') { + header('Content-type: text/xml'); + echo replace_macros(Theme::get_template('poco_xml.tpl'), array_xmlify(array('$response' => $ret))); + http_status_exit(500); + } + if ($format === 'json') { + header('Content-type: application/json'); + echo json_encode($ret); + killme(); + } else { + http_status_exit(500); + } + } + +} \ No newline at end of file diff --git a/Code/Lib/Statistics.php b/Code/Lib/Statistics.php new file mode 100644 index 000000000..cde30dde6 --- /dev/null +++ b/Code/Lib/Statistics.php @@ -0,0 +1,62 @@ + %s - INTERVAL %s", + db_utcnow(), + db_quoteinterval('6 MONTH') + ); + $total = ($r) ? count($r) : 0; + Config::Set('system', 'channels_active_halfyear_stat', $total); + return $total; + } + + function get_channels_1mo() + { + $r = q( + "select channel_id from channel left join account on account_id = channel_account_id + where account_flags = 0 and channel_active > %s - INTERVAL %s", + db_utcnow(), + db_quoteinterval('1 MONTH') + ); + $total = ($r) ? count($r) : 0; + Config::Set('system', 'channels_active_monthly_stat', $total); + return $total; + } + + function get_posts() + { + $posts = q("SELECT COUNT(*) AS local_posts FROM item WHERE item_wall = 1 and id = parent"); + $total = ($posts) ? intval($posts[0]['local_posts']) : 0; + Config::Set('system', 'local_posts_stat', $total); + return $total; + } + + function get_comments() + { + $posts = q("SELECT COUNT(*) AS local_posts FROM item WHERE item_wall = 1 and id != parent"); + $total = ($posts) ? intval($posts[0]['local_posts']) : 0; + Config::Set('system', 'local_comments_stat', $total); + return $total; + } + +} diff --git a/Code/Lib/Stringsjs.php b/Code/Lib/Stringsjs.php new file mode 100644 index 000000000..7ec6cb5a2 --- /dev/null +++ b/Code/Lib/Stringsjs.php @@ -0,0 +1,129 @@ + '/images/' . PLATFORM_NAME . '-64.png', + '$delitem' => t('Delete this item?'), + '$comment' => t('Comment'), + '$showmore' => sprintf(t('%s show all'), ''), + '$showfewer' => sprintf(t('%s show less'), ''), + '$divgrowmore' => sprintf(t('%s expand'), ''), + '$divgrowless' => sprintf(t('%s collapse'), ''), + '$pwshort' => t("Password too short"), + '$pwnomatch' => t("Passwords do not match"), + '$everybody' => t('everybody'), + '$passphrase' => t('Secret Passphrase'), + '$passhint' => t('Passphrase hint'), + '$permschange' => t('Notice: Permissions have changed but have not yet been submitted.'), + '$closeAll' => t('close all'), + '$nothingnew' => t('Nothing new here'), + '$rating_desc' => t('Rate This Channel (this is public)'), + '$rating_val' => t('Rating'), + '$rating_text' => t('Describe (optional)'), + '$submit' => t('Submit'), + '$linkurl' => t('Please enter a link URL'), + '$leavethispage' => t('Unsaved changes. Are you sure you wish to leave this page?'), + '$location' => t('Location'), + '$lovely' => t('lovely'), + '$wonderful' => t('wonderful'), + '$fantastic' => t('fantastic'), + '$great' => t('great'), + '$nick_invld1' => t('Your chosen nickname was either already taken or not valid. Please use our suggestion ('), + '$nick_invld2' => t(') or enter a new one.'), + '$nick_valid' => t('Thank you, this nickname is valid.'), + '$name_empty' => t('A channel name is required.'), + '$name_ok1' => t('This is a '), + '$name_ok2' => t(' channel name'), + '$pinned' => t('Pinned'), + '$pin_item' => t('Pin this post'), + '$unpin_item' => t('Unpin this post'), + '$tos' => t('Please accept terms to continue'), + + // translatable prefix and suffix strings for jquery.timeago - + // using the defaults set below if left untranslated, empty strings if + // translated to "NONE" and the corresponding language strings + // if translated to anything else + '$t01' => ((t('timeago.prefixAgo') == 'timeago.prefixAgo') ? '' : ((t('timeago.prefixAgo') == 'NONE') ? '' : t('timeago.prefixAgo'))), + '$t02' => ((t('timeago.prefixFromNow') == 'timeago.prefixFromNow') ? '' : ((t('timeago.prefixFromNow') == 'NONE') ? '' : t('timeago.prefixFromNow'))), + '$t03' => ((t('timeago.suffixAgo') == 'timeago.suffixAgo') ? 'ago' : ((t('timeago.suffixAgo') == 'NONE') ? '' : t('timeago.suffixAgo'))), + '$t04' => ((t('timeago.suffixFromNow') == 'timeago.suffixFromNow') ? 'from now' : ((t('timeago.suffixFromNow') == 'NONE') ? '' : t('timeago.suffixFromNow'))), + + // translatable main strings for jquery.timeago + '$t05' => t('less than a minute'), + '$t06' => t('about a minute'), + '$t07' => t('%d minutes'), + '$t08' => t('about an hour'), + '$t09' => t('about %d hours'), + '$t10' => t('a day'), + '$t11' => t('%d days'), + '$t12' => t('about a month'), + '$t13' => t('%d months'), + '$t14' => t('about a year'), + '$t15' => t('%d years'), + '$t16' => t(' '), // wordSeparator + '$t17' => ((t('timeago.numbers') != 'timeago.numbers') ? t('timeago.numbers') : '[]'), + + '$January' => t('January'), + '$February' => t('February'), + '$March' => t('March'), + '$April' => t('April'), + '$May' => t('May', 'long'), + '$June' => t('June'), + '$July' => t('July'), + '$August' => t('August'), + '$September' => t('September'), + '$October' => t('October'), + '$November' => t('November'), + '$December' => t('December'), + '$Jan' => t('Jan'), + '$Feb' => t('Feb'), + '$Mar' => t('Mar'), + '$Apr' => t('Apr'), + '$MayShort' => t('May', 'short'), + '$Jun' => t('Jun'), + '$Jul' => t('Jul'), + '$Aug' => t('Aug'), + '$Sep' => t('Sep'), + '$Oct' => t('Oct'), + '$Nov' => t('Nov'), + '$Dec' => t('Dec'), + '$Sunday' => t('Sunday'), + '$Monday' => t('Monday'), + '$Tuesday' => t('Tuesday'), + '$Wednesday' => t('Wednesday'), + '$Thursday' => t('Thursday'), + '$Friday' => t('Friday'), + '$Saturday' => t('Saturday'), + '$Sun' => t('Sun'), + '$Mon' => t('Mon'), + '$Tue' => t('Tue'), + '$Wed' => t('Wed'), + '$Thu' => t('Thu'), + '$Fri' => t('Fri'), + '$Sat' => t('Sat'), + '$today' => t('today', 'calendar'), + '$month' => t('month', 'calendar'), + '$week' => t('week', 'calendar'), + '$day' => t('day', 'calendar'), + '$allday' => t('All day', 'calendar'), + '$channel_social' => t('A social networking profile that is public by default and private if desired'), + '$channel_social_restricted' => t('A social networking profile where content is private to your [Friends] Access List by default but can be made public if desired'), + '$channel_forum' => t('A public group where members are allowed to upload media by default'), + '$channel_forum_restricted' => t('A private group with no upload permission'), + '$channel_forum_moderated' => t('A public group where posts are moderated by the owner. The [moderated] permission may be removed from any group member once trust is established'), + '$channel_collection' => t('A sub-channel of your main channel - often devoted to a specific language or topic. Replies are sent back to your main channel'), + '$channel_collection_restricted' => t('A private sub-channel of your main channel - often devoted to a specific language or topic. Replies are sent back to your main channel'), + )); + } + + + +} \ No newline at end of file diff --git a/Code/Lib/SvgSanitizer.php b/Code/Lib/SvgSanitizer.php new file mode 100644 index 000000000..2ad2b6676 --- /dev/null +++ b/Code/Lib/SvgSanitizer.php @@ -0,0 +1,160 @@ + ['class', 'clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'id', 'mask', 'opacity', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform', 'href', 'xlink:href', 'xlink:title'], + 'circle' => ['class', 'clip-path', 'clip-rule', 'cx', 'cy', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'id', 'mask', 'opacity', 'r', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform'], + 'clipPath' => ['class', 'clipPathUnits', 'id'], + 'defs' => [], + 'style' => ['type'], + 'desc' => [], + 'ellipse' => ['class', 'clip-path', 'clip-rule', 'cx', 'cy', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'id', 'mask', 'opacity', 'requiredFeatures', 'rx', 'ry', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform'], + 'feGaussianBlur' => ['class', 'color-interpolation-filters', 'id', 'requiredFeatures', 'stdDeviation'], + 'filter' => ['class', 'color-interpolation-filters', 'filterRes', 'filterUnits', 'height', 'id', 'primitiveUnits', 'requiredFeatures', 'width', 'x', 'xlink:href', 'y'], + 'foreignObject' => ['class', 'font-size', 'height', 'id', 'opacity', 'requiredFeatures', 'style', 'transform', 'width', 'x', 'y'], + 'g' => ['class', 'clip-path', 'clip-rule', 'id', 'display', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform', 'font-family', 'font-size', 'font-style', 'font-weight', 'text-anchor'], + 'image' => ['class', 'clip-path', 'clip-rule', 'filter', 'height', 'id', 'mask', 'opacity', 'requiredFeatures', 'style', 'systemLanguage', 'transform', 'width', 'x', 'xlink:href', 'xlink:title', 'y'], + 'line' => ['class', 'clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'id', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform', 'x1', 'x2', 'y1', 'y2'], + 'linearGradient' => ['class', 'id', 'gradientTransform', 'gradientUnits', 'requiredFeatures', 'spreadMethod', 'systemLanguage', 'x1', 'x2', 'xlink:href', 'y1', 'y2'], + 'marker' => ['id', 'class', 'markerHeight', 'markerUnits', 'markerWidth', 'orient', 'preserveAspectRatio', 'refX', 'refY', 'systemLanguage', 'viewBox'], + 'mask' => ['class', 'height', 'id', 'maskContentUnits', 'maskUnits', 'width', 'x', 'y'], + 'metadata' => ['class', 'id'], + 'path' => ['class', 'clip-path', 'clip-rule', 'd', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'id', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform'], + 'pattern' => ['class', 'height', 'id', 'patternContentUnits', 'patternTransform', 'patternUnits', 'requiredFeatures', 'style', 'systemLanguage', 'viewBox', 'width', 'x', 'xlink:href', 'y'], + 'polygon' => ['class', 'clip-path', 'clip-rule', 'id', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'id', 'class', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'points', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform'], + 'polyline' => ['class', 'clip-path', 'clip-rule', 'id', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'opacity', 'points', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform'], + 'radialGradient' => ['class', 'cx', 'cy', 'fx', 'fy', 'gradientTransform', 'gradientUnits', 'id', 'r', 'requiredFeatures', 'spreadMethod', 'systemLanguage', 'xlink:href'], + 'rect' => ['class', 'clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'height', 'id', 'mask', 'opacity', 'requiredFeatures', 'rx', 'ry', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform', 'width', 'x', 'y'], + 'stop' => ['class', 'id', 'offset', 'requiredFeatures', 'stop-color', 'stop-opacity', 'style', 'systemLanguage'], + 'svg' => ['class', 'clip-path', 'clip-rule', 'filter', 'id', 'height', 'mask', 'preserveAspectRatio', 'requiredFeatures', 'style', 'systemLanguage', 'viewBox', 'width', 'x', 'xmlns', 'xmlns:se', 'xmlns:xlink', 'y'], + 'switch' => ['class', 'id', 'requiredFeatures', 'systemLanguage'], + 'symbol' => ['class', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'id', 'opacity', 'preserveAspectRatio', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'transform', 'viewBox'], + 'text' => ['class', 'clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'id', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'text-anchor', 'transform', 'x', 'xml:space', 'y'], + 'textPath' => ['class', 'id', 'method', 'requiredFeatures', 'spacing', 'startOffset', 'style', 'systemLanguage', 'transform', 'xlink:href'], + 'title' => [], + 'tspan' => ['class', 'clip-path', 'clip-rule', 'dx', 'dy', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'id', 'mask', 'opacity', 'requiredFeatures', 'rotate', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'systemLanguage', 'text-anchor', 'textLength', 'transform', 'x', 'xml:space', 'y'], + 'use' => ['class', 'clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'height', 'id', 'mask', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'transform', 'width', 'x', 'xlink:href', 'y'], + ]; + + public function __construct() + { + $this->xmlDoc = new DOMDocument('1.0', 'UTF-8'); + $this->xmlDoc->preserveWhiteSpace = false; + libxml_use_internal_errors(true); + } + + // load XML SVG + public function load($file) + { + $this->xmlDoc->load($file); + } + + public function loadXML($str) + { + if (!$str) { + logger('loadxml: empty input', LOGGER_DEBUG); + return false; + } + if (!$this->xmlDoc->loadXML($str)) { + logger('loadxml: ' . print_r(array_slice(libxml_get_errors(), 0, Config::Get('system', 'svg_backtrace_limit', 3)), true), LOGGER_DEBUG); + return false; + } + return true; + } + + public function sanitize() + { + // all elements in xml doc + $allElements = $this->xmlDoc->getElementsByTagName('*'); + + // loop through all elements + for ($i = 0; $i < $allElements->length; $i++) { + $this->removedattrs = []; + + $currentNode = $allElements->item($i); + + // logger('current_node: ' . print_r($currentNode,true)); + + // array of allowed attributes in specific element + $allowlist_attr_arr = self::$allowlist[$currentNode->tagName]; + + // does element exist in allowlist? + if (isset($allowlist_attr_arr)) { + $total = $currentNode->attributes->length; + + for ($x = 0; $x < $total; $x++) { + // get attributes name + $attrName = $currentNode->attributes->item($x)->nodeName; + + // logger('checking: ' . print_r($currentNode->attributes->item($x),true)); + $matches = false; + + // check if attribute isn't in allowlist + if (!in_array($attrName, $allowlist_attr_arr)) { + $this->removedattrs[] = $attrName; + } // check for disallowed functions + elseif ( + preg_match_all( + '/([a-zA-Z0-9]+)[\s]*\(/', + $currentNode->attributes->item($x)->textContent, + $matches, + PREG_SET_ORDER + ) + ) { + if ($attrName === 'text') { + continue; + } + foreach ($matches as $match) { + if (!in_array($match[1], self::$allowed_functions)) { + logger('queue_remove_function: ' . $match[1], LOGGER_DEBUG); + $this->removedattrs[] = $attrName; + } + } + } + } + if ($this->removedattrs) { + foreach ($this->removedattrs as $attr) { + $currentNode->removeAttribute($attr); + logger('removed: ' . $attr, LOGGER_DEBUG); + } + } + } // else remove element + else { + logger('remove_node: ' . print_r($currentNode, true)); + $currentNode->parentNode->removeChild($currentNode); + } + } + return true; + } + + public function saveSVG() + { + $this->xmlDoc->formatOutput = true; + return ($this->xmlDoc->saveXML()); + } +} diff --git a/Code/Lib/System.php b/Code/Lib/System.php new file mode 100644 index 000000000..cb3f2922b --- /dev/null +++ b/Code/Lib/System.php @@ -0,0 +1,170 @@ + ZOT_REVISION ]; + Hook::call('zot_revision', $x); + return $x['revision']; + } + + public static function get_std_version() + { + if (defined('STD_VERSION')) { + return STD_VERSION; + } + return '0.0.0'; + } + + public static function compatible_project($p) + { + + if (in_array(strtolower($p), ['hubzilla', 'zap', 'red', 'misty', 'mistpark', 'redmatrix', 'osada', 'roadhouse','streams'])) { + return true; + } + return false; + } +} diff --git a/Code/Lib/ThreadItem.php b/Code/Lib/ThreadItem.php new file mode 100644 index 000000000..6356393eb --- /dev/null +++ b/Code/Lib/ThreadItem.php @@ -0,0 +1,1081 @@ +data = $data; + $this->toplevel = ($this->get_id() == $this->get_data_value('parent')); + $this->threaded = get_config('system', 'thread_allow', true); + + $observer = App::get_observer(); + + // Prepare the children + if ($data['children']) { + foreach ($data['children'] as $item) { + /* + * Only add those that will be displayed + */ + + if (! visible_activity($item)) { + continue; + } + + // this is a quick hack to hide ActivityPub DMs that we should not be allowed to see + // but may have been forwarded as part of a conversation + + if (intval($item['item_private']) && (intval($item['item_restrict']) & 1 ) && $item['mid'] !== $item['parent_mid']) { + if (! $observer) { + continue; + } + } + + $child = new ThreadItem($item); + $this->add_child($child); + } + } + + // allow a site to configure the order and content of the reaction emoji list + if ($this->toplevel) { + $x = get_config('system', 'reactions'); + if ($x && is_array($x) && count($x)) { + $this->reactions = $x; + } + } + } + + /** + * Get data in a form usable by a conversation template + * + * Returns: + * _ The data requested on success + * _ false on failure + */ + + public function get_template_data($conv_responses, $thread_level = 1) + { + + $result = []; + + $item = $this->get_data(); + + $commentww = ''; + $sparkle = ''; + $buttons = ''; + $dropping = false; + $star = false; + $isstarred = "unstarred fa-star-o"; + $is_comment = false; + $is_item = false; + $osparkle = ''; + $total_children = $this->count_descendants(); + $unseen_comments = ((isset($item['real_uid']) && $item['real_uid']) ? 0 : $this->count_unseen_descendants()); + $privacy_warning = false; + + $conv = $this->get_conversation(); + $observer = $conv->get_observer(); + + $lock = t('Public visibility'); + if (intval($item['item_private']) === 2) { + $lock = t('Direct message (private mail)'); + } + if (intval($item['item_private']) === 1) { + $lock = t('Restricted visibility'); + } + + $locktype = intval($item['item_private']); + + $shareable = ((($conv->get_profile_owner() == local_channel() && local_channel()) && (! intval($item['item_private']))) ? true : false); + + // allow an exemption for sharing stuff from your private feeds + if ($item['author']['xchan_network'] === 'rss') { + $shareable = true; + } + + // @fixme + // Have recently added code to properly handle polls in group reshares by redirecting all of the poll responses to the group. + // Sharing a poll using a regular embedded share is harder because the poll will need to fork. This is due to comment permissions. + // The original poll author may not accept responses from strangers. Forking the poll will receive responses from the sharer's + // followers, but there's no elegant way to merge these two sets of results together. For now, we'll disable sharing polls. + + if ($item['obj_type'] === 'Question') { + $shareable = false; + } + + + if ($item['item_restrict'] & 2) { + $privacy_warning = true; + $lock = t('This comment is part of a private conversation, yet was shared with the public. Discretion advised.'); + } + + $mode = $conv->get_mode(); + + $edlink = 'editpost'; + + if (local_channel() && $observer['xchan_hash'] === $item['author_xchan']) { + $edpost = array(z_root() . '/' . $edlink . '/' . $item['id'], t('Edit')); + } else { + $edpost = false; + } + + if (local_channel() && $observer['xchan_hash'] === $item['owner_xchan']) { + $myconv = true; + } else { + $myconv = false; + } + + + if ($item['verb'] === 'Announce') { + $edpost = false; + } + + + if ( + $observer && $observer['xchan_hash'] + && ( $observer['xchan_hash'] == $this->get_data_value('author_xchan') + || $observer['xchan_hash'] == $this->get_data_value('owner_xchan') + || $observer['xchan_hash'] == $this->get_data_value('source_xchan') + || $this->get_data_value('uid') == local_channel()) + ) { + $dropping = true; + } + + + if (array_key_exists('real_uid', $item)) { + $edpost = false; + $dropping = false; + } + + + if ($dropping) { + $drop = array( + 'dropping' => $dropping, + 'delete' => t('Delete'), + ); + } elseif (is_site_admin()) { + $drop = [ 'dropping' => true, 'delete' => t('Admin Delete') ]; + } + + if (isset($observer_is_pageowner) && $observer_is_pageowner) { + $multidrop = array( + 'select' => t('Select'), + ); + } + + $filer = ((($conv->get_profile_owner() == local_channel()) && (! array_key_exists('real_uid', $item))) ? t('Save to Folder') : false); + + $profile_avatar = $item['author']['xchan_photo_m']; + $profile_link = chanlink_hash($item['author_xchan']); + $profile_name = $item['author']['xchan_name']; + + $profile_addr = $item['author']['xchan_addr'] ? $item['author']['xchan_addr'] : $item['author']['xchan_url']; + + $location = format_location($item); + $isevent = false; + $attend = null; + $canvote = false; + + // process action responses - e.g. like/dislike/attend/agree/whatever + $response_verbs = [ 'like', 'dislike' ]; + + if ($item['obj_type'] === ACTIVITY_OBJ_EVENT) { + $response_verbs[] = 'attendyes'; + $response_verbs[] = 'attendno'; + $response_verbs[] = 'attendmaybe'; + if ($this->is_commentable() && $observer) { + $isevent = true; + $attend = array( t('I will attend'), t('I will not attend'), t('I might attend')); + $undo_attend = t('Undo attendance'); + } + } + + $responses = get_responses($conv_responses, $response_verbs, $this, $item); + + $my_responses = []; + foreach ($response_verbs as $v) { + $my_responses[$v] = ((isset($conv_responses[$v][$item['mid'] . '-m']) && $conv_responses[$v][$item['mid'] . '-m']) ? 1 : 0); + } + + $like_count = ((x($conv_responses['like'], $item['mid'])) ? $conv_responses['like'][$item['mid']] : ''); + $like_list = ((x($conv_responses['like'], $item['mid'])) ? $conv_responses['like'][$item['mid'] . '-l'] : ''); + if (($like_list) && (count($like_list) > MAX_LIKERS)) { + $like_list_part = array_slice($like_list, 0, MAX_LIKERS); + array_push($like_list_part, '' . t('View all') . ''); + } else { + $like_list_part = ''; + } + if (get_config('system', 'show_like_counts', true)) { + $like_button_label = tt('Like', 'Likes', $like_count, 'noun'); + } else { + $like_button_label = t('Likes', 'noun'); + } + + $dislike_count = ((x($conv_responses['dislike'], $item['mid'])) ? $conv_responses['dislike'][$item['mid']] : ''); + $dislike_list = ((x($conv_responses['dislike'], $item['mid'])) ? $conv_responses['dislike'][$item['mid'] . '-l'] : ''); + if (get_config('system', 'show_like_counts', true)) { + $dislike_button_label = tt('Dislike', 'Dislikes', $dislike_count, 'noun'); + } else { + $dislike_button_label = t('Dislikes', 'noun'); + } + + if (($dislike_list) && (count($dislike_list) > MAX_LIKERS)) { + $dislike_list_part = array_slice($dislike_list, 0, MAX_LIKERS); + array_push($dislike_list_part, '' . t('View all') . ''); + } else { + $dislike_list_part = ''; + } + + + $showlike = ((x($conv_responses['like'], $item['mid'])) ? format_like($conv_responses['like'][$item['mid']], $conv_responses['like'][$item['mid'] . '-l'], 'like', $item['mid']) : ''); + $showdislike = ((x($conv_responses['dislike'], $item['mid'])) + ? format_like($conv_responses['dislike'][$item['mid']], $conv_responses['dislike'][$item['mid'] . '-l'], 'dislike', $item['mid']) : ''); + + /* + * We should avoid doing this all the time, but it depends on the conversation mode + * And the conv mode may change when we change the conv, or it changes its mode + * Maybe we should establish a way to be notified about conversation changes + */ + + $this->check_wall_to_wall(); + + if ($this->is_toplevel()) { + if (local_channel() && ($conv->get_profile_owner() == local_channel() || intval($item['item_private']) === 0)) { + $star = [ + 'toggle' => t('Save'), + 'isstarred' => ((intval($item['item_starred'])) ? true : false), + ]; + } + } else { + $is_comment = true; + } + + + $verified = (intval($item['item_verified']) ? t('Message signature validated') : ''); + $forged = ((($item['sig']) && (! intval($item['item_verified']))) ? t('Message signature incorrect') : ''); + $unverified = '' ; // (($this->is_wall_to_wall() && (! intval($item['item_verified']))) ? t('Message cannot be verified') : ''); + + + if ($conv->get_profile_owner() == local_channel()) { + $tagger = array( + 'tagit' => t("Add Tag"), + 'classtagger' => "", + ); + } + + $has_bookmarks = false; + if (isset($item['term']) && is_array($item['term'])) { + foreach ($item['term'] as $t) { + if ($t['ttype'] == TERM_BOOKMARK) { + $has_bookmarks = true; + } + } + } + + $has_event = false; + if (($item['obj_type'] === ACTIVITY_OBJ_EVENT) && $conv->get_profile_owner() == local_channel()) { + $has_event = true; + } + + if ($this->is_commentable() && $observer) { + $like = array( t('I like this'), t('Undo like')); + $dislike = array( t('I don\'t like this'), t('Undo dislike') ); + } + + $share = $embed = EMPTY_STR; + + if ($shareable) { + $share = t('Repeat This'); + $embed = t('Share this'); + } + + $dreport = ''; + + $keep_reports = intval(get_config('system', 'expire_delivery_reports')); + if ($keep_reports === 0) { + $keep_reports = 10; + } + + if ((! get_config('system', 'disable_dreport')) && strcmp(datetime_convert('UTC', 'UTC', $item['created']), datetime_convert('UTC', 'UTC', "now - $keep_reports days")) > 0) { + $dreport = t('Delivery Report'); + $dreport_link = gen_link_id($item['mid']); + } + $is_new = false; + + if (strcmp(datetime_convert('UTC', 'UTC', $item['created']), datetime_convert('UTC', 'UTC', 'now - 12 hours')) > 0) { + $is_new = true; + } + + localize_item($item); + + $opts = []; + if ($this->is_wall_to_wall()) { + if ($this->owner_censored) { + $opts['censored'] = true; + } + } + + $body = prepare_body($item, true, $opts); + + // $viewthread (below) is only valid in list mode. If this is a channel page, build the thread viewing link + // since we can't depend on llink or plink pointing to the right local location. + + $owner_address = substr($item['owner']['xchan_addr'], 0, strpos($item['owner']['xchan_addr'], '@')); + $viewthread = $item['llink']; + if ($conv->get_mode() === 'channel') { + $viewthread = z_root() . '/channel/' . $owner_address . '?f=&mid=' . urlencode(gen_link_id($item['mid'])); + } + + $comment_count_txt = sprintf(tt('%d comment', '%d comments', $total_children), $total_children); + $list_unseen_txt = (($unseen_comments) ? sprintf(t('%d unseen'), $unseen_comments) : ''); + + $children = $this->get_children(); + + + $has_tags = (($body['tags'] || $body['categories'] || $body['mentions'] || $body['attachments'] || $body['folders']) ? true : false); + + $dropdown_extras_arr = [ 'item' => $item , 'dropdown_extras' => '' ]; + Hook::call('dropdown_extras', $dropdown_extras_arr); + $dropdown_extras = $dropdown_extras_arr['dropdown_extras']; + + // Pinned item processing + $allowed_type = (in_array($item['item_type'], get_config('system', 'pin_types', [ ITEM_TYPE_POST ])) ? true : false); + $pinned_items = ($allowed_type ? get_pconfig($item['uid'], 'pinned', $item['item_type'], []) : []); + $pinned = ((! empty($pinned_items) && in_array($item['mid'], $pinned_items)) ? true : false); + + $tmp_item = array( + 'template' => $this->get_template(), + 'mode' => $mode, + 'item_type' => intval($item['item_type']), + 'comment_order' => $item['comment_order'], + 'parent' => $this->get_data_value('parent'), + 'collapsed' => ((intval($item['comment_order']) > 3) ? true : false), + 'type' => implode("", array_slice(explode("/", $item['verb']), -1)), + 'body' => $body['html'], + 'tags' => $body['tags'], + 'categories' => $body['categories'], + 'mentions' => $body['mentions'], + 'attachments' => $body['attachments'], + 'folders' => $body['folders'], + 'text' => strip_tags($body['html']), + 'id' => $this->get_id(), + 'mid' => $item['mid'], + 'isevent' => $isevent, + 'attend' => $attend, + 'undo_attend' => $undo_attend, + 'consensus' => '', + 'conlabels' => '', + 'canvote' => $canvote, + 'linktitle' => sprintf(t('View %s\'s profile - %s'), $profile_name, (($item['author']['xchan_addr']) ? $item['author']['xchan_addr'] : $item['author']['xchan_url'])), + 'olinktitle' => sprintf(t('View %s\'s profile - %s'), $this->get_owner_name(), (($item['owner']['xchan_addr']) ? $item['owner']['xchan_addr'] : $item['owner']['xchan_url'])), + 'llink' => $item['llink'], + 'viewthread' => $viewthread, + 'to' => t('to'), + 'via' => t('via'), + 'wall' => t('Wall-to-Wall'), + 'vwall' => t('via Wall-To-Wall:'), + 'profile_url' => $profile_link, + 'thread_action_menu' => thread_action_menu($item, $conv->get_mode()), + 'thread_author_menu' => thread_author_menu($item, $conv->get_mode()), + 'dreport' => $dreport, + 'dreport_link' => ((isset($dreport_link) && $dreport_link) ? $dreport_link : EMPTY_STR), + 'myconv' => $myconv, + 'name' => $profile_name, + 'thumb' => $profile_avatar, + 'osparkle' => $osparkle, + 'sparkle' => $sparkle, + 'title' => $item['title'], + 'title_tosource' => get_pconfig($conv->get_profile_owner(), 'system', 'title_tosource'), + 'ago' => relative_date($item['created']), + 'app' => $item['app'], + 'str_app' => sprintf(t('from %s'), $item['app']), + 'isotime' => datetime_convert('UTC', date_default_timezone_get(), $item['created'], 'c'), + 'localtime' => datetime_convert('UTC', date_default_timezone_get(), $item['created'], 'r'), + 'editedtime' => (($item['edited'] != $item['created']) ? sprintf(t('last edited: %s'), datetime_convert('UTC', date_default_timezone_get(), $item['edited'], 'r')) : ''), + 'expiretime' => (($item['expires'] > NULL_DATE) ? sprintf(t('Expires: %s'), datetime_convert('UTC', date_default_timezone_get(), $item['expires'], 'r')) : ''), + 'lock' => $lock, + 'locktype' => $locktype, + 'delayed' => $item['item_delayed'], + 'privacy_warning' => $privacy_warning, + 'verified' => $verified, + 'unverified' => $unverified, + 'forged' => $forged, + 'location' => $location, + 'divider' => get_pconfig($conv->get_profile_owner(), 'system', 'item_divider'), + 'attend_label' => t('Attend'), + 'attend_title' => t('Attendance Options'), + 'vote_label' => t('Vote'), + 'vote_title' => t('Voting Options'), + 'comment_lbl' => (($this->is_commentable() && $observer) ? t('Reply') : ''), + 'is_comment' => $is_comment, + 'is_new' => $is_new, + 'mod_display' => ((argv(0) === 'display') ? true : false), // comments are not collapsed when using mod_display + 'owner_url' => $this->get_owner_url(), + 'owner_photo' => $this->get_owner_photo(), + 'owner_name' => $this->get_owner_name(), + 'photo' => $body['photo'], + 'event' => $body['event'], + 'has_tags' => $has_tags, + 'reactions' => $this->reactions, + + // Item toolbar buttons + + 'emojis' => '', // deprecated - use your operating system or a browser plugin + 'like' => $like, + 'dislike' => $dislike, + 'share' => $share, + 'embed' => $embed, + 'rawmid' => $item['mid'], + 'plink' => get_plink($item), + 'edpost' => $edpost, // ((Features::enabled($conv->get_profile_owner(),'edit_posts')) ? $edpost : ''), + 'star' => $star, + 'tagger' => ((Features::enabled($conv->get_profile_owner(), 'commtag')) ? $tagger : ''), + 'filer' => ((Features::enabled($conv->get_profile_owner(), 'filing')) ? $filer : ''), + 'pinned' => ($pinned ? t('Pinned post') : ''), + 'pinnable' => (($this->is_toplevel() && local_channel() && $item['owner_xchan'] == $observer['xchan_hash'] && $allowed_type && $item['item_private'] == 0 && $item['item_delayed'] == 0) ? '1' : ''), + 'pinme' => ($pinned ? t('Unpin this post') : t('Pin this post')), + 'isdraft' => boolval($item['item_unpublished']), + 'draft_txt' => t('Saved draft'), + 'bookmark' => (($conv->get_profile_owner() == local_channel() && local_channel() && $has_bookmarks) ? t('Save Bookmarks') : ''), + 'addtocal' => (($has_event && ! $item['resource_id']) ? t('Add to Calendar') : ''), + 'drop' => $drop, + 'multidrop' => ((Features::enabled($conv->get_profile_owner(), 'multi_delete')) ? $multidrop : ''), + 'dropdown_extras' => $dropdown_extras, + + // end toolbar buttons + + 'unseen_comments' => $unseen_comments, + 'comment_count' => $total_children, + 'comment_count_txt' => $comment_count_txt, + 'list_unseen_txt' => $list_unseen_txt, + 'markseen' => t('Mark all seen'), + 'responses' => $responses, + 'my_responses' => $my_responses, + 'like_count' => $like_count, + 'like_list' => $like_list, + 'like_list_part' => $like_list_part, + 'like_button_label' => $like_button_label, + 'like_modal_title' => t('Likes', 'noun'), + 'dislike_modal_title' => t('Dislikes', 'noun'), + 'dislike_count' => $dislike_count, + 'dislike_list' => $dislike_list, + 'dislike_list_part' => $dislike_list_part, + 'dislike_button_label' => $dislike_button_label, + 'modal_dismiss' => t('Close'), + 'showlike' => $showlike, + 'showdislike' => $showdislike, + 'comment' => ($item['item_delayed'] ? '' : $this->get_comment_box()), + 'previewing' => ($conv->is_preview() ? true : false ), + 'preview_lbl' => t('This is an unsaved preview'), + 'wait' => t('Please wait'), + 'submid' => str_replace(['+','='], ['',''], base64_encode($item['mid'])), + 'thread_level' => $thread_level, + 'indentpx' => intval(get_pconfig(local_channel(), 'system', 'thread_indent_px', get_config('system', 'thread_indent_px', 0))), + 'thread_max' => intval(get_config('system', 'thread_maxlevel', 20)) + 1 + ); + + $arr = array('item' => $item, 'output' => $tmp_item); + Hook::call('display_item', $arr); + + $result = $arr['output']; + + $result['children'] = []; + + if (local_channel() && get_pconfig(local_channel(), 'system', 'activitypub', get_config('system', 'activitypub', ACTIVITYPUB_ENABLED))) { + // place to store all the author addresses (links if not available) in the thread so we can auto-mention them in JS. + $result['authors'] = []; + // fix to add in sub-replies if replying to a comment on your own post from the top level. + if ($observer && ($profile_addr === $observer['xchan_hash'] || $profile_addr === $observer['xchan_addr'])) { + // ignore it + } else { + $result['authors'][] = $profile_addr; + } + + // Add any mentions from the immediate parent, unless they are mentions of the current viewer or duplicates + if (isset($item['term']) && is_array($item['term'])) { + $additional_mentions = []; + foreach ($item['term'] as $t) { + if ($t['ttype'] == TERM_MENTION) { + $additional_mentions[] = ((($position = strpos($t['url'], 'url=')) !== false) ? urldecode(substr($t['url'], $position + 4)) : $t['url']); + } + } + if ($additional_mentions) { + $r = q("select hubloc_addr, hubloc_id_url, hubloc_hash from hubloc where hubloc_id_url in (" . protect_sprintf(stringify_array($additional_mentions, true)) . ") "); + if ($r) { + foreach ($r as $rv) { + $ment = (($r[0]['hubloc_addr']) ? $r[0]['hubloc_addr'] : $r[0]['hubloc_id_url']); + if ($ment) { + if ($observer && $observer['xchan_hash'] !== $rv['hubloc_hash'] && ! in_array($ment, $result['authors'])) { + $result['authors'][] = $ment; + } + } + } + } + } + } + } + + $nb_children = count($children); + + $total_children = $this->count_visible_descendants(); + + $visible_comments = get_config('system', 'expanded_comments', 3); + + if (($this->get_display_mode() === 'normal') && ($nb_children > 0)) { + if ($children) { + foreach ($children as $child) { + $xz = $child->get_template_data($conv_responses, $thread_level + 1); + $result['children'][] = $xz; + } + } + // Collapse + if ($total_children > $visible_comments && $thread_level == 1) { + $result['children'][0]['comment_firstcollapsed'] = true; + $result['children'][0]['num_comments'] = $comment_count_txt; + $result['children'][0]['hide_text'] = sprintf(t('%s show all'), ''); + } + } + + $result['private'] = $item['item_private']; + $result['toplevel'] = ($this->is_toplevel() ? 'toplevel_item' : ''); + + if ($this->is_threaded()) { + $result['flatten'] = false; + $result['threaded'] = true; + } else { + $result['flatten'] = true; + $result['threaded'] = false; + } + + return $result; + } + + public function get_id() + { + return $this->get_data_value('id'); + } + + public function get_display_mode() + { + return $this->display_mode; + } + + public function set_display_mode($mode) + { + $this->display_mode = $mode; + } + + public function is_threaded() + { + return $this->threaded; + } + + public function get_author() + { + $xchan = $this->get_data_value('author'); + if ($xchan['xchan_addr']) { + return $xchan['xchan_addr']; + } + return $xchan['xchan_url']; + } + + public function set_reload($val) + { + $this->reload = $val; + } + + public function get_reload() + { + return $this->reload; + } + + public function set_commentable($val) + { + $this->commentable = $val; + foreach ($this->get_children() as $child) { + $child->set_commentable($val); + } + } + + public function is_commentable() + { + return $this->commentable; + } + + /** + * Add a child item + */ + public function add_child($item) + { + $item_id = $item->get_id(); + if (!$item_id) { + logger('[ERROR] Item::add_child : Item has no ID!!', LOGGER_DEBUG); + return false; + } + if ($this->get_child($item->get_id())) { + logger('[WARN] Item::add_child : Item already exists (' . $item->get_id() . ').', LOGGER_DEBUG); + return false; + } + + /* + * Only add what will be displayed + */ + + if (activity_match($item->get_data_value('verb'), ACTIVITY_LIKE) || activity_match($item->get_data_value('verb'), ACTIVITY_DISLIKE)) { + return false; + } + + $item->set_parent($this); + $this->children[] = $item; + return end($this->children); + } + + /** + * Get a child by its ID + */ + + public function get_child($id) + { + foreach ($this->get_children() as $child) { + if ($child->get_id() == $id) { + return $child; + } + } + return null; + } + + /** + * Get all our children + */ + + public function get_children() + { + return $this->children; + } + + /** + * Set our parent + */ + protected function set_parent($item) + { + $parent = $this->get_parent(); + if ($parent) { + $parent->remove_child($this); + } + $this->parent = $item; + $this->set_conversation($item->get_conversation()); + } + + /** + * Remove our parent + */ + + protected function remove_parent() + { + $this->parent = null; + $this->conversation = null; + } + + /** + * Remove a child + */ + + public function remove_child($item) + { + $id = $item->get_id(); + foreach ($this->get_children() as $key => $child) { + if ($child->get_id() == $id) { + $child->remove_parent(); + unset($this->children[$key]); + // Reindex the array, in order to make sure there won't be any trouble on loops using count() + $this->children = array_values($this->children); + return true; + } + } + logger('[WARN] Item::remove_child : Item is not a child (' . $id . ').', LOGGER_DEBUG); + return false; + } + + /** + * Get parent item + */ + protected function get_parent() + { + return $this->parent; + } + + /** + * set conversation + */ + public function set_conversation($conv) + { + $previous_mode = ($this->conversation ? $this->conversation->get_mode() : ''); + + $this->conversation = $conv; + + // Set it on our children too + foreach ($this->get_children() as $child) { + $child->set_conversation($conv); + } + } + + /** + * get conversation + */ + public function get_conversation() + { + return $this->conversation; + } + + /** + * Get raw data + * + * We shouldn't need this + */ + public function get_data() + { + return $this->data; + } + + /** + * Get a data value + * + * Returns: + * _ value on success + * _ false on failure + */ + public function get_data_value($name) + { + if (!isset($this->data[$name])) { +// logger('[ERROR] Item::get_data_value : Item has no value name "'. $name .'".', LOGGER_DEBUG); + return false; + } + + return $this->data[$name]; + } + + /** + * Get template + */ + public function get_template() + { + return $this->template; + } + + + public function set_template($t) + { + $this->template = $t; + } + + /** + * Check if this is a toplevel post + */ + private function is_toplevel() + { + return $this->toplevel; + } + + /** + * Count the total of our descendants + */ + private function count_descendants() + { + $children = $this->get_children(); + $total = count($children); + if ($total > 0) { + foreach ($children as $child) { + $total += $child->count_descendants(); + } + } + return $total; + } + + public function count_visible_descendants() + { + $total = 0; + $children = $this->get_children(); + if ($children) { + foreach ($children as $child) { + if (! visible_activity($child->data)) { + continue; + } + $total++; + $total += $child->count_visible_descendants(); + } + } + return $total; + } + + + private function label_descendants($count = 0) + { + if (! array_key_exists('sequence', $this->data)) { + if ($count) { + $count++; + } + $this->data['sequence'] = $count; + } + logger('labelled: ' . print_r($this->data, true), LOGGER_DATA); + $children = $this->get_children(); + $total = count($children); + if ($total > 0) { + foreach ($children as $child) { + if (! visible_activity($child->data)) { + continue; + } + if (! array_key_exists('sequence', $this->data)) { + $count++; + $child->data['sequence'] = $count; + logger('labelled_child: ' . print_r($child->data, true), LOGGER_DATA); + } + $child->label_descendants($count); + } + } + } + + private function count_unseen_descendants() + { + $children = $this->get_children(); + $total = count($children); + if ($total > 0) { + $total = 0; + foreach ($children as $child) { + if (! visible_activity($child->data)) { + continue; + } + if (intval($child->data['item_unseen'])) { + $total++; + } + } + } + return $total; + } + + + /** + * Get the template for the comment box + */ + private function get_comment_box_template() + { + return $this->comment_box_template; + } + + /** + * Get the comment box + * + * Returns: + * _ The comment box string (empty if no comment box) + * _ false on failure + */ + private function get_comment_box($indent = 0) + { + + if (!$this->is_toplevel() && !get_config('system', 'thread_allow', true)) { + return ''; + } + + $comment_box = ''; + $conv = $this->get_conversation(); + +// logger('Commentable conv: ' . $conv->is_commentable()); + + if (! $this->is_commentable()) { + return; + } + + $template = Theme::get_template($this->get_comment_box_template()); + + $observer = $conv->get_observer(); + + $arr = array('comment_buttons' => '','id' => $this->get_id()); + Hook::call('comment_buttons', $arr); + $comment_buttons = $arr['comment_buttons']; + + $feature_auto_save_draft = ((Features::enabled($conv->get_profile_owner(), 'auto_save_draft')) ? "true" : "false"); + $permanent_draft = ((intval($conv->get_profile_owner()) === intval(local_channel()) && Apps::system_app_installed($conv->get_profile_owner(), 'Drafts')) ? ('Save draft') : EMPTY_STR); + + + + $comment_box = replace_macros($template, array( + '$return_path' => '', + '$threaded' => $this->is_threaded(), + '$jsreload' => $conv->reload, + '$type' => (($conv->get_mode() === 'channel') ? 'wall-comment' : 'net-comment'), + '$id' => $this->get_id(), + '$parent' => $this->get_id(), + '$comment_buttons' => $comment_buttons, + '$profile_uid' => $conv->get_profile_owner(), + '$mylink' => $observer['xchan_url'], + '$mytitle' => t('This is you'), + '$myphoto' => $observer['xchan_photo_s'], + '$comment' => t('Comment'), + '$submit' => t('Submit'), + '$edat' => EMPTY_STR, + '$edbold' => t('Bold'), + '$editalic' => t('Italic'), + '$eduline' => t('Underline'), + '$edquote' => t('Quote'), + '$edcode' => t('Code'), + '$edimg' => t('Image'), + '$edatt' => t('Attach/Upload file'), + '$edurl' => t('Insert Link'), + '$edvideo' => t('Video'), + '$preview' => t('Preview'), + '$reset' => t('Reset'), + '$indent' => $indent, + '$can_upload' => (perm_is_allowed($conv->get_profile_owner(), get_observer_hash(), 'write_storage') && $conv->is_uploadable()), + '$feature_encrypt' => ((Apps::system_app_installed($conv->get_profile_owner(), 'Secrets')) ? true : false), + '$feature_markup' => ((Apps::system_app_installed($conv->get_profile_owner(), 'Markup')) ? true : false), + '$encrypt' => t('Encrypt text'), + '$cipher' => $conv->get_cipher(), + '$sourceapp' => App::$sourcename, + '$observer' => get_observer_hash(), + '$anoncomments' => ((($conv->get_mode() === 'channel' || $conv->get_mode() === 'display') && perm_is_allowed($conv->get_profile_owner(), '', 'post_comments')) ? true : false), + '$anonname' => [ 'anonname', t('Your full name (required)') ], + '$anonmail' => [ 'anonmail', t('Your email address (required)') ], + '$anonurl' => [ 'anonurl', t('Your website URL (optional)') ], + '$auto_save_draft' => $feature_auto_save_draft, + '$save' => $permanent_draft, + '$top' => $this->is_toplevel() + )); + + return $comment_box; + } + + private function get_redirect_url() + { + return $this->redirect_url; + } + + /** + * Check if we are a wall to wall item and set the relevant properties + */ + protected function check_wall_to_wall() + { + $conv = $this->get_conversation(); + $this->wall_to_wall = false; + $this->owner_url = ''; + $this->owner_photo = ''; + $this->owner_name = ''; + $this->owner_censored = false; + + if ($conv->get_mode() === 'channel') { + return; + } + + if ($this->is_toplevel() && ($this->get_data_value('author_xchan') != $this->get_data_value('owner_xchan'))) { + $this->owner_url = chanlink_hash($this->data['owner']['xchan_hash']); + $this->owner_photo = $this->data['owner']['xchan_photo_m']; + $this->owner_name = $this->data['owner']['xchan_name']; + $this->wall_to_wall = true; + } + + // present friend-of-friend conversations from hyperdrive as relayed posts from the first friend + // we find among the respondents. + + if ($this->is_toplevel() && (! $this->data['owner']['abook_id'])) { + if ($this->data['children']) { + $friend = $this->find_a_friend($this->data['children']); + if ($friend) { + $this->owner_url = $friend['url']; + $this->owner_photo = $friend['photo']; + $this->owner_name = $friend['name']; + $this->owner_censored = $friend['censored']; + $this->wall_to_wall = true; + } + } + } + } + + private function find_a_friend($items) + { + $ret = null; + if ($items) { + foreach ($items as $child) { + if ($child['author']['abook_id'] && (! intval($child['author']['abook_self']))) { + return [ + 'url' => chanlink_hash($child['author']['xchan_hash']), + 'photo' => $child['author']['xchan_photo_m'], + 'name' => $child['author']['xchan_name'], + 'censored' => (($child['author']['xchan_censored'] || $child['author']['abook_censor']) ? true : false) + ]; + if ($child['children']) { + $ret = $this->find_a_friend($child['children']); + if ($ret) { + break; + } + } + } + } + } + return $ret; + } + + + private function is_wall_to_wall() + { + return $this->wall_to_wall; + } + + private function get_owner_url() + { + return $this->owner_url; + } + + private function get_owner_photo() + { + return $this->owner_photo; + } + + private function get_owner_name() + { + return $this->owner_name; + } + + private function is_visiting() + { + return $this->visiting; + } +} diff --git a/Code/Lib/ThreadListener.php b/Code/Lib/ThreadListener.php new file mode 100644 index 000000000..42080bced --- /dev/null +++ b/Code/Lib/ThreadListener.php @@ -0,0 +1,63 @@ +set_mode($mode); + $this->preview = $preview; + $this->uploadable = $uploadable; + $this->prepared_item = $prepared_item; + $c = ((local_channel()) ? get_pconfig(local_channel(), 'system', 'default_cipher') : ''); + if ($c) { + $this->cipher = $c; + } + } + + /** + * Set the mode we'll be displayed on + */ + private function set_mode($mode) + { + if ($this->get_mode() == $mode) { + return; + } + + $this->observer = App::get_observer(); + $ob_hash = (($this->observer) ? $this->observer['xchan_hash'] : ''); + + switch ($mode) { + case 'stream': + $this->profile_owner = local_channel(); + $this->writable = true; + break; + case 'pubstream': + $this->profile_owner = local_channel(); + $this->writable = ((local_channel()) ? true : false); + break; + case 'hq': + $this->profile_owner = local_channel(); + $this->writable = true; + break; + case 'channel': + $this->profile_owner = App::$profile['profile_uid']; + $this->writable = perm_is_allowed($this->profile_owner, $ob_hash, 'post_comments'); + break; + case 'cards': + $this->profile_owner = App::$profile['profile_uid']; + $this->writable = perm_is_allowed($this->profile_owner, $ob_hash, 'post_comments'); + $this->reload = $_SESSION['return_url']; + break; + case 'articles': + $this->profile_owner = App::$profile['profile_uid']; + $this->writable = perm_is_allowed($this->profile_owner, $ob_hash, 'post_comments'); + $this->reload = $_SESSION['return_url']; + break; + case 'display': + // in this mode we set profile_owner after initialisation (from conversation()) and then + // pull some trickery which allows us to re-invoke this function afterward + // it's an ugly hack so @FIXME + $this->writable = perm_is_allowed($this->profile_owner, $ob_hash, 'post_comments'); + $this->uploadable = perm_is_allowed($this->profile_owner, $ob_hash, 'write_storage'); + break; + case 'page': + $this->profile_owner = App::$profile['uid']; + $this->writable = perm_is_allowed($this->profile_owner, $ob_hash, 'post_comments'); + break; + default: + logger('[ERROR] Conversation::set_mode : Unhandled mode (' . $mode . ').', LOGGER_DEBUG); + return false; + break; + } + $this->mode = $mode; + } + + /** + * Get mode + */ + public function get_mode() + { + return $this->mode; + } + + /** + * Check if page is writable + */ + public function is_writable() + { + return $this->writable; + } + + public function is_commentable() + { + return $this->commentable; + } + + public function is_uploadable() + { + return $this->uploadable; + } + + + /** + * Check if page is a preview + */ + public function is_preview() + { + return $this->preview; + } + + /** + * Get profile owner + */ + public function get_profile_owner() + { + return $this->profile_owner; + } + + public function set_profile_owner($uid) + { + $this->profile_owner = $uid; + $mode = $this->get_mode(); + $this->mode = null; + $this->set_mode($mode); + } + + public function get_observer() + { + return $this->observer; + } + + public function get_cipher() + { + return $this->cipher; + } + + + /** + * Add a thread to the conversation + * + * Returns: + * _ The inserted item on success + * _ false on failure + */ + public function add_thread($item) + { + $item_id = $item->get_id(); + if (!$item_id) { + logger('Item has no ID!!', LOGGER_DEBUG, LOG_ERR); + return false; + } + if ($this->get_thread($item->get_id())) { + logger('Thread already exists (' . $item->get_id() . ').', LOGGER_DEBUG, LOG_WARNING); + return false; + } + + /* + * Only add things that will be displayed + */ + + + if (($item->get_data_value('id') != $item->get_data_value('parent')) && (activity_match($item->get_data_value('verb'), ACTIVITY_LIKE) || activity_match($item->get_data_value('verb'), ACTIVITY_DISLIKE))) { + return false; + } + + $item->set_commentable(false); + $ob_hash = (($this->observer) ? $this->observer['xchan_hash'] : ''); + + if (! comments_are_now_closed($item->get_data())) { + if (($item->get_data_value('author_xchan') === $ob_hash) || ($item->get_data_value('owner_xchan') === $ob_hash)) { + $item->set_commentable(true); + } + + if (intval($item->get_data_value('item_nocomment'))) { + $item->set_commentable(false); + } elseif (! $item->is_commentable()) { + if ((array_key_exists('owner', $item->data)) && intval($item->data['owner']['abook_self'])) { + $item->set_commentable(perm_is_allowed($this->profile_owner, $ob_hash, 'post_comments')); + } else { + $item->set_commentable(can_comment_on_post($ob_hash, $item->data)); + } + } + } + if ($this->mode === 'pubstream' && (! local_channel())) { + $item->set_commentable(false); + } + + + $item->set_conversation($this); + $this->threads[] = $item; + return end($this->threads); + } + + /** + * Get data in a form usable by a conversation template + * + * We should find a way to avoid using those arguments (at least most of them) + * + * Returns: + * _ The data requested on success + * _ false on failure + */ + public function get_template_data($conv_responses) + { + $result = []; + + foreach ($this->threads as $item) { + if (($item->get_data_value('id') == $item->get_data_value('parent')) && $this->prepared_item) { + $item_data = $this->prepared_item; + } else { + $item_data = $item->get_template_data($conv_responses); + } + if (!$item_data) { + logger('Failed to get item template data (' . $item->get_id() . ').', LOGGER_DEBUG, LOG_ERR); + return false; + } + $result[] = $item_data; + } + + return $result; + } + + /** + * Get a thread based on its item id + * + * Returns: + * _ The found item on success + * _ false on failure + */ + private function get_thread($id) + { + foreach ($this->threads as $item) { + if ($item->get_id() == $id) { + return $item; + } + } + + return false; + } +} diff --git a/Code/Lib/Verify.php b/Code/Lib/Verify.php new file mode 100644 index 000000000..6c80be2f4 --- /dev/null +++ b/Code/Lib/Verify.php @@ -0,0 +1,72 @@ + ['Accept: application/jrd+json, */*']]); + + if ($s['success']) { + $j = json_decode($s['body'], true); + return ($j); + } + + return false; + } + + public static function parse_resource($resource) + { + + self::$resource = urlencode($resource); + + if (strpos($resource, 'http') === 0) { + $m = parse_url($resource); + if ($m) { + if ($m['scheme'] !== 'https') { + return false; + } + self::$server = $m['host'] . (($m['port']) ? ':' . $m['port'] : ''); + } else { + return false; + } + } elseif (strpos($resource, 'tag:') === 0) { + $arr = explode(':', $resource); // split the tag + $h = explode(',', $arr[1]); // split the host,date + self::$server = $h[0]; + } else { + $x = explode('@', $resource); + if (!strlen($x[0])) { + // e.g. @dan@pixelfed.org + array_shift($x); + } + $username = $x[0]; + if (count($x) > 1) { + self::$server = $x[1]; + } else { + return false; + } + if (strpos($resource, 'acct:') !== 0) { + self::$resource = urlencode('acct:' . $resource); + } + } + } + + /** + * @brief fetch a webfinger resource and return a zot6 discovery url if present + * + */ + + public static function zot_url($resource) + { + + $arr = self::exec($resource); + + if (is_array($arr) && array_key_exists('links', $arr)) { + foreach ($arr['links'] as $link) { + if (array_key_exists('rel',$link) && in_array($link['rel'], [ PROTOCOL_NOMAD, PROTOCOL_ZOT6 ])) { + if (array_key_exists('href', $link) && $link['href'] !== EMPTY_STR) { + return $link['href']; + } + } + } + } + return false; + } +} + diff --git a/Code/Lib/XConfig.php b/Code/Lib/XConfig.php new file mode 100644 index 000000000..1d2d10ea5 --- /dev/null +++ b/Code/Lib/XConfig.php @@ -0,0 +1,192 @@ +XConfig is comparable to PConfig, except that it uses xchan + * (an observer hash) as an identifier. + * + * XConfig is used for observer specific configurations and takes a + * xchan as identifier. + * The storage is of size MEDIUMTEXT. + * + * @code{.php}$var = Code\Lib\XConfig::Get('xchan', 'category', 'key'); + * // with default value for non existent key + * $var = Code\Lib\XConfig::Get('xchan', 'category', 'unsetkey', 'defaultvalue');@endcode + * + * The old (deprecated?) way to access a XConfig value is: + * @code{.php}$observer = App::get_observer_hash(); + * if ($observer) { + * $var = get_xconfig($observer, 'category', 'key'); + * }@endcode + */ +class XConfig +{ + + /** + * @brief Loads a full xchan's configuration into a cached storage. + * + * All configuration values of the given observer hash are stored in global + * cache which is available under the global variable App::$config[$xchan]. + * + * @param string $xchan + * The observer's hash + * @return void|false Returns false if xchan is not set + */ + public static function Load($xchan) + { + + if (! $xchan) { + return false; + } + + if (! array_key_exists($xchan, App::$config)) { + App::$config[$xchan] = []; + } + + $r = q( + "SELECT * FROM xconfig WHERE xchan = '%s'", + dbesc($xchan) + ); + + if ($r) { + foreach ($r as $rr) { + $k = $rr['k']; + $c = $rr['cat']; + if (! array_key_exists($c, App::$config[$xchan])) { + App::$config[$xchan][$c] = []; + App::$config[$xchan][$c]['config_loaded'] = true; + } + App::$config[$xchan][$c][$k] = $rr['v']; + } + } + } + + /** + * @brief Get a particular observer's config variable given the category + * name ($family) and a key. + * + * Get a particular observer's config value from the given category ($family) + * and the $key from a cached storage in App::$config[$xchan]. + * + * Returns false if not set. + * + * @param string $xchan + * The observer's hash + * @param string $family + * The category of the configuration value + * @param string $key + * The configuration key to query + * @param bool $default (optional) default false + * @return mixed Stored $value or false if it does not exist + */ + public static function Get($xchan, $family, $key, $default = false) + { + + if (! $xchan) { + return $default; + } + + if (! array_key_exists($xchan, App::$config)) { + load_xconfig($xchan); + } + + if ((! array_key_exists($family, App::$config[$xchan])) || (! array_key_exists($key, App::$config[$xchan][$family]))) { + return $default; + } + + return unserialise(App::$config[$xchan][$family][$key]); + } + + /** + * @brief Sets a configuration value for an observer. + * + * Stores a config value ($value) in the category ($family) under the key ($key) + * for the observer's $xchan hash. + * + * @param string $xchan + * The observer's hash + * @param string $family + * The category of the configuration value + * @param string $key + * The configuration key to set + * @param string $value + * The value to store + * @return mixed Stored $value or false + */ + public static function Set($xchan, $family, $key, $value) + { + + // manage array value + $dbvalue = ((is_array($value)) ? serialise($value) : $value); + $dbvalue = ((is_bool($dbvalue)) ? intval($dbvalue) : $dbvalue); + + if (self::Get($xchan, $family, $key) === false) { + if (! array_key_exists($xchan, App::$config)) { + App::$config[$xchan] = []; + } + if (! array_key_exists($family, App::$config[$xchan])) { + App::$config[$xchan][$family] = []; + } + + $ret = q( + "INSERT INTO xconfig ( xchan, cat, k, v ) VALUES ( '%s', '%s', '%s', '%s' )", + dbesc($xchan), + dbesc($family), + dbesc($key), + dbesc($dbvalue) + ); + } else { + $ret = q( + "UPDATE xconfig SET v = '%s' WHERE xchan = '%s' and cat = '%s' AND k = '%s'", + dbesc($dbvalue), + dbesc($xchan), + dbesc($family), + dbesc($key) + ); + } + + App::$config[$xchan][$family][$key] = $value; + + if ($ret) { + return $value; + } + + return $ret; + } + + /** + * @brief Deletes the given key from the observer's config. + * + * Removes the configured value from the stored cache in App::$config[$xchan] + * and removes it from the database. + * + * @param string $xchan + * The observer's hash + * @param string $family + * The category of the configuration value + * @param string $key + * The configuration key to delete + * @return mixed + */ + public static function Delete($xchan, $family, $key) + { + + if (isset(App::$config[$xchan]) && isset(App::$config[$xchan][$family]) && isset(App::$config[$xchan][$family][$key])) { + unset(App::$config[$xchan][$family][$key]); + } + + $ret = q( + "DELETE FROM xconfig WHERE xchan = '%s' AND cat = '%s' AND k = '%s'", + dbesc($xchan), + dbesc($family), + dbesc($key) + ); + + return $ret; + } +} diff --git a/Code/Lib/Xchan.php b/Code/Lib/Xchan.php new file mode 100644 index 000000000..f8b579f15 --- /dev/null +++ b/Code/Lib/Xchan.php @@ -0,0 +1,12 @@ + $profile['xprof_hash'], + $arr['xprof_dob'] => (($profile['birthday'] === '0000-00-00') ? $profile['birthday'] : datetime_convert('', '', $profile['birthday'], 'Y-m-d')), + $arr['xprof_age'] => (($profile['age']) ? intval($profile['age']) : 0), + $arr['xprof_desc'] => (($profile['description']) ? htmlspecialchars($profile['description'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_gender'] => (($profile['gender']) ? htmlspecialchars($profile['gender'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_marital'] => (($profile['marital']) ? htmlspecialchars($profile['marital'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_sexual'] => (($profile['sexual']) ? htmlspecialchars($profile['sexual'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_locale'] => (($profile['locale']) ? htmlspecialchars($profile['locale'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_region'] => (($profile['region']) ? htmlspecialchars($profile['region'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_postcode'] => (($profile['postcode']) ? htmlspecialchars($profile['postcode'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_country'] => (($profile['country']) ? htmlspecialchars($profile['country'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_about'] => (($profile['about']) ? htmlspecialchars($profile['about'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_homepage'] => (($profile['homepage']) ? htmlspecialchars($profile['homepage'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_hometown'] => (($profile['hometown']) ? htmlspecialchars($profile['hometown'], ENT_COMPAT, 'UTF-8', false) : ''), + $arr['xprof_keywords'] => (($profile['keywords']) ? htmlspecialchars($profile['keywords'], ENT_COMPAT, 'UTF-8', false) : ''), + + ]; + + return create_table_from_array('xprof', $store); + } + + +} \ No newline at end of file diff --git a/Code/Lib/Yaml.php b/Code/Lib/Yaml.php new file mode 100644 index 000000000..3f5a13464 --- /dev/null +++ b/Code/Lib/Yaml.php @@ -0,0 +1,33 @@ +getMessage()); + } + return $value; + } + + public static function encode($data) + { + return Syaml::dump($data); + } + + public static function fromJSON($data) + { + return Syaml::dump(json_decode($data,true)); + } + +} diff --git a/Code/Lib/ZotURL.php b/Code/Lib/ZotURL.php new file mode 100644 index 000000000..0ba78853b --- /dev/null +++ b/Code/Lib/ZotURL.php @@ -0,0 +1,128 @@ + false ]; + + if (strpos($url, 'x-zot:') !== 0) { + return $ret; + } + + + if (! $url) { + return $ret; + } + + $portable_url = substr($url, 6); + $u = explode('/', $portable_url); + $portable_id = $u[0]; + + $hosts = self::lookup($portable_id, $hub); + + if (! $hosts) { + return $ret; + } + + foreach ($hosts as $h) { + $newurl = $h . '/id/' . $portable_url; + + $m = parse_url($newurl); + + $data = json_encode([ 'zot_token' => random_string() ]); + + if ($channel && $m) { + $headers = [ + 'Accept' => 'application/x-nomad+json, application/x-zot+json', + 'Content-Type' => 'application/x-nomad+json', + 'X-Zot-Token' => random_string(), + 'Digest' => HTTPSig::generate_digest_header($data), + 'Host' => $m['host'], + '(request-target)' => 'post ' . get_request_string($newurl) + ]; + $h = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel), false); + } else { + $h = [ 'Accept: application/x-nomad+json, application/x-zot+json' ]; + } + + $result = []; + + $redirects = 0; + $x = z_post_url($newurl, $data, $redirects, [ 'headers' => $h ]); + if ($x['success']) { + return $x; + } + } + + return $ret; + } + + public static function is_zoturl($s) + { + + if (strpos($url, 'x-zot:') === 0) { + return true; + } + return false; + } + + + public static function lookup($portable_id, $hub) + { + + $r = q( + "select * from hubloc left join site on hubloc_url = site_url where hubloc_hash = '%s' and site_dead = 0 order by hubloc_primary desc", + dbesc($portable_id) + ); + + if (! $r) { + // extend to network lookup + + $path = '/q/' . $portable_id; + + // first check sending hub since they have recently communicated with this object + + $redirects = 0; + + if ($hub) { + $x = z_fetch_url($hub['hubloc_url'] . $path, false, $redirects); + $u = self::parse_response($x); + if ($u) { + return $u; + } + } + + // If this fails, fallback on directory servers + + return false; + } + return ids_to_array($r, 'hubloc_url'); + } + + + public static function parse_response($arr) + { + if (! $arr['success']) { + return false; + } + $a = json_decode($arr['body'], true); + if ($a['success'] && array_key_exists('results', $a) && is_array($a['results']) && count($a['results'])) { + foreach ($a['results'] as $b) { + $m = discover_by_webbie($b); + if ($m) { + return([ $b ]); + } + } + } + return false; + } +} + diff --git a/Code/Lib/Zotfinger.php b/Code/Lib/Zotfinger.php new file mode 100644 index 000000000..91964a3c9 --- /dev/null +++ b/Code/Lib/Zotfinger.php @@ -0,0 +1,90 @@ + random_string()]); + + if ($channel && $m) { + $headers = [ + 'Accept' => 'application/x-nomad+json, application/x-zot+json', + 'Content-Type' => 'application/x-nomad+json', + 'X-Zot-Token' => random_string(), + 'Digest' => HTTPSig::generate_digest_header($data), + 'Host' => $m['host'], + '(request-target)' => 'post ' . get_request_string($resource) + ]; + $h = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel), false); + } else { + $h = ['Accept: application/x-nomad+json, application/x-zot+json']; + } + + $result = []; + + $redirects = 0; + $x = z_post_url($resource, $data, $redirects, ['headers' => $h]); + + if (in_array(intval($x['return_code']), [ 404, 410 ]) && $recurse) { + + // The resource has been deleted or doesn't exist at this location. + // Try to find another nomadic resource for this channel and return that. + + // First, see if there's a hubloc for this site. Fetch that record to + // obtain the nomadic identity hash. Then use that to find any additional + // nomadic locations. + + $h = Activity::get_actor_hublocs($resource, 'nomad'); + if ($h) { + // mark this location deleted + hubloc_delete($h[0]); + $hubs = Activity::get_actor_hublocs($h[0]['hubloc_hash']); + if ($hubs) { + foreach ($hubs as $hub) { + if ($hub['hubloc_id_url'] !== $resource and !$hub['hubloc_deleted']) { + $rzf = self::exec($hub['hubloc_id_url'],$channel,$verify); + if ($rzf) { + return $rzf; + } + } + } + } + } + } + + if ($x['success']) { + if ($verify) { + $result['signature'] = HTTPSig::verify($x, EMPTY_STR, 'zot6'); + } + + $result['data'] = json_decode($x['body'], true); + + if ($result['data'] && is_array($result['data']) && array_key_exists('encrypted', $result['data']) && $result['data']['encrypted']) { + $result['data'] = json_decode(Crypto::unencapsulate($result['data'], get_config('system', 'prvkey')), true); + } + + return $result; + } + + return false; + } +} + diff --git a/Code/Module/Acloader.php b/Code/Module/Acloader.php new file mode 100644 index 000000000..260a6edc2 --- /dev/null +++ b/Code/Module/Acloader.php @@ -0,0 +1,346 @@ + standard ACL request + // 'g' => Groups only ACL request + // 'f' => forums only ACL request + // 'c' => Connections only ACL request or editor (textarea) mention request + // $_REQUEST['search'] contains ACL search text. + + + // $type = + // 'm' => autocomplete private mail recipient (checks post_mail permission) + // 'a' => autocomplete connections (mod_connections, mod_poke, mod_sources, mod_photos) + // 'x' => nav search bar autocomplete (match any xchan) + // 'z' => autocomplete any xchan, but also include abook_alias, requires non-zero local_channel() + // and also contains xid without urlencode, used specifically by activity_filter widget + // $_REQUEST['query'] contains autocomplete search text. + + + // The different autocomplete libraries use different names for the search text + // parameter. Internally we'll use $search to represent the search text no matter + // what request variable it was attached to. + + if (array_key_exists('query', $_REQUEST)) { + $search = $_REQUEST['query']; + } + + if ((!local_channel()) && (!in_array($type, ['x', 'c', 'f']))) { + killme(); + } + + $permitted = []; + + if (in_array($type, ['m', 'a', 'f'])) { + // These queries require permission checking. We'll create a simple array of xchan_hash for those with + // the requisite permissions which we can check against. + + $x = q( + "select xchan from abconfig where chan = %d and cat = 'system' and k = 'their_perms' and v like '%s'", + intval(local_channel()), + dbesc(($type === 'm') ? '%post_mail%' : '%tag_deliver%') + ); + + $permitted = ids_to_array($x, 'xchan'); + } + + + if ($search) { + $sql_extra = " AND pgrp.gname LIKE " . protect_sprintf("'%" . dbesc($search) . "%'") . " "; + // sql_extra2 is typically used when we don't have a local_channel - so we are not search abook_alias + $sql_extra2 = " AND ( xchan_name LIKE " . protect_sprintf("'%" . dbesc($search) . "%'") . " OR xchan_addr LIKE " . protect_sprintf("'%" . dbesc(punify($search)) . ((strpos($search, '@') === false) ? "%@%'" : "%'")) . ") "; + + + // This horrible mess is needed because position also returns 0 if nothing is found. + // Would be MUCH easier if it instead returned a very large value + // Otherwise we could just + // order by LEAST(POSITION($search IN xchan_name),POSITION($search IN xchan_addr)). + + $order_extra2 = "CASE WHEN xchan_name LIKE " + . protect_sprintf("'%" . dbesc($search) . "%'") + . " then POSITION('" . protect_sprintf(dbesc($search)) + . "' IN xchan_name) else position('" . protect_sprintf(dbesc(punify($search))) . "' IN xchan_addr) end, "; + + $sql_extra3 = "AND ( xchan_addr like " . protect_sprintf("'%" . dbesc(punify($search)) . "%'") . " OR xchan_name like " . protect_sprintf("'%" . dbesc($search) . "%'") . " OR abook_alias like " . protect_sprintf("'%" . dbesc($search) . "%'") . " ) "; + + $sql_extra4 = "AND ( xchan_name LIKE " . protect_sprintf("'%" . dbesc($search) . "%'") . " OR xchan_addr LIKE " . protect_sprintf("'%" . dbesc(punify($search)) . ((strpos($search, '@') === false) ? "%@%'" : "%'")) . " OR abook_alias LIKE " . protect_sprintf("'%" . dbesc($search) . "%'") . ") "; + } else { + $sql_extra = $sql_extra2 = $sql_extra3 = $sql_extra4 = ""; + } + + + $groups = []; + $contacts = []; + + if ($type == '' || $type == 'g') { + // Normal privacy groups + + $r = q( + "SELECT pgrp.id, pgrp.hash, pgrp.gname + FROM pgrp, pgrp_member + WHERE pgrp.deleted = 0 AND pgrp.uid = %d + AND pgrp_member.gid = pgrp.id + $sql_extra + GROUP BY pgrp.id + ORDER BY pgrp.gname + LIMIT %d OFFSET %d", + intval(local_channel()), + intval($count), + intval($start) + ); + + if ($r) { + foreach ($r as $g) { + // logger('acl: group: ' . $g['gname'] . ' members: ' . AccessList::members_xchan(local_channel(),$g['id'])); + $groups[] = [ + "type" => "g", + "photo" => "images/twopeople.png", + "name" => $g['gname'], + "id" => $g['id'], + "xid" => $g['hash'], + "uids" => AccessList::members_xchan(local_channel(), $g['id']), + "link" => '' + ]; + } + } + } + + if ($type == '' || $type == 'c' || $type === 'f') { + // Getting info from the abook is better for local users because it contains info about permissions + if (local_channel()) { + // add connections + + $r = q( + "SELECT abook_id as id, xchan_hash as hash, xchan_name as name, xchan_photo_s as micro, xchan_url as url, xchan_addr as nick, xchan_type, abook_flags, abook_self + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d AND abook_blocked = 0 and abook_pending = 0 and xchan_deleted = 0 $sql_extra4 order by xchan_name asc limit $count", + intval(local_channel()) + ); + } else { // Visitors + $r = q( + "SELECT xchan_hash as id, xchan_hash as hash, xchan_name as name, xchan_photo_s as micro, xchan_url as url, xchan_addr as nick, 0 as abook_flags, 0 as abook_self + FROM xchan left join xlink on xlink_link = xchan_hash + WHERE xlink_xchan = '%s' AND xchan_deleted = 0 $sql_extra2 order by $order_extra2 xchan_name asc limit $count", + dbesc(get_observer_hash()) + ); + } + if ((count($r) < 100) && $type == 'c') { + $r2 = q("SELECT xchan_hash as id, xchan_hash as hash, xchan_name as name, xchan_photo_s as micro, xchan_url as url, xchan_addr as nick, 0 as abook_flags, 0 as abook_self + FROM xchan WHERE xchan_deleted = 0 and xchan_network != 'unknown' $sql_extra2 order by $order_extra2 xchan_name asc limit $count"); + if ($r2) { + $r = array_merge($r, $r2); + $r = unique_multidim_array($r, 'hash'); + } + } + } elseif ($type == 'm') { + $r = []; + $z = q( + "SELECT xchan_hash as hash, xchan_name as name, xchan_addr as nick, xchan_photo_s as micro, xchan_url as url + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d + and xchan_deleted = 0 + $sql_extra3 + ORDER BY xchan_name ASC ", + intval(local_channel()) + ); + if ($z) { + foreach ($z as $zz) { + if (in_array($zz['hash'], $permitted)) { + $r[] = $zz; + } + } + } + } elseif ($type == 'a') { + $r = q( + "SELECT abook_id as id, xchan_name as name, xchan_hash as hash, xchan_addr as nick, xchan_photo_s as micro, xchan_network as network, xchan_url as url, xchan_addr as attag FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d + and xchan_deleted = 0 + $sql_extra3 + ORDER BY xchan_name ASC ", + intval(local_channel()) + ); + } elseif ($type == 'z') { + $r = q( + "SELECT xchan_name as name, xchan_hash as hash, xchan_addr as nick, xchan_photo_s as micro, xchan_network as network, xchan_url as url, xchan_addr as attag FROM xchan left join abook on xchan_hash = abook_xchan + WHERE ( abook_channel = %d OR abook_channel IS NULL ) + and xchan_deleted = 0 + $sql_extra3 + ORDER BY xchan_name ASC ", + intval(local_channel()) + ); + } elseif ($type == 'x') { + $contacts = []; + $r = $this->navbar_complete(); + if ($r) { + foreach ($r as $g) { + $contacts[] = [ + "photo" => $g['photo'], + "name" => $g['name'], + "nick" => $g['address'], + 'link' => (($g['address']) ? $g['address'] : $g['url']), + 'xchan' => $g['hash'] + ]; + } + } + + $o = [ + 'start' => $start, + 'count' => $count, + 'items' => $contacts, + ]; + json_return_and_die($o); + } else { + $r = []; + } + + if ($r) { + foreach ($r as $g) { + if (isset($g['network']) && in_array($g['network'], ['rss', 'anon', 'unknown']) && ($type != 'a')) { + continue; + } + + // 'z' (activity_filter autocomplete) requires an un-encoded hash to prevent double encoding + + if ($type !== 'z') { + $g['hash'] = urlencode($g['hash']); + } + + if (!$g['nick']) { + $g['nick'] = $g['url']; + } + + if (in_array($g['hash'], $permitted) && $type === 'f' && (!$noforums)) { + $contacts[] = [ + "type" => "c", + "photo" => "images/twopeople.png", + "name" => $g['name'], + "id" => urlencode($g['id']), + "xid" => $g['hash'], + "link" => (($g['nick']) ? $g['nick'] : $g['url']), + "nick" => substr($g['nick'], 0, strpos($g['nick'], '@')), + "self" => (intval($g['abook_self']) ? 'abook-self' : ''), + "taggable" => 'taggable', + "label" => t('network') + ]; + } + if ($type !== 'f') { + $contacts[] = [ + "type" => "c", + "photo" => $g['micro'], + "name" => $g['name'], + "id" => urlencode($g['id']), + "xid" => $g['hash'], + "link" => (($g['nick']) ? $g['nick'] : $g['url']), + "nick" => ((strpos($g['nick'], '@')) ? substr($g['nick'], 0, strpos($g['nick'], '@')) : $g['nick']), + "self" => (intval($g['abook_self']) ? 'abook-self' : ''), + "taggable" => '', + "label" => '', + ]; + } + } + } + + $items = array_merge($groups, $contacts); + + $o = [ + 'start' => $start, + 'count' => $count, + 'items' => $items, + ]; + + json_return_and_die($o); + } + + + public function navbar_complete() + { + + // logger('navbar_complete'); + + if (observer_prohibited()) { + return; + } + + $dirmode = intval(get_config('system', 'directory_mode')); + $search = ((x($_REQUEST, 'search')) ? htmlentities($_REQUEST['search'], ENT_COMPAT, 'UTF-8', false) : ''); + if (!$search || mb_strlen($search) < 2) { + return []; + } + + $star = false; + $address = false; + + if (substr($search, 0, 1) === '@') { + $search = substr($search, 1); + } + + if (substr($search, 0, 1) === '*') { + $star = true; + $search = substr($search, 1); + } + + if (strpos($search, '@') !== false) { + $address = true; + } + + + $url = z_root() . '/dirsearch'; + + + $results = []; + + $count = (x($_REQUEST, 'count') ? $_REQUEST['count'] : 100); + + if ($url) { + $query = $url . '?f='; + $query .= '&name=' . urlencode($search) . "&limit=$count" . (($address) ? '&address=' . urlencode(punify($search)) : ''); + + $x = z_fetch_url($query); + if ($x['success']) { + $t = 0; + $j = json_decode($x['body'], true); + if ($j && $j['results']) { + $results = $j['results']; + } + } + } + return $results; + } +} diff --git a/Code/Module/Activity.php b/Code/Module/Activity.php new file mode 100644 index 000000000..30d564634 --- /dev/null +++ b/Code/Module/Activity.php @@ -0,0 +1,303 @@ + [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + ZlibActivity::ap_schema() + ]], $i); + + $headers = []; + $headers['Content-Type'] = 'application/x-nomad+json'; + $x['signature'] = LDSignatures::sign($x, $chan); + $ret = json_encode($x, JSON_UNESCAPED_SLASHES); + $headers['Digest'] = HTTPSig::generate_digest_header($ret); + $headers['(request-target)'] = strtolower($_SERVER['REQUEST_METHOD']) . ' ' . $_SERVER['REQUEST_URI']; + $h = HTTPSig::create_sig($headers, $chan['channel_prvkey'], Channel::url($chan)); + HTTPSig::set_headers($h); + echo $ret; + killme(); + } + + + goaway(z_root() . '/item/' . argv(1)); + } +} + diff --git a/Code/Module/Admin.php b/Code/Module/Admin.php new file mode 100644 index 000000000..db5e203b9 --- /dev/null +++ b/Code/Module/Admin.php @@ -0,0 +1,188 @@ +sm = new SubModule(); + } + + public function init() + { + + logger('admin_init', LOGGER_DEBUG); + + if (!is_site_admin()) { + logger('admin denied.'); + return; + } + + if (argc() > 1) { + $this->sm->call('init'); + } + } + + + public function post() + { + + logger('admin_post', LOGGER_DEBUG); + + if (!is_site_admin()) { + logger('admin denied.'); + return; + } + + if (argc() > 1) { + $this->sm->call('post'); + } + + // goaway(z_root() . '/admin' ); + } + + /** + * @return string + */ + + public function get() + { + + logger('admin_content', LOGGER_DEBUG); + + if (!is_site_admin()) { + logger('admin denied.'); + return login(false); + } + + /* + * Page content + */ + + Navbar::set_selected('Admin'); + + $o = ''; + + if (argc() > 1) { + $o = $this->sm->call('get'); + if ($o === false) { + notice(t('Item not found.')); + } + } else { + $o = $this->admin_page_summary(); + } + + if (is_ajax()) { + echo $o; + killme(); + } else { + return $o; + } + } + + + /** + * @brief Returns content for Admin Summary Page. + * + * @return string HTML from parsed admin_summary.tpl + */ + + public function admin_page_summary() + { + + // list total user accounts, expirations etc. + $accounts = []; + $r = q( + "SELECT COUNT(CASE WHEN account_id > 0 THEN 1 ELSE NULL END) AS total, COUNT(CASE WHEN account_expires > %s THEN 1 ELSE NULL END) AS expiring, COUNT(CASE WHEN account_expires < %s AND account_expires > '%s' THEN 1 ELSE NULL END) AS expired, COUNT(CASE WHEN (account_flags & %d)>0 THEN 1 ELSE NULL END) AS blocked FROM account", + db_utcnow(), + db_utcnow(), + dbesc(NULL_DATE), + intval(ACCOUNT_BLOCKED) + ); + if ($r) { + $accounts['total'] = ['label' => t('Accounts'), 'val' => $r[0]['total']]; + $accounts['blocked'] = ['label' => t('Blocked accounts'), 'val' => $r[0]['blocked']]; + $accounts['expired'] = ['label' => t('Expired accounts'), 'val' => $r[0]['expired']]; + $accounts['expiring'] = ['label' => t('Expiring accounts'), 'val' => $r[0]['expiring']]; + } + + // pending registrations + + $pdg = q( + "SELECT account.*, register.hash from account left join register on account_id = register.uid where (account_flags & %d ) > 0 ", + intval(ACCOUNT_PENDING) + ); + + $pending = (($pdg) ? count($pdg) : 0); + + // available channels, primary and clones + $channels = []; + $r = q("SELECT COUNT(*) AS total, COUNT(CASE WHEN channel_primary = 1 THEN 1 ELSE NULL END) AS main, COUNT(CASE WHEN channel_primary = 0 THEN 1 ELSE NULL END) AS clones FROM channel WHERE channel_removed = 0 and channel_system = 0"); + if ($r) { + $channels['total'] = ['label' => t('Channels'), 'val' => $r[0]['total']]; + $channels['main'] = ['label' => t('Primary'), 'val' => $r[0]['main']]; + $channels['clones'] = ['label' => t('Clones'), 'val' => $r[0]['clones']]; + } + + // We can do better, but this is a quick queue status + $r = q("SELECT COUNT(outq_delivered) AS total FROM outq WHERE outq_delivered = 0"); + $queue = (($r) ? $r[0]['total'] : 0); + $queues = ['label' => t('Message queues'), 'queue' => $queue]; + + $plugins = Addon::list_installed(); + + // Could be extended to provide also other alerts to the admin + + $alertmsg = ''; + + $upgrade = EMPTY_STR; + + if ((!defined('PLATFORM_ARCHITECTURE')) || (PLATFORM_ARCHITECTURE === 'zap')) { + $vrelease = get_repository_version('release'); + $vdev = get_repository_version('dev'); + $upgrade = ((version_compare(STD_VERSION, $vrelease) < 0) ? t('Your software should be updated') : ''); + } + + $t = Theme::get_template('admin_summary.tpl'); + return replace_macros($t, [ + '$title' => t('Administration'), + '$page' => t('Summary'), + '$adminalertmsg' => $alertmsg, + '$queues' => $queues, + '$accounts' => [t('Registered accounts'), $accounts], + '$pending' => [t('Pending registrations'), $pending], + '$channels' => [t('Registered channels'), $channels], + '$plugins' => (($plugins) ? [t('Active addons'), $plugins] : EMPTY_STR), + '$version' => [t('Version'), STD_VERSION], + '$vmaster' => [t('Repository version (release)'), $vrelease], + '$vdev' => [t('Repository version (dev)'), $vdev], + '$upgrade' => $upgrade, + '$build' => Config::Get('system', 'db_version') + ]); + } +} diff --git a/Code/Module/Admin/Account_edit.php b/Code/Module/Admin/Account_edit.php new file mode 100644 index 000000000..9d941d630 --- /dev/null +++ b/Code/Module/Admin/Account_edit.php @@ -0,0 +1,86 @@ + 2) { + $account_id = argv(2); + } + + $x = q( + "select * from account where account_id = %d limit 1", + intval($account_id) + ); + + if (!$x) { + notice(t('Account not found.') . EOL); + return ''; + } + + + $a = replace_macros(Theme::get_template('admin_account_edit.tpl'), [ + '$account' => $x[0], + '$title' => t('Account Edit'), + '$pass1' => ['pass1', t('New Password'), ' ', ''], + '$pass2' => ['pass2', t('New Password again'), ' ', ''], + '$account_language' => ['account_language', t('Account language (for emails)'), $x[0]['account_language'], '', language_list()], + '$service_class' => ['service_class', t('Service class'), $x[0]['account_service_class'], ''], + '$submit' => t('Submit'), + ]); + + return $a; + } +} diff --git a/Code/Module/Admin/Accounts.php b/Code/Module/Admin/Accounts.php new file mode 100644 index 000000000..085af4a10 --- /dev/null +++ b/Code/Module/Admin/Accounts.php @@ -0,0 +1,215 @@ + 2) { + $uid = argv(3); + $account = q( + "SELECT * FROM account WHERE account_id = %d", + intval($uid) + ); + + if (!$account) { + notice(t('Account not found') . EOL); + goaway(z_root() . '/admin/accounts'); + } + + check_form_security_token_redirectOnErr('/admin/accounts', 'admin_accounts', 't'); + + switch (argv(2)) { + case 'delete': + // delete user + Account::remove($uid, true, false); + + notice(sprintf(t("Account '%s' deleted"), $account[0]['account_email']) . EOL); + break; + case 'block': + q( + "UPDATE account SET account_flags = ( account_flags | %d ) WHERE account_id = %d", + intval(ACCOUNT_BLOCKED), + intval($uid) + ); + + notice(sprintf(t("Account '%s' blocked"), $account[0]['account_email']) . EOL); + break; + case 'unblock': + q( + "UPDATE account SET account_flags = ( account_flags & ~ %d ) WHERE account_id = %d", + intval(ACCOUNT_BLOCKED), + intval($uid) + ); + + notice(sprintf(t("Account '%s' unblocked"), $account[0]['account_email']) . EOL); + break; + } + + goaway(z_root() . '/admin/accounts'); + } + + /* get pending */ + $pending = q( + "SELECT account.*, register.hash from account left join register on account_id = register.uid where (account_flags & %d ) != 0 ", + intval(ACCOUNT_PENDING) + ); + + /* get accounts */ + + $total = q("SELECT count(*) as total FROM account"); + if (count($total)) { + App::set_pager_total($total[0]['total']); + App::set_pager_itemspage(100); + } + + $serviceclass = (($_REQUEST['class']) ? " and account_service_class = '" . dbesc($_REQUEST['class']) . "' " : ''); + + $key = (($_REQUEST['key']) ? dbesc($_REQUEST['key']) : 'account_id'); + $dir = 'asc'; + if (array_key_exists('dir', $_REQUEST)) { + $dir = ((intval($_REQUEST['dir'])) ? 'asc' : 'desc'); + } + + $base = z_root() . '/admin/accounts?f='; + $odir = (($dir === 'asc') ? '0' : '1'); + + $users = q( + "SELECT account_id , account_email, account_lastlog, account_created, account_expires, account_service_class, ( account_flags & %d ) > 0 as blocked, + (SELECT %s FROM channel as ch WHERE ch.channel_account_id = ac.account_id and ch.channel_removed = 0 ) as channels FROM account as ac + where true $serviceclass and account_flags != %d order by $key $dir limit %d offset %d ", + intval(ACCOUNT_BLOCKED), + db_concat('ch.channel_address', ' '), + intval(ACCOUNT_BLOCKED | ACCOUNT_PENDING), + intval(App::$pager['itemspage']), + intval(App::$pager['start']) + ); + + if ($users) { + for ($x = 0; $x < count($users); $x++) { + $channel_arr = explode(' ', $users[$x]['channels']); + if ($channel_arr) { + $linked = []; + foreach ($channel_arr as $c) { + $linked[] = '' . $c . ''; + } + $users[$x]['channels'] = implode(' ', $linked); + } + } + } + + $t = + $o = replace_macros(Theme::get_template('admin_accounts.tpl'), [ + '$title' => t('Administration'), + '$page' => t('Accounts'), + '$submit' => t('Submit'), + '$select_all' => t('select all'), + '$h_pending' => t('Registrations waiting for confirm'), + '$th_pending' => array(t('Request date'), t('Email')), + '$no_pending' => t('No registrations.'), + '$approve' => t('Approve'), + '$deny' => t('Deny'), + '$delete' => t('Delete'), + '$block' => t('Block'), + '$unblock' => t('Unblock'), + '$odir' => $odir, + '$base' => $base, + '$h_users' => t('Accounts'), + '$th_users' => [ + [t('ID'), 'account_id'], + [t('Email'), 'account_email'], + [t('All Channels'), 'channels'], + [t('Register date'), 'account_created'], + [t('Last login'), 'account_lastlog'], + [t('Expires'), 'account_expires'], + [t('Service Class'), 'account_service_class'] + ], + '$confirm_delete_multi' => t('Selected accounts will be deleted!\n\nEverything these accounts had posted on this site will be permanently deleted!\n\nAre you sure?'), + '$confirm_delete' => t('The account {0} will be deleted!\n\nEverything this account has posted on this site will be permanently deleted!\n\nAre you sure?'), + '$form_security_token' => get_form_security_token("admin_accounts"), + '$baseurl' => z_root(), + '$pending' => $pending, + '$users' => $users, + ]); + + $o .= paginate($a); + + return $o; + } +} diff --git a/Code/Module/Admin/Addons.php b/Code/Module/Admin/Addons.php new file mode 100644 index 000000000..e4b43c067 --- /dev/null +++ b/Code/Module/Admin/Addons.php @@ -0,0 +1,480 @@ + 2 && is_file("addon/" . argv(2) . "/" . argv(2) . ".php")) { + @include_once("addon/" . argv(2) . "/" . argv(2) . ".php"); + if (function_exists(argv(2) . '_plugin_admin_post')) { + $func = argv(2) . '_plugin_admin_post'; + $func($a); + } + + goaway(z_root() . '/admin/addons/' . argv(2)); + } elseif (argc() > 2) { + switch (argv(2)) { + case 'updaterepo': + if (array_key_exists('repoName', $_REQUEST)) { + $repoName = $_REQUEST['repoName']; + } else { + json_return_and_die(array('message' => 'No repo name provided.', 'success' => false)); + } + $extendDir = 'cache/git/sys/extend'; + $addonDir = $extendDir . '/addon'; + if (!file_exists($extendDir)) { + if (!mkdir($extendDir, 0770, true)) { + logger('Error creating extend folder: ' . $extendDir); + json_return_and_die(array('message' => 'Error creating extend folder: ' . $extendDir, 'success' => false)); + } else { + if (!symlink(realpath('extend/addon'), $addonDir)) { + logger('Error creating symlink to addon folder: ' . $addonDir); + json_return_and_die(array('message' => 'Error creating symlink to addon folder: ' . $addonDir, 'success' => false)); + } + } + } + $repoDir = 'cache/git/sys/extend/addon/' . $repoName; + if (!is_dir($repoDir)) { + logger('Repo directory does not exist: ' . $repoDir); + json_return_and_die(array('message' => 'Invalid addon repo.', 'success' => false)); + } + if (!is_writable($repoDir)) { + logger('Repo directory not writable to web server: ' . $repoDir); + json_return_and_die(array('message' => 'Repo directory not writable to web server.', 'success' => false)); + } + $git = new GitRepo('sys', null, false, $repoName, $repoDir); + try { + if ($git->pull()) { + $files = array_diff(scandir($repoDir), array('.', '..')); + foreach ($files as $file) { + if (is_dir($repoDir . '/' . $file) && $file !== '.git') { + $source = '../extend/addon/' . $repoName . '/' . $file; + $target = realpath('addon/') . '/' . $file; + unlink($target); + if (!symlink($source, $target)) { + logger('Error linking addons to /addon'); + json_return_and_die(array('message' => 'Error linking addons to /addon', 'success' => false)); + } + } + } + json_return_and_die(array('message' => 'Repo updated.', 'success' => true)); + } else { + json_return_and_die(array('message' => 'Error updating addon repo.', 'success' => false)); + } + } catch (GitException $e) { + json_return_and_die(array('message' => 'Error updating addon repo.', 'success' => false)); + } + case 'removerepo': + if (array_key_exists('repoName', $_REQUEST)) { + $repoName = $_REQUEST['repoName']; + } else { + json_return_and_die(array('message' => 'No repo name provided.', 'success' => false)); + } + $extendDir = 'cache/git/sys/extend'; + $addonDir = $extendDir . '/addon'; + if (!file_exists($extendDir)) { + if (!mkdir($extendDir, 0770, true)) { + logger('Error creating extend folder: ' . $extendDir); + json_return_and_die(array('message' => 'Error creating extend folder: ' . $extendDir, 'success' => false)); + } else { + if (!symlink(realpath('extend/addon'), $addonDir)) { + logger('Error creating symlink to addon folder: ' . $addonDir); + json_return_and_die(array('message' => 'Error creating symlink to addon folder: ' . $addonDir, 'success' => false)); + } + } + } + $repoDir = 'cache/git/sys/extend/addon/' . $repoName; + if (!is_dir($repoDir)) { + logger('Repo directory does not exist: ' . $repoDir); + json_return_and_die(array('message' => 'Invalid addon repo.', 'success' => false)); + } + if (!is_writable($repoDir)) { + logger('Repo directory not writable to web server: ' . $repoDir); + json_return_and_die(array('message' => 'Repo directory not writable to web server.', 'success' => false)); + } + /// @TODO remove directory and unlink /addon/files + if (rrmdir($repoDir)) { + json_return_and_die(array('message' => 'Repo deleted.', 'success' => true)); + } else { + json_return_and_die(array('message' => 'Error deleting addon repo.', 'success' => false)); + } + case 'installrepo': + if (array_key_exists('repoURL', $_REQUEST)) { + $repoURL = $_REQUEST['repoURL']; + $extendDir = 'cache/git/sys/extend'; + $addonDir = $extendDir . '/addon'; + if (!file_exists($extendDir)) { + if (!mkdir($extendDir, 0770, true)) { + logger('Error creating extend folder: ' . $extendDir); + json_return_and_die(array('message' => 'Error creating extend folder: ' . $extendDir, 'success' => false)); + } else { + if (!symlink(realpath('extend/addon'), $addonDir)) { + logger('Error creating symlink to addon folder: ' . $addonDir); + json_return_and_die(array('message' => 'Error creating symlink to addon folder: ' . $addonDir, 'success' => false)); + } + } + } + if (!is_writable($extendDir)) { + logger('Directory not writable to web server: ' . $extendDir); + json_return_and_die(array('message' => 'Directory not writable to web server.', 'success' => false)); + } + $repoName = null; + if (array_key_exists('repoName', $_REQUEST) && $_REQUEST['repoName'] !== '') { + $repoName = $_REQUEST['repoName']; + } else { + $repoName = GitRepo::getRepoNameFromURL($repoURL); + } + if (!$repoName) { + logger('Invalid git repo'); + json_return_and_die(array('message' => 'Invalid git repo', 'success' => false)); + } + $repoDir = $addonDir . '/' . $repoName; + $tempRepoBaseDir = 'cache/git/sys/temp/'; + $tempAddonDir = $tempRepoBaseDir . $repoName; + + if (!is_writable($addonDir) || !is_writable($tempAddonDir)) { + logger('Temp repo directory or /extend/addon not writable to web server: ' . $tempAddonDir); + json_return_and_die(array('message' => 'Temp repo directory not writable to web server.', 'success' => false)); + } + rename($tempAddonDir, $repoDir); + + if (!is_writable(realpath('addon/'))) { + logger('/addon directory not writable to web server: ' . $tempAddonDir); + json_return_and_die(array('message' => '/addon directory not writable to web server.', 'success' => false)); + } + $files = array_diff(scandir($repoDir), array('.', '..')); + foreach ($files as $file) { + if (is_dir($repoDir . '/' . $file) && $file !== '.git') { + $source = '../extend/addon/' . $repoName . '/' . $file; + $target = realpath('addon/') . '/' . $file; + unlink($target); + if (!symlink($source, $target)) { + logger('Error linking addons to /addon'); + json_return_and_die(array('message' => 'Error linking addons to /addon', 'success' => false)); + } + } + } + $git = new GitRepo('sys', $repoURL, false, $repoName, $repoDir); + $repo = $git->probeRepo(); + json_return_and_die(array('repo' => $repo, 'message' => '', 'success' => true)); + } + case 'addrepo': + if (array_key_exists('repoURL', $_REQUEST)) { + $repoURL = $_REQUEST['repoURL']; + $extendDir = 'cache/git/sys/extend'; + $addonDir = $extendDir . '/addon'; + $tempAddonDir = realpath('cache') . '/git/sys/temp'; + if (!file_exists($extendDir)) { + if (!mkdir($extendDir, 0770, true)) { + logger('Error creating extend folder: ' . $extendDir); + json_return_and_die(array('message' => 'Error creating extend folder: ' . $extendDir, 'success' => false)); + } else { + if (!symlink(realpath('extend/addon'), $addonDir)) { + logger('Error creating symlink to addon folder: ' . $addonDir); + json_return_and_die(array('message' => 'Error creating symlink to addon folder: ' . $addonDir, 'success' => false)); + } + } + } + if (!is_dir($tempAddonDir)) { + if (!mkdir($tempAddonDir, 0770, true)) { + logger('Error creating temp plugin repo folder: ' . $tempAddonDir); + json_return_and_die(array('message' => 'Error creating temp plugin repo folder: ' . $tempAddonDir, 'success' => false)); + } + } + $repoName = null; + if (array_key_exists('repoName', $_REQUEST) && $_REQUEST['repoName'] !== '') { + $repoName = $_REQUEST['repoName']; + } else { + $repoName = GitRepo::getRepoNameFromURL($repoURL); + } + if (!$repoName) { + logger('Invalid git repo'); + json_return_and_die(array('message' => 'Invalid git repo: ' . $repoName, 'success' => false)); + } + $repoDir = $tempAddonDir . '/' . $repoName; + if (!is_writable($tempAddonDir)) { + logger('Temporary directory for new addon repo is not writable to web server: ' . $tempAddonDir); + json_return_and_die(array('message' => 'Temporary directory for new addon repo is not writable to web server.', 'success' => false)); + } + // clone the repo if new automatically + $git = new GitRepo('sys', $repoURL, true, $repoName, $repoDir); + + $remotes = $git->git->remote(); + $fetchURL = $remotes['origin']['fetch']; + if ($fetchURL !== $git->url) { + if (rrmdir($repoDir)) { + $git = new GitRepo('sys', $repoURL, true, $repoName, $repoDir); + } else { + json_return_and_die(array('message' => 'Error deleting existing addon repo.', 'success' => false)); + } + } + $repo = $git->probeRepo(); + $repo['readme'] = $repo['manifest'] = null; + foreach ($git->git->tree('master') as $object) { + if ($object['type'] == 'blob' && (strtolower($object['file']) === 'readme.md' || strtolower($object['file']) === 'readme')) { + $repo['readme'] = MarkdownExtra::defaultTransform($git->git->cat->blob($object['hash'])); + } elseif ($object['type'] == 'blob' && strtolower($object['file']) === 'manifest.json') { + $repo['manifest'] = $git->git->cat->blob($object['hash']); + } + } + json_return_and_die(array('repo' => $repo, 'message' => '', 'success' => true)); + } else { + json_return_and_die(array('message' => 'No repo URL provided', 'success' => false)); + } + break; + default: + break; + } + } + } + + /** + * @brief Addons admin page. + * + * @return string with parsed HTML + */ + public function get() + { + + /* + * Single plugin + */ + + if (App::$argc == 3) { + $plugin = App::$argv[2]; + if (!is_file("addon/$plugin/$plugin.php")) { + notice(t("Item not found.")); + return ''; + } + + $enabled = in_array($plugin, Addon::list_installed()); + $info = Addon::get_info($plugin); + $x = check_plugin_versions($info); + + // disable plugins which are installed but incompatible versions + + if ($enabled && !$x) { + $enabled = false; + uninstall_plugin($plugin); + } + $info['disabled'] = 1 - intval($x); + + if (x($_GET, "a") && $_GET['a'] == "t") { + check_form_security_token_redirectOnErr('/admin/addons', 'admin_addons', 't'); + $pinstalled = false; + // Toggle plugin status + $idx = array_search($plugin, Addon::list_installed()); + if ($idx !== false) { + Addon::uninstall($plugin); + $pinstalled = false; + info(sprintf(t("Plugin %s disabled."), $plugin)); + } else { + Addon::install($plugin); + $pinstalled = true; + info(sprintf(t("Plugin %s enabled."), $plugin)); + } + + if ($pinstalled) { + @require_once("addon/$plugin/$plugin.php"); + if (function_exists($plugin . '_plugin_admin')) { + goaway(z_root() . '/admin/addons/' . $plugin); + } + } + goaway(z_root() . '/admin/addons'); + } + + // display plugin details + + if (in_array($plugin, Addon::list_installed())) { + $status = 'on'; + $action = t('Disable'); + } else { + $status = 'off'; + $action = t('Enable'); + } + + $readme = null; + if (is_file("addon/$plugin/README.md")) { + $readme = file_get_contents("addon/$plugin/README.md"); + $readme = MarkdownExtra::defaultTransform($readme); + } elseif (is_file("addon/$plugin/README")) { + $readme = "
      " . file_get_contents("addon/$plugin/README") . "
      "; + } + + $admin_form = ''; + + $r = q( + "select * from addon where plugin_admin = 1 and aname = '%s' limit 1", + dbesc($plugin) + ); + + if ($r) { + @require_once("addon/$plugin/$plugin.php"); + if (function_exists($plugin . '_plugin_admin')) { + $func = $plugin . '_plugin_admin'; + $func($a, $admin_form); + } + } + + + $t = Theme::get_template('admin_plugins_details.tpl'); + return replace_macros($t, array( + '$title' => t('Administration'), + '$page' => t('Addons'), + '$toggle' => t('Toggle'), + '$settings' => t('Settings'), + '$baseurl' => z_root(), + + '$plugin' => $plugin, + '$status' => $status, + '$action' => $action, + '$info' => $info, + '$str_author' => t('Author: '), + '$str_maintainer' => t('Maintainer: '), + '$str_minversion' => t('Minimum project version: '), + '$str_maxversion' => t('Maximum project version: '), + '$str_minphpversion' => t('Minimum PHP version: '), + '$str_serverroles' => t('Compatible Server Roles: '), + '$str_requires' => t('Requires: '), + '$disabled' => t('Disabled - version incompatibility'), + + '$admin_form' => $admin_form, + '$function' => 'addons', + '$screenshot' => '', + '$readme' => $readme, + + '$form_security_token' => get_form_security_token('admin_addons'), + )); + } + + + /* + * List plugins + */ + $plugins = []; + $files = glob('addon/*/'); + if ($files) { + foreach ($files as $file) { + if ($file === 'addon/vendor/') { + continue; + } + if (is_dir($file)) { + list($tmp, $id) = array_map('trim', explode('/', $file)); + $info = Addon::get_info($id); + $enabled = in_array($id, App::$plugins); + $x = check_plugin_versions($info); + + // disable plugins which are installed but incompatible versions + + if ($enabled && !$x) { + $enabled = false; + $idz = array_search($id, Addon::list_installed()); + if ($idz !== false) { + uninstall_plugin($id); + } + } + $info['disabled'] = 1 - intval($x); + + $plugins[] = array($id, (($enabled) ? "on" : "off"), $info); + } + } + } + + usort($plugins, 'self::plugin_sort'); + + $allowManageRepos = false; + if (is_writable('extend/addon') && is_writable('cache')) { + $allowManageRepos = true; + } + + $admin_plugins_add_repo_form = replace_macros( + Theme::get_template('admin_plugins_addrepo.tpl'), + array( + '$post' => 'admin/addons/addrepo', + '$desc' => t('Enter the public git repository URL of the addon repo.'), + '$repoURL' => array('repoURL', t('Addon repo git URL'), '', ''), + '$repoName' => array('repoName', t('Custom repo name'), '', '', t('(optional)')), + '$submit' => t('Download Addon Repo') + ) + ); + $newRepoModalID = random_string(3); + $newRepoModal = replace_macros( + Theme::get_template('generic_modal.tpl'), + array( + '$id' => $newRepoModalID, + '$title' => t('Install new repo'), + '$ok' => t('Install'), + '$cancel' => t('Cancel') + ) + ); + + $reponames = $this->listAddonRepos(); + $addonrepos = []; + foreach ($reponames as $repo) { + $addonrepos[] = array('name' => $repo, 'description' => ''); + /// @TODO Parse repo info to provide more information about repos + } + + $t = Theme::get_template('admin_plugins.tpl'); + return replace_macros($t, array( + '$title' => t('Administration'), + '$page' => t('Addons'), + '$submit' => t('Submit'), + '$baseurl' => z_root(), + '$function' => 'addons', + '$plugins' => $plugins, + '$disabled' => t('Disabled - version incompatibility'), + '$form_security_token' => get_form_security_token('admin_addons'), + '$allowManageRepos' => $allowManageRepos, + '$managerepos' => t('Manage Repos'), + '$installedtitle' => t('Installed Addon Repositories'), + '$addnewrepotitle' => t('Install a New Addon Repository'), + '$expandform' => false, + '$form' => $admin_plugins_add_repo_form, + '$newRepoModal' => $newRepoModal, + '$newRepoModalID' => $newRepoModalID, + '$addonrepos' => $addonrepos, + '$repoUpdateButton' => t('Update'), + '$repoBranchButton' => t('Switch branch'), + '$repoRemoveButton' => t('Remove') + )); + } + + public function listAddonRepos() + { + $addonrepos = []; + $addonDir = 'extend/addon/'; + if (is_dir($addonDir)) { + if ($handle = opendir($addonDir)) { + while (false !== ($entry = readdir($handle))) { + if ($entry != "." && $entry != "..") { + $addonrepos[] = $entry; + } + } + closedir($handle); + } + } + return $addonrepos; + } + + public static function plugin_sort($a, $b) + { + return (strcmp(strtolower($a[2]['name']), strtolower($b[2]['name']))); + } +} diff --git a/Code/Module/Admin/Channels.php b/Code/Module/Admin/Channels.php new file mode 100644 index 000000000..26d7731de --- /dev/null +++ b/Code/Module/Admin/Channels.php @@ -0,0 +1,203 @@ + 2) { + $uid = argv(3); + $channel = q( + "SELECT * FROM channel WHERE channel_id = %d", + intval($uid) + ); + + if (!$channel) { + notice(t('Channel not found') . EOL); + goaway(z_root() . '/admin/channels'); + } + + switch (argv(2)) { + case "delete": + { + check_form_security_token_redirectOnErr('/admin/channels', 'admin_channels', 't'); + // delete channel + Channel::channel_remove($uid, true); + + notice(sprintf(t("Channel '%s' deleted"), $channel[0]['channel_name']) . EOL); + } + break; + + case "block": + { + check_form_security_token_redirectOnErr('/admin/channels', 'admin_channels', 't'); + $pflags = $channel[0]['channel_pageflags'] ^ PAGE_CENSORED; + q( + "UPDATE channel SET channel_pageflags = %d where channel_id = %d", + intval($pflags), + intval($uid) + ); + Run::Summon(['Directory', $uid, 'nopush']); + + notice(sprintf((($pflags & PAGE_CENSORED) ? t("Channel '%s' censored") : t("Channel '%s' uncensored")), $channel[0]['channel_name'] . ' (' . $channel[0]['channel_address'] . ')') . EOL); + } + break; + + case "code": + { + check_form_security_token_redirectOnErr('/admin/channels', 'admin_channels', 't'); + $pflags = $channel[0]['channel_pageflags'] ^ PAGE_ALLOWCODE; + q( + "UPDATE channel SET channel_pageflags = %d where channel_id = %d", + intval($pflags), + intval($uid) + ); + + notice(sprintf((($pflags & PAGE_ALLOWCODE) ? t("Channel '%s' code allowed") : t("Channel '%s' code disallowed")), $channel[0]['channel_name'] . ' (' . $channel[0]['channel_address'] . ')') . EOL); + } + break; + + default: + break; + } + goaway(z_root() . '/admin/channels'); + } + + $key = (($_REQUEST['key']) ? dbesc($_REQUEST['key']) : 'channel_id'); + $dir = 'asc'; + if (array_key_exists('dir', $_REQUEST)) { + $dir = ((intval($_REQUEST['dir'])) ? 'asc' : 'desc'); + } + + $base = z_root() . '/admin/channels?f='; + $odir = (($dir === 'asc') ? '0' : '1'); + + /* get channels */ + + $total = q("SELECT count(*) as total FROM channel where channel_removed = 0 and channel_system = 0"); + if ($total) { + App::set_pager_total($total[0]['total']); + App::set_pager_itemspage(100); + } + + $channels = q( + "SELECT * from channel where channel_removed = 0 and channel_system = 0 order by $key $dir limit %d offset %d ", + intval(App::$pager['itemspage']), + intval(App::$pager['start']) + ); + + if ($channels) { + for ($x = 0; $x < count($channels); $x++) { + if ($channels[$x]['channel_pageflags'] & PAGE_CENSORED) { + $channels[$x]['blocked'] = true; + } else { + $channels[$x]['blocked'] = false; + } + + if ($channels[$x]['channel_pageflags'] & PAGE_ALLOWCODE) { + $channels[$x]['allowcode'] = true; + } else { + $channels[$x]['allowcode'] = false; + } + + $channels[$x]['channel_link'] = z_root() . '/channel/' . $channels[$x]['channel_address']; + } + } + + Hook::call('admin_channels', $channels); + + $o = replace_macros(Theme::get_template('admin_channels.tpl'), [ + // strings // + '$title' => t('Administration'), + '$page' => t('Channels'), + '$submit' => t('Submit'), + '$select_all' => t('select all'), + '$delete' => t('Delete'), + '$block' => t('Censor'), + '$unblock' => t('Uncensor'), + '$code' => t('Allow Code'), + '$uncode' => t('Disallow Code'), + '$h_channels' => t('Channel'), + '$base' => $base, + '$odir' => $odir, + '$th_channels' => array( + [t('UID'), 'channel_id'], + [t('Name'), 'channel_name'], + [t('Address'), 'channel_address']), + + '$confirm_delete_multi' => t('Selected channels will be deleted!\n\nEverything that was posted in these channels on this site will be permanently deleted!\n\nAre you sure?'), + '$confirm_delete' => t('The channel {0} will be deleted!\n\nEverything that was posted in this channel on this site will be permanently deleted!\n\nAre you sure?'), + + '$form_security_token' => get_form_security_token('admin_channels'), + + // values // + '$baseurl' => z_root(), + '$channels' => $channels, + ]); + $o .= paginate($a); + + return $o; + } +} diff --git a/Code/Module/Admin/Cover_photo.php b/Code/Module/Admin/Cover_photo.php new file mode 100644 index 000000000..8a5963d5a --- /dev/null +++ b/Code/Module/Admin/Cover_photo.php @@ -0,0 +1,448 @@ + 0 order by imgscale asc LIMIT 1", + dbesc($image_id), + intval($channel['channel_id']) + ); + + if ($r) { + $max_thumb = intval(get_config('system', 'max_thumbnail', 1600)); + $iscaled = false; + if (intval($r[0]['height']) > $max_thumb || intval($r[0]['width']) > $max_thumb) { + $imagick_path = get_config('system', 'imagick_convert_path'); + if ($imagick_path && @file_exists($imagick_path) && intval($r[0]['os_storage'])) { + $fname = dbunescbin($r[0]['content']); + $tmp_name = $fname . '-001'; + $newsize = photo_calculate_scale(array_merge(getimagesize($fname), ['max' => $max_thumb])); + $cmd = $imagick_path . ' ' . escapeshellarg(PROJECT_BASE . '/' . $fname) . ' -resize ' . $newsize . ' ' . escapeshellarg(PROJECT_BASE . '/' . $tmp_name); + // logger('imagick thumbnail command: ' . $cmd); + for ($x = 0; $x < 4; $x++) { + exec($cmd); + if (file_exists($tmp_name)) { + break; + } + } + if (file_exists($tmp_name)) { + $base_image = $r[0]; + $gis = getimagesize($tmp_name); + logger('gis: ' . print_r($gis, true)); + $base_image['width'] = $gis[0]; + $base_image['height'] = $gis[1]; + $base_image['content'] = @file_get_contents($tmp_name); + $iscaled = true; + @unlink($tmp_name); + } + } + } + if (!$iscaled) { + $base_image = $r[0]; + $base_image['content'] = (($base_image['os_storage']) ? @file_get_contents(dbunescbin($base_image['content'])) : dbunescbin($base_image['content'])); + } + + $im = photo_factory($base_image['content'], $base_image['mimetype']); + if ($im->is_valid()) { + // We are scaling and cropping the relative pixel locations to the original photo instead of the + // scaled photo we operated on. + + // First load the scaled photo to check its size. (Should probably pass this in the post form and save + // a query.) + + $g = q( + "select width, height from photo where resource_id = '%s' and uid = %d and imgscale = 3", + dbesc($image_id), + intval($channel['channel_id']) + ); + + + $scaled_width = $g[0]['width']; + $scaled_height = $g[0]['height']; + + if ((!$scaled_width) || (!$scaled_height)) { + logger('potential divide by zero scaling cover photo'); + return; + } + + // unset all other cover photos + + q( + "update photo set photo_usage = %d where photo_usage = %d and uid = %d", + intval(PHOTO_NORMAL), + intval(PHOTO_COVER), + intval($channel['channel_id']) + ); + + $orig_srcx = ($base_image['width'] / $scaled_width) * $srcX; + $orig_srcy = ($base_image['height'] / $scaled_height) * $srcY; + $orig_srcw = ($srcW / $scaled_width) * $base_image['width']; + $orig_srch = ($srcH / $scaled_height) * $base_image['height']; + + $im->cropImageRect(1200, 435, $orig_srcx, $orig_srcy, $orig_srcw, $orig_srch); + + $aid = get_account_id(); + + $p = [ + 'aid' => 0, + 'uid' => $channel['channel_id'], + 'resource_id' => $base_image['resource_id'], + 'filename' => $base_image['filename'], + 'album' => t('Cover Photos'), + 'os_path' => $base_image['os_path'], + 'display_path' => $base_image['display_path'], + 'created' => $base_image['created'], + 'edited' => $base_image['edited'] + ]; + + $p['imgscale'] = 7; + $p['photo_usage'] = PHOTO_COVER; + + $r1 = $im->storeThumbnail($p, PHOTO_RES_COVER_1200); + + $im->doScaleImage(850, 310); + $p['imgscale'] = 8; + + $r2 = $im->storeThumbnail($p, PHOTO_RES_COVER_850); + + $im->doScaleImage(425, 160); + $p['imgscale'] = 9; + + $r3 = $im->storeThumbnail($p, PHOTO_RES_COVER_425); + + if ($r1 === false || $r2 === false || $r3 === false) { + // if one failed, delete them all so we can start over. + notice(t('Image resize failed.') . EOL); + $x = q( + "delete from photo where resource_id = '%s' and uid = %d and imgscale >= 7 ", + dbesc($base_image['resource_id']), + intval($channel['channel_id']) + ); + return; + } + } else { + notice(t('Unable to process image') . EOL); + } + } + + goaway(z_root() . '/admin'); + } + + + $hash = photo_new_resource(); + $smallest = 0; + + $matches = []; + $partial = false; + + if (array_key_exists('HTTP_CONTENT_RANGE', $_SERVER)) { + $pm = preg_match('/bytes (\d*)\-(\d*)\/(\d*)/', $_SERVER['HTTP_CONTENT_RANGE'], $matches); + if ($pm) { + logger('Content-Range: ' . print_r($matches, true)); + $partial = true; + } + } + + if ($partial) { + $x = save_chunk($channel, $matches[1], $matches[2], $matches[3]); + + if ($x['partial']) { + header('Range: bytes=0-' . (($x['length']) ? $x['length'] - 1 : 0)); + json_return_and_die($x); + } else { + header('Range: bytes=0-' . (($x['size']) ? $x['size'] - 1 : 0)); + + $_FILES['userfile'] = [ + 'name' => $x['name'], + 'type' => $x['type'], + 'tmp_name' => $x['tmp_name'], + 'error' => $x['error'], + 'size' => $x['size'] + ]; + } + } else { + if (!array_key_exists('userfile', $_FILES)) { + $_FILES['userfile'] = [ + 'name' => $_FILES['files']['name'], + 'type' => $_FILES['files']['type'], + 'tmp_name' => $_FILES['files']['tmp_name'], + 'error' => $_FILES['files']['error'], + 'size' => $_FILES['files']['size'] + ]; + } + } + + $res = attach_store($channel, $channel['channel_hash'], '', array('album' => t('Cover Photos'), 'hash' => $hash)); + + logger('attach_store: ' . print_r($res, true), LOGGER_DEBUG); + + json_return_and_die(['message' => $hash]); + } + + + /** + * @brief Generate content of profile-photo view + * + * @return string + * + */ + + + public function get() + { + + if (!is_site_admin()) { + notice(t('Permission denied.') . EOL); + return; + } + + $channel = Channel::get_system(); + + $newuser = false; + + if (argc() == 3 && argv(1) === 'new') { + $newuser = true; + } + + + if (argv(2) === 'reset') { + q( + "update photo set photo_usage = %d where photo_usage = %d and uid = %d", + intval(PHOTO_NORMAL), + intval(PHOTO_COVER), + intval($channel['channel_id']) + ); + } + + if (argv(2) === 'use') { + if (argc() < 4) { + notice(t('Permission denied.') . EOL); + return; + } + + // check_form_security_token_redirectOnErr('/cover_photo', 'cover_photo'); + + $resource_id = argv(3); + + $r = q( + "SELECT id, album, imgscale FROM photo WHERE uid = %d AND resource_id = '%s' and imgscale > 0 ORDER BY imgscale ASC", + intval($channel['channel_id']), + dbesc($resource_id) + ); + if (!$r) { + notice(t('Photo not available.') . EOL); + return; + } + $havescale = false; + foreach ($r as $rr) { + if ($rr['imgscale'] == 7) { + $havescale = true; + } + } + + $r = q( + "SELECT content, mimetype, resource_id, os_storage FROM photo WHERE id = %d and uid = %d limit 1", + intval($r[0]['id']), + intval($channel['channel_id']) + ); + if (!$r) { + notice(t('Photo not available.') . EOL); + return; + } + + if (intval($r[0]['os_storage'])) { + $data = @file_get_contents(dbunescbin($r[0]['content'])); + } else { + $data = dbunescbin($r[0]['content']); + } + + $ph = photo_factory($data, $r[0]['mimetype']); + $smallest = 0; + if ($ph->is_valid()) { + // go ahead as if we have just uploaded a new photo to crop + $i = q( + "select resource_id, imgscale from photo where resource_id = '%s' and uid = %d and imgscale = 0", + dbesc($r[0]['resource_id']), + intval($channel['channel_id']) + ); + + if ($i) { + $hash = $i[0]['resource_id']; + foreach ($i as $ii) { + $smallest = intval($ii['imgscale']); + } + } + } + + $this->cover_photo_crop_ui_head($ph, $hash, $smallest); + } + + + if (!array_key_exists('imagecrop', App::$data)) { + $o .= replace_macros(Theme::get_template('admin_cover_photo.tpl'), [ + '$user' => $channel['channel_address'], + '$channel_id' => $channel['channel_id'], + '$info' => t('Your cover photo may be visible to anybody on the internet'), + '$existing' => Channel::get_cover_photo($channel['channel_id'], 'array', PHOTO_RES_COVER_850), + '$lbl_upfile' => t('Upload File:'), + '$lbl_profiles' => t('Select a profile:'), + '$title' => t('Change Cover Photo'), + '$submit' => t('Upload'), + '$profiles' => $profiles, + '$embedPhotos' => t('Use a photo from your albums'), + '$embedPhotosModalTitle' => t('Use a photo from your albums'), + '$embedPhotosModalCancel' => t('Cancel'), + '$embedPhotosModalOK' => t('OK'), + '$modalchooseimages' => t('Choose images to embed'), + '$modalchoosealbum' => t('Choose an album'), + '$modaldiffalbum' => t('Choose a different album'), + '$modalerrorlist' => t('Error getting album list'), + '$modalerrorlink' => t('Error getting photo link'), + '$modalerroralbum' => t('Error getting album'), + '$form_security_token' => get_form_security_token("cover_photo"), + '$select' => t('Select previously uploaded photo'), + + ]); + + Hook::call('cover_photo_content_end', $o); + + return $o; + } else { + $filename = App::$data['imagecrop'] . '-3'; + $resolution = 3; + + $o .= replace_macros(Theme::get_template('admin_cropcover.tpl'), [ + '$filename' => $filename, + '$profile' => intval($_REQUEST['profile']), + '$resource' => App::$data['imagecrop'] . '-3', + '$image_url' => z_root() . '/photo/' . $filename, + '$title' => t('Crop Image'), + '$desc' => t('Please adjust the image cropping for optimum viewing.'), + '$form_security_token' => get_form_security_token("cover_photo"), + '$done' => t('Done Editing') + ]); + return $o; + } + } + + /* @brief Generate the UI for photo-cropping + * + * @param $a Current application + * @param $ph Photo-Factory + * @return void + * + */ + + public function cover_photo_crop_ui_head($ph, $hash, $smallest) + { + + $max_length = get_config('system', 'max_image_length', MAX_IMAGE_LENGTH); + + if ($max_length > 0) { + $ph->scaleImage($max_length); + } + + $width = $ph->getWidth(); + $height = $ph->getHeight(); + + if ($width < 300 || $height < 300) { + $ph->scaleImageUp(240); + $width = $ph->getWidth(); + $height = $ph->getHeight(); + } + + + App::$data['imagecrop'] = $hash; + App::$data['imagecrop_resolution'] = $smallest; + App::$page['htmlhead'] .= replace_macros(Theme::get_template('crophead.tpl'), []); + return; + } +} diff --git a/Code/Module/Admin/Dbsync.php b/Code/Module/Admin/Dbsync.php new file mode 100644 index 000000000..a2d1dfea9 --- /dev/null +++ b/Code/Module/Admin/Dbsync.php @@ -0,0 +1,111 @@ + 3 && intval(argv(3)) && argv(2) === 'mark') { + // remove the old style config if it exists + del_config('database', 'update_r' . intval(argv(3))); + set_config('database', '_' . intval(argv(3)), 'success'); + if (intval(get_config('system', 'db_version')) < intval(argv(3))) { + set_config('system', 'db_version', intval(argv(3))); + } + info(t('Update has been marked successful') . EOL); + goaway(z_root() . '/admin/dbsync'); + } + + if (argc() > 3 && intval(argv(3)) && argv(2) === 'verify') { + $s = '_' . intval(argv(3)); + $cls = '\\Code\Update\\' . $s; + if (class_exists($cls)) { + $c = new $cls(); + if (method_exists($c, 'verify')) { + $retval = $c->verify(); + if ($retval === UPDATE_FAILED) { + $o .= sprintf(t('Verification of update %s failed. Check system logs.'), $s); + } elseif ($retval === UPDATE_SUCCESS) { + $o .= sprintf(t('Update %s was successfully applied.'), $s); + set_config('database', $s, 'success'); + } else { + $o .= sprintf(t('Verifying update %s did not return a status. Unknown if it succeeded.'), $s); + } + } else { + $o .= sprintf(t('Update %s does not contain a verification function.'), $s); + } + } else { + $o .= sprintf(t('Update function %s could not be found.'), $s); + } + + return $o; + + + // remove the old style config if it exists + del_config('database', 'update_r' . intval(argv(3))); + set_config('database', '_' . intval(argv(3)), 'success'); + if (intval(get_config('system', 'db_version')) < intval(argv(3))) { + set_config('system', 'db_version', intval(argv(3))); + } + info(t('Update has been marked successful') . EOL); + goaway(z_root() . '/admin/dbsync'); + } + + if (argc() > 2 && intval(argv(2))) { + $x = intval(argv(2)); + $s = '_' . $x; + $cls = '\\Code\Update\\' . $s; + if (class_exists($cls)) { + $c = new $cls(); + $retval = $c->run(); + if ($retval === UPDATE_FAILED) { + $o .= sprintf(t('Executing update procedure %s failed. Check system logs.'), $s); + } elseif ($retval === UPDATE_SUCCESS) { + $o .= sprintf(t('Update %s was successfully applied.'), $s); + set_config('database', $s, 'success'); + } else { + $o .= sprintf(t('Update %s did not return a status. It cannot be determined if it was successful.'), $s); + } + } else { + $o .= sprintf(t('Update function %s could not be found.'), $s); + } + + return $o; + } + + $failed = []; + $r = q("select * from config where cat = 'database' "); + if (count($r)) { + foreach ($r as $rr) { + $upd = intval(substr($rr['k'], -4)); + if ($rr['v'] === 'success') { + continue; + } + $failed[] = $upd; + } + } + if (count($failed)) { + $o = replace_macros(Theme::get_template('failed_updates.tpl'), array( + '$base' => z_root(), + '$banner' => t('Failed Updates'), + '$desc' => '', + '$mark' => t('Mark success (if update was manually applied)'), + '$verify' => t('Attempt to verify this update if a verification procedure exists'), + '$apply' => t('Attempt to execute this update step automatically'), + '$failed' => $failed + )); + } else { + return '

      ' . t('No failed updates.') . '

      '; + } + return $o; + } +} + diff --git a/Code/Module/Admin/Logs.php b/Code/Module/Admin/Logs.php new file mode 100644 index 000000000..329f4063f --- /dev/null +++ b/Code/Module/Admin/Logs.php @@ -0,0 +1,101 @@ + 'Normal', + LOGGER_TRACE => 'Trace', + LOGGER_DEBUG => 'Debug', + LOGGER_DATA => 'Data', + LOGGER_ALL => 'All' + ); + + $t = Theme::get_template('admin_logs.tpl'); + + $f = get_config('system', 'logfile'); + + $data = ''; + + if (!file_exists($f)) { + $data = t("Error trying to open $f log file.\r\n
      Check to see if file $f exist and is + readable."); + } else { + $fp = fopen($f, 'r'); + if (!$fp) { + $data = t("Couldn't open $f log file.\r\n
      Check to see if file $f is readable."); + } else { + $fstat = fstat($fp); + $size = $fstat['size']; + if ($size != 0) { + if ($size > 5000000 || $size < 0) { + $size = 5000000; + } + $seek = fseek($fp, 0 - $size, SEEK_END); + if ($seek === 0) { + $data = escape_tags(fread($fp, $size)); + while (!feof($fp)) { + $data .= escape_tags(fread($fp, 4096)); + } + } + } + fclose($fp); + } + } + + return replace_macros($t, array( + '$title' => t('Administration'), + '$page' => t('Logs'), + '$submit' => t('Submit'), + '$clear' => t('Clear'), + '$data' => $data, + '$baseurl' => z_root(), + '$logname' => get_config('system', 'logfile'), + + // name, label, value, help string, extra data... + '$debugging' => array('debugging', t("Debugging"), get_config('system', 'debugging'), ""), + '$logfile' => array('logfile', t("Log file"), get_config('system', 'logfile'), t("Must be writable by web server. Relative to your top-level webserver directory.")), + '$loglevel' => array('loglevel', t("Log level"), get_config('system', 'loglevel'), "", $log_choices), + + '$form_security_token' => get_form_security_token('admin_logs'), + )); + } +} diff --git a/Code/Module/Admin/Profile_photo.php b/Code/Module/Admin/Profile_photo.php new file mode 100644 index 000000000..039fe4a13 --- /dev/null +++ b/Code/Module/Admin/Profile_photo.php @@ -0,0 +1,540 @@ +is_valid()) { + $im->cropImage(300, $srcX, $srcY, $srcW, $srcH); + + $aid = 0; + + $p = [ + 'aid' => $aid, + 'uid' => $channel['channel_id'], + 'resource_id' => $base_image['resource_id'], + 'filename' => $base_image['filename'], + 'album' => t('Profile Photos'), + 'os_path' => $base_image['os_path'], + 'display_path' => $base_image['display_path'], + 'created' => $base_image['created'], + 'edited' => $base_image['edited'] + ]; + + $p['imgscale'] = PHOTO_RES_PROFILE_300; + $p['photo_usage'] = (($is_default_profile) ? PHOTO_PROFILE : PHOTO_NORMAL); + + $r1 = $im->storeThumbnail($p, PHOTO_RES_PROFILE_300); + + $im->scaleImage(80); + $p['imgscale'] = PHOTO_RES_PROFILE_80; + + $r2 = $im->storeThumbnail($p, PHOTO_RES_PROFILE_80); + + $im->scaleImage(48); + $p['imgscale'] = PHOTO_RES_PROFILE_48; + + $r3 = $im->storeThumbnail($p, PHOTO_RES_PROFILE_48); + + if ($r1 === false || $r2 === false || $r3 === false) { + // if one failed, delete them all so we can start over. + notice(t('Image resize failed.') . EOL); + $x = q( + "delete from photo where resource_id = '%s' and uid = %d and imgscale in ( %d, %d, %d ) ", + dbesc($base_image['resource_id']), + $channel['channel_id'], + intval(PHOTO_RES_PROFILE_300), + intval(PHOTO_RES_PROFILE_80), + intval(PHOTO_RES_PROFILE_48) + ); + return; + } + + + $r = q( + "UPDATE photo SET photo_usage = %d WHERE photo_usage = %d + AND resource_id != '%s' AND uid = %d", + intval(PHOTO_NORMAL), + intval(PHOTO_PROFILE), + dbesc($base_image['resource_id']), + intval($channel['channel_id']) + ); + + // We'll set the updated profile-photo timestamp even if it isn't the default profile, + // so that browsers will do a cache update unconditionally + // Also set links back to site-specific profile photo url in case it was + // changed to a generic URL by a clone operation. Otherwise the new photo may + // not get pushed to other sites correctly. + + $r = q( + "UPDATE xchan set xchan_photo_mimetype = '%s', xchan_photo_date = '%s', xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s' + where xchan_hash = '%s'", + dbesc($im->getType()), + dbesc(datetime_convert()), + dbesc(z_root() . '/photo/profile/l/' . $channel['channel_id']), + dbesc(z_root() . '/photo/profile/m/' . $channel['channel_id']), + dbesc(z_root() . '/photo/profile/s/' . $channel['channel_id']), + dbesc($channel['xchan_hash']) + ); + + // Similarly, tell the nav bar to bypass the cache and update the avatar image. + $_SESSION['reload_avatar'] = true; + + Config::Set('system', 'site_icon_url', z_root() . '/photo/profile/m/' . $channel['channel_id']); + + info(t('Shift-reload the page or clear browser cache if the new photo does not display immediately.') . EOL); + } else { + notice(t('Unable to process image') . EOL); + } + } + + goaway(z_root() . '/admin'); + } + + // A new photo was uploaded. Store it and save some important details + // in App::$data for use in the cropping function + + + $hash = photo_new_resource(); + $importing = false; + $smallest = 0; + + + if ($_REQUEST['importfile']) { + $hash = $_REQUEST['importfile']; + $importing = true; + } else { + $matches = []; + $partial = false; + + if (array_key_exists('HTTP_CONTENT_RANGE', $_SERVER)) { + $pm = preg_match('/bytes (\d*)\-(\d*)\/(\d*)/', $_SERVER['HTTP_CONTENT_RANGE'], $matches); + if ($pm) { + logger('Content-Range: ' . print_r($matches, true), LOGGER_DEBUG); + $partial = true; + } + } + + if ($partial) { + $x = save_chunk($channel, $matches[1], $matches[2], $matches[3]); + + if ($x['partial']) { + header('Range: bytes=0-' . (($x['length']) ? $x['length'] - 1 : 0)); + json_return_and_die($x); + } else { + header('Range: bytes=0-' . (($x['size']) ? $x['size'] - 1 : 0)); + + $_FILES['userfile'] = [ + 'name' => $x['name'], + 'type' => $x['type'], + 'tmp_name' => $x['tmp_name'], + 'error' => $x['error'], + 'size' => $x['size'] + ]; + } + } else { + if (!array_key_exists('userfile', $_FILES)) { + $_FILES['userfile'] = [ + 'name' => $_FILES['files']['name'], + 'type' => $_FILES['files']['type'], + 'tmp_name' => $_FILES['files']['tmp_name'], + 'error' => $_FILES['files']['error'], + 'size' => $_FILES['files']['size'] + ]; + } + } + + $res = attach_store($channel, $channel['channel_hash'], '', array('album' => t('Profile Photos'), 'hash' => $hash)); + + logger('attach_store: ' . print_r($res, true), LOGGER_DEBUG); + + json_return_and_die(['message' => $hash]); + } + + if (($res && intval($res['data']['is_photo'])) || $importing) { + $i = q( + "select * from photo where resource_id = '%s' and uid = %d order by imgscale", + dbesc($hash), + intval($channel['channel_hash']) + ); + + if (!$i) { + notice(t('Image upload failed.') . EOL); + return; + } + $os_storage = false; + + foreach ($i as $ii) { + if (intval($ii['imgscale']) < PHOTO_RES_640) { + $smallest = intval($ii['imgscale']); + $os_storage = intval($ii['os_storage']); + $imagedata = $ii['content']; + $filetype = $ii['mimetype']; + } + } + } + + $imagedata = (($os_storage) ? @file_get_contents(dbunescbin($imagedata)) : dbunescbin($imagedata)); + $ph = photo_factory($imagedata, $filetype); + + if (!$ph->is_valid()) { + notice(t('Unable to process image.') . EOL); + return; + } + + return $this->profile_photo_crop_ui_head($ph, $hash, $smallest); + + // This will "fall through" to the get() method, and since + // App::$data['imagecrop'] is set, it will proceed to cropping + // rather than present the upload form + } + + + /* @brief Generate content of profile-photo view + * + * @return void + * + */ + + + public function get() + { + + if (!is_site_admin()) { + notice(t('Permission denied.') . EOL); + return; + } + + $channel = Channel::get_system(); + $pf = 0; + $newuser = false; + + if (argc() == 3 && argv(2) === 'new') { + $newuser = true; + } + + if (argv(2) === 'reset') { + Config::Delete('system', 'site_icon_url'); + } + + if (argv(2) === 'use') { + if (argc() < 4) { + notice(t('Permission denied.') . EOL); + return; + } + + $resource_id = argv(3); + + $pf = (($_REQUEST['pf']) ? intval($_REQUEST['pf']) : 0); + + $c = q( + "select id, is_default from profile where uid = %d", + intval($channel['channel_id']) + ); + + $multi_profiles = true; + + if (($c) && (count($c) === 1) && (intval($c[0]['is_default']))) { + $_REQUEST['profile'] = $c[0]['id']; + $multi_profiles = false; + } else { + $_REQUEST['profile'] = $pf; + } + + $r = q( + "SELECT id, album, imgscale FROM photo WHERE uid = %d AND resource_id = '%s' ORDER BY imgscale ASC", + intval($channel['channel_id']), + dbesc($resource_id) + ); + if (!$r) { + notice(t('Photo not available.') . EOL); + return; + } + $havescale = false; + foreach ($r as $rr) { + if ($rr['imgscale'] == PHOTO_RES_PROFILE_80) { + $havescale = true; + } + } + + // set an already loaded and cropped photo as profile photo + + if ($havescale) { + // unset any existing profile photos + $r = q( + "UPDATE photo SET photo_usage = %d WHERE photo_usage = %d AND uid = %d", + intval(PHOTO_NORMAL), + intval(PHOTO_PROFILE), + intval($channel['channel_id']) + ); + + $r = q( + "UPDATE photo SET photo_usage = %d WHERE uid = %d AND resource_id = '%s'", + intval(PHOTO_PROFILE), + intval($channel['channel_id']), + dbesc($resource_id) + ); + + $r = q( + "UPDATE xchan set xchan_photo_date = '%s' where xchan_hash = '%s'", + dbesc(datetime_convert()), + dbesc($channel['xchan_hash']) + ); + + goaway(z_root() . '/admin'); + } + + $r = q( + "SELECT content, mimetype, resource_id, os_storage FROM photo WHERE id = %d and uid = %d limit 1", + intval($r[0]['id']), + intval($channel['channel_id']) + ); + if (!$r) { + notice(t('Photo not available.') . EOL); + return; + } + + if (intval($r[0]['os_storage'])) { + $data = @file_get_contents(dbunescbin($r[0]['content'])); + } else { + $data = dbunescbin($r[0]['content']); + } + + $ph = photo_factory($data, $r[0]['mimetype']); + $smallest = 0; + if ($ph->is_valid()) { + // go ahead as if we have just uploaded a new photo to crop + $i = q( + "select resource_id, imgscale from photo where resource_id = '%s' and uid = %d order by imgscale", + dbesc($r[0]['resource_id']), + intval($channel['channel_id']) + ); + + if ($i) { + $hash = $i[0]['resource_id']; + foreach ($i as $ii) { + if (intval($ii['imgscale']) < PHOTO_RES_640) { + $smallest = intval($ii['imgscale']); + } + } + } + } + + if ($multi_profiles) { + App::$data['importfile'] = $resource_id; + } else { + $this->profile_photo_crop_ui_head($ph, $hash, $smallest); + } + + // falls through with App::$data['imagecrop'] set so we go straight to the cropping section + } + + // present an upload form + + $profiles = q( + "select id, profile_name as name, is_default from profile where uid = %d order by id asc", + intval($channel['channel_id']) + ); + + if ($profiles) { + for ($x = 0; $x < count($profiles); $x++) { + $profiles[$x]['selected'] = false; + if ($pf && $profiles[$x]['id'] == $pf) { + $profiles[$x]['selected'] = true; + } + if ((!$pf) && $profiles[$x]['is_default']) { + $profiles[$x]['selected'] = true; + } + } + } + + $importing = ((array_key_exists('importfile', App::$data)) ? true : false); + + if (!array_key_exists('imagecrop', App::$data)) { + $tpl = Theme::get_template('admin_profile_photo.tpl'); + + $o .= replace_macros($tpl, [ + '$user' => $channel['channel_address'], + '$channel_id' => $channel['channel_id'], + '$info' => ((count($profiles) > 1) ? t('Your default profile photo is visible to anybody on the internet. Profile photos for alternate profiles will inherit the permissions of the profile') : t('Your site photo is visible to anybody on the internet and may be distributed to other websites.')), + '$importfile' => (($importing) ? App::$data['importfile'] : ''), + '$lbl_upfile' => t('Upload File:'), + '$lbl_profiles' => t('Select a profile:'), + '$title' => (($importing) ? t('Use Photo for Site Logo') : t('Change Site Logo')), + '$submit' => (($importing) ? t('Use') : t('Upload')), + '$profiles' => $profiles, + '$single' => ((count($profiles) == 1) ? true : false), + '$profile0' => $profiles[0], + '$embedPhotos' => t('Use a photo from your albums'), + '$embedPhotosModalTitle' => t('Use a photo from your albums'), + '$embedPhotosModalCancel' => t('Cancel'), + '$embedPhotosModalOK' => t('OK'), + '$modalchooseimages' => t('Choose images to embed'), + '$modalchoosealbum' => t('Choose an album'), + '$modaldiffalbum' => t('Choose a different album'), + '$modalerrorlist' => t('Error getting album list'), + '$modalerrorlink' => t('Error getting photo link'), + '$modalerroralbum' => t('Error getting album'), + '$form_security_token' => get_form_security_token("profile_photo"), + '$select' => t('Select previously uploaded photo'), + ]); + + Hook::call('profile_photo_content_end', $o); + return $o; + } else { + // present a cropping form + + $filename = App::$data['imagecrop'] . '-' . App::$data['imagecrop_resolution']; + $resolution = App::$data['imagecrop_resolution']; + $o .= replace_macros(Theme::get_template('admin_cropbody.tpl'), [ + '$filename' => $filename, + '$profile' => intval($_REQUEST['profile']), + '$resource' => App::$data['imagecrop'] . '-' . App::$data['imagecrop_resolution'], + '$image_url' => z_root() . '/photo/' . $filename, + '$title' => t('Crop Image'), + '$desc' => t('Please adjust the image cropping for optimum viewing.'), + '$form_security_token' => get_form_security_token("profile_photo"), + '$done' => t('Done Editing') + ]); + return $o; + } + } + + /* @brief Generate the UI for photo-cropping + * + * @param $ph Photo-Factory + * @return void + * + */ + + public function profile_photo_crop_ui_head($ph, $hash, $smallest) + { + $max_length = get_config('system', 'max_image_length', MAX_IMAGE_LENGTH); + + if ($max_length > 0) { + $ph->scaleImage($max_length); + } + + App::$data['width'] = $ph->getWidth(); + App::$data['height'] = $ph->getHeight(); + + if (App::$data['width'] < 500 || App::$data['height'] < 500) { + $ph->scaleImageUp(400); + App::$data['width'] = $ph->getWidth(); + App::$data['height'] = $ph->getHeight(); + } + + App::$data['imagecrop'] = $hash; + App::$data['imagecrop_resolution'] = $smallest; + App::$page['htmlhead'] .= replace_macros(Theme::get_template('crophead.tpl'), []); + return; + } +} diff --git a/Code/Module/Admin/Profs.php b/Code/Module/Admin/Profs.php new file mode 100644 index 000000000..0530a68b5 --- /dev/null +++ b/Code/Module/Admin/Profs.php @@ -0,0 +1,200 @@ + 3) && argv(2) == 'drop' && intval(argv(3))) { + $r = q( + "delete from profdef where id = %d", + intval(argv(3)) + ); + // remove from allowed fields + + goaway(z_root() . '/admin/profs'); + } + + if ((argc() > 2) && argv(2) === 'new') { + return replace_macros(Theme::get_template('profdef_edit.tpl'), array( + '$header' => t('New Profile Field'), + '$field_name' => array('field_name', t('Field nickname'), $_REQUEST['field_name'], t('System name of field')), + '$field_type' => array('field_type', t('Input type'), (($_REQUEST['field_type']) ? $_REQUEST['field_type'] : 'text'), ''), + '$field_desc' => array('field_desc', t('Field Name'), $_REQUEST['field_desc'], t('Label on profile pages')), + '$field_help' => array('field_help', t('Help text'), $_REQUEST['field_help'], t('Additional info (optional)')), + '$submit' => t('Save') + )); + } + + if ((argc() > 2) && intval(argv(2))) { + $r = q( + "select * from profdef where id = %d limit 1", + intval(argv(2)) + ); + if (!$r) { + notice(t('Field definition not found') . EOL); + goaway(z_root() . '/admin/profs'); + } + + return replace_macros(Theme::get_template('profdef_edit.tpl'), array( + '$id' => intval($r[0]['id']), + '$header' => t('Edit Profile Field'), + '$field_name' => array('field_name', t('Field nickname'), $r[0]['field_name'], t('System name of field')), + '$field_type' => array('field_type', t('Input type'), $r[0]['field_type'], ''), + '$field_desc' => array('field_desc', t('Field Name'), $r[0]['field_desc'], t('Label on profile pages')), + '$field_help' => array('field_help', t('Help text'), $r[0]['field_help'], t('Additional info (optional)')), + '$submit' => t('Save') + )); + } + + $basic = ''; + $barr = []; + $fields = Channel::get_profile_fields_basic(); + + if (!$fields) { + $fields = Channel::get_profile_fields_basic(1); + } + if ($fields) { + foreach ($fields as $k => $v) { + if ($basic) { + $basic .= ', '; + } + $basic .= trim($k); + $barr[] = trim($k); + } + } + + $advanced = ''; + $fields = Channel::get_profile_fields_advanced(); + if (!$fields) { + $fields = Channel::get_profile_fields_advanced(1); + } + if ($fields) { + foreach ($fields as $k => $v) { + if (in_array(trim($k), $barr)) { + continue; + } + if ($advanced) { + $advanced .= ', '; + } + $advanced .= trim($k); + } + } + + $all = ''; + $fields = Channel::get_profile_fields_advanced(1); + if ($fields) { + foreach ($fields as $k => $v) { + if ($all) { + $all .= ', '; + } + $all .= trim($k); + } + } + + $r = q("select * from profdef where true"); + if ($r) { + foreach ($r as $rr) { + if ($all) { + $all .= ', '; + } + $all .= $rr['field_name']; + } + } + + + $o = replace_macros(Theme::get_template('admin_profiles.tpl'), array( + '$title' => t('Profile Fields'), + '$basic' => array('basic', t('Basic Profile Fields'), $basic, ''), + '$advanced' => array('advanced', t('Advanced Profile Fields'), $advanced, t('(In addition to basic fields)')), + '$all' => $all, + '$all_desc' => t('All available fields'), + '$cust_field_desc' => t('Custom Fields'), + '$cust_fields' => $r, + '$edit' => t('Edit'), + '$drop' => t('Delete'), + '$new' => t('Create Custom Field'), + '$submit' => t('Submit') + )); + + return $o; + } +} diff --git a/Code/Module/Admin/Queue.php b/Code/Module/Admin/Queue.php new file mode 100644 index 000000000..4f270c9a9 --- /dev/null +++ b/Code/Module/Admin/Queue.php @@ -0,0 +1,52 @@ + t('Queue Statistics'), + '$numentries' => t('Total Entries'), + '$priority' => t('Priority'), + '$desturl' => t('Destination URL'), + '$nukehub' => t('Mark hub permanently offline'), + '$empty' => t('Empty queue for this hub'), + '$lastconn' => t('Last known contact'), + '$hasentries' => ((count($r)) ? true : false), + '$entries' => $r, + '$expert' => $expert + )); + + return $o; + } +} diff --git a/Code/Module/Admin/Security.php b/Code/Module/Admin/Security.php new file mode 100644 index 000000000..a0251a23b --- /dev/null +++ b/Code/Module/Admin/Security.php @@ -0,0 +1,191 @@ +trim_array_elems(explode("\n", $_POST['allowed_sites'])); + set_config('system', 'allowed_sites', $ws); + + $bs = $this->trim_array_elems(explode("\n", $_POST['denied_sites'])); + set_config('system', 'denied_sites', $bs); + + $wc = $this->trim_array_elems(explode("\n", $_POST['allowed_channels'])); + set_config('system', 'allowed_channels', $wc); + + $bc = $this->trim_array_elems(explode("\n", $_POST['denied_channels'])); + set_config('system', 'denied_channels', $bc); + + $ws = $this->trim_array_elems(explode("\n", $_POST['pubstream_allowed_sites'])); + set_config('system', 'pubstream_allowed_sites', $ws); + + $bs = $this->trim_array_elems(explode("\n", $_POST['pubstream_denied_sites'])); + set_config('system', 'pubstream_denied_sites', $bs); + + $wc = $this->trim_array_elems(explode("\n", $_POST['pubstream_allowed_channels'])); + set_config('system', 'pubstream_allowed_channels', $wc); + + $bc = $this->trim_array_elems(explode("\n", $_POST['pubstream_denied_channels'])); + set_config('system', 'pubstream_denied_channels', $bc); + + $embed_sslonly = ((x($_POST, 'embed_sslonly')) ? true : false); + set_config('system', 'embed_sslonly', $embed_sslonly); + + $we = $this->trim_array_elems(explode("\n", $_POST['embed_allow'])); + set_config('system', 'embed_allow', $we); + + $be = $this->trim_array_elems(explode("\n", $_POST['embed_deny'])); + set_config('system', 'embed_deny', $be); + + $ts = ((x($_POST, 'transport_security')) ? true : false); + set_config('system', 'transport_security_header', $ts); + + $cs = ((x($_POST, 'content_security')) ? true : false); + set_config('system', 'content_security_policy', $cs); + + goaway(z_root() . '/admin/security'); + } + + + public function get() + { + + $allowedsites = get_config('system', 'allowed_sites'); + $allowedsites_str = ((is_array($allowedsites)) ? implode("\n", $allowedsites) : ''); + + $deniedsites = get_config('system', 'denied_sites'); + $deniedsites_str = ((is_array($deniedsites)) ? implode("\n", $deniedsites) : ''); + + + $allowedchannels = get_config('system', 'allowed_channels'); + $allowedchannels_str = ((is_array($allowedchannels)) ? implode("\n", $allowedchannels) : ''); + + $deniedchannels = get_config('system', 'denied_channels'); + $deniedchannels_str = ((is_array($deniedchannels)) ? implode("\n", $deniedchannels) : ''); + + $psallowedsites = get_config('system', 'pubstream_allowed_sites'); + $psallowedsites_str = ((is_array($psallowedsites)) ? implode("\n", $psallowedsites) : ''); + + $psdeniedsites = get_config('system', 'pubstream_denied_sites'); + $psdeniedsites_str = ((is_array($psdeniedsites)) ? implode("\n", $psdeniedsites) : ''); + + + $psallowedchannels = get_config('system', 'pubstream_allowed_channels'); + $psallowedchannels_str = ((is_array($psallowedchannels)) ? implode("\n", $psallowedchannels) : ''); + + $psdeniedchannels = get_config('system', 'pubstream_denied_channels'); + $psdeniedchannels_str = ((is_array($psdeniedchannels)) ? implode("\n", $psdeniedchannels) : ''); + + $allowedembeds = get_config('system', 'embed_allow'); + $allowedembeds_str = ((is_array($allowedembeds)) ? implode("\n", $allowedembeds) : ''); + + $deniedembeds = get_config('system', 'embed_deny'); + $deniedembeds_str = ((is_array($deniedembeds)) ? implode("\n", $deniedembeds) : ''); + + $embed_coop = intval(get_config('system', 'embed_coop')); + + if ((!$allowedembeds) && (!$deniedembeds)) { + $embedhelp1 = t("By default, unfiltered HTML is allowed in embedded media. This is inherently insecure."); + } + + $embedhelp2 = t("The recommended setting is to only allow unfiltered HTML from the following sites:"); + $embedhelp3 = t("https://youtube.com/
      https://www.youtube.com/
      https://youtu.be/
      https://vimeo.com/
      https://soundcloud.com/
      "); + $embedhelp4 = t("All other embedded content will be filtered, unless embedded content from that site is explicitly blocked."); + + $t = Theme::get_template('admin_security.tpl'); + return replace_macros($t, array( + '$title' => t('Administration'), + '$page' => t('Security'), + '$form_security_token' => get_form_security_token('admin_security'), + '$block_public' => array('block_public', t("Block public"), get_config('system', 'block_public'), t("Check to block public access to all otherwise public personal pages on this site unless you are currently authenticated.")), + '$block_public_search' => array('block_public_search', t("Block public search"), get_config('system', 'block_public_search', 1), t("Prevent access to search content unless you are currently authenticated.")), + '$block_public_dir' => ['block_public_directory', t('Block directory from visitors'), get_config('system', 'block_public_directory', true), t('Only allow authenticated access to directory.')], + '$localdir_hide' => ['localdir_hide', t('Hide local directory'), intval(get_config('system', 'localdir_hide')), t('Only use the global directory')], + '$cloud_noroot' => ['cloud_noroot', t('Provide a cloud root directory'), 1 - intval(get_config('system', 'cloud_disable_siteroot', true)), t('The cloud root directory lists all channel names which provide public files. Otherwise only the names of connections are shown.')], + '$cloud_disksize' => ['cloud_disksize', t('Show total disk space available to cloud uploads'), intval(get_config('system', 'cloud_report_disksize')), ''], + '$thumbnail_security' => ['thumbnail_security', t("Allow SVG thumbnails in file browser"), get_config('system', 'thumbnail_security', 0), t("WARNING: SVG images may contain malicious code.")], + + '$inline_pdf' => ['inline_pdf', t("Allow embedded (inline) PDF files"), get_config('system', 'inline_pdf', 0), ''], + '$anonymous_comments' => ['anonymous_comments', t('Permit anonymous comments'), intval(get_config('system', 'anonymous_comments')), t('Moderation will be performed by channels that select this comment option.')], + '$transport_security' => array('transport_security', t('Set "Transport Security" HTTP header'), intval(get_config('system', 'transport_security_header')), ''), + '$content_security' => array('content_security', t('Set "Content Security Policy" HTTP header'), intval(get_config('system', 'content_security_policy')), ''), + '$allowed_email' => array('allowed_email', t("Allowed email domains"), get_config('system', 'allowed_email'), t("Comma separated list of domains which are allowed in email addresses for registrations to this site. Wildcards are accepted. Empty to allow any domains")), + '$not_allowed_email' => array('not_allowed_email', t("Not allowed email domains"), get_config('system', 'not_allowed_email'), t("Comma separated list of domains which are not allowed in email addresses for registrations to this site. Wildcards are accepted. Empty to allow any domains, unless allowed domains have been defined.")), + '$allowed_sites' => array('allowed_sites', t('Allow communications only from these sites'), $allowedsites_str, t('One site per line. Leave empty to allow communication from anywhere by default')), + '$denied_sites' => array('denied_sites', t('Block communications from these sites'), $deniedsites_str, ''), + '$allowed_channels' => array('allowed_channels', t('Allow communications only from these channels'), $allowedchannels_str, t('One channel (hash) per line. Leave empty to allow communication from any channel by default')), + '$denied_channels' => array('denied_channels', t('Block communications from these channels'), $deniedchannels_str, ''), + + '$psallowed_sites' => array('pubstream_allowed_sites', t('Allow public stream communications only from these sites'), $psallowedsites_str, t('One site per line. Leave empty to allow communication from anywhere by default')), + '$psdenied_sites' => array('pubstream_denied_sites', t('Block public stream communications from these sites'), $psdeniedsites_str, ''), + '$psallowed_channels' => array('pubstream_allowed_channels', t('Allow public stream communications only from these channels'), $psallowedchannels_str, t('One channel (hash) per line. Leave empty to allow communication from any channel by default')), + '$psdenied_channels' => array('pubstream_denied_channels', t('Block public stream communications from these channels'), $psdeniedchannels_str, ''), + + + '$embed_sslonly' => array('embed_sslonly', t('Only allow embeds from secure (SSL) websites and links.'), intval(get_config('system', 'embed_sslonly')), ''), + '$embed_allow' => array('embed_allow', t('Allow unfiltered embedded HTML content only from these domains'), $allowedembeds_str, t('One site per line. By default embedded content is filtered.')), + '$embed_deny' => array('embed_deny', t('Block embedded HTML from these domains'), $deniedembeds_str, ''), + +// '$embed_coop' => array('embed_coop', t('Cooperative embed security'), $embed_coop, t('Enable to share embed security with other compatible sites/hubs')), + + '$submit' => t('Submit') + )); + } + + + public function trim_array_elems($arr) + { + $narr = []; + + if ($arr && is_array($arr)) { + for ($x = 0; $x < count($arr); $x++) { + $y = trim($arr[$x]); + if ($y) { + $narr[] = $y; + } + } + } + return $narr; + } +} diff --git a/Code/Module/Admin/Site.php b/Code/Module/Admin/Site.php new file mode 100644 index 000000000..ed131dc4b --- /dev/null +++ b/Code/Module/Admin/Site.php @@ -0,0 +1,384 @@ + 0) ? intval(trim($_POST['delivery_batch_count'])) : 3); + $poll_interval = ((x($_POST, 'poll_interval')) ? intval(trim($_POST['poll_interval'])) : 0); + $maxloadavg = ((x($_POST, 'maxloadavg')) ? intval(trim($_POST['maxloadavg'])) : 50); +// $feed_contacts = ((x($_POST,'feed_contacts')) ? intval($_POST['feed_contacts']) : 0); + $ap_contacts = ((x($_POST, 'ap_contacts')) ? intval($_POST['ap_contacts']) : 0); + $verify_email = ((x($_POST, 'verify_email')) ? 1 : 0); + $imagick_path = ((x($_POST, 'imagick_path')) ? trim($_POST['imagick_path']) : ''); + $force_queue = ((intval($_POST['force_queue']) > 0) ? intval($_POST['force_queue']) : 3000); + $pub_incl = escape_tags(trim($_POST['pub_incl'])); + $pub_excl = escape_tags(trim($_POST['pub_excl'])); + + $permissions_role = escape_tags(trim($_POST['permissions_role'])); + +// set_config('system', 'feed_contacts', $feed_contacts); + set_config('system', 'activitypub', $ap_contacts); + set_config('system', 'delivery_interval', $delivery_interval); + set_config('system', 'delivery_batch_count', $delivery_batch_count); + set_config('system', 'poll_interval', $poll_interval); + set_config('system', 'maxloadavg', $maxloadavg); + set_config('system', 'frontpage', $frontpage); + set_config('system', 'cache_images', $cache_images); + set_config('system', 'sellpage', $site_sellpage); + set_config('system', 'workflow_channel_next', $first_page); + set_config('system', 'site_location', $site_location); + set_config('system', 'mirror_frontpage', $mirror_frontpage); + set_config('system', 'sitename', $sitename); + set_config('system', 'login_on_homepage', $login_on_homepage); + set_config('system', 'enable_context_help', $enable_context_help); + set_config('system', 'verify_email', $verify_email); + set_config('system', 'default_expire_days', $default_expire_days); + set_config('system', 'active_expire_days', $active_expire_days); + set_config('system', 'reply_address', $reply_address); + set_config('system', 'from_email', $from_email); + set_config('system', 'from_email_name', $from_email_name); + set_config('system', 'imagick_convert_path', $imagick_path); + set_config('system', 'default_permissions_role', $permissions_role); + set_config('system', 'show_like_counts', $show_like_counts); + set_config('system', 'pubstream_incl', $pub_incl); + set_config('system', 'pubstream_excl', $pub_excl); + set_config('system', 'max_imported_follow', $max_imported_follow); + set_config('system', 'animated_avatars', $animations); + set_config('system', 'tos_required', $tos_required); + + if ($directory_server) { + set_config('system', 'directory_server', $directory_server); + } + + if ($admininfo == '') { + del_config('system', 'admininfo'); + } else { + require_once('include/text.php'); + linkify_tags($admininfo, local_channel()); + set_config('system', 'admininfo', $admininfo); + } + set_config('system', 'siteinfo', $siteinfo); + + // sync sitename and siteinfo updates to the system channel + + q( + "update profile set about = '%s' where uid = %d and is_default = 1", + dbesc($siteinfo), + intval($sys['channel_id']) + ); + q( + "update profile set fullname = '%s' where uid = %d and is_default = 1", + dbesc($sitename), + intval($sys['channel_id']) + ); + q( + "update channel set channel_name = '%s' where channel_id = %d", + dbesc($sitename), + intval($sys['channel_id']) + ); + q( + "update xchan set xchan_name = '%s' , xchan_name_updated = '%s' where xchan_hash = '%s'", + dbesc($sitename), + dbesc(datetime_convert()), + dbesc($sys['channel_hash']) + ); + + set_config('system', 'language', $language); + set_config('system', 'theme', $theme); + // set_config('system','site_channel', $site_channel); + set_config('system', 'maximagesize', $maximagesize); + + set_config('system', 'register_policy', $register_policy); + set_config('system', 'minimum_age', $minimum_age); + set_config('system', 'invitation_only', $invite_only); + set_config('system', 'access_policy', $access_policy); + set_config('system', 'account_abandon_days', $abandon_days); + set_config('system', 'register_text', $register_text); + set_config('system', 'publish_all', $force_publish); + set_config('system', 'public_stream_mode', $public_stream_mode); + set_config('system', 'open_pubstream', $open_pubstream); + set_config('system', 'force_queue_threshold', $force_queue); + if ($global_directory == '') { + del_config('system', 'directory_submit_url'); + } else { + set_config('system', 'directory_submit_url', $global_directory); + } + + set_config('system', 'no_community_page', $no_community_page); + set_config('system', 'no_utf', $no_utf); + set_config('system', 'verifyssl', $verifyssl); + set_config('system', 'proxyuser', $proxyuser); + set_config('system', 'proxy', $proxy); + set_config('system', 'curl_timeout', $timeout); + set_config('system', 'curl_post_timeout', $post_timeout); + + info(t('Site settings updated.') . EOL); + goaway(z_root() . '/admin/site'); + } + + /** + * @brief Admin page site. + * + * @return string with HTML + */ + + public function get() + { + + /* Installed langs */ + $lang_choices = []; + $langs = glob('view/*/strings.php'); + + if (is_array($langs) && count($langs)) { + if (!in_array('view/en/strings.php', $langs)) { + $langs[] = 'view/en/'; + } + asort($langs); + foreach ($langs as $l) { + $t = explode("/", $l); + $lang_choices[$t[1]] = $t[1]; + } + } + + /* Installed themes */ + $theme_choices_mobile["---"] = t("Default"); + $theme_choices = []; + $files = glob('view/theme/*'); + if ($files) { + foreach ($files as $file) { + $vars = ''; + $f = basename($file); + + $info = Theme::get_info($f); + $compatible = Addon::check_versions($info); + if (!$compatible) { + $theme_choices[$f] = $theme_choices_mobile[$f] = sprintf(t('%s - (Incompatible)'), $f); + continue; + } + + if (file_exists($file . '/library')) { + continue; + } + if (file_exists($file . '/mobile')) { + $vars = t('mobile'); + } + if (file_exists($file . '/experimental')) { + $vars .= t('experimental'); + } + if (file_exists($file . '/unsupported')) { + $vars .= t('unsupported'); + } + if ($vars) { + $theme_choices[$f] = $f . ' (' . $vars . ')'; + $theme_choices_mobile[$f] = $f . ' (' . $vars . ')'; + } else { + $theme_choices[$f] = $f; + $theme_choices_mobile[$f] = $f; + } + } + } + + $dir_choices = null; + $dirmode = get_config('system', 'directory_mode'); + $realm = get_directory_realm(); + + // directory server should not be set or settable unless we are a directory client + // avoid older redmatrix servers which don't have modern encryption + + if ($dirmode == DIRECTORY_MODE_NORMAL) { + $x = q( + "select site_url from site where site_flags in (%d,%d) and site_realm = '%s' and site_dead = 0", + intval(DIRECTORY_MODE_SECONDARY), + intval(DIRECTORY_MODE_PRIMARY), + dbesc($realm) + ); + if ($x) { + $dir_choices = []; + foreach ($x as $xx) { + $dir_choices[$xx['site_url']] = $xx['site_url']; + } + } + } + + + /* Admin Info */ + + $admininfo = get_config('system', 'admininfo'); + + /* Register policy */ + $register_choices = [ + REGISTER_CLOSED => t("No"), + REGISTER_APPROVE => t("Yes - with approval"), + REGISTER_OPEN => t("Yes") + ]; + + /* Acess policy */ + $access_choices = [ + ACCESS_PRIVATE => t("My site is not a public server"), + ACCESS_FREE => t("My site provides free public access"), + ACCESS_PAID => t("My site provides paid public access"), + ACCESS_TIERED => t("My site provides free public access and premium paid plans") + ]; + + $perm_roles = PermissionRoles::roles(); + $default_role = get_config('system', 'default_permissions_role', 'social'); + + $role = ['permissions_role', t('Default permission role for new accounts'), $default_role, t('This role will be used for the first channel created after registration.'), $perm_roles]; + + + $homelogin = get_config('system', 'login_on_homepage'); + $enable_context_help = get_config('system', 'enable_context_help'); + + return replace_macros(Theme::get_template('admin_site.tpl'), [ + '$title' => t('Administration'), + '$page' => t('Site'), + '$submit' => t('Submit'), + '$h_basic' => t('Site Configuration'), + '$registration' => t('Registration'), + '$upload' => t('File upload'), + '$corporate' => t('Policies'), + '$advanced' => t('Advanced'), + '$baseurl' => z_root(), + '$sitename' => ['sitename', t("Site name"), htmlspecialchars(get_config('system', 'sitename', App::get_hostname()), ENT_QUOTES, 'UTF-8'), ''], + '$admininfo' => ['admininfo', t("Administrator Information"), $admininfo, t("Contact information for site administrators. Displayed on siteinfo page. BBCode may be used here.")], + '$siteinfo' => ['siteinfo', t('Site Information'), get_config('system', 'siteinfo'), t("Publicly visible description of this site. Displayed on siteinfo page. BBCode may be used here.")], + '$language' => ['language', t("System language"), get_config('system', 'language', 'en'), "", $lang_choices], + '$theme' => ['theme', t("System theme"), get_config('system', 'theme'), t("Default system theme - may be over-ridden by user profiles - change theme settings"), $theme_choices], +// '$theme_mobile' => [ 'theme_mobile', t("Mobile system theme"), get_config('system','mobile_theme'), t("Theme for mobile devices"), $theme_choices_mobile ], +// '$site_channel' => [ 'site_channel', t("Channel to use for this website's static pages"), get_config('system','site_channel'), t("Site Channel") ], + '$ap_contacts' => ['ap_contacts', t('ActivityPub protocol'), get_config('system', 'activitypub', ACTIVITYPUB_ENABLED), t('Provides access to software supporting the ActivityPub protocol.')], + '$maximagesize' => ['maximagesize', t("Maximum image size"), intval(get_config('system', 'maximagesize')), t("Maximum size in bytes of uploaded images. Default is 0, which means no limits.")], + '$cache_images' => ['cache_images', t('Cache all public images'), intval(get_config('system', 'cache_images', 1)), t('If disabled, proxy non-SSL images, but do not store locally')], + '$register_policy' => ['register_policy', t("Does this site allow new member registration?"), get_config('system', 'register_policy'), "", $register_choices], + '$invite_only' => ['invite_only', t("Invitation only"), get_config('system', 'invitation_only'), t("Only allow new member registrations with an invitation code. New member registration must be allowed for this to work.")], + '$invite_working' => defined('INVITE_WORKING'), + '$minimum_age' => ['minimum_age', t("Minimum age"), (x(get_config('system', 'minimum_age')) ? get_config('system', 'minimum_age') : 13), t("Minimum age (in years) for who may register on this site.")], + '$access_policy' => ['access_policy', t("Which best describes the types of account offered by this hub?"), get_config('system', 'access_policy'), t("If a public server policy is selected, this information may be displayed on the public server site list."), $access_choices], + '$register_text' => ['register_text', t("Register text"), htmlspecialchars(get_config('system', 'register_text'), ENT_QUOTES, 'UTF-8'), t("Will be displayed prominently on the registration page.")], + '$tos_required' => [ 'tos_required', t('Require acceptance of Terms of Service'),get_config('system','tos_required'),'', [ t('No'), t('Yes') ] ], + '$role' => $role, + '$frontpage' => ['frontpage', t("Site homepage to show visitors (default: login box)"), get_config('system', 'frontpage'), t("example: 'public' to show public stream, 'page/sys/home' to show a system webpage called 'home' or 'include:home.html' to include a file.")], + '$mirror_frontpage' => ['mirror_frontpage', t("Preserve site homepage URL"), get_config('system', 'mirror_frontpage'), t('Present the site homepage in a frame at the original location instead of redirecting')], + '$abandon_days' => ['abandon_days', t('Accounts abandoned after x days'), get_config('system', 'account_abandon_days'), t('Will not waste system resources polling external sites for abandonded accounts. Enter 0 for no time limit.')], + '$block_public_dir' => ['block_public_directory', t('Block directory from visitors'), get_config('system', 'block_public_directory', true), t('Only allow authenticated access to directory.')], + '$verify_email' => ['verify_email', t("Verify Email Addresses"), get_config('system', 'verify_email'), t("Check to verify email addresses used in account registration (recommended).")], + '$force_publish' => ['publish_all', t("Force publish in directory"), get_config('system', 'publish_all'), t("Check to force all profiles on this site to be listed in the site directory.")], + + '$public_stream_mode' => ['public_stream_mode', t('Public stream'), intval(get_config('system', 'public_stream_mode', 0)), t('Provide a Public stream on your site. This content is unmoderated.'), [ + 0 => t('the Public stream is disabled'), + 1 => t('the Public stream contains public conversations from this site only'), + 2 => t('the Public stream contains public conversations from anywhere on the internet'), + ]], + + '$open_pubstream' => ['open_pubstream', t('Allow anybody on the internet to access the Public stream'), get_config('system', 'open_pubstream', 0), t('Default is to only allow viewing by site members. Warning: this content is unmoderated.')], + '$show_like_counts' => ['show_like_counts', t('Show numbers of likes and dislikes in conversations'), get_config('system', 'show_like_counts', 1), t('If disabled, the presence of likes and dislikes will be shown, but without totals.')], + '$animations' => ['animations', t('Permit animated profile photos'), get_config('system', 'animated_avatars', true), t('Changing this may take several days to work through the system')], + '$incl' => ['pub_incl', t('Only import Public stream posts with this text'), get_config('system', 'pubstream_incl'), t('words one per line or #tags or /patterns/ or lang=xx, leave blank to import all posts')], + '$excl' => ['pub_excl', t('Do not import Public stream posts with this text'), get_config('system', 'pubstream_excl'), t('words one per line or #tags or /patterns/ or lang=xx, leave blank to import all posts')], + '$max_imported_follow' => ['max_imported_follow', t('Maximum number of imported friends of friends'), get_config('system', 'max_imported_follow', MAX_IMPORTED_FOLLOW), t('Warning: higher numbers will improve the quality of friend suggestions and directory results but can exponentially increase resource usage')], + '$login_on_homepage' => ['login_on_homepage', t("Login on Homepage"), ((intval($homelogin) || $homelogin === false) ? 1 : ''), t("Present a login box to visitors on the home page if no other content has been configured.")], + '$enable_context_help' => ['enable_context_help', t("Enable context help"), ((intval($enable_context_help) === 1 || $enable_context_help === false) ? 1 : 0), t("Display contextual help for the current page when the help button is pressed.")], + '$reply_address' => ['reply_address', t('Reply-to email address for system generated email.'), get_config('system', 'reply_address', 'noreply@' . App::get_hostname()), ''], + '$from_email' => ['from_email', t('Sender (From) email address for system generated email.'), get_config('system', 'from_email', 'Administrator@' . App::get_hostname()), ''], + '$from_email_name' => ['from_email_name', t('Display name of email sender for system generated email.'), get_config('system', 'from_email_name', System::get_site_name()), ''], + '$directory_server' => (($dir_choices) ? ['directory_server', t("Directory Server URL"), get_config('system', 'directory_server'), t("Default directory server"), $dir_choices] : null), + '$proxyuser' => ['proxyuser', t("Proxy user"), get_config('system', 'proxyuser'), ""], + '$proxy' => ['proxy', t("Proxy URL"), get_config('system', 'proxy'), ""], + '$timeout' => ['timeout', t("Network fetch timeout"), (x(get_config('system', 'curl_timeout')) ? get_config('system', 'curl_timeout') : 60), t("Value is in seconds. Set to 0 for unlimited (not recommended).")], + '$post_timeout' => ['post_timeout', t("Network post timeout"), (x(get_config('system', 'curl_post_timeout')) ? get_config('system', 'curl_post_timeout') : 90), t("Value is in seconds. Set to 0 for unlimited (not recommended).")], + '$delivery_interval' => ['delivery_interval', t("Delivery interval"), (x(get_config('system', 'delivery_interval')) ? get_config('system', 'delivery_interval') : 2), t("Delay background delivery processes by this many seconds to reduce system load. Recommend: 4-5 for shared hosts, 2-3 for virtual private servers. 0-1 for large dedicated servers.")], + '$delivery_batch_count' => ['delivery_batch_count', t('Deliveries per process'), (x(get_config('system', 'delivery_batch_count')) ? get_config('system', 'delivery_batch_count') : 3), t("Number of deliveries to attempt in a single operating system process. Adjust if necessary to tune system performance. Recommend: 1-5.")], + '$force_queue' => ['force_queue', t("Queue Threshold"), get_config('system', 'force_queue_threshold', 3000), t("Always defer immediate delivery if queue contains more than this number of entries.")], + '$poll_interval' => ['poll_interval', t("Poll interval"), (x(get_config('system', 'poll_interval')) ? get_config('system', 'poll_interval') : 2), t("Delay background polling processes by this many seconds to reduce system load. If 0, use delivery interval.")], + '$imagick_path' => ['imagick_path', t("Path to ImageMagick convert program"), get_config('system', 'imagick_convert_path'), t("If set, use this program to generate photo thumbnails for huge images ( > 4000 pixels in either dimension), otherwise memory exhaustion may occur. Example: /usr/bin/convert")], + '$maxloadavg' => ['maxloadavg', t("Maximum Load Average"), ((intval(get_config('system', 'maxloadavg')) > 0) ? get_config('system', 'maxloadavg') : 50), t("Maximum system load before delivery and poll processes are deferred - default 50.")], + '$default_expire_days' => ['default_expire_days', t('Expiration period in days for imported streams and cached images'), intval(get_config('system', 'default_expire_days', 60)), t('0 for no expiration of imported content')], + '$active_expire_days' => ['active_expire_days', t('Do not expire any posts which have comments less than this many days ago'), intval(get_config('system', 'active_expire_days', 7)), ''], + '$sellpage' => ['site_sellpage', t('Public servers: Optional landing (marketing) webpage for new registrants'), get_config('system', 'sellpage', ''), sprintf(t('Create this page first. Default is %s/register'), z_root())], + '$first_page' => ['first_page', t('Page to display after creating a new channel'), get_config('system', 'workflow_channel_next', 'profiles'), t('Default: profiles')], + '$location' => ['site_location', t('Site location'), get_config('system', 'site_location', ''), t('Region or country - shared with other sites')], + '$form_security_token' => get_form_security_token("admin_site"), + ]); + } +} diff --git a/Code/Module/Admin/Themes.php b/Code/Module/Admin/Themes.php new file mode 100644 index 000000000..9d781c5c2 --- /dev/null +++ b/Code/Module/Admin/Themes.php @@ -0,0 +1,243 @@ + $f, 'experimental' => $is_experimental, 'supported' => $is_supported, 'allowed' => $is_allowed); + } + } + + if (!count($themes)) { + notice(t('No themes found.')); + return ''; + } + + /* + * Single theme + */ + + if (App::$argc == 3) { + $theme = App::$argv[2]; + if (!is_dir("view/theme/$theme")) { + notice(t("Item not found.")); + return ''; + } + + if (x($_GET, "a") && $_GET['a'] == "t") { + check_form_security_token_redirectOnErr('/admin/themes', 'admin_themes', 't'); + + // Toggle theme status + + $this->toggle_theme($themes, $theme, $result); + $s = $this->rebuild_theme_table($themes); + if ($result) { + info(sprintf('Theme %s enabled.', $theme)); + } else { + info(sprintf('Theme %s disabled.', $theme)); + } + + set_config('system', 'allowed_themes', $s); + goaway(z_root() . '/admin/themes'); + } + + // display theme details + + if ($this->theme_status($themes, $theme)) { + $status = "on"; + $action = t("Disable"); + } else { + $status = "off"; + $action = t("Enable"); + } + + $readme = null; + if (is_file("view/theme/$theme/README.md")) { + $readme = file_get_contents("view/theme/$theme/README.md"); + $readme = MarkdownExtra::defaultTransform($readme); + } elseif (is_file("view/theme/$theme/README")) { + $readme = '
      ' . file_get_contents("view/theme/$theme/README") . '
      '; + } + + $admin_form = ''; + if (is_file("view/theme/$theme/php/config.php")) { + require_once("view/theme/$theme/php/config.php"); + if (function_exists("theme_admin")) { + $admin_form = theme_admin($a); + } + } + + $screenshot = array(get_theme_screenshot($theme), t('Screenshot')); + if (!stristr($screenshot[0], $theme)) { + $screenshot = null; + } + + $t = Theme::get_template('admin_plugins_details.tpl'); + return replace_macros($t, array( + '$title' => t('Administration'), + '$page' => t('Themes'), + '$toggle' => t('Toggle'), + '$settings' => t('Settings'), + '$baseurl' => z_root(), + + '$plugin' => $theme, + '$status' => $status, + '$action' => $action, + '$info' => get_theme_info($theme), + '$function' => 'themes', + '$admin_form' => $admin_form, + '$str_author' => t('Author: '), + '$str_maintainer' => t('Maintainer: '), + '$screenshot' => $screenshot, + '$readme' => $readme, + + '$form_security_token' => get_form_security_token('admin_themes'), + )); + } + + /* + * List themes + */ + + $xthemes = []; + if ($themes) { + foreach ($themes as $th) { + $xthemes[] = array($th['name'], (($th['allowed']) ? "on" : "off"), get_theme_info($th['name'])); + } + } + + $t = Theme::get_template('admin_plugins.tpl'); + return replace_macros($t, array( + '$title' => t('Administration'), + '$page' => t('Themes'), + '$submit' => t('Submit'), + '$baseurl' => z_root(), + '$function' => 'themes', + '$plugins' => $xthemes, + '$experimental' => t('[Experimental]'), + '$unsupported' => t('[Unsupported]'), + '$form_security_token' => get_form_security_token('admin_themes'), + )); + } + + + /** + * @brief Toggle a theme. + * + * @param array &$themes + * @param[in] string $th + * @param[out] int &$result + */ + public function toggle_theme(&$themes, $th, &$result) + { + for ($x = 0; $x < count($themes); $x++) { + if ($themes[$x]['name'] === $th) { + if ($themes[$x]['allowed']) { + $themes[$x]['allowed'] = 0; + $result = 0; + } else { + $themes[$x]['allowed'] = 1; + $result = 1; + } + } + } + } + + /** + * @param array $themes + * @param string $th + * @return int + */ + public function theme_status($themes, $th) + { + for ($x = 0; $x < count($themes); $x++) { + if ($themes[$x]['name'] === $th) { + if ($themes[$x]['allowed']) { + return 1; + } else { + return 0; + } + } + } + return 0; + } + + /** + * @param array $themes + * @return string + */ + public function rebuild_theme_table($themes) + { + $o = ''; + if (count($themes)) { + foreach ($themes as $th) { + if ($th['allowed']) { + if (strlen($o)) { + $o .= ','; + } + $o .= $th['name']; + } + } + } + return $o; + } +} diff --git a/Code/Module/Affinity.php b/Code/Module/Affinity.php new file mode 100644 index 000000000..30d245802 --- /dev/null +++ b/Code/Module/Affinity.php @@ -0,0 +1,95 @@ + 99) { + $cmax = 99; + } + $cmin = intval($_POST['affinity_cmin']); + if ($cmin < 0 || $cmin > 99) { + $cmin = 0; + } + set_pconfig(local_channel(), 'affinity', 'cmin', 0); + set_pconfig(local_channel(), 'affinity', 'cmax', $cmax); + + info(t('Friend Zoom settings updated.') . EOL); + } + + Libsync::build_sync_packet(); + } + + + public function get() + { + + $desc = t('This app (when installed) presents a slider control in your connection editor and also on your stream page. The slider represents your degree of friendship with each connection. It allows you to zoom in or out and display conversations from only your closest friends or everybody in your stream.'); + + $text = ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Friend Zoom'))) { + return $text; + } + + $text .= EOL . t('The number below represents the default maximum slider position for your stream page as a percentage.') . EOL . EOL; + + $setting_fields = $text; + + $cmax = intval(get_pconfig(local_channel(), 'affinity', 'cmax')); + $cmax = (($cmax) ? $cmax : 99); +// $setting_fields .= replace_macros(Theme::get_template('field_input.tpl'), array( +// '$field' => array('affinity_cmax', t('Default maximum affinity level'), $cmax, t('0-99 default 99')) +// )); + + if (Apps::system_app_installed(local_channel(), 'Friend Zoom')) { + $labels = array( + 0 => t('Me'), + 20 => t('Family'), + 40 => t('Friends'), + 60 => t('Peers'), + 80 => t('Connections'), + 99 => t('All') + ); + Hook::call('affinity_labels', $labels); + + $tpl = Theme::get_template('affinity.tpl'); + $x = replace_macros($tpl, [ + '$cmin' => 0, + '$cmax' => $cmax, + '$lbl' => t('Default friend zoom in/out'), + '$refresh' => t('Refresh'), + '$labels' => $labels, + ]); + + + $arr = array('html' => $x); + Hook::call('affinity_slider', $arr); + $setting_fields .= $arr['html']; + } + + $s .= replace_macros(Theme::get_template('generic_app_settings.tpl'), array( + '$addon' => array('affinity', '' . t('Friend Zoom Settings'), '', t('Submit')), + '$content' => $setting_fields + )); + + return $s; + } +} diff --git a/Code/Module/Album.php b/Code/Module/Album.php new file mode 100644 index 000000000..7494d9abb --- /dev/null +++ b/Code/Module/Album.php @@ -0,0 +1,104 @@ + 1) { + $channel = Channel::from_username(argv(1)); + } + if (!$channel) { + http_status_exit(404, 'Not found.'); + } + + $sql_extra = permissions_sql($channel['channel_id'], $observer_xchan); + + if (argc() > 2) { + $folder = argv(2); + $r = q( + "select * from attach where is_dir = 1 and hash = '%s' and uid = %d $sql_extra limit 1", + dbesc($folder), + intval($channel['channel_id']) + ); + $allowed = (($r) ? attach_can_view($channel['channel_id'], $observer_xchan, $r[0]['hash'], $bear) : false); + } else { + $folder = EMPTY_STR; + $allowed = perm_is_allowed($channel['channel_id'], $observer_xchan, 'view_storage'); + } + + if (!$allowed) { + http_status_exit(403, 'Permission denied.'); + } + + $x = q( + "select * from attach where folder = '%s' and uid = %d $sql_extra", + dbesc($folder), + intval($channel['channel_id']) + ); + + $contents = []; + + if ($x) { + foreach ($x as $xv) { + if (intval($xv['is_dir'])) { + continue; + } + if (!attach_can_view($channel['channel_id'], $observer_xchan, $xv['hash'], $bear)) { + continue; + } + if (intval($xv['is_photo'])) { + $contents[] = z_root() . '/photo/' . $xv['hash']; + } + } + } + + $obj = Activity::encode_simple_collection($contents, App::$query_string, 'OrderedCollection', count($contents)); + as_return_and_die($obj, $channel); + } + } +} diff --git a/Code/Module/Ap_probe.php b/Code/Module/Ap_probe.php new file mode 100644 index 000000000..8e22734a1 --- /dev/null +++ b/Code/Module/Ap_probe.php @@ -0,0 +1,49 @@ + t('ActivityPub Probe Diagnostic'), + '$resource' => ['resource', t('Object URL'), $_REQUEST['resource'], EMPTY_STR], + '$authf' => ['authf', t('Authenticated fetch'), $_REQUEST['authf'], EMPTY_STR, [t('No'), t('Yes')]], + '$submit' => t('Submit') + ]); + + if (x($_REQUEST, 'resource')) { + $resource = $_REQUEST['resource']; + if ($_REQUEST['authf']) { + $channel = App::get_channel(); + if (!$channel) { + $channel = Channel::get_system(); + } + } + + $x = Activity::fetch($resource, $channel, null, true); + + if ($x) { + $o .= '
      ' . str_replace('\\n', "\n", htmlspecialchars(json_encode($x, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT))) . '
      '; + $o .= '
      ' . str_replace('\\n', "\n", htmlspecialchars(Yaml::encode($x))) . '
      '; + } + } + + return $o; + } +} diff --git a/Code/Module/Api.php b/Code/Module/Api.php new file mode 100644 index 000000000..0eaac91d1 --- /dev/null +++ b/Code/Module/Api.php @@ -0,0 +1,142 @@ +"; var_dump($e); + killme(); + } + + + if (x($_POST, 'oauth_yes')) { + $app = $this->oauth_get_client($request); + if (is_null($app)) { + return "Invalid request. Unknown token."; + } + + $consumer = new OAuth1Consumer($app['client_id'], $app['pw'], $app['redirect_uri']); + + $verifier = md5($app['secret'] . local_channel()); + set_config('oauth', $verifier, local_channel()); + + + if ($consumer->callback_url != null) { + $params = $request->get_parameters(); + $glue = '?'; + if (strstr($consumer->callback_url, $glue)) { + $glue = '?'; + } + goaway($consumer->callback_url . $glue . "oauth_token=" . OAuth1Util::urlencode_rfc3986($params['oauth_token']) . "&oauth_verifier=" . OAuth1Util::urlencode_rfc3986($verifier)); + killme(); + } + + $tpl = Theme::get_template("oauth_authorize_done.tpl"); + $o = replace_macros($tpl, array( + '$title' => t('Authorize application connection'), + '$info' => t('Return to your app and insert this Security Code:'), + '$code' => $verifier, + )); + + return $o; + } + + + if (!local_channel()) { + // TODO: we need login form to redirect to this page + notice(t('Please login to continue.') . EOL); + return login(false, 'api-login', $request->get_parameters()); + } + + $app = $this->oauth_get_client($request); + if (is_null($app)) { + return "Invalid request. Unknown token."; + } + + $tpl = Theme::get_template('oauth_authorize.tpl'); + $o = replace_macros($tpl, array( + '$title' => t('Authorize application connection'), + '$app' => $app, + '$authorize' => t('Do you want to authorize this application to access your posts and contacts, and/or create new posts for you?'), + '$yes' => t('Yes'), + '$no' => t('No'), + )); + + // echo "
      "; var_dump($app); killme();
      +
      +            return $o;
      +        }
      +
      +        echo api_call();
      +        killme();
      +    }
      +
      +    public function oauth_get_client($request)
      +    {
      +
      +        $params = $request->get_parameters();
      +        $token = $params['oauth_token'];
      +
      +        $r = q(
      +            "SELECT clients.* FROM clients, tokens WHERE clients.client_id = tokens.client_id 
      +			AND tokens.id = '%s' AND tokens.auth_scope = 'request' ",
      +            dbesc($token)
      +        );
      +        if ($r) {
      +            return $r[0];
      +        }
      +
      +        return null;
      +    }
      +}
      diff --git a/Code/Module/Appman.php b/Code/Module/Appman.php
      new file mode 100644
      index 000000000..436ec9971
      --- /dev/null
      +++ b/Code/Module/Appman.php
      @@ -0,0 +1,166 @@
      + intval($_REQUEST['uid']),
      +                'url' => escape_tags($_REQUEST['url']),
      +                'guid' => escape_tags($_REQUEST['guid']),
      +                'author' => escape_tags($_REQUEST['author']),
      +                'addr' => escape_tags($_REQUEST['addr']),
      +                'name' => escape_tags($_REQUEST['name']),
      +                'desc' => escape_tags($_REQUEST['desc']),
      +                'photo' => escape_tags($_REQUEST['photo']),
      +                'version' => escape_tags($_REQUEST['version']),
      +                'price' => escape_tags($_REQUEST['price']),
      +                'page' => escape_tags($_REQUEST['sellpage']), // do not use 'page' as a request variable here as it conflicts with pagination
      +                'requires' => escape_tags($_REQUEST['requires']),
      +                'system' => intval($_REQUEST['system']),
      +                'plugin' => escape_tags($_REQUEST['plugin']),
      +                'sig' => escape_tags($_REQUEST['sig']),
      +                'categories' => escape_tags($_REQUEST['categories'])
      +            ];
      +
      +            $_REQUEST['appid'] = Apps::app_install($channel_id, $arr);
      +
      +            if (Apps::app_installed($channel_id, $arr)) {
      +                info(t('App installed.') . EOL);
      +            }
      +
      +            goaway(z_root() . '/apps');
      +        }
      +
      +
      +        $papp = Apps::app_decode($_POST['papp']);
      +
      +        if (!is_array($papp)) {
      +            notice(t('Malformed app.') . EOL);
      +            return;
      +        }
      +
      +        if ($_POST['install']) {
      +            Apps::app_install($channel_id, $papp);
      +            if (Apps::app_installed($channel_id, $papp)) {
      +                info(t('App installed.') . EOL);
      +            }
      +        }
      +
      +        if ($_POST['delete']) {
      +            Apps::app_destroy($channel_id, $papp);
      +        }
      +
      +        if ($_POST['edit']) {
      +            return;
      +        }
      +
      +        if ($_POST['feature']) {
      +            Apps::app_feature($channel_id, $papp, $_POST['feature']);
      +        }
      +
      +        if ($_POST['pin']) {
      +            Apps::app_feature($channel_id, $papp, $_POST['pin']);
      +        }
      +
      +        if ($_SESSION['return_url']) {
      +            goaway(z_root() . '/' . $_SESSION['return_url']);
      +        }
      +
      +        goaway(z_root() . '/apps');
      +    }
      +
      +
      +    public function get()
      +    {
      +
      +		$channel_id = local_channel();
      +		
      +        if (!$channel_id) {
      +            notice(t('Permission denied.') . EOL);
      +            return;
      +        }
      +
      +		if (Channel::is_system($channel_id)) {
      +			$channel_id = 0;
      +		}
      +		
      +        $channel = App::get_channel();
      +
      +        if (argc() > 3) {
      +            if (argv(2) === 'moveup') {
      +                Apps::moveup($channel_id, argv(1), argv(3));
      +            }
      +            if (argv(2) === 'movedown') {
      +                Apps::movedown($channel_id, argv(1), argv(3));
      +            }
      +            goaway(z_root() . '/apporder');
      +        }
      +
      +        $app = null;
      +        $embed = null;
      +        if ($_REQUEST['appid']) {
      +            $r = q(
      +                "select * from app where app_id = '%s' and app_channel = %d limit 1",
      +                dbesc($_REQUEST['appid']),
      +                dbesc($channel_id)
      +            );
      +            if ($r) {
      +                $app = $r[0];
      +
      +                $term = q(
      +                    "select * from term where otype = %d and oid = %d and uid = %d",
      +                    intval(TERM_OBJ_APP),
      +                    intval($r[0]['id']),
      +                    intval($channel_id)
      +                );
      +                if ($term) {
      +                    $app['categories'] = array_elm_to_str($term, 'term');
      +                }
      +            }
      +
      +            $embed = ['embed', t('Embed code'), Apps::app_encode($app, true), EMPTY_STR, 'onclick="this.select();"'];
      +        }
      +
      +        return replace_macros(Theme::get_template('app_create.tpl'), [
      +            '$banner' => (($app) ? t('Edit App') : t('Create App')),
      +            '$app' => $app,
      +            '$guid' => (($app) ? $app['app_id'] : EMPTY_STR),
      +            '$author' => (($app) ? $app['app_author'] : $channel['channel_hash']),
      +            '$addr' => (($app) ? $app['app_addr'] : $channel['xchan_addr']),
      +            '$name' => ['name', t('Name of app'), (($app) ? $app['app_name'] : EMPTY_STR), t('Required')],
      +            '$url' => ['url', t('Location (URL) of app'), (($app) ? $app['app_url'] : EMPTY_STR), t('Required')],
      +            '$desc' => ['desc', t('Description'), (($app) ? $app['app_desc'] : EMPTY_STR), EMPTY_STR],
      +            '$photo' => ['photo', t('Photo icon URL'), (($app) ? $app['app_photo'] : EMPTY_STR), t('80 x 80 pixels - optional')],
      +            '$categories' => ['categories', t('Categories (optional, comma separated list)'), (($app) ? $app['categories'] : EMPTY_STR), EMPTY_STR],
      +            '$version' => ['version', t('Version ID'), (($app) ? $app['app_version'] : EMPTY_STR), EMPTY_STR],
      +            '$price' => ['price', t('Price of app'), (($app) ? $app['app_price'] : EMPTY_STR), EMPTY_STR],
      +            '$page' => ['sellpage', t('Location (URL) to purchase app'), (($app) ? $app['app_page'] : EMPTY_STR), EMPTY_STR],
      +            '$system' => (($app) ? intval($app['app_system']) : 0),
      +            '$plugin' => (($app) ? $app['app_plugin'] : EMPTY_STR),
      +            '$requires' => (($app) ? $app['app_requires'] : EMPTY_STR),
      +            '$embed' => $embed,
      +            '$submit' => t('Submit')
      +        ]);
      +    }
      +}
      diff --git a/Code/Module/Apporder.php b/Code/Module/Apporder.php
      new file mode 100644
      index 000000000..43c2865dc
      --- /dev/null
      +++ b/Code/Module/Apporder.php
      @@ -0,0 +1,57 @@
      + t('Arrange Apps'),
      +            '$header' => [t('Change order of pinned navbar apps'), t('Change order of app tray apps')],
      +            '$desc' => [t('Use arrows to move the corresponding app left (top) or right (bottom) in the navbar'),
      +                t('Use arrows to move the corresponding app up or down in the app tray')],
      +            '$nav_apps' => $nav_apps,
      +            '$navbar_apps' => $navbar_apps
      +        ]);
      +    }
      +}
      diff --git a/Code/Module/Apps.php b/Code/Module/Apps.php
      new file mode 100644
      index 000000000..f77a338fd
      --- /dev/null
      +++ b/Code/Module/Apps.php
      @@ -0,0 +1,66 @@
      + get_config('system', 'sitename'),
      +            '$cat' => $cat,
      +            '$title' => (($available) ? t('Available Apps') : t('Installed Apps')),
      +            '$apps' => $apps,
      +            '$authed' => ((local_channel()) ? true : false),
      +            '$manage' => (($available) ? EMPTY_STR : t('Manage apps')),
      +            '$create' => (($mode == 'edit') ? t('Create Custom App') : '')
      +        ));
      +    }
      +}
      diff --git a/Code/Module/Apschema.php b/Code/Module/Apschema.php
      new file mode 100644
      index 000000000..935bd8fc6
      --- /dev/null
      +++ b/Code/Module/Apschema.php
      @@ -0,0 +1,22 @@
      + array_merge(['as' => 'https://www.w3.org/ns/activitystreams#'], Activity::ap_schema())
      +        ];
      +
      +        header('Content-Type: application/ld+json');
      +        echo json_encode($arr, JSON_UNESCAPED_SLASHES);
      +        killme();
      +    }
      +}
      diff --git a/Code/Module/Attach.php b/Code/Module/Attach.php
      new file mode 100644
      index 000000000..92ead58cc
      --- /dev/null
      +++ b/Code/Module/Attach.php
      @@ -0,0 +1,58 @@
      + 2) ? intval(argv(2)) : 0));
      +
      +        if (!$r['success']) {
      +            notice($r['message'] . EOL);
      +            return;
      +        }
      +
      +        $c = q(
      +            "select channel_address from channel where channel_id = %d limit 1",
      +            intval($r['data']['uid'])
      +        );
      +
      +        if (!$c) {
      +            return;
      +        }
      +
      +        header('Content-type: ' . $r['data']['filetype']);
      +        header('Content-Disposition: attachment; filename="' . $r['data']['filename'] . '"');
      +        if (intval($r['data']['os_storage'])) {
      +            $fname = dbunescbin($r['data']['content']);
      +            if (strpos($fname, 'store') !== false) {
      +                $istream = fopen($fname, 'rb');
      +            } else {
      +                $istream = fopen('store/' . $c[0]['channel_address'] . '/' . $fname, 'rb');
      +            }
      +            $ostream = fopen('php://output', 'wb');
      +            if ($istream && $ostream) {
      +                pipe_streams($istream, $ostream);
      +                fclose($istream);
      +                fclose($ostream);
      +            }
      +        } else {
      +            echo dbunescbin($r['data']['content']);
      +        }
      +        killme();
      +    }
      +}
      diff --git a/Code/Module/Authorize.php b/Code/Module/Authorize.php
      new file mode 100644
      index 000000000..02b729faf
      --- /dev/null
      +++ b/Code/Module/Authorize.php
      @@ -0,0 +1,145 @@
      + $name,
      +                'icon' => (x($_REQUEST, 'logo_uri') ? $_REQUEST['logo_uri'] : z_root() . '/images/icons/plugin.png'),
      +                'url' => (x($_REQUEST, 'client_uri') ? $_REQUEST['client_uri'] : ''),
      +            ];
      +
      +            $link = (($app['url']) ? '' . $app['name'] . ' ' : $app['name']);
      +
      +            $o .= replace_macros(Theme::get_template('oauth_authorize.tpl'), [
      +                '$title' => t('Authorize'),
      +                '$authorize' => sprintf(t('Do you authorize the app %s to access your channel data?'), $link),
      +                '$app' => $app,
      +                '$yes' => t('Allow'),
      +                '$no' => t('Deny'),
      +                '$client_id' => (x($_REQUEST, 'client_id') ? $_REQUEST['client_id'] : ''),
      +                '$redirect_uri' => (x($_REQUEST, 'redirect_uri') ? $_REQUEST['redirect_uri'] : ''),
      +                '$state' => (x($_REQUEST, 'state') ? $_REQUEST['state'] : ''),
      +            ]);
      +            return $o;
      +        }
      +    }
      +
      +    public function post()
      +    {
      +        if (!local_channel()) {
      +            return;
      +        }
      +
      +        $storage = new OAuth2Storage(DBA::$dba->db);
      +        $s = new OAuth2Server($storage);
      +
      +        // TODO: The automatic client registration protocol below should adhere more
      +        // closely to "OAuth 2.0 Dynamic Client Registration Protocol" defined
      +        // at https://tools.ietf.org/html/rfc7591
      +
      +        // If no client_id was provided, generate a new one.
      +        if (x($_POST, 'client_name')) {
      +            $client_name = $_POST['client_name'];
      +        } else {
      +            $client_name = $_POST['client_name'] = EMPTY_STR;
      +        }
      +
      +        // If no client_id was provided, generate a new one.
      +        if (x($_POST, 'client_id')) {
      +            $client_id = $_POST['client_id'];
      +        } else {
      +            $client_id = $_POST['client_id'] = random_string(16);
      +        }
      +
      +        // If no redirect_uri was provided, generate a fake one.
      +        if (x($_POST, 'redirect_uri')) {
      +            $redirect_uri = $_POST['redirect_uri'];
      +        } else {
      +            $redirect_uri = $_POST['redirect_uri'] = 'https://fake.example.com/oauth';
      +        }
      +
      +        $request = Request::createFromGlobals();
      +        $response = new Response();
      +
      +        // Note, "sub" field must match type and content. $user_id is used to populate - make sure it's a string.
      +        $channel = Channel::from_id(local_channel());
      +        $user_id = $channel['channel_id'];
      +
      +        $client_found = false;
      +        $client = $storage->getClientDetails($client_id);
      +
      +        logger('client: ' . print_r($client, true), LOGGER_DATA);
      +
      +        if ($client) {
      +            if (intval($client['user_id']) === 0 || intval($client['user_id']) === intval($user_id)) {
      +                $client_found = true;
      +                $client_name = $client['client_name'];
      +                $client_secret = $client['client_secret'];
      +                // Until "Dynamic Client Registration" is fully tested - allow new clients to assign their own secret in the REQUEST
      +                if (!$client_secret) {
      +                    $client_secret = ((isset($_REQUEST['client_secret'])) ? $_REQUEST['client_secret'] : random_string(16));
      +                }
      +                $grant_types = $client['grant_types'];
      +                // Client apps are registered per channel
      +
      +
      +                logger('client_id: ' . $client_id);
      +                logger('client_secret: ' . $client_secret);
      +                logger('redirect_uri: ' . $redirect_uri);
      +                logger('grant_types: ' . $_REQUEST['grant_types']);
      +                logger('scope: ' . $_REQUEST['scope']);
      +                logger('user_id: ' . $user_id);
      +                logger('client_name: ' . $client_name);
      +
      +                $storage->setClientDetails($client_id, $client_secret, $redirect_uri, $grant_types, $_REQUEST['scope'], $user_id, $client_name);
      +            }
      +        }
      +        if (!$client_found) {
      +            $response->send();
      +            killme();
      +        }
      +
      +        $response->setParameter('client_secret', $client['client_secret']);
      +
      +        // validate the authorize request
      +        if (!$s->validateAuthorizeRequest($request, $response)) {
      +            $response->send();
      +            killme();
      +        }
      +
      +        // print the authorization code if the user has authorized your client
      +        $is_authorized = ($_POST['authorize'] === 'allow');
      +        $s->handleAuthorizeRequest($request, $response, $is_authorized, $user_id);
      +        if ($is_authorized) {
      +            $code = substr($response->getHttpHeader('Location'), strpos($response->getHttpHeader('Location'), 'code=') + 5, 40);
      +            logger('Authorization Code: ' . $code);
      +        }
      +
      +        $response->send();
      +        killme();
      +    }
      +}
      diff --git a/Code/Module/Block.php b/Code/Module/Block.php
      new file mode 100644
      index 000000000..263d3d34a
      --- /dev/null
      +++ b/Code/Module/Block.php
      @@ -0,0 +1,97 @@
      + 1 && argv(1) === 'sys' && is_site_admin()) {
      +            $sys = Channel::get_system();
      +            if ($sys && intval($sys['channel_id'])) {
      +                App::$is_sys = true;
      +            }
      +        }
      +
      +        if (argc() > 1) {
      +            $which = argv(1);
      +        } else {
      +            return;
      +        }
      +
      +        Libprofile::load($which);
      +    }
      +
      +
      +    public function get()
      +    {
      +
      +        if (!App::$profile) {
      +            notice(t('Requested profile is not available.') . EOL);
      +            App::$error = 404;
      +            return;
      +        }
      +
      +        $which = argv(1);
      +
      +        $_SESSION['return_url'] = App::$query_string;
      +
      +        $uid = local_channel();
      +        $owner = 0;
      +        $channel = null;
      +        $observer = App::get_observer();
      +
      +        $channel = App::get_channel();
      +
      +        if (App::$is_sys && is_site_admin()) {
      +            $sys = Channel::get_system();
      +            if ($sys && intval($sys['channel_id'])) {
      +                $uid = $owner = intval($sys['channel_id']);
      +                $channel = $sys;
      +                $observer = $sys;
      +            }
      +        }
      +
      +        if (!$owner) {
      +            // Figure out who the page owner is.
      +            $r = q(
      +                "select channel_id from channel where channel_address = '%s'",
      +                dbesc($which)
      +            );
      +            if ($r) {
      +                $owner = intval($r[0]['channel_id']);
      +            }
      +        }
      +
      +        $ob_hash = (($observer) ? $observer['xchan_hash'] : '');
      +
      +        $perms = get_all_perms($owner, $ob_hash);
      +
      +        if (!$perms['write_pages']) {
      +            notice(t('Permission denied.') . EOL);
      +            return;
      +        }
      +
      +        // Block design features from visitors
      +
      +        if ((!$uid) || ($uid != $owner)) {
      +            notice(t('Permission denied.') . EOL);
      +            return;
      +        }
      +
      +        $mimetype = (($_REQUEST['mimetype']) ? $_REQUEST['mimetype'] : get_pconfig($owner, 'system', 'page_mimetype'));
      +
      +        $x = array(
      +            'webpage' => ITEM_TYPE_BLOCK,
      +            'is_owner' => true,
      +            'nickname' => App::$profile['channel_address'],
      +            'lockstate' => (($channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 'lock' : 'unlock'),
      +            'bang' => '',
      +            'showacl' => false,
      +            'visitor' => true,
      +            'mimetype' => $mimetype,
      +            'mimeselect' => true,
      +            'hide_location' => true,
      +            'ptlabel' => t('Block Name'),
      +            'profile_uid' => intval($owner),
      +            'expanded' => true,
      +            'novoting' => true,
      +            'bbco_autocomplete' => 'bbcode',
      +            'bbcode' => true
      +        );
      +
      +        if ($_REQUEST['title']) {
      +            $x['title'] = $_REQUEST['title'];
      +        }
      +        if ($_REQUEST['body']) {
      +            $x['body'] = $_REQUEST['body'];
      +        }
      +        if ($_REQUEST['pagetitle']) {
      +            $x['pagetitle'] = $_REQUEST['pagetitle'];
      +        }
      +
      +        $editor = status_editor($x);
      +
      +
      +        $r = q(
      +            "select iconfig.iid, iconfig.k, iconfig.v, mid, title, body, mimetype, created, edited from iconfig 
      +			left join item on iconfig.iid = item.id
      +			where uid = %d and iconfig.cat = 'system' and iconfig.k = 'BUILDBLOCK' 
      +			and item_type = %d order by item.created desc",
      +            intval($owner),
      +            intval(ITEM_TYPE_BLOCK)
      +        );
      +
      +        $pages = null;
      +
      +        if ($r) {
      +            $pages = [];
      +            foreach ($r as $rr) {
      +                $element_arr = array(
      +                    'type' => 'block',
      +                    'title' => $rr['title'],
      +                    'body' => $rr['body'],
      +                    'created' => $rr['created'],
      +                    'edited' => $rr['edited'],
      +                    'mimetype' => $rr['mimetype'],
      +                    'pagetitle' => $rr['v'],
      +                    'mid' => $rr['mid']
      +                );
      +                $pages[$rr['iid']][] = array(
      +                    'url' => $rr['iid'],
      +                    'name' => $rr['v'],
      +                    'title' => $rr['title'],
      +                    'created' => $rr['created'],
      +                    'edited' => $rr['edited'],
      +                    'bb_element' => '[element]' . base64url_encode(json_encode($element_arr)) . '[/element]'
      +                );
      +            }
      +        }
      +
      +        //Build the base URL for edit links
      +        $url = z_root() . '/editblock/' . $which;
      +
      +        $o .= replace_macros(Theme::get_template('blocklist.tpl'), array(
      +            '$baseurl' => $url,
      +            '$title' => t('Blocks'),
      +            '$name' => t('Block Name'),
      +            '$blocktitle' => t('Block Title'),
      +            '$created' => t('Created'),
      +            '$edited' => t('Edited'),
      +            '$create' => t('Create'),
      +            '$edit' => t('Edit'),
      +            '$share' => t('Share'),
      +            '$delete' => t('Delete'),
      +            '$editor' => $editor,
      +            '$pages' => $pages,
      +            '$channel' => $which,
      +            '$view' => t('View'),
      +            '$preview' => '1',
      +        ));
      +
      +        return $o;
      +    }
      +}
      diff --git a/Code/Module/Ca.php b/Code/Module/Ca.php
      new file mode 100644
      index 000000000..9df6c8cf5
      --- /dev/null
      +++ b/Code/Module/Ca.php
      @@ -0,0 +1,66 @@
      + 1) {
      +            $path = 'cache/img/' . substr(argv(1), 0, 2) . '/' . argv(1);
      +
      +            if (file_exists($path) && filesize($path)) {
      +                $x = @getimagesize($path);
      +                if ($x) {
      +                    header('Content-Type: ' . $x['mime']);
      +                }
      +
      +                $cache = intval(get_config('system', 'photo_cache_time'));
      +                if (!$cache) {
      +                    $cache = (3600 * 24); // 1 day
      +                }
      +                header(
      +                    'Expires: ' . gmdate('D, d M Y H:i:s', time() + $cache)
      +                    . ' GMT'
      +                );
      +                // Set browser cache age as $cache.  But set timeout of
      +                // 'shared caches' much lower in the event that infrastructure
      +                // caching is present.
      +                $smaxage = intval($cache / 12);
      +                header(
      +                    'Cache-Control: s-maxage=' . $smaxage
      +                    . '; max-age=' . $cache . ';'
      +                );
      +
      +                $infile = fopen($path, 'rb');
      +                $outfile = fopen('php://output', 'wb');
      +                if ($infile && $outfile) {
      +                    pipe_streams($infile, $outfile);
      +                }
      +                fclose($infile);
      +                fclose($outfile);
      +                killme();
      +            }
      +
      +            if ($_GET['url']) {
      +                goaway($url);
      +            }
      +        }
      +        http_status_exit(404, 'Not found');
      +    }
      +}
      diff --git a/Code/Module/Cal.php b/Code/Module/Cal.php
      new file mode 100644
      index 000000000..e485a4b3e
      --- /dev/null
      +++ b/Code/Module/Cal.php
      @@ -0,0 +1,379 @@
      + 1) {
      +            $nick = argv(1);
      +
      +            Libprofile::load($nick);
      +
      +            $channelx = Channel::from_username($nick);
      +
      +            if (!$channelx) {
      +                return;
      +            }
      +
      +            App::$data['channel'] = $channelx;
      +
      +            $observer = App::get_observer();
      +            App::$data['observer'] = $observer;
      +
      +            $observer_xchan = (($observer) ? $observer['xchan_hash'] : '');
      +
      +            head_set_icon(App::$data['channel']['xchan_photo_s']);
      +
      +            App::$page['htmlhead'] .= "";
      +        }
      +
      +        return;
      +    }
      +
      +
      +    public function get()
      +    {
      +
      +        if (observer_prohibited()) {
      +            return;
      +        }
      +
      +        $channel = null;
      +
      +        if (argc() > 1) {
      +            $channel = Channel::from_username(argv(1));
      +        }
      +
      +
      +        if (!$channel) {
      +            notice(t('Channel not found.') . EOL);
      +            return;
      +        }
      +
      +        // since we don't currently have an event permission - use the stream permission
      +
      +        if (!perm_is_allowed($channel['channel_id'], get_observer_hash(), 'view_stream')) {
      +            notice(t('Permissions denied.') . EOL);
      +            return;
      +        }
      +
      +        Navbar::set_selected('Calendar');
      +
      +        $sql_extra = permissions_sql($channel['channel_id'], get_observer_hash(), 'event');
      +
      +        $first_day = intval(get_pconfig($channel['channel_id'], 'system', 'cal_first_day', 0));
      +
      +        $htpl = Theme::get_template('event_head.tpl');
      +        App::$page['htmlhead'] .= replace_macros($htpl, array(
      +            '$baseurl' => z_root(),
      +            '$module_url' => '/cal/' . $channel['channel_address'],
      +            '$modparams' => 2,
      +            '$lang' => App::$language,
      +            '$first_day' => $first_day
      +        ));
      +
      +        $o = '';
      +
      +        $mode = 'view';
      +        $y = 0;
      +        $m = 0;
      +        $ignored = ((x($_REQUEST, 'ignored')) ? " and dismissed = " . intval($_REQUEST['ignored']) . " " : '');
      +
      +        // logger('args: ' . print_r(\App::$argv,true));
      +
      +        if (argc() > 3 && intval(argv(2)) && intval(argv(3))) {
      +            $mode = 'view';
      +            $y = intval(argv(2));
      +            $m = intval(argv(3));
      +        }
      +        if (argc() <= 3) {
      +            $mode = 'view';
      +            $event_id = argv(2);
      +        }
      +
      +        if ($mode == 'view') {
      +            /* edit/create form */
      +            if ($event_id) {
      +                $r = q(
      +                    "SELECT * FROM event WHERE event_hash = '%s' AND uid = %d LIMIT 1",
      +                    dbesc($event_id),
      +                    intval($channel['channel_id'])
      +                );
      +                if (count($r)) {
      +                    $orig_event = $r[0];
      +                }
      +            }
      +
      +
      +            // Passed parameters overrides anything found in the DB
      +            if (!x($orig_event)) {
      +                $orig_event = [];
      +            }
      +
      +
      +            $tz = date_default_timezone_get();
      +            if (x($orig_event)) {
      +                $tz = (($orig_event['adjust']) ? date_default_timezone_get() : 'UTC');
      +            }
      +
      +            $syear = datetime_convert('UTC', $tz, $sdt, 'Y');
      +            $smonth = datetime_convert('UTC', $tz, $sdt, 'm');
      +            $sday = datetime_convert('UTC', $tz, $sdt, 'd');
      +            $shour = datetime_convert('UTC', $tz, $sdt, 'H');
      +            $sminute = datetime_convert('UTC', $tz, $sdt, 'i');
      +
      +            $stext = datetime_convert('UTC', $tz, $sdt);
      +            $stext = substr($stext, 0, 14) . "00:00";
      +
      +            $fyear = datetime_convert('UTC', $tz, $fdt, 'Y');
      +            $fmonth = datetime_convert('UTC', $tz, $fdt, 'm');
      +            $fday = datetime_convert('UTC', $tz, $fdt, 'd');
      +            $fhour = datetime_convert('UTC', $tz, $fdt, 'H');
      +            $fminute = datetime_convert('UTC', $tz, $fdt, 'i');
      +
      +            $ftext = datetime_convert('UTC', $tz, $fdt);
      +            $ftext = substr($ftext, 0, 14) . "00:00";
      +
      +            $type = ((x($orig_event)) ? $orig_event['etype'] : 'event');
      +
      +            $f = get_config('system', 'event_input_format');
      +            if (!$f) {
      +                $f = 'ymd';
      +            }
      +
      +            $catsenabled = Apps::system_app_installed(local_channel(), 'Categories');
      +
      +
      +            $show_bd = perm_is_allowed($channel['channel_id'], get_observer_hash(), 'view_contacts');
      +            if (!$show_bd) {
      +                $sql_extra .= " and event.etype != 'birthday' ";
      +            }
      +
      +
      +            $category = '';
      +
      +            $thisyear = datetime_convert('UTC', date_default_timezone_get(), 'now', 'Y');
      +            $thismonth = datetime_convert('UTC', date_default_timezone_get(), 'now', 'm');
      +            if (!$y) {
      +                $y = intval($thisyear);
      +            }
      +            if (!$m) {
      +                $m = intval($thismonth);
      +            }
      +
      +            // Put some limits on dates. The PHP date functions don't seem to do so well before 1900.
      +            // An upper limit was chosen to keep search engines from exploring links millions of years in the future.
      +
      +            if ($y < 1901) {
      +                $y = 1900;
      +            }
      +            if ($y > 2099) {
      +                $y = 2100;
      +            }
      +
      +            $nextyear = $y;
      +            $nextmonth = $m + 1;
      +            if ($nextmonth > 12) {
      +                $nextmonth = 1;
      +                $nextyear++;
      +            }
      +
      +            $prevyear = $y;
      +            if ($m > 1) {
      +                $prevmonth = $m - 1;
      +            } else {
      +                $prevmonth = 12;
      +                $prevyear--;
      +            }
      +
      +            $dim = get_dim($y, $m);
      +            $start = sprintf('%d-%d-%d %d:%d:%d', $y, $m, 1, 0, 0, 0);
      +            $finish = sprintf('%d-%d-%d %d:%d:%d', $y, $m, $dim, 23, 59, 59);
      +
      +
      +            if (argv(2) === 'json') {
      +                if (x($_GET, 'start')) {
      +                    $start = $_GET['start'];
      +                }
      +                if (x($_GET, 'end')) {
      +                    $finish = $_GET['end'];
      +                }
      +            }
      +
      +            $start = datetime_convert('UTC', 'UTC', $start);
      +            $finish = datetime_convert('UTC', 'UTC', $finish);
      +
      +            $adjust_start = datetime_convert('UTC', date_default_timezone_get(), $start);
      +            $adjust_finish = datetime_convert('UTC', date_default_timezone_get(), $finish);
      +
      +
      +            if (!perm_is_allowed(App::$profile['uid'], get_observer_hash(), 'view_contacts')) {
      +                $sql_extra .= " and etype != 'birthday' ";
      +            }
      +
      +            if (x($_GET, 'id')) {
      +                $r = q(
      +                    "SELECT event.*, item.plink, item.item_flags, item.author_xchan, item.owner_xchan
      +	                                from event left join item on resource_id = event_hash where resource_type = 'event' and event.uid = %d and event.id = %d $sql_extra limit 1",
      +                    intval($channel['channel_id']),
      +                    intval($_GET['id'])
      +                );
      +            } else {
      +                // fixed an issue with "nofinish" events not showing up in the calendar.
      +                // There's still an issue if the finish date crosses the end of month.
      +                // Noting this for now - it will need to be fixed here and in Friendica.
      +                // Ultimately the finish date shouldn't be involved in the query.
      +
      +                $r = q(
      +                    "SELECT event.*, item.plink, item.item_flags, item.author_xchan, item.owner_xchan
      +	                              from event left join item on event_hash = resource_id 
      +					where resource_type = 'event' and event.uid = %d and event.uid = item.uid $ignored 
      +					AND (( adjust = 0 AND ( dtend >= '%s' or nofinish = 1 ) AND dtstart <= '%s' ) 
      +					OR  (  adjust = 1 AND ( dtend >= '%s' or nofinish = 1 ) AND dtstart <= '%s' )) $sql_extra ",
      +                    intval($channel['channel_id']),
      +                    dbesc($start),
      +                    dbesc($finish),
      +                    dbesc($adjust_start),
      +                    dbesc($adjust_finish)
      +                );
      +            }
      +
      +            $links = [];
      +
      +            if ($r) {
      +                xchan_query($r);
      +                $r = fetch_post_tags($r, true);
      +
      +                $r = sort_by_date($r);
      +            }
      +
      +            if ($r) {
      +                foreach ($r as $rr) {
      +                    $j = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], 'j') : datetime_convert('UTC', 'UTC', $rr['dtstart'], 'j'));
      +                    if (!x($links, $j)) {
      +                        $links[$j] = z_root() . '/' . App::$cmd . '#link-' . $j;
      +                    }
      +                }
      +            }
      +
      +            $events = [];
      +
      +            $last_date = '';
      +            $fmt = t('l, F j');
      +
      +            if ($r) {
      +                foreach ($r as $rr) {
      +                    $j = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], 'j') : datetime_convert('UTC', 'UTC', $rr['dtstart'], 'j'));
      +                    $d = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], $fmt) : datetime_convert('UTC', 'UTC', $rr['dtstart'], $fmt));
      +                    $d = day_translate($d);
      +
      +                    $start = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], 'c') : datetime_convert('UTC', 'UTC', $rr['dtstart'], 'c'));
      +                    if ($rr['nofinish']) {
      +                        $end = null;
      +                    } else {
      +                        $end = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtend'], 'c') : datetime_convert('UTC', 'UTC', $rr['dtend'], 'c'));
      +                    }
      +
      +
      +                    $is_first = ($d !== $last_date);
      +
      +                    $last_date = $d;
      +
      +                    $edit = false;
      +
      +                    $drop = false;
      +
      +                    $title = strip_tags(html_entity_decode(bbcode($rr['summary']), ENT_QUOTES, 'UTF-8'));
      +                    if (!$title) {
      +                        list($title, $_trash) = explode(" $rr['id'],
      +                        'hash' => $rr['event_hash'],
      +                        'start' => $start,
      +                        'end' => $end,
      +                        'drop' => $drop,
      +                        'allDay' => false,
      +                        'title' => $title,
      +
      +                        'j' => $j,
      +                        'd' => $d,
      +                        'edit' => $edit,
      +                        'is_first' => $is_first,
      +                        'item' => $rr,
      +                        'html' => $html,
      +                        'plink' => array($rr['plink'], t('Link to Source'), '', ''),
      +                    );
      +                }
      +            }
      +
      +            if (argv(2) === 'json') {
      +                echo json_encode($events);
      +                killme();
      +            }
      +
      +            // links: array('href', 'text', 'extra css classes', 'title')
      +            if (x($_GET, 'id')) {
      +                $tpl = Theme::get_template("event_cal.tpl");
      +            } else {
      +                $tpl = Theme::get_template("events_cal-js.tpl");
      +            }
      +
      +            $nick = $channel['channel_address'];
      +
      +            $o = replace_macros($tpl, array(
      +                '$baseurl' => z_root(),
      +                '$new_event' => array(z_root() . '/cal', (($event_id) ? t('Edit Event') : t('Create Event')), '', ''),
      +                '$previus' => array(z_root() . "/cal/$nick/$prevyear/$prevmonth", t('Previous'), '', ''),
      +                '$next' => array(z_root() . "/cal/$nick/$nextyear/$nextmonth", t('Next'), '', ''),
      +                '$export' => array(z_root() . "/cal/$nick/$y/$m/export", t('Export'), '', ''),
      +                '$calendar' => cal($y, $m, $links, ' eventcal'),
      +                '$events' => $events,
      +                '$upload' => t('Import'),
      +                '$submit' => t('Submit'),
      +                '$prev' => t('Previous'),
      +                '$next' => t('Next'),
      +                '$today' => t('Today'),
      +                '$form' => $form,
      +                '$expandform' => ((x($_GET, 'expandform')) ? true : false)
      +            ));
      +
      +            if (x($_GET, 'id')) {
      +                echo $o;
      +                killme();
      +            }
      +
      +            return $o;
      +        }
      +    }
      +}
      diff --git a/Code/Module/Calendar.php b/Code/Module/Calendar.php
      new file mode 100644
      index 000000000..4d8efdaa7
      --- /dev/null
      +++ b/Code/Module/Calendar.php
      @@ -0,0 +1,494 @@
      +set($x[0]);
      +
      +            $created = $x[0]['created'];
      +            $edited = datetime_convert();
      +        } else {
      +            $created = $edited = datetime_convert();
      +            $acl->set_from_array($_POST);
      +        }
      +
      +        $post_tags = [];
      +        $ac = $acl->get();
      +
      +        $str_contact_allow = $ac['allow_cid'];
      +        $str_group_allow = $ac['allow_gid'];
      +        $str_contact_deny = $ac['deny_cid'];
      +        $str_group_deny = $ac['deny_gid'];
      +
      +        $private = $acl->is_private();
      +
      +        $results = linkify_tags($desc, local_channel());
      +
      +        if ($results) {
      +            // Set permissions based on tag replacements
      +
      +            set_linkified_perms($results, $str_contact_allow, $str_group_allow, local_channel(), false, $private);
      +
      +            foreach ($results as $result) {
      +                $success = $result['success'];
      +                if ($success['replaced']) {
      +                    $post_tags[] = [
      +                        'uid' => local_channel(),
      +                        'ttype' => $success['termtype'],
      +                        'otype' => TERM_OBJ_POST,
      +                        'term' => $success['term'],
      +                        'url' => $success['url']
      +                    ];
      +                }
      +            }
      +        }
      +
      +
      +        if (strlen($categories)) {
      +            $cats = explode(',', $categories);
      +            foreach ($cats as $cat) {
      +                $post_tags[] = array(
      +                    'uid' => local_channel(),
      +                    'ttype' => TERM_CATEGORY,
      +                    'otype' => TERM_OBJ_POST,
      +                    'term' => trim($cat),
      +                    'url' => $channel['xchan_url'] . '?f=&cat=' . urlencode(trim($cat))
      +                );
      +            }
      +        }
      +
      +        $datarray = [
      +            'dtstart' => $start,
      +            'dtend' => $finish,
      +            'summary' => $summary,
      +            'description' => $desc,
      +            'location' => $location,
      +            'etype' => $type,
      +            'adjust' => $adjust,
      +            'nofinish' => $nofinish,
      +            'uid' => local_channel(),
      +            'account' => get_account_id(),
      +            'event_xchan' => $channel['channel_hash'],
      +            'allow_cid' => $str_contact_allow,
      +            'allow_gid' => $str_group_allow,
      +            'deny_cid' => $str_contact_deny,
      +            'deny_gid' => $str_group_deny,
      +            'private' => intval($private),
      +            'id' => $event_id,
      +            'created' => $created,
      +            'edited' => $edited
      +        ];
      +
      +        if (intval($_REQUEST['preview'])) {
      +            $html = format_event_html($datarray);
      +            echo $html;
      +            killme();
      +        }
      +
      +        $event = event_store_event($datarray);
      +
      +        if ($post_tags) {
      +            $datarray['term'] = $post_tags;
      +        }
      +
      +        $item_id = event_store_item($datarray, $event);
      +
      +        if ($item_id) {
      +            $r = q(
      +                "select * from item where id = %d",
      +                intval($item_id)
      +            );
      +            if ($r) {
      +                xchan_query($r);
      +                $sync_item = fetch_post_tags($r);
      +                $z = q(
      +                    "select * from event where event_hash = '%s' and uid = %d limit 1",
      +                    dbesc($r[0]['resource_id']),
      +                    intval($channel['channel_id'])
      +                );
      +                if ($z) {
      +                    Libsync::build_sync_packet($channel['channel_id'], ['event_item' => [encode_item($sync_item[0], true)], 'event' => $z]);
      +                }
      +            }
      +        }
      +
      +        Run::Summon(['Notifier', 'event', $item_id]);
      +        killme();
      +    }
      +
      +
      +    public function get()
      +    {
      +
      +        if (argc() > 2 && argv(1) == 'ical') {
      +            $event_id = argv(2);
      +
      +            $sql_extra = permissions_sql(local_channel());
      +
      +            $r = q(
      +                "select * from event where event_hash = '%s' $sql_extra limit 1",
      +                dbesc($event_id)
      +            );
      +            if ($r) {
      +                header('Content-type: text/calendar');
      +                header('Content-Disposition: attachment; filename="' . t('event') . '-' . $event_id . '.ics"');
      +                echo ical_wrapper($r);
      +                killme();
      +            } else {
      +                notice(t('Event not found.') . EOL);
      +                return;
      +            }
      +        }
      +
      +        if (!local_channel()) {
      +            notice(t('Permission denied.') . EOL);
      +            return;
      +        }
      +
      +        if ((argc() > 2) && (argv(1) === 'ignore') && intval(argv(2))) {
      +            $r = q(
      +                "update event set dismissed = 1 where id = %d and uid = %d",
      +                intval(argv(2)),
      +                intval(local_channel())
      +            );
      +        }
      +
      +        if ((argc() > 2) && (argv(1) === 'unignore') && intval(argv(2))) {
      +            $r = q(
      +                "update event set dismissed = 0 where id = %d and uid = %d",
      +                intval(argv(2)),
      +                intval(local_channel())
      +            );
      +        }
      +
      +        $channel = App::get_channel();
      +
      +        $mode = 'view';
      +        $export = false;
      +
      +        $ignored = ((x($_REQUEST, 'ignored')) ? " and dismissed = " . intval($_REQUEST['ignored']) . " " : '');
      +
      +        if (argc() > 1) {
      +            if (argc() > 2 && argv(1) === 'add') {
      +                $mode = 'add';
      +                $item_id = intval(argv(2));
      +            }
      +            if (argc() > 2 && argv(1) === 'drop') {
      +                $mode = 'drop';
      +                $event_id = argv(2);
      +            }
      +            if (argc() <= 2 && argv(1) === 'export') {
      +                $export = true;
      +            }
      +            if (argc() > 2 && intval(argv(1)) && intval(argv(2))) {
      +                $mode = 'view';
      +            }
      +            if (argc() <= 2) {
      +                $mode = 'view';
      +                $event_id = argv(1);
      +            }
      +        }
      +
      +        if ($mode === 'add') {
      +            event_addtocal($item_id, local_channel());
      +            killme();
      +        }
      +
      +        if ($mode == 'view') {
      +            /* edit/create form */
      +            if ($event_id) {
      +                $r = q(
      +                    "SELECT * FROM event WHERE event_hash = '%s' AND uid = %d LIMIT 1",
      +                    dbesc($event_id),
      +                    intval(local_channel())
      +                );
      +                if ($r) {
      +                    $orig_event = $r[0];
      +                }
      +            }
      +
      +            $channel = App::get_channel();
      +
      +            if (argv(1) === 'json') {
      +                if (x($_GET, 'start')) {
      +                    $start = $_GET['start'];
      +                }
      +                if (x($_GET, 'end')) {
      +                    $finish = $_GET['end'];
      +                }
      +            }
      +
      +            $start = datetime_convert('UTC', 'UTC', $start);
      +            $finish = datetime_convert('UTC', 'UTC', $finish);
      +
      +            $adjust_start = datetime_convert('UTC', date_default_timezone_get(), $start);
      +            $adjust_finish = datetime_convert('UTC', date_default_timezone_get(), $finish);
      +
      +            if (x($_GET, 'id')) {
      +                $r = q(
      +                    "SELECT event.*, item.plink, item.item_flags, item.author_xchan, item.owner_xchan, item.id as item_id
      +	                                from event left join item on item.resource_id = event.event_hash
      +					where item.resource_type = 'event' and event.uid = %d and event.id = %d limit 1",
      +                    intval(local_channel()),
      +                    intval($_GET['id'])
      +                );
      +            } elseif ($export) {
      +                $r = q(
      +                    "SELECT * from event where uid = %d",
      +                    intval(local_channel())
      +                );
      +            } else {
      +                // fixed an issue with "nofinish" events not showing up in the calendar.
      +                // There's still an issue if the finish date crosses the end of month.
      +                // Noting this for now - it will need to be fixed here and in Friendica.
      +                // Ultimately the finish date shouldn't be involved in the query.
      +
      +                $r = q(
      +                    "SELECT event.*, item.plink, item.item_flags, item.author_xchan, item.owner_xchan, item.id as item_id
      +					from event left join item on event.event_hash = item.resource_id 
      +					where item.resource_type = 'event' and event.uid = %d and event.uid = item.uid $ignored 
      +					AND (( event.adjust = 0 AND ( event.dtend >= '%s' or event.nofinish = 1 ) AND event.dtstart <= '%s' ) 
      +					OR  (  event.adjust = 1 AND ( event.dtend >= '%s' or event.nofinish = 1 ) AND event.dtstart <= '%s' )) ",
      +                    intval(local_channel()),
      +                    dbesc($start),
      +                    dbesc($finish),
      +                    dbesc($adjust_start),
      +                    dbesc($adjust_finish)
      +                );
      +            }
      +
      +            if ($r && !$export) {
      +                xchan_query($r);
      +                $r = fetch_post_tags($r, true);
      +
      +                $r = sort_by_date($r);
      +            }
      +
      +            $events = [];
      +
      +            if ($r) {
      +                foreach ($r as $rr) {
      +                    $start = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], 'c') : datetime_convert('UTC', 'UTC', $rr['dtstart'], 'c'));
      +                    if ($rr['nofinish']) {
      +                        $end = null;
      +                    } else {
      +                        $end = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtend'], 'c') : datetime_convert('UTC', 'UTC', $rr['dtend'], 'c'));
      +
      +                        // give a fake end to birthdays so they get crammed into a
      +                        // single day on the calendar
      +
      +                        if ($rr['etype'] === 'birthday') {
      +                            $end = null;
      +                        }
      +                    }
      +
      +                    $catsenabled = Apps::system_app_installed($x['profile_uid'], 'Categories');
      +                    $categories = '';
      +                    if ($catsenabled) {
      +                        if ($rr['term']) {
      +                            $categories = array_elm_to_str(get_terms_oftype($rr['term'], TERM_CATEGORY), 'term');
      +                        }
      +                    }
      +
      +                    $allDay = false;
      +
      +                    // allDay event rules
      +                    if (!strpos($start, 'T') && !strpos($end, 'T')) {
      +                        $allDay = true;
      +                    }
      +                    if (strpos($start, 'T00:00:00') && strpos($end, 'T00:00:00')) {
      +                        $allDay = true;
      +                    }
      +
      +                    $edit = ((local_channel() && $rr['author_xchan'] == get_observer_hash()) ? array(z_root() . '/events/' . $rr['event_hash'] . '?expandform=1', t('Edit event'), '', '') : false);
      +
      +                    $drop = [z_root() . '/events/drop/' . $rr['event_hash'], t('Delete event'), '', ''];
      +
      +                    $events[] = [
      +                        'calendar_id' => 'calendar',
      +                        'rw' => true,
      +                        'id' => $rr['id'],
      +                        'uri' => $rr['event_hash'],
      +                        'start' => $start,
      +                        'end' => $end,
      +                        'drop' => $drop,
      +                        'allDay' => $allDay,
      +                        'title' => html_entity_decode($rr['summary'], ENT_COMPAT, 'UTF-8'),
      +                        'editable' => $edit ? true : false,
      +                        'item' => $rr,
      +                        'plink' => [$rr['plink'], t('Link to source')],
      +                        'description' => htmlentities($rr['description'], ENT_COMPAT, 'UTF-8', false),
      +                        'location' => htmlentities($rr['location'], ENT_COMPAT, 'UTF-8', false),
      +                        'allow_cid' => expand_acl($rr['allow_cid']),
      +                        'allow_gid' => expand_acl($rr['allow_gid']),
      +                        'deny_cid' => expand_acl($rr['deny_cid']),
      +                        'deny_gid' => expand_acl($rr['deny_gid']),
      +                        'categories' => $categories
      +                    ];
      +                }
      +            }
      +
      +            if ($export) {
      +                header('Content-type: text/calendar');
      +                header('Content-Disposition: attachment; filename="' . t('calendar') . '-' . $channel['channel_address'] . '.ics"');
      +                echo ical_wrapper($r);
      +                killme();
      +            }
      +
      +            if (App::$argv[1] === 'json') {
      +                json_return_and_die($events);
      +            }
      +        }
      +
      +
      +        if ($mode === 'drop' && $event_id) {
      +            $r = q(
      +                "SELECT * FROM event WHERE event_hash = '%s' AND uid = %d LIMIT 1",
      +                dbesc($event_id),
      +                intval(local_channel())
      +            );
      +
      +            $sync_event = $r[0];
      +
      +            if ($r) {
      +                $r = q(
      +                    "delete from event where event_hash = '%s' and uid = %d",
      +                    dbesc($event_id),
      +                    intval(local_channel())
      +                );
      +                if ($r) {
      +                    $r = q(
      +                        "update item set resource_type = '', resource_id = '' where resource_type = 'event' and resource_id = '%s' and uid = %d",
      +                        dbesc($event_id),
      +                        intval(local_channel())
      +                    );
      +                    $sync_event['event_deleted'] = 1;
      +                    Libsync::build_sync_packet(0, ['event' => [$sync_event]]);
      +                    killme();
      +                }
      +                notice(t('Failed to remove event') . EOL);
      +                killme();
      +            }
      +        }
      +    }
      +}
      diff --git a/Code/Module/Card_edit.php b/Code/Module/Card_edit.php
      new file mode 100644
      index 000000000..68c6ec8c2
      --- /dev/null
      +++ b/Code/Module/Card_edit.php
      @@ -0,0 +1,148 @@
      + 1) ? intval(argv(1)) : 0);
      +
      +        if (!$post_id) {
      +            notice(t('Item not found') . EOL);
      +            return;
      +        }
      +
      +        $itm = q(
      +            "SELECT * FROM item WHERE id = %d and item_type = %d LIMIT 1",
      +            intval($post_id),
      +            intval(ITEM_TYPE_CARD)
      +        );
      +        if ($itm) {
      +            $item_id = q(
      +                "select * from iconfig where cat = 'system' and k = 'CARD' and iid = %d limit 1",
      +                intval($itm[0]['id'])
      +            );
      +            if ($item_id) {
      +                $card_title = $item_id[0]['v'];
      +            }
      +        } else {
      +            notice(t('Item not found') . EOL);
      +            return;
      +        }
      +
      +        $owner = $itm[0]['uid'];
      +        $uid = local_channel();
      +
      +        $observer = App::get_observer();
      +
      +        $channel = Channel::from_id($owner);
      +        if (!$channel) {
      +            notice(t('Channel not found.') . EOL);
      +            return;
      +        }
      +
      +        $ob_hash = (($observer) ? $observer['xchan_hash'] : '');
      +
      +        if (!perm_is_allowed($owner, $ob_hash, 'write_pages')) {
      +            notice(t('Permission denied.') . EOL);
      +            return;
      +        }
      +
      +        $is_owner = (($uid && $uid == $owner) ? true : false);
      +
      +        $o = '';
      +
      +
      +        $category = '';
      +        $catsenabled = ((Apps::system_app_installed($owner, 'Categories')) ? 'categories' : '');
      +
      +        if ($catsenabled) {
      +            $itm = fetch_post_tags($itm);
      +
      +            $cats = get_terms_oftype($itm[0]['term'], TERM_CATEGORY);
      +
      +            foreach ($cats as $cat) {
      +                if (strlen($category)) {
      +                    $category .= ', ';
      +                }
      +                $category .= $cat['term'];
      +            }
      +        }
      +
      +        if ($itm[0]['attach']) {
      +            $j = json_decode($itm[0]['attach'], true);
      +            if ($j) {
      +                foreach ($j as $jj) {
      +                    $itm[0]['body'] .= "\n" . '[attachment]' . basename($jj['href']) . ',' . $jj['revision'] . '[/attachment]' . "\n";
      +                }
      +            }
      +        }
      +
      +
      +        $mimetype = $itm[0]['mimetype'];
      +
      +        $content = $itm[0]['body'];
      +
      +
      +        $rp = 'cards/' . $channel['channel_address'];
      +
      +        $x = array(
      +            'nickname' => $channel['channel_address'],
      +            'bbco_autocomplete' => 'bbcode',
      +            'return_path' => $rp,
      +            'webpage' => ITEM_TYPE_CARD,
      +            'button' => t('Edit'),
      +            'writefiles' => perm_is_allowed($owner, get_observer_hash(), 'write_pages'),
      +            'weblink' => t('Insert web link'),
      +            'hide_voting' => false,
      +            'hide_future' => false,
      +            'hide_location' => false,
      +            'hide_expire' => false,
      +            'showacl' => true,
      +            'acl' => Libacl::populate($itm[0], false, PermissionDescription::fromGlobalPermission('view_pages')),
      +            'permissions' => $itm[0],
      +            'lockstate' => (($itm[0]['allow_cid'] || $itm[0]['allow_gid'] || $itm[0]['deny_cid'] || $itm[0]['deny_gid']) ? 'lock' : 'unlock'),
      +            'ptyp' => $itm[0]['type'],
      +            'mimeselect' => false,
      +            'mimetype' => $itm[0]['mimetype'],
      +            'body' => undo_post_tagging($content),
      +            'post_id' => $post_id,
      +            'visitor' => true,
      +            'title' => htmlspecialchars($itm[0]['title'], ENT_COMPAT, 'UTF-8'),
      +            'placeholdertitle' => t('Title (optional)'),
      +            'pagetitle' => $card_title,
      +            'profile_uid' => (intval($channel['channel_id'])),
      +            'catsenabled' => $catsenabled,
      +            'category' => $category,
      +			'bbcode' => ((in_array($mimetype, [ 'text/bbcode', 'text/x-multicode' ])) ? true : false)
      +        );
      +
      +        $editor = status_editor($x);
      +
      +        $o .= replace_macros(Theme::get_template('edpost_head.tpl'), array(
      +            '$title' => t('Edit Card'),
      +            '$delete' => ((($itm[0]['author_xchan'] === $ob_hash) || ($itm[0]['owner_xchan'] === $ob_hash)) ? t('Delete') : false),
      +            '$id' => $itm[0]['id'],
      +            '$cancel' => t('Cancel'),
      +            '$editor' => $editor
      +        ));
      +
      +        return $o;
      +    }
      +}
      diff --git a/Code/Module/Cards.php b/Code/Module/Cards.php
      new file mode 100644
      index 000000000..58920c92f
      --- /dev/null
      +++ b/Code/Module/Cards.php
      @@ -0,0 +1,223 @@
      + 1) {
      +            $which = argv(1);
      +        } else {
      +            return;
      +        }
      +
      +        Libprofile::load($which);
      +    }
      +
      +    /**
      +     * {@inheritDoc}
      +     * @see \Code\Web\Controller::get()
      +     */
      +    public function get()
      +    {
      +
      +        if (observer_prohibited(true)) {
      +            return login();
      +        }
      +
      +        if (!App::$profile) {
      +            notice(t('Requested profile is not available.') . EOL);
      +            App::$error = 404;
      +            return;
      +        }
      +
      +        if (!Apps::system_app_installed(App::$profile_uid, 'Cards')) {
      +            //Do not display any associated widgets at this point
      +            App::$pdl = '';
      +
      +            $o = 'Cards App (Not Installed):
      '; + $o .= t('Create personal planning cards'); + return $o; + } + + Navbar::set_selected('Cards'); + + head_add_link([ + 'rel' => 'alternate', + 'type' => 'application/json+oembed', + 'href' => z_root() . '/oep?f=&url=' . urlencode(z_root() . '/' . App::$query_string), + 'title' => 'oembed' + ]); + + + $category = (($_REQUEST['cat']) ? escape_tags(trim($_REQUEST['cat'])) : ''); + + if ($category) { + $sql_extra2 .= protect_sprintf(term_item_parent_query(App::$profile['profile_uid'], 'item', $category, TERM_CATEGORY)); + } + + + $which = argv(1); + + $selected_card = ((argc() > 2) ? argv(2) : ''); + + $_SESSION['return_url'] = App::$query_string; + + $uid = local_channel(); + $owner = App::$profile_uid; + $observer = App::get_observer(); + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + if (!perm_is_allowed($owner, $ob_hash, 'view_pages')) { + notice(t('Permission denied.') . EOL); + return; + } + + $is_owner = ($uid && $uid == $owner); + + $channel = Channel::from_id($owner); + + if ($channel) { + $channel_acl = [ + 'allow_cid' => $channel['channel_allow_cid'], + 'allow_gid' => $channel['channel_allow_gid'], + 'deny_cid' => $channel['channel_deny_cid'], + 'deny_gid' => $channel['channel_deny_gid'] + ]; + } else { + $channel_acl = ['allow_cid' => '', 'allow_gid' => '', 'deny_cid' => '', 'deny_gid' => '']; + } + + + if (perm_is_allowed($owner, $ob_hash, 'write_pages')) { + $x = [ + 'webpage' => ITEM_TYPE_CARD, + 'is_owner' => true, + 'content_label' => t('Add Card'), + 'button' => t('Create'), + 'nickname' => $channel['channel_address'], + 'lockstate' => (($channel['channel_allow_cid'] || $channel['channel_allow_gid'] + || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 'lock' : 'unlock'), + 'acl' => (($is_owner) ? Libacl::populate( + $channel_acl, + false, + PermissionDescription::fromGlobalPermission('view_pages') + ) : ''), + 'permissions' => $channel_acl, + 'showacl' => (($is_owner) ? true : false), + 'visitor' => true, + 'hide_location' => false, + 'hide_voting' => false, + 'profile_uid' => intval($owner), + 'mimetype' => 'text/x-multicode', + 'mimeselect' => false, + 'layoutselect' => false, + 'expanded' => false, + 'novoting' => false, + 'catsenabled' => Apps::system_app_installed($owner, 'Categories'), + 'bbco_autocomplete' => 'bbcode', + 'bbcode' => true + ]; + + if ($_REQUEST['title']) { + $x['title'] = $_REQUEST['title']; + } + if ($_REQUEST['body']) { + $x['body'] = $_REQUEST['body']; + } + + $editor = status_editor($x); + } else { + $editor = ''; + } + + + $itemspage = get_pconfig(local_channel(), 'system', 'itemspage'); + App::set_pager_itemspage(((intval($itemspage)) ? $itemspage : 20)); + $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval(App::$pager['itemspage']), intval(App::$pager['start'])); + + + $sql_extra = item_permissions_sql($owner); + $sql_item = ''; + + if ($selected_card) { + $r = q( + "select * from iconfig where iconfig.cat = 'system' and iconfig.k = 'CARD' and iconfig.v = '%s' limit 1", + dbesc($selected_card) + ); + if ($r) { + $sql_item = "and item.id = " . intval($r[0]['iid']) . " "; + } + } + + $r = q( + "select * from item + where uid = %d and item_type = %d + $sql_extra $sql_item order by item.created desc $pager_sql", + intval($owner), + intval(ITEM_TYPE_CARD) + ); + + $item_normal = " and item.item_hidden = 0 and item.item_type in (0,6) 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 "; + + $items_result = []; + if ($r) { + $pager_total = count($r); + + $parents_str = ids_to_querystr($r, 'id'); + + $items = q( + "SELECT item.*, item.id AS item_id + FROM item + WHERE item.uid = %d $item_normal + AND item.parent IN ( %s ) + $sql_extra $sql_extra2 ", + intval(App::$profile['profile_uid']), + dbesc($parents_str) + ); + if ($items) { + xchan_query($items); + $items = fetch_post_tags($items, true); + $items_result = conv_sort($items, 'updated'); + } + } + + $mode = 'cards'; + + if (get_pconfig(local_channel(), 'system', 'articles_list_mode') && (!$selected_card)) { + $page_mode = 'pager_list'; + } else { + $page_mode = 'traditional'; + } + + $content = conversation($items_result, $mode, false, $page_mode); + + $o = replace_macros(Theme::get_template('cards.tpl'), [ + '$title' => t('Cards'), + '$editor' => $editor, + '$content' => $content, + '$pager' => alt_pager($pager_total) + ]); + + return $o; + } +} diff --git a/Code/Module/Categories.php b/Code/Module/Categories.php new file mode 100644 index 000000000..a3297cc49 --- /dev/null +++ b/Code/Module/Categories.php @@ -0,0 +1,42 @@ +' . $desc . ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Categories'))) { + return $text; + } + + $c = new Comanche(); + return $c->widget('catcloud', EMPTY_STR); + } +} diff --git a/Code/Module/Cdav.php b/Code/Module/Cdav.php new file mode 100644 index 000000000..fee892b06 --- /dev/null +++ b/Code/Module/Cdav.php @@ -0,0 +1,1447 @@ + $c, 'account' => $a[0]]; + $channel_login = $c['channel_id']; + } + } + } + if (!$record) { + continue; + } + + if ($record) { + $verified = HTTPSig::verify('', $record['channel']['channel_pubkey']); + if (!($verified && $verified['header_signed'] && $verified['header_valid'])) { + $record = null; + } + if ($record['account']) { + authenticate_success($record['account']); + if ($channel_login) { + change_channel($channel_login); + } + } + break; + } + } + } + } + } + + + /** + * This server combines both CardDAV and CalDAV functionality into a single + * server. It is assumed that the server runs at the root of a HTTP domain (be + * that a domainname-based vhost or a specific TCP port. + * + * This example also assumes that you're using SQLite and the database has + * already been setup (along with the database tables). + * + * You may choose to use MySQL instead, just change the PDO connection + * statement. + */ + + /** + * UTC or GMT is easy to work with, and usually recommended for any + * application. + */ + date_default_timezone_set('UTC'); + + /** + * Make sure this setting is turned on and reflect the root url for your WebDAV + * server. + * + * This can be for example the root / or a complete path to your server script. + */ + + $baseUri = '/cdav/'; + + /** + * Database + * + */ + + $pdo = DBA::$dba->db; + + // Autoloader + require_once 'vendor/autoload.php'; + + /** + * The backends. Yes we do really need all of them. + * + * This allows any developer to subclass just any of them and hook into their + * own backend systems. + */ + + $auth = new BasicAuth(); + $auth->setRealm(ucfirst(System::get_platform_name()) . ' ' . 'CalDAV/CardDAV'); + + if (local_channel()) { + logger('loggedin'); + + if ((argv(1) == 'addressbooks') && (!Apps::system_app_installed(local_channel(), 'CardDAV'))) { + killme(); + } + + $channel = App::get_channel(); + $auth->setCurrentUser($channel['channel_address']); + $auth->channel_id = $channel['channel_id']; + $auth->channel_hash = $channel['channel_hash']; + $auth->channel_account_id = $channel['channel_account_id']; + if ($channel['channel_timezone']) { + $auth->setTimezone($channel['channel_timezone']); + } + $auth->observer = $channel['channel_hash']; + + $principalUri = 'principals/' . $channel['channel_address']; + if (!cdav_principal($principalUri)) { + $this->activate($pdo, $channel); + if (!cdav_principal($principalUri)) { + return; + } + } + } + + + $principalBackend = new \Sabre\DAVACL\PrincipalBackend\PDO($pdo); + $carddavBackend = new PDO($pdo); + $caldavBackend = new \Sabre\CalDAV\Backend\PDO($pdo); + + /** + * The directory tree + * + * Basically this is an array which contains the 'top-level' directories in the + * WebDAV server. + */ + + $nodes = [ + // /principals + new Collection($principalBackend), + + // /calendars + new CalendarRoot($principalBackend, $caldavBackend), + + // /addressbook + new AddressBookRoot($principalBackend, $carddavBackend) + ]; + + + // The object tree needs in turn to be passed to the server class + + $server = new Server($nodes); + + if (isset($baseUri)) { + $server->setBaseUri($baseUri); + } + + // Plugins + $server->addPlugin(new \Sabre\DAV\Auth\Plugin($auth)); + //$server->addPlugin(new \Sabre\DAV\Browser\Plugin()); + $server->addPlugin(new \Sabre\DAV\Sync\Plugin()); + $server->addPlugin(new \Sabre\DAV\Sharing\Plugin()); + $server->addPlugin(new \Sabre\DAVACL\Plugin()); + + // CalDAV plugins + $server->addPlugin(new \Sabre\CalDAV\Plugin()); + $server->addPlugin(new SharingPlugin()); + //$server->addPlugin(new \Sabre\CalDAV\Schedule\Plugin()); + $server->addPlugin(new ICSExportPlugin()); + + // CardDAV plugins + $server->addPlugin(new Plugin()); + $server->addPlugin(new VCFExportPlugin()); + + // And off we go! + $server->exec(); + + killme(); + } + } + + public function post() + { + + if (!local_channel()) { + return; + } + + if ((argv(1) === 'addressbook') && (!Apps::system_app_installed(local_channel(), 'CardDAV'))) { + return; + } + + $channel = App::get_channel(); + $principalUri = 'principals/' . $channel['channel_address']; + + if (!cdav_principal($principalUri)) { + return; + } + + $pdo = DBA::$dba->db; + + require_once 'vendor/autoload.php'; + + if (argc() == 2 && argv(1) === 'calendar') { + $caldavBackend = new \Sabre\CalDAV\Backend\PDO($pdo); + $calendars = $caldavBackend->getCalendarsForUser($principalUri); + + // create new calendar + if ($_REQUEST['{DAV:}displayname'] && $_REQUEST['create']) { + do { + $duplicate = false; + $calendarUri = random_string(40); + + $r = q( + "SELECT uri FROM calendarinstances WHERE principaluri = '%s' AND uri = '%s' LIMIT 1", + dbesc($principalUri), + dbesc($calendarUri) + ); + + if ($r) { + $duplicate = true; + } + } while ($duplicate == true); + + $properties = [ + '{DAV:}displayname' => $_REQUEST['{DAV:}displayname'], + '{http://apple.com/ns/ical/}calendar-color' => $_REQUEST['color'], + '{urn:ietf:params:xml:ns:caldav}calendar-description' => $channel['channel_name'] + ]; + + $id = $caldavBackend->createCalendar($principalUri, $calendarUri, $properties); + + // set new calendar to be visible + set_pconfig(local_channel(), 'cdav_calendar', $id[0], 1); + } + + //create new calendar object via ajax request + if ($_REQUEST['submit'] === 'create_event' && $_REQUEST['title'] && $_REQUEST['target'] && $_REQUEST['dtstart']) { + $id = explode(':', $_REQUEST['target']); + + if (!cdav_perms($id[0], $calendars, true)) { + return; + } + + $title = $_REQUEST['title']; + $start = datetime_convert(App::$timezone, 'UTC', $_REQUEST['dtstart']); + $dtstart = new DateTime($start); + if ($_REQUEST['dtend']) { + $end = datetime_convert(App::$timezone, 'UTC', $_REQUEST['dtend']); + $dtend = new DateTime($end); + } + $description = $_REQUEST['description']; + $location = $_REQUEST['location']; + + do { + $duplicate = false; + $objectUri = random_string(40) . '.ics'; + + $r = q( + "SELECT uri FROM calendarobjects WHERE calendarid = %s AND uri = '%s' LIMIT 1", + intval($id[0]), + dbesc($objectUri) + ); + + if (count($r)) { + $duplicate = true; + } + } while ($duplicate == true); + + + $vcalendar = new VCalendar([ + 'VEVENT' => [ + 'SUMMARY' => $title, + 'DTSTART' => $dtstart + ] + ]); + if ($dtend) { + $vcalendar->VEVENT->add('DTEND', $dtend); + $vcalendar->VEVENT->DTEND['TZID'] = App::$timezone; + } + if ($description) { + $vcalendar->VEVENT->add('DESCRIPTION', $description); + } + if ($location) { + $vcalendar->VEVENT->add('LOCATION', $location); + } + + $vcalendar->VEVENT->DTSTART['TZID'] = App::$timezone; + + $calendarData = $vcalendar->serialize(); + + $caldavBackend->createCalendarObject($id, $objectUri, $calendarData); + + killme(); + } + + // edit calendar name and color + if ($_REQUEST['{DAV:}displayname'] && $_REQUEST['edit'] && $_REQUEST['id']) { + $id = explode(':', $_REQUEST['id']); + + if (!cdav_perms($id[0], $calendars)) { + return; + } + + $mutations = [ + '{DAV:}displayname' => $_REQUEST['{DAV:}displayname'], + '{http://apple.com/ns/ical/}calendar-color' => $_REQUEST['color'] + ]; + + $patch = new PropPatch($mutations); + + $caldavBackend->updateCalendar($id, $patch); + + $patch->commit(); + } + + // edit calendar object via ajax request + if ($_REQUEST['submit'] === 'update_event' && $_REQUEST['uri'] && $_REQUEST['title'] && $_REQUEST['target'] && $_REQUEST['dtstart']) { + $id = explode(':', $_REQUEST['target']); + + if (!cdav_perms($id[0], $calendars, true)) { + return; + } + + $uri = $_REQUEST['uri']; + $title = $_REQUEST['title']; + $start = datetime_convert(App::$timezone, 'UTC', $_REQUEST['dtstart']); + $dtstart = new DateTime($start); + if ($_REQUEST['dtend']) { + $end = datetime_convert(App::$timezone, 'UTC', $_REQUEST['dtend']); + $dtend = new DateTime($end); + } + $description = $_REQUEST['description']; + $location = $_REQUEST['location']; + + $object = $caldavBackend->getCalendarObject($id, $uri); + + $vcalendar = Reader::read($object['calendardata']); + + if ($title) { + $vcalendar->VEVENT->SUMMARY = $title; + } + if ($dtstart) { + $vcalendar->VEVENT->DTSTART = $dtstart; + } + if ($dtend) { + $vcalendar->VEVENT->DTEND = $dtend; + } else { + unset($vcalendar->VEVENT->DTEND); + } + if ($description) { + $vcalendar->VEVENT->DESCRIPTION = $description; + } + if ($location) { + $vcalendar->VEVENT->LOCATION = $location; + } + + $calendarData = $vcalendar->serialize(); + + $caldavBackend->updateCalendarObject($id, $uri, $calendarData); + killme(); + } + + // delete calendar object via ajax request + if ($_REQUEST['delete'] && $_REQUEST['uri'] && $_REQUEST['target']) { + $id = explode(':', $_REQUEST['target']); + + if (!cdav_perms($id[0], $calendars, true)) { + return; + } + + $uri = $_REQUEST['uri']; + + $caldavBackend->deleteCalendarObject($id, $uri); + killme(); + } + + // edit calendar object date/timeme via ajax request (drag and drop) + if ($_REQUEST['update'] && $_REQUEST['id'] && $_REQUEST['uri']) { + $id = [$_REQUEST['id'][0], $_REQUEST['id'][1]]; + + if (!cdav_perms($id[0], $calendars, true)) { + return; + } + + $uri = $_REQUEST['uri']; + $start = datetime_convert(App::$timezone, 'UTC', $_REQUEST['dtstart']); + $dtstart = new DateTime($start); + if ($_REQUEST['dtend']) { + $end = datetime_convert(App::$timezone, 'UTC', $_REQUEST['dtend']); + $dtend = new DateTime($end); + } + + $object = $caldavBackend->getCalendarObject($id, $uri); + + $vcalendar = Reader::read($object['calendardata']); + + if ($dtstart) { + $vcalendar->VEVENT->DTSTART = $dtstart; + } + if ($dtend) { + $vcalendar->VEVENT->DTEND = $dtend; + } else { + unset($vcalendar->VEVENT->DTEND); + } + + $calendarData = $vcalendar->serialize(); + + $caldavBackend->updateCalendarObject($id, $uri, $calendarData); + + killme(); + } + + // share a calendar - this only works on local system (with channels on the same server) + if ($_REQUEST['sharee'] && $_REQUEST['share']) { + $id = [intval($_REQUEST['calendarid']), intval($_REQUEST['instanceid'])]; + + if (!cdav_perms($id[0], $calendars)) { + return; + } + + $hash = $_REQUEST['sharee']; + + $sharee_arr = Channel::from_hash($hash); + + $sharee = new Sharee(); + + $sharee->href = 'mailto:' . $sharee_arr['xchan_addr']; + $sharee->principal = 'principals/' . $sharee_arr['channel_address']; + $sharee->access = intval($_REQUEST['access']); + $sharee->properties = ['{DAV:}displayname' => $channel['channel_name']]; + + $caldavBackend->updateInvites($id, [$sharee]); + } + } + + if (argc() >= 2 && argv(1) === 'addressbook') { + $carddavBackend = new PDO($pdo); + $addressbooks = $carddavBackend->getAddressBooksForUser($principalUri); + + // create new addressbook + if ($_REQUEST['{DAV:}displayname'] && $_REQUEST['create']) { + do { + $duplicate = false; + $addressbookUri = random_string(20); + + $r = q( + "SELECT uri FROM addressbooks WHERE principaluri = '%s' AND uri = '%s' LIMIT 1", + dbesc($principalUri), + dbesc($addressbookUri) + ); + + if ($r) { + $duplicate = true; + } + } while ($duplicate == true); + + $properties = ['{DAV:}displayname' => $_REQUEST['{DAV:}displayname']]; + + $carddavBackend->createAddressBook($principalUri, $addressbookUri, $properties); + } + + // edit addressbook + if ($_REQUEST['{DAV:}displayname'] && $_REQUEST['edit'] && intval($_REQUEST['id'])) { + $id = $_REQUEST['id']; + + if (!cdav_perms($id, $addressbooks)) { + return; + } + + $mutations = [ + '{DAV:}displayname' => $_REQUEST['{DAV:}displayname'] + ]; + + $patch = new PropPatch($mutations); + + $carddavBackend->updateAddressBook($id, $patch); + + $patch->commit(); + } + + // create addressbook card + if ($_REQUEST['create'] && $_REQUEST['target'] && $_REQUEST['fn']) { + $id = $_REQUEST['target']; + + do { + $duplicate = false; + $uri = random_string(40) . '.vcf'; + + $r = q( + "SELECT uri FROM cards WHERE addressbookid = %s AND uri = '%s' LIMIT 1", + intval($id), + dbesc($uri) + ); + + if ($r) { + $duplicate = true; + } + } while ($duplicate == true); + + // TODO: this mostly duplictes the procedure in update addressbook card. + // Should move this part to a function to avoid duplication + $fn = $_REQUEST['fn']; + + $vcard = new \Sabre\VObject\Component\VCard([ + 'FN' => $fn, + 'N' => array_reverse(explode(' ', $fn)) + ]); + + $org = $_REQUEST['org']; + if ($org) { + $vcard->ORG = $org; + } + + $title = $_REQUEST['title']; + if ($title) { + $vcard->TITLE = $title; + } + + $tel = $_REQUEST['tel']; + $tel_type = $_REQUEST['tel_type']; + if ($tel) { + $i = 0; + foreach ($tel as $item) { + if ($item) { + $vcard->add('TEL', $item, ['type' => $tel_type[$i]]); + } + $i++; + } + } + + $email = $_REQUEST['email']; + $email_type = $_REQUEST['email_type']; + if ($email) { + $i = 0; + foreach ($email as $item) { + if ($item) { + $vcard->add('EMAIL', $item, ['type' => $email_type[$i]]); + } + $i++; + } + } + + $impp = $_REQUEST['impp']; + $impp_type = $_REQUEST['impp_type']; + if ($impp) { + $i = 0; + foreach ($impp as $item) { + if ($item) { + $vcard->add('IMPP', $item, ['type' => $impp_type[$i]]); + } + $i++; + } + } + + $url = $_REQUEST['url']; + $url_type = $_REQUEST['url_type']; + if ($url) { + $i = 0; + foreach ($url as $item) { + if ($item) { + $vcard->add('URL', $item, ['type' => $url_type[$i]]); + } + $i++; + } + } + + $adr = $_REQUEST['adr']; + $adr_type = $_REQUEST['adr_type']; + + if ($adr) { + $i = 0; + foreach ($adr as $item) { + if ($item) { + $vcard->add('ADR', $item, ['type' => $adr_type[$i]]); + } + $i++; + } + } + + $note = $_REQUEST['note']; + if ($note) { + $vcard->NOTE = $note; + } + + $cardData = $vcard->serialize(); + + $carddavBackend->createCard($id, $uri, $cardData); + } + + // edit addressbook card + if ($_REQUEST['update'] && $_REQUEST['uri'] && $_REQUEST['target']) { + $id = $_REQUEST['target']; + + if (!cdav_perms($id, $addressbooks)) { + return; + } + + $uri = $_REQUEST['uri']; + + $object = $carddavBackend->getCard($id, $uri); + $vcard = Reader::read($object['carddata']); + + $fn = $_REQUEST['fn']; + if ($fn) { + $vcard->FN = $fn; + $vcard->N = array_reverse(explode(' ', $fn)); + } + + $org = $_REQUEST['org']; + if ($org) { + $vcard->ORG = $org; + } else { + unset($vcard->ORG); + } + + $title = $_REQUEST['title']; + if ($title) { + $vcard->TITLE = $title; + } else { + unset($vcard->TITLE); + } + + $tel = $_REQUEST['tel']; + $tel_type = $_REQUEST['tel_type']; + if ($tel) { + $i = 0; + unset($vcard->TEL); + foreach ($tel as $item) { + if ($item) { + $vcard->add('TEL', $item, ['type' => $tel_type[$i]]); + } + $i++; + } + } else { + unset($vcard->TEL); + } + + $email = $_REQUEST['email']; + $email_type = $_REQUEST['email_type']; + if ($email) { + $i = 0; + unset($vcard->EMAIL); + foreach ($email as $item) { + if ($item) { + $vcard->add('EMAIL', $item, ['type' => $email_type[$i]]); + } + $i++; + } + } else { + unset($vcard->EMAIL); + } + + $impp = $_REQUEST['impp']; + $impp_type = $_REQUEST['impp_type']; + if ($impp) { + $i = 0; + unset($vcard->IMPP); + foreach ($impp as $item) { + if ($item) { + $vcard->add('IMPP', $item, ['type' => $impp_type[$i]]); + } + $i++; + } + } else { + unset($vcard->IMPP); + } + + $url = $_REQUEST['url']; + $url_type = $_REQUEST['url_type']; + if ($url) { + $i = 0; + unset($vcard->URL); + foreach ($url as $item) { + if ($item) { + $vcard->add('URL', $item, ['type' => $url_type[$i]]); + } + $i++; + } + } else { + unset($vcard->URL); + } + + $adr = $_REQUEST['adr']; + $adr_type = $_REQUEST['adr_type']; + if ($adr) { + $i = 0; + unset($vcard->ADR); + foreach ($adr as $item) { + if ($item) { + $vcard->add('ADR', $item, ['type' => $adr_type[$i]]); + } + $i++; + } + } else { + unset($vcard->ADR); + } + + $note = $_REQUEST['note']; + if ($note) { + $vcard->NOTE = $note; + } else { + unset($vcard->NOTE); + } + + $cardData = $vcard->serialize(); + + $carddavBackend->updateCard($id, $uri, $cardData); + } + + // delete addressbook card + if ($_REQUEST['delete'] && $_REQUEST['uri'] && $_REQUEST['target']) { + $id = $_REQUEST['target']; + + if (!cdav_perms($id, $addressbooks)) { + return; + } + + $uri = $_REQUEST['uri']; + + $carddavBackend->deleteCard($id, $uri); + } + } + + // Import calendar or addressbook + if (($_FILES) && array_key_exists('userfile', $_FILES) && intval($_FILES['userfile']['size']) && $_REQUEST['target']) { + $src = $_FILES['userfile']['tmp_name']; + + if ($src) { + if ($_REQUEST['c_upload']) { + if ($_REQUEST['target'] == 'calendar') { + $result = parse_ical_file($src, local_channel()); + if ($result) { + info(t('Calendar entries imported.') . EOL); + } else { + notice(t('No calendar entries found.') . EOL); + } + + @unlink($src); + return; + } + + $id = explode(':', $_REQUEST['target']); + $ext = 'ics'; + $table = 'calendarobjects'; + $column = 'calendarid'; + $objects = new ICalendar(@file_get_contents($src)); + $profile = Node::PROFILE_CALDAV; + $backend = new \Sabre\CalDAV\Backend\PDO($pdo); + } + + if ($_REQUEST['a_upload']) { + $id[] = intval($_REQUEST['target']); + $ext = 'vcf'; + $table = 'cards'; + $column = 'addressbookid'; + $objects = new VCard(@file_get_contents($src)); + $profile = Node::PROFILE_CARDDAV; + $backend = new PDO($pdo); + } + + while ($object = $objects->getNext()) { + if ($_REQUEST['a_upload']) { + $object = $object->convert(Document::VCARD40); + } + + $ret = $object->validate($profile & Node::REPAIR); + + // level 3 Means that the document is invalid, + // level 2 means a warning. A warning means it's valid but it could cause interopability issues, + // level 1 means that there was a problem earlier, but the problem was automatically repaired. + + if ($ret[0]['level'] < 3) { + do { + $duplicate = false; + $objectUri = random_string(40) . '.' . $ext; + + $r = q( + "SELECT uri FROM $table WHERE $column = %d AND uri = '%s' LIMIT 1", + dbesc($id[0]), + dbesc($objectUri) + ); + + if ($r) { + $duplicate = true; + } + } while ($duplicate == true); + + if ($_REQUEST['c_upload']) { + $backend->createCalendarObject($id, $objectUri, $object->serialize()); + } + + if ($_REQUEST['a_upload']) { + $backend->createCard($id[0], $objectUri, $object->serialize()); + } + } else { + if ($_REQUEST['c_upload']) { + notice('' . t('INVALID EVENT DISMISSED!') . '' . EOL . + '' . t('Summary: ') . '' . (($object->VEVENT->SUMMARY) ? $object->VEVENT->SUMMARY : t('Unknown')) . EOL . + '' . t('Date: ') . '' . (($object->VEVENT->DTSTART) ? $object->VEVENT->DTSTART : t('Unknown')) . EOL . + '' . t('Reason: ') . '' . $ret[0]['message'] . EOL); + } + + if ($_REQUEST['a_upload']) { + notice('' . t('INVALID CARD DISMISSED!') . '' . EOL . + '' . t('Name: ') . '' . (($object->FN) ? $object->FN : t('Unknown')) . EOL . + '' . t('Reason: ') . '' . $ret[0]['message'] . EOL); + } + } + } + } + @unlink($src); + } + } + + public function get() + { + + if (!local_channel()) { + return; + } + + if ((argv(1) === 'addressbook') && (!Apps::system_app_installed(local_channel(), 'CardDAV'))) { + // Do not display any associated widgets at this point + App::$pdl = ''; + + $o = '' . t('CardDAV App') . ' (' . t('Not Installed') . '):
      '; + $o .= t('CalDAV capable addressbook'); + return $o; + } + + App::$profile_uid = local_channel(); + + $channel = App::get_channel(); + $principalUri = 'principals/' . $channel['channel_address']; + + $pdo = DBA::$dba->db; + + require_once 'vendor/autoload.php'; + + Head::add_css('cdav.css'); + + if (!cdav_principal($principalUri)) { + $this->activate($pdo, $channel); + if (!cdav_principal($principalUri)) { + return; + } + } + + if (argv(1) === 'calendar') { + Navbar::set_selected('Calendar'); + $caldavBackend = new \Sabre\CalDAV\Backend\PDO($pdo); + $calendars = $caldavBackend->getCalendarsForUser($principalUri); + } + + // Display calendar(s) here + if (argc() <= 3 && argv(1) === 'calendar') { + Head::add_css('/library/fullcalendar/packages/core/main.min.css'); + Head::add_css('/library/fullcalendar/packages/daygrid/main.min.css'); + Head::add_css('/library/fullcalendar/packages/timegrid/main.min.css'); + Head::add_css('/library/fullcalendar/packages/list/main.min.css'); + Head::add_css('cdav_calendar.css'); + + Head::add_js('/library/fullcalendar/packages/core/main.min.js'); + Head::add_js('/library/fullcalendar/packages/interaction/main.min.js'); + Head::add_js('/library/fullcalendar/packages/daygrid/main.min.js'); + Head::add_js('/library/fullcalendar/packages/timegrid/main.min.js'); + Head::add_js('/library/fullcalendar/packages/list/main.min.js'); + + $sources = ''; + $resource_id = ''; + $resource = null; + + if (argc() == 3) { + $resource_id = argv(2); + } + + if ($resource_id) { + $r = q( + "SELECT event.*, item.author_xchan, item.owner_xchan, item.plink, item.id as item_id FROM event LEFT JOIN item ON event.event_hash = item.resource_id + WHERE event.uid = %d AND event.event_hash = '%s' LIMIT 1", + intval(local_channel()), + dbesc($resource_id) + ); + if ($r) { + xchan_query($r); + $r = fetch_post_tags($r, true); + + $r[0]['dtstart'] = (($r[0]['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $r[0]['dtstart'], 'c') : datetime_convert('UTC', 'UTC', $r[0]['dtstart'], 'c')); + $r[0]['dtend'] = (($r[0]['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $r[0]['dtend'], 'c') : datetime_convert('UTC', 'UTC', $r[0]['dtend'], 'c')); + + $r[0]['plink'] = [$r[0]['plink'], t('Link to source')]; + + $resource = $r[0]; + + $catsenabled = Apps::system_app_installed(local_channel(), 'Categories'); + + $categories = ''; + if ($catsenabled) { + if ($r[0]['term']) { + $categories = array_elm_to_str(get_terms_oftype($r[0]['term'], TERM_CATEGORY), 'term'); + } + } + + if ($r[0]['dismissed'] == 0) { + q( + "UPDATE event SET dismissed = 1 WHERE event.uid = %d AND event.event_hash = '%s'", + intval(local_channel()), + dbesc($resource_id) + ); + } + } + } + + if (get_pconfig(local_channel(), 'cdav_calendar', 'calendar')) { + $sources .= '{ + id: \'calendar\', + url: \'/calendar/json/\', + color: \'#3a87ad\' + }, '; + } + + $calendars[] = [ + 'displayname' => $channel['channel_name'], + 'id' => 'calendar' + ]; + + foreach ($calendars as $calendar) { + $editable = (($calendar['share-access'] == 2) ? 'false' : 'true'); // false/true must be string since we're passing it to javascript + $color = (($calendar['{http://apple.com/ns/ical/}calendar-color']) ? $calendar['{http://apple.com/ns/ical/}calendar-color'] : '#6cad39'); + $sharer = (($calendar['share-access'] == 3) ? $calendar['{urn:ietf:params:xml:ns:caldav}calendar-description'] : ''); + $switch = get_pconfig(local_channel(), 'cdav_calendar', $calendar['id'][0]); + if ($switch) { + $sources .= '{ + id: ' . $calendar['id'][0] . ', + url: \'/cdav/calendar/json/' . $calendar['id'][0] . '/' . $calendar['id'][1] . '\', + color: \'' . $color . '\' + }, '; + } + + if ($calendar['share-access'] != 2) { + $writable_calendars[] = [ + 'displayname' => $calendar['{DAV:}displayname'], + 'sharer' => $sharer, + 'id' => $calendar['id'] + ]; + } + } + + $sources = rtrim($sources, ', '); + + $first_day = Features::enabled(local_channel(), 'cal_first_day'); + $first_day = (($first_day) ? $first_day : 0); + + $title = ['title', t('Event title')]; + $dtstart = ['dtstart', t('Start date and time')]; + $dtend = ['dtend', t('End date and time')]; + $description = ['description', t('Description')]; + $location = ['location', t('Location')]; + + $catsenabled = Apps::system_app_installed(local_channel(), 'Categories'); + + + $accesslist = new AccessControl($channel); + $perm_defaults = $accesslist->get(); + + $acl = Libacl::populate($perm_defaults, false, PermissionDescription::fromGlobalPermission('view_stream')); + + $permissions = $perm_defaults; + + $o .= replace_macros(Theme::get_template('cdav_calendar.tpl'), [ + '$sources' => $sources, + '$color' => $color, + '$lang' => App::$language, + '$timezone' => App::$timezone, + '$first_day' => $first_day, + '$prev' => t('Previous'), + '$next' => t('Next'), + '$today' => t('Today'), + '$month' => t('Month'), + '$week' => t('Week'), + '$day' => t('Day'), + '$list_month' => t('List month'), + '$list_week' => t('List week'), + '$list_day' => t('List day'), + '$title' => $title, + '$calendars' => $calendars, + '$writable_calendars' => $writable_calendars, + '$dtstart' => $dtstart, + '$dtend' => $dtend, + '$description' => $description, + '$location' => $location, + '$more' => t('More'), + '$less' => t('Less'), + '$update' => t('Update'), + '$calendar_select_label' => t('Select calendar'), + '$calendar_optiopns_label' => [t('Channel Calendars'), t('CalDAV Calendars')], + '$delete' => t('Delete'), + '$delete_all' => t('Delete all'), + '$cancel' => t('Cancel'), + '$create' => t('Create'), + '$recurrence_warning' => t('Sorry! Editing of recurrent events is not yet implemented.'), + '$channel_hash' => $channel['channel_hash'], + '$acl' => $acl, + '$lockstate' => (($accesslist->is_private()) ? 'lock' : 'unlock'), + '$allow_cid' => acl2json($permissions['allow_cid']), + '$allow_gid' => acl2json($permissions['allow_gid']), + '$deny_cid' => acl2json($permissions['deny_cid']), + '$deny_gid' => acl2json($permissions['deny_gid']), + '$catsenabled' => $catsenabled, + '$categories_label' => t('Categories'), + '$resource' => json_encode($resource), + '$categories' => $categories + ]); + + return $o; + } + + // Provide json data for calendar + if (argc() == 5 && argv(1) === 'calendar' && argv(2) === 'json' && intval(argv(3)) && intval(argv(4))) { + $events = []; + + $id = [argv(3), argv(4)]; + + if (!cdav_perms($id[0], $calendars)) { + json_return_and_die($events); + } + + if (x($_GET, 'start')) { + $start = new DateTime($_GET['start']); + } + if (x($_GET, 'end')) { + $end = new DateTime($_GET['end']); + } + + $filters['name'] = 'VCALENDAR'; + $filters['prop-filters'][0]['name'] = 'VEVENT'; + $filters['comp-filters'][0]['name'] = 'VEVENT'; + $filters['comp-filters'][0]['time-range']['start'] = $start; + $filters['comp-filters'][0]['time-range']['end'] = $end; + + $uris = $caldavBackend->calendarQuery($id, $filters); + + if ($uris) { + $objects = $caldavBackend->getMultipleCalendarObjects($id, $uris); + foreach ($objects as $object) { + $vcalendar = Reader::read($object['calendardata']); + + if (isset($vcalendar->VEVENT->RRULE)) { + // expanding recurrent events seems to loose timezone info + // save it here so we can add it later + $recurrent_timezone = (string)$vcalendar->VEVENT->DTSTART['TZID']; + $vcalendar = $vcalendar->expand($start, $end); + } + + foreach ($vcalendar->VEVENT as $vevent) { + $title = (string)$vevent->SUMMARY; + $dtstart = (string)$vevent->DTSTART; + $dtend = (string)$vevent->DTEND; + $description = (string)$vevent->DESCRIPTION; + $location = (string)$vevent->LOCATION; + $timezone = (string)$vevent->DTSTART['TZID']; + $rw = ((cdav_perms($id[0], $calendars, true)) ? true : false); + $editable = $rw ? true : false; + $recurrent = ((isset($vevent->{'RECURRENCE-ID'})) ? true : false); + + if ($recurrent) { + $editable = false; + $timezone = $recurrent_timezone; + } + + $allDay = false; + + // allDay event rules + if (!strpos($dtstart, 'T') && !strpos($dtend, 'T')) { + $allDay = true; + } + if (strpos($dtstart, 'T000000') && strpos($dtend, 'T000000')) { + $allDay = true; + } + + $events[] = [ + 'calendar_id' => $id, + 'uri' => $object['uri'], + 'title' => $title, + 'start' => datetime_convert($timezone, $timezone, $dtstart, 'c'), + 'end' => (($dtend) ? datetime_convert($timezone, $timezone, $dtend, 'c') : ''), + 'description' => $description, + 'location' => $location, + 'allDay' => $allDay, + 'editable' => $editable, + 'recurrent' => $recurrent, + 'rw' => $rw + ]; + } + } + } + json_return_and_die($events); + } + + // enable/disable calendars + if (argc() == 5 && argv(1) === 'calendar' && argv(2) === 'switch' && argv(3) && (argv(4) == 1 || argv(4) == 0)) { + $id = argv(3); + + if (!cdav_perms($id, $calendars)) { + killme(); + } + + set_pconfig(local_channel(), 'cdav_calendar', argv(3), argv(4)); + killme(); + } + + // drop calendar + if (argc() == 5 && argv(1) === 'calendar' && argv(2) === 'drop' && intval(argv(3)) && intval(argv(4))) { + $id = [argv(3), argv(4)]; + + if (!cdav_perms($id[0], $calendars)) { + killme(); + } + + $caldavBackend->deleteCalendar($id); + killme(); + } + + // drop sharee + if (argc() == 6 && argv(1) === 'calendar' && argv(2) === 'dropsharee' && intval(argv(3)) && intval(argv(4))) { + $id = [argv(3), argv(4)]; + $hash = argv(5); + + if (!cdav_perms($id[0], $calendars)) { + killme(); + } + + $sharee_arr = Channel::from_hash($hash); + + $sharee = new Sharee(); + + $sharee->href = 'mailto:' . $sharee_arr['xchan_addr']; + $sharee->principal = 'principals/' . $sharee_arr['channel_address']; + $sharee->access = 4; + $caldavBackend->updateInvites($id, [$sharee]); + + killme(); + } + + + if (argv(1) === 'addressbook') { + Navbar::set_selected('CardDAV'); + $carddavBackend = new PDO($pdo); + $addressbooks = $carddavBackend->getAddressBooksForUser($principalUri); + } + + // Display Adressbook here + if (argc() == 3 && argv(1) === 'addressbook' && intval(argv(2))) { + $id = argv(2); + + $displayname = cdav_perms($id, $addressbooks); + + if (!$displayname) { + return; + } + + Head::add_css('cdav_addressbook.css'); + + $o = ''; + + $sabrecards = $carddavBackend->getCards($id); + foreach ($sabrecards as $sabrecard) { + $uris[] = $sabrecard['uri']; + } + + if ($uris) { + $objects = $carddavBackend->getMultipleCards($id, $uris); + + foreach ($objects as $object) { + $vcard = Reader::read($object['carddata']); + + $photo = ''; + if ($vcard->PHOTO) { + $photo_value = strtolower($vcard->PHOTO->getValueType()); // binary or uri + if ($photo_value === 'binary') { + $photo_type = strtolower($vcard->PHOTO['TYPE']); // mime jpeg, png or gif + $photo = 'data:image/' . $photo_type . ';base64,' . base64_encode((string)$vcard->PHOTO); + } else { + $url = parse_url((string)$vcard->PHOTO); + $photo = 'data:' . $url['path']; + } + } + + $fn = ''; + if ($vcard->FN) { + $fn = (string)$vcard->FN; + } + + $org = ''; + if ($vcard->ORG) { + $org = (string)$vcard->ORG; + } + + $title = ''; + if ($vcard->TITLE) { + $title = (string)$vcard->TITLE; + } + + $tels = []; + if ($vcard->TEL) { + foreach ($vcard->TEL as $tel) { + $type = (($tel['TYPE']) ? translate_type((string)$tel['TYPE']) : ''); + $tels[] = [ + 'type' => $type, + 'nr' => (string)$tel + ]; + } + } + + $emails = []; + if ($vcard->EMAIL) { + foreach ($vcard->EMAIL as $email) { + $type = (($email['TYPE']) ? translate_type((string)$email['TYPE']) : ''); + $emails[] = [ + 'type' => $type, + 'address' => (string)$email + ]; + } + } + + $impps = []; + if ($vcard->IMPP) { + foreach ($vcard->IMPP as $impp) { + $type = (($impp['TYPE']) ? translate_type((string)$impp['TYPE']) : ''); + $impps[] = [ + 'type' => $type, + 'address' => (string)$impp + ]; + } + } + + $urls = []; + if ($vcard->URL) { + foreach ($vcard->URL as $url) { + $type = (($url['TYPE']) ? translate_type((string)$url['TYPE']) : ''); + $urls[] = [ + 'type' => $type, + 'address' => (string)$url + ]; + } + } + + $adrs = []; + if ($vcard->ADR) { + foreach ($vcard->ADR as $adr) { + $type = (($adr['TYPE']) ? translate_type((string)$adr['TYPE']) : ''); + $adrs[] = [ + 'type' => $type, + 'address' => $adr->getParts() + ]; + } + } + + $note = ''; + if ($vcard->NOTE) { + $note = (string)$vcard->NOTE; + } + + $cards[] = [ + 'id' => $object['id'], + 'uri' => $object['uri'], + 'photo' => $photo, + 'fn' => $fn, + 'org' => $org, + 'title' => $title, + 'tels' => $tels, + 'emails' => $emails, + 'impps' => $impps, + 'urls' => $urls, + 'adrs' => $adrs, + 'note' => $note + ]; + } + + usort($cards, function ($a, $b) { + return strcasecmp($a['fn'], $b['fn']); + }); + } + + $o .= replace_macros(Theme::get_template('cdav_addressbook.tpl'), [ + '$id' => $id, + '$cards' => $cards, + '$displayname' => $displayname, + '$name_label' => t('Name'), + '$org_label' => t('Organisation'), + '$title_label' => t('Title'), + '$tel_label' => t('Phone'), + '$email_label' => t('Email'), + '$impp_label' => t('Instant messenger'), + '$url_label' => t('Website'), + '$adr_label' => t('Address'), + '$note_label' => t('Note'), + '$mobile' => t('Mobile'), + '$home' => t('Home'), + '$work' => t('Work'), + '$other' => t('Other'), + '$add_card' => t('Add Contact'), + '$add_field' => t('Add Field'), + '$create' => t('Create'), + '$update' => t('Update'), + '$delete' => t('Delete'), + '$cancel' => t('Cancel'), + '$po_box' => t('P.O. Box'), + '$extra' => t('Additional'), + '$street' => t('Street'), + '$locality' => t('Locality'), + '$region' => t('Region'), + '$zip_code' => t('ZIP Code'), + '$country' => t('Country') + ]); + + return $o; + } + + // delete addressbook + if (argc() > 3 && argv(1) === 'addressbook' && argv(2) === 'drop' && intval(argv(3))) { + $id = argv(3); + + if (!cdav_perms($id, $addressbooks)) { + return; + } + + $carddavBackend->deleteAddressBook($id); + killme(); + } + } + + public function activate($pdo, $channel) + { + + if (!$channel) { + return; + } + + $uri = 'principals/' . $channel['channel_address']; + + + $r = q( + "select * from principals where uri = '%s' limit 1", + dbesc($uri) + ); + if ($r) { + $r = q( + "update principals set email = '%s', displayname = '%s' where uri = '%s' ", + dbesc($channel['xchan_addr']), + dbesc($channel['channel_name']), + dbesc($uri) + ); + } else { + $r = q( + "insert into principals ( uri, email, displayname ) values('%s','%s','%s') ", + dbesc($uri), + dbesc($channel['xchan_addr']), + dbesc($channel['channel_name']) + ); + + // create default calendar + $caldavBackend = new \Sabre\CalDAV\Backend\PDO($pdo); + $properties = [ + '{DAV:}displayname' => t('Default Calendar'), + '{http://apple.com/ns/ical/}calendar-color' => '#6cad39', + '{urn:ietf:params:xml:ns:caldav}calendar-description' => $channel['channel_name'] + ]; + + $id = $caldavBackend->createCalendar($uri, 'default', $properties); + set_pconfig(local_channel(), 'cdav_calendar', $id[0], 1); + set_pconfig(local_channel(), 'cdav_calendar', 'calendar', 1); + + // create default addressbook + $carddavBackend = new PDO($pdo); + $properties = ['{DAV:}displayname' => t('Default Addressbook')]; + $carddavBackend->createAddressBook($uri, 'default', $properties); + } + } +} diff --git a/Code/Module/Changeaddr.php b/Code/Module/Changeaddr.php new file mode 100644 index 000000000..85bc921af --- /dev/null +++ b/Code/Module/Changeaddr.php @@ -0,0 +1,119 @@ + NULL_DATE) { + $d1 = datetime_convert('UTC', 'UTC', 'now - 48 hours'); + if ($account['account_password_changed'] > $d1) { + notice(t('Channel name changes are not allowed within 48 hours of changing the account password.') . EOL); + return; + } + } + + $new_address = trim($_POST['newname']); + + if ($new_address === $channel['channel_address']) { + return; + } + + if ($new_address === 'sys') { + notice(t('Reserved nickname. Please choose another.') . EOL); + return; + } + + if (check_webbie(array($new_address)) !== $new_address) { + notice(t('Nickname has unsupported characters or is already being used on this site.') . EOL); + return $ret; + } + + Channel::change_address($channel, $new_address); + + goaway(z_root() . '/changeaddr'); + } + + + public function get() + { + + if (!get_config('system', 'allow_nick_change')) { + notice(t('Feature has been disabled') . EOL); + return; + } + + + if (!local_channel()) { + goaway(z_root()); + } + + $channel = App::get_channel(); + + $hash = random_string(); + + $_SESSION['remove_account_verify'] = $hash; + + $tpl = Theme::get_template('channel_rename.tpl'); + $o .= replace_macros($tpl, [ + '$basedir' => z_root(), + '$hash' => $hash, + '$title' => t('Change channel nickname/address'), + '$desc' => array(t('WARNING: '), t('Any/all connections on other networks will be lost!')), + '$passwd' => t('Please enter your password for verification:'), + '$newname' => ['newname', t('New channel address'), $channel['channel_address'], ''], + '$submit' => t('Rename Channel') + ]); + + return $o; + } +} diff --git a/Code/Module/Channel.php b/Code/Module/Channel.php new file mode 100644 index 000000000..b48b8740d --- /dev/null +++ b/Code/Module/Channel.php @@ -0,0 +1,619 @@ + 1) { + $which = argv(1); + } + if (!$which) { + if (local_channel()) { + $channel = App::get_channel(); + if ($channel && $channel['channel_address']) { + $which = $channel['channel_address']; + } + } + } + if (!$which) { + notice(t('You must be logged in to see this page.') . EOL); + return; + } + + $profile = 0; + $channel = App::get_channel(); + + if ((local_channel()) && (argc() > 2) && (argv(2) === 'view')) { + $which = $channel['channel_address']; + $profile = argv(1); + } + + $channel = Zlib\Channel::from_username($which, true); + if (!$channel) { + http_status_exit(404, 'Not found'); + } + if ($channel['channel_removed']) { + http_status_exit(410, 'Gone'); + } + + if (get_pconfig($channel['channel_id'], 'system', 'noindex')) { + App::$meta->set('robots', 'noindex, noarchive'); + } + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/atom+xml', + 'title' => t('Only posts'), + 'href' => z_root() . '/feed/' . $which + ]); + + + // An ActivityStreams actor record is more or less required for ActivityStreams compliance + // unless the actor object is inlined into every activity/object. This implies that it + // is more or less required for the Zot6 protocol, which uses ActivityStreams as a content + // serialisation and which doesn't always include the full actor record with every + // activity/object. + + // "more or less" means it isn't spelled out in the ActivityStreams spec, but a number of + // things will break in subtle ways if it isn't provided. + + // The ActivityPub protocol requires an 'inbox', which will not be present in this record + // if/when the ActivityPub protocol is disabled. This will be the case when using the Redmatrix + // fork of Zap; which disables ActivityPub connectivity by default. + + if (ActivityStreams::is_as_request()) { + // Somebody may attempt an ActivityStreams fetch on one of our message permalinks + // Make it do the right thing. + + $mid = ((x($_REQUEST, 'mid')) ? $_REQUEST['mid'] : ''); + $mid = unpack_link_id($mid); + + if ($mid) { + $obj = null; + if (strpos($mid, z_root() . '/item/') === 0) { + App::$argc = 2; + App::$argv = ['item', basename($mid)]; + $obj = new Item(); + } + if (strpos($mid, z_root() . '/activity/') === 0) { + App::$argc = 2; + App::$argv = ['activity', basename($mid)]; + $obj = new Activity(); + } + if ($obj) { + $obj->init(); + } + } + if (intval($channel['channel_system'])) { + goaway(z_root()); + } + as_return_and_die(Activity::encode_person($channel, true, true), $channel); + } + + // handle zot6 channel discovery + + if (Libzot::is_zot_request()) { + $sigdata = HTTPSig::verify(file_get_contents('php://input'), EMPTY_STR, 'zot6'); + + if ($sigdata && $sigdata['signer'] && $sigdata['header_valid']) { + $data = json_encode(Libzot::zotinfo(['guid_hash' => $channel['channel_hash'], 'target_url' => $sigdata['signer']])); + $s = q( + "select site_crypto, hubloc_sitekey from site left join hubloc on hubloc_url = site_url where hubloc_id_url = '%s' and hubloc_network in ('nomad','zot6') limit 1", + dbesc($sigdata['signer']) + ); + + if ($s && $s[0]['hubloc_sitekey'] && $s[0]['site_crypto']) { + $data = json_encode(Crypto::encapsulate($data, $s[0]['hubloc_sitekey'], Libzot::best_algorithm($s[0]['site_crypto']))); + } + } else { + $data = json_encode(Libzot::zotinfo(['guid_hash' => $channel['channel_hash']])); + } + + $headers = [ + 'Content-Type' => 'application/x-nomad+json', + 'Digest' => HTTPSig::generate_digest_header($data), + '(request-target)' => strtolower($_SERVER['REQUEST_METHOD']) . ' ' . $_SERVER['REQUEST_URI'] + ]; + $h = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Zlib\Channel::url($channel)); + HTTPSig::set_headers($h); + echo $data; + killme(); + } + + + // Run Libprofile::load() here to make sure the theme is set before + // we start loading content + + Libprofile::load($which, $profile); + + if (!$_REQUEST['mid']) { + App::$meta->set('og:title', $channel['channel_name']); + App::$meta->set('og:image', $channel['xchan_photo_l']); + App::$meta->set('og:type', 'webpage'); + App::$meta->set('og:url:secure_url', Zlib\Channel::url($channel)); + if (App::$profile['about'] && perm_is_allowed($channel['channel_id'], get_observer_hash(), 'view_profile')) { + App::$meta->set('og:description', App::$profile['about']); + } else { + App::$meta->set('og:description', sprintf(t('This is the home page of %s.'), $channel['channel_name'])); + } + } + } + + public function get() + { + + $noscript_content = get_config('system', 'noscript_content', '1'); + + $category = $datequery = $datequery2 = ''; + + $mid = ((x($_REQUEST, 'mid')) ? $_REQUEST['mid'] : ''); + $mid = unpack_link_id($mid); + + $datequery = ((x($_GET, 'dend') && is_a_date_arg($_GET['dend'])) ? notags($_GET['dend']) : ''); + $datequery2 = ((x($_GET, 'dbegin') && is_a_date_arg($_GET['dbegin'])) ? notags($_GET['dbegin']) : ''); + + if (observer_prohibited(true)) { + return login(); + } + + $category = ((x($_REQUEST, 'cat')) ? $_REQUEST['cat'] : ''); + $hashtags = ((x($_REQUEST, 'tag')) ? $_REQUEST['tag'] : ''); + $order = ((x($_GET, 'order')) ? notags($_GET['order']) : 'post'); + $static = ((array_key_exists('static', $_REQUEST)) ? intval($_REQUEST['static']) : 0); + $search = ((x($_GET, 'search')) ? $_GET['search'] : EMPTY_STR); + + $groups = []; + + $o = ''; + + if ($this->updating) { + // Ensure we've got a profile owner if updating. + App::$profile['profile_uid'] = App::$profile_uid = $this->profile_uid; + } + + $is_owner = (((local_channel()) && (App::$profile['profile_uid'] == local_channel())) ? true : false); + + $channel = App::get_channel(); + $observer = App::get_observer(); + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $perms = get_all_perms(App::$profile['profile_uid'], $ob_hash); + + + if ($this->loading && !$mid) { + $_SESSION['loadtime_channel'] = datetime_convert(); + if ($is_owner) { + PConfig::Set(local_channel(), 'system', 'loadtime_channel', $_SESSION['loadtime_channel']); + } + } + + + if (!$perms['view_stream']) { + // We may want to make the target of this redirect configurable + if ($perms['view_profile']) { + notice(t('Insufficient permissions. Request redirected to profile page.') . EOL); + goaway(z_root() . "/profile/" . App::$profile['channel_address']); + } + notice(t('Permission denied.') . EOL); + return; + } + + + if (!$this->updating) { + Navbar::set_selected('Channel Home'); + + $static = Zlib\Channel::manual_conv_update(App::$profile['profile_uid']); + + // search terms header + if ($search) { + $o .= replace_macros(Theme::get_template("section_title.tpl"), array( + '$title' => t('Search Results For:') . ' ' . htmlspecialchars($search, ENT_COMPAT, 'UTF-8') + )); + } + + $role = get_pconfig(App::$profile['profile_uid'], 'system', 'permissions_role'); + if ($role === 'social_restricted' && (!$ob_hash)) { + // provide warning that content is probably hidden ? + } + + if ($channel && $is_owner) { + $channel_acl = array( + 'allow_cid' => $channel['channel_allow_cid'], + 'allow_gid' => $channel['channel_allow_gid'], + 'deny_cid' => $channel['channel_deny_cid'], + 'deny_gid' => $channel['channel_deny_gid'] + ); + } else { + $channel_acl = ['allow_cid' => '', 'allow_gid' => '', 'deny_cid' => '', 'deny_gid' => '']; + } + + + if ($perms['post_wall']) { + $x = array( + 'is_owner' => $is_owner, + 'allow_location' => ((($is_owner || $observer) && (intval(get_pconfig(App::$profile['profile_uid'], 'system', 'use_browser_location')))) ? true : false), + 'default_location' => (($is_owner) ? App::$profile['channel_location'] : ''), + 'nickname' => App::$profile['channel_address'], + 'lockstate' => (((strlen(App::$profile['channel_allow_cid'])) || (strlen(App::$profile['channel_allow_gid'])) || (strlen(App::$profile['channel_deny_cid'])) || (strlen(App::$profile['channel_deny_gid']))) ? 'lock' : 'unlock'), + 'acl' => (($is_owner) ? Libacl::populate($channel_acl, true, PermissionDescription::fromGlobalPermission('view_stream'), Libacl::get_post_aclDialogDescription(), 'acl_dialog_post') : ''), + 'permissions' => $channel_acl, + 'showacl' => (($is_owner) ? 'yes' : ''), + 'bang' => '', + 'visitor' => (($is_owner || $observer) ? true : false), + 'profile_uid' => App::$profile['profile_uid'], + 'editor_autocomplete' => true, + 'bbco_autocomplete' => 'bbcode', + 'bbcode' => true, + 'jotnets' => true, + 'reset' => t('Reset form') + ); + + $o .= status_editor($x); + } + + if (!$mid && !$search) { + $obj = new Pinned(); + $o .= $obj->widget([]); + } + } + + + /** + * Get permissions SQL + */ + + $item_normal = " and item.item_hidden = 0 and item.item_type = 0 and item.item_deleted = 0 + and item.item_unpublished = 0 and item.item_pending_remove = 0 + and item.item_blocked = 0 "; + if (!$is_owner) { + $item_normal .= "and item.item_delayed = 0 "; + } + $item_normal_update = item_normal_update(); + $sql_extra = item_permissions_sql(App::$profile['profile_uid']); + + if (get_pconfig(App::$profile['profile_uid'], 'system', 'channel_list_mode') && (!$mid)) { + $page_mode = 'list'; + } else { + $page_mode = 'client'; + } + + $abook_uids = " and abook.abook_channel = " . intval(App::$profile['profile_uid']) . " "; + + $simple_update = (($this->updating) ? " AND item_unseen = 1 " : ''); + + if ($search) { + $search = escape_tags($search); + if (strpos($search, '#') === 0) { + $sql_extra .= term_query('item', substr($search, 1), TERM_HASHTAG, TERM_COMMUNITYTAG); + } else { + $sql_extra .= sprintf( + " AND (item.body like '%s' OR item.title like '%s') ", + dbesc(protect_sprintf('%' . $search . '%')), + dbesc(protect_sprintf('%' . $search . '%')) + ); + } + } + + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/json+oembed', + 'href' => z_root() . '/oep?f=&url=' . urlencode(z_root() . '/' . App::$query_string), + 'title' => 'oembed' + ]); + + if ($this->updating && isset($_SESSION['loadtime_channel'])) { + $simple_update = " AND item.changed > '" . datetime_convert('UTC', 'UTC', $_SESSION['loadtime_channel']) . "' "; + } + if ($this->loading) { + $simple_update = ''; + } + + if ($static && $simple_update) { + $simple_update .= " and author_xchan = '" . protect_sprintf(get_observer_hash()) . "' "; + } + + if (($this->updating) && (!$this->loading)) { + if ($mid) { + $r = q( + "SELECT parent AS item_id from item where mid like '%s' and uid = %d $item_normal_update + AND item_wall = 1 $simple_update $sql_extra limit 1", + dbesc($mid . '%'), + intval(App::$profile['profile_uid']) + ); + } else { + $r = q( + "SELECT parent AS item_id from item + left join abook on ( item.owner_xchan = abook.abook_xchan $abook_uids ) + WHERE uid = %d $item_normal_update + AND item_wall = 1 $simple_update + AND (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra + ORDER BY created DESC", + intval(App::$profile['profile_uid']) + ); + } + } else { + if (x($category)) { + $sql_extra2 .= protect_sprintf(term_item_parent_query(App::$profile['profile_uid'], 'item', $category, TERM_CATEGORY)); + } + if (x($hashtags)) { + $sql_extra2 .= protect_sprintf(term_item_parent_query(App::$profile['profile_uid'], 'item', $hashtags, TERM_HASHTAG, TERM_COMMUNITYTAG)); + } + + if ($datequery) { + $sql_extra2 .= protect_sprintf(sprintf(" AND item.created <= '%s' ", dbesc(datetime_convert(date_default_timezone_get(), '', $datequery)))); + $order = 'post'; + } + if ($datequery2) { + $sql_extra2 .= protect_sprintf(sprintf(" AND item.created >= '%s' ", dbesc(datetime_convert(date_default_timezone_get(), '', $datequery2)))); + } + + if ($datequery || $datequery2) { + $sql_extra2 .= " and item.item_thread_top != 0 "; + } + + if ($order === 'post') { + $ordering = "created"; + } else { + $ordering = "commented"; + } + + $itemspage = get_pconfig(local_channel(), 'system', 'itemspage'); + App::set_pager_itemspage(((intval($itemspage)) ? $itemspage : 20)); + $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval(App::$pager['itemspage']), intval(App::$pager['start'])); + + if ($noscript_content || $this->loading) { + if ($mid) { + $r = q( + "SELECT parent AS item_id from item where mid like '%s' and uid = %d $item_normal + AND item_wall = 1 $sql_extra limit 1", + dbesc($mid . '%'), + intval(App::$profile['profile_uid']) + ); + if (!$r) { + notice(t('Permission denied.') . EOL); + } + } else { + $r = q( + "SELECT DISTINCT item.parent AS item_id, $ordering FROM item + left join abook on ( item.author_xchan = abook.abook_xchan $abook_uids ) + WHERE true and item.uid = %d $item_normal + AND (abook.abook_blocked = 0 or abook.abook_flags is null) + AND item.item_wall = 1 AND item.item_thread_top = 1 + $sql_extra $sql_extra2 + ORDER BY $ordering DESC $pager_sql ", + intval(App::$profile['profile_uid']) + ); + } + } else { + $r = []; + } + } + if ($r) { + $parents_str = ids_to_querystr($r, 'item_id'); + + $items = q( + "SELECT item.*, item.id AS item_id + FROM item + WHERE item.uid = %d $item_normal + AND item.parent IN ( %s ) + $sql_extra ", + intval(App::$profile['profile_uid']), + dbesc($parents_str) + ); + + xchan_query($items); + $items = fetch_post_tags($items, true); + $items = conv_sort($items, $ordering); + + if ($this->loading && $mid && (!count($items))) { + // This will happen if we don't have sufficient permissions + // to view the parent item (or the item itself if it is toplevel) + notice(t('Permission denied.') . EOL); + } + } else { + $items = []; + } + + if ((!$this->updating) && (!$this->loading)) { + // This is ugly, but we can't pass the profile_uid through the session to the ajax updater, + // because browser prefetching might change it on us. We have to deliver it with the page. + + $maxheight = get_pconfig(App::$profile['profile_uid'], 'system', 'channel_divmore_height'); + if (!$maxheight) { + $maxheight = 400; + } + + $o .= '
      ' . "\r\n"; + $o .= "\r\n"; + + App::$page['htmlhead'] .= replace_macros(Theme::get_template("build_query.tpl"), array( + '$baseurl' => z_root(), + '$pgtype' => 'channel', + '$uid' => ((App::$profile['profile_uid']) ? App::$profile['profile_uid'] : '0'), + '$gid' => '0', + '$cid' => '0', + '$cmin' => '(-1)', + '$cmax' => '(-1)', + '$star' => '0', + '$liked' => '0', + '$conv' => '0', + '$spam' => '0', + '$nouveau' => '0', + '$wall' => '1', + '$draft' => '0', + '$fh' => '0', + '$dm' => '0', + '$static' => $static, + '$page' => ((App::$pager['page'] != 1) ? App::$pager['page'] : 1), + '$search' => $search, + '$xchan' => '', + '$order' => (($order) ? urlencode($order) : ''), + '$list' => ((x($_REQUEST, 'list')) ? intval($_REQUEST['list']) : 0), + '$file' => '', + '$cats' => (($category) ? urlencode($category) : ''), + '$tags' => (($hashtags) ? urlencode($hashtags) : ''), + '$mid' => (($mid) ? urlencode($mid) : ''), + '$verb' => '', + '$net' => '', + '$dend' => $datequery, + '$dbegin' => $datequery2 + )); + } + + $update_unseen = ''; + + if ($page_mode === 'list') { + + /** + * in "list mode", only mark the parent item and any like activities as "seen". + * We won't distinguish between comment likes and post likes. The important thing + * is that the number of unseen comments will be accurate. The SQL to separate the + * comment likes could also get somewhat hairy. + */ + + if (isset($parents_str) && $parents_str) { + $update_unseen = " AND ( id IN ( " . dbesc($parents_str) . " )"; + $update_unseen .= " OR ( parent IN ( " . dbesc($parents_str) . " ) AND verb in ( '" . dbesc(ACTIVITY_LIKE) . "','" . dbesc(ACTIVITY_DISLIKE) . "' ))) "; + } + } else { + if (isset($parents_str) && $parents_str) { + $update_unseen = " AND parent IN ( " . dbesc($parents_str) . " )"; + } + } + + if ($is_owner && $update_unseen && (!$_SESSION['sudo'])) { + $x = ['channel_id' => local_channel(), 'update' => 'unset']; + Hook::call('update_unseen', $x); + if ($x['update'] === 'unset' || intval($x['update'])) { + $r = q( + "UPDATE item SET item_unseen = 0 where item_unseen = 1 and item_wall = 1 AND uid = %d $update_unseen", + intval(local_channel()) + ); + } + + $ids = ids_to_array($items, 'item_id'); + $seen = PConfig::Get(local_channel(), 'system', 'seen_items'); + if (!$seen) { + $seen = []; + } + $seen = array_merge($ids, $seen); + PConfig::Set(local_channel(), 'system', 'seen_items', $seen); + } + + $mode = (($search) ? 'search' : 'channel'); + + if ($this->updating) { + $o .= conversation($items, $mode, $this->updating, $page_mode); + } else { + $o .= ''; + + $o .= conversation($items, $mode, $this->updating, $page_mode); + + if ($mid && $items[0]['title']) { + App::$page['title'] = $items[0]['title'] . " - " . App::$page['title']; + } + } + + // We reset $channel so that info can be obtained for unlogged visitors + $channel = Zlib\Channel::from_id(App::$profile['profile_uid']); + + if (isset($_REQUEST['mid']) && $_REQUEST['mid']) { + if (preg_match("/\[[zi]mg(.*?)\]([^\[]+)/is", $items[0]['body'], $matches)) { + $ogimage = $matches[2]; + // Will we use og:image:type someday? We keep this just in case + // $ogimagetype = guess_image_type($ogimage); + } + + // some work on post content to generate a description + // almost fully based on work done on Hubzilla by Max Kostikov + $ogdesc = $items[0]['body']; + + $ogdesc = str_replace("#^[", "[", $ogdesc); + + $ogdesc = bbcode($ogdesc, ['tryoembed' => false]); + $ogdesc = trim(html2plain($ogdesc, 0, true)); + $ogdesc = html_entity_decode($ogdesc, ENT_QUOTES, 'UTF-8'); + + // remove all URLs + $ogdesc = preg_replace("/https?\:\/\/[a-zA-Z0-9\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,\@]+/", "", $ogdesc); + + // shorten description + $ogdesc = substr($ogdesc, 0, 300); + $ogdesc = str_replace("\n", " ", $ogdesc); + while (strpos($ogdesc, " ") !== false) { + $ogdesc = str_replace(" ", " ", $ogdesc); + } + $ogdesc = (strlen($ogdesc) < 298 ? $ogdesc : rtrim(substr($ogdesc, 0, strrpos($ogdesc, " ")), "?.,:;!-") . "..."); + + // we can now start loading content + + App::$meta->set('og:title', ($items[0]['title'] ? $items[0]['title'] : $channel['channel_name'])); + App::$meta->set('og:image', ($ogimage ? $ogimage : $channel['xchan_photo_l'])); + App::$meta->set('og:type', 'article'); + App::$meta->set('og:url:secure_url', Zlib\Channel::url($channel)); + App::$meta->set('og:description', ($ogdesc ? $ogdesc : sprintf(t('This post was published on the home page of %s.'), $channel['channel_name']))); + } + + if ($mid) { + $o .= '
      '; + } + + return $o; + } +} diff --git a/Code/Module/Chanview.php b/Code/Module/Chanview.php new file mode 100644 index 000000000..753d675e6 --- /dev/null +++ b/Code/Module/Chanview.php @@ -0,0 +1,184 @@ + $url, + '$photo' => get_xconfig(App::$poi['xchan_hash'], 'system', 'cover_photo'), + '$alt' => t('Cover photo for this channel'), + '$about' => $about, + '$followers_txt' => t('Followers'), + '$following_txt' => t('Following'), + '$followers' => $followers, + '$following' => $following, + '$visit' => t('Visit'), + '$full' => t('toggle full screen mode') + ]); + + return $o; + } + } +} diff --git a/Code/Module/Chat.php b/Code/Module/Chat.php new file mode 100644 index 000000000..750eed5fd --- /dev/null +++ b/Code/Module/Chat.php @@ -0,0 +1,273 @@ + 1) ? argv(1) : null); + if (local_channel() && (!$which)) { + $channel = App::get_channel(); + if ($channel && $channel['channel_address']) { + $which = $channel['channel_address']; + } + } + + if (!$which) { + notice(t('You must be logged in to see this page.') . EOL); + return; + } + + $profile = 0; + + // Run Libprofile::load() here to make sure the theme is set before + // we start loading content + + Libprofile::load($which, $profile); + } + + public function post() + { + + if ($_POST['room_name']) { + $room = strip_tags(trim($_POST['room_name'])); + } + + if ((!$room) || (!local_channel())) { + return; + } + + $channel = App::get_channel(); + + if ($_POST['action'] === 'drop') { + logger('delete chatroom'); + Chatroom::destroy($channel, ['cr_name' => $room]); + goaway(z_root() . '/chat/' . $channel['channel_address']); + } + + $acl = new AccessControl($channel); + $acl->set_from_array($_REQUEST); + + $arr = $acl->get(); + $arr['name'] = $room; + $arr['expire'] = intval($_POST['chat_expire']); + if (intval($arr['expire']) < 0) { + $arr['expire'] = 0; + } + + Chatroom::create($channel, $arr); + + $x = q( + "select * from chatroom where cr_name = '%s' and cr_uid = %d limit 1", + dbesc($room), + intval(local_channel()) + ); + + Libsync::build_sync_packet(0, array('chatroom' => $x)); + + if ($x) { + goaway(z_root() . '/chat/' . $channel['channel_address'] . '/' . $x[0]['cr_id']); + } + + // that failed. Try again perhaps? + + goaway(z_root() . '/chat/' . $channel['channel_address'] . '/new'); + } + + + public function get() + { + +// if(! Apps::system_app_installed(App::$profile_uid, 'Chatrooms')) { +// // Do not display any associated widgets at this point +// App::$pdl = ''; +// +// $o = 'Chatrooms App (Not Installed):
      '; +// $o .= t('Access Controlled Chatrooms'); +// return $o; +// } + + if (local_channel()) { + $channel = App::get_channel(); + Navbar::set_selected('Chatrooms'); + } + + $ob = App::get_observer(); + $observer = get_observer_hash(); + if (!$observer) { + notice(t('Permission denied.') . EOL); + return; + } + + if (!perm_is_allowed(App::$profile['profile_uid'], $observer, 'chat')) { + notice(t('Permission denied.') . EOL); + return; + } + + if ((argc() > 3) && intval(argv(2)) && (argv(3) === 'leave')) { + Chatroom::leave($observer, argv(2), $_SERVER['REMOTE_ADDR']); + goaway(z_root() . '/channel/' . argv(1)); + } + + + if ((argc() > 3) && intval(argv(2)) && (argv(3) === 'status')) { + $ret = ['success' => false]; + $room_id = intval(argv(2)); + if (!$room_id || !$observer) { + return; + } + + $r = q( + "select * from chatroom where cr_id = %d limit 1", + intval($room_id) + ); + if (!$r) { + json_return_and_die($ret); + } + require_once('include/security.php'); + $sql_extra = permissions_sql($r[0]['cr_uid']); + + $x = q( + "select * from chatroom where cr_id = %d and cr_uid = %d $sql_extra limit 1", + intval($room_id), + intval($r[0]['cr_uid']) + ); + if (!$x) { + json_return_and_die($ret); + } + $y = q( + "select count(*) as total from chatpresence where cp_room = %d", + intval($room_id) + ); + if ($y) { + $ret['success'] = true; + $ret['chatroom'] = $r[0]['cr_name']; + $ret['inroom'] = $y[0]['total']; + } + + // figure out how to present a timestamp of the last activity, since we don't know the observer's timezone. + + $z = q( + "select created from chat where chat_room = %d order by created desc limit 1", + intval($room_id) + ); + if ($z) { + $ret['last'] = $z[0]['created']; + } + json_return_and_die($ret); + } + + + if (argc() > 2 && intval(argv(2))) { + $room_id = intval(argv(2)); + + $x = Chatroom::enter($observer, $room_id, 'online', $_SERVER['REMOTE_ADDR']); + if (!$x) { + return; + } + $x = q( + "select * from chatroom where cr_id = %d and cr_uid = %d $sql_extra limit 1", + intval($room_id), + intval(App::$profile['profile_uid']) + ); + + if ($x) { + $acl = new AccessControl(false); + $acl->set($x[0]); + + $private = $acl->is_private(); + $room_name = $x[0]['cr_name']; + } else { + notice(t('Room not found') . EOL); + return; + } + + $cipher = get_pconfig(local_channel(), 'system', 'default_cipher'); + if (!$cipher) { + $cipher = 'AES-128-CCM'; + } + + + $o = replace_macros(Theme::get_template('chat.tpl'), [ + '$is_owner' => ((local_channel() && local_channel() == $x[0]['cr_uid']) ? true : false), + '$room_name' => $room_name, + '$room_id' => $room_id, + '$baseurl' => z_root(), + '$nickname' => argv(1), + '$submit' => t('Submit'), + '$leave' => t('Leave Room'), + '$drop' => t('Delete Room'), + '$away' => t('I am away right now'), + '$online' => t('I am online'), + '$feature_encrypt' => ((Apps::system_app_installed(local_channel(), 'Secrets')) ? true : false), + '$cipher' => $cipher, + '$linkurl' => t('Please enter a link URL:'), + '$encrypt' => t('Encrypt text'), + '$insert' => t('Insert web link') + ]); + return $o; + } + + require_once('include/conversation.php'); + + $o = ''; + + $acl = new AccessControl($channel); + $channel_acl = $acl->get(); + + $lockstate = (($channel_acl['allow_cid'] || $channel_acl['allow_gid'] || $channel_acl['deny_cid'] || $channel_acl['deny_gid']) ? 'lock' : 'unlock'); + + $chatroom_new = ''; + if (local_channel()) { + $chatroom_new = replace_macros(Theme::get_template('chatroom_new.tpl'), array( + '$header' => t('New Chatroom'), + '$name' => array('room_name', t('Chatroom name'), '', ''), + '$chat_expire' => array('chat_expire', t('Expiration of chats (minutes)'), 120, ''), + '$permissions' => t('Permissions'), + '$acl' => Libacl::populate($channel_acl, false), + '$allow_cid' => acl2json($channel_acl['allow_cid']), + '$allow_gid' => acl2json($channel_acl['allow_gid']), + '$deny_cid' => acl2json($channel_acl['deny_cid']), + '$deny_gid' => acl2json($channel_acl['deny_gid']), + '$lockstate' => $lockstate, + '$submit' => t('Submit') + + )); + } + + $rooms = Chatroom::roomlist(App::$profile['profile_uid']); + + $o .= replace_macros(Theme::get_template('chatrooms.tpl'), [ + '$header' => sprintf(t('%1$s\'s Chatrooms'), App::$profile['fullname']), + '$name' => t('Name'), + '$baseurl' => z_root(), + '$nickname' => App::$profile['channel_address'], + '$rooms' => $rooms, + '$norooms' => t('No chatrooms available'), + '$newroom' => t('Create New'), + '$is_owner' => ((local_channel() && local_channel() == App::$profile['profile_uid']) ? 1 : 0), + '$chatroom_new' => $chatroom_new, + '$expire' => t('Expiration'), + '$expire_unit' => t('min') //minutes + ]); + + return $o; + } +} diff --git a/Code/Module/Chatsvc.php b/Code/Module/Chatsvc.php new file mode 100644 index 000000000..497a8a2a7 --- /dev/null +++ b/Code/Module/Chatsvc.php @@ -0,0 +1,201 @@ + false); + + App::$data['chat']['room_id'] = intval($_REQUEST['room_id']); + $x = q( + "select cr_uid from chatroom where cr_id = %d and cr_id != 0 limit 1", + intval(App::$data['chat']['room_id']) + ); + if (!$x) { + json_return_and_die($ret); + } + + App::$data['chat']['uid'] = $x[0]['cr_uid']; + + if (!perm_is_allowed(App::$data['chat']['uid'], get_observer_hash(), 'chat')) { + json_return_and_die($ret); + } + } + + public function post() + { + + $ret = array('success' => false); + + $room_id = App::$data['chat']['room_id']; + $text = escape_tags($_REQUEST['chat_text']); + if (!$text) { + return; + } + + $sql_extra = permissions_sql(App::$data['chat']['uid']); + + $r = q( + "select * from chatroom where cr_uid = %d and cr_id = %d $sql_extra", + intval(App::$data['chat']['uid']), + intval(App::$data['chat']['room_id']) + ); + if (!$r) { + json_return_and_die($ret); + } + + $arr = array( + 'chat_room' => App::$data['chat']['room_id'], + 'chat_xchan' => get_observer_hash(), + 'chat_text' => $text + ); + + Hook::call('chat_post', $arr); + + $x = q( + "insert into chat ( chat_room, chat_xchan, created, chat_text ) + values( %d, '%s', '%s', '%s' )", + intval(App::$data['chat']['room_id']), + dbesc(get_observer_hash()), + dbesc(datetime_convert()), + dbesc(str_rot47(base64url_encode($arr['chat_text']))) + ); + + $ret['success'] = true; + json_return_and_die($ret); + } + + public function get() + { + + $status = strip_tags($_REQUEST['status']); + $room_id = intval(App::$data['chat']['room_id']); + $stopped = ((x($_REQUEST, 'stopped') && intval($_REQUEST['stopped'])) ? true : false); + + if ($status && $room_id) { + $x = q( + "select channel_address from channel where channel_id = %d limit 1", + intval(App::$data['chat']['uid']) + ); + + $r = q( + "update chatpresence set cp_status = '%s', cp_last = '%s' where cp_room = %d and cp_xchan = '%s' and cp_client = '%s'", + dbesc($status), + dbesc(datetime_convert()), + intval($room_id), + dbesc(get_observer_hash()), + dbesc($_SERVER['REMOTE_ADDR']) + ); + + goaway(z_root() . '/chat/' . $x[0]['channel_address'] . '/' . $room_id); + } + + if (!$stopped) { + $lastseen = intval($_REQUEST['last']); + + $ret = array('success' => false); + + $sql_extra = permissions_sql(App::$data['chat']['uid']); + + $r = q( + "select * from chatroom where cr_uid = %d and cr_id = %d $sql_extra", + intval(App::$data['chat']['uid']), + intval(App::$data['chat']['room_id']) + ); + if (!$r) { + json_return_and_die($ret); + } + + $inroom = []; + + $r = q( + "select * from chatpresence left join xchan on xchan_hash = cp_xchan where cp_room = %d order by xchan_name", + intval(App::$data['chat']['room_id']) + ); + if ($r) { + foreach ($r as $rv) { + if (!$rv['xchan_name']) { + $rv['xchan_hash'] = $rv['cp_xchan']; + $rv['xchan_name'] = substr($rv['cp_xchan'], strrpos($rv['cp_xchan'], '.') + 1); + $rv['xchan_addr'] = ''; + $rv['xchan_network'] = 'unknown'; + $rv['xchan_url'] = z_root(); + $rv['xchan_hidden'] = 1; + $rv['xchan_photo_mimetype'] = 'image/png'; + $rv['xchan_photo_l'] = z_root() . '/' . Channel::get_default_profile_photo(300); + $rv['xchan_photo_m'] = z_root() . '/' . Channel::get_default_profile_photo(80); + $rv['xchan_photo_s'] = z_root() . '/' . Channel::get_default_profile_photo(48); + } + + switch ($rv['cp_status']) { + case 'away': + $status = t('Away'); + $status_class = 'away'; + break; + case 'online': + default: + $status = t('Online'); + $status_class = 'online'; + break; + } + + $inroom[] = array('img' => zid($rv['xchan_photo_m']), 'img_type' => $rv['xchan_photo_mimetype'], 'name' => $rv['xchan_name'], 'status' => $status, 'status_class' => $status_class); + } + } + + $chats = []; + + $r = q( + "select * from chat left join xchan on chat_xchan = xchan_hash where chat_room = %d and chat_id > %d order by created", + intval(App::$data['chat']['room_id']), + intval($lastseen) + ); + if ($r) { + foreach ($r as $rr) { + $chats[] = array( + 'id' => $rr['chat_id'], + 'img' => zid($rr['xchan_photo_m']), + 'img_type' => $rr['xchan_photo_mimetype'], + 'name' => $rr['xchan_name'], + 'isotime' => datetime_convert('UTC', date_default_timezone_get(), $rr['created'], 'c'), + 'localtime' => datetime_convert('UTC', date_default_timezone_get(), $rr['created'], 'r'), + 'text' => zidify_links(smilies(bbcode(base64url_decode(str_rot47($rr['chat_text']))))), + 'self' => ((get_observer_hash() == $rr['chat_xchan']) ? 'self' : '') + ); + } + } + } + + $r = q( + "update chatpresence set cp_last = '%s' where cp_room = %d and cp_xchan = '%s' and cp_client = '%s'", + dbesc(datetime_convert()), + intval(App::$data['chat']['room_id']), + dbesc(get_observer_hash()), + dbesc($_SERVER['REMOTE_ADDR']) + ); + + $ret['success'] = true; + if (!$stopped) { + $ret['inroom'] = $inroom; + $ret['chats'] = $chats; + } + json_return_and_die($ret); + } +} diff --git a/Code/Module/Clients.php b/Code/Module/Clients.php new file mode 100644 index 000000000..eb26bf2e0 --- /dev/null +++ b/Code/Module/Clients.php @@ -0,0 +1,23 @@ +' . $desc . ''; + } + goaway(z_root() . '/settings/oauth2'); + } +} diff --git a/Code/Module/Cloud.php b/Code/Module/Cloud.php new file mode 100644 index 000000000..bb82ab0b5 --- /dev/null +++ b/Code/Module/Cloud.php @@ -0,0 +1,149 @@ + 1) { + $which = argv(1); + } + $profile = 0; + + if ($which) { + Libprofile::load($which, $profile); + } + + + $auth = new BasicAuth(); + + $ob_hash = get_observer_hash(); + + if ($ob_hash) { + if (local_channel()) { + $channel = App::get_channel(); + $auth->setCurrentUser($channel['channel_address']); + $auth->channel_id = $channel['channel_id']; + $auth->channel_hash = $channel['channel_hash']; + $auth->channel_account_id = $channel['channel_account_id']; + if ($channel['channel_timezone']) { + $auth->setTimezone($channel['channel_timezone']); + } + } + $auth->observer = $ob_hash; + } + + // if we arrived at this path with any query parameters in the url, build a clean url without + // them and redirect. + + if (!array_key_exists('cloud_sort', $_SESSION)) { + $_SESSION['cloud_sort'] = 'name'; + } + + $_SESSION['cloud_sort'] = (($_REQUEST['sort']) ? trim(notags($_REQUEST['sort'])) : $_SESSION['cloud_sort']); + + $x = clean_query_string(); + if ($x !== App::$query_string) { + goaway(z_root() . '/' . $x); + } + + $rootDirectory = new Directory('/', $auth); + + // A SabreDAV server-object + $server = new SDAV\Server($rootDirectory); + // prevent overwriting changes each other with a lock backend + $lockBackend = new SDAV\Locks\Backend\File('cache/locks'); + $lockPlugin = new SDAV\Locks\Plugin($lockBackend); + + $server->addPlugin($lockPlugin); + + $is_readable = false; + + // provide a directory view for the cloud in Hubzilla + $browser = new Browser($auth); + $auth->setBrowserPlugin($browser); + + $server->addPlugin($browser); + + // Experimental QuotaPlugin + // require_once('\Code\Storage/QuotaPlugin.php'); + // $server->addPlugin(new \Code\Storage\\QuotaPlugin($auth)); + + + // over-ride the default XML output on thrown exceptions + + $server->on('exception', [$this, 'DAVException']); + + // All we need to do now, is to fire up the server + + $server->exec(); + + if ($browser->build_page) { + construct_page(); + } + + killme(); + } + + + public function DAVException($err) + { + + if ($err instanceof NotFound) { + notice(t('Not found') . EOL); + } elseif ($err instanceof Forbidden) { + notice(t('Permission denied') . EOL); + } elseif ($err instanceof NotImplemented) { + notice(t('Please refresh page') . EOL); + // It would be nice to do the following on remote authentication + // which provides an unexpected page query param, but we do not + // because if the exception has a different cause, it will loop. + // goaway(z_root() . '/' . App::$query_string); + } else { + notice(t('Unknown error') . EOL); + } + + construct_page(); + + killme(); + } +} diff --git a/Code/Module/Cloud_tiles.php b/Code/Module/Cloud_tiles.php new file mode 100644 index 000000000..ac8861760 --- /dev/null +++ b/Code/Module/Cloud_tiles.php @@ -0,0 +1,25 @@ +' . $desc . ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Comment Control'))) { + return $text; + } + + $desc = t('This app is installed. A button to control comments may be found below the post editor.'); + + $text = ''; + + return $text; + } +} diff --git a/Code/Module/Common.php b/Code/Module/Common.php new file mode 100644 index 000000000..5664ecf1d --- /dev/null +++ b/Code/Module/Common.php @@ -0,0 +1,81 @@ + 1 && intval(argv(1))) { + $channel_id = intval(argv(1)); + } else { + notice(t('No channel.') . EOL); + App::$error = 404; + return; + } + + $x = q( + "select channel_address from channel where channel_id = %d limit 1", + intval($channel_id) + ); + + if ($x) { + Libprofile::load($x[0]['channel_address'], 0); + } + } + + public function get() + { + + $o = ''; + + if (!App::$profile['profile_uid']) { + return; + } + + $observer_hash = get_observer_hash(); + + if (!perm_is_allowed(App::$profile['profile_uid'], $observer_hash, 'view_contacts')) { + notice(t('Permission denied.') . EOL); + return; + } + + $t = Socgraph::count_common_friends(App::$profile['profile_uid'], $observer_hash); + + if (!$t) { + notice(t('No connections in common.') . EOL); + return; + } + + $r = Socgraph::common_friends(App::$profile['profile_uid'], $observer_hash); + + if ($r) { + foreach ($r as $rr) { + $items[] = [ + 'url' => $rr['xchan_url'], + 'name' => $rr['xchan_name'], + 'photo' => $rr['xchan_photo_m'], + 'tags' => '' + ]; + } + } + + $tpl = Theme::get_template('common_friends.tpl'); + + $o = replace_macros($tpl, [ + '$title' => t('View Common Connections'), + '$items' => $items + ]); + + return $o; + } +} diff --git a/Code/Module/Connac.php b/Code/Module/Connac.php new file mode 100644 index 000000000..1c1e1a241 --- /dev/null +++ b/Code/Module/Connac.php @@ -0,0 +1,35 @@ + 1) { + App::$data['channel'] = Channel::from_username(argv(1)); + Libprofile::load(argv(1), EMPTY_STR); + } else { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + } + + public function post() + { + + if (!array_key_exists('channel', App::$data)) { + return; + } + + $edit = ((local_channel() && (local_channel() == App::$data['channel']['channel_id'])) ? true : false); + + if ($edit) { + $has_premium = ((App::$data['channel']['channel_pageflags'] & PAGE_PREMIUM) ? 1 : 0); + $premium = (($_POST['premium']) ? intval($_POST['premium']) : 0); + $text = escape_tags($_POST['text']); + + if ($has_premium != $premium) { + $r = q( + "update channel set channel_pageflags = ( channel_pageflags %s %d ) where channel_id = %d", + db_getfunc('^'), + intval(PAGE_PREMIUM), + intval(local_channel()) + ); + + Run::Summon(['Notifier', 'refresh_all', App::$data['channel']['channel_id']]); + } + set_pconfig(App::$data['channel']['channel_id'], 'system', 'selltext', $text); + // reload the page completely to get fresh data + goaway(z_root() . '/' . App::$query_string); + } + + $url = EMPTY_STR; + $observer = App::get_observer(); + if (($observer) && ($_POST['submit'] === t('Continue'))) { + if ($observer['xchan_follow']) { + $url = sprintf($observer['xchan_follow'], urlencode(Channel::get_webfinger(App::$data['channel']))); + } + if (!$url) { + $r = q( + "select * from hubloc where hubloc_hash = '%s' order by hubloc_id desc limit 1", + dbesc($observer['xchan_hash']) + ); + if ($r) { + $url = $r[0]['hubloc_url'] . '/follow?f=&url=' . urlencode(Channel::get_webfinger(App::$data['channel'])); + } + } + } + if ($url) { + goaway($url . '&confirm=1'); + } else { + notice('Unable to connect to your home hub location.'); + } + } + + + public function get() + { + + $edit = ((local_channel() && (local_channel() == App::$data['channel']['channel_id'])) ? true : false); + + $text = get_pconfig(App::$data['channel']['channel_id'], 'system', 'selltext'); + + if ($edit) { + $o = replace_macros(Theme::get_template('sellpage_edit.tpl'), array( + '$header' => t('Premium Channel Setup'), + '$address' => App::$data['channel']['channel_address'], + '$premium' => array('premium', t('Enable premium channel connection restrictions'), ((App::$data['channel']['channel_pageflags'] & PAGE_PREMIUM) ? '1' : ''), ''), + '$lbl_about' => t('Please enter your restrictions or conditions, such as paypal receipt, usage guidelines, etc.'), + '$text' => $text, + '$desc' => t('This channel may require additional steps or acknowledgement of the following conditions prior to connecting:'), + '$lbl2' => t('Potential connections will then see the following text before proceeding:'), + '$desc2' => t('By continuing, I certify that I have complied with any instructions provided on this page.'), + '$submit' => t('Submit'), + + + )); + return $o; + } else { + if (!$text) { + $text = t('(No specific instructions have been provided by the channel owner.)'); + } + + $submit = replace_macros(Theme::get_template('sellpage_submit.tpl'), array( + '$continue' => t('Continue'), + '$address' => App::$data['channel']['channel_address'] + )); + + $o = replace_macros(Theme::get_template('sellpage_view.tpl'), array( + '$header' => t('Restricted or Premium Channel'), + '$desc' => t('This channel may require additional steps or acknowledgement of the following conditions prior to connecting:'), + '$text' => prepare_text($text), + + '$desc2' => t('By continuing, I certify that I have complied with any instructions provided on this page.'), + '$submit' => $submit, + + )); + + $arr = array('channel' => App::$data['channel'], 'observer' => App::get_observer(), 'sellpage' => $o, 'submit' => $submit); + Hook::call('connect_premium', $arr); + $o = $arr['sellpage']; + } + + return $o; + } +} diff --git a/Code/Module/Connections.php b/Code/Module/Connections.php new file mode 100644 index 000000000..e91b43878 --- /dev/null +++ b/Code/Module/Connections.php @@ -0,0 +1,392 @@ + array( + 'label' => t('Active Connections'), + 'url' => z_root() . '/connections/active', + 'sel' => ($active) ? 'active' : '', + 'title' => t('Show active connections'), + ), + + 'pending' => array( + 'label' => t('New Connections'), + 'url' => z_root() . '/connections/pending', + 'sel' => ($pending) ? 'active' : '', + 'title' => t('Show pending (new) connections'), + ), + + 'blocked' => array( + 'label' => t('Blocked'), + 'url' => z_root() . '/connections/blocked', + 'sel' => ($blocked) ? 'active' : '', + 'title' => t('Only show blocked connections'), + ), + + 'ignored' => array( + 'label' => t('Ignored'), + 'url' => z_root() . '/connections/ignored', + 'sel' => ($ignored) ? 'active' : '', + 'title' => t('Only show ignored connections'), + ), + + 'archived' => array( + 'label' => t('Archived/Unreachable'), + 'url' => z_root() . '/connections/archived', + 'sel' => ($archived) ? 'active' : '', + 'title' => t('Only show archived/unreachable connections'), + ), + + 'hidden' => array( + 'label' => t('Hidden'), + 'url' => z_root() . '/connections/hidden', + 'sel' => ($hidden) ? 'active' : '', + 'title' => t('Only show hidden connections'), + ), + + 'all' => array( + 'label' => t('All Connections'), + 'url' => z_root() . '/connections/all', + 'sel' => ($all) ? 'active' : '', + 'title' => t('Show all connections'), + ), + + ); + + $searching = false; + if ($search) { + $search_hdr = $search; + $search_txt = dbesc(protect_sprintf($search)); + $searching = true; + } + $sql_extra .= (($searching) ? " AND ( xchan_name like '%%$search_txt%%' OR abook_alias like '%%$search_txt%%' ) " : ""); + + if (isset($_REQUEST['gid']) && intval($_REQUEST['gid'])) { + $sql_extra .= " and xchan_hash in ( select xchan from pgrp_member where gid = " . intval($_REQUEST['gid']) . " and uid = " . intval(local_channel()) . " ) "; + } + + $r = q( + "SELECT COUNT(abook.abook_id) AS total FROM abook left join xchan on abook.abook_xchan = xchan.xchan_hash + where abook_channel = %d and abook_self = 0 and xchan_deleted = 0 and xchan_orphan = 0 $sql_extra ", + intval(local_channel()) + ); + if ($r) { + App::set_pager_total($r[0]['total']); + $total = $r[0]['total']; + } + + $order_q = 'xchan_name'; + if (isset($_REQUEST['order'])) { + switch ($_REQUEST['order']) { + case 'date': + $order_q = 'abook_created desc'; + break; + case 'created': + $order_q = 'abook_created'; + break; + case 'cmax': + $order_q = 'abook_closeness'; + break; + case 'name': + default: + $order_q = 'xchan_name'; + break; + } + } + + + $order = array( + + 'name' => array( + 'label' => t('Name'), + 'url' => z_root() . '/connections' . ((argv(1)) ? '/' . argv(1) : '') . '?order=name', + 'sel' => ((isset($_REQUEST['order']) && $_REQUEST['order'] !== 'name') ? 'active' : ''), + 'title' => t('Order by name'), + ), + + 'date' => array( + 'label' => t('Recent'), + 'url' => z_root() . '/connections' . ((argv(1)) ? '/' . argv(1) : '') . '?order=date', + 'sel' => ((isset($_REQUEST['order']) && $_REQUEST['order'] === 'date') ? 'active' : ''), + 'title' => t('Order by recent'), + ), + + 'created' => array( + 'label' => t('Created'), + 'url' => z_root() . '/connections' . ((argv(1)) ? '/' . argv(1) : '') . '?order=created', + 'sel' => ((isset($_REQUEST['order']) && $_REQUEST['order'] === 'created') ? 'active' : ''), + 'title' => t('Order by date'), + ), +// reserved for cmax +// 'date' => array( +// 'label' => t(''), +// 'url' => z_root() . '/connections' . ((argv(1)) ? '/' . argv(1) : '') . '?order=date', +// 'sel' => ($_REQUEST['order'] === 'date') ? 'active' : '', +// 'title' => t('Order by recent'), +// ), + + ); + + + $r = q( + "SELECT abook.*, xchan.* FROM abook left join xchan on abook.abook_xchan = xchan.xchan_hash + WHERE abook_channel = %d and abook_self = 0 and xchan_deleted = 0 and xchan_orphan = 0 $sql_extra ORDER BY $order_q LIMIT %d OFFSET %d ", + intval(local_channel()), + intval(App::$pager['itemspage']), + intval(App::$pager['start']) + ); + + $contacts = []; + + if ($r) { + vcard_query($r); + + + foreach ($r as $rr) { + if ((!$blocked) && LibBlock::fetch_by_entity(local_channel(), $rr['xchan_hash'])) { + continue; + } + if ($rr['xchan_url']) { + if ((isset($rr['vcard']) && $rr['vcard']) && is_array($rr['vcard']['tels']) && $rr['vcard']['tels'][0]['nr']) { + $phone = $rr['vcard']['tels'][0]['nr']; + } else { + $phone = ''; + } + + $status_str = ''; + $status = array( + ((isset($rr['abook_active']) && intval($rr['abook_active'])) ? t('Active') : ''), + ((intval($rr['abook_pending'])) ? t('Pending approval') : ''), + ((intval($rr['abook_archived'])) ? t('Archived') : ''), + ((intval($rr['abook_hidden'])) ? t('Hidden') : ''), + ((intval($rr['abook_ignored'])) ? t('Ignored') : ''), + ((intval($rr['abook_blocked'])) ? t('Blocked') : ''), + ((intval($rr['abook_not_here'])) ? t('Not connected at this location') : '') + ); + + $oneway = false; + if (!their_perms_contains(local_channel(), $rr['xchan_hash'], 'post_comments')) { + $oneway = true; + } + + foreach ($status as $str) { + if (!$str) { + continue; + } + $status_str .= $str; + $status_str .= ', '; + } + $status_str = rtrim($status_str, ', '); + + $contacts[] = array( + 'img_hover' => sprintf(t('%1$s [%2$s]'), $rr['xchan_name'], $rr['xchan_url']), + 'edit_hover' => t('Edit connection'), + 'edit' => t('Edit'), + 'delete_hover' => t('Delete connection'), + 'id' => $rr['abook_id'], + 'thumb' => $rr['xchan_photo_m'], + 'name' => $rr['xchan_name'] . (($rr['abook_alias']) ? ' <' . $rr['abook_alias'] . '>' : ''), + 'classes' => ((intval($rr['abook_archived']) || intval($rr['abook_not_here'])) ? 'archived' : ''), + 'link' => z_root() . '/connedit/' . $rr['abook_id'], + 'deletelink' => z_root() . '/connedit/' . intval($rr['abook_id']) . '/drop', + 'delete' => t('Delete'), + 'url' => chanlink_hash($rr['xchan_hash']), + 'webbie_label' => t('Channel address'), + 'webbie' => $rr['xchan_addr'], + 'network_label' => t('Network'), + 'network' => network_to_name($rr['xchan_network']), + 'channel_type' => intval($rr['xchan_type']), + 'call' => t('Call'), + 'phone' => $phone, + 'status_label' => t('Status'), + 'status' => $status_str, + 'connected_label' => t('Connected'), + 'connected' => datetime_convert('UTC', date_default_timezone_get(), $rr['abook_created'], 'c'), + 'approve_hover' => t('Approve connection'), + 'approve' => (($rr['abook_pending']) ? t('Approve') : false), + 'ignore_hover' => t('Ignore connection'), + 'ignore' => ((!$rr['abook_ignored']) ? t('Ignore') : false), + 'recent_label' => t('Recent activity'), + 'recentlink' => z_root() . '/stream/?f=&cid=' . intval($rr['abook_id']), + 'oneway' => $oneway, + 'allow_delete' => ($rr['abook_pending'] || get_pconfig(local_channel(), 'system', 'connections_quick_delete')), + ); + } + } + } + + + if ($_REQUEST['aj']) { + if ($contacts) { + $o = replace_macros(Theme::get_template('contactsajax.tpl'), array( + '$contacts' => $contacts, + '$edit' => t('Edit'), + )); + } else { + $o = '
      '; + } + echo $o; + killme(); + } else { + $o .= ""; + $o .= replace_macros(Theme::get_template('connections.tpl'), array( + '$header' => t('Connections') . (($head) ? ': ' . $head : ''), + '$tabs' => $tabs, + '$order' => $order, + '$sort' => t('Filter by'), + '$sortorder' => t('Sort by'), + '$total' => $total, + '$search' => ((isset($search_hdr)) ? $search_hdr : EMPTY_STR), + '$label' => t('Search'), + '$desc' => t('Search your connections'), + '$finding' => (($searching) ? t('Connections search') . ": '" . $search . "'" : ""), + '$submit' => t('Find'), + '$edit' => t('Edit'), + '$cmd' => App::$cmd, + '$contacts' => $contacts, + '$paginate' => paginate($a), + + )); + } + + if (!$contacts) { + $o .= '
      '; + } + + return $o; + } +} diff --git a/Code/Module/Connedit.php b/Code/Module/Connedit.php new file mode 100644 index 000000000..079188c4f --- /dev/null +++ b/Code/Module/Connedit.php @@ -0,0 +1,907 @@ += 2) && intval(argv(1))) { + $r = q( + "SELECT abook.*, xchan.* FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d and abook_id = %d LIMIT 1", + intval(local_channel()), + intval(argv(1)) + ); + // Set the person-of-interest for use by widgets that operate on a single pre-defined channel + if ($r) { + App::$poi = array_shift($r); + } + } + + $channel = App::get_channel(); + if ($channel) { + head_set_icon($channel['xchan_photo_s']); + } + } + + + /** + * @brief Evaluate posted values and set changes + */ + + public function post() + { + + if (!local_channel()) { + return; + } + + $contact_id = intval(argv(1)); + if (!$contact_id) { + return; + } + + $channel = App::get_channel(); + + $orig_record = q( + "SELECT * FROM abook WHERE abook_id = %d AND abook_channel = %d LIMIT 1", + intval($contact_id), + intval(local_channel()) + ); + + if (!$orig_record) { + notice(t('Could not access contact record.') . EOL); + goaway(z_root() . '/connections'); + } + + $orig_record = array_shift($orig_record); + + Hook::call('contact_edit_post', $_POST); + + $vc = get_abconfig(local_channel(), $orig_record['abook_xchan'], 'system', 'vcard'); + $vcard = (($vc) ? Reader::read($vc) : null); + $serialised_vcard = update_vcard($_REQUEST, $vcard); + if ($serialised_vcard) { + set_abconfig(local_channel(), $orig_record['abook_xchan'], 'system', 'vcard', $serialised_vcard); + } + + $autoperms = null; + $is_self = false; + + if (intval($orig_record['abook_self'])) { + $autoperms = intval($_POST['autoperms']); + $is_self = true; + } + + $profile_id = ((array_key_exists('profile_assign', $_POST)) ? $_POST['profile_assign'] : $orig_record['abook_profile']); + + if ($profile_id) { + $r = q( + "SELECT profile_guid FROM profile WHERE profile_guid = '%s' AND uid = %d LIMIT 1", + dbesc($profile_id), + intval(local_channel()) + ); + if (!$r) { + notice(t('Could not locate selected profile.') . EOL); + return; + } + } + + $abook_incl = ((array_key_exists('abook_incl', $_POST)) ? escape_tags($_POST['abook_incl']) : $orig_record['abook_incl']); + $abook_excl = ((array_key_exists('abook_excl', $_POST)) ? escape_tags($_POST['abook_excl']) : $orig_record['abook_excl']); + $abook_alias = ((array_key_exists('abook_alias', $_POST)) ? escape_tags(trim($_POST['abook_alias'])) : $orig_record['abook_alias']); + + $block_announce = ((array_key_exists('block_announce', $_POST)) ? intval($_POST['block_announce']) : 0); + + set_abconfig($channel['channel_id'], $orig_record['abook_xchan'], 'system', 'block_announce', $block_announce); + + $hidden = intval($_POST['hidden']); + + $priority = intval($_POST['poll']); + if ($priority > 5 || $priority < 0) { + $priority = 0; + } + + if (!array_key_exists('closeness', $_POST)) { + $_POST['closeness'] = 80; + } + $closeness = intval($_POST['closeness']); + if ($closeness < 0 || $closeness > 99) { + $closeness = 80; + } + + $all_perms = Permissions::Perms(); + + $p = EMPTY_STR; + + if ($all_perms) { + foreach ($all_perms as $perm => $desc) { + if (array_key_exists('perms_' . $perm, $_POST)) { + if ($p) { + $p .= ','; + } + $p .= $perm; + } + } + set_abconfig($channel['channel_id'], $orig_record['abook_xchan'], 'system', 'my_perms', $p); + if ($autoperms) { + set_pconfig($channel['channel_id'], 'system', 'autoperms', $p); + } + } + + $new_friend = false; + + if (($_REQUEST['pending']) && intval($orig_record['abook_pending'])) { + $new_friend = true; + + // @fixme it won't be common, but when you accept a new connection request + // the permissions will now be that of your permissions role and ignore + // any you may have set manually on the form. We'll probably see a bug if somebody + // tries to set the permissions *and* approve the connection in the same + // request. The workaround is to approve the connection, then go back and + // adjust permissions as desired. + + $p = Permissions::connect_perms(local_channel()); + $my_perms = Permissions::serialise($p['perms']); + if ($my_perms) { + set_abconfig($channel['channel_id'], $orig_record['abook_xchan'], 'system', 'my_perms', $my_perms); + } + } + + $abook_pending = (($new_friend) ? 0 : $orig_record['abook_pending']); + + $r = q( + "UPDATE abook SET abook_profile = '%s', abook_closeness = %d, abook_pending = %d, + abook_incl = '%s', abook_excl = '%s', abook_alias = '%s' + where abook_id = %d AND abook_channel = %d", + dbesc($profile_id), + intval($closeness), + intval($abook_pending), + dbesc($abook_incl), + dbesc($abook_excl), + dbesc($abook_alias), + intval($contact_id), + intval(local_channel()) + ); + + if ($r) { + info(t('Connection updated.') . EOL); + } else { + notice(t('Failed to update connection record.') . EOL); + } + + if (!intval(App::$poi['abook_self'])) { + if ($new_friend) { + Run::Summon(['Notifier', 'permissions_accept', $contact_id]); + } + + Run::Summon([ + 'Notifier', + (($new_friend) ? 'permissions_create' : 'permissions_update'), + $contact_id + ]); + } + + if ($new_friend) { + $default_group = $channel['channel_default_group']; + if ($default_group) { + $g = AccessList::rec_byhash(local_channel(), $default_group); + if ($g) { + AccessList::member_add(local_channel(), '', App::$poi['abook_xchan'], $g['id']); + } + } + + // Check if settings permit ("post new friend activity" is allowed, and + // friends in general or this friend in particular aren't hidden) + // and send out a new friend activity + + $pr = q( + "select * from profile where uid = %d and is_default = 1 and hide_friends = 0", + intval($channel['channel_id']) + ); + if (($pr) && (!intval($orig_record['abook_hidden'])) && (intval(get_pconfig($channel['channel_id'], 'system', 'post_newfriend')))) { + $xarr = []; + + $xarr['item_wall'] = 1; + $xarr['item_origin'] = 1; + $xarr['item_thread_top'] = 1; + $xarr['owner_xchan'] = $xarr['author_xchan'] = $channel['channel_hash']; + $xarr['allow_cid'] = $channel['channel_allow_cid']; + $xarr['allow_gid'] = $channel['channel_allow_gid']; + $xarr['deny_cid'] = $channel['channel_deny_cid']; + $xarr['deny_gid'] = $channel['channel_deny_gid']; + $xarr['item_private'] = (($xarr['allow_cid'] || $xarr['allow_gid'] || $xarr['deny_cid'] || $xarr['deny_gid']) ? 1 : 0); + + $xarr['body'] = '[zrl=' . $channel['xchan_url'] . ']' . $channel['xchan_name'] . '[/zrl]' . ' ' . t('is now connected to') . ' ' . '[zrl=' . App::$poi['xchan_url'] . ']' . App::$poi['xchan_name'] . '[/zrl]'; + + $xarr['body'] .= "\n\n\n" . '[zrl=' . App::$poi['xchan_url'] . '][zmg=80x80]' . App::$poi['xchan_photo_m'] . '[/zmg][/zrl]'; + + post_activity_item($xarr); + } + + // pull in a bit of content if there is any to pull in + Run::Summon(['Onepoll', $contact_id]); + } + + // Refresh the structure in memory with the new data + + $r = q( + "SELECT abook.*, xchan.* + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d and abook_id = %d LIMIT 1", + intval(local_channel()), + intval($contact_id) + ); + if ($r) { + App::$poi = array_shift($r); + } + + if ($new_friend) { + $arr = ['channel_id' => local_channel(), 'abook' => App::$poi]; + Hook::call('accept_follow', $arr); + } + + $this->connedit_clone($a); + + if (($_REQUEST['pending']) && (!$_REQUEST['done'])) { + goaway(z_root() . '/connections/ifpending'); + } + + return; + } + + /* @brief Clone connection + * + * + */ + + public function connedit_clone(&$a) + { + + if (!App::$poi) { + return; + } + + $channel = App::get_channel(); + + $r = q( + "SELECT abook.*, xchan.* + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d and abook_id = %d LIMIT 1", + intval(local_channel()), + intval(App::$poi['abook_id']) + ); + if (!$r) { + return; + } + + App::$poi = array_shift($r); + $clone = App::$poi; + + unset($clone['abook_id']); + unset($clone['abook_account']); + unset($clone['abook_channel']); + + $abconfig = load_abconfig($channel['channel_id'], $clone['abook_xchan']); + if ($abconfig) { + $clone['abconfig'] = $abconfig; + } + Libsync::build_sync_packet($channel['channel_id'], ['abook' => [$clone]]); + } + + /** + * @brief Generate content of connection edit page + */ + + public function get() + { + + $sort_type = 0; + $o = EMPTY_STR; + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return login(); + } + + $section = ((array_key_exists('section', $_REQUEST)) ? $_REQUEST['section'] : ''); + $channel = App::get_channel(); + + $yes_no = [t('No'), t('Yes')]; + + $connect_perms = Permissions::connect_perms(local_channel()); + + $o .= "\n"; + + if (argc() == 3) { + $contact_id = intval(argv(1)); + if (!$contact_id) { + return; + } + + $cmd = argv(2); + + $orig_record = q( + "SELECT abook.*, xchan.* FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_id = %d AND abook_channel = %d AND abook_self = 0 LIMIT 1", + intval($contact_id), + intval(local_channel()) + ); + + if (!$orig_record) { + notice(t('Could not access address book record.') . EOL); + goaway(z_root() . '/connections'); + } + + $orig_record = array_shift($orig_record); + + if ($cmd === 'update') { + // pull feed and consume it, which should subscribe to the hub. + Run::Summon(['Poller', $contact_id]); + goaway(z_root() . '/connedit/' . $contact_id); + } + + if ($cmd === 'fetchvc') { + $url = str_replace('/channel/', '/profile/', $orig_record['xchan_url']) . '/vcard'; + $recurse = 0; + $x = z_fetch_url(zid($url), false, $recurse, ['session' => true]); + if ($x['success']) { + $h = new HTTPHeaders($x['header']); + $fields = $h->fetcharr(); + if ($fields && array_key_exists('content-type', $fields)) { + $type = explode(';', trim($fields['content-type'])); + if ($type && $type[0] === 'text/vcard' && $x['body']) { + $vc = Reader::read($x['body']); + $vcard = $vc->serialize(); + if ($vcard) { + set_abconfig(local_channel(), $orig_record['abook_xchan'], 'system', 'vcard', $vcard); + $this->connedit_clone($a); + } + } + } + } + goaway(z_root() . '/connedit/' . $contact_id); + } + + + if ($cmd === 'resetphoto') { + q( + "update xchan set xchan_photo_date = '2001-01-01 00:00:00' where xchan_hash = '%s'", + dbesc($orig_record['xchan_hash']) + ); + $cmd = 'refresh'; + } + + if ($cmd === 'refresh') { + if (in_array($orig_record['xchan_network'],['nomad','zot6'])) { + if (!Libzot::refresh($orig_record, App::get_channel())) { + notice(t('Refresh failed - channel is currently unavailable.')); + } + } else { + if ($orig_record['xchan_network'] === 'activitypub') { + ActivityPub::discover($orig_record['xchan_hash'], true); + } + // if they are on a different network we'll force a refresh of the connection basic info + Run::Summon(['Notifier', 'permissions_update', $contact_id]); + } + goaway(z_root() . '/connedit/' . $contact_id); + } + + switch ($cmd) { + case 'block': + if (intval($orig_record['abook_blocked'])) { + LibBlock::remove(local_channel(), $orig_record['abook_xchan']); + $sync = [ + 'block_channel_id' => local_channel(), + 'block_entity' => $orig_record['abook_xchan'], + 'block_type' => BLOCKTYPE_CHANNEL, + 'deleted' => true, + ]; + } else { + LibBlock::store([ + 'block_channel_id' => local_channel(), + 'block_entity' => $orig_record['abook_xchan'], + 'block_type' => BLOCKTYPE_CHANNEL, + 'block_comment' => t('Added by Connedit') + ]); + $z = q( + "insert into xign ( uid, xchan ) values ( %d , '%s' ) ", + intval(local_channel()), + dbesc($orig_record['abook_xchan']) + ); + $sync = LibBlock::fetch_by_entity(local_channel(), $orig_record['abook_xchan']); + } + $ignored = ['uid' => local_channel(), 'xchan' => $orig_record['abook_xchan']]; + Libsync::build_sync_packet(0, ['xign' => [$ignored], 'block' => [$sync]]); + $flag_result = abook_toggle_flag($orig_record, ABOOK_FLAG_BLOCKED); + break; + case 'ignore': + $flag_result = abook_toggle_flag($orig_record, ABOOK_FLAG_IGNORED); + break; + case 'censor': + $flag_result = abook_toggle_flag($orig_record, ABOOK_FLAG_CENSORED); + break; + case 'archive': + $flag_result = abook_toggle_flag($orig_record, ABOOK_FLAG_ARCHIVED); + break; + case 'hide': + $flag_result = abook_toggle_flag($orig_record, ABOOK_FLAG_HIDDEN); + break; + case 'approve': + if (intval($orig_record['abook_pending'])) { + $flag_result = abook_toggle_flag($orig_record, ABOOK_FLAG_PENDING); + } + break; + default: + break; + } + + if (isset($flag_result)) { + if ($flag_result) { + $this->connedit_clone($a); + } else { + notice(t('Unable to set address book parameters.') . EOL); + } + goaway(z_root() . '/connedit/' . $contact_id); + } + + if ($cmd === 'drop') { + if ($orig_record['xchan_network'] === 'activitypub') { + ActivityPub::contact_remove(local_channel(), $orig_record); + } + contact_remove(local_channel(), $orig_record['abook_id'], true); + + // The purge notification is sent to the xchan_hash as the abook record will have just been removed + + Run::Summon(['Notifier', 'purge', $orig_record['xchan_hash']]); + + Libsync::build_sync_packet(0, ['abook' => [['abook_xchan' => $orig_record['abook_xchan'], 'entry_deleted' => true]]]); + + info(t('Connection has been removed.') . EOL); + if (isset($_SESSION['return_url']) && $_SESSION['return_url']) { + goaway(z_root() . '/' . $_SESSION['return_url']); + } + goaway(z_root() . '/contacts'); + } + } + + if (App::$poi) { + $abook_prev = 0; + $abook_next = 0; + + $contact_id = App::$poi['abook_id']; + $contact = App::$poi; + + $cn = q( + "SELECT abook_id, xchan_name from abook left join xchan on abook_xchan = xchan_hash where abook_channel = %d and abook_self = 0 and xchan_deleted = 0 order by xchan_name", + intval(local_channel()) + ); + + if ($cn) { + // store previous/next ids for navigation + $pntotal = count($cn); + + for ($x = 0; $x < $pntotal; $x++) { + if ($cn[$x]['abook_id'] == $contact_id) { + if ($x === 0) { + $abook_prev = 0; + } else { + $abook_prev = $cn[$x - 1]['abook_id']; + } + if ($x === $pntotal) { + $abook_next = 0; + } else { + $abook_next = $cn[$x + 1]['abook_id']; + } + } + } + } + + $tools = array( + + 'view' => array( + 'label' => t('View Profile'), + 'url' => chanlink_cid($contact['abook_id']), + 'sel' => '', + 'title' => sprintf(t('View %s\'s profile'), $contact['xchan_name']), + ), + + 'refresh' => array( + 'label' => t('Refresh Permissions'), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/refresh', + 'sel' => '', + 'title' => t('Fetch updated permissions'), + ), + + 'rephoto' => array( + 'label' => t('Refresh Photo'), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/resetphoto', + 'sel' => '', + 'title' => t('Fetch updated photo'), + ), + + 'recent' => array( + 'label' => t('Recent Activity'), + 'url' => z_root() . '/stream/?f=&cid=' . $contact['abook_id'], + 'sel' => '', + 'title' => t('View recent posts and comments'), + ), + + 'block' => array( + 'label' => (intval($contact['abook_blocked']) ? t('Unblock') : t('Block')), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/block', + 'sel' => (intval($contact['abook_blocked']) ? 'active' : ''), + 'title' => t('Block (or Unblock) all communications with this connection'), + 'info' => (intval($contact['abook_blocked']) ? t('This connection is blocked') : ''), + ), + + 'ignore' => array( + 'label' => (intval($contact['abook_ignored']) ? t('Unignore') : t('Ignore')), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/ignore', + 'sel' => (intval($contact['abook_ignored']) ? 'active' : ''), + 'title' => t('Ignore (or Unignore) all inbound communications from this connection'), + 'info' => (intval($contact['abook_ignored']) ? t('This connection is ignored') : ''), + ), + + 'censor' => array( + 'label' => (intval($contact['abook_censor']) ? t('Uncensor') : t('Censor')), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/censor', + 'sel' => (intval($contact['abook_censor']) ? 'active' : ''), + 'title' => t('Censor (or Uncensor) images from this connection'), + 'info' => (intval($contact['abook_censor']) ? t('This connection is censored') : ''), + ), + + 'archive' => array( + 'label' => (intval($contact['abook_archived']) ? t('Unarchive') : t('Archive')), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/archive', + 'sel' => (intval($contact['abook_archived']) ? 'active' : ''), + 'title' => t('Archive (or Unarchive) this connection - mark channel dead but keep content'), + 'info' => (intval($contact['abook_archived']) ? t('This connection is archived') : ''), + ), + + 'hide' => array( + 'label' => (intval($contact['abook_hidden']) ? t('Unhide') : t('Hide')), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/hide', + 'sel' => (intval($contact['abook_hidden']) ? 'active' : ''), + 'title' => t('Hide or Unhide this connection from your other connections'), + 'info' => (intval($contact['abook_hidden']) ? t('This connection is hidden') : ''), + ), + + 'delete' => array( + 'label' => t('Delete'), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/drop', + 'sel' => '', + 'title' => t('Delete this connection'), + ), + + ); + + + if (in_array($contact['xchan_network'], [ 'zot6', 'nomad' ])) { + $tools['fetchvc'] = [ + 'label' => t('Fetch Vcard'), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/fetchvc', + 'sel' => '', + 'title' => t('Fetch electronic calling card for this connection') + ]; + } + + $sections = []; + + $sections['perms'] = [ + 'label' => t('Permissions'), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/?f=§ion=perms', + 'sel' => '', + 'title' => t('Open Individual Permissions section by default'), + ]; + + $self = false; + + if (intval($contact['abook_self'])) { + $self = true; + $abook_prev = $abook_next = 0; + } + + $vc = get_abconfig(local_channel(), $contact['abook_xchan'], 'system', 'vcard'); + + $vctmp = (($vc) ? Reader::read($vc) : null); + $vcard = (($vctmp) ? get_vcard_array($vctmp, $contact['abook_id']) : []); + if (!$vcard) { + $vcard['fn'] = $contact['xchan_name']; + } + + + $tpl = Theme::get_template("abook_edit.tpl"); + + if (Apps::system_app_installed(local_channel(), 'Friend Zoom')) { + $sections['affinity'] = [ + 'label' => t('Friend Zoom'), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/?f=§ion=affinity', + 'sel' => '', + 'title' => t('Open Friend Zoom section by default'), + ]; + + $labels = [ + 0 => t('Me'), + 20 => t('Family'), + 40 => t('Friends'), + 60 => t('Peers'), + 80 => t('Connections'), + 99 => t('All') + ]; + Hook::call('affinity_labels', $labels); + + $slider_tpl = Theme::get_template('contact_slider.tpl'); + + $slideval = intval($contact['abook_closeness']); + + $slide = replace_macros($slider_tpl, array( + '$min' => 1, + '$val' => $slideval, + '$labels' => $labels, + )); + } + + if (Apps::system_app_installed(local_channel(), 'Content Filter')) { + $sections['filter'] = [ + 'label' => t('Filter'), + 'url' => z_root() . '/connedit/' . $contact['abook_id'] . '/?f=§ion=filter', + 'sel' => '', + 'title' => t('Open Custom Filter section by default'), + ]; + } + + $rating_val = 0; + $rating_text = ''; + + $xl = q( + "select * from xlink where xlink_xchan = '%s' and xlink_link = '%s' and xlink_static = 1", + dbesc($channel['channel_hash']), + dbesc($contact['xchan_hash']) + ); + + if ($xl) { + $rating_val = intval($xl[0]['xlink_rating']); + $rating_text = $xl[0]['xlink_rating_text']; + } + + $rating_enabled = get_config('system', 'rating_enabled'); + + if ($rating_enabled) { + $rating = replace_macros(Theme::get_template('rating_slider.tpl'), array( + '$min' => -10, + '$val' => $rating_val + )); + } else { + $rating = false; + } + + + $perms = []; + $channel = App::get_channel(); + + $global_perms = Permissions::Perms(); + + $existing = get_all_perms(local_channel(), $contact['abook_xchan'], false); + + $unapproved = array('pending', t('Approve this connection'), '', t('Accept connection to allow communication'), array(t('No'), t('Yes'))); + + $multiprofs = ((Features::enabled(local_channel(), 'multi_profiles')) ? true : false); + + if ($slide && !$multiprofs) { + $affinity = t('Set Friend Zoom'); + } + + if (!$slide && $multiprofs) { + $affinity = t('Set Profile'); + } + + if ($slide && $multiprofs) { + $affinity = t('Set Friend Zoom & Profile'); + } + + + $theirs = get_abconfig(local_channel(), $contact['abook_xchan'], 'system', 'their_perms', EMPTY_STR); + + $their_perms = Permissions::FilledPerms(explode(',', $theirs)); + foreach ($global_perms as $k => $v) { + if (!array_key_exists($k, $their_perms)) { + $their_perms[$k] = 1; + } + } + + $my_perms = explode(',', get_abconfig(local_channel(), $contact['abook_xchan'], 'system', 'my_perms', EMPTY_STR)); + + foreach ($global_perms as $k => $v) { + $thisperm = ((in_array($k, $my_perms)) ? 1 : 0); + + $checkinherited = PermissionLimits::Get(local_channel(), $k); + + // For auto permissions (when $self is true) we don't want to look at existing + // permissions because they are enabled for the channel owner + if ((!$self) && ($existing[$k])) { + $thisperm = "1"; + } + + $perms[] = array('perms_' . $k, $v, ((array_key_exists($k, $their_perms)) ? intval($their_perms[$k]) : ''), $thisperm, $yes_no, (($checkinherited & PERMS_SPECIFIC) ? '' : '1'), '', $checkinherited); + } + + $current_permcat = EMPTY_STR; + + if (Apps::System_app_installed(local_channel(),'Roles')) { + $pcat = new Permcat(local_channel(), $contact['abook_id']); + $pcatlist = $pcat->listing(); + $permcats = []; + if ($pcatlist) { + foreach ($pcatlist as $pc) { + $permcats[$pc['name']] = $pc['localname']; + } + } + + $current_permcat = $pcat->match($my_perms); + } + + $locstr = locations_by_netid($contact['xchan_hash']); + if (!$locstr) { + $locstr = unpunify($contact['xchan_url']); + } + + $clone_warn = ''; + $clonable = (in_array($contact['xchan_network'], ['nomad', 'zot6', 'zot', 'rss']) ? true : false); + if (!$clonable) { + $clone_warn = ''; + $clone_warn .= ((intval($contact['abook_not_here'])) + ? t('This connection is unreachable from this location.') + : t('This connection may be unreachable from other channel locations.') + ); + $clone_warn .= '
      ' . t('Location independence is not supported by their network.'); + } + + + if (intval($contact['abook_not_here']) && $unclonable) { + $not_here = t('This connection is unreachable from this location. Location independence is not supported by their network.'); + } + + $o .= replace_macros($tpl, [ + '$header' => (($self) ? t('Connection Default Permissions') : sprintf(t('Connection: %s'), $contact['xchan_name']) . (($contact['abook_alias']) ? ' <' . $contact['abook_alias'] . '>' : '')), + '$autoperms' => array('autoperms', t('Apply these permissions automatically'), ((get_pconfig(local_channel(), 'system', 'autoperms')) ? 1 : 0), t('Connection requests will be approved without your interaction'), $yes_no), + '$permcat' => ['permcat', t('Permission role'), $current_permcat, '', $permcats], + '$permcat_new' => t('Add permission role'), + '$permcat_enable' => Apps::system_app_installed(local_channel(),'Roles'), + '$addr' => unpunify($contact['xchan_addr']), + '$primeurl' => unpunify($contact['xchan_url']), + '$block_announce' => ['block_announce', t('Ignore shares and repeats this connection posts'), get_abconfig(local_channel(), $contact['xchan_hash'], 'system', 'block_announce', false), t('Note: This is not recommended for Groups.'), [t('No'), t('Yes')]], + '$section' => $section, + '$sections' => $sections, + '$vcard' => $vcard, + '$addr_text' => t('This connection\'s primary address is'), + '$loc_text' => t('Available locations:'), + '$locstr' => $locstr, + '$unclonable' => $clone_warn, + '$notself' => (($self) ? '' : '1'), + '$self' => (($self) ? '1' : ''), + '$autolbl' => t('The permissions indicated on this page will be applied to all new connections.'), + '$tools_label' => t('Connection Tools'), + '$tools' => (($self) ? '' : $tools), + '$lbl_slider' => t('Slide to adjust your degree of friendship'), + '$lbl_rating' => t('Rating'), + '$lbl_rating_label' => t('Slide to adjust your rating'), + '$lbl_rating_txt' => t('Optionally explain your rating'), + '$connfilter' => Apps::system_app_installed(local_channel(), 'Content Filter'), + '$connfilter_label' => t('Custom Filter'), + '$incl' => array('abook_incl', t('Only import posts with this text'), $contact['abook_incl'], t('words one per line or #tags, $categories, /patterns/, or lang=xx, leave blank to import all posts')), + '$excl' => array('abook_excl', t('Do not import posts with this text'), $contact['abook_excl'], t('words one per line or #tags, $categories, /patterns/, or lang=xx, leave blank to import all posts')), + '$alias' => array('abook_alias', t('Nickname'), $contact['abook_alias'], t('optional - allows you to search by a name that you have chosen')), + '$rating_text' => array('rating_text', t('Optionally explain your rating'), $rating_text, ''), + '$rating_info' => t('This information is public!'), + '$rating' => $rating, + '$rating_val' => $rating_val, + '$slide' => $slide, + '$affinity' => $affinity, + '$pending_label' => t('Connection Pending Approval'), + '$is_pending' => (intval($contact['abook_pending']) ? 1 : ''), + '$unapproved' => $unapproved, + '$inherited' => '', // t('inherited'), + '$submit' => t('Submit'), + '$lbl_vis2' => sprintf(t('Please choose the profile you would like to display to %s when viewing your profile securely.'), $contact['xchan_name']), + '$close' => (($contact['abook_closeness']) ? $contact['abook_closeness'] : 80), + '$them' => t('Their Settings'), + '$me' => t('My Settings'), + '$perms' => $perms, + '$permlbl' => t('Individual Permissions'), + '$permnote' => t('Some individual permissions may have been preset or locked based on your channel type and privacy settings.'), + '$permnote_self' => t('Some individual permissions may have been preset or locked based on your channel type and privacy settings.'), + '$lastupdtext' => t('Last update:'), + '$last_update' => relative_date($contact['abook_connected']), + '$profile_select' => contact_profile_assign($contact['abook_profile']), + '$multiprofs' => $multiprofs, + '$contact_id' => $contact['abook_id'], + '$name' => $contact['xchan_name'], + '$abook_prev' => $abook_prev, + '$abook_next' => $abook_next, + '$vcard_label' => t('Details'), + '$displayname' => $displayname, + '$name_label' => t('Name'), + '$org_label' => t('Organisation'), + '$title_label' => t('Title'), + '$tel_label' => t('Phone'), + '$email_label' => t('Email'), + '$impp_label' => t('Instant messenger'), + '$url_label' => t('Website'), + '$adr_label' => t('Address'), + '$note_label' => t('Note'), + '$mobile' => t('Mobile'), + '$home' => t('Home'), + '$work' => t('Work'), + '$other' => t('Other'), + '$add_card' => t('Add Contact'), + '$add_field' => t('Add Field'), + '$create' => t('Create'), + '$update' => t('Update'), + '$delete' => t('Delete'), + '$cancel' => t('Cancel'), + '$po_box' => t('P.O. Box'), + '$extra' => t('Additional'), + '$street' => t('Street'), + '$locality' => t('Locality'), + '$region' => t('Region'), + '$zip_code' => t('ZIP Code'), + '$country' => t('Country') + ]); + + $arr = array('contact' => $contact, 'output' => $o); + + Hook::call('contact_edit', $arr); + + return $arr['output']; + } + } +} diff --git a/Code/Module/Contactgroup.php b/Code/Module/Contactgroup.php new file mode 100644 index 000000000..a2faf8413 --- /dev/null +++ b/Code/Module/Contactgroup.php @@ -0,0 +1,57 @@ + AccessList['id'] + * argv[2] => connection portable_id (base64url_encoded for transport) + * + */ + + +class Contactgroup extends Controller +{ + + public function get() + { + + if (!local_channel()) { + killme(); + } + + if ((argc() > 2) && (intval(argv(1))) && (argv(2))) { + $r = abook_by_hash(local_channel(), base64url_decode(argv(2))); + if ($r) { + $change = $r['abook_xchan']; + } + } + + if ((argc() > 1) && (intval(argv(1)))) { + $group = AccessList::by_id(local_channel(), argv(1)); + + if (!$group) { + killme(); + } + + $members = AccessList::members(local_channel(), $group['id']); + $preselected = ids_to_array($members, 'xchan_hash'); + + if ($change) { + if (in_array($change, $preselected)) { + AccessList::member_remove(local_channel(), $group['gname'], $change); + } else { + AccessList::member_add(local_channel(), $group['gname'], $change); + } + } + } + + killme(); + } +} diff --git a/Code/Module/Content_filter.php b/Code/Module/Content_filter.php new file mode 100644 index 000000000..686603306 --- /dev/null +++ b/Code/Module/Content_filter.php @@ -0,0 +1,74 @@ +' . $desc . ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Content Filter'))) { + return $text; + } + + $text .= EOL . t('The settings on this page apply to all incoming content. To edit the settings for individual connetions, see the similar settings on the Connection Edit page for that connection.') . EOL . EOL; + + $setting_fields = $text; + + $setting_fields .= replace_macros(Theme::get_template('field_textarea.tpl'), array( + '$field' => [ + 'message_filter_incl', + t('Only import posts with this text'), + get_pconfig(local_channel(), 'system', 'message_filter_incl', ''), + t('words one per line or #tags, $categories, /patterns/, lang=xx, lang!=xx - leave blank to import all posts') + ] + )); + $setting_fields .= replace_macros(Theme::get_template('field_textarea.tpl'), array( + '$field' => [ + 'message_filter_excl', + t('Do not import posts with this text'), + get_pconfig(local_channel(), 'system', 'message_filter_excl', ''), + t('words one per line or #tags, $categories, /patterns/, lang=xx, lang!=xx - leave blank to import all posts') + ] + )); + + $s .= replace_macros(Theme::get_template('generic_app_settings.tpl'), array( + '$addon' => array('content_filter', '' . t('Content Filter Settings'), '', t('Submit')), + '$content' => $setting_fields + )); + + return $s; + } +} diff --git a/Code/Module/Conversation.php b/Code/Module/Conversation.php new file mode 100644 index 000000000..c23bb2b3d --- /dev/null +++ b/Code/Module/Conversation.php @@ -0,0 +1,174 @@ + 0 order by imgscale asc LIMIT 1", + dbesc($image_id), + intval(local_channel()) + ); + + if ($r) { + $max_thumb = intval(get_config('system', 'max_thumbnail', 1600)); + $iscaled = false; + if (intval($r[0]['height']) > $max_thumb || intval($r[0]['width']) > $max_thumb) { + $imagick_path = get_config('system', 'imagick_convert_path'); + if ($imagick_path && @file_exists($imagick_path) && intval($r[0]['os_storage'])) { + $fname = dbunescbin($r[0]['content']); + $tmp_name = $fname . '-001'; + $newsize = photo_calculate_scale(array_merge(getimagesize($fname), ['max' => $max_thumb])); + $cmd = $imagick_path . ' ' . escapeshellarg(PROJECT_BASE . '/' . $fname) . ' -resize ' . $newsize . ' ' . escapeshellarg(PROJECT_BASE . '/' . $tmp_name); + // logger('imagick thumbnail command: ' . $cmd); + for ($x = 0; $x < 4; $x++) { + exec($cmd); + if (file_exists($tmp_name)) { + break; + } + } + if (file_exists($tmp_name)) { + $base_image = $r[0]; + $gis = getimagesize($tmp_name); + logger('gis: ' . print_r($gis, true)); + $base_image['width'] = $gis[0]; + $base_image['height'] = $gis[1]; + $base_image['content'] = @file_get_contents($tmp_name); + $iscaled = true; + @unlink($tmp_name); + } + } + } + if (!$iscaled) { + $base_image = $r[0]; + $base_image['content'] = (($base_image['os_storage']) ? @file_get_contents(dbunescbin($base_image['content'])) : dbunescbin($base_image['content'])); + } + + $im = photo_factory($base_image['content'], $base_image['mimetype']); + if ($im && $im->is_valid()) { + // We are scaling and cropping the relative pixel locations to the original photo instead of the + // scaled photo we operated on. + + // First load the scaled photo to check its size. (Should probably pass this in the post form and save + // a query.) + + $g = q( + "select width, height from photo where resource_id = '%s' and uid = %d and imgscale = 3", + dbesc($image_id), + intval(local_channel()) + ); + + + $scaled_width = $g[0]['width']; + $scaled_height = $g[0]['height']; + + if ((!$scaled_width) || (!$scaled_height)) { + logger('potential divide by zero scaling cover photo'); + return; + } + + // unset all other cover photos + + q( + "update photo set photo_usage = %d where photo_usage = %d and uid = %d", + intval(PHOTO_NORMAL), + intval(PHOTO_COVER), + intval(local_channel()) + ); + + $orig_srcx = ($base_image['width'] / $scaled_width) * $srcX; + $orig_srcy = ($base_image['height'] / $scaled_height) * $srcY; + $orig_srcw = ($srcW / $scaled_width) * $base_image['width']; + $orig_srch = ($srcH / $scaled_height) * $base_image['height']; + + $im->cropImageRect(1200, 435, $orig_srcx, $orig_srcy, $orig_srcw, $orig_srch); + + $aid = get_account_id(); + + $p = [ + 'aid' => $aid, + 'uid' => local_channel(), + 'resource_id' => $base_image['resource_id'], + 'filename' => $base_image['filename'], + 'album' => t('Cover Photos'), + 'os_path' => $base_image['os_path'], + 'display_path' => $base_image['display_path'], + 'created' => $base_image['created'], + 'edited' => $base_image['edited'] + ]; + + $p['imgscale'] = 7; + $p['photo_usage'] = PHOTO_COVER; + + $r1 = $im->storeThumbnail($p, PHOTO_RES_COVER_1200); + + $im->doScaleImage(850, 310); + $p['imgscale'] = 8; + + $r2 = $im->storeThumbnail($p, PHOTO_RES_COVER_850); + + $im->doScaleImage(425, 160); + $p['imgscale'] = 9; + + $r3 = $im->storeThumbnail($p, PHOTO_RES_COVER_425); + + if ($r1 === false || $r2 === false || $r3 === false) { + // if one failed, delete them all so we can start over. + notice(t('Image resize failed.') . EOL); + $x = q( + "delete from photo where resource_id = '%s' and uid = %d and imgscale >= 7 ", + dbesc($base_image['resource_id']), + local_channel() + ); + return; + } + + $channel = App::get_channel(); + $this->send_cover_photo_activity($channel, $base_image, $profile); + } else { + notice(t('Unable to process image') . EOL); + } + } + + goaway(z_root() . '/channel/' . $channel['channel_address']); + } + + + $hash = photo_new_resource(); + $smallest = 0; + + $matches = []; + $partial = false; + + if (array_key_exists('HTTP_CONTENT_RANGE', $_SERVER)) { + $pm = preg_match('/bytes (\d*)\-(\d*)\/(\d*)/', $_SERVER['HTTP_CONTENT_RANGE'], $matches); + if ($pm) { + logger('Content-Range: ' . print_r($matches, true)); + $partial = true; + } + } + + if ($partial) { + $x = save_chunk($channel, $matches[1], $matches[2], $matches[3]); + + if ($x['partial']) { + header('Range: bytes=0-' . (($x['length']) ? $x['length'] - 1 : 0)); + json_return_and_die($x); + } else { + header('Range: bytes=0-' . (($x['size']) ? $x['size'] - 1 : 0)); + + $_FILES['userfile'] = [ + 'name' => $x['name'], + 'type' => $x['type'], + 'tmp_name' => $x['tmp_name'], + 'error' => $x['error'], + 'size' => $x['size'] + ]; + } + } else { + if (!array_key_exists('userfile', $_FILES)) { + $_FILES['userfile'] = [ + 'name' => $_FILES['files']['name'], + 'type' => $_FILES['files']['type'], + 'tmp_name' => $_FILES['files']['tmp_name'], + 'error' => $_FILES['files']['error'], + 'size' => $_FILES['files']['size'] + ]; + } + } + + $res = attach_store(App::get_channel(), get_observer_hash(), '', array('album' => t('Cover Photos'), 'hash' => $hash)); + + logger('attach_store: ' . print_r($res, true), LOGGER_DEBUG); + + json_return_and_die(['message' => $hash]); + } + + public function send_cover_photo_activity($channel, $photo, $profile) + { + + $arr = []; + $arr['item_thread_top'] = 1; + $arr['item_origin'] = 1; + $arr['item_wall'] = 1; + $arr['uuid'] = new_uuid(); + $arr['mid'] = z_root() . '/item/' . $arr['uuid']; + $arr['obj_type'] = ACTIVITY_OBJ_NOTE; + $arr['verb'] = ACTIVITY_CREATE; + + if ($profile && stripos($profile['gender'], t('female')) !== false) { + $t = t('%1$s updated her %2$s'); + } elseif ($profile && 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('cover photo') . '[/zrl]'; + + $ltext = '[zrl=' . z_root() . '/profile/' . $channel['channel_address'] . ']' . '[zmg]' . z_root() . '/photo/' . $photo['resource_id'] . '-8[/zmg][/zrl]'; + + $arr['body'] = sprintf($t, $channel['channel_name'], $ptext) . "\n\n" . $ltext; + + $arr['obj'] = [ + 'type' => ACTIVITY_OBJ_NOTE, + 'published' => datetime_convert('UTC', 'UTC', $photo['created'], ATOM_TIME), + 'updated' => datetime_convert('UTC', 'UTC', $photo['edited'], ATOM_TIME), + 'id' => $arr['mid'], + 'url' => ['type' => 'Link', 'mediaType' => $photo['mimetype'], 'href' => z_root() . '/photo/' . $photo['resource_id'] . '-7'], + '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); + } + + + /** + * @brief Generate content of profile-photo view + * + * @return string + * + */ + + + public function get() + { + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + $channel = App::get_channel(); + + $newuser = false; + + if (argc() == 2 && argv(1) === 'new') { + $newuser = true; + } + + if (argv(1) === 'use') { + if (argc() < 3) { + notice(t('Permission denied.') . EOL); + return; + } + + // check_form_security_token_redirectOnErr('/cover_photo', 'cover_photo'); + + $resource_id = argv(2); + + $r = q( + "SELECT id, album, imgscale FROM photo WHERE uid = %d AND resource_id = '%s' and imgscale > 0 ORDER BY imgscale ASC", + intval(local_channel()), + dbesc($resource_id) + ); + if (!$r) { + notice(t('Photo not available.') . EOL); + return; + } + $havescale = false; + foreach ($r as $rr) { + if ($rr['imgscale'] == 7) { + $havescale = true; + } + } + + $r = q( + "SELECT content, mimetype, resource_id, os_storage FROM photo WHERE id = %d and uid = %d limit 1", + intval($r[0]['id']), + intval(local_channel()) + ); + if (!$r) { + notice(t('Photo not available.') . EOL); + return; + } + + if (intval($r[0]['os_storage'])) { + $data = @file_get_contents(dbunescbin($r[0]['content'])); + } else { + $data = dbunescbin($r[0]['content']); + } + + $ph = photo_factory($data, $r[0]['mimetype']); + $smallest = 0; + if ($ph && $ph->is_valid()) { + // go ahead as if we have just uploaded a new photo to crop + $i = q( + "select resource_id, imgscale from photo where resource_id = '%s' and uid = %d and imgscale = 0", + dbesc($r[0]['resource_id']), + intval(local_channel()) + ); + + if ($i) { + $hash = $i[0]['resource_id']; + foreach ($i as $ii) { + $smallest = intval($ii['imgscale']); + } + } + } + + $this->cover_photo_crop_ui_head($ph, $hash, $smallest); + } + + + if (!array_key_exists('imagecrop', App::$data)) { + $o .= replace_macros(Theme::get_template('cover_photo.tpl'), [ + '$user' => App::$channel['channel_address'], + '$info' => t('Your cover photo may be visible to anybody on the internet'), + '$existing' => get_cover_photo(local_channel(), 'array', PHOTO_RES_COVER_850), + '$lbl_upfile' => t('Upload File:'), + '$lbl_profiles' => t('Select a profile:'), + '$title' => t('Change Cover Photo'), + '$submit' => t('Upload'), + '$profiles' => $profiles, + '$embedPhotos' => t('Use a photo from your albums'), + '$embedPhotosModalTitle' => t('Use a photo from your albums'), + '$embedPhotosModalCancel' => t('Cancel'), + '$embedPhotosModalOK' => t('OK'), + '$modalchooseimages' => t('Choose images to embed'), + '$modalchoosealbum' => t('Choose an album'), + '$modaldiffalbum' => t('Choose a different album'), + '$modalerrorlist' => t('Error getting album list'), + '$modalerrorlink' => t('Error getting photo link'), + '$modalerroralbum' => t('Error getting album'), + '$form_security_token' => get_form_security_token("cover_photo"), + '$select' => t('Select previously uploaded photo'), + + ]); + + Hook::call('cover_photo_content_end', $o); + + return $o; + } else { + $filename = App::$data['imagecrop'] . '-3'; + $resolution = 3; + + $o .= replace_macros(Theme::get_template('cropcover.tpl'), [ + '$filename' => $filename, + '$profile' => intval($_REQUEST['profile']), + '$resource' => App::$data['imagecrop'] . '-3', + '$image_url' => z_root() . '/photo/' . $filename, + '$title' => t('Crop Image'), + '$desc' => t('Please adjust the image cropping for optimum viewing.'), + '$form_security_token' => get_form_security_token("cover_photo"), + '$done' => t('Done Editing') + ]); + return $o; + } + } + + /* @brief Generate the UI for photo-cropping + * + * @param $a Current application + * @param $ph Photo-Factory + * @return void + * + */ + + public function cover_photo_crop_ui_head($ph, $hash, $smallest) + { + + $max_length = get_config('system', 'max_image_length', MAX_IMAGE_LENGTH); + if ($max_length > 0) { + $ph->scaleImage($max_length); + } + + $width = $ph->getWidth(); + $height = $ph->getHeight(); + + if ($width < 300 || $height < 300) { + $ph->scaleImageUp(240); + $width = $ph->getWidth(); + $height = $ph->getHeight(); + } + + + App::$data['imagecrop'] = $hash; + App::$data['imagecrop_resolution'] = $smallest; + App::$page['htmlhead'] .= replace_macros(Theme::get_template('crophead.tpl'), []); + return; + } +} diff --git a/Code/Module/Dav.php b/Code/Module/Dav.php new file mode 100644 index 000000000..4a4d479b8 --- /dev/null +++ b/Code/Module/Dav.php @@ -0,0 +1,147 @@ + $c, 'account' => $a[0]]; + $channel_login = $c['channel_id']; + } + } + } + if (!$record) { + continue; + } + + if ($record) { + $verified = HTTPSig::verify('', $record['channel']['channel_pubkey']); + if (!($verified && $verified['header_signed'] && $verified['header_valid'])) { + $record = null; + } + if ($record['account']) { + authenticate_success($record['account']); + if ($channel_login) { + change_channel($channel_login); + } + } + break; + } + } + } + } + } + + if (!is_dir('store')) { + os_mkdir('store', STORAGE_DEFAULT_PERMISSIONS, false); + } + + if (argc() > 1) { + Libprofile::load(argv(1), 0); + } + + + $auth = new BasicAuth(); +// $auth->observer = get_observer_hash(); + + $auth->setRealm(ucfirst(System::get_platform_name()) . ' ' . 'WebDAV'); + + $rootDirectory = new \Code\Storage\Directory('/', $auth); + + // A SabreDAV server-object + $server = new SDAV\Server($rootDirectory); + + + $authPlugin = new Plugin($auth); + $server->addPlugin($authPlugin); + + + // prevent overwriting changes each other with a lock backend + $lockBackend = new SDAV\Locks\Backend\File('cache/locks'); + $lockPlugin = new SDAV\Locks\Plugin($lockBackend); + + $server->addPlugin($lockPlugin); + + // provide a directory view for the cloud in Hubzilla + $browser = new Browser($auth); + $auth->setBrowserPlugin($browser); + + // Experimental QuotaPlugin + // $server->addPlugin(new \Code\Storage\QuotaPlugin($auth)); + + // All we need to do now, is to fire up the server + $server->exec(); + + killme(); + } +} diff --git a/Code/Module/Defperms.php b/Code/Module/Defperms.php new file mode 100644 index 000000000..c86a25c46 --- /dev/null +++ b/Code/Module/Defperms.php @@ -0,0 +1,275 @@ + $desc) { + if (array_key_exists('perms_' . $perm, $_POST)) { + if ($p) { + $p .= ','; + } + $p .= $perm; + } + } + set_abconfig($channel['channel_id'], $orig_record[0]['abook_xchan'], 'system', 'my_perms', $p); + if ($autoperms) { + set_pconfig($channel['channel_id'], 'system', 'autoperms', $p); + } + } + + notice(t('Settings updated.') . EOL); + + + // Refresh the structure in memory with the new data + + $r = q( + "SELECT abook.*, xchan.* + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d and abook_id = %d LIMIT 1", + intval(local_channel()), + intval($contact_id) + ); + if ($r) { + App::$poi = $r[0]; + } + + $this->defperms_clone($a); + + goaway(z_root() . '/defperms'); + + return; + } + + /* @brief Clone connection + * + * + */ + + public function defperms_clone(&$a) + { + + if (!App::$poi) { + return; + } + + $channel = App::get_channel(); + + $r = q( + "SELECT abook.*, xchan.* + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d and abook_id = %d LIMIT 1", + intval(local_channel()), + intval(App::$poi['abook_id']) + ); + if ($r) { + App::$poi = array_shift($r); + } + + $clone = App::$poi; + + unset($clone['abook_id']); + unset($clone['abook_account']); + unset($clone['abook_channel']); + + $abconfig = load_abconfig($channel['channel_id'], $clone['abook_xchan']); + if ($abconfig) { + $clone['abconfig'] = $abconfig; + } + Libsync::build_sync_packet(0 /* use the current local_channel */, array('abook' => array($clone))); + } + + /* @brief Generate content of connection default permissions page + * + * + */ + + public function get() + { + + $sort_type = 0; + $o = ''; + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return login(); + } + + $role = get_pconfig(local_channel(), 'system', 'permissions_role'); + if ($role) { + notice(t('Permission denied.') . EOL); + return; + } + + $section = ((array_key_exists('section', $_REQUEST)) ? $_REQUEST['section'] : ''); + $channel = App::get_channel(); + + $yes_no = [t('No'), t('Yes')]; + + $connect_perms = Permissions::connect_perms(local_channel()); + + $o .= "\n"; + + if (App::$poi) { + $sections = []; + + $self = false; + + $tpl = Theme::get_template('defperms.tpl'); + + $perms = []; + $channel = App::get_channel(); + + $contact = App::$poi; + + $global_perms = Permissions::Perms(); + + $hidden_perms = []; + + foreach ($global_perms as $k => $v) { + $thisperm = get_abconfig(local_channel(), $contact['abook_xchan'], 'my_perms', $k); + + $checkinherited = PermissionLimits::Get(local_channel(), $k); + + $inherited = (($checkinherited & PERMS_SPECIFIC) ? false : true); + + $perms[] = ['perms_' . $k, $v, intval($thisperm), '', $yes_no, (($inherited) ? ' disabled="disabled" ' : '')]; + if ($inherited) { + $hidden_perms[] = ['perms_' . $k, intval($thisperm)]; + } + } + + $pcat = new Permcat(local_channel()); + $pcatlist = $pcat->listing(); + $permcats = []; + if ($pcatlist) { + foreach ($pcatlist as $pc) { + $permcats[$pc['name']] = $pc['localname']; + } + } + + $o .= replace_macros($tpl, [ + '$header' => t('Connection Default Permissions'), + '$autoperms' => array('autoperms', t('Apply these permissions automatically'), ((get_pconfig(local_channel(), 'system', 'autoperms')) ? 1 : 0), t('If enabled, connection requests will be approved without your interaction'), $yes_no), + '$permcat' => ['permcat', t('Permission role'), '', '', $permcats], + '$permcat_new' => t('Add permission role'), + '$permcat_enable' => Apps::system_app_installed($channel_id, 'Roles'), + '$section' => $section, + '$sections' => $sections, + '$autolbl' => t('The permissions indicated on this page will be applied to all new connections.'), + '$autoapprove' => t('Automatic approval settings'), + '$unapproved' => $unapproved, + '$inherited' => t('inherited'), + '$submit' => t('Submit'), + '$me' => t('My Settings'), + '$perms' => $perms, + '$hidden_perms' => $hidden_perms, + '$permlbl' => t('Individual Permissions'), + '$permnote_self' => t('Some individual permissions may have been preset or locked based on your channel type and privacy settings.'), + '$contact_id' => $contact['abook_id'], + '$name' => $contact['xchan_name'], + ]); + + $arr = array('contact' => $contact, 'output' => $o); + + Hook::call('contact_edit', $arr); + + return $arr['output']; + } + } +} diff --git a/Code/Module/Dircensor.php b/Code/Module/Dircensor.php new file mode 100644 index 000000000..cfbed4491 --- /dev/null +++ b/Code/Module/Dircensor.php @@ -0,0 +1,52 @@ + [['uid' => local_channel(), 'xchan' => $_GET['ignore']]]]); + if ($_REQUEST['return']) { + goaway(z_root() . '/' . base64_decode($_REQUEST['return'])); + } + goaway(z_root() . '/directory?f=&suggest=1'); + } + + $observer = get_observer_hash(); + $global_changed = false; + $safe_changed = false; + $type_changed = false; + $active_changed = false; + + if (array_key_exists('global', $_REQUEST)) { + $globaldir = intval($_REQUEST['global']); + if (get_config('system', 'localdir_hide')) { + $globaldir = 1; + } + $global_changed = true; + } + if ($global_changed) { + $_SESSION['globaldir'] = $globaldir; + if ($observer) { + set_xconfig($observer, 'directory', 'globaldir', $globaldir); + } + } + + if (array_key_exists('safe', $_REQUEST)) { + $safemode = intval($_REQUEST['safe']); + $safe_changed = true; + } + if ($safe_changed) { + $_SESSION['safemode'] = $safemode; + if ($observer) { + set_xconfig($observer, 'directory', 'safemode', $safemode); + } + } + + if (array_key_exists('type', $_REQUEST)) { + $type = intval($_REQUEST['type']); + $type_changed = true; + } + if ($type_changed) { + $_SESSION['chantype'] = $type; + if ($observer) { + set_xconfig($observer, 'directory', 'chantype', $type); + } + } + + if (array_key_exists('active', $_REQUEST)) { + $active = intval($_REQUEST['active']); + $active_changed = true; + } + if ($active_changed) { + $_SESSION['activedir'] = $active; + if ($observer) { + set_xconfig($observer, 'directory', 'activedir', $active); + } + } + } + + public function get() + { + + if (observer_prohibited()) { + notice(t('Public access denied.') . EOL); + return; + } + + $observer = get_observer_hash(); + + if (get_config('system', 'block_public_directory', true) && (!$observer)) { + notice(t('Public access denied.') . EOL); + return login(false); + } + + $globaldir = Libzotdir::get_directory_setting($observer, 'globaldir'); + + // override your personal global search pref if we're doing a navbar search of the directory + if (intval($_REQUEST['navsearch'])) { + $globaldir = 1; + } + + $safe_mode = Libzotdir::get_directory_setting($observer, 'safemode'); + + $type = Libzotdir::get_directory_setting($observer, 'chantype'); + + $active = Libzotdir::get_directory_setting($observer, 'activedir'); + + $o = ''; + Navbar::set_selected('Directory'); + + if (x($_POST, 'search')) { + $search = notags(trim($_POST['search'])); + } else { + $search = ((x($_GET, 'search')) ? notags(trim(rawurldecode($_GET['search']))) : ''); + } + + if (strpos($search, '=')) { + $advanced = $search; + } + + $keywords = (($_GET['keywords']) ? $_GET['keywords'] : ''); + + // Suggest channels if no search terms or keywords are given + $suggest = (local_channel() && x($_REQUEST, 'suggest')) ? $_REQUEST['suggest'] : ''; + + if ($suggest) { + // the directory options have no effect in suggestion mode + + $globaldir = 1; + $safe_mode = 1; + $active = 1; + $type = 0; + + // only return DIRECTORY_PAGESIZE suggestions as the suggestion sorting + // only works if the suggestion query and the directory query have the + // same number of results + + App::set_pager_itemspage(60); + $r = Socgraph::suggestion_query(local_channel(), get_observer_hash(), App::$pager['start'], DIRECTORY_PAGESIZE); + + if (!$r) { + if ($_REQUEST['aj']) { + echo '
      '; + killme(); + } + + notice(t('No default suggestions were found.') . EOL); + return; + } + + // Remember in which order the suggestions were + $addresses = []; + $common = []; + $index = 0; + foreach ($r as $rr) { + $common[$rr['xchan_hash']] = ((intval($rr['total']) > 0) ? intval($rr['total']) - 1 : 0); + $addresses[$rr['xchan_hash']] = $index++; + } + + // Build query to get info about suggested people + $advanced = ''; + foreach (array_keys($addresses) as $address) { + $advanced .= "xhash=\"$address\" "; + } + // Remove last space in the advanced query + $advanced = rtrim($advanced); + } + + $tpl = Theme::get_template('directory_header.tpl'); + + $dirmode = intval(get_config('system', 'directory_mode')); + + $directory_admin = false; + + $url = z_root() . '/dirsearch'; + if (Channel::is_system(local_channel())) { + $directory_admin = true; + } + + $contacts = []; + + if (local_channel()) { + $x = q( + "select abook_xchan from abook where abook_channel = %d", + intval(local_channel()) + ); + if ($x) { + foreach ($x as $xx) { + $contacts[] = $xx['abook_xchan']; + } + } + } + + if ($url) { + $numtags = get_config('system', 'directorytags'); + + $kw = ((intval($numtags) > 0) ? intval($numtags) : 50); + + if (get_config('system', 'disable_directory_keywords')) { + $kw = 0; + } + + $query = $url . '?f=&kw=' . $kw . (($safe_mode != 1) ? '&safe=' . $safe_mode : ''); + + if ($token) { + $query .= '&t=' . $token; + } + + if (!$globaldir) { + $query .= '&hub=' . App::get_hostname(); + } + if ($search) { + $query .= '&name=' . urlencode($search) . '&keywords=' . urlencode($search); + } + if (strpos($search, '@')) { + $query .= '&address=' . urlencode($search); + } + if (strpos($search, 'http') === 0) { + $query .= '&url=' . urlencode($search); + } + if ($keywords) { + $query .= '&keywords=' . urlencode($keywords); + } + if ($advanced) { + $query .= '&query=' . urlencode($advanced); + } + if (!is_null($type)) { + $query .= '&type=' . intval($type); + } + $directory_sort_order = get_config('system', 'directory_sort_order'); + if (!$directory_sort_order) { + $directory_sort_order = 'date'; + } + + $sort_order = ((x($_REQUEST, 'order')) ? $_REQUEST['order'] : $directory_sort_order); + + if ($sort_order) { + $query .= '&order=' . urlencode($sort_order); + } + + if (App::$pager['page'] != 1) { + $query .= '&p=' . App::$pager['page']; + } + + if (isset($active)) { + $query .= '&active=' . intval($active); + } + + + // logger('mod_directory: query: ' . $query); + + $x = z_fetch_url($query); + // logger('directory: return from upstream: ' . print_r($x,true), LOGGER_DATA); + + if ($x['success']) { + $t = 0; + $j = json_decode($x['body'], true); + if ($j) { + if ($j['results']) { + $results = $j['results']; + if ($suggest) { + // change order to "number of common friends descending" + $results = self::reorder_results($results, $addresses); + } + + $entries = []; + + $photo = 'thumb'; + + foreach ($results as $rr) { + $profile_link = chanlink_url($rr['url']); + + $pdesc = (($rr['description']) ? $rr['description'] . '
      ' : ''); + $connect_link = ((local_channel()) ? z_root() . '/follow?f=&url=' . urlencode($rr['address']) : ''); + + // Checking status is disabled ATM until someone checks the performance impact more carefully + //$online = Channel::remote_online_status($rr['address']); + $online = ''; + + if (in_array($rr['hash'], $contacts)) { + $connect_link = ''; + } + + $location = ''; + if (strlen($rr['locale'])) { + $location .= $rr['locale']; + } + if (strlen($rr['region'])) { + if (strlen($rr['locale'])) { + $location .= ', '; + } + $location .= $rr['region']; + } + if (strlen($rr['country'])) { + if (strlen($location)) { + $location .= ', '; + } + $location .= $rr['country']; + } + + $age = ''; + if (strlen($rr['birthday'])) { + if (($years = age($rr['birthday'], 'UTC', '')) > 0) { + $age = $years; + } + } + + $page_type = ''; + + $total_ratings = ''; + + $profile = $rr; + + $gender = ((x($profile, 'gender') == 1) ? t('Gender: ') . $profile['gender'] : false); + $marital = ((x($profile, 'marital') == 1) ? t('Status: ') . $profile['marital'] : false); + $homepage = ((x($profile, 'homepage') == 1) ? t('Homepage: ') : false); + $homepageurl = ((x($profile, 'homepage') == 1) ? html2plain($profile['homepage']) : ''); + $hometown = ((x($profile, 'hometown') == 1) ? html2plain($profile['hometown']) : false); + $about = ((x($profile, 'about') == 1) ? zidify_links(bbcode($profile['about'])) : false); + + $keywords = ((x($profile, 'keywords')) ? $profile['keywords'] : ''); + + $out = ''; + + if ($keywords) { + $keywords = str_replace(',', ' ', $keywords); + $keywords = str_replace(' ', ' ', $keywords); + $karr = explode(' ', $keywords); + + if ($karr) { + if (local_channel()) { + $pk = q( + "select keywords from profile where uid = %d and is_default = 1 limit 1", + intval(local_channel()) + ); + if ($pk) { + $keywords = str_replace(',', ' ', $pk[0]['keywords']); + $keywords = str_replace(' ', ' ', $keywords); + $marr = explode(' ', $keywords); + } + } + foreach ($karr as $k) { + if (strlen($out)) { + $out .= ', '; + } + if ($marr && in_arrayi($k, $marr)) { + $out .= '' . $k . ''; + } else { + $out .= '' . $k . ''; + } + } + } + } + + $entry = [ + 'id' => ++$t, + 'profile_link' => $profile_link, + 'type' => $rr['type'], + 'photo' => $rr['photo'], + 'hash' => $rr['hash'], + 'alttext' => $rr['name'] . ((local_channel() || remote_channel()) ? ' ' . $rr['address'] : ''), + 'name' => $rr['name'], + 'age' => $age, + 'age_label' => t('Age:'), + 'profile' => $profile, + 'address' => $rr['address'], + 'nickname' => substr($rr['address'], 0, strpos($rr['address'], '@')), + 'location' => $location, + 'location_label' => t('Location:'), + 'gender' => $gender, + 'total_ratings' => $total_ratings, + 'viewrate' => true, + 'canrate' => (($rating_enabled && local_channel()) ? true : false), + // 'network' => network_to_name($rr['network']), + // 'network_label' => t('Network:'), + 'pdesc' => $pdesc, + 'pdesc_label' => t('Description:'), + 'censor' => (($directory_admin) ? 'dircensor/' . $rr['hash'] : ''), + 'censor_label' => (($rr['censored']) ? t('Uncensor') : t('Censor')), + 'marital' => $marital, + 'homepage' => $homepage, + 'homepageurl' => (($safe_mode) ? $homepageurl : linkify($homepageurl)), + 'hometown' => $hometown, + 'hometown_label' => t('Hometown:'), + 'about' => $about, + 'about_label' => t('About:'), + 'conn_label' => t('Connect'), + 'forum_label' => t('Group'), + 'collections_label' => t('Collection'), + 'connect' => $connect_link, + 'online' => $online, + 'kw' => (($out) ? t('Keywords: ') : ''), + 'keywords' => $out, + 'ignlink' => $suggest ? z_root() . '/directory?ignore=' . $rr['hash'] : '', + 'ignore_label' => t('Don\'t suggest'), + 'common_friends' => (($common[$rr['hash']]) ? intval($common[$rr['hash']]) : ''), + 'common_label' => t('Suggestion ranking:'), + 'common_count' => intval($common[$rr['hash']]), + 'safe' => $safe_mode + ]; + + + $blocked = LibBlock::fetch($channel['channel_id'], BLOCKTYPE_SERVER); + + $found_block = false; + if ($blocked) { + foreach ($blocked as $b) { + if (strpos($rr['site_url'], $b['block_entity']) !== false) { + $found_block = true; + break; + } + } + if ($found_block) { + continue; + } + } + + if (LibBlock::fetch_by_entity(local_channel(), $entry['hash'])) { + continue; + } + + $arr = array('contact' => $rr, 'entry' => $entry); + + Hook::call('directory_item', $arr); + + unset($profile); + unset($location); + + if (!$arr['entry']) { + continue; + } + + if ($sort_order == '' && $suggest) { + $entries[$addresses[$rr['address']]] = $arr['entry']; // Use the same indexes as originally to get the best suggestion first + } else { + $entries[] = $arr['entry']; + } + } + + ksort($entries); // Sort array by key so that foreach-constructs work as expected + + if ($j['keywords']) { + App::$data['directory_keywords'] = $j['keywords']; + } + + // logger('mod_directory: entries: ' . print_r($entries,true), LOGGER_DATA); + + + if ($_REQUEST['aj']) { + if ($entries) { + $o = replace_macros(Theme::get_template('directajax.tpl'), ['$entries' => $entries]); + } else { + $o = '
      '; + } + echo $o; + killme(); + } else { + $maxheight = 94; + + $dirtitle = (($globaldir) ? t('Directory') : t('Local Directory')); + + $o .= ""; + $o .= replace_macros($tpl, [ + '$search' => $search, + '$desc' => t('Find'), + '$finddsc' => t('Finding:'), + '$safetxt' => htmlspecialchars($search, ENT_QUOTES, 'UTF-8'), + '$entries' => $entries, + '$dirlbl' => $suggest ? t('Channel Suggestions') : $dirtitle, + '$submit' => t('Find'), + '$next' => alt_pager($j['records'], t('next page'), t('previous page')), + '$sort' => t('Sort options'), + '$normal' => t('Alphabetic'), + '$reverse' => t('Reverse Alphabetic'), + '$date' => t('Newest to Oldest'), + '$reversedate' => t('Oldest to Newest'), + '$suggest' => $suggest ? '&suggest=1' : '' + ]); + } + } else { + if ($_REQUEST['aj']) { + $o = '
      '; + echo $o; + killme(); + } + if ($search && App::$pager['page'] == 1 && $j['records'] == 0) { + if (strpos($search, '@')) { + goaway(z_root() . '/chanview/?f=&address=' . $search); + } elseif (strpos($search, 'http') === 0) { + goaway(z_root() . '/chanview/?f=&url=' . $search); + } else { + $r = q( + "select xchan_hash from xchan where xchan_name = '%s' limit 1", + dbesc($search) + ); + if ($r) { + goaway(z_root() . '/chanview/?f=&hash=' . urlencode($r[0]['xchan_hash'])); + } + } + } + info(t("No entries (some entries may be hidden).") . EOL); + } + } + } + } + return $o; + } + + + public static function reorder_results($results, $suggests) + { + if (!$suggests) { + return $results; + } + + $out = []; + foreach ($suggests as $k => $v) { + foreach ($results as $rv) { + if ($k == $rv['hash']) { + $out[intval($v)] = $rv; + break; + } + } + } + return $out; + } +} diff --git a/Code/Module/Dirsearch.php b/Code/Module/Dirsearch.php new file mode 100644 index 000000000..741639a4c --- /dev/null +++ b/Code/Module/Dirsearch.php @@ -0,0 +1,466 @@ + false]; + + // logger('request: ' . print_r($_REQUEST,true)); + + + if (argc() > 1 && argv(1) === 'sites') { + $ret = $this->list_public_sites(); + json_return_and_die($ret); + } + + $dirmode = intval(get_config('system', 'directory_mode')); + + + $network = EMPTY_STR; + + $sql_extra = ''; + + $tables = ['name', 'address', 'xhash', 'locale', 'region', 'postcode', + 'country', 'gender', 'marital', 'sexual', 'keywords']; + + // parse advanced query if present + + if ($_REQUEST['query']) { + $advanced = $this->dir_parse_query($_REQUEST['query']); + if ($advanced) { + foreach ($advanced as $adv) { + if (in_array($adv['field'], $tables)) { + if ($adv['field'] === 'name') { + $sql_extra .= $this->dir_query_build($adv['logic'], 'xchan_name', $adv['value']); + } elseif ($adv['field'] === 'address') { + $sql_extra .= $this->dir_query_build($adv['logic'], 'xchan_addr', $adv['value']); + } elseif ($adv['field'] === 'xhash') { + $sql_extra .= $this->dir_query_build($adv['logic'], 'xchan_hash', $adv['value']); + } else { + $sql_extra .= $this->dir_query_build($adv['logic'], 'xprof_' . $adv['field'], $adv['value']); + } + } + } + } + } + + $hash = ((x($_REQUEST['hash'])) ? $_REQUEST['hash'] : ''); + $name = ((x($_REQUEST, 'name')) ? $_REQUEST['name'] : ''); + $url = ((x($_REQUEST, 'url')) ? $_REQUEST['url'] : ''); + $hub = ((x($_REQUEST, 'hub')) ? $_REQUEST['hub'] : ''); + $address = ((x($_REQUEST, 'address')) ? $_REQUEST['address'] : ''); + $locale = ((x($_REQUEST, 'locale')) ? $_REQUEST['locale'] : ''); + $region = ((x($_REQUEST, 'region')) ? $_REQUEST['region'] : ''); + $postcode = ((x($_REQUEST, 'postcode')) ? $_REQUEST['postcode'] : ''); + $country = ((x($_REQUEST, 'country')) ? $_REQUEST['country'] : ''); + $gender = ((x($_REQUEST, 'gender')) ? $_REQUEST['gender'] : ''); + $marital = ((x($_REQUEST, 'marital')) ? $_REQUEST['marital'] : ''); + $sexual = ((x($_REQUEST, 'sexual')) ? $_REQUEST['sexual'] : ''); + $keywords = ((x($_REQUEST, 'keywords')) ? $_REQUEST['keywords'] : ''); + $agege = ((x($_REQUEST, 'agege')) ? intval($_REQUEST['agege']) : 0); + $agele = ((x($_REQUEST, 'agele')) ? intval($_REQUEST['agele']) : 0); + $kw = ((x($_REQUEST, 'kw')) ? intval($_REQUEST['kw']) : 0); + $active = ((x($_REQUEST, 'active')) ? intval($_REQUEST['active']) : 0); + $type = ((array_key_exists('type', $_REQUEST)) ? intval($_REQUEST['type']) : 0); + + // allow a site to disable the directory's keyword list + if (get_config('system', 'disable_directory_keywords')) { + $kw = 0; + } + + // by default use a safe search + $safe = ((x($_REQUEST, 'safe'))); + if ($safe === false) { + $safe = 1; + } + + // Directory mirrors will request sync packets, which are lists + // of records that have changed since the sync datetime. + + if (array_key_exists('sync', $_REQUEST)) { + if ($_REQUEST['sync']) { + $sync = datetime_convert('UTC', 'UTC', $_REQUEST['sync']); + } else { + $sync = datetime_convert('UTC', 'UTC', '2010-01-01 01:01:00'); + } + } else { + $sync = false; + } + + if (($dirmode == DIRECTORY_MODE_STANDALONE) && (!$hub)) { + $hub = App::get_hostname(); + } + + if ($hub) { + $hub_query = " and xchan_hash in (select hubloc_hash from hubloc where hubloc_host = '" . protect_sprintf(dbesc($hub)) . "') "; + } else { + $hub_query = ''; + } + + if ($url) { + $r = q( + "select xchan_name from hubloc left join xchan on hubloc_hash = xchan_hash where hubloc_url = '%s' or hubloc_id_url = '%s'", + dbesc($url), + dbesc($url) + ); + if ($r && $r[0]['xchan_name']) { + $name = $r[0]['xchan_name']; + } + } + + // The order identifier is validated further below + + $sort_order = ((x($_REQUEST, 'order')) ? $_REQUEST['order'] : ''); + + + // parse and assemble the query for advanced searches + + $joiner = ' OR '; + + if ($_REQUEST['and']) { + $joiner = ' AND '; + } + + if ($name) { + $sql_extra .= $this->dir_query_build($joiner, 'xchan_name', $name); + } + if ($address) { + $sql_extra .= $this->dir_query_build($joiner, 'xchan_addr', $address); + } + if ($hash) { + $sql_extra .= $this->dir_query_build($joiner, 'xchan_hash', $hash); + } + if ($locale) { + $sql_extra .= $this->dir_query_build($joiner, 'xprof_locale', $locale); + } + if ($region) { + $sql_extra .= $this->dir_query_build($joiner, 'xprof_region', $region); + } + if ($postcode) { + $sql_extra .= $this->dir_query_build($joiner, 'xprof_postcode', $postcode); + } + if ($country) { + $sql_extra .= $this->dir_query_build($joiner, 'xprof_country', $country); + } + if ($gender) { + $sql_extra .= $this->dir_query_build($joiner, 'xprof_gender', $gender); + } + if ($marital) { + $sql_extra .= $this->dir_query_build($joiner, 'xprof_marital', $marital); + } + if ($sexual) { + $sql_extra .= $this->dir_query_build($joiner, 'xprof_sexual', $sexual); + } + if ($keywords) { + $sql_extra .= $this->dir_query_build($joiner, 'xprof_keywords', $keywords); + } + + // we only support an age range currently. You must set both agege + // (greater than or equal) and agele (less than or equal) + + if ($agele && $agege) { + $sql_extra .= " $joiner ( xprof_age <= " . intval($agele) . " "; + $sql_extra .= " AND xprof_age >= " . intval($agege) . ") "; + } + + + $perpage = (($_REQUEST['n']) ? $_REQUEST['n'] : 60); + $page = (($_REQUEST['p']) ? intval($_REQUEST['p'] - 1) : 0); + $startrec = (($page + 1) * $perpage) - $perpage; + $limit = (($_REQUEST['limit']) ? intval($_REQUEST['limit']) : 0); + $return_total = ((x($_REQUEST, 'return_total')) ? intval($_REQUEST['return_total']) : 0); + + // mtime is not currently working + + $mtime = ((x($_REQUEST, 'mtime')) ? datetime_convert('UTC', 'UTC', $_REQUEST['mtime']) : ''); + + // merge them into xprof + + $ret['success'] = true; + + // If &limit=n, return at most n entries + // If &return_total=1, we count matching entries and return that as 'total_items' for use in pagination. + // By default we return one page (default 60 items maximum) and do not count total entries + + $logic = ((strlen($sql_extra)) ? 'false' : 'true'); + + if ($hash) { + $logic = 'true'; + } + + if ($dirmode == DIRECTORY_MODE_STANDALONE) { + $sql_extra .= " and xchan_addr like '%%" . App::get_hostname() . "' "; + } + + $safesql = (($safe > 0) ? " and xchan_censored = 0 and xchan_selfcensored = 0 " : ''); + if ($safe < 0) { + $safesql = " and ( xchan_censored = 1 OR xchan_selfcensored = 1 ) "; + } + + if ($type) { + $safesql .= " and xchan_type = " . intval($type); + } + + $activesql = EMPTY_STR; + + if ($active) { + $activesql = "and xchan_updated > '" . datetime_convert(date_default_timezone_get(), 'UTC', 'now - 60 days') . "' "; + } + + if ($limit) { + $qlimit = " LIMIT $limit "; + } else { + $qlimit = " LIMIT " . intval($perpage) . " OFFSET " . intval($startrec); + if ($return_total) { + $r = q("SELECT COUNT(xchan_hash) AS total FROM xchan left join xprof on xchan_hash = xprof_hash where $logic $sql_extra $network and xchan_hidden = 0 and xchan_orphan = 0 and xchan_deleted = 0 $safesql $activesql "); + if ($r) { + $ret['total_items'] = $r[0]['total']; + } + } + } + + if ($sort_order == 'normal') { + $order = " order by xchan_name asc "; + + // Start the alphabetic search at 'A' + // This will make a handful of channels whose names begin with + // punctuation un-searchable in this mode + + $safesql .= " and ascii(substring(xchan_name FROM 1 FOR 1)) > 64 "; + } elseif ($sort_order == 'reverse') { + $order = " order by xchan_name desc "; + } elseif ($sort_order == 'reversedate') { + $order = " order by xchan_name_date asc "; + } else { + $order = " order by xchan_name_date desc "; + } + + + // normal directory query + + $r = q("SELECT xchan.*, xprof.* from xchan left join xprof on xchan_hash = xprof_hash + where ( $logic $sql_extra ) $hub_query $network and xchan_hidden = 0 and xchan_orphan = 0 and xchan_deleted = 0 + $safesql $activesql $order $qlimit "); + + $ret['page'] = $page + 1; + $ret['records'] = count($r); + + if ($r) { + $entries = []; + $dups = []; + $isdup = EMPTY_STR; + + // Collect activitypub identities and query which also have zot6 identities. + // Do this once per page fetch rather than once per entry. + + foreach ($r as $rv) { + if ($rv['xchan_network'] === 'activitypub') { + if ($isdup) { + $isdup .= ','; + } + $isdup .= "'" . dbesc($rv['xchan_url']) . "'"; + } + if ($isdup) { + $isdup = protect_sprintf($isdup); + $z = q("select xchan_url, xchan_hash from xchan where xchan_url in ( $isdup ) and xchan_network in ('nomad','zot6')"); + if ($z) { + foreach ($z as $zv) { + $dups[$zv['xchan_url']] = $zv['xchan_hash']; + } + } + } + } + + foreach ($r as $rr) { + // If it's an activitypub record and the channel also has a zot6 address, don't return it. + + if (array_key_exists($rr['xchan_url'], $dups)) { + continue; + } + + if (!check_siteallowed($rr['xchan_url'])) { + continue; + } + + if (!check_channelallowed($rr['xchan_hash'])) { + continue; + } + + + $entry = []; + + $entry['name'] = $rr['xchan_name']; + $entry['hash'] = $rr['xchan_hash']; + $entry['censored'] = $rr['xchan_censored']; + $entry['selfcensored'] = $rr['xchan_selfcensored']; + $entry['type'] = $rr['xchan_type']; + $entry['url'] = $rr['xchan_url']; + $entry['photo_l'] = $rr['xchan_photo_l']; + $entry['photo'] = $rr['xchan_photo_m']; + $entry['address'] = $rr['xchan_addr']; + $entry['network'] = $rr['xchan_network']; + $entry['description'] = $rr['xprof_desc']; + $entry['locale'] = $rr['xprof_locale']; + $entry['region'] = $rr['xprof_region']; + $entry['postcode'] = $rr['xprof_postcode']; + $entry['country'] = $rr['xprof_country']; + $entry['birthday'] = $rr['xprof_dob']; + $entry['age'] = $rr['xprof_age']; + $entry['gender'] = $rr['xprof_gender']; + $entry['marital'] = $rr['xprof_marital']; + $entry['sexual'] = $rr['xprof_sexual']; + $entry['about'] = $rr['xprof_about']; + $entry['homepage'] = $rr['xprof_homepage']; + $entry['hometown'] = $rr['xprof_hometown']; + $entry['keywords'] = $rr['xprof_keywords']; + + $entries[] = $entry; + } + + $ret['results'] = $entries; + if ($kw) { + $k = dir_tagadelic($kw, $hub, $type, $safesql); + if ($k) { + $ret['keywords'] = []; + foreach ($k as $kv) { + $ret['keywords'][] = ['term' => $kv[0], 'weight' => $kv[1], 'normalise' => $kv[2]]; + } + } + } + } + json_return_and_die($ret); + } + + public function dir_query_build($joiner, $field, $s) + { + $ret = ''; + if (trim($s)) { + $ret .= dbesc($joiner) . " " . dbesc($field) . " like '" . protect_sprintf('%' . dbesc($s) . '%') . "' "; + } + return $ret; + } + + public function dir_flag_build($joiner, $field, $bit, $s) + { + return dbesc($joiner) . " ( " . dbesc($field) . " & " . intval($bit) . " ) " . ((intval($s)) ? '>' : '=') . " 0 "; + } + + + public function dir_parse_query($s) + { + + $ret = []; + $curr = []; + $all = explode(' ', $s); + $quoted_string = false; + + if ($all) { + foreach ($all as $q) { + if ($quoted_string === false) { + if ($q === 'and') { + $curr['logic'] = 'and'; + continue; + } + if ($q === 'or') { + $curr['logic'] = 'or'; + continue; + } + if ($q === 'not') { + $curr['logic'] .= ' not'; + continue; + } + if (strpos($q, '=')) { + if (!isset($curr['logic'])) { + $curr['logic'] = 'or'; + } + $curr['field'] = trim(substr($q, 0, strpos($q, '='))); + $curr['value'] = trim(substr($q, strpos($q, '=') + 1)); + if ($curr['value'][0] == '"' && $curr['value'][strlen($curr['value']) - 1] != '"') { + $quoted_string = true; + $curr['value'] = substr($curr['value'], 1); + continue; + } elseif ($curr['value'][0] == '"' && $curr['value'][strlen($curr['value']) - 1] == '"') { + $curr['value'] = substr($curr['value'], 1, strlen($curr['value']) - 2); + $ret[] = $curr; + $curr = []; + continue; + } else { + $ret[] = $curr; + $curr = []; + continue; + } + } + } else { + if ($q[strlen($q) - 1] == '"') { + $curr['value'] .= ' ' . str_replace('"', '', trim($q)); + $ret[] = $curr; + $curr = []; + $quoted_string = false; + } else { + $curr['value'] .= ' ' . trim($q); + } + } + } + } + logger('dir_parse_query:' . print_r($ret, true), LOGGER_DATA); + return $ret; + } + + + public function list_public_sites() + { + + $rand = db_getfunc('rand'); + $realm = get_directory_realm(); + + $r = q( + "select * from site where site_type = %d and site_dead = 0", + intval(SITE_TYPE_ZOT) + ); + + $ret = array('success' => false); + + if ($r) { + $ret['success'] = true; + $ret['sites'] = []; + + foreach ($r as $rr) { + if ($rr['site_access'] == ACCESS_FREE) { + $access = 'free'; + } elseif ($rr['site_access'] == ACCESS_PAID) { + $access = 'paid'; + } elseif ($rr['site_access'] == ACCESS_TIERED) { + $access = 'tiered'; + } else { + $access = 'private'; + } + + if ($rr['site_register'] == REGISTER_OPEN) { + $register = 'open'; + } elseif ($rr['site_register'] == REGISTER_APPROVE) { + $register = 'approve'; + } else { + $register = 'closed'; + } + + $ret['sites'][] = array('url' => $rr['site_url'], 'access' => $access, 'register' => $register, 'sellpage' => $rr['site_sellpage'], 'location' => $rr['site_location'], 'project' => $rr['site_project'], 'version' => $rr['site_version']); + } + } + return $ret; + } +} diff --git a/Code/Module/Display.php b/Code/Module/Display.php new file mode 100644 index 000000000..9d09b8584 --- /dev/null +++ b/Code/Module/Display.php @@ -0,0 +1,523 @@ +updating)); + + $module_format = 'html'; + + if (argc() > 1) { + $module_format = substr(argv(1), strrpos(argv(1), '.') + 1); + if (!in_array($module_format, ['atom', 'zot', 'json'])) { + $module_format = 'html'; + } + } + + if ($this->loading) { + $_SESSION['loadtime_display'] = datetime_convert(); + } + + if (observer_prohibited()) { + notice(t('Public access denied.') . EOL); + return; + } + + if (argc() > 1) { + $item_hash = argv(1); + if ($module_format !== 'html') { + $item_hash = substr($item_hash, 0, strrpos($item_hash, '.')); + } + } + + if ($_REQUEST['mid']) { + $item_hash = $_REQUEST['mid']; + } + + if (!$item_hash) { + App::$error = 404; + notice(t('Item not found.') . EOL); + return; + } + + $observer_is_owner = false; + $updateable = false; + + if (local_channel() && (!$this->updating)) { + $channel = App::get_channel(); + + $channel_acl = array( + 'allow_cid' => $channel['channel_allow_cid'], + 'allow_gid' => $channel['channel_allow_gid'], + 'deny_cid' => $channel['channel_deny_cid'], + 'deny_gid' => $channel['channel_deny_gid'] + ); + + $x = array( + 'is_owner' => true, + 'allow_location' => ((intval(get_pconfig($channel['channel_id'], 'system', 'use_browser_location'))) ? '1' : ''), + 'default_location' => $channel['channel_location'], + 'nickname' => $channel['channel_address'], + 'lockstate' => (($group || $cid || $channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 'lock' : 'unlock'), + 'acl' => Libacl::populate($channel_acl, true, PermissionDescription::fromGlobalPermission('view_stream'), Libacl::get_post_aclDialogDescription(), 'acl_dialog_post'), + 'permissions' => $channel_acl, + 'bang' => '', + 'visitor' => true, + 'profile_uid' => local_channel(), + 'return_path' => 'channel/' . $channel['channel_address'], + 'expanded' => true, + 'editor_autocomplete' => true, + 'bbco_autocomplete' => 'bbcode', + 'bbcode' => true, + 'jotnets' => true, + 'reset' => t('Reset form') + ); + + $o = '
      '; + $o .= status_editor($x); + $o .= '
      '; + } + + // This page can be viewed by anybody so the query could be complicated + // First we'll see if there is a copy of the item which is owned by us - if we're logged in locally. + // If that fails (or we aren't logged in locally), + // query an item in which the observer (if logged in remotely) has cid or gid rights + // and if that fails, look for a copy of the post that has no privacy restrictions. + // If we find the post, but we don't find a copy that we're allowed to look at, this fact needs to be reported. + + // find a copy of the item somewhere + + $target_item = null; + + $item_hash = unpack_link_id($item_hash); + + $r = q( + "select id, uid, mid, parent_mid, thr_parent, verb, item_type, item_deleted, author_xchan, item_blocked from item where mid like '%s' limit 1", + dbesc($item_hash . '%') + ); + + if ($r) { + $target_item = $r[0]; + } + + $x = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($target_item['author_xchan']) + ); + if ($x) { +// not yet ready for prime time +// \App::$poi = $x[0]; + } + + // if the item is to be moderated redirect to /moderate + if ($target_item['item_blocked'] == ITEM_MODERATED) { + goaway(z_root() . '/moderate/' . $target_item['id']); + } + + $r = null; + + if ($target_item['item_type'] == ITEM_TYPE_WEBPAGE) { + $x = q( + "select * from channel where channel_id = %d limit 1", + intval($target_item['uid']) + ); + $y = q( + "select * from iconfig left join item on iconfig.iid = item.id + where item.uid = %d and iconfig.cat = 'system' and iconfig.k = 'WEBPAGE' and item.id = %d limit 1", + intval($target_item['uid']), + intval($target_item['parent']) + ); + if ($x && $y) { + goaway(z_root() . '/page/' . $x[0]['channel_address'] . '/' . $y[0]['v']); + } else { + notice(t('Page not found.') . EOL); + return ''; + } + } + if ($target_item['item_type'] == ITEM_TYPE_ARTICLE) { + $x = q( + "select * from channel where channel_id = %d limit 1", + intval($target_item['uid']) + ); + $y = q( + "select * from iconfig left join item on iconfig.iid = item.id + where item.uid = %d and iconfig.cat = 'system' and iconfig.k = 'ARTICLE' and item.id = %d limit 1", + intval($target_item['uid']), + intval($target_item['parent']) + ); + if ($x && $y) { + goaway(z_root() . '/articles/' . $x[0]['channel_address'] . '/' . $y[0]['v']); + } else { + notice(t('Page not found.') . EOL); + return ''; + } + } + if ($target_item['item_type'] == ITEM_TYPE_CARD) { + $x = q( + "select * from channel where channel_id = %d limit 1", + intval($target_item['uid']) + ); + $y = q( + "select * from iconfig left join item on iconfig.iid = item.id + where item.uid = %d and iconfig.cat = 'system' and iconfig.k = 'CARD' and item.id = %d limit 1", + intval($target_item['uid']), + intval($target_item['parent']) + ); + if ($x && $y) { + goaway(z_root() . '/cards/' . $x[0]['channel_address'] . '/' . $y[0]['v']); + } else { + notice(t('Page not found.') . EOL); + return ''; + } + } + if ($target_item['item_type'] == ITEM_TYPE_CUSTOM) { + Hook::call('item_custom_display', $target_item); + notice(t('Page not found.') . EOL); + return ''; + } + + + $static = ((array_key_exists('static', $_REQUEST)) ? intval($_REQUEST['static']) : 0); + + + $simple_update = (($this->updating) ? " AND item_unseen = 1 " : ''); + + if ($this->updating && $_SESSION['loadtime_display']) { + $simple_update = " AND item.changed > '" . datetime_convert('UTC', 'UTC', $_SESSION['loadtime_display']) . "' "; + } + if ($this->loading) { + $simple_update = ''; + } + + if ($static && $simple_update) { + $simple_update .= " and item_thread_top = 0 and author_xchan = '" . protect_sprintf(get_observer_hash()) . "' "; + } + + if ((!$this->updating) && (!$this->loading)) { + $static = ((local_channel()) ? Channel::manual_conv_update(local_channel()) : 1); + + // if the target item is not a post (eg a like) we want to address its thread parent + + $mid = ((($target_item['verb'] == ACTIVITY_LIKE) || ($target_item['verb'] == ACTIVITY_DISLIKE)) ? $target_item['thr_parent'] : $target_item['mid']); + + // if we received a decoded hash originally we must encode it again before handing to javascript + + $mid = gen_link_id($mid); + + $o .= '
      ' . "\r\n"; + $o .= "\r\n"; + + App::$page['htmlhead'] .= replace_macros(Theme::get_template("build_query.tpl"), array( + '$baseurl' => z_root(), + '$pgtype' => 'display', + '$uid' => '0', + '$gid' => '0', + '$cid' => '0', + '$cmin' => '(-1)', + '$cmax' => '(-1)', + '$star' => '0', + '$liked' => '0', + '$conv' => '0', + '$spam' => '0', + '$fh' => '0', + '$dm' => '0', + '$nouveau' => '0', + '$wall' => '0', + '$draft' => '0', + '$static' => $static, + '$page' => ((App::$pager['page'] != 1) ? App::$pager['page'] : 1), + '$list' => ((x($_REQUEST, 'list')) ? intval($_REQUEST['list']) : 0), + '$search' => '', + '$xchan' => '', + '$order' => '', + '$file' => '', + '$cats' => '', + '$tags' => '', + '$dend' => '', + '$dbegin' => '', + '$verb' => '', + '$net' => '', + '$mid' => (($mid) ? urlencode($mid) : '') + )); + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/json+oembed', + 'href' => z_root() . '/oep?f=&url=' . urlencode(z_root() . '/' . App::$query_string), + 'title' => 'oembed' + ]); + } + + $observer_hash = get_observer_hash(); + $item_normal = item_normal(); + $item_normal_update = item_normal_update(); + + $sql_extra = ((local_channel()) ? EMPTY_STR : item_permissions_sql(0, $observer_hash)); + + if ($noscript_content || $this->loading) { + $r = null; + + require_once('include/channel.php'); + $sys = Channel::get_system(); + $sysid = $sys['channel_id']; + + if (local_channel()) { + $r = q( + "SELECT item.id as item_id from item WHERE uid = %d and mid = '%s' $item_normal limit 1", + intval(local_channel()), + dbesc($target_item['parent_mid']) + ); + if ($r) { + $updateable = true; + } + } + + if (!(is_array($r) && count($r))) { + $r = q( + "SELECT item.id as item_id from item WHERE mid = '%s' $sql_extra $item_normal limit 1", + dbesc($target_item['parent_mid']) + ); + } + } elseif ($this->updating && !$this->loading) { + $r = null; + + require_once('include/channel.php'); + $sys = Channel::get_system(); + $sysid = $sys['channel_id']; + + if (local_channel()) { + $r = q( + "SELECT item.parent AS item_id from item WHERE uid = %d and parent_mid = '%s' $item_normal_update $simple_update limit 1", + intval(local_channel()), + dbesc($target_item['parent_mid']) + ); + if ($r) { + $updateable = true; + } + } + + if (!$r) { + $r = q( + "SELECT item.parent AS item_id from item WHERE parent_mid = '%s' $sql_extra $item_normal_update $simple_update limit 1", + dbesc($target_item['parent_mid']) + ); + } + } else { + $r = []; + } + + if ($r) { + $parents_str = ids_to_querystr($r, 'item_id'); + if ($parents_str) { + $items = q( + "SELECT item.*, item.id AS item_id + FROM item + WHERE parent in ( %s ) $item_normal $sql_extra ", + dbesc($parents_str) + ); + xchan_query($items); + $items = fetch_post_tags($items, true); + $items = conv_sort($items, 'created'); + } + } else { + $items = []; + } + + // see if the top-level post owner chose to block search engines + + if ($items && get_pconfig($items[0]['uid'], 'system', 'noindex')) { + App::$meta->set('robots', 'noindex, noarchive'); + } + + foreach ($items as $item) { + if ($item['mid'] === $item_hash) { + if (preg_match("/\[[zi]mg(.*?)\]([^\[]+)/is", $items[0]['body'], $matches)) { + $ogimage = $matches[2]; + // Will we use og:image:type someday? We keep this just in case + // $ogimagetype = guess_image_type($ogimage); + } + + // some work on post content to generate a description + // almost fully based on work done on Hubzilla by Max Kostikov + $ogdesc = $item['body']; + + $ogdesc = bbcode($ogdesc, ['export' => true]); + $ogdesc = trim(html2plain($ogdesc, 0, true)); + $ogdesc = html_entity_decode($ogdesc, ENT_QUOTES, 'UTF-8'); + + // remove all URLs + $ogdesc = preg_replace("/https?\:\/\/[a-zA-Z0-9\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,\@]+/", "", $ogdesc); + + // shorten description + $ogdesc = substr($ogdesc, 0, 300); + $ogdesc = str_replace("\n", " ", $ogdesc); + while (strpos($ogdesc, " ") !== false) { + $ogdesc = str_replace(" ", " ", $ogdesc); + } + $ogdesc = (strlen($ogdesc) < 298 ? $ogdesc : rtrim(substr($ogdesc, 0, strrpos($ogdesc, " ")), "?.,:;!-") . "..."); + + $ogsite = (System::get_site_name()) ? escape_tags(System::get_site_name()) : System::get_platform_name(); + + // we can now start loading content + if ($item['mid'] == $item['parent_mid']) { + App::$meta->set('og:title', ($items[0]['title'] + ? sprintf(t('"%1$s", shared by %2$s with %3$s'), $items[0]['title'], $item['author']['xchan_name'], $ogsite) + : sprintf(t('%1$s shared this post with %2$s'), $item['author']['xchan_name'], $ogsite))); + App::$meta->set('og:image', (isset($ogimage) ? $ogimage : System::get_site_icon())); + App::$meta->set('og:type', 'article'); + App::$meta->set('og:url:secure_url', $item['llink']); + App::$meta->set('og:description', ($ogdesc ? $ogdesc : sprintf(t('Not much to read, click to see the post.')))); + } else { + if (($target_item['verb'] == ACTIVITY_LIKE) || ($target_item['verb'] == ACTIVITY_DISLIKE)) { + App::$meta->set('og:title', ($items[0]['title'] + ? sprintf(t('%1$s shared a reaction to "%2$s"'), $item['author']['xchan_name'], $items[0]['title']) + : sprintf(t('%s shared a reaction to this post/conversation'), $item['author']['xchan_name']))); + App::$meta->set('og:image', (isset($ogimage) ? $ogimage : System::get_site_icon())); + App::$meta->set('og:type', 'article'); + App::$meta->set('og:url:secure_url', $item['llink']); + App::$meta->set('og:description', $ogdesc); + } else { + App::$meta->set('og:title', ($items[0]['title'] + ? sprintf(t('%1$s commented "%2$s"'), $item['author']['xchan_name'], $items[0]['title']) + : sprintf(t('%s shared a comment of this post/conversation'), $item['author']['xchan_name']))); + App::$meta->set('og:image', (isset($ogimage) ? $ogimage : System::get_site_icon())); + App::$meta->set('og:type', 'article'); + App::$meta->set('og:url:secure_url', $item['llink']); + App::$meta->set('og:description', sprintf(t('%1$s wrote this: "%2$s"'), $item['author']['xchan_name'], $ogdesc)); + } + } + } + } + + if (local_channel() && $items) { + $ids = ids_to_array($items, 'item_id'); + $seen = PConfig::Get(local_channel(), 'system', 'seen_items'); + if (!$seen) { + $seen = []; + } + $seen = array_unique(array_merge($ids, $seen)); + PConfig::Set(local_channel(), 'system', 'seen_items', $seen); + } + + switch ($module_format) { + case 'html': + if ($this->updating) { + $o .= conversation($items, 'display', $this->updating, 'client'); + } else { + $o .= ''; + + App::$page['title'] = (($items[0]['title']) ? $items[0]['title'] . " - " . App::$page['title'] : App::$page['title']); + + $o .= conversation($items, 'display', $this->updating, 'client'); + } + + break; + + case 'atom': + $atom = replace_macros(Theme::get_template('atom_feed.tpl'), array( + '$version' => xmlify(System::get_project_version()), + '$generator' => xmlify(System::get_platform_name()), + '$generator_uri' => 'https://hubzilla.org', + '$feed_id' => xmlify(App::$cmd), + '$feed_title' => xmlify(t('Article')), + '$feed_updated' => xmlify(datetime_convert('UTC', 'UTC', 'now', ATOM_TIME)), + '$author' => '', + '$owner' => '', + '$profile_page' => xmlify(z_root() . '/display/' . $target_item['mid']), + )); + + $x = ['xml' => $atom, 'channel' => $channel, 'observer_hash' => $observer_hash, 'params' => $params]; + Hook::call('atom_feed_top', $x); + + $atom = $x['xml']; + + // a much simpler interface + Hook::call('atom_feed', $atom); + + + if ($items) { + $type = 'html'; + foreach ($items as $item) { + if ($item['item_private']) { + continue; + } + $atom .= atom_entry($item, $type, null, '', true, '', false); + } + } + + Hook::call('atom_feed_end', $atom); + + $atom .= '' . "\r\n"; + + header('Content-type: application/atom+xml'); + echo $atom; + killme(); + } + + if ($updateable) { + $x = q( + "UPDATE item SET item_unseen = 0 where item_unseen = 1 AND uid = %d and parent = %d ", + intval(local_channel()), + intval($r[0]['item_id']) + ); + } + + $o .= '
      '; + + if ((($this->updating && $this->loading) || $noscript_content) && (!$items)) { + $r = q( + "SELECT id, item_deleted FROM item WHERE mid = '%s' LIMIT 1", + dbesc($item_hash) + ); + + if ($r) { + if (intval($r[0]['item_deleted'])) { + notice(t('Item has been removed.') . EOL); + } else { + notice(t('Permission denied.') . EOL); + } + } else { + notice(t('Item not found.') . EOL); + } + } + + return $o; + } +} diff --git a/Code/Module/Drafts.php b/Code/Module/Drafts.php new file mode 100644 index 000000000..82b5ee71a --- /dev/null +++ b/Code/Module/Drafts.php @@ -0,0 +1,31 @@ +' . $desc . ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Drafts'))) { + return $text; + } + } +} diff --git a/Code/Module/Dreport.php b/Code/Module/Dreport.php new file mode 100644 index 000000000..bf3829022 --- /dev/null +++ b/Code/Module/Dreport.php @@ -0,0 +1,199 @@ + 2) { + $cmd = argv(1); + $mid = argv(2); + } elseif (argc() > 1) { + $cmd = EMPTY_STR; + $mid = argv(1); + } + + $message_id = unpack_link_id($mid); + + if (!$message_id) { + notice(t('Invalid message') . EOL); + return; + } + + if ($cmd === 'push') { + $i = q( + "select id from item where mid = '%s' and uid = %d and ( author_xchan = '%s' or ( owner_xchan = '%s' and item_wall = 1 )) ", + dbesc($message_id), + intval($channel['channel_id']), + dbesc($channel['channel_hash']), + dbesc($channel['channel_hash']) + ); + if ($i) { + Run::Summon(['Notifier', 'edit_post', $i[0]['id']]); + } + sleep(3); + goaway(z_root() . '/dreport/' . urlencode($mid)); + } + + if ($cmd === 'log') { + $host = escape_tags($_REQUEST['host']); + $r = q( + "select * from dreport where dreport_xchan = '%s' and dreport_mid = '%s' and dreport_recip = '%s'", + dbesc($channel['channel_hash']), + dbesc($message_id), + dbesc($host) + ); + if ($r) { + $output = '

      ' . t('Delivery Log') . '

      '; + $output .= EOL . $r[0]['dreport_log']; + return $output; + } else { + notice(t('Item not found') . EOL); + return; + } + } + + switch ($table) { + case 'item': + $i = q( + "select id from item where mid = '%s' and ( author_xchan = '%s' or ( owner_xchan = '%s' and item_wall = 1 )) ", + dbesc($message_id), + dbesc($channel['channel_hash']), + dbesc($channel['channel_hash']) + ); + break; + default: + break; + } + + if (!$i) { + notice(t('Permission denied') . EOL); + return; + } + + $r = q( + "select * from dreport where dreport_xchan = '%s' and dreport_mid = '%s'", + dbesc($channel['channel_hash']), + dbesc($message_id) + ); + + if (!$r) { + notice(t('no results') . EOL); + } + + for ($x = 0; $x < count($r); $x++) { + // This has two purposes: 1. make the delivery report strings translateable, and + // 2. assign an ordering to item delivery results so we can group them and provide + // a readable report with more interesting events listed toward the top and lesser + // interesting items towards the bottom + + switch ($r[$x]['dreport_result']) { + case 'channel sync processed': + $r[$x]['gravity'] = 0; + $r[$x]['dreport_result'] = t('channel sync processed'); + break; + case 'queued': + $r[$x]['gravity'] = 2; + $r[$x]['dreport_result'] = '' . t('queued') . ''; + break; + case 'site dead': + $r[$x]['gravity'] = 3; + $r[$x]['dreport_result'] = t('site dead'); + break; + case 'site deferred': + $r[$x]['gravity'] = 4; + $r[$x]['dreport_result'] = t('site might be dead - deferred'); + break; + case 'posted': + $r[$x]['gravity'] = 5; + $r[$x]['dreport_result'] = t('posted'); + break; + case 'accepted for delivery': + $r[$x]['gravity'] = 6; + $r[$x]['dreport_result'] = t('accepted for delivery'); + break; + case 'updated': + $r[$x]['gravity'] = 7; + $r[$x]['dreport_result'] = t('updated'); + case 'update ignored': + $r[$x]['gravity'] = 8; + $r[$x]['dreport_result'] = t('update ignored'); + break; + case 'permission denied': + $r[$x]['dreport_result'] = t('permission denied'); + $r[$x]['gravity'] = 9; + break; + case 'recipient not found': + $r[$x]['dreport_result'] = t('recipient not found'); + break; + case 'mail recalled': + $r[$x]['dreport_result'] = t('mail recalled'); + break; + case 'duplicate mail received': + $r[$x]['dreport_result'] = t('duplicate mail received'); + break; + case 'mail delivered': + $r[$x]['dreport_result'] = t('mail delivered'); + break; + default: + if (strpos($r[$x]['dreport_result'], 'delivery rejected') === 0) { + $r[$x]['dreport_result'] = t('delivery rejected') . ' ' . substr($r[$x]['dreport_result'], 17); + } + $r[$x]['gravity'] = 1; + break; + } + } + + usort($r, 'self::dreport_gravity_sort'); + + $entries = []; + foreach ($r as $rr) { + $entries[] = [ + 'name' => escape_tags($rr['dreport_name'] ?: $rr['dreport_recip']), + 'result' => $rr['dreport_result'], + 'time' => escape_tags(datetime_convert('UTC', date_default_timezone_get(), $rr['dreport_time'])) + ]; + } + + $output = replace_macros(Theme::get_template('dreport.tpl'), array( + '$title' => sprintf(t('Delivery report for %1$s'), basename($message_id)) . '...', + '$table' => $table, + '$mid' => urlencode($mid), + '$options' => t('Options'), + '$push' => t('Redeliver'), + '$entries' => $entries + )); + + + return $output; + } + + private static function dreport_gravity_sort($a, $b) + { + if ($a['gravity'] == $b['gravity']) { + if ($a['dreport_name'] === $b['dreport_name']) { + return strcmp($a['dreport_time'], $b['dreport_time']); + } + return strcmp($a['dreport_name'], $b['dreport_name']); + } + return (($a['gravity'] > $b['gravity']) ? 1 : (-1)); + } +} diff --git a/Code/Module/Editblock.php b/Code/Module/Editblock.php new file mode 100644 index 000000000..7407fd584 --- /dev/null +++ b/Code/Module/Editblock.php @@ -0,0 +1,162 @@ + 1 && argv(1) === 'sys' && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + App::$is_sys = true; + } + } + + if (argc() > 1) { + $which = argv(1); + } else { + return; + } + + Libprofile::load($which); + } + + public function get() + { + + if (!App::$profile) { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + + $which = argv(1); + + $uid = local_channel(); + $owner = 0; + $channel = null; + $observer = App::get_observer(); + + $channel = App::get_channel(); + + if (App::$is_sys && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + $uid = $owner = intval($sys['channel_id']); + $channel = $sys; + $observer = $sys; + } + } + + if (!$owner) { + // Figure out who the page owner is. + $r = q( + "select channel_id from channel where channel_address = '%s'", + dbesc($which) + ); + if ($r) { + $owner = intval($r[0]['channel_id']); + } + } + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + if (!perm_is_allowed($owner, $ob_hash, 'write_pages')) { + notice(t('Permission denied.') . EOL); + return; + } + + $is_owner = (($uid && $uid == $owner) ? true : false); + + $o = ''; + + // Figure out which post we're editing + $post_id = ((argc() > 2) ? intval(argv(2)) : 0); + + if (!($post_id && $owner)) { + notice(t('Item not found') . EOL); + return; + } + + $itm = q( + "SELECT * FROM item WHERE id = %d and uid = %s LIMIT 1", + intval($post_id), + intval($owner) + ); + if ($itm) { + $item_id = q( + "select * from iconfig where cat = 'system' and k = 'BUILDBLOCK' and iid = %d limit 1", + intval($itm[0]['id']) + ); + if ($item_id) { + $block_title = $item_id[0]['v']; + } + } else { + notice(t('Item not found') . EOL); + return; + } + + $mimetype = $itm[0]['mimetype']; + + $content = $itm[0]['body']; + if ($itm[0]['mimetype'] === 'text/markdown') { + $content = MarkdownSoap::unescape($itm[0]['body']); + } + + + $rp = 'blocks/' . $channel['channel_address']; + + $x = array( + 'nickname' => $channel['channel_address'], + 'bbco_autocomplete'=> ((in_array($mimetype, [ 'text/bbcode', 'text/x-multicode' ])) ? 'bbcode' : 'comanche-block'), + 'return_path' => $rp, + 'webpage' => ITEM_TYPE_BLOCK, + 'ptlabel' => t('Block Name'), + 'button' => t('Edit'), + 'writefiles' => ((in_array($mimetype, [ 'text/bbcode', 'text/x-multicode' ])) ? perm_is_allowed($owner, get_observer_hash(), 'write_storage') : false), + 'weblink' => ((in_array($mimetype, [ 'text/bbcode' , 'text/x-multicode' ])) ? t('Insert web link') : false), + 'hide_voting' => true, + 'hide_future' => true, + 'hide_location' => true, + 'hide_expire' => true, + 'showacl' => false, + 'ptyp' => $itm[0]['type'], + 'mimeselect' => true, + 'mimetype' => $itm[0]['mimetype'], + 'body' => undo_post_tagging($content), + 'post_id' => $post_id, + 'visitor' => true, + 'title' => htmlspecialchars($itm[0]['title'],ENT_COMPAT,'UTF-8'), + 'placeholdertitle' => t('Title (optional)'), + 'pagetitle' => $block_title, + 'profile_uid' => (intval($channel['channel_id'])), + 'bbcode' => ((in_array($mimetype, [ 'text/bbcode' , 'text/x-multicode' ])) ? true : false) + ); + + $editor = status_editor($x); + + $o .= replace_macros(Theme::get_template('edpost_head.tpl'), array( + '$title' => t('Edit Block'), + '$delete' => ((($itm[0]['author_xchan'] === $ob_hash) || ($itm[0]['owner_xchan'] === $ob_hash)) ? t('Delete') : false), + '$id' => $itm[0]['id'], + '$cancel' => t('Cancel'), + '$editor' => $editor + )); + + return $o; + } +} diff --git a/Code/Module/Editlayout.php b/Code/Module/Editlayout.php new file mode 100644 index 000000000..5cd3915ff --- /dev/null +++ b/Code/Module/Editlayout.php @@ -0,0 +1,159 @@ + 1 && argv(1) === 'sys' && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + App::$is_sys = true; + } + } + + if (argc() > 1) { + $which = argv(1); + } else { + return; + } + + Libprofile::load($which); + } + + public function get() + { + + if (!App::$profile) { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + + $which = argv(1); + + $uid = local_channel(); + $owner = 0; + $channel = null; + $observer = App::get_observer(); + + $channel = App::get_channel(); + + if (App::$is_sys && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + $uid = $owner = intval($sys['channel_id']); + $channel = $sys; + $observer = $sys; + } + } + + if (!$owner) { + // Figure out who the page owner is. + $r = q( + "select channel_id from channel where channel_address = '%s'", + dbesc($which) + ); + if ($r) { + $owner = intval($r[0]['channel_id']); + } + } + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + if (!perm_is_allowed($owner, $ob_hash, 'write_pages')) { + notice(t('Permission denied.') . EOL); + return; + } + + $is_owner = (($uid && $uid == $owner) ? true : false); + + $o = ''; + + // Figure out which post we're editing + $post_id = ((argc() > 2) ? intval(argv(2)) : 0); + + if (!$post_id) { + notice(t('Item not found') . EOL); + return; + } + + // Now we've got a post and an owner, let's find out if we're allowed to edit it + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $perms = get_all_perms($owner, $ob_hash); + + if (!$perms['write_pages']) { + notice(t('Permission denied.') . EOL); + return; + } + + $itm = q( + "SELECT * FROM item WHERE id = %d and uid = %s LIMIT 1", + intval($post_id), + intval($owner) + ); + + $item_id = q( + "select * from iconfig where cat = 'system' and k = 'PDL' and iid = %d limit 1", + intval($itm[0]['id']) + ); + if ($item_id) { + $layout_title = $item_id[0]['v']; + } + + + $rp = 'layouts/' . $which; + + $x = array( + 'webpage' => ITEM_TYPE_PDL, + 'nickname' => $channel['channel_address'], + 'editor_autocomplete' => true, + 'bbco_autocomplete' => 'comanche', + 'return_path' => $rp, + 'button' => t('Edit'), + 'hide_voting' => true, + 'hide_future' => true, + 'hide_expire' => true, + 'hide_location' => true, + 'hide_weblink' => true, + 'hide_attach' => true, + 'hide_preview' => true, + 'disable_comments' => true, + 'ptyp' => $itm[0]['obj_type'], + 'body' => undo_post_tagging($itm[0]['body']), + 'post_id' => $post_id, + 'title' => htmlspecialchars($itm[0]['title'], ENT_COMPAT, 'UTF-8'), + 'pagetitle' => $layout_title, + 'ptlabel' => t('Layout Name'), + 'placeholdertitle' => t('Layout Description (Optional)'), + 'showacl' => false, + 'profile_uid' => intval($owner), + ); + + $editor = status_editor($x); + + $o .= replace_macros(Theme::get_template('edpost_head.tpl'), array( + '$title' => t('Edit Layout'), + '$delete' => ((($itm[0]['author_xchan'] === $ob_hash) || ($itm[0]['owner_xchan'] === $ob_hash)) ? t('Delete') : false), + '$id' => $itm[0]['id'], + '$cancel' => t('Cancel'), + '$editor' => $editor + )); + + return $o; + } +} diff --git a/Code/Module/Editpost.php b/Code/Module/Editpost.php new file mode 100644 index 000000000..d39dc2c6e --- /dev/null +++ b/Code/Module/Editpost.php @@ -0,0 +1,155 @@ + 1) ? intval(argv(1)) : 0); + + if (!$post_id) { + notice(t('Item not found') . EOL); + return; + } + + $item = q( + "SELECT * FROM item WHERE id = %d AND ( owner_xchan = '%s' OR author_xchan = '%s' ) LIMIT 1", + intval($post_id), + dbesc(get_observer_hash()), + dbesc(get_observer_hash()) + ); + + // don't allow web editing of potentially binary content (item_obscured = 1) + + if ((!$item) || intval($item[0]['item_obscured'])) { + notice(t('Item is not editable') . EOL); + return; + } + + $item = array_shift($item); + + $owner_uid = intval($item['uid']); + $owner = Channel::from_id($owner_uid); + + if ($item['resource_type'] === 'photo' && $item['resource_id'] && $owner) { + goaway(z_root() . '/photos/' . $owner['channel_address'] . '/image/' . $item['resource_id'] . '?expandform=1'); + } + + if ($item['resource_type'] === 'event' && $item['resource_id']) { + goaway(z_root() . '/events/' . $item['resource_id'] . '?expandform=1'); + } + + + $channel = App::get_channel(); + + $category = ''; + $collections = []; + $catsenabled = ((Apps::system_app_installed($owner_uid, 'Categories')) ? 'categories' : ''); + + // we have a single item, but fetch_post_tags expects an array. Convert it before and after. + + $item = array_shift(fetch_post_tags([$item])); + + if ($catsenabled) { + $cats = get_terms_oftype($item['term'], TERM_CATEGORY); + + if ($cats) { + foreach ($cats as $cat) { + if (strlen($category)) { + $category .= ', '; + } + $category .= $cat['term']; + } + } + } + + $clcts = get_terms_oftype($item['term'], TERM_PCATEGORY); + if ($clcts) { + foreach ($clcts as $clct) { + $collections[] = $clct['term']; + } + } + + if ($item['attach']) { + $j = json_decode($item['attach'], true); + if ($j) { + foreach ($j as $jj) { + $item['body'] .= "\n" . '[attachment]' . basename($jj['href']) . ',' . $jj['revision'] . '[/attachment]' . "\n"; + } + } + } + + if (intval($item['item_unpublished'])) { + // clear the old creation date if editing a saved draft. These will always show as just created. + unset($item['created']); + } + + if ($item['summary']) { + $item['body'] = '[summary]' . $item['summary'] . '[/summary]' . "\n\n" . $item['body']; + } + + $x = [ + 'nickname' => $channel['channel_address'], + 'item' => $item, + 'editor_autocomplete' => true, + 'bbco_autocomplete' => 'bbcode', + 'return_path' => $_SESSION['return_url'], + 'button' => t('Submit'), + 'hide_voting' => true, + 'hide_future' => true, + 'hide_location' => true, + 'is_draft' => ((intval($item['item_unpublished'])) ? true : false), + 'parent' => (($item['mid'] === $item['parent_mid']) ? 0 : $item['parent']), + 'mimetype' => $item['mimetype'], + 'ptyp' => $item['obj_type'], + 'body' => htmlspecialchars_decode(undo_post_tagging($item['body']), ENT_COMPAT), + 'post_id' => $post_id, + 'defloc' => $channel['channel_location'], + 'visitor' => true, + 'title' => htmlspecialchars_decode($item['title'], ENT_COMPAT), + 'category' => $category, + 'showacl' => ((intval($item['item_unpublished'])) ? true : false), + 'lockstate' => (($item['allow_cid'] || $item['allow_gid'] || $item['deny_cid'] || $item['deny_gid']) ? 'lock' : 'unlock'), + 'acl' => Libacl::populate($item, true, PermissionDescription::fromGlobalPermission('view_stream'), Libacl::get_post_aclDialogDescription(), 'acl_dialog_post'), + 'bang' => EMPTY_STR, + 'permissions' => $item, + 'profile_uid' => $owner_uid, + 'catsenabled' => $catsenabled, + 'collections' => $collections, + 'jotnets' => true, + 'hide_expire' => true, + 'bbcode' => true + ]; + + $editor = status_editor($x); + + $output .= replace_macros( + Theme::get_template('edpost_head.tpl'), + ['$title' => t('Edit post'), '$cancel' => t('Cancel'), '$editor' => $editor] + ); + + return $output; + } +} diff --git a/Code/Module/Editwebpage.php b/Code/Module/Editwebpage.php new file mode 100644 index 000000000..915aa9507 --- /dev/null +++ b/Code/Module/Editwebpage.php @@ -0,0 +1,193 @@ + 1 && argv(1) === 'sys' && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + App::$is_sys = true; + } + } + + if (argc() > 1) { + $which = argv(1); + } else { + return; + } + + Libprofile::load($which); + } + + public function get() + { + + if (!App::$profile) { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + + $which = argv(1); + + $uid = local_channel(); + $owner = 0; + $channel = null; + $observer = App::get_observer(); + + $channel = App::get_channel(); + + if (App::$is_sys && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + $uid = $owner = intval($sys['channel_id']); + $channel = $sys; + $observer = $sys; + } + } + + if (!$owner) { + // Figure out who the page owner is. + $r = q( + "select channel_id from channel where channel_address = '%s'", + dbesc($which) + ); + if ($r) { + $owner = intval($r[0]['channel_id']); + } + } + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + if (!perm_is_allowed($owner, $ob_hash, 'write_pages')) { + notice(t('Permission denied.') . EOL); + return; + } + + $is_owner = (($uid && $uid == $owner) ? true : false); + + $o = ''; + + // Figure out which post we're editing + $post_id = ((argc() > 2) ? intval(argv(2)) : 0); + + if (!$post_id) { + notice(t('Item not found') . EOL); + return; + } + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $perms = get_all_perms($owner, $ob_hash); + + if (!$perms['write_pages']) { + notice(t('Permission denied.') . EOL); + return; + } + + // We've already figured out which item we want and whose copy we need, + // so we don't need anything fancy here + + $sql_extra = item_permissions_sql($owner); + + $itm = q( + "SELECT * FROM item WHERE id = %d and uid = %s $sql_extra LIMIT 1", + intval($post_id), + intval($owner) + ); + + // don't allow web editing of potentially binary content (item_obscured = 1) + // @FIXME how do we do it instead? + + if ((!$itm) || intval($itm[0]['item_obscured'])) { + notice(t('Permission denied.') . EOL); + return; + } + + $item_id = q( + "select * from iconfig where cat = 'system' and k = 'WEBPAGE' and iid = %d limit 1", + intval($itm[0]['id']) + ); + if ($item_id) { + $page_title = urldecode($item_id[0]['v']); + } + + $mimetype = $itm[0]['mimetype']; + + if ($mimetype === 'application/x-php') { + if ((!$uid) || ($uid != $itm[0]['uid'])) { + notice(t('Permission denied.') . EOL); + return; + } + } + + $layout = $itm[0]['layout_mid']; + + $content = $itm[0]['body']; + if ($itm[0]['mimetype'] === 'text/markdown') { + $content = MarkdownSoap::unescape($itm[0]['body']); + } + + $rp = 'webpages/' . $which; + + $x = array( + 'nickname' => $channel['channel_address'], + 'bbco_autocomplete' => ((in_array($mimetype, [ 'text/bbcode', 'text/x-multicode'])) ? 'bbcode' : ''), + 'return_path' => $rp, + 'webpage' => ITEM_TYPE_WEBPAGE, + 'ptlabel' => t('Page link'), + 'pagetitle' => $page_title, + 'writefiles' => ((in_array($mimetype, [ 'text/bbcode', 'text/x-multicode'])) ? perm_is_allowed($owner, get_observer_hash(), 'write_storage') : false), + 'button' => t('Edit'), + 'weblink' => ((in_array($mimetype, [ 'text/bbcode', 'text/x-multicode'])) ? t('Insert web link') : false), + 'hide_location' => true, + 'hide_voting' => true, + 'ptyp' => $itm[0]['type'], + 'body' => undo_post_tagging($content), + 'post_id' => $post_id, + 'visitor' => ($is_owner) ? true : false, + 'acl' => Libacl::populate($itm[0], false, PermissionDescription::fromGlobalPermission('view_pages')), + 'permissions' => $itm[0], + 'showacl' => ($is_owner) ? true : false, + 'mimetype' => $mimetype, + 'mimeselect' => true, + 'layout' => $layout, + 'layoutselect' => true, + 'title' => htmlspecialchars($itm[0]['title'], ENT_COMPAT, 'UTF-8'), + 'lockstate' => (((strlen($itm[0]['allow_cid'])) || (strlen($itm[0]['allow_gid'])) || (strlen($itm[0]['deny_cid'])) || (strlen($itm[0]['deny_gid']))) ? 'lock' : 'unlock'), + 'profile_uid' => (intval($owner)), + 'bbcode' => ((in_array($mimetype, ['text/bbcode', 'text/x-multicode'])) ? true : false) + ); + + $editor = status_editor($x); + + $o .= replace_macros(Theme::get_template('edpost_head.tpl'), array( + '$title' => t('Edit Webpage'), + '$delete' => ((($itm[0]['author_xchan'] === $ob_hash) || ($itm[0]['owner_xchan'] === $ob_hash)) ? t('Delete') : false), + '$editor' => $editor, + '$cancel' => t('Cancel'), + '$id' => $itm[0]['id'] + )); + + return $o; + } +} diff --git a/Code/Module/Email_resend.php b/Code/Module/Email_resend.php new file mode 100644 index 000000000..44b30cdb0 --- /dev/null +++ b/Code/Module/Email_resend.php @@ -0,0 +1,45 @@ + 1) { + $result = false; + $email = hex2bin(argv(1)); + + if ($email) { + $result = Account::verify_email_address(['resend' => true, 'email' => $email]); + } + + if ($result) { + notice(t('Email verification resent')); + } else { + notice(t('Unable to resend email verification message.')); + } + + goaway(z_root() . '/email_validation/' . bin2hex($email)); + } + + // @todo - one can provide a form here to resend the mail + // after directing to here if a succesful login was attempted from an unverified address. + } +} diff --git a/Code/Module/Email_validation.php b/Code/Module/Email_validation.php new file mode 100644 index 000000000..960fbe1b0 --- /dev/null +++ b/Code/Module/Email_validation.php @@ -0,0 +1,54 @@ + 1) { + $email = hex2bin(argv(1)); + } + + $o = replace_macros(Theme::get_template('email_validation.tpl'), [ + '$title' => t('Email Verification Required'), + '$desc' => sprintf(t('A verification token was sent to your email address [%s]. Enter that token here to complete the account verification step. Please allow a few minutes for delivery, and check your spam folder if you do not see the message.'), $email), + '$resend' => t('Resend Email'), + '$email' => bin2hex($email), + '$submit' => t('Submit'), + '$token' => ['token', t('Validation token'), '', ''], + ]); + + return $o; + } +} diff --git a/Code/Module/Embed.php b/Code/Module/Embed.php new file mode 100644 index 000000000..1e62e0777 --- /dev/null +++ b/Code/Module/Embed.php @@ -0,0 +1,24 @@ + 1) ? intval(argv(1)) : 0); + + if ($post_id && local_channel()) { + echo '[share=' . $post_id . '][/share]'; + } + killme(); + } +} diff --git a/Code/Module/Embedphotos.php b/Code/Module/Embedphotos.php new file mode 100644 index 000000000..97718164f --- /dev/null +++ b/Code/Module/Embedphotos.php @@ -0,0 +1,248 @@ + 2 && is_site_admin() && intval(argv(2))) { + $channel_id = argv(2); + } else { + $channel_id = local_channel(); + } + if (argc() > 1 && argv(1) === 'album') { + // API: /embedphotos/album + $name = (x($_POST, 'name') ? $_POST['name'] : null); + if (!$name) { + json_return_and_die(array('errormsg' => 'Error retrieving album', 'status' => false)); + } + $album = $this->embedphotos_widget_album(array('channel_id' => $channel_id, 'album' => $name)); + json_return_and_die(array('status' => true, 'content' => $album)); + } + if (argc() > 1 && argv(1) === 'albumlist') { + // API: /embedphotos/albumlist + $album_list = $this->embedphotos_album_list($channel_id); + json_return_and_die(array('status' => true, 'albumlist' => $album_list)); + } + if (argc() > 1 && argv(1) === 'photolink') { + // API: /embedphotos/photolink + $href = (x($_POST, 'href') ? $_POST['href'] : null); + if (!$href) { + json_return_and_die(array('errormsg' => 'Error retrieving link ' . $href, 'status' => false)); + } + $resource_id = array_pop(explode("/", $href)); + + $x = self::photolink($resource_id, $channel_id); + if ($x) { + json_return_and_die(array('status' => true, 'photolink' => $x, 'resource_id' => $resource_id)); + } + json_return_and_die(array('errormsg' => 'Error retrieving resource ' . $resource_id, 'status' => false)); + } + } + + + protected static function photolink($resource, $channel_id = 0) + { + if (intval($channel_id)) { + $channel = Channel::from_id($channel_id); + } else { + $channel = App::get_channel(); + } + + $output = EMPTY_STR; + if ($channel) { + $resolution = ((Features::enabled($channel['channel_id'], 'large_photos')) ? 1 : 2); + $r = q( + "select mimetype, height, width, title from photo where resource_id = '%s' and $resolution = %d and uid = %d limit 1", + dbesc($resource), + intval($resolution), + intval($channel['channel_id']) + ); + if (!$r) { + return $output; + } + + if ($r[0]['mimetype'] === 'image/jpeg') { + $ext = '.jpg'; + } elseif ($r[0]['mimetype'] === 'image/png') { + $ext = '.png'; + } elseif ($r[0]['mimetype'] === 'image/gif') { + $ext = '.gif'; + } else { + $ext = EMPTY_STR; + } + + $alt = $r[0]['title']; + if (!$alt) { + $a = q( + "select filename from attach where hash = '%s' and uid = %d limit 1", + dbesc($resource), + intval($channel['channel_id']) + ); + if ($a) { + $alt = $a[0]['filename']; + } else { + $alt = t('Image/photo'); + } + } + $alt = ' alt="' . $alt . '"'; + + $output = '[zrl=' . z_root() . '/photos/' . $channel['channel_address'] . '/image/' . $resource . ']' + . '[zmg width="' . $r[0]['width'] . '" height="' . $r[0]['height'] . '"' . $alt . ']' + . z_root() . '/photo/' . $resource . '-' . $resolution . $ext . '[/zmg][/zrl]'; + + return $output; + } + } + + + /** + * Copied from include/widgets.php::widget_album() with a modification to get the profile_uid from + * the input array as in widget_item() + * + * @param array $args + * @return string with HTML + */ + + public function embedphotos_widget_album($args) + { + + $channel_id = 0; + if (array_key_exists('channel_id', $args)) { + $channel_id = $args['channel_id']; + $channel = Channel::from_id($channel_id); + } + + if (!$channel_id) { + return ''; + } + + $owner_uid = $channel_id; + require_once('include/security.php'); + $sql_extra = permissions_sql($channel_id); + + if (!perm_is_allowed($channel_id, get_observer_hash(), 'view_storage')) { + return ''; + } + + if ($args['album']) { + $album = (($args['album'] === '/') ? '' : $args['album']); + } + + if ($args['title']) { + $title = $args['title']; + } + + /** + * This may return incorrect permissions if you have multiple directories of the same name. + * It is a limitation of the photo table using a name for a photo album instead of a folder hash + */ + if ($album) { + $x = q( + "select hash from attach where filename = '%s' and uid = %d limit 1", + dbesc($album), + intval($owner_uid) + ); + if ($x) { + $y = attach_can_view_folder($owner_uid, get_observer_hash(), $x[0]['hash']); + if (!$y) { + return ''; + } + } + } + + $order = 'DESC'; + + $r = q( + "SELECT p.resource_id, p.id, p.filename, p.mimetype, p.imgscale, p.description, p.created FROM photo p INNER JOIN + (SELECT resource_id, max(imgscale) imgscale FROM photo WHERE uid = %d AND album = '%s' AND imgscale <= 4 + AND photo_usage IN ( %d, %d ) $sql_extra GROUP BY resource_id) ph + ON (p.resource_id = ph.resource_id AND p.imgscale = ph.imgscale) + ORDER BY created $order", + intval($owner_uid), + dbesc($album), + intval(PHOTO_NORMAL), + intval(PHOTO_PROFILE) + ); + + $photos = []; + if ($r) { + $twist = 'rotright'; + foreach ($r as $rr) { + if ($twist == 'rotright') { + $twist = 'rotleft'; + } else { + $twist = 'rotright'; + } + + $ext = $phototypes[$rr['mimetype']]; + + $imgalt_e = $rr['filename']; + $desc_e = $rr['description']; + + $imagelink = (z_root() . '/photos/' . $channel['channel_address'] . '/image/' . $rr['resource_id'] + . (($_GET['order'] === 'posted') ? '?f=&order=posted' : '')); + + $photos[] = [ + 'id' => $rr['id'], + 'twist' => ' ' . $twist . rand(2, 4), + 'link' => $imagelink, + 'title' => t('View Photo'), + 'src' => z_root() . '/photo/' . $rr['resource_id'] . '-' . $rr['imgscale'] . '.' . $ext, + 'alt' => $imgalt_e, + 'desc' => $desc_e, + 'ext' => $ext, + 'hash' => $rr['resource_id'], + 'unknown' => t('Unknown') + ]; + } + } + + $o .= replace_macros(Theme::get_template('photo_album.tpl'), [ + '$photos' => $photos, + '$album' => (($title) ? $title : $album), + '$album_id' => rand(), + '$album_edit' => array(t('Edit Album'), $album_edit), + '$can_post' => false, + '$upload' => [t('Upload'), z_root() . '/photos/' . $channel['channel_address'] . '/upload/' . bin2hex($album)], + '$order' => false, + '$upload_form' => $upload_form, + '$no_fullscreen_btn' => true + ]); + + return $o; + } + + public function embedphotos_album_list($channel_id) + { + $channel = Channel::from_id($channel_id); + $p = photos_albums_list($channel, App::get_observer()); + if ($p['success']) { + return $p['albums']; + } else { + return null; + } + } +} diff --git a/Code/Module/Event.php b/Code/Module/Event.php new file mode 100644 index 000000000..625f30af6 --- /dev/null +++ b/Code/Module/Event.php @@ -0,0 +1,61 @@ + MAX_EVENT_REPEAT_COUNT) { + $count = MAX_EVENT_REPEAT_COUNT; + } + + require_once('include/text.php'); + linkify_tags($desc, local_channel()); + linkify_tags($location, local_channel()); + + //$action = ($event_hash == '') ? 'new' : "event/" . $event_hash; + + //@fixme: this url gives a wsod if there is a linebreak detected in one of the variables ($desc or $location) + //$onerror_url = z_root() . "/events/" . $action . "?summary=$summary&description=$desc&location=$location&start=$start_text&finish=$finish_text&adjust=$adjust&nofinish=$nofinish&type=$type"; + $onerror_url = z_root() . "/events"; + + if (strcmp($finish, $start) < 0 && !$nofinish) { + notice(t('Event can not end before it has started.') . EOL); + if (intval($_REQUEST['preview'])) { + echo(t('Unable to generate preview.')); + killme(); + } + goaway($onerror_url); + } + + if ((!$summary) || (!$start)) { + notice(t('Event title and start time are required.') . EOL); + if (intval($_REQUEST['preview'])) { + echo(t('Unable to generate preview.')); + killme(); + } + goaway($onerror_url); + } + + // $share = ((intval($_POST['distr'])) ? intval($_POST['distr']) : 0); + + $share = 1; + + + $acl = new AccessControl(false); + + if ($event_id) { + $x = q( + "select * from event where id = %d and uid = %d limit 1", + intval($event_id), + intval(local_channel()) + ); + if (!$x) { + notice(t('Event not found.') . EOL); + if (intval($_REQUEST['preview'])) { + echo(t('Unable to generate preview.')); + killme(); + } + return; + } + + $acl->set($x[0]); + + $created = $x[0]['created']; + $edited = datetime_convert(); + + if ( + $x[0]['allow_cid'] === '<' . $channel['channel_hash'] . '>' + && $x[0]['allow_gid'] === '' && $x[0]['deny_cid'] === '' && $x[0]['deny_gid'] === '' + ) { + $share = false; + } else { + $share = true; + } + } else { + $created = $edited = datetime_convert(); + if ($share) { + $acl->set_from_array($_POST); + } else { + $acl->set(array('allow_cid' => '<' . $channel['channel_hash'] . '>', 'allow_gid' => '', 'deny_cid' => '', 'deny_gid' => '')); + } + } + + $post_tags = []; + + $ac = $acl->get(); + + if (strlen($categories)) { + $cats = explode(',', $categories); + foreach ($cats as $cat) { + $post_tags[] = array( + 'uid' => $profile_uid, + 'ttype' => TERM_CATEGORY, + 'otype' => TERM_OBJ_POST, + 'term' => trim($cat), + 'url' => $channel['xchan_url'] . '?f=&cat=' . urlencode(trim($cat)) + ); + } + } + + $datarray = []; + $datarray['dtstart'] = $start; + $datarray['dtend'] = $finish; + $datarray['summary'] = $summary; + $datarray['description'] = $desc; + $datarray['location'] = $location; + $datarray['etype'] = $type; + $datarray['adjust'] = $adjust; + $datarray['nofinish'] = $nofinish; + $datarray['uid'] = local_channel(); + $datarray['account'] = get_account_id(); + $datarray['event_xchan'] = $channel['channel_hash']; + $datarray['allow_cid'] = $ac['allow_cid']; + $datarray['allow_gid'] = $ac['allow_gid']; + $datarray['deny_cid'] = $ac['deny_cid']; + $datarray['deny_gid'] = $ac['deny_gid']; + $datarray['private'] = (($acl->is_private()) ? 1 : 0); + $datarray['id'] = $event_id; + $datarray['created'] = $created; + $datarray['edited'] = $edited; + + if (intval($_REQUEST['preview'])) { + $html = format_event_html($datarray); + echo $html; + killme(); + } + + $event = event_store_event($datarray); + + if ($post_tags) { + $datarray['term'] = $post_tags; + } + + $item_id = event_store_item($datarray, $event); + + if ($item_id) { + $r = q( + "select * from item where id = %d", + intval($item_id) + ); + if ($r) { + xchan_query($r); + $sync_item = fetch_post_tags($r); + $z = q( + "select * from event where event_hash = '%s' and uid = %d limit 1", + dbesc($r[0]['resource_id']), + intval($channel['channel_id']) + ); + if ($z) { + Libsync::build_sync_packet($channel['channel_id'], ['event_item' => [encode_item($sync_item[0], true)], 'event' => $z]); + } + } + } + + if ($share) { + Run::Summon(['Notifier', 'event', $item_id]); + } + } + + + public function get() + { + + if (argc() > 2 && argv(1) == 'ical') { + $event_id = argv(2); + + require_once('include/security.php'); + $sql_extra = permissions_sql(local_channel()); + + $r = q( + "select * from event where event_hash = '%s' $sql_extra limit 1", + dbesc($event_id) + ); + if ($r) { + header('Content-type: text/calendar'); + header('Content-Disposition: attachment; filename="' . t('event') . '-' . $event_id . '.ics"'); + echo ical_wrapper($r); + killme(); + } else { + notice(t('Event not found.') . EOL); + return; + } + } + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + $channel = App::get_channel(); + + Navbar::set_selected('Events'); + + if ((argc() > 2) && (argv(1) === 'ignore') && intval(argv(2))) { + $r = q( + "update event set dismissed = 1 where id = %d and uid = %d", + intval(argv(2)), + intval(local_channel()) + ); + } + + if ((argc() > 2) && (argv(1) === 'unignore') && intval(argv(2))) { + $r = q( + "update event set dismissed = 0 where id = %d and uid = %d", + intval(argv(2)), + intval(local_channel()) + ); + } + + $first_day = intval(get_pconfig(local_channel(), 'system', 'cal_first_day', 0)); + + $htpl = Theme::get_template('event_head.tpl'); + App::$page['htmlhead'] .= replace_macros($htpl, array( + '$baseurl' => z_root(), + '$module_url' => '/events', + '$modparams' => 1, + '$lang' => App::$language, + '$first_day' => $first_day + )); + + $o = ''; + + $mode = 'view'; + $y = 0; + $m = 0; + $ignored = ((x($_REQUEST, 'ignored')) ? " and dismissed = " . intval($_REQUEST['ignored']) . " " : ''); + + + // logger('args: ' . print_r(App::$argv,true)); + + + if (argc() > 1) { + if (argc() > 2 && argv(1) === 'add') { + $mode = 'add'; + $item_id = intval(argv(2)); + } + if (argc() > 2 && argv(1) === 'drop') { + $mode = 'drop'; + $event_id = argv(2); + } + if (argc() > 2 && intval(argv(1)) && intval(argv(2))) { + $mode = 'view'; + $y = intval(argv(1)); + $m = intval(argv(2)); + } + if (argc() <= 2) { + $mode = 'view'; + $event_id = argv(1); + } + } + + if ($mode === 'add') { + event_addtocal($item_id, local_channel()); + killme(); + } + + if ($mode == 'view') { + /* edit/create form */ + if ($event_id) { + $r = q( + "SELECT * FROM event WHERE event_hash = '%s' AND uid = %d LIMIT 1", + dbesc($event_id), + intval(local_channel()) + ); + if (count($r)) { + $orig_event = $r[0]; + } + } + + $channel = App::get_channel(); + + // Passed parameters overrides anything found in the DB + if (!x($orig_event)) { + $orig_event = []; + } + + // In case of an error the browser is redirected back here, with these parameters filled in with the previous values + /* + if(x($_REQUEST,'nofinish')) $orig_event['nofinish'] = $_REQUEST['nofinish']; + if(x($_REQUEST,'adjust')) $orig_event['adjust'] = $_REQUEST['adjust']; + if(x($_REQUEST,'summary')) $orig_event['summary'] = $_REQUEST['summary']; + if(x($_REQUEST,'description')) $orig_event['description'] = $_REQUEST['description']; + if(x($_REQUEST,'location')) $orig_event['location'] = $_REQUEST['location']; + if(x($_REQUEST,'start')) $orig_event['dtstart'] = $_REQUEST['start']; + if(x($_REQUEST,'finish')) $orig_event['dtend'] = $_REQUEST['finish']; + if(x($_REQUEST,'type')) $orig_event['etype'] = $_REQUEST['type']; + */ + + $n_checked = ((x($orig_event) && $orig_event['nofinish']) ? ' checked="checked" ' : ''); + $a_checked = ((x($orig_event) && $orig_event['adjust']) ? ' checked="checked" ' : ''); + $t_orig = ((x($orig_event)) ? $orig_event['summary'] : ''); + $d_orig = ((x($orig_event)) ? $orig_event['description'] : ''); + $l_orig = ((x($orig_event)) ? $orig_event['location'] : ''); + $eid = ((x($orig_event)) ? $orig_event['id'] : 0); + $event_xchan = ((x($orig_event)) ? $orig_event['event_xchan'] : $channel['channel_hash']); + $mid = ((x($orig_event)) ? $orig_event['mid'] : ''); + + if (!x($orig_event)) { + $sh_checked = ''; + $a_checked = ' checked="checked" '; + } else { + $sh_checked = ((($orig_event['allow_cid'] === '<' . $channel['channel_hash'] . '>' || (!$orig_event['allow_cid'])) && (!$orig_event['allow_gid']) && (!$orig_event['deny_cid']) && (!$orig_event['deny_gid'])) ? '' : ' checked="checked" '); + } + + if ($orig_event['event_xchan']) { + $sh_checked .= ' disabled="disabled" '; + } + + $sdt = ((x($orig_event)) ? $orig_event['dtstart'] : 'now'); + + $fdt = ((x($orig_event)) ? $orig_event['dtend'] : '+1 hour'); + + $tz = date_default_timezone_get(); + if (x($orig_event)) { + $tz = (($orig_event['adjust']) ? date_default_timezone_get() : 'UTC'); + } + + $syear = datetime_convert('UTC', $tz, $sdt, 'Y'); + $smonth = datetime_convert('UTC', $tz, $sdt, 'm'); + $sday = datetime_convert('UTC', $tz, $sdt, 'd'); + $shour = datetime_convert('UTC', $tz, $sdt, 'H'); + $sminute = datetime_convert('UTC', $tz, $sdt, 'i'); + + $stext = datetime_convert('UTC', $tz, $sdt); + $stext = substr($stext, 0, 14) . "00:00"; + + $fyear = datetime_convert('UTC', $tz, $fdt, 'Y'); + $fmonth = datetime_convert('UTC', $tz, $fdt, 'm'); + $fday = datetime_convert('UTC', $tz, $fdt, 'd'); + $fhour = datetime_convert('UTC', $tz, $fdt, 'H'); + $fminute = datetime_convert('UTC', $tz, $fdt, 'i'); + + $ftext = datetime_convert('UTC', $tz, $fdt); + $ftext = substr($ftext, 0, 14) . "00:00"; + + $type = ((x($orig_event)) ? $orig_event['etype'] : 'event'); + + $f = get_config('system', 'event_input_format'); + if (!$f) { + $f = 'ymd'; + } + + $catsenabled = Apps::system_app_installed(local_channel(), 'Categories'); + + $category = ''; + + if ($catsenabled && x($orig_event)) { + $itm = q( + "select * from item where resource_type = 'event' and resource_id = '%s' and uid = %d limit 1", + dbesc($orig_event['event_hash']), + intval(local_channel()) + ); + $itm = fetch_post_tags($itm); + if ($itm) { + $cats = get_terms_oftype($itm[0]['term'], TERM_CATEGORY); + foreach ($cats as $cat) { + if (strlen($category)) { + $category .= ', '; + } + $category .= $cat['term']; + } + } + } + + $acl = new AccessControl($channel); + $perm_defaults = $acl->get(); + + $permissions = ((x($orig_event)) ? $orig_event : $perm_defaults); + + $freq_options = [ + 'DAILY' => t('day(s)'), + 'WEEKLY' => t('week(s)'), + 'MONTHLY' => t('month(s)'), + 'YEARLY' => t('year(s)') + ]; + + + $tpl = Theme::get_template('event_form.tpl'); + + $form = replace_macros($tpl, array( + '$post' => z_root() . '/events', + '$eid' => $eid, + '$type' => $type, + '$xchan' => $event_xchan, + '$mid' => $mid, + '$event_hash' => $event_id, + '$summary' => array('summary', (($event_id) ? t('Edit event title') : t('Event title')), $t_orig, t('Required'), '*'), + '$catsenabled' => $catsenabled, + '$placeholdercategory' => t('Categories (comma-separated list)'), + '$c_text' => (($event_id) ? t('Edit Category') : t('Category')), + '$category' => $category, + '$required' => '*', + '$s_dsel' => datetimesel($f, new DateTime(), DateTime::createFromFormat('Y', $syear + 5), DateTime::createFromFormat('Y-m-d H:i', "$syear-$smonth-$sday $shour:$sminute"), (($event_id) ? t('Edit start date and time') : t('Start date and time')), 'start_text', true, true, '', '', true, $first_day), + '$n_text' => t('Finish date and time are not known or not relevant'), + '$n_checked' => $n_checked, + '$f_dsel' => datetimesel($f, new DateTime(), DateTime::createFromFormat('Y', $fyear + 5), DateTime::createFromFormat('Y-m-d H:i', "$fyear-$fmonth-$fday $fhour:$fminute"), (($event_id) ? t('Edit finish date and time') : t('Finish date and time')), 'finish_text', true, true, 'start_text', '', false, $first_day), + '$nofinish' => array('nofinish', t('Finish date and time are not known or not relevant'), $n_checked, '', array(t('No'), t('Yes')), 'onclick="enableDisableFinishDate();"'), + '$adjust' => array('adjust', t('Adjust for viewer timezone'), $a_checked, t('Important for events that happen in a particular place. Not practical for global holidays.'), array(t('No'), t('Yes'))), + '$a_text' => t('Adjust for viewer timezone'), + '$d_text' => (($event_id) ? t('Edit Description') : t('Description')), + '$d_orig' => $d_orig, + '$l_text' => (($event_id) ? t('Edit Location') : t('Location')), + '$l_orig' => $l_orig, + '$t_orig' => $t_orig, + '$preview' => t('Preview'), + '$perms_label' => t('Permission settings'), + // populating the acl dialog was a permission description from view_stream because Cal.php, which + // displays events, says "since we don't currently have an event permission - use the stream permission" + '$acl' => (($orig_event['event_xchan']) ? '' : Libacl::populate(((x($orig_event)) ? $orig_event : $perm_defaults), false, PermissionDescription::fromGlobalPermission('view_stream'))), + + '$allow_cid' => acl2json($permissions['allow_cid']), + '$allow_gid' => acl2json($permissions['allow_gid']), + '$deny_cid' => acl2json($permissions['deny_cid']), + '$deny_gid' => acl2json($permissions['deny_gid']), + '$tz_choose' => Features::enabled(local_channel(), 'event_tz_select'), + '$timezone' => array('timezone_select', t('Timezone:'), date_default_timezone_get(), '', get_timezones()), + + '$lockstate' => (($acl->is_private()) ? 'lock' : 'unlock'), + + '$submit' => t('Submit'), + '$advanced' => t('Advanced Options'), + + '$repeat' => ['repeat', t('Event repeat'), false, '', [t('No'), t('Yes')]], + '$freq' => ['freq', t('Repeat frequency'), '', '', $freq_options], + '$interval' => ['interval', t('Repeat every'), 1, ''], + '$count' => ['count', t('Number of total repeats'), 10, ''], + '$until' => '', + '$byday' => '', + + )); + /* end edit/create form */ + + $thisyear = datetime_convert('UTC', date_default_timezone_get(), 'now', 'Y'); + $thismonth = datetime_convert('UTC', date_default_timezone_get(), 'now', 'm'); + if (!$y) { + $y = intval($thisyear); + } + if (!$m) { + $m = intval($thismonth); + } + + $export = false; + if (argc() === 4 && argv(3) === 'export') { + $export = true; + } + + // Put some limits on dates. The PHP date functions don't seem to do so well before 1900. + // An upper limit was chosen to keep search engines from exploring links millions of years in the future. + + if ($y < 1901) { + $y = 1900; + } + if ($y > 2099) { + $y = 2100; + } + + $nextyear = $y; + $nextmonth = $m + 1; + if ($nextmonth > 12) { + $nextmonth = 1; + $nextyear++; + } + + $prevyear = $y; + if ($m > 1) { + $prevmonth = $m - 1; + } else { + $prevmonth = 12; + $prevyear--; + } + + $dim = get_dim($y, $m); + $start = sprintf('%d-%d-%d %d:%d:%d', $y, $m, 1, 0, 0, 0); + $finish = sprintf('%d-%d-%d %d:%d:%d', $y, $m, $dim, 23, 59, 59); + + + if (argv(1) === 'json') { + if (x($_GET, 'start')) { + $start = $_GET['start']; + } + if (x($_GET, 'end')) { + $finish = $_GET['end']; + } + } + + $start = datetime_convert('UTC', 'UTC', $start); + $finish = datetime_convert('UTC', 'UTC', $finish); + + $adjust_start = datetime_convert('UTC', date_default_timezone_get(), $start); + $adjust_finish = datetime_convert('UTC', date_default_timezone_get(), $finish); + + if (x($_GET, 'id')) { + $r = q( + "SELECT event.*, item.plink, item.item_flags, item.author_xchan, item.owner_xchan + from event left join item on resource_id = event_hash where resource_type = 'event' and event.uid = %d and event.id = %d limit 1", + intval(local_channel()), + intval($_GET['id']) + ); + } elseif ($export) { + $r = q( + "SELECT * from event where uid = %d + AND (( adjust = 0 AND ( dtend >= '%s' or nofinish = 1 ) AND dtstart <= '%s' ) + OR ( adjust = 1 AND ( dtend >= '%s' or nofinish = 1 ) AND dtstart <= '%s' )) ", + intval(local_channel()), + dbesc($start), + dbesc($finish), + dbesc($adjust_start), + dbesc($adjust_finish) + ); + } else { + // fixed an issue with "nofinish" events not showing up in the calendar. + // There's still an issue if the finish date crosses the end of month. + // Noting this for now - it will need to be fixed here and in Friendica. + // Ultimately the finish date shouldn't be involved in the query. + + $r = q( + "SELECT event.*, item.plink, item.item_flags, item.author_xchan, item.owner_xchan + from event left join item on event_hash = resource_id + where resource_type = 'event' and event.uid = %d and event.uid = item.uid $ignored + AND (( adjust = 0 AND ( dtend >= '%s' or nofinish = 1 ) AND dtstart <= '%s' ) + OR ( adjust = 1 AND ( dtend >= '%s' or nofinish = 1 ) AND dtstart <= '%s' )) ", + intval(local_channel()), + dbesc($start), + dbesc($finish), + dbesc($adjust_start), + dbesc($adjust_finish) + ); + } + + $links = []; + + if ($r && !$export) { + xchan_query($r); + $r = fetch_post_tags($r, true); + + $r = sort_by_date($r); + } + + if ($r) { + foreach ($r as $rr) { + $j = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], 'j') : datetime_convert('UTC', 'UTC', $rr['dtstart'], 'j')); + if (!x($links, $j)) { + $links[$j] = z_root() . '/' . App::$cmd . '#link-' . $j; + } + } + } + + $events = []; + + $last_date = ''; + $fmt = t('l, F j'); + + if ($r) { + foreach ($r as $rr) { + $j = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], 'j') : datetime_convert('UTC', 'UTC', $rr['dtstart'], 'j')); + $d = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], $fmt) : datetime_convert('UTC', 'UTC', $rr['dtstart'], $fmt)); + $d = day_translate($d); + + $start = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtstart'], 'c') : datetime_convert('UTC', 'UTC', $rr['dtstart'], 'c')); + if ($rr['nofinish']) { + $end = null; + } else { + $end = (($rr['adjust']) ? datetime_convert('UTC', date_default_timezone_get(), $rr['dtend'], 'c') : datetime_convert('UTC', 'UTC', $rr['dtend'], 'c')); + + // give a fake end to birthdays so they get crammed into a + // single day on the calendar + + if ($rr['etype'] === 'birthday') { + $end = null; + } + } + + + $is_first = ($d !== $last_date); + + $last_date = $d; + + $edit = ((local_channel() && $rr['author_xchan'] == get_observer_hash()) ? array(z_root() . '/events/' . $rr['event_hash'] . '?expandform=1', t('Edit event'), '', '') : false); + + $drop = array(z_root() . '/events/drop/' . $rr['event_hash'], t('Delete event'), '', ''); + + $title = strip_tags(html_entity_decode(zidify_links(bbcode($rr['summary'])), ENT_QUOTES, 'UTF-8')); + if (!$title) { + list($title, $_trash) = explode(" $rr['id'], + 'hash' => $rr['event_hash'], + 'start' => $start, + 'end' => $end, + 'drop' => $drop, + 'allDay' => false, + 'title' => $title, + + 'j' => $j, + 'd' => $d, + 'edit' => $edit, + 'is_first' => $is_first, + 'item' => $rr, + 'html' => $html, + 'plink' => array($rr['plink'], t('Link to Source'), '', ''), + ); + } + } + + if ($export) { + header('Content-type: text/calendar'); + header('Content-Disposition: attachment; filename="' . t('calendar') . '-' . $channel['channel_address'] . '.ics"'); + echo ical_wrapper($r); + killme(); + } + + if (App::$argv[1] === 'json') { + echo json_encode($events); + killme(); + } + + // links: array('href', 'text', 'extra css classes', 'title') + if (x($_GET, 'id')) { + $tpl = Theme::get_template("event.tpl"); + } else { + $tpl = Theme::get_template("events-js.tpl"); + } + + $o = replace_macros($tpl, array( + '$baseurl' => z_root(), + '$new_event' => array(z_root() . '/events', (($event_id) ? t('Edit Event') : t('Create Event')), '', ''), + '$previus' => array(z_root() . "/events/$prevyear/$prevmonth", t('Previous'), '', ''), + '$next' => array(z_root() . "/events/$nextyear/$nextmonth", t('Next'), '', ''), + '$export' => array(z_root() . "/events/$y/$m/export", t('Export'), '', ''), + '$calendar' => cal($y, $m, $links, ' eventcal'), + '$events' => $events, + '$view_label' => t('View'), + '$month' => t('Month'), + '$week' => t('Week'), + '$day' => t('Day'), + '$prev' => t('Previous'), + '$next' => t('Next'), + '$today' => t('Today'), + '$form' => $form, + '$expandform' => ((x($_GET, 'expandform')) ? true : false), + )); + + if (x($_GET, 'id')) { + echo $o; + killme(); + } + + return $o; + } + + if ($mode === 'drop' && $event_id) { + $r = q( + "SELECT * FROM event WHERE event_hash = '%s' AND uid = %d LIMIT 1", + dbesc($event_id), + intval(local_channel()) + ); + + $sync_event = $r[0]; + + if ($r) { + $r = q( + "delete from event where event_hash = '%s' and uid = %d", + dbesc($event_id), + intval(local_channel()) + ); + if ($r) { + $i = q( + "select * from item where resource_type = 'event' and resource_id = '%s' and uid = %d", + dbesc($event_id), + intval(local_channel()) + ); + + if ($i) { + $can_delete = false; + $local_delete = true; + + $ob_hash = get_observer_hash(); + if ($ob_hash && ($ob_hash === $i[0]['author_xchan'] || $ob_hash === $i[0]['owner_xchan'] || $ob_hash === $i[0]['source_xchan'])) { + $can_delete = true; + } + + // The site admin can delete any post/item on the site. + // If the item originated on this site+channel the deletion will propagate downstream. + // Otherwise just the local copy is removed. + + if (is_site_admin()) { + $local_delete = true; + if (intval($i[0]['item_origin'])) { + $can_delete = true; + } + } + + if ($can_delete || $local_delete) { + // if this is a different page type or it's just a local delete + // but not by the item author or owner, do a simple deletion + + $complex = false; + + if (intval($i[0]['item_type']) || ($local_delete && (!$can_delete))) { + drop_item($i[0]['id']); + } else { + // complex deletion that needs to propagate and be performed in phases + drop_item($i[0]['id'], true, DROPITEM_PHASE1); + $complex = true; + } + + $ii = q( + "select * from item where id = %d", + intval($i[0]['id']) + ); + if ($ii) { + xchan_query($ii); + $sync_item = fetch_post_tags($ii); + Libsync::build_sync_packet($i[0]['uid'], array('item' => array(encode_item($sync_item[0], true)))); + } + + if ($complex) { + tag_deliver($i[0]['uid'], $i[0]['id']); + } + } + } + + $r = q( + "update item set resource_type = '', resource_id = '' where resource_type = 'event' and resource_id = '%s' and uid = %d", + dbesc($event_id), + intval(local_channel()) + ); + $sync_event['event_deleted'] = 1; + Libsync::build_sync_packet(0, array('event' => array($sync_event))); + + info(t('Event removed') . EOL); + } else { + notice(t('Failed to remove event') . EOL); + } + goaway(z_root() . '/events'); + } + } + } +} diff --git a/Code/Module/Expire.php b/Code/Module/Expire.php new file mode 100644 index 000000000..55e807781 --- /dev/null +++ b/Code/Module/Expire.php @@ -0,0 +1,54 @@ +' . $desc . ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Expire Posts'))) { + return $text; + } + + $setting_fields .= replace_macros(Theme::get_template('field_input.tpl'), [ + '$field' => array('selfexpiredays', t('Expire and delete all my posts after this many days'), intval(get_pconfig(local_channel(), 'system', 'selfexpiredays', 0)), t('Leave at 0 if you wish to manually control expiration of specific posts.')) + ]); + + return replace_macros(Theme::get_template('generic_app_settings.tpl'), [ + '$addon' => array('expire', t('Automatic Expiration Settings'), '', t('Submit')), + '$content' => $setting_fields + ]); + } +} diff --git a/Code/Module/Fastping.php b/Code/Module/Fastping.php new file mode 100644 index 000000000..d1b1b08ba --- /dev/null +++ b/Code/Module/Fastping.php @@ -0,0 +1,72 @@ + $m); + } + unset($_SESSION['sysmsg']); + } + if (x($_SESSION, 'sysmsg_info')) { + foreach ($_SESSION['sysmsg_info'] as $m) { + $result['info'][] = array('message' => $m); + } + unset($_SESSION['sysmsg_info']); + } + if (!($vnotify & VNOTIFY_INFO)) { + $result['info'] = []; + } + if (!($vnotify & VNOTIFY_ALERT)) { + $result['notice'] = []; + } + + json_return_and_die($result); + } +} diff --git a/Code/Module/Fbrowser.php b/Code/Module/Fbrowser.php new file mode 100644 index 000000000..f625f1f87 --- /dev/null +++ b/Code/Module/Fbrowser.php @@ -0,0 +1,144 @@ + + */ + +require_once('include/photo_factory.php'); + + +class Fbrowser extends Controller +{ + + public function get() + { + + if (!local_channel()) { + killme(); + } + + if (App::$argc == 1) { + killme(); + } + + //echo "
      "; var_dump(\App::$argv); killme();
      +
      +        switch (App::$argv[1]) {
      +            case "image":
      +                $path = array(array(z_root() . "/fbrowser/image/", t("Photos")));
      +                $albums = false;
      +                $sql_extra = "";
      +                $sql_extra2 = " ORDER BY created DESC LIMIT 0, 10";
      +
      +                if (App::$argc == 2) {
      +                    $albums = q(
      +                        "SELECT distinct(album) AS album FROM photo WHERE uid = %d ",
      +                        intval(local_channel())
      +                    );
      +                    // anon functions only from 5.3.0... meglio tardi che mai..
      +                    $albums = array_map("self::folder1", $albums);
      +                }
      +
      +                $album = "";
      +                if (App::$argc == 3) {
      +                    $album = hex2bin(App::$argv[2]);
      +                    $sql_extra = sprintf("AND album = '%s' ", dbesc($album));
      +                    $sql_extra2 = "";
      +                    $path[] = array(z_root() . "/fbrowser/image/" . App::$argv[2] . "/", $album);
      +                }
      +
      +                $r = q(
      +                    "SELECT resource_id, id, filename, type, min(imgscale) AS hiq,max(imgscale) AS loq, description  
      +						FROM photo WHERE uid = %d $sql_extra
      +						GROUP BY resource_id $sql_extra2",
      +                    intval(local_channel())
      +                );
      +
      +                $files = array_map("self::files1", $r);
      +
      +                $tpl = Theme::get_template("filebrowser.tpl");
      +                echo replace_macros($tpl, array(
      +                    '$type' => 'image',
      +                    '$baseurl' => z_root(),
      +                    '$path' => $path,
      +                    '$folders' => $albums,
      +                    '$files' => $files,
      +                    '$cancel' => t('Cancel'),
      +                ));
      +
      +
      +                break;
      +            case "file":
      +                if (App::$argc == 2) {
      +                    $files = q(
      +                        "SELECT id, filename, filetype FROM attach WHERE uid = %d ",
      +                        intval(local_channel())
      +                    );
      +
      +                    $files = array_map("self::files2", $files);
      +                    //echo "
      "; var_dump($files); killme();
      +
      +
      +                    $tpl = Theme::get_template("filebrowser.tpl");
      +                    echo replace_macros($tpl, array(
      +                        '$type' => 'file',
      +                        '$baseurl' => z_root(),
      +                        '$path' => array(array(z_root() . "/fbrowser/image/", t("Files"))),
      +                        '$folders' => false,
      +                        '$files' => $files,
      +                        '$cancel' => t('Cancel'),
      +                    ));
      +                }
      +
      +                break;
      +        }
      +
      +
      +        killme();
      +    }
      +
      +    private static function folder1($el)
      +    {
      +        return array(bin2hex($el['album']), $el['album']);
      +    }
      +
      +
      +    private static function files1($rr)
      +    {
      +
      +        $ph = photo_factory('');
      +        $types = $ph->supportedTypes();
      +        $ext = $types[$rr['type']];
      +
      +        $filename_e = $rr['filename'];
      +
      +        return array(
      +            z_root() . '/photo/' . $rr['resource_id'] . '-' . $rr['hiq'] . '.' . $ext,
      +            $filename_e,
      +            z_root() . '/photo/' . $rr['resource_id'] . '-' . $rr['loq'] . '.' . $ext
      +        );
      +    }
      +
      +    private static function files2($rr)
      +    {
      +        list($m1, $m2) = explode("/", $rr['filetype']);
      +        $filetype = ((file_exists("images/icons/$m1.png")) ? $m1 : "zip");
      +
      +        if (App::get_template_engine() === 'internal') {
      +            $filename_e = template_escape($rr['filename']);
      +        } else {
      +            $filename_e = $rr['filename'];
      +        }
      +
      +        return array(z_root() . '/attach/' . $rr['id'], $filename_e, z_root() . '/images/icons/16/' . $filetype . '.png');
      +    }
      +}
      diff --git a/Code/Module/Fedi_id.php b/Code/Module/Fedi_id.php
      new file mode 100644
      index 000000000..98b6619e4
      --- /dev/null
      +++ b/Code/Module/Fedi_id.php
      @@ -0,0 +1,59 @@
      + t('Home instance'),
      +                '$address' => ['address', t('Enter your channel address or fediverse ID (e.g. channel@example.com)'), '', t('If you do not have a fediverse ID, please use your browser \'back\' button to return to the previous page')],
      +                '$action' => 'fedi_id/' . argv(1),
      +                '$method' => 'post',
      +                '$submit' => t('Connect')
      +            ]
      +        );
      +    }
      +}
      diff --git a/Code/Module/Feed.php b/Code/Module/Feed.php
      new file mode 100644
      index 000000000..478b68340
      --- /dev/null
      +++ b/Code/Module/Feed.php
      @@ -0,0 +1,50 @@
      + 1) {
      +            if (observer_prohibited(true)) {
      +                killme();
      +            }
      +
      +            $channel = Channel::from_username(argv(1));
      +            if (!$channel) {
      +                killme();
      +            }
      +
      +            logger('public feed request from ' . $_SERVER['REMOTE_ADDR'] . ' for ' . $channel['channel_address']);
      +
      +            echo get_public_feed($channel, $params);
      +
      +            killme();
      +        }
      +    }
      +}
      diff --git a/Code/Module/File_upload.php b/Code/Module/File_upload.php
      new file mode 100644
      index 000000000..a9e84e9a2
      --- /dev/null
      +++ b/Code/Module/File_upload.php
      @@ -0,0 +1,106 @@
      + array($sync)));
      +                }
      +                goaway(z_root() . '/cloud/' . $channel['channel_address'] . '/' . $r['data']['display_path']);
      +            }
      +        } else {
      +            $matches = [];
      +            $partial = false;
      +
      +
      +            if (array_key_exists('HTTP_CONTENT_RANGE', $_SERVER)) {
      +                $pm = preg_match('/bytes (\d*)\-(\d*)\/(\d*)/', $_SERVER['HTTP_CONTENT_RANGE'], $matches);
      +                if ($pm) {
      +                    logger('Content-Range: ' . print_r($matches, true));
      +                    $partial = true;
      +                }
      +            }
      +
      +            if ($partial) {
      +                $x = save_chunk($channel, $matches[1], $matches[2], $matches[3]);
      +
      +                if ($x['partial']) {
      +                    header('Range: bytes=0-' . (($x['length']) ? $x['length'] - 1 : 0));
      +                    json_return_and_die($x);
      +                } else {
      +                    header('Range: bytes=0-' . (($x['size']) ? $x['size'] - 1 : 0));
      +
      +                    $_FILES['userfile'] = [
      +                        'name' => $x['name'],
      +                        'type' => $x['type'],
      +                        'tmp_name' => $x['tmp_name'],
      +                        'error' => $x['error'],
      +                        'size' => $x['size']
      +                    ];
      +                }
      +            } else {
      +                if (!array_key_exists('userfile', $_FILES)) {
      +                    $_FILES['userfile'] = [
      +                        'name' => $_FILES['files']['name'],
      +                        'type' => $_FILES['files']['type'],
      +                        'tmp_name' => $_FILES['files']['tmp_name'],
      +                        'error' => $_FILES['files']['error'],
      +                        'size' => $_FILES['files']['size']
      +                    ];
      +                }
      +            }
      +
      +            $r = attach_store($channel, get_observer_hash(), '', $_REQUEST);
      +            if ($r['success']) {
      +                $sync = attach_export_data($channel, $r['data']['hash']);
      +                if ($sync) {
      +                    Libsync::build_sync_packet($channel['channel_id'], array('file' => array($sync)));
      +                }
      +            }
      +        }
      +        goaway(z_root() . '/' . $_REQUEST['return_url']);
      +    }
      +}
      diff --git a/Code/Module/Filer.php b/Code/Module/Filer.php
      new file mode 100644
      index 000000000..73255f7ce
      --- /dev/null
      +++ b/Code/Module/Filer.php
      @@ -0,0 +1,71 @@
      + 1) ? intval(App::$argv[1]) : 0);
      +
      +        logger('filer: tag ' . $term . ' item ' . $item_id);
      +
      +        if ($item_id && strlen($term)) {
      +            // file item
      +            store_item_tag(local_channel(), $item_id, TERM_OBJ_POST, TERM_FILE, $term, '');
      +
      +            // protect the entire conversation from periodic expiration
      +
      +            $r = q(
      +                "select parent from item where id = %d and uid = %d limit 1",
      +                intval($item_id),
      +                intval(local_channel())
      +            );
      +            if ($r) {
      +                $x = q(
      +                    "update item set item_retained = 1 where id = %d and uid = %d",
      +                    intval($r[0]['parent']),
      +                    intval(local_channel())
      +                );
      +            }
      +        } else {
      +            $filetags = [];
      +            $r = q(
      +                "select distinct(term) from term where uid = %d and ttype = %d order by term asc",
      +                intval(local_channel()),
      +                intval(TERM_FILE)
      +            );
      +            if (count($r)) {
      +                foreach ($r as $rr) {
      +                    $filetags[] = $rr['term'];
      +                }
      +            }
      +            $tpl = Theme::get_template("filer_dialog.tpl");
      +            $o = replace_macros($tpl, array(
      +                '$field' => array('term', t('Enter a folder name'), '', '', $filetags, 'placeholder="' . t('or select an existing folder (doubleclick)') . '"'),
      +                '$submit' => t('Save'),
      +                '$title' => t('Save to Folder'),
      +                '$cancel' => t('Cancel')
      +            ));
      +
      +            echo $o;
      +        }
      +        killme();
      +    }
      +}
      diff --git a/Code/Module/Filerm.php b/Code/Module/Filerm.php
      new file mode 100644
      index 000000000..c92ac61d1
      --- /dev/null
      +++ b/Code/Module/Filerm.php
      @@ -0,0 +1,46 @@
      + 1) ? intval(App::$argv[1]) : 0);
      +
      +        logger('filerm: tag ' . $term . ' item ' . $item_id);
      +
      +        if ($item_id && strlen($term)) {
      +            $r = q(
      +                "delete from term where uid = %d and ttype = %d and oid = %d and term = '%s'",
      +                intval(local_channel()),
      +                intval(($category) ? TERM_CATEGORY : TERM_FILE),
      +                intval($item_id),
      +                dbesc($term)
      +            );
      +        }
      +
      +        if (x($_SESSION, 'return_url')) {
      +            goaway(z_root() . '/' . $_SESSION['return_url']);
      +        }
      +
      +        killme();
      +    }
      +}
      diff --git a/Code/Module/Filestorage.php b/Code/Module/Filestorage.php
      new file mode 100644
      index 000000000..f4402e2a6
      --- /dev/null
      +++ b/Code/Module/Filestorage.php
      @@ -0,0 +1,259 @@
      +set_from_array($_POST);
      +        $x = $acl->get();
      +
      +        $url = get_cloud_url($channel_id, $channel['channel_address'], $resource);
      +
      +        // get the object before permissions change so we can catch eventual former allowed members
      +        $object = get_file_activity_object($channel_id, $resource, $url);
      +
      +        attach_change_permissions($channel_id, $resource, $x['allow_cid'], $x['allow_gid'], $x['deny_cid'], $x['deny_gid'], $recurse, true);
      +
      +        $sync = attach_export_data($channel, $resource, false);
      +        if ($sync) {
      +            Libsync::build_sync_packet($channel_id, array('file' => array($sync)));
      +        }
      +
      +//      file_activity($channel_id, $object, $x['allow_cid'], $x['allow_gid'], $x['deny_cid'], $x['deny_gid'], 'post', $notify);
      +
      +        goaway(dirname($url));
      +    }
      +
      +    public function get()
      +    {
      +
      +        if (argc() > 1) {
      +            $channel = Channel::from_username(argv(1));
      +        }
      +        if (!$channel) {
      +            notice(t('Channel unavailable.') . EOL);
      +            App::$error = 404;
      +            return;
      +        }
      +
      +        $owner = intval($channel['channel_id']);
      +        $observer = App::get_observer();
      +
      +        $ob_hash = (($observer) ? $observer['xchan_hash'] : '');
      +
      +        $perms = get_all_perms($owner, $ob_hash);
      +
      +        if (!($perms['view_storage'] || is_site_admin())) {
      +            notice(t('Permission denied.') . EOL);
      +            return;
      +        }
      +
      +
      +        if (argc() > 3 && argv(3) === 'delete') {
      +            if (argc() > 4 && argv(4) === 'json') {
      +                $json_return = true;
      +            }
      +
      +            $admin_delete = false;
      +
      +            if (!$perms['write_storage']) {
      +                if (is_site_admin()) {
      +                    $admin_delete = true;
      +                } else {
      +                    notice(t('Permission denied.') . EOL);
      +                    if ($json_return) {
      +                        json_return_and_die(['success' => false]);
      +                    }
      +                    return;
      +                }
      +            }
      +
      +            $file = intval(argv(2));
      +            $r = q(
      +                "SELECT hash, creator FROM attach WHERE id = %d AND uid = %d LIMIT 1",
      +                dbesc($file),
      +                intval($owner)
      +            );
      +            if (!$r) {
      +                notice(t('File not found.') . EOL);
      +
      +                if ($json_return) {
      +                    json_return_and_die(['success' => false]);
      +                }
      +
      +                goaway(z_root() . '/cloud/' . $which);
      +            }
      +
      +            $f = array_shift($r);
      +
      +            if (intval(local_channel()) !== $owner) {
      +                if ($f['creator'] && $f['creator'] !== $ob_hash) {
      +                    notice(t('Permission denied.') . EOL);
      +
      +                    if ($json_return) {
      +                        json_return_and_die(['success' => false]);
      +                    }
      +                    goaway(z_root() . '/cloud/' . $which);
      +                }
      +            }
      +
      +            $url = get_cloud_url($channel['channel_id'], $channel['channel_address'], $f['hash']);
      +
      +            attach_delete($owner, $f['hash']);
      +
      +            if (!$admin_delete) {
      +                $sync = attach_export_data($channel, $f['hash'], true);
      +                if ($sync) {
      +                    Libsync::build_sync_packet($channel['channel_id'], ['file' => [$sync]]);
      +                }
      +            }
      +
      +            if ($json_return) {
      +                json_return_and_die(['success' => true]);
      +            }
      +
      +            goaway(dirname($url));
      +        }
      +
      +
      +        // Since we have ACL'd files in the wild, but don't have ACL here yet, we
      +        // need to return for anyone other than the owner, despite the perms check for now.
      +
      +        $is_owner = (((local_channel()) && ($owner == local_channel())) ? true : false);
      +        if (!($is_owner || is_site_admin())) {
      +            notice(t('Permission denied.') . EOL);
      +            return;
      +        }
      +
      +
      +        if (argc() > 3 && argv(3) === 'edit') {
      +            if (!$perms['write_storage']) {
      +                notice(t('Permission denied.') . EOL);
      +                return;
      +            }
      +
      +            $file = intval(argv(2));
      +
      +            $r = q(
      +                "select id, uid, folder, filename, revision, flags, is_dir, os_storage, hash, allow_cid, allow_gid, deny_cid, deny_gid from attach where id = %d and uid = %d limit 1",
      +                intval($file),
      +                intval($owner)
      +            );
      +
      +            $f = array_shift($r);
      +
      +            $channel = App::get_channel();
      +
      +            $cloudpath = get_cloudpath($f);
      +
      +            $aclselect_e = Libacl::populate($f, false, PermissionDescription::fromGlobalPermission('view_storage'));
      +            $is_a_dir = (intval($f['is_dir']) ? true : false);
      +
      +            $lockstate = (($f['allow_cid'] || $f['allow_gid'] || $f['deny_cid'] || $f['deny_gid']) ? 'lock' : 'unlock');
      +
      +            // Encode path that is used for link so it's a valid URL
      +            // Keep slashes as slashes, otherwise mod_rewrite doesn't work correctly
      +            $encoded_path = str_replace('%2F', '/', rawurlencode($cloudpath));
      +            $folder_list = attach_folder_select_list($channel['channel_id']);
      +
      +            $o = replace_macros(Theme::get_template('attach_edit.tpl'), [
      +                '$header' => t('Edit file permissions'),
      +                '$file' => $f,
      +                '$cloudpath' => z_root() . '/' . $encoded_path,
      +                '$uid' => $channel['channel_id'],
      +                '$channelnick' => $channel['channel_address'],
      +                '$permissions' => t('Permissions'),
      +                '$aclselect' => $aclselect_e,
      +                '$allow_cid' => acl2json($f['allow_cid']),
      +                '$allow_gid' => acl2json($f['allow_gid']),
      +                '$deny_cid' => acl2json($f['deny_cid']),
      +                '$deny_gid' => acl2json($f['deny_gid']),
      +                '$lockstate' => $lockstate,
      +                '$newname' => ['newname', t('Change filename to'), '', t('Leave blank to keep the existing filename')],
      +                '$newdir' => ['newdir', t('Move to directory'), $f['folder'], '', $folder_list],
      +                '$permset' => t('Set/edit permissions'),
      +                '$recurse' => ['recurse', t('Include all files and sub folders'), 0, '', [t('No'), t('Yes')]],
      +                '$backlink' => t('Return to file list'),
      +                '$isadir' => $is_a_dir,
      +                '$cpdesc' => t('Copy/paste this code to attach file to a post'),
      +                '$cpldesc' => t('Copy/paste this URL to link file from a web page'),
      +                '$submit' => t('Submit'),
      +                '$attach_btn_title' => t('Share this file'),
      +                '$link_btn_title' => t('Show URL to this file'),
      +                '$notify' => ['notify_edit', t('Show in your contacts shared folder'), 0, '', [t('No'), t('Yes')]],
      +            ]);
      +
      +            echo $o;
      +            killme();
      +        }
      +        goaway(z_root() . '/cloud/' . $which);
      +    }
      +}
      diff --git a/Code/Module/Finger.php b/Code/Module/Finger.php
      new file mode 100644
      index 000000000..45302d397
      --- /dev/null
      +++ b/Code/Module/Finger.php
      @@ -0,0 +1,38 @@
      + t('Webfinger Diagnostic'),
      +            '$resource' => ['resource', t('Lookup address or URL'), $_GET['resource'], EMPTY_STR],
      +            '$submit' => t('Submit')
      +        ]);
      +
      +        if ($_GET['resource']) {
      +            $resource = trim(escape_tags($_GET['resource']));
      +
      +            $result = Webfinger::exec($resource);
      +
      +            $o .= '
      ' . str_replace("\n", '
      ', print_array($result)) . '
      '; + } + return $o; + } +} diff --git a/Code/Module/Follow.php b/Code/Module/Follow.php new file mode 100644 index 000000000..39e7b71bf --- /dev/null +++ b/Code/Module/Follow.php @@ -0,0 +1,175 @@ += 2) { + $abook_id = intval(argv(1)); + if (!$abook_id) { + return; + } + + $r = q( + "select * from abook left join xchan on abook_xchan = xchan_hash where abook_id = %d", + intval($abook_id) + ); + if (!$r) { + return; + } + + $chan = Channel::from_id($r[0]['abook_channel']); + + if (!$chan) { + http_status_exit(404, 'Not found'); + } + + $actor = Activity::encode_person($chan, true, true); + if (!$actor) { + http_status_exit(404, 'Not found'); + } + + // Pleroma requires a unique follow id for every follow and follow response + // instead of our method of re-using the abook_id. This causes issues if they unfollow + // and re-follow so md5 their follow id and slap it on the end so they don't simply discard our + // subsequent accept/reject actions. + + $orig_follow = get_abconfig($chan['channel_id'], $r[0]['xchan_hash'], 'activitypub', 'their_follow_id'); + $orig_follow_type = get_abconfig($chan['channel_id'], $r[0]['xchan_hash'], 'activitypub', 'their_follow_type'); + + as_return_and_die([ + 'id' => z_root() . '/follow/' . $r[0]['abook_id'] . (($orig_follow) ? '/' . md5($orig_follow) : EMPTY_STR), + 'type' => (($orig_follow_type) ? $orig_follow_type : 'Follow'), + 'actor' => $actor, + 'object' => $r[0]['xchan_url'] + ], $chan); + } + + + $uid = local_channel(); + + if (!$uid) { + return; + } + + $url = notags(trim(punify($_REQUEST['url']))); + $return_url = $_SESSION['return_url']; + $confirm = intval($_REQUEST['confirm']); + $interactive = (($_REQUEST['interactive']) ? intval($_REQUEST['interactive']) : 1); + $channel = App::get_channel(); + + if ((strpos($url, 'http') === 0) || strpos($url, 'bear:') === 0 || strpos($url, 'x-zot:') === 0) { + $n = Activity::fetch($url); + if ($n && isset($n['type']) && !ActivityStreams::is_an_actor($n['type'])) { + // set client flag to convert objects to implied activities + $a = new ActivityStreams($n, null, true); + if ( + $a->type === 'Announce' && is_array($a->obj) + && array_key_exists('object', $a->obj) && array_key_exists('actor', $a->obj) + ) { + // This is a relayed/forwarded Activity (as opposed to a shared/boosted object) + // Reparse the encapsulated Activity and use that instead + logger('relayed activity', LOGGER_DEBUG); + $a = new ActivityStreams($a->obj, null, true); + } + + if ($a->is_valid()) { + if (is_array($a->actor) && array_key_exists('id', $a->actor)) { + Activity::actor_store($a->actor['id'], $a->actor); + } + + // ActivityPub sourced items are cacheable + $item = Activity::decode_note($a, true); + + if ($item) { + Activity::store($channel, get_observer_hash(), $a, $item, true); + + $r = q( + "select * from item where mid = '%s' and uid = %d", + dbesc($item['mid']), + intval($uid) + ); + if ($r) { + if ($interactive) { + goaway(z_root() . '/display/' . gen_link_id($item['mid'])); + } else { + $result['success'] = true; + json_return_and_die($result); + } + } + } + } + } + } + + + $result = Connect::connect($channel, $url); + + if ($result['success'] == false) { + if ($result['message']) { + notice($result['message']); + } + if ($interactive) { + goaway($return_url); + } else { + json_return_and_die($result); + } + } + + info(t('Connection added.') . EOL); + + $clone = []; + foreach ($result['abook'] as $k => $v) { + if (strpos($k, 'abook_') === 0) { + $clone[$k] = $v; + } + } + unset($clone['abook_id']); + unset($clone['abook_account']); + unset($clone['abook_channel']); + + $abconfig = load_abconfig($channel['channel_id'], $clone['abook_xchan']); + if ($abconfig) { + $clone['abconfig'] = $abconfig; + } + Libsync::build_sync_packet(0, ['abook' => [$clone]], true); + + $can_view_stream = their_perms_contains($channel['channel_id'], $clone['abook_xchan'], 'view_stream'); + + // If we can view their stream, pull in some posts + + if (($can_view_stream) || ($result['abook']['xchan_network'] === 'rss')) { + Run::Summon(['Onepoll', $result['abook']['abook_id']]); + } + + if ($interactive) { + goaway(z_root() . '/connedit/' . $result['abook']['abook_id'] . '?follow=1'); + } else { + json_return_and_die(['success' => true]); + } + } + + public function get() + { + if (!local_channel()) { + return login(); + } + } +} diff --git a/Code/Module/Followers.php b/Code/Module/Followers.php new file mode 100644 index 000000000..d2f4a3007 --- /dev/null +++ b/Code/Module/Followers.php @@ -0,0 +1,94 @@ + 100) { + $ret = Activity::paged_collection_init($t[0]['total'], App::$query_string); + } else { + $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval(App::$pager['itemspage']), intval(App::$pager['start'])); + + $r = q( + "select * from xchan left join abconfig on abconfig.xchan = xchan_hash left join abook on abook_xchan = xchan_hash where abook_channel = %d and abconfig.chan = %d and abconfig.cat = 'system' and abconfig.k = 'their_perms' and abconfig.v like '%%send_stream%%' and xchan_hash != '%s' and xchan_orphan = 0 and xchan_deleted = 0 and abook_hidden = 0 and abook_pending = 0 and abook_self = 0 $pager_sql", + intval($channel['channel_id']), + intval($channel['channel_id']), + dbesc($channel['channel_hash']) + ); + + $this->results = $r; + $ret = Activity::encode_follow_collection($r, App::$query_string, 'OrderedCollection', $t[0]['total']); + } + + if (ActivityStreams::is_as_request()) { + as_return_and_die($ret, $channel); + } + } + + function get() { + + if ($this->results) { + foreach ($this->results as $member) { + $members[] = micropro($member, true, 'mpgroup', 'card'); + } + } + $o = replace_macros(Theme::get_template('listmembers.tpl'), [ + '$title' => t('List members'), + '$members' => $members + ]); + return $o; + } +} diff --git a/Code/Module/Following.php b/Code/Module/Following.php new file mode 100644 index 000000000..354498e38 --- /dev/null +++ b/Code/Module/Following.php @@ -0,0 +1,97 @@ + 100) { + $ret = Activity::paged_collection_init($t[0]['total'], App::$query_string); + } else { + $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval(App::$pager['itemspage']), intval(App::$pager['start'])); + + $r = q( + "select * from xchan left join abconfig on abconfig.xchan = xchan_hash left join abook on abook_xchan = xchan_hash where abook_channel = %d and abconfig.chan = %d and abconfig.cat = 'system' and abconfig.k = 'my_perms' and abconfig.v like '%%send_stream%%' and xchan_hash != '%s' and xchan_orphan = 0 and xchan_deleted = 0 and abook_hidden = 0 and abook_pending = 0 and abook_self = 0 $pager_sql", + intval($channel['channel_id']), + intval($channel['channel_id']), + dbesc($channel['channel_hash']) + ); + + $this->results = $r; + + $ret = Activity::encode_follow_collection($r, App::$query_string, 'OrderedCollection', $t[0]['total']); + } + + if (ActivityStreams::is_as_request()) { + as_return_and_die($ret, $channel); + } + } + + function get() { + + if ($this->results) { + foreach ($this->results as $member) { + $members[] = micropro($member, true, 'mpgroup', 'card'); + } + } + $o = replace_macros(Theme::get_template('listmembers.tpl'), [ + '$title' => t('List members'), + '$members' => $members + ]); + return $o; + } + +} diff --git a/Code/Module/Future.php b/Code/Module/Future.php new file mode 100644 index 000000000..aecc72190 --- /dev/null +++ b/Code/Module/Future.php @@ -0,0 +1,21 @@ +' . $desc . ''; + + return $text; + } +} diff --git a/Code/Module/Getfile.php b/Code/Module/Getfile.php new file mode 100644 index 000000000..3eb713f7e --- /dev/null +++ b/Code/Module/Getfile.php @@ -0,0 +1,148 @@ + 1) { + $verify_hash = argv(1); + if ($verify_hash !== $resource) { + logger('resource mismatch'); + killme(); + } + } + + if (!$hash) { + logger('no sender hash'); + killme(); + } + + foreach (['REDIRECT_REMOTE_USER', 'HTTP_AUTHORIZATION'] as $head) { + if (array_key_exists($head, $_SERVER) && substr(trim($_SERVER[$head]), 0, 9) === 'Signature') { + if ($head !== 'HTTP_AUTHORIZATION') { + $_SERVER['HTTP_AUTHORIZATION'] = $_SERVER[$head]; + continue; + } + + $verified = HTTPSig::verify(''); + if ($verified && $verified['header_signed'] && $verified['header_valid']) { + $r = q( + "select hubloc_hash from hubloc where hubloc_id_url = '%s' or hubloc_addr = '%s' limit 1", + dbesc($verified['signer']), + dbesc(str_replace('acct:', '', $verified['signer'])) + ); + if ($r && $r[0]['hubloc_hash'] === $hash) { + $header_verified = true; + } + } + } + } + + if (!$header_verified) { + http_status_exit(403, 'Permission denied'); + } + + $channel = Channel::from_hash($hash); + + if (!$channel) { + logger('error: missing info'); + killme(); + } + + if ($resolution > 0) { + $r = q( + "select * from photo where resource_id = '%s' and uid = %d and imgscale = %d limit 1", + dbesc($resource), + intval($channel['channel_id']), + intval($resolution) + ); + if ($r) { + header('Content-type: ' . $r[0]['mimetype']); + + if (intval($r[0]['os_storage'])) { + $fname = dbunescbin($r[0]['content']); + if (strpos($fname, 'store') !== false) { + $istream = fopen($fname, 'rb'); + } else { + $istream = fopen('store/' . $channel['channel_address'] . '/' . $fname, 'rb'); + } + $ostream = fopen('php://output', 'wb'); + if ($istream && $ostream) { + pipe_streams($istream, $ostream); + fclose($istream); + fclose($ostream); + } + } else { + echo dbunescbin($r[0]['content']); + } + } + killme(); + } + + $r = attach_by_hash($resource, $channel['channel_hash'], $revision); + + if (!$r['success']) { + logger('attach_by_hash failed: ' . $r['message']); + notice($r['message'] . EOL); + return; + } + + header('Content-type: ' . $r['data']['filetype']); + header('Content-Disposition: attachment; filename="' . $r['data']['filename'] . '"'); + if (intval($r['data']['os_storage'])) { + $fname = dbunescbin($r['data']['content']); + if (strpos($fname, 'store') !== false) { + $istream = fopen($fname, 'rb'); + } else { + $istream = fopen('store/' . $channel['channel_address'] . '/' . $fname, 'rb'); + } + $ostream = fopen('php://output', 'wb'); + if ($istream && $ostream) { + pipe_streams($istream, $ostream); + fclose($istream); + fclose($ostream); + } + } else { + echo dbunescbin($r['data']['content']); + } + killme(); + } +} diff --git a/Code/Module/Hashtags.php b/Code/Module/Hashtags.php new file mode 100644 index 000000000..cb3097702 --- /dev/null +++ b/Code/Module/Hashtags.php @@ -0,0 +1,37 @@ + $rv['term']]; + } + } + + json_return_and_die($result); + } +} diff --git a/Code/Module/Hcard.php b/Code/Module/Hcard.php new file mode 100644 index 000000000..fc8d35be5 --- /dev/null +++ b/Code/Module/Hcard.php @@ -0,0 +1,78 @@ + 1) { + $which = argv(1); + } else { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + + logger('hcard_request: ' . $which, LOGGER_DEBUG); + + $profile = ''; + $channel = App::get_channel(); + + if ((local_channel()) && (argc() > 2) && (argv(2) === 'view')) { + $which = $channel['channel_address']; + $profile = argv(1); + $r = q( + "select profile_guid from profile where id = %d and uid = %d limit 1", + intval($profile), + intval(local_channel()) + ); + if (!$r) { + $profile = ''; + } + $profile = $r[0]['profile_guid']; + } + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/atom+xml', + 'title' => t('Posts and comments'), + 'href' => z_root() . '/feed/' . $which + ]); + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/atom+xml', + 'title' => t('Only posts'), + 'href' => z_root() . '/feed/' . $which . '?f=&top=1' + ]); + + + if (!$profile) { + $x = q( + "select channel_id as profile_uid from channel where channel_address = '%s' limit 1", + dbesc(argv(1)) + ); + if ($x) { + App::$profile = $x[0]; + } + } + + Libprofile::load($which, $profile); + } + + + public function get() + { + + $x = new \Code\Widget\Profile(); + return $x->widget([]); + } +} diff --git a/Code/Module/Help.php b/Code/Module/Help.php new file mode 100644 index 000000000..362bd7fd3 --- /dev/null +++ b/Code/Module/Help.php @@ -0,0 +1,145 @@ +'; + $o .= '
      '; + $o .= '

      ' . t('Documentation Search') . ' - ' . htmlspecialchars($_REQUEST['search']) . '

      '; + $o .= '
      '; + $o .= '
      '; + + $r = search_doc_files($_REQUEST['search']); + if ($r) { + $o .= '
        '; + foreach ($r as $rr) { + $dirname = dirname($rr['v']); + $fname = basename($rr['v']); + $fname = substr($fname, 0, strrpos($fname, '.')); + $path = trim(substr($dirname, 4), '/'); + + $o .= '
      • ' . ucwords(str_replace('_', ' ', notags($fname))) . '
        ' + . '' . 'help/' . (($path) ? $path . '/' : '') . $fname . '
        ' + . '...' . str_replace('$Projectname', System::get_platform_name(), $rr['text']) . '...

      • '; + } + $o .= '
      '; + $o .= '
      '; + $o .= ''; + } + + return $o; + } + + + if (argc() > 2 && argv(argc() - 2) === 'assets') { + $path = ''; + for ($x = 1; $x < argc(); $x++) { + if (strlen($path)) { + $path .= '/'; + } + $path .= argv($x); + } + $realpath = 'doc/' . $path; + //Set the content-type header as appropriate + $imageInfo = getimagesize($realpath); + switch ($imageInfo[2]) { + case IMAGETYPE_JPEG: + header("Content-Type: image/jpeg"); + break; + case IMAGETYPE_GIF: + header("Content-Type: image/gif"); + break; + case IMAGETYPE_PNG: + header("Content-Type: image/png"); + break; + default: + break; + } + header("Content-Length: " . filesize($realpath)); + + // dump the picture and stop the script + readfile($realpath); + killme(); + } + + if (argc() === 1) { + $files = self::listdir('doc'); + + if ($files) { + foreach ($files as $file) { + if ((!strpos($file, '/site/')) && file_exists(str_replace('doc/', 'doc/site/', $file))) { + continue; + } + if (strpos($file, 'README')) { + continue; + } + if (preg_match('/\/(..|..\-..)\//', $file, $matches)) { + $language = $matches[1]; + } else { + $language = t('Unknown language'); + } + if ($language === substr(App::$language, 0, 2)) { + $language = ''; + } + + $link = str_replace(['doc/', '.mc'], ['help/', ''], $file); + if (strpos($link, '/global/') !== false || strpos($link, '/media/') !== false) { + continue; + } + $content .= '' . (($language) ? " [$language]" : '') . EOL; + } + } + } else { + $content = get_help_content(); + } + + + return replace_macros(Theme::get_template('help.tpl'), array( + '$title' => t('$Projectname Documentation'), + '$tocHeading' => t('Contents'), + '$content' => $content, + '$heading' => $heading, + '$language' => $language + )); + } + + public static function listdir($path) + { + $results = []; + $handle = opendir($path); + if (!$handle) { + return $results; + } + while (false !== ($file = readdir($handle))) { + if ($file === '.' || $file === '..') { + continue; + } + if (is_dir($path . '/' . $file)) { + $results = array_merge($results, self::listdir($path . '/' . $file)); + } else { + $results[] = $path . '/' . $file; + } + } + closedir($handle); + return $results; + } +} diff --git a/Code/Module/Home.php b/Code/Module/Home.php new file mode 100644 index 000000000..1d37bcafc --- /dev/null +++ b/Code/Module/Home.php @@ -0,0 +1,173 @@ + [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], Activity::encode_site()); + + $headers = []; + $headers['Content-Type'] = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'; + $x['signature'] = LDSignatures::sign($x, ['channel_address' => z_root(), 'channel_prvkey' => get_config('system', 'prvkey')]); + $ret = json_encode($x, JSON_UNESCAPED_SLASHES); + logger('data: ' . jindent($ret), LOGGER_DATA); + $headers['Date'] = datetime_convert('UTC', 'UTC', 'now', 'D, d M Y H:i:s \\G\\M\\T'); + $headers['Digest'] = HTTPSig::generate_digest_header($ret); + $headers['(request-target)'] = strtolower($_SERVER['REQUEST_METHOD']) . ' ' . $_SERVER['REQUEST_URI']; + + $h = HTTPSig::create_sig($headers, get_config('system', 'prvkey'), z_root()); + HTTPSig::set_headers($h); + + echo $ret; + killme(); + } + + if (Libzot::is_zot_request()) { + $channel = Channel::get_system(); + $sigdata = HTTPSig::verify(file_get_contents('php://input'), EMPTY_STR, 'zot6'); + + if ($sigdata && $sigdata['signer'] && $sigdata['header_valid']) { + $data = json_encode(Libzot::zotinfo(['guid_hash' => $channel['channel_hash'], 'target_url' => $sigdata['signer']])); + $s = q( + "select site_crypto, hubloc_sitekey from site left join hubloc on hubloc_url = site_url where hubloc_id_url = '%s' and hubloc_network in ('zot6','nomad') limit 1", + dbesc($sigdata['signer']) + ); + + if ($s && $s[0]['hubloc_sitekey'] && $s[0]['site_crypto']) { + $data = json_encode(Crypto::encapsulate($data, $s[0]['hubloc_sitekey'], Libzot::best_algorithm($s[0]['site_crypto']))); + } + } else { + $data = json_encode(Libzot::zotinfo(['guid_hash' => $channel['channel_hash']])); + } + + $headers = [ + 'Content-Type' => 'application/x-nomad+json', + 'Digest' => HTTPSig::generate_digest_header($data), + '(request-target)' => strtolower($_SERVER['REQUEST_METHOD']) . ' ' . $_SERVER['REQUEST_URI'] + ]; + $h = HTTPSig::create_sig($headers, get_config('system', 'prvkey'), z_root()); + HTTPSig::set_headers($h); + echo $data; + killme(); + } + + + $splash = ((argc() > 1 && argv(1) === 'splash') ? true : false); + + $channel = App::get_channel(); + if (local_channel() && $channel && $channel['xchan_url'] && !$splash) { + $dest = $channel['channel_startpage']; + if (!$dest) { + $dest = get_pconfig(local_channel(), 'system', 'startpage'); + } + if (!$dest) { + $dest = get_config('system', 'startpage'); + } + if (!$dest) { + $dest = z_root() . '/stream'; + } + goaway($dest); + } + + if (remote_channel() && (!$splash) && $_SESSION['atoken']) { + $r = q( + "select * from atoken where atoken_id = %d", + intval($_SESSION['atoken']) + ); + if ($r) { + $x = Channel::from_id($r[0]['atoken_uid']); + if ($x) { + goaway(z_root() . '/channel/' . $x['channel_address']); + } + } + } + + + if (get_account_id() && !$splash) { + goaway(z_root() . '/new_channel'); + } + } + + + public function get() + { + + $o = EMPTY_STR; + + if (x($_SESSION, 'theme')) { + unset($_SESSION['theme']); + } + if (x($_SESSION, 'mobile_theme')) { + unset($_SESSION['mobile_theme']); + } + + $splash = ((argc() > 1 && argv(1) === 'splash') ? true : false); + + Hook::call('home_content', $o); + if ($o) { + return $o; + } + + $frontpage = get_config('system', 'frontpage'); + if ($frontpage) { + if (strpos($frontpage, 'include:') !== false) { + $file = trim(str_replace('include:', '', $frontpage)); + if (file_exists($file)) { + App::$page['template'] = 'full'; + App::$page['title'] = t('$Projectname'); + $o .= file_get_contents($file); + return $o; + } + } + if (strpos($frontpage, 'http') !== 0) { + $frontpage = z_root() . '/' . $frontpage; + } + if (intval(get_config('system', 'mirror_frontpage'))) { + $o = '' . t('$Projectname') . ''; + echo $o; + killme(); + } + goaway($frontpage); + } + + + $sitename = get_config('system', 'sitename'); + if ($sitename) { + $o .= '

      ' . sprintf(t('Welcome to %s'), $sitename) . '

      '; + } + + $loginbox = get_config('system', 'login_on_homepage'); + if (intval($loginbox) || $loginbox === false) { + $o .= login(true); + } + + return $o; + } +} diff --git a/Code/Module/Hostxrd.php b/Code/Module/Hostxrd.php new file mode 100644 index 000000000..0d9f38905 --- /dev/null +++ b/Code/Module/Hostxrd.php @@ -0,0 +1,32 @@ + App::get_hostname(), + '$zroot' => z_root() + )); + $arr = array('xrd' => $x); + Hook::call('hostxrd', $arr); + + echo $arr['xrd']; + killme(); + } +} diff --git a/Code/Module/Hq.php b/Code/Module/Hq.php new file mode 100644 index 000000000..900c1cf80 --- /dev/null +++ b/Code/Module/Hq.php @@ -0,0 +1,321 @@ +loading) { + $_SESSION['loadtime_hq'] = datetime_convert(); + } + + if (argc() > 1 && argv(1) !== 'load') { + $item_hash = argv(1); + } + + if ($_REQUEST['mid']) { + $item_hash = $_REQUEST['mid']; + } + + $item_normal = item_normal(); + $item_normal_update = item_normal_update(); + + if (!$item_hash) { + $r = q( + "SELECT mid FROM item + WHERE uid = %d $item_normal + AND mid = parent_mid + ORDER BY created DESC LIMIT 1", + intval(local_channel()) + ); + + if ($r[0]['mid']) { + $item_hash = gen_link_id($r[0]['mid']); + } + } + + if ($item_hash) { + $item_hash = unpack_link_id($item_hash); + + $target_item = null; + + $r = q( + "select id, uid, mid, parent_mid, thr_parent, verb, item_type, item_deleted, item_blocked from item where mid like '%s' limit 1", + dbesc($item_hash . '%') + ); + + if ($r) { + $target_item = $r[0]; + } + + //if the item is to be moderated redirect to /moderate + if ($target_item['item_blocked'] == ITEM_MODERATED) { + goaway(z_root() . '/moderate/' . $target_item['id']); + } + + $static = ((array_key_exists('static', $_REQUEST)) ? intval($_REQUEST['static']) : 0); + + $simple_update = (($this->updating) ? " AND item_unseen = 1 " : ''); + + if ($this->updating && $_SESSION['loadtime_hq']) { + $simple_update = " AND item.changed > '" . datetime_convert('UTC', 'UTC', $_SESSION['loadtime_hq']) . "' "; + } + + if ($static && $simple_update) { + $simple_update .= " and item_thread_top = 0 and author_xchan = '" . protect_sprintf(get_observer_hash()) . "' "; + } + + $sys = Channel::get_system(); + $sql_extra = item_permissions_sql($sys['channel_id']); + + $sys_item = false; + } + + if (!$this->updating) { + $channel = App::get_channel(); + + $channel_acl = [ + 'allow_cid' => $channel['channel_allow_cid'], + 'allow_gid' => $channel['channel_allow_gid'], + 'deny_cid' => $channel['channel_deny_cid'], + 'deny_gid' => $channel['channel_deny_gid'] + ]; + + $x = [ + 'is_owner' => true, + 'allow_location' => ((intval(get_pconfig($channel['channel_id'], 'system', 'use_browser_location'))) ? '1' : ''), + 'default_location' => $channel['channel_location'], + 'nickname' => $channel['channel_address'], + 'lockstate' => (($group || $cid || $channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 'lock' : 'unlock'), + 'acl' => Libacl::populate($channel_acl, true, PermissionDescription::fromGlobalPermission('view_stream'), Libacl::get_post_aclDialogDescription(), 'acl_dialog_post'), + 'permissions' => $channel_acl, + 'bang' => '', + 'visitor' => true, + 'profile_uid' => local_channel(), + 'return_path' => 'hq', + 'expanded' => true, + 'editor_autocomplete' => true, + 'bbco_autocomplete' => 'bbcode', + 'bbcode' => true, + 'jotnets' => true, + 'reset' => t('Reset form') + ]; + + $o = replace_macros( + Theme::get_template("hq.tpl"), + [ + '$no_messages' => (($target_item) ? false : true), + '$no_messages_label' => [t('Welcome to $Projectname!'), t('You have got no unseen posts...')], + '$editor' => status_editor($x) + ] + ); + } + + if (!$this->updating && !$this->loading) { + Navbar::set_selected('HQ'); + + $static = ((local_channel()) ? Channel::manual_conv_update(local_channel()) : 1); + + if ($target_item) { + // if the target item is not a post (eg a like) we want to address its thread parent + $mid = ((($target_item['verb'] == ACTIVITY_LIKE) || ($target_item['verb'] == ACTIVITY_DISLIKE)) ? $target_item['thr_parent'] : $target_item['mid']); + + // if we got a decoded hash we must encode it again before handing to javascript + $mid = gen_link_id($mid); + } else { + $mid = ''; + } + + $o .= '
      ' . "\r\n"; + $o .= "\r\n"; + + App::$page['htmlhead'] .= replace_macros(Theme::get_template("build_query.tpl"), [ + '$baseurl' => z_root(), + '$pgtype' => 'hq', + '$uid' => local_channel(), + '$gid' => '0', + '$cid' => '0', + '$cmin' => '0', + '$cmax' => '99', + '$star' => '0', + '$liked' => '0', + '$conv' => '0', + '$spam' => '0', + '$fh' => '0', + '$dm' => '0', + '$nouveau' => '0', + '$wall' => '0', + '$draft' => '0', + '$static' => $static, + '$page' => 1, + '$list' => ((x($_REQUEST, 'list')) ? intval($_REQUEST['list']) : 0), + '$search' => '', + '$xchan' => '', + '$order' => '', + '$file' => '', + '$cats' => '', + '$tags' => '', + '$dend' => '', + '$dbegin' => '', + '$verb' => '', + '$net' => '', + '$mid' => (($mid) ? urlencode($mid) : '') + ]); + } + + $updateable = false; + + if ($this->loading && $target_item) { + $r = null; + + $r = q( + "SELECT item.id AS item_id FROM item + WHERE uid = %d + AND mid = '%s' + $item_normal + LIMIT 1", + intval(local_channel()), + dbesc($target_item['parent_mid']) + ); + + if ($r) { + $updateable = true; + } + + if (!$r) { + $sys_item = true; + + $r = q( + "SELECT item.id AS item_id FROM item + LEFT JOIN abook ON item.author_xchan = abook.abook_xchan + WHERE mid = '%s' AND item.uid = %d $item_normal + AND (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra LIMIT 1", + dbesc($target_item['parent_mid']), + intval($sys['channel_id']) + ); + } + } elseif ($this->updating && $target_item) { + $r = null; + + $r = q( + "SELECT item.parent AS item_id FROM item + WHERE uid = %d + AND parent_mid = '%s' + $item_normal_update + $simple_update + LIMIT 1", + intval(local_channel()), + dbesc($target_item['parent_mid']) + ); + + if ($r) { + $updateable = true; + } + + if (!$r) { + $sys_item = true; + + $r = q( + "SELECT item.parent AS item_id FROM item + LEFT JOIN abook ON item.author_xchan = abook.abook_xchan + WHERE mid = '%s' AND item.uid = %d $item_normal_update $simple_update + AND (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra LIMIT 1", + dbesc($target_item['parent_mid']), + intval($sys['channel_id']) + ); + } + + $_SESSION['loadtime_hq'] = datetime_convert(); + } else { + $r = []; + } + + if ($r) { + $items = q( + "SELECT item.*, item.id AS item_id + FROM item + WHERE parent = '%s' $item_normal ", + dbesc($r[0]['item_id']) + ); + + xchan_query($items, true, (($sys_item) ? local_channel() : 0)); + $items = fetch_post_tags($items, true); + $items = conv_sort($items, 'created'); + } else { + $items = []; + } + + $o .= conversation($items, 'hq', $this->updating, 'client'); + + if ($updateable) { + $x = q( + "UPDATE item SET item_unseen = 0 WHERE item_unseen = 1 AND uid = %d AND parent = %d ", + intval(local_channel()), + intval($r[0]['item_id']) + ); + } + + $o .= '
      '; + + return $o; + } +} diff --git a/Code/Module/Id.php b/Code/Module/Id.php new file mode 100644 index 000000000..84c1dc75d --- /dev/null +++ b/Code/Module/Id.php @@ -0,0 +1,119 @@ + 2) { + $item_id = argv(2); + } + + $portable_id = EMPTY_STR; + + $sigdata = HTTPSig::verify(EMPTY_STR); + if ($sigdata['portable_id'] && $sigdata['header_valid']) { + $portable_id = $sigdata['portable_id']; + } + + $chan = Channel::from_hash($request_portable_id); + + if ($chan) { + $channel_id = $chan['channel_id']; + if (!$item_id) { + $handler = new Channel(); + App::$argc = 2; + App::$argv[0] = 'channel'; + App::$argv[1] = $chan['channel_address']; + $handler->init(); + } + } else { + http_status_exit(404, 'Not found'); + } + + + $item_normal = " and item.item_hidden = 0 and item.item_type = 0 and item.item_unpublished = 0 and item.item_delayed = 0 and item.item_blocked = 0 "; + + $sql_extra = item_permissions_sql(0); + + $r = q( + "select * from item where uuid = '%s' $item_normal $sql_extra and uid = %d limit 1", + dbesc($item_id), + intval($channel_id) + ); + if (!$r) { + $r = q( + "select * from item where uuid = '%s' $item_normal and uid = %d limit 1", + dbesc($item_id), + intval($channel_id) + ); + if ($r) { + http_status_exit(403, 'Forbidden'); + } + http_status_exit(404, 'Not found'); + } + + if (!perm_is_allowed($chan['channel_id'], get_observer_hash(), 'view_stream')) { + http_status_exit(403, 'Forbidden'); + } + + xchan_query($r, true); + $items = fetch_post_tags($r, true); + + $i = Activity::encode_item($items[0], (get_config('system', 'activitypub', ACTIVITYPUB_ENABLED) ? true : false)); + + if (!$i) { + http_status_exit(404, 'Not found'); + } + + $x = array_merge(['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], $i); + + $headers = []; + $headers['Content-Type'] = 'application/x-nomad+json'; + $ret = json_encode($x, JSON_UNESCAPED_SLASHES); + $headers['Digest'] = HTTPSig::generate_digest_header($ret); + $headers['(request-target)'] = strtolower($_SERVER['REQUEST_METHOD']) . ' ' . $_SERVER['REQUEST_URI']; + $h = HTTPSig::create_sig($headers, $chan['channel_prvkey'], Channel::url($chan)); + HTTPSig::set_headers($h); + echo $ret; + killme(); + } + } +} diff --git a/Code/Module/Impel.php b/Code/Module/Impel.php new file mode 100644 index 000000000..4a1c406f0 --- /dev/null +++ b/Code/Module/Impel.php @@ -0,0 +1,213 @@ + false); + + if (!local_channel()) { + json_return_and_die($ret); + } + + logger('impel: ' . print_r($_REQUEST, true), LOGGER_DATA); + + $elm = $_REQUEST['element']; + $x = base64url_decode($elm); + if (!$x) { + json_return_and_die($ret); + } + + $j = json_decode($x, true); + if (!$j) { + json_return_and_die($ret); + } + + // logger('element: ' . print_r($j,true)); + + $channel = App::get_channel(); + + $arr = []; + $is_menu = false; + + // a portable menu has its links rewritten with the local baseurl + $portable_menu = false; + + switch ($j['type']) { + case 'webpage': + $arr['item_type'] = ITEM_TYPE_WEBPAGE; + $namespace = 'WEBPAGE'; + $installed_type = t('webpage'); + break; + case 'block': + $arr['item_type'] = ITEM_TYPE_BLOCK; + $namespace = 'BUILDBLOCK'; + $installed_type = t('block'); + break; + case 'layout': + $arr['item_type'] = ITEM_TYPE_PDL; + $namespace = 'PDL'; + $installed_type = t('layout'); + break; + case 'portable-menu': + $portable_menu = true; + // fall through + case 'menu': + $is_menu = true; + $installed_type = t('menu'); + break; + default: + logger('mod_impel: unrecognised element type' . print_r($j, true)); + break; + } + + if ($is_menu) { + $m = []; + $m['menu_channel_id'] = local_channel(); + $m['menu_name'] = $j['pagetitle']; + $m['menu_desc'] = $j['desc']; + if ($j['created']) { + $m['menu_created'] = datetime_convert($j['created']); + } + if ($j['edited']) { + $m['menu_edited'] = datetime_convert($j['edited']); + } + + $m['menu_flags'] = 0; + if ($j['flags']) { + if (in_array('bookmark', $j['flags'])) { + $m['menu_flags'] |= MENU_BOOKMARK; + } + if (in_array('system', $j['flags'])) { + $m['menu_flags'] |= MENU_SYSTEM; + } + } + + $menu_id = Menu::create($m); + + if ($menu_id) { + if (is_array($j['items'])) { + foreach ($j['items'] as $it) { + $mitem = []; + + $mitem['mitem_link'] = str_replace('[channelurl]', z_root() . '/channel/' . $channel['channel_address'], $it['link']); + $mitem['mitem_link'] = str_replace('[pageurl]', z_root() . '/page/' . $channel['channel_address'], $it['link']); + $mitem['mitem_link'] = str_replace('[cloudurl]', z_root() . '/cloud/' . $channel['channel_address'], $it['link']); + $mitem['mitem_link'] = str_replace('[baseurl]', z_root(), $it['link']); + + $mitem['mitem_desc'] = escape_tags($it['desc']); + $mitem['mitem_order'] = intval($it['order']); + if (is_array($it['flags'])) { + $mitem['mitem_flags'] = 0; + if (in_array('zid', $it['flags'])) { + $mitem['mitem_flags'] |= MENU_ITEM_ZID; + } + if (in_array('new-window', $it['flags'])) { + $mitem['mitem_flags'] |= MENU_ITEM_NEWWIN; + } + if (in_array('chatroom', $it['flags'])) { + $mitem['mitem_flags'] |= MENU_ITEM_CHATROOM; + } + } + MenuItem::add($menu_id, local_channel(), $mitem); + } + if ($j['edited']) { + $x = q( + "update menu set menu_edited = '%s' where menu_id = %d and menu_channel_id = %d", + dbesc(datetime_convert('UTC', 'UTC', $j['edited'])), + intval($menu_id), + intval(local_channel()) + ); + } + } + $ret['success'] = true; + } + $x = $ret; + } else { + $arr['uid'] = local_channel(); + $arr['aid'] = $channel['channel_account_id']; + $arr['title'] = $j['title']; + $arr['body'] = $j['body']; + $arr['term'] = $j['term']; + $arr['layout_mid'] = $j['layout_mid']; + $arr['created'] = datetime_convert('UTC', 'UTC', $j['created']); + $arr['edited'] = datetime_convert('UTC', 'UTC', $j['edited']); + $arr['owner_xchan'] = get_observer_hash(); + $arr['author_xchan'] = (($j['author_xchan']) ? $j['author_xchan'] : get_observer_hash()); + $arr['mimetype'] = (($j['mimetype']) ? $j['mimetype'] : 'text/x-multicode'); + + if (!$j['mid']) { + $j['uuid'] = new_uuid(); + $j['mid'] = z_root() . '/item/' . $j['uuid']; + } + + $arr['uuid'] = $j['uuid']; + $arr['mid'] = $arr['parent_mid'] = $j['mid']; + + + if ($j['pagetitle']) { + $pagetitle = strtolower(URLify::transliterate($j['pagetitle'])); + } + + // Verify ability to use html or php!!! + + $execflag = ((intval($channel['channel_id']) == intval(local_channel()) && ($channel['channel_pageflags'] & PAGE_ALLOWCODE)) ? true : false); + + $i = q( + "select id, edited, item_deleted from item where mid = '%s' and uid = %d limit 1", + dbesc($arr['mid']), + intval(local_channel()) + ); + + IConfig::Set($arr, 'system', $namespace, (($pagetitle) ? $pagetitle : substr($arr['mid'], 0, 16)), true); + + if ($i) { + $arr['id'] = $i[0]['id']; + // don't update if it has the same timestamp as the original + if ($arr['edited'] > $i[0]['edited']) { + $x = item_store_update($arr, $execflag); + } + } else { + if (($i) && (intval($i[0]['item_deleted']))) { + // was partially deleted already, finish it off + q( + "delete from item where mid = '%s' and uid = %d", + dbesc($arr['mid']), + intval(local_channel()) + ); + } else { + $x = item_store($arr, $execflag); + } + } + + if ($x && $x['success']) { + $item_id = $x['item_id']; + } + } + + if ($x['success']) { + $ret['success'] = true; + info(sprintf(t('%s element installed'), $installed_type)); + } else { + notice(sprintf(t('%s element installation failed'), $installed_type)); + } + + //??? should perhaps return ret? + + json_return_and_die(true); + } +} diff --git a/Code/Module/Import.php b/Code/Module/Import.php new file mode 100644 index 000000000..a556b1b45 --- /dev/null +++ b/Code/Module/Import.php @@ -0,0 +1,711 @@ + $email . ':' . $password]; + $ret = z_fetch_url($api_path, $binary, $redirects, $opts); + if ($ret['success']) { + $data = $ret['body']; + } else { + notice(t('Unable to download data from old server') . EOL); + return; + } + } + + if (!$data) { + logger('Empty import file.'); + notice(t('Imported file is empty.') . EOL); + return; + } + + $data = json_decode($data, true); + + //logger('import: data: ' . print_r($data,true)); + //print_r($data); + + + // handle Friendica export + + if (array_path_exists('user/parent-uid', $data)) { + $settings = ['account_id' => $account_id, 'sieze' => 1, 'newname' => $newname]; + $f = new Friendica($data, $settings); + + return; + } + + if (!array_key_exists('compatibility', $data)) { + Hook::call('import_foreign_channel_data', $data); + if ($data['handled']) { + return; + } + } + + $codebase = 'zap'; + + if ((!array_path_exists('compatibility/codebase', $data)) || $data['compatibility']['codebase'] !== $codebase) { + notice('Data export format is not compatible with this software'); + return; + } + + if ($moving) { + $seize = 1; + } + + // import channel + + $relocate = ((array_key_exists('relocate', $data)) ? $data['relocate'] : null); + + if (array_key_exists('channel', $data)) { + $max_identities = ServiceClass::account_fetch($account_id, 'total_identities'); + + if ($max_identities !== false) { + $r = q( + "select channel_id from channel where channel_account_id = %d and channel_removed = 0 ", + intval($account_id) + ); + if ($r && count($r) > $max_identities) { + notice(sprintf(t('Your service plan only allows %d channels.'), $max_identities) . EOL); + return; + } + } + + if ($newname) { + $x = false; + + if (get_config('system', 'unicode_usernames')) { + $x = punify(mb_strtolower($newname)); + } + + if ((!$x) || strlen($x) > 64) { + $x = strtolower(URLify::transliterate($newname)); + } else { + $x = $newname; + } + $newname = $x; + } + + $channel = import_channel($data['channel'], $account_id, $seize, $newname); + } else { + $moving = false; + $channel = App::get_channel(); + } + + if (!$channel) { + logger('Channel not found. ' . print_r($channel, true)); + notice(t('No channel. Import failed.') . EOL); + return; + } + + if (is_array($data['config'])) { + import_config($channel, $data['config']); + } + + logger('import step 2'); + + if (array_key_exists('channel', $data)) { + if ($data['photo']) { + import_channel_photo(base64url_decode($data['photo']['data']), $data['photo']['type'], $account_id, $channel['channel_id']); + } + + if (is_array($data['profile'])) { + import_profiles($channel, $data['profile']); + } + } + + logger('import step 3'); + + // import xchans and contact photos + // This *must* be done before importing hublocs + + if (array_key_exists('channel', $data) && $seize) { + // replace any existing xchan we may have on this site if we're seizing control + + $r = q( + "delete from xchan where xchan_hash = '%s'", + dbesc($channel['channel_hash']) + ); + + $r = xchan_store_lowlevel( + [ + 'xchan_hash' => $channel['channel_hash'], + 'xchan_guid' => $channel['channel_guid'], + 'xchan_guid_sig' => $channel['channel_guid_sig'], + 'xchan_pubkey' => $channel['channel_pubkey'], + 'xchan_photo_l' => z_root() . "/photo/profile/l/" . $channel['channel_id'], + 'xchan_photo_m' => z_root() . "/photo/profile/m/" . $channel['channel_id'], + 'xchan_photo_s' => z_root() . "/photo/profile/s/" . $channel['channel_id'], + 'xchan_addr' => Channel::get_webfinger($channel), + 'xchan_url' => z_root() . '/channel/' . $channel['channel_address'], + 'xchan_connurl' => z_root() . '/poco/' . $channel['channel_address'], + 'xchan_follow' => z_root() . '/follow?f=&url=%s', + 'xchan_name' => $channel['channel_name'], + 'xchan_network' => 'nomad', + 'xchan_updated' => datetime_convert(), + 'xchan_photo_date' => datetime_convert(), + 'xchan_name_date' => datetime_convert() + ] + ); + } + + logger('import step 4'); + + // import xchans + $xchans = $data['xchan']; + if ($xchans) { + foreach ($xchans as $xchan) { + // Provide backward compatibility for zot11 based projects + + if ($xchan['xchan_network'] === 'nomad' && version_compare(ZOT_REVISION, '10.0') <= 0) { + $xchan['xchan_network'] = 'zot6'; + } + + $hash = Libzot::make_xchan_hash($xchan['xchan_guid'], $xchan['xchan_pubkey']); + + if (in_array($xchan['xchan_network'], ['nomad', 'zot6']) && $hash !== $xchan['xchan_hash']) { + logger('forged xchan: ' . print_r($xchan, true)); + continue; + } + + $r = q( + "select xchan_hash from xchan where xchan_hash = '%s' limit 1", + dbesc($xchan['xchan_hash']) + ); + if ($r) { + continue; + } + xchan_store_lowlevel($xchan); + + + if ($xchan['xchan_hash'] === $channel['channel_hash']) { + $r = q( + "update xchan set xchan_updated = '%s', xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s' where xchan_hash = '%s'", + dbesc(datetime_convert()), + dbesc(z_root() . '/photo/profile/l/' . $channel['channel_id']), + dbesc(z_root() . '/photo/profile/m/' . $channel['channel_id']), + dbesc(z_root() . '/photo/profile/s/' . $channel['channel_id']), + dbesc($xchan['xchan_hash']) + ); + } else { + $photos = import_remote_xchan_photo($xchan['xchan_photo_l'], $xchan['xchan_hash']); + if ($photos) { + if ($photos[4]) { + $photodate = NULL_DATE; + } else { + $photodate = $xchan['xchan_photo_date']; + } + + $r = q( + "update xchan set xchan_updated = '%s', xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s', xchan_photo_mimetype = '%s', xchan_photo_date = '%s' where xchan_hash = '%s'", + dbesc(datetime_convert()), + dbesc($photos[0]), + dbesc($photos[1]), + dbesc($photos[2]), + dbesc($photos[3]), + dbesc($photodate), + dbesc($xchan['xchan_hash']) + ); + } + } + } + + logger('import step 5'); + } + + + logger('import step 6'); + + + if (is_array($data['hubloc'])) { + import_hublocs($channel, $data['hubloc'], $seize, $moving); + } + + logger('import step 7'); + + // create new hubloc for the new channel at this site + + if (array_key_exists('channel', $data)) { + $r = hubloc_store_lowlevel( + [ + 'hubloc_guid' => $channel['channel_guid'], + 'hubloc_guid_sig' => $channel['channel_guid_sig'], + 'hubloc_id_url' => Channel::url($channel), + 'hubloc_hash' => $channel['channel_hash'], + 'hubloc_addr' => Channel::get_webfinger($channel), + 'hubloc_network' => 'nomad', + 'hubloc_primary' => (($seize) ? 1 : 0), + 'hubloc_url' => z_root(), + 'hubloc_url_sig' => Libzot::sign(z_root(), $channel['channel_prvkey']), + 'hubloc_site_id' => Libzot::make_xchan_hash(z_root(), get_config('system', 'pubkey')), + 'hubloc_host' => App::get_hostname(), + 'hubloc_callback' => z_root() . '/zot', + 'hubloc_sitekey' => get_config('system', 'pubkey'), + 'hubloc_updated' => datetime_convert() + ] + ); + + // reset the original primary hubloc if it is being seized + + if ($seize) { + $r = q( + "update hubloc set hubloc_primary = 0 where hubloc_primary = 1 and hubloc_hash = '%s' and hubloc_url != '%s' ", + dbesc($channel['channel_hash']), + dbesc(z_root()) + ); + } + } + + + $friends = 0; + $feeds = 0; + + // import contacts + $abooks = $data['abook']; + if ($abooks) { + foreach ($abooks as $abook) { + $abook_copy = $abook; + + $abconfig = null; + if (array_key_exists('abconfig', $abook) && is_array($abook['abconfig']) && count($abook['abconfig'])) { + $abconfig = $abook['abconfig']; + } + + unset($abook['abook_id']); + unset($abook['abook_rating']); + unset($abook['abook_rating_text']); + unset($abook['abconfig']); + unset($abook['abook_their_perms']); + unset($abook['abook_my_perms']); + unset($abook['abook_not_here']); + + $abook['abook_account'] = $account_id; + $abook['abook_channel'] = $channel['channel_id']; + + $reconnect = false; + + if (array_key_exists('abook_instance', $abook) && $abook['abook_instance'] && strpos($abook['abook_instance'], z_root()) === false) { + $abook['abook_not_here'] = 1; + if (!($abook['abook_pending'] || $abook['abook_blocked'])) { + $reconnect = true; + } + } + + if ($abook['abook_self']) { + $ctype = 0; + $role = get_pconfig($channel['channel_id'], 'system', 'permissions_role'); + if (strpos($role, 'collection' !== false)) { + $ctype = 2; + } elseif (strpos($role, 'group') !== false) { + $ctype = 1; + } + if ($ctype) { + q( + "update xchan set xchan_type = %d where xchan_hash = '%s' ", + intval($ctype), + dbesc($abook['abook_xchan']) + ); + } + } else { + if ($max_friends !== false && $friends > $max_friends) { + continue; + } + if ($max_feeds !== false && intval($abook['abook_feed']) && ($feeds > $max_feeds)) { + continue; + } + } + + $r = q( + "select abook_id from abook where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($abook['abook_xchan']), + intval($channel['channel_id']) + ); + if ($r) { + $columns = db_columns('abook'); + + foreach ($abook as $k => $v) { + if (!in_array($k, $columns)) { + continue; + } + $r = q( + "UPDATE abook SET " . TQUOT . "%s" . TQUOT . " = '%s' WHERE abook_xchan = '%s' AND abook_channel = %d", + dbesc($k), + dbesc($v), + dbesc($abook['abook_xchan']), + intval($channel['channel_id']) + ); + } + } else { + abook_store_lowlevel($abook); + + $friends++; + if (intval($abook['abook_feed'])) { + $feeds++; + } + } + + if ($abconfig) { + foreach ($abconfig as $abc) { + set_abconfig($channel['channel_id'], $abc['xchan'], $abc['cat'], $abc['k'], $abc['v']); + } + } + if ($reconnect) { + Connect::connect($channel, $abook['abook_xchan']); + } + } + + logger('import step 8'); + } + + // import groups + $groups = $data['group']; + if ($groups) { + $saved = []; + foreach ($groups as $group) { + $saved[$group['hash']] = ['old' => $group['id']]; + if (array_key_exists('name', $group)) { + $group['gname'] = $group['name']; + unset($group['name']); + } + unset($group['id']); + $group['uid'] = $channel['channel_id']; + + create_table_from_array('pgrp', $group); + } + $r = q( + "select * from pgrp where uid = %d", + intval($channel['channel_id']) + ); + if ($r) { + foreach ($r as $rr) { + $saved[$rr['hash']]['new'] = $rr['id']; + } + } + } + + // import group members + $group_members = $data['group_member']; + if ($group_members) { + foreach ($group_members as $group_member) { + unset($group_member['id']); + $group_member['uid'] = $channel['channel_id']; + foreach ($saved as $x) { + if ($x['old'] == $group_member['gid']) { + $group_member['gid'] = $x['new']; + } + } + create_table_from_array('pgrp_member', $group_member); + } + } + + logger('import step 9'); + + + if (is_array($data['atoken'])) { + import_atoken($channel, $data['atoken']); + } + if (is_array($data['xign'])) { + import_xign($channel, $data['xign']); + } + if (is_array($data['block'])) { + import_block($channel, $data['block']); + } + if (is_array($data['obj'])) { + import_objs($channel, $data['obj']); + } + if (is_array($data['likes'])) { + import_likes($channel, $data['likes']); + } + if (is_array($data['app'])) { + import_apps($channel, $data['app']); + } + if (is_array($data['sysapp'])) { + import_sysapps($channel, $data['sysapp']); + } + if (is_array($data['chatroom'])) { + import_chatrooms($channel, $data['chatroom']); + } +// if (is_array($data['conv'])) { +// import_conv($channel,$data['conv']); +// } +// if (is_array($data['mail'])) { +// import_mail($channel,$data['mail']); +// } + if (is_array($data['event'])) { + import_events($channel, $data['event']); + } + if (is_array($data['event_item'])) { + import_items($channel, $data['event_item'], false, $relocate); + } +// if (is_array($data['menu'])) { +// import_menus($channel,$data['menu']); +// } +// if (is_array($data['wiki'])) { +// import_items($channel,$data['wiki'],false,$relocate); +// } +// if (is_array($data['webpages'])) { +// import_items($channel,$data['webpages'],false,$relocate); +// } + $addon = array('channel' => $channel, 'data' => $data); + Hook::call('import_channel', $addon); + + $saved_notification_flags = Channel::notifications_off($channel['channel_id']); + if ($import_posts && array_key_exists('item', $data) && $data['item']) { + import_items($channel, $data['item'], false, $relocate); + } + + if ($api_path && $import_posts) { // we are importing from a server and not a file + $m = parse_url($api_path); + + $hz_server = $m['scheme'] . '://' . $m['host']; + + $since = datetime_convert(date_default_timezone_get(), date_default_timezone_get(), '0001-01-01 00:00'); + $until = datetime_convert(date_default_timezone_get(), date_default_timezone_get(), 'now + 1 day'); + + $poll_interval = get_config('system', 'poll_interval', 3); + $page = 0; + + while (1) { + $headers = [ + 'X-API-Token' => random_string(), + 'X-API-Request' => $hz_server . '/api/z/1.0/item/export_page?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until) . '&page=' . $page, + 'Host' => $m['host'], + '(request-target)' => 'get /api/z/1.0/item/export_page?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until) . '&page=' . $page, + ]; + + $headers = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel), true, 'sha512'); + + $x = z_fetch_url($hz_server . '/api/z/1.0/item/export_page?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until) . '&page=' . $page, false, $redirects, ['headers' => $headers]); + + // logger('z_fetch: ' . print_r($x,true)); + + if (!$x['success']) { + logger('no API response'); + break; + } + + $j = json_decode($x['body'], true); + + if (!$j) { + break; + } + + if (!($j['item'] || count($j['item']))) { + break; + } + + Run::Summon(['Content_importer', sprintf('%d', $page), $since, $until, $channel['channel_address'], urlencode($hz_server)]); + sleep($poll_interval); + + $page++; + continue; + } + + $headers = [ + 'X-API-Token' => random_string(), + 'X-API-Request' => $hz_server . '/api/z/1.0/files?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until), + 'Host' => $m['host'], + '(request-target)' => 'get /api/z/1.0/files?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until), + ]; + + $headers = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel), true, 'sha512'); + + $x = z_fetch_url($hz_server . '/api/z/1.0/files?f=&zap_compat=1&since=' . urlencode($since) . '&until=' . urlencode($until), false, $redirects, ['headers' => $headers]); + + if (!$x['success']) { + logger('no API response'); + return; + } + + $j = json_decode($x['body'], true); + + if (!$j) { + return; + } + + if (!$j['success']) { + return; + } + + $poll_interval = get_config('system', 'poll_interval', 3); + + if (count($j['results'])) { + $todo = count($j['results']); + logger('total to process: ' . $todo, LOGGER_DEBUG); + + foreach ($j['results'] as $jj) { + Run::Summon(['File_importer', $jj['hash'], $channel['channel_address'], urlencode($hz_server)]); + sleep($poll_interval); + } + } + + notice(t('Files and Posts imported.') . EOL); + } + + Channel::notifications_on($channel['channel_id'], $saved_notification_flags); + + + // send out refresh requests + // notify old server that it may no longer be primary. + + Run::Summon(['Notifier', 'refresh_all', $channel['channel_id']]); + + // This will indirectly perform a refresh_all *and* update the directory + + Run::Summon(['Directory', $channel['channel_id']]); + + notice(t('Import completed.') . EOL); + + change_channel($channel['channel_id']); + + goaway(z_root() . '/stream'); + } + + /** + * @brief Handle POST action on channel import page. + */ + + public function post() + { + $account_id = get_account_id(); + if (!$account_id) { + return; + } + + check_form_security_token_redirectOnErr('/import', 'channel_import'); + $this->import_account($account_id); + } + + /** + * @brief Generate channel import page. + * + * @return string with parsed HTML. + */ + + public function get() + { + + if (!get_account_id()) { + notice(t('You must be logged in to use this feature.') . EOL); + return EMPTY_STR; + } + + return replace_macros(Theme::get_template('channel_import.tpl'), [ + '$title' => t('Import Channel'), + '$desc' => t('Use this form to import an existing channel from a different server. You may retrieve the channel identity from the old server via the network or provide an export file.'), + '$label_filename' => t('File to Upload'), + '$choice' => t('Or provide the old server details'), + '$old_address' => ['old_address', t('Your old identity address (xyz@example.com)'), '', ''], + '$email' => ['email', t('Your old login email address'), '', ''], + '$password' => ['password', t('Your old login password'), '', ''], + '$import_posts' => ['import_posts', t('Import a few months of posts if possible (limited by available memory)'), false, '', [t('No'), t('Yes')]], + + '$common' => t('For either option, please choose whether to make this hub your new primary address, or whether your old location should continue this role. You will be able to post from either location, but only one can be marked as the primary location for files, photos, and media.'), + + '$make_primary' => ['make_primary', t('Make this hub my primary location'), false, '', [t('No'), t('Yes')]], + '$moving' => ['moving', t('Move this channel (disable all previous locations)'), false, '', [t('No'), t('Yes')]], + '$newname' => ['newname', t('Use this channel nickname instead of the one provided'), '', t('Leave blank to keep your existing channel nickname. You will be randomly assigned a similar nickname if either name is already allocated on this site.')], + + '$pleasewait' => t('This process may take several minutes to complete and considerably longer if importing a large amount of posts and files. Please submit the form only once and leave this page open until finished.'), + + '$form_security_token' => get_form_security_token('channel_import'), + '$submit' => t('Submit') + ]); + } +} diff --git a/Code/Module/Import_items.php b/Code/Module/Import_items.php new file mode 100644 index 000000000..48c8e162f --- /dev/null +++ b/Code/Module/Import_items.php @@ -0,0 +1,149 @@ + $email . ':' . $password); + $url = $scheme . $servername . $api_path; + $ret = z_fetch_url($url, $binary, $redirects, $opts); + if (!$ret['success']) { + $ret = z_fetch_url('http://' . $servername . $api_path, $binary, $redirects, $opts); + } + if ($ret['success']) { + $data = $ret['body']; + } else { + notice(t('Unable to download data from old server') . EOL); + } + } + + if (!$data) { + logger('Empty file.'); + notice(t('Imported file is empty.') . EOL); + return; + } + + $data = json_decode($data, true); + + //logger('import: data: ' . print_r($data,true)); + //print_r($data); + + if (!is_array($data)) { + return; + } + +// if(array_key_exists('compatibility',$data) && array_key_exists('database',$data['compatibility'])) { +// $v1 = substr($data['compatibility']['database'],-4); +// $v2 = substr(DB_UPDATE_VERSION,-4); +// if($v2 > $v1) { +// $t = sprintf( t('Warning: Database versions differ by %1$d updates.'), $v2 - $v1 ); +// notice($t . EOL); +// } +// } + + $codebase = 'zap'; + + if ((!array_path_exists('compatibility/codebase', $data)) || $data['compatibility']['codebase'] !== $codebase) { + notice(t('Data export format is not compatible with this software')); + return; + } + + $channel = App::get_channel(); + + if (array_key_exists('item', $data) && $data['item']) { + import_items($channel, $data['item'], false, ((array_key_exists('relocate', $data)) ? $data['relocate'] : null)); + } + + info(t('Import completed') . EOL); + } + + + /** + * @brief Generate item import page. + * + * @return string with parsed HTML. + */ + public function get() + { + + if (!local_channel()) { + notice(t('Permission denied') . EOL); + return login(); + } + + $o = replace_macros(Theme::get_template('item_import.tpl'), array( + '$title' => t('Import Items'), + '$desc' => t('Use this form to import existing posts and content from an export file.'), + '$label_filename' => t('File to Upload'), + '$form_security_token' => get_form_security_token('import_items'), + '$submit' => t('Submit') + )); + + return $o; + } +} diff --git a/Code/Module/Inbox.php b/Code/Module/Inbox.php new file mode 100644 index 000000000..7e22cd2d7 --- /dev/null +++ b/Code/Module/Inbox.php @@ -0,0 +1,431 @@ +is_valid() && $AS->type === 'Announce' && is_array($AS->obj) + && array_key_exists('object', $AS->obj) && array_key_exists('actor', $AS->obj) + ) { + // This is a relayed/forwarded Activity (as opposed to a shared/boosted object) + // Reparse the encapsulated Activity and use that instead + logger('relayed activity', LOGGER_DEBUG); + $AS = new ActivityStreams($AS->obj); + } + + // logger('debug: ' . $AS->debug()); + + if (!$AS->is_valid()) { + if ($AS->deleted) { + // process mastodon user deletion activities, but only if we can validate the signature + if ($hsig['header_valid'] && $hsig['content_valid'] && $hsig['portable_id']) { + logger('removing deleted actor'); + remove_all_xchan_resources($hsig['portable_id']); + } else { + logger('ignoring deleted actor', LOGGER_DEBUG, LOG_INFO); + } + } + return; + } + + + if (is_array($AS->actor) && array_key_exists('id', $AS->actor)) { + Activity::actor_store($AS->actor['id'], $AS->actor); + } + + if (is_array($AS->obj) && ActivityStreams::is_an_actor($AS->obj['type'])) { + Activity::actor_store($AS->obj['id'], $AS->obj); + } + + if (is_array($AS->obj) && is_array($AS->obj['actor']) && array_key_exists('id', $AS->obj['actor']) && $AS->obj['actor']['id'] !== $AS->actor['id']) { + Activity::actor_store($AS->obj['actor']['id'], $AS->obj['actor']); + if (!check_channelallowed($AS->obj['actor']['id'])) { + http_status_exit(403, 'Permission denied'); + } + } + + // Validate that the channel that sent us this activity has authority to do so. + // Require a valid HTTPSignature with a signed Digest header. + + // Only permit relayed activities if the activity is signed with LDSigs + // AND the signature is valid AND the signer is the actor. + + if ($hsig['header_valid'] && $hsig['content_valid'] && $hsig['portable_id']) { + // if the sender has the ability to send messages over zot/nomad, ignore messages sent via activitypub + // as observer aware features and client side markup will be unavailable + + $test = Activity::get_actor_hublocs($hsig['portable_id'], 'all,not_deleted'); + if ($test) { + foreach ($test as $t) { + if ($t['hubloc_network'] === 'zot6') { + http_status_exit(409, 'Conflict'); + } + } + } + + // fetch the portable_id for the actor, which may or may not be the sender + + $v = Activity::get_actor_hublocs($AS->actor['id'], 'activitypub,not_deleted'); + + if ($v && $v[0]['hubloc_hash'] !== $hsig['portable_id']) { + // The sender is not actually the activity actor, so verify the LD signature. + // litepub activities (with no LD signature) will always have a matching actor and sender + + if ($AS->signer && is_array($AS->signer) && $AS->signer['id'] !== $AS->actor['id']) { + // the activity wasn't signed by the activity actor + return; + } + if (!$AS->sigok) { + // The activity signature isn't valid. + return; + } + } + + if ($v) { + // The sender has been validated and stored + $observer_hash = $hsig['portable_id']; + } + } + + if (!$observer_hash) { + return; + } + + // verify that this site has permitted communication with the sender. + + $m = parse_url($observer_hash); + + if ($m && $m['scheme'] && $m['host']) { + if (!check_siteallowed($m['scheme'] . '://' . $m['host'])) { + http_status_exit(403, 'Permission denied'); + } + // this site obviously isn't dead because they are trying to communicate with us. + $test = q( + "update site set site_dead = 0 where site_dead = 1 and site_url = '%s' ", + dbesc($m['scheme'] . '://' . $m['host']) + ); + } + if (!check_channelallowed($observer_hash)) { + http_status_exit(403, 'Permission denied'); + } + + // update the hubloc_connected timestamp, ignore failures + + $test = q( + "update hubloc set hubloc_connected = '%s' where hubloc_hash = '%s' and hubloc_network = 'activitypub'", + dbesc(datetime_convert()), + dbesc($observer_hash) + ); + + + // Now figure out who the recipients are + + if ($is_public) { + if (in_array($AS->type, ['Follow', 'Join']) && is_array($AS->obj) && ActivityStreams::is_an_actor($AS->obj['type'])) { + $channels = q( + "SELECT * from channel where channel_address = '%s' and channel_removed = 0 ", + dbesc(basename($AS->obj['id'])) + ); + } else { + $collections = Activity::get_actor_collections($observer_hash); + + if (in_array($collection['followers'], $AS->recips) + || in_array(ACTIVITY_PUBLIC_INBOX, $AS->recips) + || in_array('Public', $AS->recips) + || in_array('as:Public', $AS->recips)) { + + // deliver to anybody following $AS->actor + + $channels = q( + "SELECT * from channel where channel_id in + ( SELECT abook_channel from abook left join xchan on abook_xchan = xchan_hash + WHERE xchan_network = 'activitypub' and xchan_hash = '%s' + ) and channel_removed = 0 ", + dbesc($observer_hash) + ); + } + else { + // deliver to anybody at this site directly addressed + $channel_addr = ''; + foreach($AS->recips as $recip) { + if (strpos($recip, z_root()) === 0) { + $channel_addr .= '\'' . dbesc(basename($recip)) . '\','; + } + } + if ($channel_addr) { + $channel_addr = rtrim($channel_addr, ','); + $channels = dbq("SELECT * FROM channel + WHERE channel_address IN ($channel_addr) AND channel_removed = 0"); + } + } + + if (!$channels) { + $channels = []; + } + + $parent = $AS->parent_id; + if ($parent) { + // this is a comment - deliver to everybody who owns the parent + $owners = q( + "SELECT * from channel where channel_id in ( SELECT uid from item where mid = '%s' ) ", + dbesc($parent) + ); + if ($owners) { + $channels = array_merge($channels, $owners); + } + } + } + + if ($channels === false) { + $channels = []; + } + + if (in_array(ACTIVITY_PUBLIC_INBOX, $AS->recips) || in_array('Public', $AS->recips) || in_array('as:Public', $AS->recips)) { + // look for channels with send_stream = PERMS_PUBLIC (accept posts from anybody on the internet) + + $r = q("select * from channel where channel_id in (select uid from pconfig where cat = 'perm_limits' and k = 'send_stream' and v = '1' ) and channel_removed = 0 "); + if ($r) { + $channels = array_merge($channels, $r); + } + + // look for channels that are following hashtags. These will be checked in tgroup_check() + + $r = q("select * from channel where channel_id in (select uid from pconfig where cat = 'system' and k = 'followed_tags' and v != '' ) and channel_removed = 0 "); + if ($r) { + $channels = array_merge($channels, $r); + } + + + if (!$sys_disabled) { + $channels[] = Channel::get_system(); + } + } + } + + // $channels represents all "potential" recipients. If they are not in this array, they will not receive the activity. + // If they are in this array, we will decide whether or not to deliver on a case-by-case basis. + + if (!$channels) { + logger('no deliveries on this site'); + return; + } + + // Bto and Bcc will only be present in a C2S transaction and should not be stored. + + $saved_recips = []; + foreach (['to', 'cc', 'audience'] as $x) { + if (array_key_exists($x, $AS->data)) { + $saved_recips[$x] = $AS->data[$x]; + } + } + $AS->set_recips($saved_recips); + + + foreach ($channels as $channel) { + // Even though activitypub may be enabled for the site, check if the channel has specifically disabled it + if (!PConfig::Get($channel['channel_id'], 'system', 'activitypub', Config::Get('system', 'activitypub', ACTIVITYPUB_ENABLED))) { + continue; + } + + logger('inbox_channel: ' . $channel['channel_address'], LOGGER_DEBUG); + + switch ($AS->type) { + case 'Follow': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStreams::is_an_actor($AS->obj['type'])) { + // do follow activity + Activity::follow($channel, $AS); + } + break; + case 'Invite': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { + // do follow activity + Activity::follow($channel, $AS); + } + break; + case 'Join': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { + // do follow activity + Activity::follow($channel, $AS); + } + break; + case 'Accept': + // Activitypub for wordpress sends lowercase 'follow' on accept. + // https://github.com/pfefferle/wordpress-activitypub/issues/97 + // Mobilizon sends Accept/"Member" (not in vocabulary) in response to Join/Group + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && in_array($AS->obj['type'], ['Follow', 'follow', 'Member'])) { + // do follow activity + Activity::follow($channel, $AS); + } + break; + + case 'Reject': + default: + break; + } + + // These activities require permissions + + $item = null; + + switch ($AS->type) { + case 'Update': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStreams::is_an_actor($AS->obj['type'])) { + Activity::actor_store($AS->obj['id'], $AS->obj, true /* force cache refresh */); + break; + } + case 'Accept': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && (ActivityStreams::is_an_actor($AS->obj['type']) || $AS->obj['type'] === 'Member')) { + break; + } + case 'Create': + case 'Like': + case 'Dislike': + case 'Announce': + case 'Reject': + case 'TentativeAccept': + case 'TentativeReject': + case 'Add': + case 'Arrive': + case 'Block': + case 'Flag': + case 'Ignore': + case 'Invite': + case 'Listen': + case 'Move': + case 'Offer': + case 'Question': + case 'Read': + case 'Travel': + case 'View': + case 'emojiReaction': + case 'EmojiReaction': + case 'EmojiReact': + // These require a resolvable object structure + if (is_array($AS->obj)) { + // The boolean flag enables html cache of the item + $item = Activity::decode_note($AS, true); + } else { + logger('unresolved object: ' . print_r($AS->obj, true)); + } + break; + case 'Undo': + if ($AS->obj && is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Follow') { + // do unfollow activity + Activity::unfollow($channel, $AS); + break; + } + case 'Leave': + if ($AS->obj && is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { + // do unfollow activity + Activity::unfollow($channel, $AS); + break; + } + case 'Tombstone': + case 'Delete': + Activity::drop($channel, $observer_hash, $AS); + break; + + case 'Move': + if ( + $observer_hash && $observer_hash === $AS->actor + && is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStream::is_an_actor($AS->obj['type']) + && is_array($AS->tgt) && array_key_exists('type', $AS->tgt) && ActivityStream::is_an_actor($AS->tgt['type']) + ) { + ActivityPub::move($AS->obj, $AS->tgt); + } + break; + case 'Add': + case 'Remove': + // for writeable collections as target, it's best to provide an array and include both the type and the id in the target element. + // If it's just a string id, we'll try to fetch the collection when we receive it and that's wasteful since we don't actually need + // the contents. + if (is_array($AS->obj) && isset($AS->tgt)) { + // The boolean flag enables html cache of the item + $item = Activity::decode_note($AS, true); + break; + } + default: + break; + } + + if ($item) { + logger('parsed_item: ' . print_r($item, true), LOGGER_DATA); + Activity::store($channel, $observer_hash, $AS, $item); + } + } + + http_status_exit(200, 'OK'); + } + + public function get() + { + } +} diff --git a/Code/Module/Inspect.php b/Code/Module/Inspect.php new file mode 100644 index 000000000..e224c90b4 --- /dev/null +++ b/Code/Module/Inspect.php @@ -0,0 +1,92 @@ + 2) { + $item_type = argv(1); + $item_id = argv(2); + } elseif (argc() > 1) { + $item_type = 'item'; + $item_id = argv(1); + } + + if (!$item_id) { + App::$error = 404; + notice(t('Item not found.') . EOL); + } + + if ($item_type === 'item') { + $r = q( + "select * from item where uuid = '%s' or id = %d ", + dbesc($item_id), + intval($item_id) + ); + + if ($r) { + xchan_query($r); + $items = fetch_post_tags($r, true); + } + + if (!$items) { + return $output; + } + + foreach ($items as $item) { + if ($item['obj']) { + $item['obj'] = json_decode($item['obj'], true); + } + if ($item['target']) { + $item['target'] = json_decode($item['target'], true); + } + if ($item['attach']) { + $item['attach'] = json_decode($item['attach'], true); + } + + $output .= '
      ' . print_array($item) . '
      ' . EOL . EOL; + + $output .= '
      ' . escape_tags(json_encode(Activity::encode_activity($item, true), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '
      ' . EOL . EOL; + + $output .= '
      ' . escape_tags(json_encode(json_decode(get_iconfig($item['id'], 'activitypub', 'rawmsg'), true), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '
      ' . EOL . EOL; + } + } + + if ($item_type === 'xchan') { + $items = q( + "select * from xchan left join hubloc on xchan_hash = hubloc_hash where hubloc_hash = '%s' or hubloc_addr = '%s' ", + dbesc($item_id), + dbesc($item_id) + ); + + if (!$items) { + return $output; + } + + foreach ($items as $item) { + $output .= '
      ' . print_array($item) . '
      ' . EOL . EOL; + } + } + + + return $output; + } +} diff --git a/Code/Module/Invite.php b/Code/Module/Invite.php new file mode 100644 index 000000000..eb9c00f1e --- /dev/null +++ b/Code/Module/Invite.php @@ -0,0 +1,176 @@ + $max_invites) { + notice(t('Total invitation limit exceeded.') . EOL); + return; + } + + + $recips = ((x($_POST, 'recipients')) ? explode("\n", $_POST['recipients']) : []); + $message = ((x($_POST, 'message')) ? notags(trim($_POST['message'])) : ''); + + $total = 0; + + if (get_config('system', 'invitation_only')) { + $invonly = true; + $x = get_pconfig(local_channel(), 'system', 'invites_remaining'); + if ((!$x) && (!is_site_admin())) { + return; + } + } + + foreach ($recips as $recip) { + $recip = trim($recip); + if (!$recip) { + continue; + } + + if (!validate_email($recip)) { + notice(sprintf(t('%s : Not a valid email address.'), $recip) . EOL); + continue; + } else { + $nmessage = $message; + } + + $account = App::get_account(); + + $res = z_mail( + [ + 'toEmail' => $recip, + 'fromName' => ' ', + 'fromEmail' => $account['account_email'], + 'messageSubject' => t('Please join us on $Projectname'), + 'textVersion' => $nmessage, + ] + ); + + if ($res) { + $total++; + $current_invites++; + set_pconfig(local_channel(), 'system', 'sent_invites', $current_invites); + if ($current_invites > $max_invites) { + notice(t('Invitation limit exceeded. Please contact your site administrator.') . EOL); + return; + } + } else { + notice(sprintf(t('%s : Message delivery failed.'), $recip) . EOL); + } + } + notice(sprintf(tt("%d message sent.", "%d messages sent.", $total), $total) . EOL); + return; + } + + + public function get() + { + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + if (!Apps::system_app_installed(local_channel(), 'Invite')) { + //Do not display any associated widgets at this point + App::$pdl = ''; + + $o = 'Invite App (Not Installed):
      '; + $o .= t('Send email invitations to join this network'); + return $o; + } + + Navbar::set_selected('Invite'); + + $tpl = Theme::get_template('invite.tpl'); + $invonly = false; + + if (get_config('system', 'invitation_only')) { + $invonly = true; + $x = get_pconfig(local_channel(), 'system', 'invites_remaining'); + if ((!$x) && (!is_site_admin())) { + notice(t('You have no more invitations available') . EOL); + return ''; + } + } + + if ($invonly && ($x || is_site_admin())) { + $invite_code = autoname(8) . rand(1000, 9999); + $nmessage = str_replace('$invite_code', $invite_code, $message); + + $r = q( + "INSERT INTO register (hash,created,uid,password,lang) VALUES ('%s', '%s',0,'','') ", + dbesc($invite_code), + dbesc(datetime_convert()) + ); + + if (!is_site_admin()) { + $x--; + if ($x >= 0) { + set_pconfig(local_channel(), 'system', 'invites_remaining', $x); + } else { + return; + } + } + } + + $ob = App::get_observer(); + if (!$ob) { + return $o; + } + + $channel = App::get_channel(); + + $o = replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("send_invite"), + '$invite' => t('Send invitations'), + '$addr_text' => t('Enter email addresses, one per line:'), + '$msg_text' => t('Your message:'), + '$default_message' => t('Please join my community on $Projectname.') . "\r\n" . "\r\n" + . $linktxt + . (($invonly) ? "\r\n" . "\r\n" . t('You will need to supply this invitation code:') . " " . $invite_code . "\r\n" . "\r\n" : '') + . t('1. Register at any $Projectname location (they are all inter-connected)') + . "\r\n" . "\r\n" . z_root() . '/register' + . "\r\n" . "\r\n" . t('2. Enter my $Projectname network address into the site searchbar.') + . "\r\n" . "\r\n" . $ob['xchan_addr'] . ' (' . t('or visit') . " " . z_root() . '/channel/' . $channel['channel_address'] . ')' + . "\r\n" . "\r\n" + . t('3. Click [Connect]') + . "\r\n" . "\r\n", + '$submit' => t('Submit') + )); + + return $o; + } +} diff --git a/Code/Module/Item.php b/Code/Module/Item.php new file mode 100644 index 000000000..86c146a6f --- /dev/null +++ b/Code/Module/Item.php @@ -0,0 +1,2004 @@ + [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], $i); + + $headers = []; + $headers['Content-Type'] = 'application/x-nomad+json'; + $x['signature'] = LDSignatures::sign($x, $chan); + $ret = json_encode($x, JSON_UNESCAPED_SLASHES); + $headers['Digest'] = HTTPSig::generate_digest_header($ret); + $headers['(request-target)'] = strtolower($_SERVER['REQUEST_METHOD']) . ' ' . $_SERVER['REQUEST_URI']; + $h = HTTPSig::create_sig($headers, $chan['channel_prvkey'], Channel::url($chan)); + HTTPSig::set_headers($h); + echo $ret; + killme(); + } + + // if it isn't a drop command and isn't a post method and wasn't handled already, + // the default action is a browser request for a persistent uri and this should return + // the text/html page of the item. + + if (argc() > 1 && argv(1) !== 'drop') { + $x = q( + "select uid, item_wall, llink, mid from item where mid = '%s' or mid = '%s' or uuid = '%s'", + dbesc(z_root() . '/item/' . argv(1)), + dbesc(z_root() . '/activity/' . argv(1)), + dbesc(argv(1)) + ); + if ($x) { + foreach ($x as $xv) { + if (intval($xv['item_wall'])) { + $c = Channel::from_id($xv['uid']); + if ($c) { + goaway($c['xchan_url'] . '?mid=' . gen_link_id($xv['mid'])); + } + } + } + goaway($x[0]['llink']); + } + + // save this state and catch it in the get() function + $this->return_404 = true; + } + } + + public function post() + { + + if ((!local_channel()) && (!remote_channel()) && (!isset($_REQUEST['anonname']))) { + return; + } + + // drop an array of items. + + if (isset($_REQUEST['dropitems'])) { + $arr_drop = explode(',', $_REQUEST['dropitems']); + drop_items($arr_drop); + $json = array('success' => 1); + echo json_encode($json); + killme(); + } + + + $uid = local_channel(); + $channel = null; + $observer = null; + $token = EMPTY_STR; + $datarray = []; + $item_starred = false; + $item_uplink = false; + $item_notshown = false; + $item_nsfw = false; + $item_relay = false; + $item_mentionsme = false; + $item_verified = false; + $item_retained = false; + $item_rss = false; + $item_deleted = false; + $item_hidden = false; + $item_delayed = false; + $item_pending_remove = false; + $item_blocked = false; + + $post_tags = false; + $pub_copy = false; + + + /** + * Is this a reply to something? + */ + + $parent = ((isset($_REQUEST['parent'])) ? intval($_REQUEST['parent']) : 0); + $parent_mid = ((isset($_REQUEST['parent_mid'])) ? trim($_REQUEST['parent_mid']) : ''); + + $hidden_mentions = ((isset($_REQUEST['hidden_mentions'])) ? trim($_REQUEST['hidden_mentions']) : ''); + + + /** + * Who is viewing this page and posting this thing + */ + + $remote_xchan = ((isset($_REQUEST['remote_xchan'])) ? trim($_REQUEST['remote_xchan']) : false); + $remote_observer = xchan_match(['xchan_hash' => $remote_xchan]); + + if (!$remote_observer) { + $remote_xchan = $remote_observer = false; + } + + // This is the local channel representing who the posted item will belong to. + + $profile_uid = ((isset($_REQUEST['profile_uid'])) ? intval($_REQUEST['profile_uid']) : 0); + + // *If* you are logged in as the site admin you are allowed to create top-level items for the sys channel. + // This would typically be a webpage or webpage element. + // Comments and replies are excluded because further below we also check for sys channel ownership and + // will make a copy of the parent that you can interact with in your own stream + + $sys = Channel::get_system(); + if ($sys && $profile_uid && ($sys['channel_id'] == $profile_uid) && is_site_admin() && !$parent) { + $uid = intval($sys['channel_id']); + $channel = $sys; + $observer = $sys; + } + + Hook::call('post_local_start', $_REQUEST); + + // logger('postvars ' . print_r($_REQUEST,true), LOGGER_DATA); + + $api_source = ((isset($_REQUEST['api_source']) && $_REQUEST['api_source']) ? true : false); + + $nocomment = 0; + if (isset($_REQUEST['comments_enabled'])) { + $nocomment = 1 - intval($_REQUEST['comments_enabled']); + } + + // this is in days, convert to absolute time + $channel_comments_closed = get_pconfig($profile_uid, 'system', 'close_comments'); + if (intval($channel_comments_closed)) { + $channel_comments_closed = datetime_convert(date_Default_timezone_get(), 'UTC', 'now + ' . intval($channel_comments_closed) . ' days'); + } else { + $channel_comments_closed = NULL_DATE; + } + + $comments_closed = ((isset($_REQUEST['comments_closed']) && $_REQUEST['comments_closed']) ? datetime_convert(date_default_timezone_get(), 'UTC', $_REQUEST['comments_closed']) : $channel_comments_closed); + + $is_poll = ((trim($_REQUEST['poll_answers'][0]) != '' && trim($_REQUEST['poll_answers'][1]) != '') ? true : false); + + // 'origin' (if non-zero) indicates that this network is where the message originated, + // for the purpose of relaying comments to other conversation members. + // If using the API from a device (leaf node) you must set origin to 1 (default) or leave unset. + // If the API is used from another network with its own distribution + // and deliveries, you may wish to set origin to 0 or false and allow the other + // network to relay comments. + + // If you are unsure, it is prudent (and important) to leave it unset. + + $origin = (($api_source && array_key_exists('origin', $_REQUEST)) ? intval($_REQUEST['origin']) : 1); + + // To represent message-ids on other networks - this will create an iconfig record + + $namespace = (($api_source && array_key_exists('namespace', $_REQUEST)) ? strip_tags($_REQUEST['namespace']) : ''); + $remote_id = (($api_source && array_key_exists('remote_id', $_REQUEST)) ? strip_tags($_REQUEST['remote_id']) : ''); + + $owner_hash = null; + + $message_id = ((x($_REQUEST, 'message_id') && $api_source) ? strip_tags($_REQUEST['message_id']) : ''); + $created = ((x($_REQUEST, 'created')) ? datetime_convert(date_default_timezone_get(), 'UTC', $_REQUEST['created']) : datetime_convert()); + + // Because somebody will probably try this and create a mess + + if ($created <= NULL_DATE) { + $created = datetime_convert(); + } + + $post_id = ((x($_REQUEST, 'post_id')) ? intval($_REQUEST['post_id']) : 0); + + $app = ((x($_REQUEST, 'source')) ? strip_tags($_REQUEST['source']) : ''); + $return_path = ((x($_REQUEST, 'return')) ? $_REQUEST['return'] : ''); + $preview = ((x($_REQUEST, 'preview')) ? intval($_REQUEST['preview']) : 0); + $categories = ((x($_REQUEST, 'category')) ? escape_tags($_REQUEST['category']) : ''); + $webpage = ((x($_REQUEST, 'webpage')) ? intval($_REQUEST['webpage']) : 0); + $item_obscured = ((x($_REQUEST, 'obscured')) ? intval($_REQUEST['obscured']) : 0); + $pagetitle = ((x($_REQUEST, 'pagetitle')) ? escape_tags(urlencode($_REQUEST['pagetitle'])) : ''); + $layout_mid = ((x($_REQUEST, 'layout_mid')) ? escape_tags($_REQUEST['layout_mid']) : ''); + $plink = ((x($_REQUEST, 'permalink')) ? escape_tags($_REQUEST['permalink']) : ''); + $obj_type = ((x($_REQUEST, 'obj_type')) ? escape_tags($_REQUEST['obj_type']) : ACTIVITY_OBJ_NOTE); + + + $item_unpublished = ((isset($_REQUEST['draft'])) ? intval($_REQUEST['draft']) : 0); + + // allow API to bulk load a bunch of imported items without sending out a bunch of posts. + $nopush = ((x($_REQUEST, 'nopush')) ? intval($_REQUEST['nopush']) : 0); + + /* + * Check service class limits + */ + if ($uid && !(x($_REQUEST, 'parent')) && !(x($_REQUEST, 'post_id'))) { + $ret = $this->item_check_service_class($uid, (($_REQUEST['webpage'] == ITEM_TYPE_WEBPAGE) ? true : false)); + if (!$ret['success']) { + notice(t($ret['message']) . EOL); + if ($api_source) { + return (['success' => false, 'message' => 'service class exception']); + } + if (x($_REQUEST, 'return')) { + goaway(z_root() . "/" . $return_path); + } + killme(); + } + } + + if ($pagetitle) { + $pagetitle = strtolower(URLify::transliterate($pagetitle)); + } + + $item_flags = $item_restrict = 0; + $expires = NULL_DATE; + + $route = ''; + $parent_item = null; + $parent_contact = null; + $thr_parent = ''; + $parid = 0; + $r = false; + + + // If this is a comment, find the parent and preset some stuff + + if ($parent || $parent_mid) { + if (!x($_REQUEST, 'type')) { + $_REQUEST['type'] = 'net-comment'; + } + if ($obj_type == ACTIVITY_OBJ_NOTE) { + $obj_type = ACTIVITY_OBJ_COMMENT; + } + + // fetch the parent item + + if ($parent) { + $r = q( + "SELECT * FROM item WHERE id = %d LIMIT 1", + intval($parent) + ); + } elseif ($parent_mid && $uid) { + // This is coming from an API source, and we are logged in + $r = q( + "SELECT * FROM item WHERE mid = '%s' AND uid = %d LIMIT 1", + dbesc($parent_mid), + intval($uid) + ); + } + + // if this isn't the real parent of the conversation, find it + if ($r) { + $parid = $r[0]['parent']; + $parent_mid = $r[0]['mid']; + if ($r[0]['id'] != $r[0]['parent']) { + $r = q( + "SELECT * FROM item WHERE id = parent AND parent = %d LIMIT 1", + intval($parid) + ); + } + + // if interacting with a pubstream item (owned by the sys channel), + // create a copy of the parent in your stream + + // $r may have changed. Check it again before trying to use it. + + if ($r && local_channel() && (!Channel::is_system(local_channel()))) { + $old_id = $r[0]['id']; + $r = [copy_of_pubitem(App::get_channel(), $r[0]['mid'])]; + if ($r[0]['id'] !== $old_id) { + // keep track that a copy was made to display a special status notice that is unique to this condition + $pub_copy = true; + } + } + } + + if (!$r) { + notice(t('Unable to locate original post.') . EOL); + if ($api_source) { + return (['success' => false, 'message' => 'invalid post id']); + } + if (x($_REQUEST, 'return')) { + goaway(z_root() . "/" . $return_path); + } + killme(); + } + + xchan_query($r, true); + + $parent_item = $r[0]; + $parent = $r[0]['id']; + + // multi-level threading - preserve the info but re-parent to our single level threading + + $thr_parent = $parent_mid; + + $route = $parent_item['route']; + } + + if ($parent_item && isset($parent_item['replyto']) && $parent_item['replyto']) { + $replyto = unserialise($parent_item['replyto']); + } + + $moderated = false; + + if (!$observer) { + $observer = App::get_observer(); + if (!$observer) { + // perhaps we're allowing moderated comments from anonymous viewers + $observer = Channel::anon_identity_init($_REQUEST); + if ($observer) { + $moderated = true; + $remote_xchan = $remote_observer = $observer; + } + } + } + + if (!$observer) { + notice(t('Permission denied.') . EOL); + if ($api_source) { + return (['success' => false, 'message' => 'permission denied']); + } + if (x($_REQUEST, 'return')) { + goaway(z_root() . "/" . $return_path); + } + killme(); + } + + if ($parent) { + logger('mod_item: item_post parent=' . $parent); + $can_comment = false; + + $can_comment = can_comment_on_post($observer['xchan_hash'], $parent_item); + if (!$can_comment) { + if ((array_key_exists('owner', $parent_item)) && intval($parent_item['owner']['abook_self']) == 1) { + $can_comment = perm_is_allowed($profile_uid, $observer['xchan_hash'], 'post_comments'); + } + } + + if (!$can_comment) { + notice(t('Permission denied.') . EOL); + if ($api_source) { + return (['success' => false, 'message' => 'permission denied']); + } + if (x($_REQUEST, 'return')) { + goaway(z_root() . "/" . $return_path); + } + killme(); + } + } else { + // fixme - $webpage could also be a wiki page or article and require a different permission to be checked. + if (!perm_is_allowed($profile_uid, $observer['xchan_hash'], ($webpage) ? 'write_pages' : 'post_wall')) { + notice(t('Permission denied.') . EOL); + if ($api_source) { + return (['success' => false, 'message' => 'permission denied']); + } + if (x($_REQUEST, 'return')) { + goaway(z_root() . "/" . $return_path); + } + killme(); + } + } + + // check if this author is being moderated through the 'moderated' (negative) permission + // when posting wall-to-wall + if ($moderated === false && intval($uid) !== intval($profile_uid)) { + $moderated = perm_is_allowed($profile_uid, $observer['xchan_hash'], 'moderated'); + } + + // If this is a comment, check the moderated permission of the parent; who may be on another site + $remote_moderated = (($parent) ? their_perms_contains($profile_uid, $parent_item['owner_xchan'], 'moderated') : false); + if ($remote_moderated) { + notice(t('Comment may be moderated.') . EOL); + } + + // is this an edited post? + + $orig_post = null; + + if ($namespace && $remote_id) { + // It wasn't an internally generated post - see if we've got an item matching this remote service id + $i = q( + "select iid from iconfig where cat = 'system' and k = '%s' and v = '%s' limit 1", + dbesc($namespace), + dbesc($remote_id) + ); + if ($i) { + $post_id = $i[0]['iid']; + } + } + + $iconfig = null; + + if ($post_id) { + $i = q( + "SELECT * FROM item WHERE uid = %d AND id = %d LIMIT 1", + intval($profile_uid), + intval($post_id) + ); + if (!count($i)) { + killme(); + } + $orig_post = $i[0]; + $iconfig = q( + "select * from iconfig where iid = %d", + intval($post_id) + ); + } + + + if (!$channel) { + if ($uid && $uid == $profile_uid) { + $channel = App::get_channel(); + } else { + // posting as yourself but not necessarily to a channel you control + $r = q( + "select * from channel left join account on channel_account_id = account_id where channel_id = %d LIMIT 1", + intval($profile_uid) + ); + if ($r) { + $channel = $r[0]; + } + } + } + + + if (!$channel) { + logger("mod_item: no channel."); + if ($api_source) { + return (['success' => false, 'message' => 'no channel']); + } + if (x($_REQUEST, 'return')) { + goaway(z_root() . "/" . $return_path); + } + killme(); + } + + $owner_xchan = null; + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($channel['channel_hash']) + ); + if ($r && count($r)) { + $owner_xchan = $r[0]; + } else { + logger("mod_item: no owner."); + if ($api_source) { + return (['success' => false, 'message' => 'no owner']); + } + if (x($_REQUEST, 'return')) { + goaway(z_root() . "/" . $return_path); + } + killme(); + } + + $walltowall = false; + $walltowall_comment = false; + + if ($remote_xchan && !$moderated) { + $observer = $remote_observer; + } + + if ($observer) { + logger('mod_item: post accepted from ' . $observer['xchan_name'] . ' for ' . $owner_xchan['xchan_name'], LOGGER_DEBUG); + + // wall-to-wall detection. + // For top-level posts, if the author and owner are different it's a wall-to-wall + // For comments, We need to additionally look at the parent and see if it's a wall post that originated locally. + + if ($observer['xchan_name'] != $owner_xchan['xchan_name']) { + if (($parent_item) && ($parent_item['item_wall'] && $parent_item['item_origin'])) { + $walltowall_comment = true; + $walltowall = true; + } + if (!$parent) { + $walltowall = true; + } + } + } + + if (!isset($replyto)) { + if (strpos($owner_xchan['xchan_hash'], 'http') === 0) { + $replyto = $owner_xchan['xchan_hash']; + } else { + $replyto = $owner_xchan['xchan_url']; + } + } + + + $acl = new AccessControl($channel); + + $view_policy = PermissionLimits::Get($channel['channel_id'], 'view_stream'); + $comment_policy = ((isset($_REQUEST['comments_from']) && intval($_REQUEST['comments_from'])) ? intval($_REQUEST['comments_from']) : PermissionLimits::Get($channel['channel_id'], 'post_comments')); + + $public_policy = ((x($_REQUEST, 'public_policy')) ? escape_tags($_REQUEST['public_policy']) : map_scope($view_policy, true)); + if ($webpage) { + $public_policy = ''; + } + if ($public_policy) { + $private = 1; + } + + if ($orig_post) { + $private = 0; + // webpages and unpublished drafts are allowed to change ACLs after the fact. Normal conversation items aren't. + if ($webpage || intval($orig_post['item_unpublished'])) { + $acl->set_from_array($_REQUEST); + } else { + $acl->set($orig_post); + $public_policy = $orig_post['public_policy']; + $private = $orig_post['item_private']; + } + + if ($public_policy || $acl->is_private()) { + $private = (($private) ? $private : 1); + } + + $location = $orig_post['location']; + $coord = $orig_post['coord']; + $verb = $orig_post['verb']; + $app = $orig_post['app']; + $title = escape_tags(trim($_REQUEST['title'])); + $summary = trim($_REQUEST['summary']); + $body = trim($_REQUEST['body']); + + $item_flags = $orig_post['item_flags']; + $item_origin = $orig_post['item_origin']; + $item_unseen = $orig_post['item_unseen']; + $item_starred = $orig_post['item_starred']; + $item_uplink = $orig_post['item_uplink']; + $item_wall = $orig_post['item_wall']; + $item_thread_top = $orig_post['item_thread_top']; + $item_notshown = $orig_post['item_notshown']; + $item_nsfw = $orig_post['item_nsfw']; + $item_relay = $orig_post['item_relay']; + $item_mentionsme = $orig_post['item_mentionsme']; + $item_nocomment = $orig_post['item_nocomment']; + $item_obscured = $orig_post['item_obscured']; + $item_verified = $orig_post['item_verified']; + $item_retained = $orig_post['item_retained']; + $item_rss = $orig_post['item_rss']; + $item_deleted = $orig_post['item_deleted']; + $item_type = $orig_post['item_type']; + $item_hidden = $orig_post['item_hidden']; + $item_delayed = $orig_post['item_delayed']; + $item_pending_remove = $orig_post['item_pending_remove']; + $item_blocked = $orig_post['item_blocked']; + + + $postopts = $orig_post['postopts']; + $created = ((intval($orig_post['item_unpublished'])) ? $created : $orig_post['created']); + $expires = ((intval($orig_post['item_unpublished'])) ? NULL_DATE : $orig_post['expires']); + $mid = $orig_post['mid']; + $thr_parent = $orig_post['thr_parent']; + $parent_mid = $orig_post['parent_mid']; + $plink = $orig_post['plink']; + } else { + if (!$walltowall) { + if ( + (array_key_exists('contact_allow', $_REQUEST)) + || (array_key_exists('group_allow', $_REQUEST)) + || (array_key_exists('contact_deny', $_REQUEST)) + || (array_key_exists('group_deny', $_REQUEST)) + ) { + $acl->set_from_array($_REQUEST); + } elseif (!$api_source) { + // if no ACL has been defined and we aren't using the API, the form + // didn't send us any parameters. This means there's no ACL or it has + // been reset to the default audience. + // If $api_source is set and there are no ACL parameters, we default + // to the channel permissions which were set in the ACL contructor. + + $acl->set(array('allow_cid' => '', 'allow_gid' => '', 'deny_cid' => '', 'deny_gid' => '')); + } + } + + + $location = ((isset($_REQUEST['location'])) ? notags(trim($_REQUEST['location'])) : EMPTY_STR); + $coord = ((isset($_REQUEST['coord'])) ? notags(trim($_REQUEST['coord'])) : EMPTY_STR); + $verb = ((isset($_REQUEST['verb'])) ? notags(trim($_REQUEST['verb'])) : EMPTY_STR); + $title = ((isset($_REQUEST['title'])) ? escape_tags(trim($_REQUEST['title'])) : EMPTY_STR); + $summary = ((isset($_REQUEST['summary'])) ? trim($_REQUEST['summary']) : EMPTY_STR); + $body = ((isset($_REQUEST['body'])) ? trim($_REQUEST['body']) : EMPTY_STR); + $body .= ((isset($_REQUEST['attachment'])) ? trim($_REQUEST['attachment']) : EMPTY_STR); + $postopts = ''; + + $allow_empty = ((array_key_exists('allow_empty', $_REQUEST)) ? intval($_REQUEST['allow_empty']) : 0); + + $private = ((isset($private) && $private) ? $private : intval($acl->is_private() || ($public_policy))); + + // If this is a comment, set the permissions from the parent. + + if ($parent_item) { + $private = 0; + $acl->set($parent_item); + $private = ((intval($parent_item['item_private']) ? $parent_item['item_private'] : $acl->is_private())); + $public_policy = $parent_item['public_policy']; + $owner_hash = $parent_item['owner_xchan']; + $webpage = $parent_item['item_type']; + $comment_policy = $parent_item['comment_policy']; + $item_nocomment = $parent_item['item_nocomment']; + $comments_closed = $parent_item['comments_closed']; + } + + if ((!$allow_empty) && (!strlen($body))) { + if ($preview) { + killme(); + } + info(t('Empty post discarded.') . EOL); + if ($api_source) { + return (['success' => false, 'message' => 'no content']); + } + if (x($_REQUEST, 'return')) { + goaway(z_root() . "/" . $return_path); + } + killme(); + } + } + + + if (Apps::system_app_installed($profile_uid, 'Expire Posts')) { + if (x($_REQUEST, 'expire')) { + $expires = datetime_convert(date_default_timezone_get(), 'UTC', $_REQUEST['expire']); + if ($expires <= datetime_convert()) { + $expires = NULL_DATE; + } + } + } + + + $mimetype = notags(trim($_REQUEST['mimetype'])); + if (!$mimetype) { + $mimetype = 'text/x-multicode'; + } + + + $execflag = ((intval($uid) == intval($profile_uid) + && ($channel['channel_pageflags'] & PAGE_ALLOWCODE)) ? true : false); + + if ($preview) { + $summary = z_input_filter($summary, $mimetype, $execflag); + $body = z_input_filter($body, $mimetype, $execflag); + } + + + $arr = ['profile_uid' => $profile_uid, 'summary' => $summary, 'content' => $body, 'mimetype' => $mimetype]; + Hook::call('post_content', $arr); + $summary = $arr['summary']; + $body = $arr['content']; + $mimetype = $arr['mimetype']; + + + $gacl = $acl->get(); + $str_contact_allow = $gacl['allow_cid']; + $str_group_allow = $gacl['allow_gid']; + $str_contact_deny = $gacl['deny_cid']; + $str_group_deny = $gacl['deny_gid']; + + + $groupww = false; + + // if this is a wall-to-wall post to a group, turn it into a direct message + + $role = get_pconfig($profile_uid, 'system', 'permissions_role'); + + $rolesettings = PermissionRoles::role_perms($role); + + $channel_type = isset($rolesettings['channel_type']) ? $rolesettings['channel_type'] : 'normal'; + + $is_group = (($channel_type === 'group') ? true : false); + + if (($is_group) && ($walltowall) && (!$walltowall_comment)) { + $groupww = true; + $str_contact_allow = $owner_xchan['xchan_hash']; + $str_group_allow = ''; + } + + + if (in_array($mimetype, [ 'text/bbcode', 'text/x-multicode' ])) { + // BBCODE alert: the following functions assume bbcode input + // and will require alternatives for alternative content-types (text/html, text/markdown, text/plain, etc.) + // we may need virtual or template classes to implement the possible alternatives + + if (strpos($body, '[/summary]') !== false) { + $match = ''; + $cnt = preg_match("/\[summary\](.*?)\[\/summary\]/ism", $body, $match); + if ($cnt) { + $summary .= $match[1]; + } + $body_content = preg_replace("/^(.*?)\[summary\](.*?)\[\/summary\]/ism", '', $body); + $body = trim($body_content); + } + + $summary = cleanup_bbcode($summary); + $body = cleanup_bbcode($body); + + // Look for tags and linkify them + $summary_tags = linkify_tags($summary, ($uid) ? $uid : $profile_uid); + $body_tags = linkify_tags($body, ($uid) ? $uid : $profile_uid); + $comment_tags = linkify_tags($hidden_mentions, ($uid) ? $uid : $profile_uid); + + foreach ([$summary_tags, $body_tags, $comment_tags] as $results) { + if ($results) { + // Set permissions based on tag replacements + set_linkified_perms($results, $str_contact_allow, $str_group_allow, $profile_uid, $parent_item, $private); + + if (!isset($post_tags)) { + $post_tags = []; + } + foreach ($results as $result) { + $success = $result['success']; + if ($success['replaced']) { + // suppress duplicate mentions/tags + $already_tagged = false; + foreach ($post_tags as $pt) { + if ($pt['term'] === $success['term'] && $pt['url'] === $success['url'] && intval($pt['ttype']) === intval($success['termtype'])) { + $already_tagged = true; + break; + } + } + if ($already_tagged) { + continue; + } + + $post_tags[] = array( + 'uid' => $profile_uid, + 'ttype' => $success['termtype'], + 'otype' => TERM_OBJ_POST, + 'term' => $success['term'], + 'url' => $success['url'] + ); + + // support #collection syntax to post to a collection + // this is accomplished by adding a pcategory tag for each collection target + // this is checked inside tag_deliver() to create a second delivery chain + + if ($success['termtype'] === TERM_HASHTAG) { + $r = q( + "select xchan_url from channel left join xchan on xchan_hash = channel_hash where channel_address = '%s' and channel_parent = '%s' and channel_removed = 0", + dbesc($success['term']), + dbesc(get_observer_hash()) + ); + if ($r) { + $post_tags[] = [ + 'uid' => $profile_uid, + 'ttype' => TERM_PCATEGORY, + 'otype' => TERM_OBJ_POST, + 'term' => $success['term'] . '@' . App::get_hostname(), + 'url' => $r[0]['xchan_url'] + ]; + } + } + } + } + } + } + + + /** + * process collections selected manually + */ + + if (array_key_exists('collections', $_REQUEST) && is_array($_REQUEST['collections']) && count($_REQUEST['collections'])) { + foreach ($_REQUEST['collections'] as $clct) { + $r = q( + "select xchan_url, xchan_hash from xchan left join hubloc on hubloc_hash = xchan_hash where hubloc_addr = '%s' limit 1", + dbesc($clct) + ); + if ($r) { + if (!isset($post_tags)) { + $post_tags = []; + } + $post_tags[] = [ + 'uid' => $profile_uid, + 'ttype' => TERM_PCATEGORY, + 'otype' => TERM_OBJ_POST, + 'term' => $clct, + 'url' => $r[0]['xchan_url'] + ]; + } + } + } + + if (in_array(substr_count($str_contact_allow,'<'), [ 1, 2 ]) && (!$str_group_allow)) { + // direct message - private between individual channels but not groups + $private = 2; + } + + if ($private) { + // for edited posts, re-use any existing OCAP token (if found). + // Otherwise generate a new one. + + if ($iconfig) { + foreach ($iconfig as $cfg) { + if ($cfg['cat'] === 'ocap' && $cfg['k'] === 'relay') { + $token = $cfg['v']; + } + } + } + if (!$token) { + $token = new_token(); + } + } + + + /** + * + * When a photo was uploaded into the message using the (profile wall) ajax + * uploader, The permissions are initially set to disallow anybody but the + * owner from seeing it. This is because the permissions may not yet have been + * set for the post. If it's private, the photo permissions should be set + * appropriately. But we didn't know the final permissions on the post until + * now. So now we'll look for links of uploaded photos and attachments that are in the + * post and set them to the same permissions as the post itself. + * + * If the post was end-to-end encrypted we can't find images and attachments in the body, + * use our media_str input instead which only contains these elements - but only do this + * when encrypted content exists because the photo/attachment may have been removed from + * the post and we should keep it private. If it's encrypted we have no way of knowing + * so we'll set the permissions regardless and realise that the media may not be + * referenced in the post. + * + */ + + if (!$preview) { + fix_attached_permissions($profile_uid, ((strpos($body, '[/crypt]')) ? $_POST['media_str'] : $body), $str_contact_allow, $str_group_allow, $str_contact_deny, $str_group_deny, $token); + } + + + $attachments = ''; + $match = false; + + if (preg_match_all('/(\[attachment\](.*?)\[\/attachment\])/', $body, $match)) { + $attachments = []; + $i = 0; + foreach ($match[2] as $mtch) { + $attach_link = ''; + $hash = substr($mtch, 0, strpos($mtch, ',')); + $rev = intval(substr($mtch, strpos($mtch, ','))); + $r = attach_by_hash_nodata($hash, $observer['xchan_hash'], $rev); + if ($r['success']) { + $attachments[] = array( + 'href' => z_root() . '/attach/' . $r['data']['hash'], + 'length' => $r['data']['filesize'], + 'type' => $r['data']['filetype'], + 'title' => urlencode($r['data']['filename']), + 'revision' => $r['data']['revision'] + ); + } + $body = str_replace($match[1][$i], $attach_link, $body); + $i++; + } + } + + + if (preg_match_all('/(\[share=(.*?)\](.*?)\[\/share\])/', $body, $match)) { + // process share by id + + $i = 0; + foreach ($match[2] as $mtch) { + $reshare = new Share($mtch); + $body = str_replace($match[1][$i], $reshare->bbcode(), $body); + if (! is_array($attachments)) { + $attachments = []; + } + $attachments = array_merge($attachments,$reshare->get_attach()); + $i++; + } + } + } + + // BBCODE end alert + + + // if the acl contains a single contact and it's a group, add a mention. This is for compatibility + // with other groups implementations which require a mention to trigger group delivery. + + if (($str_contact_allow) && (!$str_group_allow) && (!$str_contact_deny) && (!$str_group_deny)) { + $cida = expand_acl($str_contact_allow); + if (count($cida) === 1) { + $netgroups = get_forum_channels($profile_uid, 1); + if ($netgroups) { + foreach ($netgroups as $ng) { + if ($ng['xchan_hash'] == $cida[0]) { + if (!is_array($post_tags)) { + $post_tags = []; + } + $post_tags[] = array( + 'uid' => $profile_uid, + 'ttype' => TERM_MENTION, + 'otype' => TERM_OBJ_POST, + 'term' => $ng['xchan_name'], + 'url' => $ng['xchan_url'] + ); + + $colls = get_xconfig($ng['xchan_hash'], 'activitypub', 'collections'); + if ($colls && is_array($colls) && isset($colls['wall'])) { + $datarray['target'] = [ + 'id' => $colls['wall'], + 'type' => 'Collection', + 'attributedTo' => ((in_array($ng['xchan_network'], ['zot6', 'nomad'])) ? $ng['xchan_url'] : $ng['xchan_hash']) + ]; + $datarray['tgt_type'] = 'Collection'; + } + } + } + } + } + } + + + if (strlen($categories)) { + if (!isset($post_tags)) { + $post_tags = []; + } + + $cats = explode(',', $categories); + foreach ($cats as $cat) { + if ($webpage == ITEM_TYPE_CARD) { + $catlink = z_root() . '/cards/' . $channel['channel_address'] . '?f=&cat=' . urlencode(trim($cat)); + } elseif ($webpage == ITEM_TYPE_ARTICLE) { + $catlink = z_root() . '/articles/' . $channel['channel_address'] . '?f=&cat=' . urlencode(trim($cat)); + } else { + $catlink = $owner_xchan['xchan_url'] . '?f=&cat=' . urlencode(trim($cat)); + } + + $post_tags[] = array( + 'uid' => $profile_uid, + 'ttype' => TERM_CATEGORY, + 'otype' => TERM_OBJ_POST, + 'term' => trim($cat), + 'url' => $catlink + ); + } + } + + if ($orig_post) { + // preserve original tags + $t = q( + "select * from term where oid = %d and otype = %d and uid = %d and ttype in ( %d, %d, %d )", + intval($orig_post['id']), + intval(TERM_OBJ_POST), + intval($profile_uid), + intval(TERM_UNKNOWN), + intval(TERM_FILE), + intval(TERM_COMMUNITYTAG) + ); + if ($t) { + if (!isset($post_tags)) { + $post_tags = []; + } + + foreach ($t as $t1) { + $post_tags[] = array( + 'uid' => $profile_uid, + 'ttype' => $t1['ttype'], + 'otype' => TERM_OBJ_POST, + 'term' => $t1['term'], + 'url' => $t1['url'], + ); + } + } + } + + + $item_unseen = ((local_channel() != $profile_uid) ? 1 : 0); + $item_wall = ((isset($_REQUEST['type']) && ($_REQUEST['type'] === 'wall' || $_REQUEST['type'] === 'wall-comment')) ? 1 : 0); + $item_origin = (($origin) ? 1 : 0); + $item_nocomment = ((isset($item_nocomment)) ? $item_nocomment : $nocomment); + + + // determine if this is a wall post + + if ($parent) { + $item_wall = $parent_item['item_wall']; + } else { + if (!$webpage) { + $item_wall = 1; + } + } + + + if ($moderated) { + $item_blocked = ITEM_MODERATED; + } + + + if (!strlen($verb)) { + $verb = ACTIVITY_POST; + } + + $notify_type = (($parent) ? 'comment-new' : 'wall-new'); + + if (!(isset($mid) && $mid)) { + if ($message_id) { + $mid = $message_id; + } else { + $uuid = new_uuid(); + $mid = z_root() . '/item/' . $uuid; + } + } + + + if ($is_poll) { + $poll = [ + 'question' => $body, + 'answers' => $_REQUEST['poll_answers'], + 'multiple_answers' => $_REQUEST['poll_multiple_answers'], + 'expire_value' => $_REQUEST['poll_expire_value'], + 'expire_unit' => $_REQUEST['poll_expire_unit'] + ]; + $obj = $this->extract_poll_data($poll, ['item_private' => $private, 'allow_cid' => $str_contact_allow, 'allow_gid' => $str_contact_deny]); + } else { + $obj = $this->extract_bb_poll_data($body, ['item_private' => $private, 'allow_cid' => $str_contact_allow, 'allow_gid' => $str_contact_deny]); + } + + + if ($obj) { + $obj['url'] = $obj['id'] = $mid; + $obj['attributedTo'] = Channel::url($channel); + $datarray['obj'] = $obj; + $obj_type = 'Question'; + if ($obj['endTime']) { + $d = datetime_convert('UTC', 'UTC', $obj['endTime']); + if ($d > NULL_DATE) { + $comments_closed = $d; + } + } + } + + if (!$parent_mid) { + $parent_mid = $mid; + } + + if ($parent_item) { + $parent_mid = $parent_item['mid']; + } + + + // Fallback so that we alway have a thr_parent + + if (!$thr_parent) { + $thr_parent = $mid; + } + + + $item_thread_top = ((!$parent) ? 1 : 0); + + + // fix permalinks for cards, etc. + + if ($webpage == ITEM_TYPE_CARD) { + $plink = z_root() . '/cards/' . $channel['channel_address'] . '/' . (($pagetitle) ? $pagetitle : $uuid); + } + if (($parent_item) && ($parent_item['item_type'] == ITEM_TYPE_CARD)) { + $r = q( + "select v from iconfig where iconfig.cat = 'system' and iconfig.k = 'CARD' and iconfig.iid = %d limit 1", + intval($parent_item['id']) + ); + if ($r) { + $plink = z_root() . '/cards/' . $channel['channel_address'] . '/' . $r[0]['v']; + } + } + + if ($webpage == ITEM_TYPE_ARTICLE) { + $plink = z_root() . '/articles/' . $channel['channel_address'] . '/' . (($pagetitle) ? $pagetitle : $uuid); + } + if (($parent_item) && ($parent_item['item_type'] == ITEM_TYPE_ARTICLE)) { + $r = q( + "select v from iconfig where iconfig.cat = 'system' and iconfig.k = 'ARTICLE' and iconfig.iid = %d limit 1", + intval($parent_item['id']) + ); + if ($r) { + $plink = z_root() . '/articles/' . $channel['channel_address'] . '/' . $r[0]['v']; + } + } + + if ((!(isset($plink) && $plink)) && $item_thread_top) { + $plink = z_root() . '/item/' . $uuid; + } + + if (array_path_exists('obj/id', $datarray)) { + $datarray['obj']['id'] = $mid; + } + + if ($private && !$parent) { + if ( intval($private) === 1 && (!$str_group_allow) && in_array(substr_count($str_contact_allow,'<'), [ 1, 2 ])) { + $private = 2; + } + } + + $datarray['aid'] = $channel['channel_account_id']; + $datarray['uid'] = $profile_uid; + $datarray['uuid'] = $uuid; + $datarray['owner_xchan'] = (($owner_hash) ? $owner_hash : $owner_xchan['xchan_hash']); + $datarray['author_xchan'] = $observer['xchan_hash']; + $datarray['created'] = $created; + $datarray['edited'] = (($orig_post && (!intval($orig_post['item_unpublished']))) ? datetime_convert() : $created); + $datarray['expires'] = $expires; + $datarray['commented'] = (($orig_post && (!intval($orig_post['item_unpublished']))) ? datetime_convert() : $created); + $datarray['received'] = (($orig_post && (!intval($orig_post['item_unpublished']))) ? datetime_convert() : $created); + $datarray['changed'] = (($orig_post && (!intval($orig_post['item_unpublished']))) ? datetime_convert() : $created); + $datarray['comments_closed'] = $comments_closed; + $datarray['mid'] = $mid; + $datarray['parent_mid'] = $parent_mid; + $datarray['mimetype'] = $mimetype; + $datarray['title'] = $title; + $datarray['summary'] = $summary; + $datarray['body'] = $body; + $datarray['app'] = $app; + $datarray['location'] = $location; + $datarray['coord'] = $coord; + $datarray['verb'] = $verb; + $datarray['obj_type'] = $obj_type; + $datarray['allow_cid'] = $str_contact_allow; + $datarray['allow_gid'] = $str_group_allow; + $datarray['deny_cid'] = $str_contact_deny; + $datarray['deny_gid'] = $str_group_deny; + $datarray['attach'] = $attachments; + $datarray['thr_parent'] = $thr_parent; + $datarray['postopts'] = $postopts; + $datarray['item_unseen'] = intval($item_unseen); + $datarray['item_wall'] = intval($item_wall); + $datarray['item_origin'] = intval($item_origin); + $datarray['item_type'] = $webpage; + $datarray['item_private'] = intval($private); + $datarray['item_thread_top'] = intval($item_thread_top); + $datarray['item_unseen'] = intval($item_unseen); + $datarray['item_starred'] = intval($item_starred); + $datarray['item_uplink'] = intval($item_uplink); + $datarray['item_consensus'] = 0; + $datarray['item_notshown'] = intval($item_notshown); + $datarray['item_nsfw'] = intval($item_nsfw); + $datarray['item_relay'] = intval($item_relay); + $datarray['item_mentionsme'] = intval($item_mentionsme); + $datarray['item_nocomment'] = intval($item_nocomment); + $datarray['item_obscured'] = intval($item_obscured); + $datarray['item_verified'] = intval($item_verified); + $datarray['item_retained'] = intval($item_retained); + $datarray['item_rss'] = intval($item_rss); + $datarray['item_deleted'] = intval($item_deleted); + $datarray['item_hidden'] = intval($item_hidden); + $datarray['item_unpublished'] = intval($item_unpublished); + $datarray['item_delayed'] = intval($item_delayed); + $datarray['item_pending_remove'] = intval($item_pending_remove); + $datarray['item_blocked'] = intval($item_blocked); + $datarray['layout_mid'] = $layout_mid; + $datarray['public_policy'] = $public_policy; + $datarray['comment_policy'] = ((is_numeric($comment_policy)) ? map_scope($comment_policy) : $comment_policy); // only map scope if it is numeric, otherwise use what we have + $datarray['term'] = $post_tags; + $datarray['plink'] = $plink; + $datarray['route'] = $route; + $datarray['replyto'] = $replyto; + + // A specific ACL over-rides public_policy completely + + if (!empty_acl($datarray)) { + $datarray['public_policy'] = ''; + } + + if ($iconfig) { + $datarray['iconfig'] = $iconfig; + } + if ($private) { + IConfig::set($datarray, 'ocap', 'relay', $token); + } + + if (!array_key_exists('obj', $datarray)) { + $copy = $datarray; + $copy['author'] = $observer; + $datarray['obj'] = Activity::encode_item($copy, ((get_config('system', 'activitypub', ACTIVITYPUB_ENABLED)) ? true : false)); + $recips = []; + $i = $datarray['obj']; + if ($i['to']) { + $recips['to'] = $i['to']; + } + if ($i['cc']) { + $recips['cc'] = $i['cc']; + } + IConfig::Set($datarray, 'activitypub', 'recips', $recips); + } + + Activity::rewrite_mentions($datarray); + + // preview mode - prepare the body for display and send it via json + + if ($preview) { + require_once('include/conversation.php'); + + $datarray['owner'] = $owner_xchan; + $datarray['author'] = $observer; + $datarray['attach'] = json_encode($datarray['attach']); + $o = conversation(array($datarray), 'search', false, 'preview'); + // logger('preview: ' . $o, LOGGER_DEBUG); + echo json_encode(array('preview' => $o)); + killme(); + } + + // Let 'post_local' event listeners know if this is an edit. + // We will unset it immediately afterward. + + if ($orig_post) { + $datarray['edit'] = true; + } + + // suppress duplicates, *unless* you're editing an existing post. This could get picked up + // as a duplicate if you're editing it very soon after posting it initially and you edited + // some attribute besides the content, such as title or categories. + + if (PConfig::Get($profile_uid, 'system', 'suppress_duplicates', true) && (!$orig_post)) { + $z = q( + "select created from item where uid = %d and created > %s - INTERVAL %s and body = '%s' limit 1", + intval($profile_uid), + db_utcnow(), + db_quoteinterval('2 MINUTE'), + dbesc($body) + ); + + if ($z) { + $datarray['cancel'] = 1; + notice(t('Duplicate post suppressed.') . EOL); + logger('Duplicate post. Cancelled.'); + } + } + + Hook::call('post_local', $datarray); + + // This is no longer needed + unset($datarray['edit']); + + if (x($datarray, 'cancel')) { + logger('mod_item: post cancelled by plugin or duplicate suppressed.'); + if ($return_path) { + goaway(z_root() . "/" . $return_path); + } + if ($api_source) { + return (['success' => false, 'message' => 'operation cancelled']); + } + $json = array('cancel' => 1); + $json['reload'] = z_root() . '/' . $_REQUEST['jsreload']; + json_return_and_die($json); + } + + + if (mb_strlen($datarray['title']) > 191) { + $datarray['title'] = mb_substr($datarray['title'], 0, 191); + } + + if ($webpage) { + IConfig::Set( + $datarray, + 'system', + webpage_to_namespace($webpage), + (($pagetitle) ? $pagetitle : basename($datarray['mid'])), + true + ); + } elseif ($namespace) { + IConfig::Set( + $datarray, + 'system', + $namespace, + (($remote_id) ? $remote_id : basename($datarray['mid'])), + true + ); + } + + if (intval($datarray['item_unpublished'])) { + $draft_msg = t('Draft saved. Use Drafts app to continue editing.'); + } + + if ($orig_post) { + $datarray['id'] = $post_id; + + $x = item_store_update($datarray, $execflag); + if (!$parent) { + $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($profile_uid, array('item' => array(encode_item($sync_item[0], true)))); + } + } + if (!$nopush) { + Run::Summon(['Notifier', 'edit_post', $post_id]); + } + + if ($api_source) { + return ($x); + } + + + if (intval($datarray['item_unpublished'])) { + info($draft_msg); + } + + + if ((x($_REQUEST, 'return')) && strlen($return_path)) { + logger('return: ' . $return_path); + goaway(z_root() . "/" . $return_path); + } + killme(); + } else { + $post_id = 0; + } + + $post = item_store($datarray, $execflag); + + if ($pub_copy) { + info(t('Your comment has been posted.') . EOL); + } + + $post_id = $post['item_id']; + $datarray = $post['item']; + + + if ($post_id) { + logger('mod_item: saved item ' . $post_id); + + if ($parent) { + // prevent conversations which you are involved from being expired + + if (local_channel()) { + retain_item($parent); + } + + // only send comment notification if this is a wall-to-wall comment and not a DM, + // otherwise it will happen during delivery + + if (($datarray['owner_xchan'] != $datarray['author_xchan']) && (intval($parent_item['item_wall'])) && intval($datarray['item_private']) != 2) { + Enotify::submit(array( + 'type' => NOTIFY_COMMENT, + 'from_xchan' => $datarray['author_xchan'], + 'to_xchan' => $datarray['owner_xchan'], + 'item' => $datarray, + 'link' => z_root() . '/display/' . gen_link_id($datarray['mid']), + 'verb' => ACTIVITY_POST, + 'otype' => 'item', + 'parent' => $parent, + 'parent_mid' => $parent_item['mid'] + )); + } + } else { + $parent = $post_id; + + if (($datarray['owner_xchan'] != $datarray['author_xchan']) && ($datarray['item_type'] == ITEM_TYPE_POST)) { + Enotify::submit(array( + 'type' => NOTIFY_WALL, + 'from_xchan' => $datarray['author_xchan'], + 'to_xchan' => $datarray['owner_xchan'], + 'item' => $datarray, + 'link' => z_root() . '/display/' . gen_link_id($datarray['mid']), + 'verb' => ACTIVITY_POST, + 'otype' => 'item' + )); + } + + if ($uid && $uid == $profile_uid && (is_item_normal($datarray))) { + q( + "update channel set channel_lastpost = '%s' where channel_id = %d", + dbesc(datetime_convert()), + intval($uid) + ); + } + } + + // photo comments turn the corresponding item visible to the profile wall + // This way we don't see every picture in your new photo album posted to your wall at once. + // They will show up as people comment on them. + + if (intval($parent_item['item_hidden'])) { + $r = q( + "UPDATE item SET item_hidden = 0 WHERE id = %d", + intval($parent_item['id']) + ); + } + } else { + logger('mod_item: unable to retrieve post that was just stored.'); + notice(t('System error. Post not saved.') . EOL); + if ($return_path) { + goaway(z_root() . "/" . $return_path); + } + if ($api_source) { + return (['success' => false, 'message' => 'system error']); + } + killme(); + } + + if (($parent) && ($parent != $post_id)) { + // Store the comment signature information in case we need to relay to Diaspora + //$ditem = $datarray; + //$ditem['author'] = $observer; + //store_diaspora_comment_sig($ditem,$channel,$parent_item, $post_id, (($walltowall_comment) ? 1 : 0)); + } else { + $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($profile_uid, array('item' => array(encode_item($sync_item[0], true)))); + } + } + + $datarray['id'] = $post_id; + $datarray['llink'] = z_root() . '/display/' . gen_link_id($datarray['mid']); + + Hook::call('post_local_end', $datarray); + + if ($groupww) { + $nopush = false; + } + + if (!$nopush) { + Run::Summon(['Notifier', $notify_type, $post_id]); + } + logger('post_complete'); + + if ($moderated) { + info(t('Your post/comment is awaiting approval.') . EOL); + } + + // figure out how to return, depending on from whence we came + + if ($api_source) { + return $post; + } + + if (intval($datarray['item_unpublished'])) { + info($draft_msg); + } + + if ($return_path) { + goaway(z_root() . "/" . $return_path); + } + + $json = array('success' => 1); + if (x($_REQUEST, 'jsreload') && strlen($_REQUEST['jsreload'])) { + $json['reload'] = z_root() . '/' . $_REQUEST['jsreload']; + } + + logger('post_json: ' . print_r($json, true), LOGGER_DEBUG); + + echo json_encode($json); + killme(); + // NOTREACHED + } + + + public function get() + { + + + if ($this->return_404) { + notice(t('Not found')); + return; + } + + if ((!local_channel()) && (!remote_channel())) { + return; + } + + // allow pinned items to be dropped. 'pin-' was prepended to the id of these + // items so that they would have a unique html id even if the pinned item + // was also displayed in a normal conversation on the same web page. + + $drop_id = str_replace('pin-', '', argv(2)); + + if ((argc() == 3) && (argv(1) === 'drop') && intval($drop_id)) { + $i = q( + "select * from item where id = %d limit 1", + intval($drop_id) + ); + + if ($i) { + $can_delete = false; + $local_delete = false; + $regular_delete = false; + + if (local_channel() && local_channel() == $i[0]['uid']) { + $local_delete = true; + } + + $ob_hash = get_observer_hash(); + if ($ob_hash && ($ob_hash === $i[0]['author_xchan'] || $ob_hash === $i[0]['owner_xchan'] || $ob_hash === $i[0]['source_xchan'])) { + $can_delete = true; + $regular_delete = true; + } + + // The site admin can delete any post/item on the site. + // If the item originated on this site+channel the deletion will propagate downstream. + // Otherwise just the local copy is removed. + + if (is_site_admin()) { + $local_delete = true; + if (intval($i[0]['item_origin'])) { + $can_delete = true; + } + } + + + if (!($can_delete || $local_delete)) { + notice(t('Permission denied.') . EOL); + return; + } + + if ($i[0]['resource_type'] === 'event') { + // delete and sync the event separately + $r = q( + "SELECT * FROM event WHERE event_hash = '%s' AND uid = %d LIMIT 1", + dbesc($i[0]['resource_id']), + intval($i[0]['uid']) + ); + if ($r && $regular_delete) { + $sync_event = $r[0]; + q( + "delete from event WHERE event_hash = '%s' AND uid = %d LIMIT 1", + dbesc($i[0]['resource_id']), + intval($i[0]['uid']) + ); + $sync_event['event_deleted'] = 1; + Libsync::build_sync_packet($i[0]['uid'], array('event' => array($sync_event))); + } + } + + if ($i[0]['resource_type'] === 'photo') { + attach_delete($i[0]['uid'], $i[0]['resource_id'], true); + $ch = Channel::from_id($i[0]['uid']); + if ($ch && $regular_delete) { + $sync = attach_export_data($ch, $i[0]['resource_id'], true); + if ($sync) { + Libsync::build_sync_packet($i[0]['uid'], array('file' => array($sync))); + } + } + } + + + // if this is a different page type or it's just a local delete + // but not by the item author or owner, do a simple deletion + + $complex = false; + + if (intval($i[0]['item_type']) || ($local_delete && (!$can_delete))) { + drop_item($i[0]['id']); + } else { + // complex deletion that needs to propagate and be performed in phases + drop_item($i[0]['id'], true, DROPITEM_PHASE1); + $complex = true; + } + + $r = q( + "select * from item where id = %d", + intval($i[0]['id']) + ); + if ($r) { + xchan_query($r); + $sync_item = fetch_post_tags($r); + Libsync::build_sync_packet($i[0]['uid'], array('item' => array(encode_item($sync_item[0], true)))); + } + + if ($complex) { + tag_deliver($i[0]['uid'], $i[0]['id']); + } + } + } + } + + + public function item_check_service_class($channel_id, $iswebpage) + { + $ret = array('success' => false, 'message' => ''); + + if ($iswebpage) { + $r = q( + "select count(i.id) as total from item i + right join channel c on (i.author_xchan=c.channel_hash and i.uid=c.channel_id ) + and i.parent=i.id and i.item_type = %d and i.item_deleted = 0 and i.uid= %d ", + intval(ITEM_TYPE_WEBPAGE), + intval($channel_id) + ); + } else { + $r = q( + "select count(id) as total from item where parent = id and item_wall = 1 and uid = %d " . item_normal(), + intval($channel_id) + ); + } + + if (!$r) { + $ret['message'] = t('Unable to obtain post information from database.'); + return $ret; + } + + + if (!$iswebpage) { + $max = engr_units_to_bytes(ServiceClass::fetch($channel_id, 'total_items')); + if (!ServiceClass::allows($channel_id, 'total_items', $r[0]['total'])) { + $result['message'] .= ServiceClass::upgrade_message() . sprintf(t('You have reached your limit of %1$.0f top level posts.'), $max); + return $result; + } + } else { + $max = engr_units_to_bytes(ServiceClass::fetch($channel_id, 'total_pages')); + if (!ServiceClass::allows($channel_id, 'total_pages', $r[0]['total'])) { + $result['message'] .= ServiceClass::upgrade_message() . sprintf(t('You have reached your limit of %1$.0f webpages.'), $max); + return $result; + } + } + + $ret['success'] = true; + return $ret; + } + + public function extract_bb_poll_data(&$body, $item) + { + + $multiple = false; + + if (strpos($body, '[/question]') === false && strpos($body, '[/answer]') === false) { + return false; + } + if (strpos($body, '[nobb]') !== false) { + return false; + } + + $obj = []; + $ptr = []; + $matches = null; + $obj['type'] = 'Question'; + + if (preg_match_all('/\[answer\](.*?)\[\/answer\]/ism', $body, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $ptr[] = ['name' => $match[1], 'type' => 'Note', 'replies' => ['type' => 'Collection', 'totalItems' => 0]]; + $body = str_replace('[answer]' . $match[1] . '[/answer]', EMPTY_STR, $body); + } + } + + $matches = null; + + if (preg_match('/\[question\](.*?)\[\/question\]/ism', $body, $matches)) { + $obj['content'] = bbcode($matches[1]); + $body = str_replace('[question]' . $matches[1] . '[/question]', $matches[1], $body); + $obj['oneOf'] = $ptr; + } + + $matches = null; + + if (preg_match('/\[question=multiple\](.*?)\[\/question\]/ism', $body, $matches)) { + $obj['content'] = bbcode($matches[1]); + $body = str_replace('[question=multiple]' . $matches[1] . '[/question]', $matches[1], $body); + $obj['anyOf'] = $ptr; + } + + $matches = null; + + if (preg_match('/\[ends\](.*?)\[\/ends\]/ism', $body, $matches)) { + $obj['endTime'] = datetime_convert(date_default_timezone_get(), 'UTC', $matches[1], ATOM_TIME); + $body = str_replace('[ends]' . $matches[1] . '[/ends]', EMPTY_STR, $body); + } + + if ($item['item_private']) { + $obj['to'] = Activity::map_acl($item); + } else { + $obj['to'] = [ACTIVITY_PUBLIC_INBOX]; + } + + return $obj; + } + + public function extract_poll_data($poll, $item) + { + + $multiple = intval($poll['multiple_answers']); + $expire_value = intval($poll['expire_value']); + $expire_unit = $poll['expire_unit']; + $question = $poll['question']; + $answers = $poll['answers']; + + $obj = []; + $ptr = []; + $obj['type'] = 'Question'; + $obj['content'] = bbcode($question); + + foreach ($answers as $answer) { + if (trim($answer)) { + $ptr[] = ['name' => escape_tags($answer), 'type' => 'Note', 'replies' => ['type' => 'Collection', 'totalItems' => 0]]; + } + } + + if ($multiple) { + $obj['anyOf'] = $ptr; + } else { + $obj['oneOf'] = $ptr; + } + + $obj['endTime'] = datetime_convert(date_default_timezone_get(), 'UTC', 'now + ' . $expire_value . ' ' . $expire_unit, ATOM_TIME); + + if ($item['item_private']) { + $obj['to'] = Activity::map_acl($item); + } else { + $obj['to'] = [ACTIVITY_PUBLIC_INBOX]; + } + + return $obj; + } +} diff --git a/Code/Module/Jwks.php b/Code/Module/Jwks.php new file mode 100644 index 000000000..bc7a6af04 --- /dev/null +++ b/Code/Module/Jwks.php @@ -0,0 +1,62 @@ + base64url_encode($e), + 'n' => base64url_encode($m), + 'kty' => 'RSA', + 'kid' => '0', + ] + ]; + + + $ret = [ + 'keys' => $keys + ]; + + if (argc() > 1) { + $entry = intval(argv(1)); + if ($keys[$entry]) { + unset($keys[$entry]['kid']); + json_return_and_die($keys[$entry], 'application/jwk+json'); + } + } + + json_return_and_die($ret, 'application/jwk-set+json'); + } +} diff --git a/Code/Module/Lang.php b/Code/Module/Lang.php new file mode 100644 index 000000000..a95b80279 --- /dev/null +++ b/Code/Module/Lang.php @@ -0,0 +1,30 @@ +Language App (Not Installed):
      '; + $o .= t('Change UI language'); + return $o; + } + } + + Navbar::set_selected('Language'); + return lang_selector(); + } +} diff --git a/Code/Module/Layouts.php b/Code/Module/Layouts.php new file mode 100644 index 000000000..1163224b0 --- /dev/null +++ b/Code/Module/Layouts.php @@ -0,0 +1,219 @@ + 1 && argv(1) === 'sys' && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + App::$is_sys = true; + } + } + + if (argc() > 1) { + $which = argv(1); + } else { + return; + } + + Libprofile::load($which); + } + + public function get() + { + + if (!App::$profile) { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + + $which = argv(1); + + $_SESSION['return_url'] = App::$query_string; + + $uid = local_channel(); + $owner = 0; + $channel = null; + $observer = App::get_observer(); + + $channel = App::get_channel(); + + if (App::$is_sys && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + $uid = $owner = intval($sys['channel_id']); + $channel = $sys; + $observer = $sys; + } + } + + if (!$owner) { + // Figure out who the page owner is. + $r = q( + "select channel_id from channel where channel_address = '%s'", + dbesc($which) + ); + if ($r) { + $owner = intval($r[0]['channel_id']); + } + } + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $perms = get_all_perms($owner, $ob_hash); + + if (!$perms['write_pages']) { + notice(t('Permission denied.') . EOL); + return; + } + + // Block design features from visitors + + if ((!$uid) || ($uid != $owner)) { + notice(t('Permission denied.') . EOL); + return; + } + + // Get the observer, check their permissions + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $perms = get_all_perms($owner, $ob_hash); + + if (!$perms['write_pages']) { + notice(t('Permission denied.') . EOL); + return; + } + + // This feature is not exposed in redbasic ui since it is not clear why one would want to + // download a json encoded pdl file - we dont have a possibility to import it. + // Use the buildin share/install feature instead. + + if ((argc() > 3) && (argv(2) === 'share') && (argv(3))) { + $r = q( + "select iconfig.v, iconfig.k, mimetype, title, body from iconfig + left join item on item.id = iconfig.iid + where uid = %d and mid = '%s' and iconfig.cat = 'system' and iconfig.k = 'PDL' order by iconfig.v asc", + intval($owner), + dbesc(argv(3)) + ); + if ($r) { + header('Content-type: application/x-hubzilla-layout'); + header('Content-Disposition: attachment; filename="' . $r[0]['sid'] . '.pdl"'); + echo json_encode($r); + killme(); + } + } + + // Create a status editor (for now - we'll need a WYSIWYG eventually) to create pages + // Nickname is set to the observers xchan, and profile_uid to the owners. + // This lets you post pages at other people's channels. + + $x = array( + 'webpage' => ITEM_TYPE_PDL, + 'is_owner' => true, + 'nickname' => App::$profile['channel_address'], + 'showacl' => false, + 'hide_voting' => true, + 'hide_future' => true, + 'hide_expire' => true, + 'hide_location' => true, + 'hide_weblink' => true, + 'hide_attach' => true, + 'hide_preview' => true, + 'disable_comments' => true, + 'ptlabel' => t('Layout Name'), + 'profile_uid' => intval($owner), + 'expanded' => true, + 'placeholdertitle' => t('Layout Description (Optional)'), + 'novoting' => true, + 'bbco_autocomplete' => 'comanche' + ); + + if ($_REQUEST['title']) { + $x['title'] = $_REQUEST['title']; + } + if ($_REQUEST['body']) { + $x['body'] = $_REQUEST['body']; + } + if ($_REQUEST['pagetitle']) { + $x['pagetitle'] = $_REQUEST['pagetitle']; + } + + $editor = status_editor($x); + + $r = q( + "select iconfig.iid, iconfig.v, mid, title, body, mimetype, created, edited, item_type from iconfig + left join item on iconfig.iid = item.id + where uid = %d and iconfig.cat = 'system' and iconfig.k = 'PDL' and item_type = %d order by item.created desc", + intval($owner), + intval(ITEM_TYPE_PDL) + ); + + $pages = null; + + if ($r) { + $pages = []; + foreach ($r as $rr) { + $element_arr = array( + 'type' => 'layout', + 'title' => $rr['title'], + 'body' => $rr['body'], + 'created' => $rr['created'], + 'edited' => $rr['edited'], + 'mimetype' => $rr['mimetype'], + 'pagetitle' => urldecode($rr['v']), + 'mid' => $rr['mid'] + ); + $pages[$rr['iid']][] = array( + 'url' => $rr['iid'], + 'title' => urldecode($rr['v']), + 'descr' => $rr['title'], + 'mid' => $rr['mid'], + 'created' => $rr['created'], + 'edited' => $rr['edited'], + 'bb_element' => '[element]' . base64url_encode(json_encode($element_arr)) . '[/element]' + ); + } + } + + //Build the base URL for edit links + $url = z_root() . '/editlayout/' . $which; + + $o .= replace_macros(Theme::get_template('layoutlist.tpl'), array( + '$title' => t('Layouts'), + '$create' => t('Create'), + '$help' => '', // array('text' => t('Help'), 'url' => 'help/comanche', 'title' => t('Comanche page description language help')), + '$editor' => $editor, + '$baseurl' => $url, + '$name' => t('Layout Name'), + '$descr' => t('Layout Description'), + '$created' => t('Created'), + '$edited' => t('Edited'), + '$edit' => t('Edit'), + '$share' => t('Share'), + '$download' => t('Download PDL file'), + '$pages' => $pages, + '$channel' => $which, + '$view' => t('View'), + )); + + return $o; + } +} diff --git a/Code/Module/Like.php b/Code/Module/Like.php new file mode 100644 index 000000000..ec10e9f05 --- /dev/null +++ b/Code/Module/Like.php @@ -0,0 +1,317 @@ + 1 && $test[0] === 'Undo') { + $undo = true; + $activity = $test[1]; + } + + if (! in_array($activity, [ 'Like', 'Dislike', 'Accept', 'Reject', 'TentativeAccept' ])) { + killme(); + } + + $is_rsvp = in_array($activity, [ 'Accept', 'Reject', 'TentativeAccept' ]); + + // Check for when target is something besides messages, where argv(1) is the type of thing + // and argv(2) is an identifier of things of that type + // We currently only recognise 'profile' but other types could be handled + + + if (! $observer) { + killme(); + } + + // this is used to like an item or comment + + $item_id = ((argc() == 2) ? notags(trim(argv(1))) : 0); + + logger('like: undo: ' . (($undo) ? 'true' : 'false')); + logger('like: verb ' . $activity . ' item ' . $item_id, LOGGER_DEBUG); + + // get the item. Allow linked photos (which are normally hidden) to be liked + + $r = q( + "SELECT * FROM item WHERE id = %d + and item_type in (0,6,7) and item_deleted = 0 and item_unpublished = 0 + and item_delayed = 0 and item_pending_remove = 0 and item_blocked = 0 LIMIT 1", + intval($item_id) + ); + + // if interacting with a pubstream item, + // create a copy of the parent in your stream. + + if ($r) { + if (local_channel() && (! Channel::is_system(local_channel()))) { + $r = [ copy_of_pubitem(App::get_channel(), $r[0]['mid']) ]; + } + } + + if (! $item_id || (! $r)) { + logger('like: no item ' . $item_id); + killme(); + } + + xchan_query($r, true); + + $item = array_shift($r); + + $owner_uid = $item['uid']; + $owner_aid = $item['aid']; + + $can_comment = false; + if ((array_key_exists('owner', $item)) && intval($item['owner']['abook_self'])) { + $can_comment = perm_is_allowed($item['uid'], $observer['xchan_hash'], 'post_comments'); + } else { + $can_comment = can_comment_on_post($observer['xchan_hash'], $item); + } + + if (! $can_comment) { + notice(t('Permission denied') . EOL); + killme(); + } + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($item['owner_xchan']) + ); + + if ($r) { + $thread_owner = array_shift($r); + } else { + killme(); + } + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($item['author_xchan']) + ); + if ($r) { + $item_author = array_shift($r); + } else { + killme(); + } + + if ($undo) { + $r = q( + "select * from item where thr_parent = '%s' and verb = '%s' and author_xchan = '%s' and uid = %d and item_deleted = 0 limit 1", + dbesc($item['thr_parent']), + dbesc($activity), + dbesc($observer['xchan_hash']), + intval($owner_uid) + ); + + xchan_query($r, true); + $r = fetch_post_tags($r, true); + $r[0]['obj'] = json_decode($r[0]['obj'], true); + $object = Activity::encode_activity($r[0], true); + + // do not do either a federated or hard delete on the original reaction + // as we are going to send an Undo to perform this task + // just set item_deleted to update the local conversation + + $retval = q( + "update item set item_deleted = 1 where id = %d", + intval($r[0]['id']) + ); + } else { + $object = Activity::fetch_item([ 'id' => $item['mid'] ]); + } + + if (! $object) { + killme(); + } + + $uuid = new_uuid(); + + // we have everything we need - start building our new item + + $arr = []; + + $arr['uuid'] = $uuid; + $arr['mid'] = z_root() . (($is_rsvp) ? '/activity/' : '/item/' ) . $uuid; + + $post_type = (($item['resource_type'] === 'photo') ? t('photo') : t('status')); + if ($item['obj_type'] === ACTIVITY_OBJ_EVENT) { + $post_type = t('event'); + } + + $objtype = $item['obj_type']; + + $body = $item['body']; + + + if (! intval($item['item_thread_top'])) { + $post_type = 'comment'; + } + + $arr['item_origin'] = 1; + $arr['item_notshown'] = 1; + $arr['item_type'] = $item['item_type']; + + if (intval($item['item_wall'])) { + $arr['item_wall'] = 1; + } + + // if this was a linked photo and was hidden, unhide it and distribute it. + + if (intval($item['item_hidden'])) { + $r = q( + "update item set item_hidden = 0 where id = %d", + intval($item['id']) + ); + + $r = q( + "select * from item where id = %d", + intval($item['id']) + ); + if ($r) { + xchan_query($r); + $sync_item = fetch_post_tags($r); + Libsync::build_sync_packet($channel['channel_id'], [ 'item' => [ encode_item($sync_item[0], true) ] ]); + } + + Run::Summon([ 'Notifier','wall-new',$item['id'] ]); + } + + + if ($undo) { + $arr['body'] = t('Undo a previous action'); + $arr['item_notshown'] = 1; + } else { + if ($activity === 'Like') { + $bodyverb = t('%1$s likes %2$s\'s %3$s'); + } + if ($activity === 'Dislike') { + $bodyverb = t('%1$s doesn\'t like %2$s\'s %3$s'); + } + if ($activity === 'Accept') { + $bodyverb = t('%1$s is attending %2$s\'s %3$s'); + } + if ($activity === 'Reject') { + $bodyverb = t('%1$s is not attending %2$s\'s %3$s'); + } + if ($activity === 'TentativeAccept') { + $bodyverb = t('%1$s may attend %2$s\'s %3$s'); + } + + if (! isset($bodyverb)) { + killme(); + } + + $ulink = '[zrl=' . $item_author['xchan_url'] . ']' . $item_author['xchan_name'] . '[/zrl]'; + $alink = '[zrl=' . $observer['xchan_url'] . ']' . $observer['xchan_name'] . '[/zrl]'; + $plink = '[zrl=' . z_root() . '/display/' . gen_link_id($item['mid']) . ']' . $post_type . '[/zrl]'; + + $arr['body'] = sprintf($bodyverb, $alink, $ulink, $plink); + } + + + if (local_channel() && $activity === 'Accept') { + event_addtocal($item['id'], $channel['channel_id']); + } + + $arr['parent'] = $item['id']; + $arr['thr_parent'] = $item['mid']; + $allow_cid = $item['allow_cid']; + $allow_gid = $item['allow_gid']; + $deny_cid = $item['deny_cid']; + $deny_gid = $item['deny_gid']; + $private = $item['private']; + + $arr['aid'] = $owner_aid; + $arr['uid'] = $owner_uid; + + $arr['item_flags'] = $item['item_flags']; + $arr['item_wall'] = $item['item_wall']; + $arr['parent_mid'] = $item['mid']; + $arr['owner_xchan'] = $thread_owner['xchan_hash']; + $arr['author_xchan'] = $observer['xchan_hash']; + + + + $arr['verb'] = (($undo) ? 'Undo' : $activity); + $arr['obj_type'] = (($undo) ? $activity : $objtype); + $arr['obj'] = $object; + + if ($target) { + $arr['tgt_type'] = $tgttype; + $arr['target'] = $target; + } + + $arr['allow_cid'] = $allow_cid; + $arr['allow_gid'] = $allow_gid; + $arr['deny_cid'] = $deny_cid; + $arr['deny_gid'] = $deny_gid; + $arr['item_private'] = $private; + + Hook::call('post_local', $arr); + + $post = item_store($arr); + $post_id = $post['item_id']; + + // save the conversation from expiration + + if (local_channel() && array_key_exists('item', $post) && (intval($post['item']['id']) != intval($post['item']['parent']))) { + retain_item($post['item']['parent']); + } + + $arr['id'] = $post_id; + + Hook::call('post_local_end', $arr); + + $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($channel['channel_id'], [ 'item' => [ encode_item($sync_item[0], true) ] ]); + } + + Run::Summon([ 'Notifier', 'like', $post_id ]); + + killme(); + } +} diff --git a/Code/Module/Linkinfo.php b/Code/Module/Linkinfo.php new file mode 100644 index 000000000..80190026d --- /dev/null +++ b/Code/Module/Linkinfo.php @@ -0,0 +1,644 @@ + true, 'nobody' => true]); + if ($result['success']) { + $hdrs = []; + $h = explode("\n", $result['header']); + foreach ($h as $l) { + list($k, $v) = array_map("trim", explode(":", trim($l), 2)); + $hdrs[strtolower($k)] = $v; + } + if (array_key_exists('content-type', $hdrs)) { + $type = $hdrs['content-type']; + } + if ($type) { + if (stripos($type, 'image/') !== false) { + $basename = basename($url); + + if ($zrl) { + echo $br . '[zmg alt="' . $basename . '"]' . $url . '[/zmg]' . $br; + } else { + echo $br . '[img alt="' . $basename . '"]' . $url . '[/img]' . $br; + } + killme(); + } + if ((stripos($type, 'video/') !== false) || ($type === 'application/ogg')) { + $thumb = self::get_video_poster($url); + if ($thumb) { + if ($zrl) { + echo $br . '[zvideo poster=\'' . $thumb . '\']' . $url . '[/zvideo]' . $br; + } else { + echo $br . '[video poster=\'' . $thumb . '\']' . $url . '[/video]' . $br; + } + killme(); + } + if ($zrl) { + echo $br . '[zvideo]' . $url . '[/zvideo]' . $br; + } else { + echo $br . '[video]' . $url . '[/video]' . $br; + } + killme(); + } + if (stripos($type, 'audio/') !== false) { + if ($zrl) { + echo $br . '[zaudio]' . $url . '[/zaudio]' . $br; + } else { + echo $br . '[audio]' . $url . '[/audio]' . $br; + } + killme(); + } + if (strtolower($type) === 'text/calendar') { + $content = z_fetch_url($url, false, 0, array('novalidate' => true)); + if ($content['success']) { + $ev = ical_to_ev($content['body']); + if ($ev) { + echo $br . format_event_bbcode($ev[0]) . $br; + killme(); + } + } + } + if (strtolower($type) === 'application/pdf' || strtolower($type) === 'application/x-pdf') { + echo $br . '[embed]' . $url . '[/embed]' . $br; + killme(); + } + } + } + + $template = $br . '[url=%s]%s[/url]%s' . $br; + + $arr = array('url' => $url, 'text' => ''); + + Hook::call('parse_link', $arr); + + if (strlen($arr['text'])) { + echo $arr['text']; + killme(); + } + + if ($process_oembed) { + $x = Oembed::process($url); + if ($x) { + echo $x; + killme(); + } + } + + if ($process_zotobj) { + $x = Activity::fetch($url, App::get_channel()); + $y = null; + if (is_array($x)) { + if (ActivityStreams::is_an_actor($x['type']) && $x['id']) { + if (check_siteallowed($x['id']) && check_channelallowed($x['id'])) { + $url = $x['url']; + if (is_array($url)) { + $url = $url[0]['href']; + } + $name = (($x['name']) ? $x['name'] . ' (' . $x['preferredUsername'] . ')' : $x['preferredUsername']); + + if (array_path_exists('icon/url', $x)) { + $text = $br . $br . '[zrl=' . $url . '][zmg=300x300]' . $x['icon']['url'] . '[/zmg][/zrl]'; + } + $text .= $br . $br . '[zrl=' . $url . ']' . $name . '[/zrl]' . $br . $br; + echo $text; + killme(); + } + } else { + $y = new ActivityStreams($x); + if ( + $y->is_valid() && $y->type === 'Announce' && is_array($y->obj) + && array_key_exists('object', $y->obj) && array_key_exists('actor', $y->obj) + ) { + // This is a relayed/forwarded Activity (as opposed to a shared/boosted object) + // Reparse the encapsulated Activity and use that instead + logger('relayed activity', LOGGER_DEBUG); + $y = new ActivityStreams($y->obj); + } + } + + if ($y && $y->is_valid()) { + $z = Activity::decode_note($y); + $r = q( + "select hubloc_hash, hubloc_network, hubloc_url from hubloc where hubloc_hash = '%s' OR hubloc_id_url = '%s'", + dbesc(is_array($y->actor) ? $y->actor['id'] : $y->actor), + dbesc(is_array($y->actor) ? $y->actor['id'] : $y->actor) + ); + + if ($r) { + $r = Libzot::zot_record_preferred($r); + if ($z) { + $z['author_xchan'] = $r['hubloc_hash']; + } + } + + if ($z) { + // do not allow somebody to embed a post that was blocked by the site admin + // We *will* let them over-rule any blocks they created themselves + + if (check_siteallowed($r['hubloc_id_url']) && check_channelallowed($z['author_xchan'])) { + $s = new Zlib\Share($z); + echo $s->bbcode(); + killme(); + } + } + } + } + } + + + if ($url && $title && $text) { + $text = $br . '[quote]' . trim($text) . '[/quote]' . $br; + + $title = str_replace(array("\r", "\n"), array('', ''), $title); + + $result = sprintf($template, $url, ($title) ? $title : $url, $text) . $str_tags; + + logger('linkinfo (unparsed): returns: ' . $result); + + echo $result; + killme(); + } + + $siteinfo = self::parseurl_getsiteinfo($url); + + // If the site uses this platform, use zrl rather than url so they get zids sent to them by default + + if (is_matrix_url($url)) { + $template = str_replace('url', 'zrl', $template); + } + + if ($siteinfo["title"] == "") { + echo sprintf($template, $url, $url, '') . $str_tags; + killme(); + } else { + $text = $siteinfo["text"]; + $title = $siteinfo["title"]; + } + + $image = ""; + + if (isset($siteinfo['images']) && is_array($siteinfo['images']) && count($siteinfo["images"])) { + /* Execute below code only if image is present in siteinfo */ + + $total_images = 0; + $max_images = get_config('system', 'max_bookmark_images'); + if ($max_images === false) { + $max_images = 2; + } else { + $max_images = intval($max_images); + } + + foreach ($siteinfo["images"] as $imagedata) { + if ($url) { + $image .= sprintf('[url=%s]', $url); + } + $image .= '[img=' . $imagedata["width"] . 'x' . $imagedata["height"] . ']' . $imagedata["src"] . '[/img]'; + if ($url) { + $image .= '[/url]'; + } + $image .= "\n"; + $total_images++; + if ($max_images && $max_images >= $total_images) { + break; + } + } + } + + if (strlen($text)) { + $text = $br . '[quote]' . trim($text) . '[/quote]' . $br; + } + + if ($image) { + $text = $br . $br . $image . $text; + } + $title = str_replace(array("\r", "\n"), array('', ''), $title); + + $result = sprintf($template, $url, ($title) ? $title : $url, $text) . $str_tags; + + logger('linkinfo: returns: ' . $result, LOGGER_DEBUG); + + echo trim($result); + killme(); + } + + + public static function deletexnode(&$doc, $node) + { + $xpath = new DomXPath($doc); + $list = $xpath->query("//" . $node); + foreach ($list as $child) { + $child->parentNode->removeChild($child); + } + } + + public static function completeurl($url, $scheme) + { + $urlarr = parse_url($url); + + if (isset($urlarr["scheme"])) { + return ($url); + } + + $schemearr = parse_url($scheme); + + $complete = $schemearr["scheme"] . "://" . $schemearr["host"]; + + if ($schemearr["port"] != "") { + $complete .= ":" . $schemearr["port"]; + } + + if (strpos($urlarr['path'], '/') !== 0) { + $complete .= '/'; + } + + $complete .= $urlarr["path"]; + + if ($urlarr["query"] != "") { + $complete .= "?" . $urlarr["query"]; + } + + if ($urlarr["fragment"] != "") { + $complete .= "#" . $urlarr["fragment"]; + } + + return ($complete); + } + + public static function get_video_poster($url) + { + + if (strpos($url, z_root() . '/cloud/') === false) { + return EMPTY_STR; + } + $m = parse_url($url, PHP_URL_PATH); + if ($m) { + // strip leading '/cloud/' + $m = substr($m, 7); + } + $nick = substr($m, 0, strpos($m, '/')); + $p = substr($m, strpos($m, '/') + 1); + + // get the channel to check permissions + + $u = Channel::from_username($nick); + + if ($u && $p) { + $sql_extra = permissions_sql(intval($u['channel_id'])); + + $r = q( + "select hash, content from attach where display_path = '%s' and uid = %d and os_storage = 1 $sql_extra limit 1", + dbesc($p), + intval($u['channel_id']) + ); + if ($r) { + $path = dbunescbin($r[0]['content']); + if ($path && @file_exists($path . '.thumb')) { + return z_root() . '/poster/' . $nick . '/' . $r[0]['hash']; + } + } + } + return EMPTY_STR; + } + + + public static function parseurl_getsiteinfo($url) + { + $siteinfo = []; + + + $result = z_fetch_url($url, false, 0, array('novalidate' => true)); + if (!$result['success']) { + return $siteinfo; + } + + $header = $result['header']; + $body = $result['body']; + + // Check codepage in HTTP headers or HTML if not exist + $cp = (preg_match('/Content-Type: text\/html; charset=(.+)\r\n/i', $header, $o) ? $o[1] : ''); + if (empty($cp)) { + $cp = (preg_match('/meta.+content=["|\']text\/html; charset=([^"|\']+)/i', $body, $o) ? $o[1] : 'AUTO'); + } + + $body = mb_convert_encoding($body, 'UTF-8', $cp); + $body = mb_convert_encoding($body, 'HTML-ENTITIES', "UTF-8"); + + if (! $body) { + return $siteinfo; + } + + try { + $doc = new DOMDocument(); + $doc->loadHTML($body); + } catch (Exception $e) { + return $siteinfo; + } + + self::deletexnode($doc, 'style'); + self::deletexnode($doc, 'script'); + self::deletexnode($doc, 'option'); + self::deletexnode($doc, 'h1'); + self::deletexnode($doc, 'h2'); + self::deletexnode($doc, 'h3'); + self::deletexnode($doc, 'h4'); + self::deletexnode($doc, 'h5'); + self::deletexnode($doc, 'h6'); + self::deletexnode($doc, 'ol'); + self::deletexnode($doc, 'ul'); + + $xpath = new DomXPath($doc); + + $list = $xpath->query("//title"); + foreach ($list as $node) { + $siteinfo["title"] = html_entity_decode($node->nodeValue, ENT_QUOTES, "UTF-8"); + } + + $list = $xpath->query("//meta[@name]"); + foreach ($list as $node) { + $attr = []; + if ($node->attributes->length) { + foreach ($node->attributes as $attribute) { + $attr[$attribute->name] = $attribute->value; + } + } + + $attr["content"] = html_entity_decode($attr["content"], ENT_QUOTES, "UTF-8"); + + switch (strtolower($attr["name"])) { + case "fulltitle": + $siteinfo["title"] = trim($attr["content"]); + break; + case "description": + $siteinfo["text"] = trim($attr["content"]); + break; + case "thumbnail": + $siteinfo["image"] = $attr["content"]; + break; + case "twitter:image": + $siteinfo["image"] = $attr["content"]; + break; + case "twitter:image:src": + $siteinfo["image"] = $attr["content"]; + break; + case "twitter:card": + if (($siteinfo["type"] == "") || ($attr["content"] == "photo")) { + $siteinfo["type"] = $attr["content"]; + } + break; + case "twitter:description": + $siteinfo["text"] = trim($attr["content"]); + break; + case "twitter:title": + $siteinfo["title"] = trim($attr["content"]); + break; + case "dc.title": + $siteinfo["title"] = trim($attr["content"]); + break; + case "dc.description": + $siteinfo["text"] = trim($attr["content"]); + break; + case "keywords": + $keywords = explode(",", $attr["content"]); + break; + case "news_keywords": + $keywords = explode(",", $attr["content"]); + break; + } + } + + $list = $xpath->query("//meta[@property]"); + foreach ($list as $node) { + $attr = []; + if ($node->attributes->length) { + foreach ($node->attributes as $attribute) { + $attr[$attribute->name] = $attribute->value; + } + } + + $attr["content"] = html_entity_decode($attr["content"], ENT_QUOTES, "UTF-8"); + + switch (strtolower($attr["property"])) { + case "og:image": + $siteinfo["image"] = $attr["content"]; + break; + case "og:title": + $siteinfo["title"] = $attr["content"]; + break; + case "og:description": + $siteinfo["text"] = $attr["content"]; + break; + } + } + + if ($siteinfo["image"] == "") { + $list = $xpath->query("//img[@src]"); + foreach ($list as $node) { + $attr = []; + if ($node->attributes->length) { + foreach ($node->attributes as $attribute) { + $attr[$attribute->name] = $attribute->value; + } + } + + $src = self::completeurl($attr["src"], $url); + $photodata = @getimagesize($src); + + if (($photodata) && ($photodata[0] > 150) and ($photodata[1] > 150)) { + if ($photodata[0] > 300) { + $photodata[1] = round($photodata[1] * (300 / $photodata[0])); + $photodata[0] = 300; + } + if ($photodata[1] > 300) { + $photodata[0] = round($photodata[0] * (300 / $photodata[1])); + $photodata[1] = 300; + } + $siteinfo["images"][] = array("src" => $src, + "width" => $photodata[0], + "height" => $photodata[1]); + } + } + } else { + $src = self::completeurl($siteinfo["image"], $url); + + unset($siteinfo["image"]); + + $photodata = @getimagesize($src); + + if (($photodata) && ($photodata[0] > 10) and ($photodata[1] > 10)) { + $siteinfo["images"][] = array("src" => $src, + "width" => $photodata[0], + "height" => $photodata[1]); + } + } + + if ($siteinfo["text"] == "") { + $text = ""; + + $list = $xpath->query("//div[@class='article']"); + foreach ($list as $node) { + if (strlen($node->nodeValue) > 40) { + $text .= " " . trim($node->nodeValue); + } + } + + if ($text == "") { + $list = $xpath->query("//div[@class='content']"); + foreach ($list as $node) { + if (strlen($node->nodeValue) > 40) { + $text .= " " . trim($node->nodeValue); + } + } + } + + // If none text was found then take the paragraph content + if ($text == "") { + $list = $xpath->query("//p"); + foreach ($list as $node) { + if (strlen($node->nodeValue) > 40) { + $text .= " " . trim($node->nodeValue); + } + } + } + + if ($text != "") { + $text = trim(str_replace(array("\n", "\r"), array(" ", " "), $text)); + + while (strpos($text, " ")) { + $text = trim(str_replace(" ", " ", $text)); + } + + $siteinfo["text"] = html_entity_decode(substr($text, 0, 350), ENT_QUOTES, "UTF-8") . '...'; + } + } + + return ($siteinfo); + } + + + private static function arr_add_hashes(&$item, $k) + { + if (substr($item, 0, 1) !== '#') { + $item = '#' . $item; + } + } +} diff --git a/Code/Module/Lists.php b/Code/Module/Lists.php new file mode 100644 index 000000000..1829f57ca --- /dev/null +++ b/Code/Module/Lists.php @@ -0,0 +1,399 @@ + 100) { + $ret = Activity::paged_collection_init($total, App::$query_string); + } else { + $members = AccessList::members($group['uid'], $group['id'], false, App::$pager['start'], App::$pager['itemspage']); + $ret = Activity::encode_follow_collection($members, App::$query_string, 'OrderedCollection', $total); + } + + as_return_and_die($ret, $channel); + } + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + App::$profile_uid = local_channel(); + Navbar::set_selected('Access Lists'); + } + + public function post() + { + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + if ((argc() == 2) && (argv(1) === 'new')) { + check_form_security_token_redirectOnErr('/lists/new', 'group_edit'); + + $name = notags(trim($_POST['groupname'])); + $public = intval($_POST['public']); + $r = AccessList::add(local_channel(), $name, $public); + if ($r) { + info(t('Access list created.') . EOL); + } else { + notice(t('Could not create access list.') . EOL); + } + goaway(z_root() . '/lists'); + } + if ((argc() == 2) && (intval(argv(1)))) { + check_form_security_token_redirectOnErr('/lists', 'group_edit'); + + $r = q( + "SELECT * FROM pgrp WHERE id = %d AND uid = %d LIMIT 1", + intval(argv(1)), + intval(local_channel()) + ); + if (!$r) { + $r = q( + "select * from pgrp where id = %d limit 1", + intval(argv(1)) + ); + if ($r) { + notice(t('Permission denied.') . EOL); + } else { + notice(t('Access list not found.') . EOL); + } + goaway(z_root() . '/connections'); + } + $group = array_shift($r); + $groupname = notags(trim($_POST['groupname'])); + $public = intval($_POST['public']); + + if ((strlen($groupname)) && (($groupname != $group['gname']) || ($public != $group['visible']))) { + $r = q( + "UPDATE pgrp SET gname = '%s', visible = %d WHERE uid = %d AND id = %d", + dbesc($groupname), + intval($public), + intval(local_channel()), + intval($group['id']) + ); + if ($r) { + info(t('Access list updated.') . EOL); + } + Libsync::build_sync_packet(local_channel(), null, true); + } + + goaway(z_root() . '/lists/' . argv(1) . '/' . argv(2)); + } + return; + } + + public function get() + { + + $change = false; + + // logger('mod_lists: ' . App::$cmd, LOGGER_DEBUG); + + // Switch to text mode interface if we have more than 'n' contacts or group members, else loading avatars will lead to poor interactivity + + $switchtotext = get_pconfig(local_channel(), 'system', 'listedit_image_limit', get_config('system', 'listedit_image_limit', 1000)); + + if ((argc() == 1) || ((argc() == 2) && (argv(1) === 'new'))) { + if (!local_channel()) { + notice(t('Permission denied') . EOL); + return; + } + + $new = (((argc() == 2) && (argv(1) === 'new')) ? true : false); + + $groups = q( + "SELECT id, gname FROM pgrp WHERE deleted = 0 AND uid = %d ORDER BY gname ASC", + intval(local_channel()) + ); + + $i = 0; + foreach ($groups as $group) { + $entries[$i]['name'] = $group['gname']; + $entries[$i]['id'] = $group['id']; + $entries[$i]['count'] = count(AccessList::members(local_channel(), $group['id'])); + $i++; + } + + $tpl = Theme::get_template('privacy_groups.tpl'); + $o = replace_macros($tpl, [ + '$title' => t('Access Lists'), + '$add_new_label' => t('Create access list'), + '$new' => $new, + + // new group form + '$gname' => array('groupname', t('Access list name')), + '$public' => array('public', t('Members are visible to other channels'), false), + '$form_security_token' => get_form_security_token("group_edit"), + '$submit' => t('Submit'), + + // groups list + '$title' => t('Access Lists'), + '$name_label' => t('Name'), + '$count_label' => t('Members'), + '$entries' => $entries + ]); + + return $o; + } + + $context = array('$submit' => t('Submit')); + $tpl = Theme::get_template('group_edit.tpl'); + + if ((argc() == 3) && (argv(1) === 'drop')) { + if (!local_channel()) { + notice(t('Permission denied') . EOL); + return; + } + + + check_form_security_token_redirectOnErr('/lists', 'group_drop', 't'); + + if (intval(argv(2))) { + $r = q( + "SELECT gname FROM pgrp WHERE id = %d AND uid = %d LIMIT 1", + intval(argv(2)), + intval(local_channel()) + ); + if ($r) { + $result = AccessList::remove(local_channel(), $r[0]['gname']); + } + if ($result) { + info(t('Access list removed.') . EOL); + } else { + notice(t('Unable to remove access list.') . EOL); + } + } + goaway(z_root() . '/lists'); + // NOTREACHED + } + + + if ((argc() > 2) && intval(argv(1)) && argv(2)) { + if (!local_channel()) { + notice(t('Permission denied') . EOL); + return; + } + + check_form_security_token_ForbiddenOnErr('group_member_change', 't'); + + $r = q( + "SELECT abook_xchan from abook left join xchan on abook_xchan = xchan_hash where abook_xchan = '%s' and abook_channel = %d and xchan_deleted = 0 and abook_self = 0 and abook_blocked = 0 and abook_pending = 0 limit 1", + dbesc(base64url_decode(argv(2))), + intval(local_channel()) + ); + if (count($r)) { + $change = base64url_decode(argv(2)); + } + } + + if (argc() > 1) { + + if (strlen(argv(1)) <= 11 && intval(argv(1))) { + $r = q( + "SELECT * FROM pgrp WHERE id = %d AND deleted = 0 LIMIT 1", + intval(argv(1)) + ); + } else { + $r = q( + "SELECT * FROM pgrp WHERE hash = '%s' AND deleted = 0 LIMIT 1", + dbesc(argv(1)) + ); + } + + if (! $r) { + notice(t('Access list not found.') . EOL); + return; + } + + $group = array_shift($r); + $uid = $group['uid']; + $owner = (local_channel() && intval(local_channel()) === intval($group['uid'])); + + if (!$owner) { + // public view of group members if permitted + if (!($group['visible'] && perm_is_allowed($uid, get_observer_hash(), 'view_contacts'))) { + notice(t('Permission denied') . EOL); + return; + } + $members = []; + $memberlist = AccessList::members($uid, $group['id']); + + if ($memberlist) { + foreach ($memberlist as $member) { + $members[] = micropro($member, true, 'mpgroup', 'card'); + } + } + $o = replace_macros(Theme::get_template('listmembers.tpl'), [ + '$title' => t('List members'), + '$members' => $members + ]); + return $o; + } + + $members = AccessList::members(local_channel(), $group['id']); + + $preselected = []; + if (count($members)) { + foreach ($members as $member) { + if (!in_array($member['xchan_hash'], $preselected)) { + $preselected[] = $member['xchan_hash']; + } + } + } + + if ($change) { + if (in_array($change, $preselected)) { + AccessList::member_remove(local_channel(), $group['gname'], $change); + } else { + AccessList::member_add(local_channel(), $group['gname'], $change); + } + + $members = AccessList::members(local_channel(), $group['id']); + + $preselected = []; + if (count($members)) { + foreach ($members as $member) { + $preselected[] = $member['xchan_hash']; + } + } + } + + $context = $context + array( + '$title' => sprintf(t('Access List: %s'), $group['gname']), + '$details_label' => t('Edit'), + '$gname' => array('groupname', t('Access list name: '), $group['gname'], ''), + '$gid' => $group['id'], + '$drop' => $drop_txt, + '$public' => array('public', t('Members are visible to other channels'), $group['visible'], ''), + '$form_security_token_edit' => get_form_security_token('group_edit'), + '$delete' => t('Delete access list'), + '$form_security_token_drop' => get_form_security_token("group_drop"), + ); + } + + if (!isset($group)) { + return; + } + + $groupeditor = array( + 'label_members' => t('List members'), + 'members' => [], + 'label_contacts' => t('Not in this list'), + 'contacts' => [], + ); + + $sec_token = addslashes(get_form_security_token('group_member_change')); + $textmode = (($switchtotext && (count($members) > $switchtotext)) ? true : 'card'); + foreach ($members as $member) { + if ($member['xchan_url']) { + $member['archived'] = (intval($member['abook_archived']) ? true : false); + $member['click'] = 'groupChangeMember(' . $group['id'] . ',\'' . base64url_encode($member['xchan_hash']) . '\',\'' . $sec_token . '\'); return false;'; + $groupeditor['members'][] = micropro($member, true, 'mpgroup', $textmode); + } else { + AccessList::member_remove(local_channel(), $group['gname'], $member['xchan_hash']); + } + } + + $r = q( + "SELECT abook.*, xchan.* FROM abook left join xchan on abook_xchan = xchan_hash WHERE abook_channel = %d AND abook_self = 0 and abook_blocked = 0 and abook_pending = 0 and xchan_deleted = 0 order by xchan_name asc", + intval(local_channel()) + ); + + if (count($r)) { + $textmode = (($switchtotext && (count($r) > $switchtotext)) ? true : 'card'); + foreach ($r as $member) { + if (!in_array($member['xchan_hash'], $preselected)) { + $member['archived'] = (intval($member['abook_archived']) ? true : false); + $member['click'] = 'groupChangeMember(' . $group['id'] . ',\'' . base64url_encode($member['xchan_hash']) . '\',\'' . $sec_token . '\'); return false;'; + $groupeditor['contacts'][] = micropro($member, true, 'mpall', $textmode); + } + } + } + + $context['$groupeditor'] = $groupeditor; + $context['$desc'] = t('Select a channel to toggle membership'); + + if ($change) { + $tpl = Theme::get_template('groupeditor.tpl'); + echo replace_macros($tpl, $context); + killme(); + } + + return replace_macros($tpl, $context); + } +} diff --git a/Code/Module/Lockview.php b/Code/Module/Lockview.php new file mode 100644 index 000000000..b53d810e6 --- /dev/null +++ b/Code/Module/Lockview.php @@ -0,0 +1,189 @@ + 1) ? argv(1) : 0); + if (is_numeric($type)) { + $item_id = intval($type); + $type = 'item'; + } else { + $item_id = ((argc() > 2) ? intval(argv(2)) : 0); + } + + if (!$item_id) { + killme(); + } + + if (!in_array($type, array('item', 'photo', 'attach', 'event', 'menu_item', 'chatroom'))) { + killme(); + } + + // we have different naming in in menu_item table and chatroom table + switch ($type) { + case 'menu_item': + $id = 'mitem_id'; + break; + case 'chatroom': + $id = 'cr_id'; + break; + default: + $id = 'id'; + break; + } + + $r = q( + "SELECT * FROM %s WHERE $id = %d LIMIT 1", + dbesc($type), + intval($item_id) + ); + + if (!$r) { + killme(); + } + + $item = $r[0]; + + // we have different naming in in menu_item table and chatroom table + switch ($type) { + case 'menu_item': + $uid = $item['mitem_channel_id']; + break; + case 'chatroom': + $uid = $item['cr_uid']; + break; + default: + $uid = $item['uid']; + break; + } + + if ($type === 'item') { + $recips = get_iconfig($item['id'], 'activitypub', 'recips'); + if ($recips) { + $o = ''; + $l = []; + if (isset($recips['to'])) { + if (!is_array($recips['to'])) { + $recips['to'] = [$recips['to']]; + } + $l = array_merge($l, $recips['to']); + } + if (isset($recips['cc'])) { + if (!is_array($recips['cc'])) { + $recips['cc'] = [$recips['cc']]; + } + $l = array_merge($l, $recips['cc']); + } + for ($x = 0; $x < count($l); $x++) { + if ($l[$x] === ACTIVITY_PUBLIC_INBOX) { + $l[$x] = '' . t('Everybody') . ''; + } else { + $l[$x] = '' . $l[$x] . ''; + } + } + echo $o . implode('
      ', $l); + killme(); + } + } + + + if ( + intval($item['item_private']) && (!strlen($item['allow_cid'])) && (!strlen($item['allow_gid'])) + && (!strlen($item['deny_cid'])) && (!strlen($item['deny_gid'])) + ) { + if ($item['mid'] === $item['parent_mid']) { + echo ''; + killme(); + } + } + + $allowed_users = expand_acl($item['allow_cid']); + $allowed_groups = expand_acl($item['allow_gid']); + $deny_users = expand_acl($item['deny_cid']); + $deny_groups = expand_acl($item['deny_gid']); + + $o = ''; + $l = []; + + stringify_array_elms($allowed_groups, true); + stringify_array_elms($allowed_users, true); + stringify_array_elms($deny_groups, true); + stringify_array_elms($deny_users, true); + + + if (count($allowed_groups)) { + $r = q("SELECT gname FROM pgrp WHERE hash IN ( " . implode(', ', $allowed_groups) . " )"); + if ($r) { + foreach ($r as $rr) { + $l[] = ''; + } + } + } + if (count($allowed_users)) { + $r = q("SELECT xchan_name FROM xchan WHERE xchan_hash IN ( " . implode(', ', $allowed_users) . " )"); + if ($r) { + foreach ($r as $rr) { + $l[] = ''; + } + } + if ($atokens) { + foreach ($atokens as $at) { + if (in_array("'" . $at['xchan_hash'] . "'", $allowed_users)) { + $l[] = ''; + } + } + } + } + + if (count($deny_groups)) { + $r = q("SELECT gname FROM pgrp WHERE hash IN ( " . implode(', ', $deny_groups) . " )"); + if ($r) { + foreach ($r as $rr) { + $l[] = ''; + } + } + } + if (count($deny_users)) { + $r = q("SELECT xchan_name FROM xchan WHERE xchan_hash IN ( " . implode(', ', $deny_users) . " )"); + if ($r) { + foreach ($r as $rr) { + $l[] = ''; + } + } + + if ($atokens) { + foreach ($atokens as $at) { + if (in_array("'" . $at['xchan_hash'] . "'", $deny_users)) { + $l[] = ''; + } + } + } + } + + echo $o . implode($l); + killme(); + } +} diff --git a/Code/Module/Locs.php b/Code/Module/Locs.php new file mode 100644 index 000000000..af4a2a31d --- /dev/null +++ b/Code/Module/Locs.php @@ -0,0 +1,149 @@ + t('Manage Channel Locations'), + '$loc' => t('Location'), + '$addr' => t('Address'), + '$mkprm' => t('Primary'), + '$drop' => t('Drop'), + '$submit' => t('Submit'), + '$sync' => t('Publish these settings'), + '$sync_text' => t('Please wait several minutes between consecutive operations.'), + '$drop_text' => t('When possible, drop a location by logging into that website/hub and removing your channel.'), + '$last_resort' => t('Use this form to drop the location if the hub is no longer operating.'), + '$hubs' => $r, + '$base_url' => z_root() + ]); + + return $o; + } +} diff --git a/Code/Module/Login.php b/Code/Module/Login.php new file mode 100644 index 000000000..e130d277a --- /dev/null +++ b/Code/Module/Login.php @@ -0,0 +1,21 @@ +nuke(); + } + goaway(z_root()); + } +} diff --git a/Code/Module/Lostpass.php b/Code/Module/Lostpass.php new file mode 100644 index 000000000..4443c0fdd --- /dev/null +++ b/Code/Module/Lostpass.php @@ -0,0 +1,149 @@ + get_config('system', 'sitename'), + '$siteurl' => z_root(), + '$username' => sprintf(t('Site Member (%s)'), $email), + '$email' => $email, + '$reset_link' => z_root() . '/lostpass?verify=' . $hash + )); + + $subject = email_header_encode(sprintf(t('Password reset requested at %s'), get_config('system', 'sitename')), 'UTF-8'); + + $res = z_mail( + [ + 'toEmail' => $email, + 'messageSubject' => sprintf(t('Password reset requested at %s'), get_config('system', 'sitename')), + 'textVersion' => $message, + ] + ); + + goaway(z_root()); + } + + + public function get() + { + + + if (x($_GET, 'verify')) { + $verify = $_GET['verify']; + + $r = q( + "SELECT * FROM account WHERE account_reset = '%s' LIMIT 1", + dbesc($verify) + ); + if (!$r) { + notice(t("Request could not be verified. (You may have previously submitted it.) Password reset failed.") . EOL); + goaway(z_root()); + return; + } + + $aid = $r[0]['account_id']; + $email = $r[0]['account_email']; + + $new_password = autoname(6) . mt_rand(100, 9999); + + $salt = random_string(32); + $password_encoded = hash('whirlpool', $salt . $new_password); + + $r = q( + "UPDATE account SET account_salt = '%s', account_password = '%s', account_reset = '', account_flags = (account_flags & ~%d) where account_id = %d", + dbesc($salt), + dbesc($password_encoded), + intval(ACCOUNT_UNVERIFIED), + intval($aid) + ); + + if ($r) { + $tpl = Theme::get_template('pwdreset.tpl'); + $o .= replace_macros($tpl, array( + '$lbl1' => t('Password Reset'), + '$lbl2' => t('Your password has been reset as requested.'), + '$lbl3' => t('Your new password is'), + '$lbl4' => t('Save or copy your new password - and then'), + '$lbl5' => '' . t('click here to login') . '.', + '$lbl6' => t('Your password may be changed from the Settings page after successful login.'), + '$newpass' => $new_password, + '$baseurl' => z_root() + + )); + + info("Your password has been reset." . EOL); + + $email_tpl = Theme::get_email_template("passchanged_eml.tpl"); + $message = replace_macros($email_tpl, array( + '$sitename' => App::$config['sitename'], + '$siteurl' => z_root(), + '$username' => sprintf(t('Site Member (%s)'), $email), + '$email' => $email, + '$new_password' => $new_password, + '$uid' => $newuid)); + + $res = z_mail( + [ + 'toEmail' => $email, + 'messageSubject' => sprintf(t('Your password has changed at %s'), get_config('system', 'sitename')), + 'textVersion' => $message, + ] + ); + + return $o; + } + } else { + $tpl = Theme::get_template('lostpass.tpl'); + + $o .= replace_macros($tpl, array( + '$title' => t('Forgot your Password?'), + '$desc' => t('Enter your email address and submit to have your password reset. Then check your email for further instructions.'), + '$name' => t('Email Address'), + '$submit' => t('Reset') + )); + + return $o; + } + } +} diff --git a/Code/Module/Magic.php b/Code/Module/Magic.php new file mode 100644 index 000000000..fa538a35c --- /dev/null +++ b/Code/Module/Magic.php @@ -0,0 +1,134 @@ + false, + 'url' => '', + 'message' => '' + ]; + + logger('mod_magic: invoked', LOGGER_DEBUG); + + logger('args: ' . print_r($_REQUEST, true), LOGGER_DATA); + + $addr = ((x($_REQUEST, 'addr')) ? $_REQUEST['addr'] : ''); + $bdest = ((x($_REQUEST, 'bdest')) ? $_REQUEST['bdest'] : ''); + $dest = ((x($_REQUEST, 'dest')) ? $_REQUEST['dest'] : ''); + $rev = ((x($_REQUEST, 'rev')) ? intval($_REQUEST['rev']) : 0); + $owa = ((x($_REQUEST, 'owa')) ? intval($_REQUEST['owa']) : 0); + $delegate = ((x($_REQUEST, 'delegate')) ? $_REQUEST['delegate'] : ''); + + // bdest is preferred as it is hex-encoded and can survive url rewrite and argument parsing + + if ($bdest) { + $dest = hex2bin($bdest); + } + + $parsed = parse_url($dest); + + if (!$parsed) { + goaway($dest); + } + + $basepath = $parsed['scheme'] . '://' . $parsed['host'] . (($parsed['port']) ? ':' . $parsed['port'] : ''); + $owapath = SConfig::get($basepath, 'system', 'openwebauth', $basepath . '/owa'); + + // This is ready-made for a plugin that provides a blacklist or "ask me" before blindly authenticating. + // By default, we'll proceed without asking. + + $arr = [ + 'channel_id' => local_channel(), + 'destination' => $dest, + 'proceed' => true + ]; + + Hook::call('magic_auth', $arr); + $dest = $arr['destination']; + if (!$arr['proceed']) { + goaway($dest); + } + + if ((get_observer_hash()) && (stripos($dest, z_root()) === 0)) { + // We are already authenticated on this site and a registered observer. + // First check if this is a delegate request on the local system and process accordingly. + // Otherwise redirect. + + if ($delegate) { + $r = q( + "select * from channel left join hubloc on channel_hash = hubloc_hash where hubloc_addr = '%s' limit 1", + dbesc($delegate) + ); + + if ($r) { + $c = array_shift($r); + if (perm_is_allowed($c['channel_id'], get_observer_hash(), 'delegate')) { + $tmp = $_SESSION; + $_SESSION['delegate_push'] = $tmp; + $_SESSION['delegate_channel'] = $c['channel_id']; + $_SESSION['delegate'] = get_observer_hash(); + $_SESSION['account_id'] = intval($c['channel_account_id']); + + change_channel($c['channel_id']); + } + } + } + + goaway($dest); + } + + if (local_channel()) { + $channel = App::get_channel(); + + // OpenWebAuth + + if ($owa) { + $dest = strip_zids($dest); + $dest = strip_query_param($dest, 'f'); + + // We now post to the OWA endpoint. This improves security by providing a signed digest + + $data = json_encode(['OpenWebAuth' => random_string()]); + + $headers = []; + $headers['Accept'] = 'application/x-nomad+json, application/x-zot+json'; + $headers['Content-Type'] = 'application/x-nomad+json'; + $headers['X-Open-Web-Auth'] = random_string(); + $headers['Digest'] = HTTPSig::generate_digest_header($data); + $headers['Host'] = $parsed['host']; + $headers['(request-target)'] = 'post ' . '/owa'; + + $headers = HTTPSig::create_sig($headers, $channel['channel_prvkey'], Channel::url($channel), true, 'sha512'); + $x = z_post_url($owapath, $data, $redirects, ['headers' => $headers]); + logger('owa fetch returned: ' . print_r($x, true), LOGGER_DATA); + if ($x['success']) { + $j = json_decode($x['body'], true); + if ($j['success'] && $j['encrypted_token']) { + // decrypt the token using our private key + $token = ''; + openssl_private_decrypt(base64url_decode($j['encrypted_token']), $token, $channel['channel_prvkey']); + $x = strpbrk($dest, '?&'); + // redirect using the encrypted token which will be exchanged for an authenticated session + $args = (($x) ? '&owt=' . $token : '?f=&owt=' . $token) . (($delegate) ? '&delegate=1' : ''); + goaway($dest . $args); + } + } + } + } + goaway($dest); + } +} diff --git a/Code/Module/Manage.php b/Code/Module/Manage.php new file mode 100644 index 000000000..db03fdeb6 --- /dev/null +++ b/Code/Module/Manage.php @@ -0,0 +1,204 @@ + 1) ? intval(argv(1)) : 0); + + if (argc() > 2) { + if (argv(2) === 'default') { + $r = q( + "select channel_id from channel where channel_id = %d and channel_account_id = %d limit 1", + intval($change_channel), + intval(get_account_id()) + ); + if ($r) { + q( + "update account set account_default_channel = %d where account_id = %d", + intval($change_channel), + intval(get_account_id()) + ); + } + goaway(z_root() . '/manage'); + } elseif (argv(2) === 'menu') { + $state = intval(PConfig::get($change_channel, 'system', 'include_in_menu', 0)); + PConfig::set($change_channel, 'system', 'include_in_menu', 1 - $state); + goaway(z_root() . '/manage'); + } + } + + + if ($change_channel) { + $r = change_channel($change_channel); + + if ((argc() > 2) && !(argv(2) === 'default')) { + goaway(z_root() . '/' . implode('/', array_slice(App::$argv, 2))); // Go to whatever is after /manage/, but with the new channel + } elseif ($r && $r['channel_startpage']) { + goaway(z_root() . '/' . $r['channel_startpage']); // If nothing extra is specified, go to the default page + } + goaway(z_root()); + } + + $channels = null; + + $r = q( + "select channel.*, xchan.* from channel left join xchan on channel.channel_hash = xchan.xchan_hash where channel.channel_account_id = %d and channel_removed = 0 order by channel_name ", + intval(get_account_id()) + ); + + $account = App::get_account(); + + if ($r && count($r)) { + $channels = ((is_site_admin()) ? array_merge([Channel::get_system()], $r) : $r); + for ($x = 0; $x < count($channels); $x++) { + $channels[$x]['link'] = 'manage/' . intval($channels[$x]['channel_id']); + $channels[$x]['include_in_menu'] = intval(PConfig::get($channels[$x]['channel_id'], 'system', 'include_in_menu', 0)); + $channels[$x]['default'] = (($channels[$x]['channel_id'] == $account['account_default_channel']) ? "1" : ''); + $channels[$x]['default_links'] = '1'; + $channels[$x]['collections_label'] = t('Collection'); + $channels[$x]['forum_label'] = t('Group'); + + $c = q( + "SELECT id, item_wall FROM item + WHERE item_unseen = 1 and uid = %d " . item_normal(), + intval($channels[$x]['channel_id']) + ); + + if ($c) { + foreach ($c as $it) { + if (intval($it['item_wall'])) { + $channels[$x]['home']++; + } else { + $channels[$x]['network']++; + } + } + } + + + $intr = q( + "SELECT COUNT(abook.abook_id) AS total FROM abook left join xchan on abook.abook_xchan = xchan.xchan_hash where abook_channel = %d and abook_pending = 1 and abook_self = 0 and abook_ignored = 0 and xchan_deleted = 0 and xchan_orphan = 0 ", + intval($channels[$x]['channel_id']) + ); + + if ($intr) { + $channels[$x]['intros'] = intval($intr[0]['total']); + } + + $events = q( + "SELECT etype, dtstart, adjust FROM event + WHERE event.uid = %d AND dtstart < '%s' AND dtstart > '%s' and dismissed = 0 + ORDER BY dtstart ASC ", + intval($channels[$x]['channel_id']), + dbesc(datetime_convert('UTC', date_default_timezone_get(), 'now + 7 days')), + dbesc(datetime_convert('UTC', date_default_timezone_get(), 'now - 1 days')) + ); + + if ($events) { + $channels[$x]['all_events'] = count($events); + + if ($channels[$x]['all_events']) { + $str_now = datetime_convert('UTC', date_default_timezone_get(), 'now', 'Y-m-d'); + foreach ($events as $e) { + $bd = false; + if ($e['etype'] === 'birthday') { + $channels[$x]['birthdays']++; + $bd = true; + } else { + $channels[$x]['events']++; + } + if (datetime_convert('UTC', ((intval($e['adjust'])) ? date_default_timezone_get() : 'UTC'), $e['dtstart'], 'Y-m-d') === $str_now) { + $channels[$x]['all_events_today']++; + if ($bd) { + $channels[$x]['birthdays_today']++; + } else { + $channels[$x]['events_today']++; + } + } + } + } + } + } + } + + $r = q( + "select count(channel_id) as total from channel where channel_account_id = %d and channel_removed = 0", + intval(get_account_id()) + ); + $limit = ServiceClass::account_fetch(get_account_id(), 'total_identities'); + if ($limit !== false) { + $channel_usage_message = sprintf(t("You have created %1$.0f of %2$.0f allowed channels."), $r[0]['total'], $limit); + } else { + $channel_usage_message = ''; + } + + + $create = ['new_channel', t('Create a new channel'), t('Create New')]; + + $delegates = null; + + if (local_channel()) { + $delegates = q( + "select * from abook left join xchan on abook_xchan = xchan_hash where + abook_channel = %d and abook_xchan in ( select xchan from abconfig where chan = %d and cat = 'system' and k = 'their_perms' and v like '%s' )", + intval(local_channel()), + intval(local_channel()), + dbesc('%delegate%') + ); + } + + if ($delegates) { + for ($x = 0; $x < count($delegates); $x++) { + $delegates[$x]['link'] = 'magic?f=&bdest=' . bin2hex($delegates[$x]['xchan_url']) + . '&delegate=' . urlencode($delegates[$x]['xchan_addr']); + $delegates[$x]['channel_name'] = $delegates[$x]['xchan_name']; + $delegates[$x]['delegate'] = 1; + $delegates[$x]['collections_label'] = t('Collection'); + $delegates[$x]['forum_label'] = t('Group'); + } + } else { + $delegates = null; + } + + return replace_macros(Theme::get_template('channels.tpl'), [ + '$header' => t('Channels'), + '$msg_selected' => t('Current Channel'), + '$selected' => local_channel(), + '$desc' => t('Switch to one of your channels by selecting it.'), + '$msg_default' => t('Default Login Channel'), + '$msg_make_default' => t('Make Default'), + '$msg_include' => t('Add to menu'), + '$msg_no_include' => t('Add to menu'), + '$create' => $create, + '$all_channels' => $channels, + '$mail_format' => t('%d new messages'), + '$intros_format' => t('%d new introductions'), + '$channel_usage_message' => $channel_usage_message, + '$delegated_desc' => t('Delegated Channel'), + '$delegates' => $delegates + ]); + } +} diff --git a/Code/Module/Manifest.php b/Code/Module/Manifest.php new file mode 100644 index 000000000..6ebd66cd1 --- /dev/null +++ b/Code/Module/Manifest.php @@ -0,0 +1,50 @@ + System::get_platform_name(), + 'short_name' => System::get_platform_name(), + 'icons' => [ + ['src' => '/images/' . System::get_platform_name() . '-64' . '.png', 'sizes' => '64x64'], + ['src' => '/images/' . System::get_platform_name() . '-192' . '.png', 'sizes' => '192x192'], + ['src' => '/images/' . System::get_platform_name() . '-512' . '.png', 'sizes' => '512x512'], + ['src' => '/images/' . System::get_platform_name() . '.svg', 'sizes' => '600x600'], + ], + 'scope' => '/', + 'start_url' => z_root(), + 'display' => 'fullscreen', + 'orientation' => 'any', + 'theme_color' => 'blue', + 'background_color' => 'white', + 'share_target' => [ + 'action' => '/rpost', + 'method' => 'POST', + 'enctype' => 'multipart/form-data', + 'params' => [ + 'title' => 'title', + 'text' => 'body', + 'url' => 'url', + 'files' => [ + ['name' => 'userfile', + 'accept' => ['image/*', 'audio/*', 'video/*', 'text/*', 'application/*'] + ] + ] + ] + ] + + ]; + + + json_return_and_die($ret, 'application/manifest+json'); + } +} diff --git a/Code/Module/Markup.php b/Code/Module/Markup.php new file mode 100644 index 000000000..d83ab40a3 --- /dev/null +++ b/Code/Module/Markup.php @@ -0,0 +1,22 @@ +' . $desc . ''; + + return $text; + } +} diff --git a/Code/Module/Menu.php b/Code/Module/Menu.php new file mode 100644 index 000000000..0b9cd8d7d --- /dev/null +++ b/Code/Module/Menu.php @@ -0,0 +1,242 @@ + 1 && argv(1) === 'sys' && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + App::$is_sys = true; + } + } + + if (argc() > 1) { + $which = argv(1); + } else { + return; + } + + Libprofile::load($which); + } + + + public function post() + { + + if (!App::$profile) { + return; + } + + $which = argv(1); + + + $uid = App::$profile['channel_id']; + + if (array_key_exists('sys', $_REQUEST) && $_REQUEST['sys'] && is_site_admin()) { + $sys = Channel::get_system(); + $uid = intval($sys['channel_id']); + App::$is_sys = true; + } + + if (!$uid) { + return; + } + + $_REQUEST['menu_channel_id'] = $uid; + + if ($_REQUEST['menu_bookmark']) { + $_REQUEST['menu_flags'] |= MENU_BOOKMARK; + } + if ($_REQUEST['menu_system']) { + $_REQUEST['menu_flags'] |= MENU_SYSTEM; + } + + $menu_id = ((argc() > 1) ? intval(argv(1)) : 0); + if ($menu_id) { + $_REQUEST['menu_id'] = intval(argv(1)); + $r = Zlib\Menu::edit($_REQUEST); + if ($r) { + Zlib\Menu::sync_packet($uid, get_observer_hash(), $menu_id); + //info( t('Menu updated.') . EOL); + goaway(z_root() . '/mitem/' . $which . '/' . $menu_id . ((App::$is_sys) ? '?f=&sys=1' : '')); + } else { + notice(t('Unable to update menu.') . EOL); + } + } else { + $r = Zlib\Menu::create($_REQUEST); + if ($r) { + Zlib\Menu::sync_packet($uid, get_observer_hash(), $r); + + //info( t('Menu created.') . EOL); + goaway(z_root() . '/mitem/' . $which . '/' . $r . ((App::$is_sys) ? '?f=&sys=1' : '')); + } else { + notice(t('Unable to create menu.') . EOL); + } + } + } + + + public function get() + { + + + if (!App::$profile) { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + + $which = argv(1); + + $_SESSION['return_url'] = App::$query_string; + + $uid = local_channel(); + $owner = 0; + $channel = null; + $observer = App::get_observer(); + + $channel = App::get_channel(); + + if (App::$is_sys && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + $uid = $owner = intval($sys['channel_id']); + $channel = $sys; + $observer = $sys; + } + } + + if (!$owner) { + // Figure out who the page owner is. + $r = Channel::from_username($which); + if ($r) { + $owner = intval($r['channel_id']); + } + } + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $perms = get_all_perms($owner, $ob_hash); + + if (!$perms['write_pages']) { + notice(t('Permission denied.') . EOL); + return; + } + + // Get the observer, check their permissions + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $perms = get_all_perms($owner, $ob_hash); + + if (!$perms['write_pages']) { + notice(t('Permission denied.') . EOL); + return; + } + + if (argc() == 2) { + $channel = (($sys) ? $sys : Channel::from_id($owner)); + + // list menus + $x = Zlib\Menu::list($owner); + if ($x) { + for ($y = 0; $y < count($x); $y++) { + $m = menu_fetch($x[$y]['menu_name'], $owner, get_observer_hash()); + if ($m) { + $x[$y]['element'] = '[element]' . base64url_encode(json_encode(menu_element($channel, $m))) . '[/element]'; + } + $x[$y]['bookmark'] = (($x[$y]['menu_flags'] & MENU_BOOKMARK) ? true : false); + } + } + + $create = replace_macros(Theme::get_template('menuedit.tpl'), array( + '$menu_name' => array('menu_name', t('Menu Name'), '', t('Unique name (not visible on webpage) - required'), '*'), + '$menu_desc' => array('menu_desc', t('Menu Title'), '', t('Visible on webpage - leave empty for no title'), ''), + '$menu_bookmark' => array('menu_bookmark', t('Allow Bookmarks'), 0, t('Menu may be used to store saved bookmarks'), array(t('No'), t('Yes'))), + '$submit' => t('Submit and proceed'), + '$sys' => App::$is_sys, + '$nick' => $which, + '$display' => 'none' + )); + + $o = replace_macros(Theme::get_template('menulist.tpl'), array( + '$title' => t('Menus'), + '$create' => $create, + '$menus' => $x, + '$nametitle' => t('Menu Name'), + '$desctitle' => t('Menu Title'), + '$edit' => t('Edit'), + '$drop' => t('Drop'), + '$created' => t('Created'), + '$edited' => t('Edited'), + '$new' => t('New'), + '$bmark' => t('Bookmarks allowed'), + '$hintnew' => t('Create'), + '$hintdrop' => t('Delete this menu'), + '$hintcontent' => t('Edit menu contents'), + '$hintedit' => t('Edit this menu'), + '$nick' => $which, + '$sys' => App::$is_sys + )); + + return $o; + } + + if (argc() > 2) { + if (intval(argv(2))) { + if (argc() == 4 && argv(3) == 'drop') { + Zlib\Menu::sync_packet($owner, get_observer_hash(), intval(argv(1)), true); + $r = Zlib\Menu::delete_id(intval(argv(2)), $owner); + if (!$r) { + notice(t('Menu could not be deleted.') . EOL); + } + + goaway(z_root() . '/menu/' . $which . ((App::$is_sys) ? '?f=&sys=1' : '')); + } + + $m = Zlib\Menu::fetch_id(intval(argv(2)), $owner); + + if (!$m) { + notice(t('Menu not found.') . EOL); + return ''; + } + + $o = replace_macros(Theme::get_template('menuedit.tpl'), array( + '$header' => t('Edit Menu'), + '$sys' => App::$is_sys, + '$menu_id' => intval(argv(2)), + '$menu_edit_link' => 'mitem/' . $which . '/' . intval(argv(1)) . ((App::$is_sys) ? '?f=&sys=1' : ''), + '$hintedit' => t('Add or remove entries to this menu'), + '$editcontents' => t('Edit menu contents'), + '$menu_name' => array('menu_name', t('Menu name'), $m['menu_name'], t('Must be unique, only seen by you'), '*'), + '$menu_desc' => array('menu_desc', t('Menu title'), $m['menu_desc'], t('Menu title as seen by others'), ''), + '$menu_bookmark' => array('menu_bookmark', t('Allow bookmarks'), (($m['menu_flags'] & MENU_BOOKMARK) ? 1 : 0), t('Menu may be used to store saved bookmarks'), array(t('No'), t('Yes'))), + '$menu_system' => (($m['menu_flags'] & MENU_SYSTEM) ? 1 : 0), + '$nick' => $which, + '$submit' => t('Submit and proceed') + )); + + return $o; + } else { + notice(t('Not found.') . EOL); + return; + } + } + } +} diff --git a/Code/Module/Mitem.php b/Code/Module/Mitem.php new file mode 100644 index 000000000..719e9eeab --- /dev/null +++ b/Code/Module/Mitem.php @@ -0,0 +1,284 @@ + 1 && argv(1) === 'sys' && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + App::$is_sys = true; + } + } + + if (argc() > 1) { + $which = argv(1); + } else { + return; + } + + Libprofile::load($which); + + if (argc() < 3) { + return; + } + + $m = Menu::fetch_id(intval(argv(2)), App::$profile['channel_id']); + if (!$m) { + notice(t('Menu not found.') . EOL); + return ''; + } + App::$data['menu'] = $m; + } + + public function post() + { + + if (!App::$profile) { + return; + } + + $which = argv(1); + + + $uid = App::$profile['channel_id']; + + if (array_key_exists('sys', $_REQUEST) && $_REQUEST['sys'] && is_site_admin()) { + $sys = Channel::get_system(); + $uid = intval($sys['channel_id']); + App::$is_sys = true; + } + + if (!$uid) { + return; + } + + + if (!App::$data['menu']) { + return; + } + + if (!$_REQUEST['mitem_desc'] || !$_REQUEST['mitem_link']) { + notice(t('Unable to create element.') . EOL); + return; + } + + $_REQUEST['mitem_channel_id'] = $uid; + $_REQUEST['menu_id'] = App::$data['menu']['menu_id']; + + $_REQUEST['mitem_flags'] = 0; + if ($_REQUEST['usezid']) { + $_REQUEST['mitem_flags'] |= MENU_ITEM_ZID; + } + if ($_REQUEST['newwin']) { + $_REQUEST['mitem_flags'] |= MENU_ITEM_NEWWIN; + } + + + $mitem_id = ((argc() > 3) ? intval(argv(3)) : 0); + if ($mitem_id) { + $_REQUEST['mitem_id'] = $mitem_id; + $r = MenuItem::edit($_REQUEST['menu_id'], $uid, $_REQUEST); + if ($r) { + Menu::sync_packet($uid, get_observer_hash(), $_REQUEST['menu_id']); + //info( t('Menu element updated.') . EOL); + goaway(z_root() . '/mitem/' . $which . '/' . $_REQUEST['menu_id'] . ((App::$is_sys) ? '?f=&sys=1' : '')); + } else { + notice(t('Unable to update menu element.') . EOL); + } + } else { + $r = MenuItem::add($_REQUEST['menu_id'], $uid, $_REQUEST); + if ($r) { + Menu::sync_packet($uid, get_observer_hash(), $_REQUEST['menu_id']); + //info( t('Menu element added.') . EOL); + if ($_REQUEST['submit']) { + goaway(z_root() . '/menu/' . $which . ((App::$is_sys) ? '?f=&sys=1' : '')); + } + if ($_REQUEST['submit-more']) { + goaway(z_root() . '/mitem/' . $which . '/' . $_REQUEST['menu_id'] . '?f=&display=block' . ((App::$is_sys) ? '&sys=1' : '')); + } + } else { + notice(t('Unable to add menu element.') . EOL); + } + } + } + + + public function get() + { + + $uid = local_channel(); + $owner = App::$profile['channel_id']; + $channel = Channel::from_id($owner); + $observer = App::get_observer(); + + $which = argv(1); + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + if (App::$is_sys && is_site_admin()) { + $sys = Channel::get_system(); + $uid = intval($sys['channel_id']); + $channel = $sys; + $ob_hash = $sys['xchan_hash']; + } + + if (!$uid) { + notice(t('Permission denied.') . EOL); + return ''; + } + + if (argc() < 3 || (!App::$data['menu'])) { + notice(t('Not found.') . EOL); + return ''; + } + + $m = Menu::fetch(App::$data['menu']['menu_name'], $owner, $ob_hash); + App::$data['menu_item'] = $m; + + $menu_list = Menu::list($owner); + + foreach ($menu_list as $menus) { + if ($menus['menu_name'] != $m['menu']['menu_name']) { + $menu_names[] = $menus['menu_name']; + } + } + + $acl = new AccessControl($channel); + + $lockstate = (($channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 'lock' : 'unlock'); + + if (argc() == 3) { + $r = q( + "select * from menu_item where mitem_menu_id = %d and mitem_channel_id = %d order by mitem_order asc, mitem_desc asc", + intval(App::$data['menu']['menu_id']), + intval($owner) + ); + + if ($_GET['display']) { + $display = $_GET['display']; + } else { + $display = (($r) ? 'none' : 'block'); + } + + $create = replace_macros(Theme::get_template('mitemedit.tpl'), array( + '$menu_id' => App::$data['menu']['menu_id'], + '$permissions' => t('Menu Item Permissions'), + '$permdesc' => t("\x28click to open/close\x29"), + '$aclselect' => Libacl::populate($acl->get(), false), + '$allow_cid' => acl2json($acl->get()['allow_cid']), + '$allow_gid' => acl2json($acl->get()['allow_gid']), + '$deny_cid' => acl2json($acl->get()['deny_cid']), + '$deny_gid' => acl2json($acl->get()['deny_gid']), + '$mitem_desc' => array('mitem_desc', t('Link Name'), '', 'Visible name of the link', '*'), + '$mitem_link' => array('mitem_link', t('Link or Submenu Target'), '', t('Enter URL of the link or select a menu name to create a submenu'), '*', 'list="menu-names"'), + '$usezid' => array('usezid', t('Use magic-auth if available'), true, '', array(t('No'), t('Yes'))), + '$newwin' => array('newwin', t('Open link in new window'), false, '', array(t('No'), t('Yes'))), + '$mitem_order' => array('mitem_order', t('Order in list'), '0', t('Higher numbers will sink to bottom of listing')), + '$submit' => t('Submit and finish'), + '$submit_more' => t('Submit and continue'), + '$display' => $display, + '$lockstate' => $lockstate, + '$menu_names' => $menu_names, + '$nick' => $which, + '$sys' => App::$is_sys + )); + + $o .= replace_macros(Theme::get_template('mitemlist.tpl'), array( + '$title' => t('Menu:'), + '$create' => $create, + '$nametitle' => t('Link Name'), + '$targettitle' => t('Link Target'), + '$menuname' => App::$data['menu']['menu_name'], + '$menudesc' => App::$data['menu']['menu_desc'], + '$edmenu' => t('Edit menu'), + '$menu_id' => App::$data['menu']['menu_id'], + '$mlist' => $r, + '$edit' => t('Edit element'), + '$drop' => t('Drop element'), + '$new' => t('New element'), + '$hintmenu' => t('Edit this menu container'), + '$hintnew' => t('Add menu element'), + '$hintdrop' => t('Delete this menu item'), + '$hintedit' => t('Edit this menu item'), + '$nick' => $which, + )); + + return $o; + } + + + if (argc() > 3) { + if (intval(argv(3))) { + $m = q( + "select * from menu_item where mitem_id = %d and mitem_channel_id = %d limit 1", + intval(argv(3)), + intval($owner) + ); + + if (!$m) { + notice(t('Menu item not found.') . EOL); + goaway(z_root() . '/menu/' . $which . ((App::$is_sys) ? '?f=&sys=1' : '')); + } + + $mitem = $m[0]; + + $lockstate = (($mitem['allow_cid'] || $mitem['allow_gid'] || $mitem['deny_cid'] || $mitem['deny_gid']) ? 'lock' : 'unlock'); + + if (argc() == 5 && argv(4) == 'drop') { + Menu::sync_packet($owner, get_observer_hash(), $mitem['mitem_menu_id']); + $r = MenuItem::delete($mitem['mitem_menu_id'], $owner, intval(argv(3))); + Menu::sync_packet($owner, get_observer_hash(), $mitem['mitem_menu_id']); + if ($r) { + info(t('Menu item deleted.') . EOL); + } else { + notice(t('Menu item could not be deleted.') . EOL); + } + + goaway(z_root() . '/mitem/' . $which . '/' . $mitem['mitem_menu_id'] . ((App::$is_sys) ? '?f=&sys=1' : '')); + } + + // edit menu item + $o = replace_macros(Theme::get_template('mitemedit.tpl'), array( + '$header' => t('Edit Menu Element'), + '$menu_id' => App::$data['menu']['menu_id'], + '$permissions' => t('Menu Item Permissions'), + '$permdesc' => t("\x28click to open/close\x29"), + '$aclselect' => Libacl::populate($mitem, false), + '$allow_cid' => acl2json($mitem['allow_cid']), + '$allow_gid' => acl2json($mitem['allow_gid']), + '$deny_cid' => acl2json($mitem['deny_cid']), + '$deny_gid' => acl2json($mitem['deny_gid']), + '$mitem_id' => intval(argv(3)), + '$mitem_desc' => array('mitem_desc', t('Link text'), $mitem['mitem_desc'], '', '*'), + '$mitem_link' => array('mitem_link', t('Link or Submenu Target'), $mitem['mitem_link'], 'Enter URL of the link or select a menu name to create a submenu', '*', 'list="menu-names"'), + '$usezid' => array('usezid', t('Use magic-auth if available'), (($mitem['mitem_flags'] & MENU_ITEM_ZID) ? 1 : 0), '', array(t('No'), t('Yes'))), + '$newwin' => array('newwin', t('Open link in new window'), (($mitem['mitem_flags'] & MENU_ITEM_NEWWIN) ? 1 : 0), '', array(t('No'), t('Yes'))), + '$mitem_order' => array('mitem_order', t('Order in list'), $mitem['mitem_order'], t('Higher numbers will sink to bottom of listing')), + '$submit' => t('Submit'), + '$lockstate' => $lockstate, + '$menu_names' => $menu_names, + '$nick' => $which + )); + + return $o; + } + } + } +} diff --git a/Code/Module/Moderate.php b/Code/Module/Moderate.php new file mode 100644 index 000000000..7795b6f7e --- /dev/null +++ b/Code/Module/Moderate.php @@ -0,0 +1,128 @@ + 2) { + $post_id = intval(argv(1)); + if (!$post_id) { + goaway(z_root() . '/moderate'); + } + + $action = argv(2); + + $r = q( + "select * from item where uid = %d and id = %d and item_blocked = %d limit 1", + intval(local_channel()), + intval($post_id), + intval(ITEM_MODERATED) + ); + + if ($r) { + $item = $r[0]; + + if ($action === 'approve') { + q( + "update item set item_blocked = 0 where uid = %d and id = %d", + intval(local_channel()), + intval($post_id) + ); + + $item['item_blocked'] = 0; + + item_update_parent_commented($item); + + notice(t('Comment approved') . EOL); + } elseif ($action === 'drop') { + drop_item($post_id, false); + notice(t('Comment deleted') . EOL); + } + + // refetch the item after changes have been made + + $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(local_channel(), array('item' => array(encode_item($sync_item[0], true)))); + } + if ($action === 'approve') { + if ($item['id'] !== $item['parent']) { + // if this is a group comment, call tag_deliver() to generate the associated + // Announce activity so microblog destinations will see it in their home timeline + $role = get_pconfig(local_channel(), 'system', 'permissions_role'); + $rolesettings = PermissionRoles::role_perms($role); + $channel_type = isset($rolesettings['channel_type']) ? $rolesettings['channel_type'] : 'normal'; + + $is_group = (($channel_type === 'group') ? true : false); + if ($is_group) { + tag_deliver(local_channel(), $post_id); + } + } + Run::Summon(['Notifier', 'comment-new', $post_id]); + } + goaway(z_root() . '/moderate'); + } + } + + if ($r) { + xchan_query($r); + $items = fetch_post_tags($r, true); + } else { + $items = []; + } + + $o = conversation($items, 'moderate', false, 'traditional'); + $o .= alt_pager(count($items)); + return $o; + } +} diff --git a/Code/Module/Mood.php b/Code/Module/Mood.php new file mode 100644 index 000000000..d4a34e97d --- /dev/null +++ b/Code/Module/Mood.php @@ -0,0 +1,175 @@ +Mood App (Not Installed):
      '; + $o .= t('Set your current mood and tell your friends'); + return $o; + } + + Navbar::set_selected('Mood'); + + $parent = ((x($_GET, 'parent')) ? intval($_GET['parent']) : '0'); + + $verbs = get_mood_verbs(); + + $shortlist = []; + foreach ($verbs as $k => $v) { + if ($v !== 'NOTRANSLATION') { + $shortlist[] = array($k, $v); + } + } + + + $tpl = Theme::get_template('mood_content.tpl'); + + $o = replace_macros($tpl, array( + '$title' => t('Mood'), + '$desc' => t('Set your current mood and tell your friends'), + '$verbs' => $shortlist, + '$parent' => $parent, + '$submit' => t('Submit'), + )); + + return $o; + } +} diff --git a/Code/Module/New_channel.php b/Code/Module/New_channel.php new file mode 100644 index 000000000..687436980 --- /dev/null +++ b/Code/Module/New_channel.php @@ -0,0 +1,212 @@ + 1) ? argv(1) : ''); + + if ($cmd === 'autofill.json') { + $result = array('error' => false, 'message' => ''); + $n = trim($_REQUEST['name']); + + $x = false; + + if (get_config('system', 'unicode_usernames')) { + $x = punify(mb_strtolower($n)); + } + + if ((!$x) || strlen($x) > 64) { + $x = strtolower(URLify::transliterate($n)); + } + + $test = []; + + // first name + if (strpos($x, ' ')) { + $test[] = legal_webbie(substr($x, 0, strpos($x, ' '))); + } + if ($test[0]) { + // first name plus first initial of last + $test[] = ((strpos($x, ' ')) ? $test[0] . legal_webbie(trim(substr($x, strpos($x, ' '), 2))) : ''); + // first name plus random number + $test[] = $test[0] . mt_rand(1000, 9999); + } + // fullname + $test[] = legal_webbie($x); + // fullname plus random number + $test[] = legal_webbie($x) . mt_rand(1000, 9999); + + json_return_and_die(check_webbie($test)); + } + + if ($cmd === 'checkaddr.json') { + $result = array('error' => false, 'message' => ''); + $n = trim($_REQUEST['nick']); + if (!$n) { + $n = trim($_REQUEST['name']); + } + + $x = false; + + if (get_config('system', 'unicode_usernames')) { + $x = punify(mb_strtolower($n)); + } + + if ((!$x) || strlen($x) > 64) { + $x = strtolower(URLify::transliterate($n)); + } + + + $test = []; + + // first name + if (strpos($x, ' ')) { + $test[] = legal_webbie(substr($x, 0, strpos($x, ' '))); + } + if ($test[0]) { + // first name plus first initial of last + $test[] = ((strpos($x, ' ')) ? $test[0] . legal_webbie(trim(substr($x, strpos($x, ' '), 2))) : ''); + // first name plus random number + $test[] = $test[0] . mt_rand(1000, 9999); + } + + $n = legal_webbie($x); + if (strlen($n)) { + $test[] = $n; + $test[] = $n . mt_rand(1000, 9999); + } + + for ($y = 0; $y < 100; $y++) { + $test[] = 'id' . mt_rand(1000, 9999); + } + + json_return_and_die(check_webbie($test)); + } + } + + public function post() + { + + $arr = $_POST; + + $acc = App::get_account(); + + if (local_channel()) { + $parent_channel = App::get_channel(); + if ($parent_channel) { + $arr['parent_hash'] = $parent_channel['channel_hash']; + } + } + + $arr['account_id'] = get_account_id(); + + // prevent execution by delegated channels as well as those not logged in. + // get_account_id() returns the account_id from the session. But \App::$account + // may point to the original authenticated account. + + if ((!$acc) || ($acc['account_id'] != $arr['account_id'])) { + notice(t('Permission denied.') . EOL); + return; + } + + $result = Channel::create($arr); + + if (!$result['success']) { + notice($result['message']); + return; + } + + $newuid = $result['channel']['channel_id']; + + change_channel($result['channel']['channel_id']); + + $next_page = get_config('system', 'workflow_channel_next', 'profiles'); + goaway(z_root() . '/' . $next_page); + } + + public function get() + { + + $acc = App::get_account(); + + if ((!$acc) || $acc['account_id'] != get_account_id()) { + notice(t('Permission denied.') . EOL); + return; + } + + $default_role = ''; + $aid = get_account_id(); + if ($aid) { + $r = q( + "select count(channel_id) as total from channel where channel_account_id = %d", + intval($aid) + ); + if ($r && (!intval($r[0]['total']))) { + $default_role = get_config('system', 'default_permissions_role', 'social'); + } + + $limit = ServiceClass::account_fetch(get_account_id(), 'total_identities'); + + if ($r && ($limit !== false)) { + $channel_usage_message = sprintf(t("You have created %1$.0f of %2$.0f allowed channels."), $r[0]['total'], $limit); + } else { + $channel_usage_message = ''; + } + } + + $name_help = ''; + $name_help .= (($default_role) + ? t('Your real name is recommended.') + : t('Examples: "Bob Jameson", "Lisa and her Horses", "Soccer", "Aviation Group"') + ); + $name_help .= ''; + + $nick_help = ''; + $nick_help .= t('This will be used to create a unique network address (like an email address).'); + if (!get_config('system', 'unicode_usernames')) { + $nick_help .= ' ' . t('Allowed characters are a-z 0-9, - and _'); + } + $nick_help .= ''; + + $privacy_role = ((x($_REQUEST, 'permissions_role')) ? $_REQUEST['permissions_role'] : ""); + + $perm_roles = PermissionRoles::roles(); + + $name = array('name', t('Channel name'), ((x($_REQUEST, 'name')) ? $_REQUEST['name'] : ''), $name_help, "*"); + $nickhub = '@' . App::get_hostname(); + $nickname = array('nickname', t('Choose a short nickname'), ((x($_REQUEST, 'nickname')) ? $_REQUEST['nickname'] : ''), $nick_help, "*"); + $role = array('permissions_role', t('Channel role and privacy'), ($privacy_role) ? $privacy_role : 'social', t('Select a channel permission role compatible with your usage needs and privacy requirements.'), $perm_roles); + + $o = replace_macros(Theme::get_template('new_channel.tpl'), array( + '$title' => t('Create a Channel'), + '$desc' => t('A channel is a unique network identity. It can represent a person (social network profile), a forum (group), a business or celebrity page, a newsfeed, and many other things.'), + '$label_import' => t('or import an existing channel from another location.'), + '$name' => $name, + '$role' => $role, + '$default_role' => $default_role, + '$nickname' => $nickname, + '$validate' => t('Validate'), + '$submit' => t('Create'), + '$channel_usage_message' => $channel_usage_message + )); + + return $o; + } +} diff --git a/Code/Module/Notes.php b/Code/Module/Notes.php new file mode 100644 index 000000000..cb4f3bac4 --- /dev/null +++ b/Code/Module/Notes.php @@ -0,0 +1,66 @@ + true); + if (array_key_exists('note_text', $_REQUEST)) { + $body = escape_tags($_REQUEST['note_text']); + + // I've had my notes vanish into thin air twice in four years. + // Provide a backup copy if there were contents previously + // and there are none being saved now. + + if (!$body) { + $old_text = get_pconfig(local_channel(), 'notes', 'text'); + if ($old_text) { + set_pconfig(local_channel(), 'notes', 'text.bak', $old_text); + } + } + set_pconfig(local_channel(), 'notes', 'text', $body); + + + // push updates to channel clones + + if ((argc() > 1) && (argv(1) === 'sync')) { + Libsync::build_sync_packet(); + } + + logger('notes saved.', LOGGER_DEBUG); + json_return_and_die($ret); + } + } + + public function get() + { + + $desc = t('This app allows you to create private notes for your personal use.'); + + $text = ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Notes'))) { + return $text; + } + + $desc = t('This app is installed. The Notes tool can be found on your stream page.'); + + $text = ''; + + return $text; + } +} diff --git a/Code/Module/Notifications.php b/Code/Module/Notifications.php new file mode 100644 index 000000000..541ecedba --- /dev/null +++ b/Code/Module/Notifications.php @@ -0,0 +1,82 @@ + 49) { + $r = q( + "select * from notify where uid = %d + and seen = 0 order by created desc limit 50", + intval(local_channel()) + ); + } else { + $r1 = q( + "select * from notify where uid = %d + and seen = 0 order by created desc limit 50", + intval(local_channel()) + ); + + $r2 = q( + "select * from notify where uid = %d + and seen = 1 order by created desc limit %d", + intval(local_channel()), + intval(50 - intval($n[0]['total'])) + ); + $r = array_merge($r1, $r2); + } + + if ($r) { + $notifications_available = true; + foreach ($r as $rr) { + $x = strip_tags(bbcode($rr['msg'])); + $notif_content .= replace_macros(Theme::get_template('notify.tpl'), array( + '$item_link' => z_root() . '/notify/view/' . $rr['id'], + '$item_image' => $rr['photo'], + '$item_text' => $x, + '$item_when' => relative_date($rr['created']), + '$item_seen' => (($rr['seen']) ? true : false), + '$new' => t('New') + )); + } + } else { + $notif_content = t('No more system notifications.'); + } + + $o .= replace_macros(Theme::get_template('notifications.tpl'), array( + '$notif_header' => t('System Notifications'), + '$notif_link_mark_seen' => t('Mark all seen'), + '$notif_content' => $notif_content, + '$notifications_available' => $notifications_available, + )); + + return $o; + } +} diff --git a/Code/Module/Notify.php b/Code/Module/Notify.php new file mode 100644 index 000000000..a01780311 --- /dev/null +++ b/Code/Module/Notify.php @@ -0,0 +1,87 @@ + 2 && argv(1) === 'view' && intval(argv(2))) { + $r = q( + "select * from notify where id = %d and uid = %d limit 1", + intval(argv(2)), + intval(local_channel()) + ); + if ($r) { + $x = ['channel_id' => local_channel(), 'update' => 'unset']; + Hook::call('update_unseen', $x); + if ((!$_SESSION['sudo']) && ($x['update'] === 'unset' || intval($x['update']))) { + q( + "update notify set seen = 1 where (( parent != '' and parent = '%s' and otype = '%s' ) or link = '%s' ) and uid = %d", + dbesc($r[0]['parent']), + dbesc($r[0]['otype']), + dbesc($r[0]['link']), + intval(local_channel()) + ); + } + goaway($r[0]['link']); + } + notice(sprintf(t('A notification with that id was not found for channel \'%s\''), $channel['channel_name'])); + goaway(z_root()); + } + } + + + public function get() + { + if (!local_channel()) { + return login(); + } + + $notif_tpl = Theme::get_template('notifications.tpl'); + + $not_tpl = Theme::get_template('notify.tpl'); + + $r = q( + "SELECT * from notify where uid = %d and seen = 0 order by created desc", + intval(local_channel()) + ); + + if ($r) { + foreach ($r as $it) { + $notif_content .= replace_macros($not_tpl, array( + '$item_link' => z_root() . '/notify/view/' . $it['id'], + '$item_image' => $it['photo'], + '$item_text' => strip_tags(bbcode($it['msg'])), + '$item_when' => relative_date($it['created']) + )); + } + } else { + $notif_content .= t('No more system notifications.'); + } + + $o .= replace_macros($notif_tpl, array( + '$notif_header' => t('System Notifications'), + '$tabs' => '', // $tabs, + '$notif_content' => $notif_content, + )); + + return $o; + } +} diff --git a/Code/Module/Nullbox.php b/Code/Module/Nullbox.php new file mode 100644 index 000000000..30734a31e --- /dev/null +++ b/Code/Module/Nullbox.php @@ -0,0 +1,14 @@ + z_root(), + 'authorization_endpoint' => z_root() . '/authorize', + 'jwks_uri' => z_root() . '/jwks', + 'token_endpoint' => z_root() . '/token', + 'userinfo_endpoint' => z_root() . '/userinfo', + 'scopes_supported' => ['openid', 'profile', 'email'], + 'response_types_supported' => ['code', 'token', 'id_token', 'code id_token', 'token id_token'] + ]; + + json_return_and_die($ret); + } +} diff --git a/Code/Module/Oembed.php b/Code/Module/Oembed.php new file mode 100644 index 000000000..9bebb8253 --- /dev/null +++ b/Code/Module/Oembed.php @@ -0,0 +1,35 @@ + 1) { + if (argv(1) == 'b2h') { + $url = array("", trim(hex2bin($_REQUEST['url']))); + echo Zlib\Oembed::replacecb($url); + killme(); + } elseif (argv(1) == 'h2b') { + $text = trim(hex2bin($_REQUEST['text'])); + echo Zlib\Oembed::html2bbcode($text); + killme(); + } else { + echo ""; + $src = base64url_decode(argv(1)); + $j = Zlib\Oembed::fetch_url($src); + echo $j['html']; + echo ""; + } + } + killme(); + } +} diff --git a/Code/Module/Oep.php b/Code/Module/Oep.php new file mode 100644 index 000000000..35143abda --- /dev/null +++ b/Code/Module/Oep.php @@ -0,0 +1,680 @@ + 1 && argv(1) === 'html') ? true : false); + if ($_REQUEST['url']) { + $_REQUEST['url'] = strip_zids($_REQUEST['url']); + $url = $_REQUEST['url']; + } + + if (!$url) { + http_status_exit(404, 'Not found'); + } + + $maxwidth = $_REQUEST['maxwidth']; + $maxheight = $_REQUEST['maxheight']; + $format = $_REQUEST['format']; + if ($format && $format !== 'json') { + http_status_exit(501, 'Not implemented'); + } + + if (fnmatch('*/photos/*/album/*', $url)) { + $arr = $this->oep_album_reply($_REQUEST); + } elseif (fnmatch('*/photos/*/image/*', $url)) { + $arr = $this->oep_photo_reply($_REQUEST); + } elseif (fnmatch('*/photos*', $url)) { + $arr = $this->oep_phototop_reply($_REQUEST); + } elseif (fnmatch('*/display/*', $url)) { + $arr = $this->oep_display_reply($_REQUEST); + } elseif (fnmatch('*/channel/*mid=*', $url)) { + $arr = $this->oep_mid_reply($_REQUEST); + } elseif (fnmatch('*/channel*', $url)) { + $arr = $this->oep_profile_reply($_REQUEST); + } elseif (fnmatch('*/profile/*', $url)) { + $arr = $this->oep_profile_reply($_REQUEST); + } elseif (fnmatch('*/cards/*', $url)) { + $arr = $this->oep_cards_reply($_REQUEST); + } elseif (fnmatch('*/articles/*', $url)) { + $arr = $this->oep_articles_reply($_REQUEST); + } + + if ($arr) { + if ($html) { + if ($arr['type'] === 'rich') { + header('Content-Type: text/html'); + echo $arr['html']; + } + } else { + header('Content-Type: application/json+oembed'); + echo json_encode($arr); + } + killme(); + } + + http_status_exit(404, 'Not found'); + } + + public function oep_display_reply($args) + { + + $ret = []; + $url = $args['url']; + $maxwidth = intval($args['maxwidth']); + $maxheight = intval($args['maxheight']); + + if (preg_match('#//(.*?)/display/(.*?)(&|\?|$)#', $url, $matches)) { + $res = $matches[2]; + } + + $res = unpack_link_id($res); + + $item_normal = item_normal(); + + $p = q( + "select * from item where mid like '%s' limit 1", + dbesc($res . '%') + ); + + if (!$p) { + return; + } + + $c = Channel::from_id($p[0]['uid']); + + + if (!($c && $res)) { + return; + } + + if (!perm_is_allowed($c[0]['channel_id'], get_observer_hash(), 'view_stream')) { + return; + } + + $sql_extra = item_permissions_sql($c['channel_id']); + + $p = q( + "select * from item where mid like '%s' and uid = %d $sql_extra $item_normal limit 1", + dbesc($res . '%'), + intval($c['channel_id']) + ); + + if (!$p) { + return; + } + + xchan_query($p, true); + $p = fetch_post_tags($p, true); + + // This function can get tripped up if the item is already a reshare + // (the multiple share declarations do not parse cleanly if nested) + // So build a template with a known nonsense string as the content, and then + // replace that known string with the actual rendered content, sending + // each content layer through bbcode() separately. + + $x = '2eGriplW^*Jmf4'; + + + $o = "[share author='" . urlencode($p[0]['author']['xchan_name']) . + "' profile='" . $p[0]['author']['xchan_url'] . + "' portable_id='" . $p[0]['author']['xchan_hash'] . + "' avatar='" . $p[0]['author']['xchan_photo_s'] . + "' link='" . $p[0]['plink'] . + "' auth='" . (in_array($p[0]['author']['network'],['nomad','zot6']) ? 'true' : 'false') . + "' posted='" . $p[0]['created'] . + "' message_id='" . $p[0]['mid'] . "']"; + if ($p[0]['title']) { + $o .= '[b]' . $p[0]['title'] . '[/b]' . "\r\n"; + } + + $o .= $x; + $o .= "[/share]"; + $o = bbcode($o); + + $o = str_replace($x, bbcode($p[0]['body']), $o); + + $ret['type'] = 'rich'; + + $w = (($maxwidth) ? $maxwidth : 640); + $h = (($maxheight) ? $maxheight : intval($w * 2 / 3)); + + $ret['html'] = '
      ' . $o . '
      '; + + $ret['width'] = $w; + $ret['height'] = $h; + + return $ret; + } + + + public function oep_cards_reply($args) + { + + $ret = []; + $url = $args['url']; + $maxwidth = intval($args['maxwidth']); + $maxheight = intval($args['maxheight']); + + if (preg_match('#//(.*?)/cards/(.*?)/(.*?)(&|\?|$)#', $url, $matches)) { + $nick = $matches[2]; + $res = $matches[3]; + } + if (!($nick && $res)) { + return $ret; + } + + $channel = Channel::from_username($nick); + + if (!$channel) { + return $ret; + } + + + if (!perm_is_allowed($channel['channel_id'], get_observer_hash(), 'view_pages')) { + return $ret; + } + + $sql_extra = item_permissions_sql($channel['channel_id'], get_observer_hash()); + + $r = q( + "select * from iconfig where iconfig.cat = 'system' and iconfig.k = 'CARD' and iconfig.v = '%s' limit 1", + dbesc($res) + ); + if ($r) { + $sql_extra .= " and item.id = " . intval($r[0]['iid']) . " "; + } else { + return $ret; + } + + $r = q( + "select * from item + where item.uid = %d and item_type = %d + $sql_extra order by item.created desc", + intval($channel['channel_id']), + intval(ITEM_TYPE_CARD) + ); + + $item_normal = " and item.item_hidden = 0 and item.item_type in (0,6) 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 "; + + if ($r) { + xchan_query($r); + $p = fetch_post_tags($r, true); + } + + $x = '2eGriplW^*Jmf4'; + + + $o = "[share author='" . urlencode($p[0]['author']['xchan_name']) . + "' profile='" . $p[0]['author']['xchan_url'] . + "' portable_id='" . $p[0]['author']['xchan_hash'] . + "' avatar='" . $p[0]['author']['xchan_photo_s'] . + "' link='" . $p[0]['plink'] . + "' auth='" . (in_array($p[0]['author']['network'],['nomad','zot6']) ? 'true' : 'false') . + "' posted='" . $p[0]['created'] . + "' message_id='" . $p[0]['mid'] . "']"; + if ($p[0]['title']) { + $o .= '[b]' . $p[0]['title'] . '[/b]' . "\r\n"; + } + + $o .= $x; + $o .= "[/share]"; + $o = bbcode($o); + + $o = str_replace($x, bbcode($p[0]['body']), $o); + + $ret['type'] = 'rich'; + + $w = (($maxwidth) ? $maxwidth : 640); + $h = (($maxheight) ? $maxheight : intval($w * 2 / 3)); + + $ret['html'] = '
      ' . $o . '
      '; + + $ret['width'] = $w; + $ret['height'] = $h; + + return $ret; + } + + public function oep_articles_reply($args) + { + + $ret = []; + $url = $args['url']; + $maxwidth = intval($args['maxwidth']); + $maxheight = intval($args['maxheight']); + + if (preg_match('#//(.*?)/articles/(.*?)/(.*?)(&|\?|$)#', $url, $matches)) { + $nick = $matches[2]; + $res = $matches[3]; + } + if (!($nick && $res)) { + return $ret; + } + + $channel = Channel::from_username($nick); + + if (!$channel) { + return $ret; + } + + + if (!perm_is_allowed($channel['channel_id'], get_observer_hash(), 'view_pages')) { + return $ret; + } + + $sql_extra = item_permissions_sql($channel['channel_id'], get_observer_hash()); + + $r = q( + "select * from iconfig where iconfig.cat = 'system' and iconfig.k = 'ARTICLE' and iconfig.v = '%s' limit 1", + dbesc($res) + ); + if ($r) { + $sql_extra .= " and item.id = " . intval($r[0]['iid']) . " "; + } else { + return $ret; + } + + $r = q( + "select * from item + where item.uid = %d and item_type = %d + $sql_extra order by item.created desc", + intval($channel['channel_id']), + intval(ITEM_TYPE_ARTICLE) + ); + + $item_normal = " and item.item_hidden = 0 and item.item_type in (0,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 "; + + if ($r) { + xchan_query($r); + $p = fetch_post_tags($r, true); + } + + $x = '2eGriplW^*Jmf4'; + + + $o = "[share author='" . urlencode($p[0]['author']['xchan_name']) . + "' profile='" . $p[0]['author']['xchan_url'] . + "' portable_id='" . $p[0]['author']['xchan_hash'] . + "' avatar='" . $p[0]['author']['xchan_photo_s'] . + "' link='" . $p[0]['plink'] . + "' auth='" . (in_array($p[0]['author']['network'],['nomad','zot6']) ? 'true' : 'false') . + "' posted='" . $p[0]['created'] . + "' message_id='" . $p[0]['mid'] . "']"; + if ($p[0]['title']) { + $o .= '[b]' . $p[0]['title'] . '[/b]' . "\r\n"; + } + + $o .= $x; + $o .= "[/share]"; + $o = bbcode($o); + + $o = str_replace($x, bbcode($p[0]['body']), $o); + + $ret['type'] = 'rich'; + + $w = (($maxwidth) ? $maxwidth : 640); + $h = (($maxheight) ? $maxheight : intval($w * 2 / 3)); + + $ret['html'] = '
      ' . $o . '
      '; + + $ret['width'] = $w; + $ret['height'] = $h; + + return $ret; + } + + + public function oep_mid_reply($args) + { + + $ret = []; + $url = $args['url']; + $maxwidth = intval($args['maxwidth']); + $maxheight = intval($args['maxheight']); + + if (preg_match('#//(.*?)/(.*?)/(.*?)/(.*?)mid\=(.*?)(&|$)#', $url, $matches)) { + $chn = $matches[3]; + $res = $matches[5]; + } + + if (!($chn && $res)) { + return; + } + $c = q( + "select * from channel where channel_address = '%s' limit 1", + dbesc($chn) + ); + + if (!$c) { + return; + } + + if (!perm_is_allowed($c[0]['channel_id'], get_observer_hash(), 'view_stream')) { + return; + } + + $sql_extra = item_permissions_sql($c[0]['channel_id']); + + $p = q( + "select * from item where mid = '%s' and uid = %d $sql_extra limit 1", + dbesc($res), + intval($c[0]['channel_id']) + ); + if (!$p) { + return; + } + + xchan_query($p, true); + $p = fetch_post_tags($p, true); + + // This function can get tripped up if the item is already a reshare + // (the multiple share declarations do not parse cleanly if nested) + // So build a template with a known nonsense string as the content, and then + // replace that known string with the actual rendered content, sending + // each content layer through bbcode() separately. + + $x = '2eGriplW^*Jmf4'; + + $o = "[share author='" . urlencode($p[0]['author']['xchan_name']) . + "' profile='" . $p[0]['author']['xchan_url'] . + "' portable_id='" . $p[0]['author']['xchan_hash'] . + "' avatar='" . $p[0]['author']['xchan_photo_s'] . + "' link='" . $p[0]['plink'] . + "' auth='" . (in_array($p[0]['author']['network'],['nomad','zot6']) ? 'true' : 'false') . + "' posted='" . $p[0]['created'] . + "' message_id='" . $p[0]['mid'] . "']"; + if ($p[0]['title']) { + $o .= '[b]' . $p[0]['title'] . '[/b]' . "\r\n"; + } + $o .= $x; + $o .= "[/share]"; + $o = bbcode($o); + + $o = str_replace($x, bbcode($p[0]['body']), $o); + + $ret['type'] = 'rich'; + + $w = (($maxwidth) ? $maxwidth : 640); + $h = (($maxheight) ? $maxheight : intval($w * 2 / 3)); + + $ret['html'] = '
      ' . $o . '
      '; + + $ret['width'] = $w; + $ret['height'] = $h; + + return $ret; + } + + public function oep_profile_reply($args) + { + + + require_once('include/channel.php'); + + $url = $args['url']; + + if (preg_match('#//(.*?)/(.*?)/(.*?)(/|\?|&|$)#', $url, $matches)) { + $chn = $matches[3]; + } + + if (!$chn) { + return; + } + + $c = Channel::from_username($chn); + + if (!$c) { + return; + } + + + $maxwidth = intval($args['maxwidth']); + $maxheight = intval($args['maxheight']); + + $width = 800; + $height = 375; + + if ($maxwidth) { + $width = $maxwidth; + $height = (375 / 800) * $width; + } + if ($maxheight) { + if ($maxheight < $height) { + $width = (800 / 375) * $maxheight; + $height = $maxheight; + } + } + $ret = []; + + $ret['type'] = 'rich'; + $ret['width'] = intval($width); + $ret['height'] = intval($height); + + $ret['html'] = Channel::get_zcard_embed($c, get_observer_hash(), array('width' => $width, 'height' => $height)); + + return $ret; + } + + public function oep_album_reply($args) + { + + $ret = []; + $url = $args['url']; + $maxwidth = intval($args['maxwidth']); + $maxheight = intval($args['maxheight']); + + if (preg_match('|//(.*?)/(.*?)/(.*?)/album/|', $url, $matches)) { + $chn = $matches[3]; + $res = basename($url); + } + + if (!($chn && $res)) { + return; + } + $c = q( + "select * from channel where channel_address = '%s' limit 1", + dbesc($chn) + ); + + if (!$c) { + return; + } + + if (!perm_is_allowed($c[0]['channel_id'], get_observer_hash(), 'view_files')) { + return; + } + + $sql_extra = permissions_sql($c[0]['channel_id']); + + $p = q( + "select resource_id from photo where album = '%s' and uid = %d and imgscale = 0 $sql_extra order by created desc limit 1", + dbesc($res), + intval($c[0]['channel_id']) + ); + if (!$p) { + return; + } + + $res = $p[0]['resource_id']; + + $r = q( + "select height, width, imgscale, resource_id from photo where uid = %d and resource_id = '%s' $sql_extra order by imgscale asc", + intval($c[0]['channel_id']), + dbesc($res) + ); + + if ($r) { + foreach ($r as $rr) { + $foundres = false; + if ($maxheight && $rr['height'] > $maxheight) { + continue; + } + if ($maxwidth && $rr['width'] > $maxwidth) { + continue; + } + $foundres = true; + break; + } + + if ($foundres) { + $ret['type'] = 'link'; + $ret['thumbnail_url'] = z_root() . '/photo/' . '/' . $rr['resource_id'] . '-' . $rr['imgscale']; + $ret['thumbnail_width'] = $rr['width']; + $ret['thumbnail_height'] = $rr['height']; + } + } + return $ret; + } + + + public function oep_phototop_reply($args) + { + + $ret = []; + $url = $args['url']; + $maxwidth = intval($args['maxwidth']); + $maxheight = intval($args['maxheight']); + + if (preg_match('|//(.*?)/(.*?)/(.*?)$|', $url, $matches)) { + $chn = $matches[3]; + } + + if (!$chn) { + return; + } + $c = q( + "select * from channel where channel_address = '%s' limit 1", + dbesc($chn) + ); + + if (!$c) { + return; + } + + if (!perm_is_allowed($c[0]['channel_id'], get_observer_hash(), 'view_files')) { + return; + } + + $sql_extra = permissions_sql($c[0]['channel_id']); + + $p = q( + "select resource_id from photo where uid = %d and imgscale = 0 $sql_extra order by created desc limit 1", + intval($c[0]['channel_id']) + ); + if (!$p) { + return; + } + + $res = $p[0]['resource_id']; + + $r = q( + "select height, width, imgscale, resource_id from photo where uid = %d and resource_id = '%s' $sql_extra order by imgscale asc", + intval($c[0]['channel_id']), + dbesc($res) + ); + + if ($r) { + foreach ($r as $rr) { + $foundres = false; + if ($maxheight && $rr['height'] > $maxheight) { + continue; + } + if ($maxwidth && $rr['width'] > $maxwidth) { + continue; + } + $foundres = true; + break; + } + + if ($foundres) { + $ret['type'] = 'link'; + $ret['thumbnail_url'] = z_root() . '/photo/' . '/' . $rr['resource_id'] . '-' . $rr['imgscale']; + $ret['thumbnail_width'] = $rr['width']; + $ret['thumbnail_height'] = $rr['height']; + } + } + return $ret; + } + + + public function oep_photo_reply($args) + { + + $ret = []; + $url = $args['url']; + $maxwidth = intval($args['maxwidth']); + $maxheight = intval($args['maxheight']); + + if (preg_match('|//(.*?)/(.*?)/(.*?)/image/|', $url, $matches)) { + $chn = $matches[3]; + $res = basename($url); + } + + if (!($chn && $res)) { + return; + } + $c = q( + "select * from channel where channel_address = '%s' limit 1", + dbesc($chn) + ); + + if (!$c) { + return; + } + + if (!perm_is_allowed($c[0]['channel_id'], get_observer_hash(), 'view_files')) { + return; + } + + $sql_extra = permissions_sql($c[0]['channel_id']); + + + $r = q( + "select height, width, imgscale, resource_id from photo where uid = %d and resource_id = '%s' $sql_extra order by imgscale asc", + intval($c[0]['channel_id']), + dbesc($res) + ); + + if ($r) { + foreach ($r as $rr) { + $foundres = false; + if ($maxheight && $rr['height'] > $maxheight) { + continue; + } + if ($maxwidth && $rr['width'] > $maxwidth) { + continue; + } + $foundres = true; + break; + } + + if ($foundres) { + $ret['type'] = 'link'; + $ret['thumbnail_url'] = z_root() . '/photo/' . '/' . $rr['resource_id'] . '-' . $rr['imgscale']; + $ret['thumbnail_width'] = $rr['width']; + $ret['thumbnail_height'] = $rr['height']; + } + } + return $ret; + } +} diff --git a/Code/Module/Oexchange.php b/Code/Module/Oexchange.php new file mode 100644 index 000000000..47fa49272 --- /dev/null +++ b/Code/Module/Oexchange.php @@ -0,0 +1,78 @@ + 1) && (argv(1) === 'xrd')) { + echo replace_macros(Theme::get_template('oexchange_xrd.tpl'), ['$base' => z_root()]); + killme(); + } + } + + public function get() + { + if (!local_channel()) { + if (remote_channel()) { + $observer = App::get_observer(); + if ($observer && $observer['xchan_url']) { + $parsed = @parse_url($observer['xchan_url']); + if (!$parsed) { + notice(t('Unable to find your site.') . EOL); + return; + } + $url = $parsed['scheme'] . '://' . $parsed['host'] . (($parsed['port']) ? ':' . $parsed['port'] : ''); + $url .= '/oexchange'; + $result = z_post_url($url, $_REQUEST); + json_return_and_die($result); + } + } + + return login(false); + } + + if ((argc() > 1) && argv(1) === 'done') { + info(t('Post successful.') . EOL); + return; + } + + $url = (((x($_REQUEST, 'url')) && strlen($_REQUEST['url'])) + ? urlencode(notags(trim($_REQUEST['url']))) : ''); + $title = (((x($_REQUEST, 'title')) && strlen($_REQUEST['title'])) + ? '&title=' . urlencode(notags(trim($_REQUEST['title']))) : ''); + $description = (((x($_REQUEST, 'description')) && strlen($_REQUEST['description'])) + ? '&description=' . urlencode(notags(trim($_REQUEST['description']))) : ''); + $tags = (((x($_REQUEST, 'tags')) && strlen($_REQUEST['tags'])) + ? '&tags=' . urlencode(notags(trim($_REQUEST['tags']))) : ''); + + $ret = z_fetch_url(z_root() . '/linkinfo?f=&url=' . $url . $title . $description . $tags); + + if ($ret['success']) { + $s = $ret['body']; + } + + if (!strlen($s)) { + return; + } + + $post = []; + + $post['profile_uid'] = local_channel(); + $post['return'] = '/oexchange/done'; + $post['body'] = $s; + $post['type'] = 'wall'; + + $_REQUEST = $post; + $mod = new Item(); + $mod->post(); + } +} diff --git a/Code/Module/Online.php b/Code/Module/Online.php new file mode 100644 index 000000000..0a56915f7 --- /dev/null +++ b/Code/Module/Online.php @@ -0,0 +1,18 @@ + false]; + if (argc() != 2) { + json_return_and_die($ret); + } + json_return_and_die(Channel::get_online_status(argv(1))); + } +} diff --git a/Code/Module/Outbox.php b/Code/Module/Outbox.php new file mode 100644 index 000000000..bc26f40ef --- /dev/null +++ b/Code/Module/Outbox.php @@ -0,0 +1,318 @@ +is_valid()) { + return; + } + + if (!PConfig::Get($channel['channel_id'], 'system', 'activitypub', Config::Get('system', 'activitypub', ACTIVITYPUB_ENABLED))) { + return; + } + + logger('outbox_channel: ' . $channel['channel_address'], LOGGER_DEBUG); + +// switch ($AS->type) { +// case 'Follow': +// if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStreams::is_an_actor($AS->obj['type']) && isset($AS->obj['id'])) { +// // do follow activity +// Activity::follow($channel,$AS); +// } +// break; +// case 'Invite': +// if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { +// // do follow activity +// Activity::follow($channel,$AS); +// } +// break; +// case 'Join': +// if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { +// // do follow activity +// Activity::follow($channel,$AS); +// } +// break; +// case 'Accept': +// // Activitypub for wordpress sends lowercase 'follow' on accept. +// // https://github.com/pfefferle/wordpress-activitypub/issues/97 +// // Mobilizon sends Accept/"Member" (not in vocabulary) in response to Join/Group +// if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && in_array($AS->obj['type'], ['Follow','follow', 'Member'])) { +// // do follow activity +// Activity::follow($channel,$AS); +// } +// break; +// case 'Reject': +// default: +// break; +// +// } + + // These activities require permissions + + $item = null; + + switch ($AS->type) { + case 'Update': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStreams::is_an_actor($AS->obj['type'])) { + // pretend this is an old cache entry to force an update of all the actor details + $AS->obj['cached'] = true; + $AS->obj['updated'] = datetime_convert('UTC', 'UTC', '1980-01-01', ATOM_TIME); + Activity::actor_store($AS->obj['id'], $AS->obj); + break; + } + case 'Accept': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && (ActivityStreams::is_an_actor($AS->obj['type']) || $AS->obj['type'] === 'Member')) { + break; + } + case 'Create': + case 'Like': + case 'Dislike': + case 'Announce': + case 'Reject': + case 'TentativeAccept': + case 'TentativeReject': + case 'Add': + case 'Arrive': + case 'Block': + case 'Flag': + case 'Ignore': + case 'Invite': + case 'Listen': + case 'Move': + case 'Offer': + case 'Question': + case 'Read': + case 'Travel': + case 'View': + case 'emojiReaction': + case 'EmojiReaction': + case 'EmojiReact': + // These require a resolvable object structure + if (is_array($AS->obj)) { + // The boolean flag enables html cache of the item + $item = Activity::decode_note($AS, true); + } else { + logger('unresolved object: ' . print_r($AS->obj, true)); + } + break; + case 'Undo': + if ($AS->obj && is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Follow') { + // do unfollow activity + Activity::unfollow($channel, $AS); + break; + } + case 'Leave': + if ($AS->obj && is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { + // do unfollow activity + Activity::unfollow($channel, $AS); + break; + } + case 'Tombstone': + case 'Delete': + Activity::drop($channel, $observer_hash, $AS); + break; + case 'Move': + if ( + $observer_hash && $observer_hash === $AS->actor + && is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStream::is_an_actor($AS->obj['type']) + && is_array($AS->tgt) && array_key_exists('type', $AS->tgt) && ActivityStream::is_an_actor($AS->tgt['type']) + ) { + ActivityPub::move($AS->obj, $AS->tgt); + } + break; + case 'Add': + case 'Remove': + default: + break; + } + + if ($item) { + logger('parsed_item: ' . print_r($item, true), LOGGER_DATA); + Activity::store($channel, $observer_hash, $AS, $item); + } + + http_status_exit(200, 'OK'); + return; + } + + + public function get() + { + + if (observer_prohibited(true)) { + killme(); + } + + if (argc() < 2) { + killme(); + } + + $channel = Channel::from_username(argv(1)); + if (!$channel) { + killme(); + } + +// if (intval($channel['channel_system'])) { +// killme(); +// } + + if (ActivityStreams::is_as_request()) { + $sigdata = HTTPSig::verify(($_SERVER['REQUEST_METHOD'] === 'POST') ? file_get_contents('php://input') : EMPTY_STR); + if ($sigdata['portable_id'] && $sigdata['header_valid']) { + $portable_id = $sigdata['portable_id']; + if (!check_channelallowed($portable_id)) { + http_status_exit(403, 'Permission denied'); + } + if (!check_siteallowed($sigdata['signer'])) { + http_status_exit(403, 'Permission denied'); + } + observer_auth($portable_id); + } elseif (Config::get('system', 'require_authenticated_fetch', false)) { + http_status_exit(403, 'Permission denied'); + } + + $observer_hash = get_observer_hash(); + + $params = []; + + $params['begin'] = ((x($_REQUEST, 'date_begin')) ? $_REQUEST['date_begin'] : NULL_DATE); + $params['end'] = ((x($_REQUEST, 'date_end')) ? $_REQUEST['date_end'] : ''); + $params['type'] = 'json'; + $params['pages'] = ((x($_REQUEST, 'pages')) ? intval($_REQUEST['pages']) : 0); + $params['top'] = ((x($_REQUEST, 'top')) ? intval($_REQUEST['top']) : 0); + $params['direction'] = ((x($_REQUEST, 'direction')) ? dbesc($_REQUEST['direction']) : 'desc'); // unimplemented + $params['cat'] = ((x($_REQUEST, 'cat')) ? escape_tags($_REQUEST['cat']) : ''); + $params['compat'] = 1; + + + $total = items_fetch( + [ + 'total' => true, + 'wall' => '1', + 'datequery' => $params['end'], + 'datequery2' => $params['begin'], + 'direction' => dbesc($params['direction']), + 'pages' => $params['pages'], + 'order' => dbesc('post'), + 'top' => $params['top'], + 'cat' => $params['cat'], + 'compat' => $params['compat'] + ], + $channel, + $observer_hash, + CLIENT_MODE_NORMAL, + App::$module + ); + + if ($total) { + App::set_pager_total($total); + App::set_pager_itemspage(100); + } + + if (App::$pager['unset'] && $total > 100) { + $ret = Activity::paged_collection_init($total, App::$query_string); + } else { + $items = items_fetch( + [ + 'wall' => '1', + 'datequery' => $params['end'], + 'datequery2' => $params['begin'], + 'records' => intval(App::$pager['itemspage']), + 'start' => intval(App::$pager['start']), + 'direction' => dbesc($params['direction']), + 'pages' => $params['pages'], + 'order' => dbesc('post'), + 'top' => $params['top'], + 'cat' => $params['cat'], + 'compat' => $params['compat'] + ], + $channel, + $observer_hash, + CLIENT_MODE_NORMAL, + App::$module + ); + + if ($items && $observer_hash) { + // check to see if this observer is a connection. If not, register any items + // belonging to this channel for notification of deletion/expiration + + $x = q( + "select abook_id from abook where abook_channel = %d and abook_xchan = '%s'", + intval($channel['channel_id']), + dbesc($observer_hash) + ); + if (!$x) { + foreach ($items as $item) { + if (strpos($item['mid'], z_root()) === 0) { + ThreadListener::store($item['mid'], $observer_hash); + } + } + } + } + + $ret = Activity::encode_item_collection($items, App::$query_string, 'OrderedCollection', true, $total); + } + + as_return_and_die($ret, $channel); + } + } +} diff --git a/Code/Module/Owa.php b/Code/Module/Owa.php new file mode 100644 index 000000000..1741ef7c3 --- /dev/null +++ b/Code/Module/Owa.php @@ -0,0 +1,76 @@ + false]; + + if (array_key_exists('REDIRECT_REMOTE_USER', $_SERVER) && (!array_key_exists('HTTP_AUTHORIZATION', $_SERVER))) { + $_SERVER['HTTP_AUTHORIZATION'] = $_SERVER['REDIRECT_REMOTE_USER']; + } + + + if (array_key_exists('HTTP_AUTHORIZATION', $_SERVER) && substr(trim($_SERVER['HTTP_AUTHORIZATION']), 0, 9) === 'Signature') { + $sigblock = HTTPSig::parse_sigheader($_SERVER['HTTP_AUTHORIZATION']); + if ($sigblock) { + $keyId = $sigblock['keyId']; + if ($keyId) { + $r = q( + "select * from hubloc left join xchan on hubloc_hash = xchan_hash + where ( hubloc_addr = '%s' or hubloc_id_url = '%s' ) and xchan_pubkey != '' ", + dbesc(str_replace('acct:', '', $keyId)), + dbesc($keyId) + ); + if (!$r) { + $found = discover_by_webbie(str_replace('acct:', '', $keyId)); + if ($found) { + $r = q( + "select * from hubloc left join xchan on hubloc_hash = xchan_hash + where ( hubloc_addr = '%s' or hubloc_id_url = '%s' ) and xchan_pubkey != '' ", + dbesc(str_replace('acct:', '', $keyId)), + dbesc($keyId) + ); + } + } + if ($r) { + foreach ($r as $hubloc) { + $verified = HTTPSig::verify(file_get_contents('php://input'), $hubloc['xchan_pubkey']); + if ($verified && $verified['header_signed'] && $verified['header_valid'] && ($verified['content_valid'] || (!$verified['content_signed']))) { + logger('OWA header: ' . print_r($verified, true), LOGGER_DATA); + logger('OWA success: ' . $hubloc['hubloc_addr'], LOGGER_DATA); + $ret['success'] = true; + $token = random_string(32); + Verify::create('owt', 0, $token, $hubloc['hubloc_addr']); + $result = ''; + openssl_public_encrypt($token, $result, $hubloc['xchan_pubkey']); + $ret['encrypted_token'] = base64url_encode($result); + break; + } else { + logger('OWA fail: ' . $hubloc['hubloc_id'] . ' ' . $hubloc['hubloc_addr']); + } + } + } + } + } + } + json_return_and_die($ret, 'application/x-nomad+json'); + } +} diff --git a/Code/Module/Page.php b/Code/Module/Page.php new file mode 100644 index 000000000..cf6abca61 --- /dev/null +++ b/Code/Module/Page.php @@ -0,0 +1,203 @@ +parse($r[0]['body']); + App::$pdl = $r[0]['body']; + } elseif ($r[0]['layout_mid']) { + $l = q( + "select body from item where mid = '%s' and uid = %d limit 1", + dbesc($r[0]['layout_mid']), + intval($u[0]['channel_id']) + ); + + if ($l) { + App::$comanche = new Comanche(); + App::$comanche->parse($l[0]['body']); + App::$pdl = $l[0]['body']; + } + } + + App::$data['webpage'] = $r; + } + + public function get() + { + + $r = App::$data['webpage']; + if (!$r) { + return; + } + + if ($r[0]['item_type'] == ITEM_TYPE_PDL) { + $r[0]['body'] = t('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'); + $r[0]['mimetype'] = 'text/plain'; + $r[0]['title'] = ''; + } + + xchan_query($r); + $r = fetch_post_tags($r, true); + + if ($r[0]['mimetype'] === 'application/x-pdl') { + App::$page['pdl_content'] = true; + } + + $o .= prepare_page($r[0]); + return $o; + } +} diff --git a/Code/Module/Pconfig.php b/Code/Module/Pconfig.php new file mode 100644 index 000000000..6c8744014 --- /dev/null +++ b/Code/Module/Pconfig.php @@ -0,0 +1,142 @@ +disallowed_pconfig())) { + notice(t('This setting requires special processing and editing has been blocked.') . EOL); + return; + } + + if (strpos($k, 'password') !== false) { + $v = obscurify($v); + } + + set_pconfig(local_channel(), $cat, $k, $v); + Libsync::build_sync_packet(); + + if ($aj) { + killme(); + } + + goaway(z_root() . '/pconfig/' . $cat . '/' . $k); + } + + + public function get() + { + + if (!local_channel()) { + return login(); + } + + $content = '

      ' . t('Configuration Editor') . '

      '; + $content .= '
      ' . t('Warning: Changing some settings could render your channel inoperable. Please leave this page unless you are comfortable with and knowledgeable about how to correctly use this feature.') . '
      ' . EOL . EOL; + + + if (argc() == 3) { + $content .= 'pconfig[' . local_channel() . ']' . EOL; + $content .= 'pconfig[' . local_channel() . '][' . escape_tags(argv(1)) . ']' . EOL . EOL; + $content .= 'pconfig[' . local_channel() . '][' . escape_tags(argv(1)) . '][' . escape_tags(argv(2)) . '] = ' . get_pconfig(local_channel(), escape_tags(argv(1)), escape_tags(argv(2))) . EOL; + + if (in_array(argv(2), $this->disallowed_pconfig())) { + notice(t('This setting requires special processing and editing has been blocked.') . EOL); + return $content; + } else { + $content .= $this->pconfig_form(escape_tags(argv(1)), escape_tags(argv(2))); + } + } + + + if (argc() == 2) { + $content .= 'pconfig[' . local_channel() . ']' . EOL; + load_pconfig(local_channel(), escape_tags(argv(1))); + if (App::$config[local_channel()][escape_tags(argv(1))]) { + foreach (App::$config[local_channel()][escape_tags(argv(1))] as $k => $x) { + $content .= 'pconfig[' . local_channel() . '][' . escape_tags(argv(1)) . '][' . $k . '] = ' . escape_tags($x) . EOL; + } + } + } + + if (argc() == 1) { + $r = q("select * from pconfig where uid = " . local_channel()); + if ($r) { + foreach ($r as $rr) { + $content .= 'pconfig[' . local_channel() . '][' . escape_tags($rr['cat']) . '][' . escape_tags($rr['k']) . '] = ' . escape_tags($rr['v']) . EOL; + } + } + } + return $content; + } + + + public function pconfig_form($cat, $k) + { + + $o = '
      '; + $o .= ''; + + $v = get_pconfig(local_channel(), $cat, $k); + if (strpos($k, 'password') !== false) { + $v = unobscurify($v); + } + + $o .= ''; + $o .= ''; + + + if (strpos($v, "\n")) { + $o .= ''; + } else { + if (is_array($v)) { + $o .= '
      ' . "\n" . print_array($v) . '
      '; + $o .= ''; + } else { + $o .= ''; + } + } + $o .= EOL . EOL; + $o .= ''; + $o .= '
      '; + + return $o; + } + + + public function disallowed_pconfig() + { + return array( + 'permissions_role' + ); + } +} diff --git a/Code/Module/Pdledit.php b/Code/Module/Pdledit.php new file mode 100644 index 000000000..efd0aa854 --- /dev/null +++ b/Code/Module/Pdledit.php @@ -0,0 +1,120 @@ + 2 && argv(2) === 'reset') { + del_pconfig(local_channel(), 'system', 'mod_' . argv(1) . '.pdl'); + goaway(z_root() . '/pdledit'); + } + + if (argc() > 1) { + $module = 'mod_' . argv(1) . '.pdl'; + } else { + $o .= '
      '; + $o .= '

      ' . t('Edit System Page Description') . '

      '; + + $edited = []; + + $r = q( + "select k from pconfig where uid = %d and cat = 'system' and k like '%s' ", + intval(local_channel()), + dbesc('mod_%.pdl') + ); + + if ($r) { + foreach ($r as $rv) { + $edited[] = substr(str_replace('.pdl', '', $rv['k']), 4); + } + } + + $files = glob('Code/Module/*.php'); + if ($files) { + foreach ($files as $f) { + $name = lcfirst(basename($f, '.php')); + $x = Theme::include('mod_' . $name . '.pdl'); + if ($x) { + $o .= '' . $name . '' . ((in_array($name, $edited)) ? ' ' . t('(modified)') . ' ' . t('Reset') . '' : '') . '
      '; + } + } + } + $addons = glob('addon/*/*.pdl'); + if ($addons) { + foreach ($addons as $a) { + $name = substr(basename($a, '.pdl'), 4); + $o .= '' . $name . '' . ((in_array($name, $edited)) ? ' ' . t('(modified)') . ' ' . t('Reset') . '' : '') . '
      '; + } + } + + $o .= '
      '; + + // list module pdl files + return $o; + } + + $t = get_pconfig(local_channel(), 'system', $module); + $s = @file_get_contents(Theme::include($module)); + if (!$s) { + $a = glob('addon/*/' . $module); + if ($a) { + $s = @file_get_contents($a[0]); + } + } + if (!$t) { + $t = $s; + } + if (!$t) { + notice(t('Layout not found.') . EOL); + return ''; + } + + $o = replace_macros(Theme::get_template('pdledit.tpl'), array( + '$header' => t('Edit System Page Description'), + '$mname' => t('Module Name:'), + '$help' => t('Layout Help'), + '$another' => t('Edit another layout'), + '$original' => t('System layout'), + '$module' => argv(1), + '$src' => $s, + '$content' => htmlspecialchars($t, ENT_COMPAT, 'UTF-8'), + '$submit' => t('Submit') + )); + + return $o; + } +} diff --git a/Code/Module/Permcat.php b/Code/Module/Permcat.php new file mode 100644 index 000000000..592b749bc --- /dev/null +++ b/Code/Module/Permcat.php @@ -0,0 +1,30 @@ + false ]); + } + + $abook_id = (argc() > 2) ? argv(2) : EMPTY_STR; + $permcat = new Zlib\Permcat(local_channel(), $abook_id); + + if (argc() > 1) { + // logger('fetched ' . argv(1) . ':' . print_r($permcat->fetch(argv(1)),true)); + json_return_and_die($permcat->fetch(argv(1))); + } + + json_return_and_die($permcat->listing()); + } +} diff --git a/Code/Module/Photo.php b/Code/Module/Photo.php new file mode 100644 index 000000000..32996d226 --- /dev/null +++ b/Code/Module/Photo.php @@ -0,0 +1,327 @@ + $resolution, 'channel_id' => $uid, 'default' => $default, 'data' => '', 'mimetype' => '']; + Hook::call('get_profile_photo', $d); + + $resolution = $d['imgscale']; + $uid = $d['channel_id']; + $default = $d['default']; + $data = $d['data']; + $mimetype = $d['mimetype']; + + if (!$data) { + $r = q( + "SELECT * FROM photo WHERE imgscale = %d AND uid = %d AND photo_usage = %d LIMIT 1", + intval($resolution), + intval($uid), + intval(PHOTO_PROFILE) + ); + if ($r) { + $data = dbunescbin($r[0]['content']); + $mimetype = $r[0]['mimetype']; + if (intval($r[0]['os_storage'])) { + $data = file_get_contents($data); + } + } + } + if (!$data) { + $data = fetch_image_from_url($default, $mimetype); + } + if (!$mimetype) { + $mimetype = 'image/png'; + } + } else { + $bear = Activity::token_from_request(); + if ($bear) { + logger('bear: ' . $bear, LOGGER_DEBUG); + } + + + /** + * Other photos + */ + + /* Check for a cookie to indicate display pixel density, in order to detect high-resolution + displays. This procedure was derived from the "Retina Images" by Jeremey Worboys, + used in accordance with the Creative Commons Attribution 3.0 Unported License. + Project link: https://github.com/Retina-Images/Retina-Images + License link: http://creativecommons.org/licenses/by/3.0/ + */ + + $cookie_value = false; + if (isset($_COOKIE['devicePixelRatio'])) { + $cookie_value = intval($_COOKIE['devicePixelRatio']); + } else { + // Force revalidation of cache on next request + $cache_directive = 'no-cache'; + $status = 'no cookie'; + } + + $resolution = 0; + + if (strpos($photo, '.') !== false) { + $photo = substr($photo, 0, strpos($photo, '.')); + } + + if (substr($photo, -2, 1) == '-') { + $resolution = intval(substr($photo, -1, 1)); + $photo = substr($photo, 0, -2); + // If viewing on a high-res screen, attempt to serve a higher resolution image: + if ($resolution == 2 && ($cookie_value > 1)) { + $resolution = 1; + } + } + + $r = q( + "SELECT uid, photo_usage FROM photo WHERE resource_id = '%s' AND imgscale = %d LIMIT 1", + dbesc($photo), + intval($resolution) + ); + if ($r) { + $allowed = (-1); + + if (intval($r[0]['photo_usage'])) { + $allowed = 1; + if (intval($r[0]['photo_usage']) === PHOTO_COVER) { + if ($resolution < PHOTO_RES_COVER_1200) { + $allowed = (-1); + } + } + if (intval($r[0]['photo_usage']) === PHOTO_PROFILE) { + if (!in_array($resolution, [4, 5, 6])) { + $allowed = (-1); + } + } + } + + if ($allowed === (-1)) { + $allowed = attach_can_view($r[0]['uid'], $observer_xchan, $photo, $bear); + } + + $channel = Channel::from_id($r[0]['uid']); + + // Now we'll see if we can access the photo + $e = q( + "SELECT * FROM photo WHERE resource_id = '%s' AND imgscale = %d $sql_extra LIMIT 1", + dbesc($photo), + intval($resolution) + ); + + $exists = (($e) ? true : false); + + if ($exists && $allowed) { + $data = dbunescbin($e[0]['content']); + $mimetype = $e[0]['mimetype']; + if (intval($e[0]['os_storage'])) { + $streaming = $data; + } + + if ($e[0]['allow_cid'] != '' || $e[0]['allow_gid'] != '' || $e[0]['deny_gid'] != '' || $e[0]['deny_gid'] != '') { + $prvcachecontrol = 'no-store, no-cache, must-revalidate'; + } + } else { + if (!$allowed) { + http_status_exit(403, 'forbidden'); + } + if (!$exists) { + http_status_exit(404, 'not found'); + } + } + } + } + + if (!isset($data)) { + if (isset($resolution)) { + switch ($resolution) { + case 4: + $data = fetch_image_from_url(z_root() . '/' . Channel::get_default_profile_photo(), $mimetype); + break; + case 5: + $data = fetch_image_from_url(z_root() . '/' . Channel::get_default_profile_photo(80), $mimetype); + break; + case 6: + $data = fetch_image_from_url(z_root() . '/' . Channel::get_default_profile_photo(48), $mimetype); + break; + default: + killme(); + } + } + } + + if (isset($res) && intval($res) && $res < 500) { + $ph = photo_factory($data, $mimetype); + if ($ph && $ph->is_valid()) { + $ph->scaleImageSquare($res); + $data = $ph->imageString(); + $mimetype = $ph->getType(); + } + } + + if (function_exists('header_remove')) { + header_remove('Pragma'); + header_remove('pragma'); + } + + header("Content-type: " . $mimetype); + + if ($prvcachecontrol) { + // it is a private photo that they have permission to view. + // tell the browser and infrastructure caches not to cache it, + // in case permissions change before the next access. + + header("Cache-Control: no-store, no-cache, must-revalidate"); + } else { + // The cache default for public photos is 1 day to provide a privacy trade-off, + // as somebody reducing photo permissions on a photo that is already + // "in the wild" won't be able to stop the photo from being viewed + // for this amount amount of time once it is cached. + // The privacy expectations of your site members and their perception + // of privacy where it affects the entire project may be affected. + // This has performance considerations but we highly recommend you + // leave it alone. + + $cache = get_config('system', 'photo_cache_time'); + if (!$cache) { + $cache = (3600 * 24); // 1 day + } + header("Expires: " . gmdate("D, d M Y H:i:s", time() + $cache) . " GMT"); + // Set browser cache age as $cache. But set timeout of 'shared caches' + // much lower in the event that infrastructure caching is present. + $smaxage = intval($cache / 12); + header('Cache-Control: s-maxage=' . $smaxage . '; max-age=' . $cache . ';'); + } + + // If it's a file resource, stream it. + + if ($streaming && $channel) { + if (strpos($streaming, 'store') !== false) { + $istream = fopen($streaming, 'rb'); + } else { + $istream = fopen('store/' . $channel['channel_address'] . '/' . $streaming, 'rb'); + } + $ostream = fopen('php://output', 'wb'); + if ($istream && $ostream) { + pipe_streams($istream, $ostream); + fclose($istream); + fclose($ostream); + } + } else { + echo $data; + } + killme(); + } +} diff --git a/Code/Module/Photomap.php b/Code/Module/Photomap.php new file mode 100644 index 000000000..ec2dc47f3 --- /dev/null +++ b/Code/Module/Photomap.php @@ -0,0 +1,25 @@ +' . $desc . ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Photomap'))) { + return $text; + } + + return $text . '

      ' . t('This app is currently installed.'); + } +} diff --git a/Code/Module/Photos.php b/Code/Module/Photos.php new file mode 100644 index 000000000..22fd0d0a5 --- /dev/null +++ b/Code/Module/Photos.php @@ -0,0 +1,1450 @@ + 1) { + $nick = escape_tags(argv(1)); + + Libprofile::load($nick); + + $channelx = Channel::from_username($nick); + + $profile_uid = 0; + + if ($channelx) { + App::$data['channel'] = $channelx; + head_set_icon($channelx['xchan_photo_s']); + $profile_uid = $channelx['channel_id']; + } + + App::$page['htmlhead'] .= ""; + App::$data['observer'] = App::get_observer(); + } + } + + + public function post() + { + + logger('mod-photos: photos_post: begin', LOGGER_DEBUG); + + logger('mod_photos: REQUEST ' . print_r($_REQUEST, true), LOGGER_DATA); + logger('mod_photos: FILES ' . print_r($_FILES, true), LOGGER_DATA); + + $ph = photo_factory(''); + + $phototypes = $ph->supportedTypes(); + + $page_owner_uid = App::$data['channel']['channel_id']; + + if (!perm_is_allowed($page_owner_uid, get_observer_hash(), 'write_storage')) { + notice(t('Permission denied.') . EOL); + killme_if_ajax(); + return; + } + + $acl = new AccessControl(App::$data['channel']); + + if ((argc() > 3) && (argv(2) === 'album')) { + $album = argv(3); + + if (!photos_album_exists($page_owner_uid, get_observer_hash(), $album)) { + notice(t('Album not found.') . EOL); + goaway(z_root() . '/' . $_SESSION['photo_return']); + } + + + /* + * DELETE photo album and all its photos + */ + + if ($_REQUEST['dropalbum'] === t('Delete Album')) { + $folder_hash = ''; + + $r = q( + "select hash from attach where is_dir = 1 and uid = %d and hash = '%s'", + intval($page_owner_uid), + dbesc($album) + ); + if (!$r) { + notice(t('Album not found.') . EOL); + return; + } + $folder_hash = $r[0]['hash']; + + + $res = []; + $admin_delete = false; + + // get the list of photos we are about to delete + + if (remote_channel() && (!local_channel())) { + $str = photos_album_get_db_idstr($page_owner_uid, $album, remote_channel()); + } elseif (local_channel()) { + $str = photos_album_get_db_idstr(local_channel(), $album); + } elseif (is_site_admin()) { + $str = photos_album_get_db_idstr_admin($page_owner_uid, $album); + $admin_delete = true; + } else { + $str = null; + } + + if (!$str) { + goaway(z_root() . '/' . $_SESSION['photo_return']); + } + + $r = q( + "select id from item where resource_id in ( $str ) and resource_type = 'photo' and uid = %d " . item_normal(), + intval($page_owner_uid) + ); + if ($r) { + foreach ($r as $rv) { + attach_delete($page_owner_uid, $rv['resource_id'], true); + } + } + + // remove the associated photos in case they weren't attached to an item (rare) + + q( + "delete from photo where resource_id in ( $str ) and uid = %d", + intval($page_owner_uid) + ); + + q( + "delete from attach where hash in ( $str ) and uid = %d", + intval($page_owner_uid) + ); + + if ($folder_hash) { + attach_delete($page_owner_uid, $folder_hash, true); + + // Sync this action to channel clones, UNLESS it was an admin delete action. + // The admin only has authority to moderate content on their own site. + + if (!$admin_delete) { + $sync = attach_export_data(App::$data['channel'], $folder_hash, true); + if ($sync) { + Libsync::build_sync_packet($page_owner_uid, ['file' => [$sync]]); + } + } + } + } + goaway(z_root() . '/photos/' . App::$data['channel']['channel_address']); + } + + if ((argc() > 2) && (x($_REQUEST, 'delete')) && ($_REQUEST['delete'] === t('Delete Photo'))) { + // same as above but remove single photo + + $ob_hash = get_observer_hash(); + + if (!$ob_hash) { + goaway(z_root() . '/' . $_SESSION['photo_return']); + } + + // query to verify ownership of the photo by this viewer + // We've already checked observer permissions to perfom this action + + // This implements the policy that remote channels (visitors and guests) + // which modify content can only modify their own content. + // The page owner can modify anything within their authority, including + // content published by others in their own channel pages. + // The site admin can of course modify anything on their own site for + // maintenance or legal compliance reasons. + + $r = q( + "SELECT id, resource_id FROM photo WHERE ( xchan = '%s' or uid = %d ) AND resource_id = '%s' LIMIT 1", + dbesc($ob_hash), + intval(local_channel()), + dbesc(argv(2)) + ); + + if ($r) { + attach_delete($page_owner_uid, $r[0]['resource_id'], true); + $sync = attach_export_data(App::$data['channel'], $r[0]['resource_id'], true); + if ($sync) { + Libsync::build_sync_packet($page_owner_uid, ['file' => [$sync]]); + } + } elseif (is_site_admin()) { + // If the admin deletes a photo, don't check ownership or invoke clone sync + attach_delete($page_owner_uid, argv(2), true); + } + + goaway(z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/album/' . $_SESSION['album_return']); + } + + // perform move_to_album + + if ((argc() > 2) && array_key_exists('move_to_album', $_POST)) { + $m = q( + "select folder from attach where hash = '%s' and uid = %d limit 1", + dbesc(argv(2)), + intval($page_owner_uid) + ); + + // we should sanitize the post variable, but probably pointless because the move + // will fail if we can't find the target + + if (($m) && ($m[0]['folder'] != $_POST['move_to_album'])) { + attach_move($page_owner_uid, argv(2), $_POST['move_to_album']); + + $sync = attach_export_data(App::$data['channel'], argv(2), false); + if ($sync) { + Libsync::build_sync_packet($page_owner_uid, ['file' => [$sync]]); + } + + // return if this is the only thing being edited + + if (!($_POST['desc'] && $_POST['newtag'])) { + goaway(z_root() . '/' . $_SESSION['photo_return']); + } + } + } + + // this still needs some work + + if (defined('FIXED')) { + if ((x($_POST, 'rotate') !== false) && ((intval($_POST['rotate']) == 1) || (intval($_POST['rotate']) == 2))) { + logger('rotate'); + + $resource_id = argv(2); + + $r = q( + "select * from photo where resource_id = '%s' and uid = %d and imgscale = 0 limit 1", + dbesc($resource_id), + intval($page_owner_uid) + ); + if ($r) { + $ph = photo_factory(@file_get_contents(dbunescbin($r[0]['content'])), $r[0]['mimetype']); + if ($ph && $ph->is_valid()) { + $rotate_deg = ((intval($_POST['rotate']) == 1) ? 270 : 90); + $ph->rotate($rotate_deg); + + $edited = datetime_convert(); + + q( + "update attach set filesize = %d, edited = '%s' where hash = '%s' and uid = %d", + strlen($ph->imageString()), + dbescdate($edited), + dbesc($resource_id), + intval($page_owner_uid) + ); + + $ph->saveImage(dbunescbin($r[0]['content'])); + + $arr = [ + 'aid' => get_account_id(), + 'uid' => intval($page_owner_uid), + 'resource_id' => dbesc($resource_id), + 'filename' => $r[0]['filename'], + 'imgscale' => 0, + 'album' => $r[0]['album'], + 'os_path' => $r[0]['os_path'], + 'os_storage' => 1, + 'os_syspath' => dbunescbin($r[0]['content']), + 'display_path' => $r[0]['display_path'], + 'photo_usage' => PHOTO_NORMAL, + 'edited' => dbescdate($edited) + ]; + + $ph->save($arr); + + unset($arr['os_syspath']); + + if ($width > 1024 || $height > 1024) { + $ph->scaleImage(1024); + } + $ph->storeThumbnail($arr, PHOTO_RES_1024); + + if ($width > 640 || $height > 640) { + $ph->scaleImage(640); + } + $ph->storeThumbnail($arr, PHOTO_RES_640); + + if ($width > 320 || $height > 320) { + $ph->scaleImage(320); + } + $ph->storeThumbnail($arr, PHOTO_RES_320); + } + } + } + } // end FIXED + + + // edit existing photo properties + + if (x($_POST, 'item_id') !== false && intval($_POST['item_id'])) { + $title = ((x($_POST, 'title')) ? escape_tags(trim($_POST['title'])) : EMPTY_STR); + $desc = ((x($_POST, 'desc')) ? escape_tags(trim($_POST['desc'])) : EMPTY_STR); + $body = ((x($_POST, 'body')) ? trim($_POST['body']) : EMPTY_STR); + + $item_id = ((x($_POST, 'item_id')) ? intval($_POST['item_id']) : 0); + $is_nsfw = ((x($_POST, 'adult')) ? intval($_POST['adult']) : 0); + + // convert any supplied posted permissions for storage + + $acl->set_from_array($_POST); + $perm = $acl->get(); + + $resource_id = argv(2); + + $p = q( + "SELECT mimetype, is_nsfw, filename, title, description, resource_id, imgscale, allow_cid, allow_gid, deny_cid, deny_gid FROM photo WHERE resource_id = '%s' AND uid = %d ORDER BY imgscale DESC", + dbesc($resource_id), + intval($page_owner_uid) + ); + if ($p) { + // update the photo structure with any of the changed elements which are common to all resolutions + $r = q( + "UPDATE photo SET title = '%s', description = '%s', allow_cid = '%s', allow_gid = '%s', deny_cid = '%s', deny_gid = '%s' WHERE resource_id = '%s' AND uid = %d", + dbesc($title), + dbesc($desc), + dbesc($perm['allow_cid']), + dbesc($perm['allow_gid']), + dbesc($perm['deny_cid']), + dbesc($perm['deny_gid']), + dbesc($resource_id), + intval($page_owner_uid) + ); + } + + $item_private = (($str_contact_allow || $str_group_allow || $str_contact_deny || $str_group_deny) ? true : false); + + $old_is_nsfw = $p[0]['is_nsfw']; + if ($old_is_nsfw != $is_nsfw) { + $r = q( + "update photo set is_nsfw = %d where resource_id = '%s' and uid = %d", + intval($is_nsfw), + dbesc($resource_id), + intval($page_owner_uid) + ); + } + + /* Don't make the item visible if the only change was the album name */ + + $visibility = 0; + if ($p[0]['description'] !== $desc || $p[0]['title'] !== $title || $body !== EMPTY_STR) { + $visibility = 1; + } + + $r = q( + "SELECT * FROM item WHERE id = %d AND uid = %d LIMIT 1", + intval($item_id), + intval($page_owner_uid) + ); + if (!$r) { + logger('linked photo item not found.'); + notice(t('linked item not found.') . EOL); + return; + } + + $linked_item = array_shift($r); + + // extract the original footer text + $footer_text = EMPTY_STR; + $orig_text = $linked_item['body']; + $matches = []; + + if (preg_match('/\[footer\](.*?)\[\/footer\]/ism', $orig_text, $matches)) { + $footer_text = $matches[0]; + } + + $body = cleanup_bbcode($body); + $tags = linkify_tags($body, $page_owner_uid); + + $post_tags = []; + if ($tags) { + foreach ($tags as $tag) { + $success = $tag['success']; + if ($success['replaced']) { + // suppress duplicate mentions/tags + $already_tagged = false; + foreach ($post_tags as $pt) { + if ($pt['term'] === $success['term'] && $pt['url'] === $success['url'] && intval($pt['ttype']) === intval($success['termtype'])) { + $already_tagged = true; + break; + } + } + if ($already_tagged) { + continue; + } + + $post_tags[] = [ + 'uid' => $page_owner_uid, + 'ttype' => $success['termtype'], + 'otype' => TERM_OBJ_POST, + 'term' => $success['term'], + 'url' => $success['url'] + ]; + } + } + } + if ($post_tags) { + q( + "delete from term where otype = 1 and oid = %d", + intval($linked_item['id']) + ); + foreach ($post_tags as $t) { + q( + "insert into term (uid,oid,otype,ttype,term,url) + values(%d,%d,%d,%d,'%s','%s') ", + intval($page_owner_uid), + intval($linked_item['id']), + intval(TERM_OBJ_POST), + intval($t['ttype']), + dbesc($t['term']), + dbesc($t['url']) + ); + } + } + + $body = z_input_filter($body, 'text/x-multicode'); + + $obj = EMPTY_STR; + + if (isset($linked_item['obj']) && strlen($linked_item['obj'])) { + $obj = json_decode($linked_item['obj'], true); + + $obj['name'] = (($title) ? $title : $p[0]['filename']); + $obj['summary'] = (($desc) ? $desc : $p[0]['filename']); + $obj['updated'] = datetime_convert('UTC', 'UTC', 'now', ATOM_TIME); + $obj['source'] = ['content' => $body, 'mediaType' => 'text/x-multicode']; + $obj['content'] = bbcode($body . $footer_text, ['export' => true]); + if (isset($obj['url']) && is_array($obj['url'])) { + for ($x = 0; $x < count($obj['url']); $x++) { + $obj['url'][$x]['summary'] = $obj['summary']; + } + } + $obj = json_encode($obj); + } + + // make sure the linked item has the same permissions as the photo regardless of any other changes + $x = q( + "update item set allow_cid = '%s', allow_gid = '%s', deny_cid = '%s', deny_gid = '%s', title = '%s', obj = '%s', body = '%s', edited = '%s', item_private = %d where id = %d", + dbesc($perm['allow_cid']), + dbesc($perm['allow_gid']), + dbesc($perm['deny_cid']), + dbesc($perm['deny_gid']), + dbesc(($desc) ? $desc : $p[0]['filename']), + dbesc($obj), + dbesc($body . $footer_text), + dbesc(datetime_convert()), + intval($acl->is_private()), + intval($item_id) + ); + + // make sure the attach has the same permissions as the photo regardless of any other changes + $x = q( + "update attach set allow_cid = '%s', allow_gid = '%s', deny_cid = '%s', deny_gid = '%s' where hash = '%s' and uid = %d and is_photo = 1", + dbesc($perm['allow_cid']), + dbesc($perm['allow_gid']), + dbesc($perm['deny_cid']), + dbesc($perm['deny_gid']), + dbesc($resource_id), + intval($page_owner_uid) + ); + + + if ($visibility) { + Run::Summon(['Notifier', 'edit_post', $item_id]); + } + + $sync = attach_export_data(App::$data['channel'], $resource_id); + + if ($sync) { + Libsync::build_sync_packet($page_owner_uid, ['file' => [$sync]]); + } + + goaway(z_root() . '/' . $_SESSION['photo_return']); + return; // NOTREACHED + } + + + /** + * default post action - upload a photo + */ + + $channel = App::$data['channel']; + $observer = App::$data['observer']; + + $_REQUEST['source'] = 'photos'; + require_once('include/attach.php'); + + if (!local_channel()) { + $_REQUEST['contact_allow'] = expand_acl($channel['channel_allow_cid']); + $_REQUEST['group_allow'] = expand_acl($channel['channel_allow_gid']); + $_REQUEST['contact_deny'] = expand_acl($channel['channel_deny_cid']); + $_REQUEST['group_deny'] = expand_acl($channel['channel_deny_gid']); + } + + + $matches = []; + $partial = false; + + if (array_key_exists('HTTP_CONTENT_RANGE', $_SERVER)) { + $pm = preg_match('/bytes (\d*)\-(\d*)\/(\d*)/', $_SERVER['HTTP_CONTENT_RANGE'], $matches); + if ($pm) { + logger('Content-Range: ' . print_r($matches, true)); + $partial = true; + } + } + + if ($partial) { + $x = save_chunk($channel, $matches[1], $matches[2], $matches[3]); + + if ($x['partial']) { + header('Range: bytes=0-' . (($x['length']) ? $x['length'] - 1 : 0)); + json_return_and_die($x); + } else { + header('Range: bytes=0-' . (($x['size']) ? $x['size'] - 1 : 0)); + + $_FILES['userfile'] = [ + 'name' => $x['name'], + 'type' => $x['type'], + 'tmp_name' => $x['tmp_name'], + 'error' => $x['error'], + 'size' => $x['size'] + ]; + } + } else { + if (!array_key_exists('userfile', $_FILES)) { + $_FILES['userfile'] = [ + 'name' => $_FILES['files']['name'], + 'type' => $_FILES['files']['type'], + 'tmp_name' => $_FILES['files']['tmp_name'], + 'error' => $_FILES['files']['error'], + 'size' => $_FILES['files']['size'] + ]; + } + } + + $r = attach_store($channel, get_observer_hash(), '', $_REQUEST); + + if (!$r['success']) { + notice($r['message'] . EOL); + if (is_ajax()) { + killme(); + } + goaway(z_root() . '/photos/' . App::$data['channel']['channel_address']); + } + if ($r['success'] && !intval($r['data']['is_photo'])) { + notice(sprintf(t('%s: Unsupported photo type. Saved as file.'), escape_tags($r['data']['filename']))); + } + if (is_ajax()) { + killme(); + } + + goaway(z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/album/' . $r['data']['folder']); + } + + + public function get() + { + + // URLs: + // photos/name + // photos/name/album/xxxxx (xxxxx is album name) + // photos/name/image/xxxxx + + + if (observer_prohibited()) { + notice(t('Public access denied.') . EOL); + return; + } + + $unsafe = 1 - get_safemode(); + + + if (!x(App::$data, 'channel')) { + notice(t('No photos selected') . EOL); + return; + } + + $ph = photo_factory(''); + $phototypes = $ph->supportedTypes(); + + $_SESSION['photo_return'] = App::$cmd; + + // + // Parse arguments + // + + $can_comment = perm_is_allowed(App::$profile['profile_uid'], get_observer_hash(), 'post_comments'); + + if (argc() > 3) { + $datatype = argv(2); + $datum = argv(3); + } else { + if (argc() > 2) { + $datatype = argv(2); + $datum = ''; + } else { + $datatype = 'summary'; + } + } + + if (argc() > 4) { + $cmd = argv(4); + } else { + $cmd = 'view'; + } + + // + // Setup permissions structures + // + + $can_post = false; + $visitor = 0; + + + $owner_uid = App::$data['channel']['channel_id']; + $owner_aid = App::$data['channel']['channel_account_id']; + + $observer = App::get_observer(); + + $can_post = perm_is_allowed($owner_uid, $observer['xchan_hash'], 'write_storage'); + $can_view = perm_is_allowed($owner_uid, $observer['xchan_hash'], 'view_storage'); + + if (!$can_view) { + notice(t('Access to this item is restricted.') . EOL); + return; + } + + $sql_item = item_permissions_sql($owner_uid, get_observer_hash()); + $sql_extra = permissions_sql($owner_uid, get_observer_hash(), 'photo'); + $sql_attach = permissions_sql($owner_uid, get_observer_hash(), 'attach'); + + Navbar::set_selected('Photos'); + + $o = ' + + '; + + + $o .= "\r\n"; + + $_is_owner = (local_channel() && (local_channel() == $owner_uid)); + + /** + * Display upload form + */ + + if ($can_post) { + $uploader = ''; + + $ret = array('post_url' => z_root() . '/photos/' . App::$data['channel']['channel_address'], + 'addon_text' => $uploader, + 'default_upload' => true); + + Hook::call('photo_upload_form', $ret); + + /* Show space usage */ + + $r = q( + "select sum(filesize) as total from photo where aid = %d and imgscale = 0 ", + intval(App::$data['channel']['channel_account_id']) + ); + + + $limit = engr_units_to_bytes(ServiceClass::fetch(App::$data['channel']['channel_id'], 'photo_upload_limit')); + if ($limit !== false) { + $usage_message = sprintf(t("%1$.2f MB of %2$.2f MB photo storage used."), $r[0]['total'] / 1024000, $limit / 1024000); + } else { + $usage_message = sprintf(t('%1$.2f MB photo storage used.'), $r[0]['total'] / 1024000); + } + + if ($_is_owner) { + $channel = App::get_channel(); + + $acl = new AccessControl($channel); + $channel_acl = $acl->get(); + + $lockstate = (($acl->is_private()) ? 'lock' : 'unlock'); + } + + $aclselect = (($_is_owner) ? Libacl::populate($channel_acl, false, PermissionDescription::fromGlobalPermission('view_storage')) : ''); + + // this is wrong but is to work around an issue with js_upload wherein it chokes if these variables + // don't exist. They really should be set to a parseable representation of the channel's default permissions + // which can be processed by getSelected() + + if (!$aclselect) { + $aclselect = ''; + } + + $selname = ''; + + if ($datum) { + $h = attach_by_hash_nodata($datum, get_observer_hash()); + $selname = $h['data']['display_path']; + } + + + $albums = ((array_key_exists('albums', App::$data)) ? App::$data['albums'] : photos_albums_list(App::$data['channel'], App::$data['observer'])); + + if (!$selname) { + $def_album = get_pconfig(App::$data['channel']['channel_id'], 'system', 'photo_path'); + if ($def_album) { + $selname = filepath_macro($def_album); + $albums['album'][] = array('text' => $selname); + } + } + + $tpl = Theme::get_template('photos_upload.tpl'); + $upload_form = replace_macros($tpl, array( + '$pagename' => t('Upload Photos'), + '$sessid' => session_id(), + '$usage' => $usage_message, + '$nickname' => App::$data['channel']['channel_address'], + '$newalbum_label' => t('Enter an album name'), + '$newalbum_placeholder' => t('or select an existing album (doubleclick)'), + '$visible' => array('visible', t('Create a status post for this upload'), 0, t('If multiple files are selected, the message will be repeated for each photo'), array(t('No'), t('Yes')), 'onclick="showHideBodyTextarea();"'), + '$caption' => array('description', t('Please briefly describe this photo for vision-impaired viewers')), + 'title' => ['title', t('Title (optional)')], + '$body' => array('body', t('Your message (optional)'), '', 'This will only appear in the status post'), + '$albums' => $albums['albums'], + '$selname' => $selname, + '$permissions' => t('Permissions'), + '$aclselect' => $aclselect, + '$allow_cid' => acl2json($channel_acl['allow_cid']), + '$allow_gid' => acl2json($channel_acl['allow_gid']), + '$deny_cid' => acl2json($channel_acl['deny_cid']), + '$deny_gid' => acl2json($channel_acl['deny_gid']), + '$lockstate' => $lockstate, + '$uploader' => $ret['addon_text'], + '$default' => (($ret['default_upload']) ? true : false), + '$uploadurl' => $ret['post_url'], + '$submit' => t('Upload') + + )); + } + + // + // dispatch request + // + + /* + * Display a single photo album + */ + + if ($datatype === 'album') { + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/json+oembed', + 'href' => z_root() . '/oep?f=&url=' . urlencode(z_root() . '/' . App::$query_string), + 'title' => 'oembed' + ]); + + if ($x = photos_album_exists($owner_uid, get_observer_hash(), $datum)) { + App::set_pager_itemspage(60); + $album = $x['display_path']; + } else { + goaway(z_root() . '/photos/' . App::$data['channel']['channel_address']); + } + + if ($_GET['order'] === 'posted') { + $order = 'created ASC'; + } elseif ($_GET['order'] === 'name') { + $order = 'filename ASC'; + } else { + $order = 'created DESC'; + } + + $r = q( + "SELECT p.resource_id, p.id, p.filename, p.mimetype, p.imgscale, p.description, p.created FROM photo p INNER JOIN + (SELECT resource_id, max(imgscale) imgscale FROM photo left join attach on folder = '%s' and photo.resource_id = attach.hash WHERE attach.uid = %d AND imgscale <= 4 AND photo_usage IN ( %d, %d, %d ) and is_nsfw = %d $sql_extra GROUP BY resource_id) ph + ON (p.resource_id = ph.resource_id AND p.imgscale = ph.imgscale) + ORDER BY $order LIMIT %d OFFSET %d", + dbesc($x['hash']), + intval($owner_uid), + intval(PHOTO_NORMAL), + intval(PHOTO_PROFILE), + intval(PHOTO_COVER), + intval($unsafe), + intval(App::$pager['itemspage']), + intval(App::$pager['start']) + ); + + // edit album name + $album_edit = null; + + if ($can_post) { + $album_e = $album; + $albums = ((array_key_exists('albums', App::$data)) ? App::$data['albums'] : photos_albums_list(App::$data['channel'], App::$data['observer'])); + + // @fixme - syncronise actions with DAV + + // $edit_tpl = Theme::get_template('album_edit.tpl'); + // $album_edit = replace_macros($edit_tpl,array( + // '$nametext' => t('Enter a new album name'), + // '$name_placeholder' => t('or select an existing one (doubleclick)'), + // '$nickname' => App::$data['channel']['channel_address'], + // '$album' => $album_e, + // '$albums' => $albums['albums'], + // '$hexalbum' => bin2hex($album), + // '$submit' => t('Submit'), + // '$dropsubmit' => t('Delete Album') + // )); + } + + $order = [ + [t('Date descending'), z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/album/' . $datum], + [t('Date ascending'), z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/album/' . $datum . '?f=&order=posted'], + [t('Name ascending'), z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/album/' . $datum . '?f=&order=name'] + ]; + + + $photos = []; + if (count($r)) { + $twist = 'rotright'; + foreach ($r as $rr) { + if ($twist == 'rotright') { + $twist = 'rotleft'; + } else { + $twist = 'rotright'; + } + + $ext = $phototypes[$rr['mimetype']]; + + $imgalt_e = $rr['filename']; + $desc_e = $rr['description']; + + $imagelink = (z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/image/' . $rr['resource_id'] + . (($_GET['order'] === 'posted') ? '?f=&order=posted' : '')); + + $photos[] = array( + 'id' => $rr['id'], + 'twist' => ' ' . $twist . rand(2, 4), + 'link' => $imagelink, + 'title' => t('View Photo'), + 'src' => z_root() . '/photo/' . $rr['resource_id'] . '-' . $rr['imgscale'] . '.' . $ext, + 'alt' => $imgalt_e, + 'desc' => $desc_e, + 'ext' => $ext, + 'hash' => $rr['resource_id'], + 'unknown' => t('Unknown') + ); + } + } + + if ($_REQUEST['aj']) { + if ($photos) { + $o = replace_macros(Theme::get_template('photosajax.tpl'), array( + '$photos' => $photos, + '$album_id' => $datum + )); + } else { + $o = '
      '; + } + echo $o; + killme(); + } else { + $o .= ""; + $tpl = Theme::get_template('photo_album.tpl'); + $o .= replace_macros($tpl, array( + '$photos' => $photos, + '$album' => $album, + '$album_id' => $datum, + '$file_view' => t('View files'), + '$files_path' => z_root() . '/cloud/' . App::$data['channel']['channel_address'] . '/' . $x['display_path'], + '$album_edit' => array(t('Edit Album'), $album_edit), + '$can_post' => $can_post, + '$upload' => array(t('Add Photos'), z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/upload/' . $datum), + '$order' => $order, + '$sort' => t('Sort'), + '$upload_form' => $upload_form, + '$usage' => $usage_message + )); + + return $o; + } + } + + /** + * Display one photo + */ + + if ($datatype === 'image') { + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/json+oembed', + 'href' => z_root() . '/oep?f=&url=' . urlencode(z_root() . '/' . App::$query_string), + 'title' => 'oembed' + ]); + + $x = q( + "select folder from attach where hash = '%s' and uid = %d $sql_attach limit 1", + dbesc($datum), + intval($owner_uid) + ); + + // fetch image, item containing image, then comments + + $ph = q( + "SELECT id,aid,uid,xchan,resource_id,created,edited,title,description,album,filename,mimetype,height,width,filesize,imgscale,photo_usage,is_nsfw,allow_cid,allow_gid,deny_cid,deny_gid FROM photo WHERE uid = %d AND resource_id = '%s' + $sql_extra ORDER BY imgscale ASC ", + intval($owner_uid), + dbesc($datum) + ); + + if (!($ph && $x)) { + /* Check again - this time without specifying permissions */ + + $ph = q( + "SELECT id FROM photo WHERE uid = %d AND resource_id = '%s' LIMIT 1", + intval($owner_uid), + dbesc($datum) + ); + if ($ph) { + notice(t('Permission denied. Access to this item may be restricted.') . EOL); + } else { + notice(t('Photo not available') . EOL); + } + return; + } + + + $prevlink = ''; + $nextlink = ''; + + if ($_GET['order'] === 'posted') { + $order = 'created ASC'; + } elseif ($_GET['order'] === 'name') { + $order = 'filename ASC'; + } else { + $order = 'created DESC'; + } + + + $prvnxt = q( + "SELECT hash FROM attach WHERE folder = '%s' AND uid = %d AND is_photo = 1 + $sql_attach ORDER BY $order ", + dbesc($x[0]['folder']), + intval($owner_uid) + ); + + if (count($prvnxt)) { + for ($z = 0; $z < count($prvnxt); $z++) { + if ($prvnxt[$z]['hash'] == $ph[0]['resource_id']) { + $prv = $z - 1; + $nxt = $z + 1; + if ($prv < 0) { + $prv = count($prvnxt) - 1; + } + if ($nxt >= count($prvnxt)) { + $nxt = 0; + } + break; + } + } + + $prevlink = z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/image/' . $prvnxt[$prv]['hash'] . (($_GET['order']) ? '?f=&order=' . $_GET['order'] : ''); + $nextlink = z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/image/' . $prvnxt[$nxt]['hash'] . (($_GET['order']) ? '?f=&order=' . $_GET['order'] : ''); + } + + + if (count($ph) == 1) { + $hires = $lores = $ph[0]; + } + if (count($ph) > 1) { + if ($ph[1]['imgscale'] == 2) { + // original is 640 or less, we can display it directly + $hires = $lores = $ph[0]; + } else { + $hires = $ph[0]; + $lores = $ph[1]; + } + } + + $album_link = z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/album/' . $x[0]['folder']; + $tools = null; + $lock = null; + + if ($can_post && ($ph[0]['uid'] == $owner_uid)) { + $tools = array( + 'profile' => array(z_root() . '/profile_photo/use/' . $ph[0]['resource_id'], t('Use as profile photo')), + 'cover' => array(z_root() . '/cover_photo/use/' . $ph[0]['resource_id'], t('Use as cover photo')), + ); + } + + // lockstate + $lockstate = (((strlen($ph[0]['allow_cid']) || strlen($ph[0]['allow_gid']) + || strlen($ph[0]['deny_cid']) || strlen($ph[0]['deny_gid']))) + ? array('lock', t('Private Photo')) + : array('unlock', null)); + + App::$page['htmlhead'] .= ''; + + if ($prevlink) { + $prevlink = array($prevlink, t('Previous')); + } + + $photo = array( + 'href' => z_root() . '/photo/' . $hires['resource_id'] . '-' . $hires['imgscale'] . '.' . $phototypes[$hires['mimetype']], + 'title' => t('View Full Size'), + 'src' => z_root() . '/photo/' . $lores['resource_id'] . '-' . $lores['imgscale'] . '.' . $phototypes[$lores['mimetype']] . '?f=&_u=' . datetime_convert('', '', '', 'ymdhis') + ); + + if ($nextlink) { + $nextlink = array($nextlink, t('Next')); + } + + + // Do we have an item for this photo? + + $linked_items = q( + "SELECT * FROM item WHERE resource_id = '%s' and resource_type = 'photo' and uid = %d + $sql_item LIMIT 1", + dbesc($datum), + intval($owner_uid) + ); + + $map = null; + $link_item = null; + + if ($linked_items) { + xchan_query($linked_items); + $linked_items = fetch_post_tags($linked_items, true); + + $link_item = $linked_items[0]; + $item_normal = item_normal(); + + $r = q( + "select * from item where parent_mid = '%s' + $item_normal and uid = %d $sql_item ", + dbesc($link_item['mid']), + intval($link_item['uid']) + ); + + if ($r) { + xchan_query($r); + $items = fetch_post_tags($r, true); + $sorted_items = conv_sort($items, 'commented'); + } + + $tags = []; + if ($link_item['term']) { + $cnt = 0; + foreach ($link_item['term'] as $t) { + $tags[$cnt] = array(0 => format_term_for_display($t)); + if ($can_post && ($ph[0]['uid'] == $owner_uid)) { + $tags[$cnt][1] = 'tagrm/drop/' . $link_item['id'] . '/' . bin2hex($t['term']); //?f=&item=' . $link_item['id']; + $tags[$cnt][2] = t('Remove'); + } + $cnt++; + } + } + + if ((local_channel()) && (local_channel() == $link_item['uid'])) { + q( + "UPDATE item SET item_unseen = 0 WHERE parent = %d and uid = %d and item_unseen = 1", + intval($link_item['parent']), + intval(local_channel()) + ); + } + + if ($link_item['coord'] && Apps::system_app_installed($owner_uid, 'Photomap')) { + $map = generate_map($link_item['coord']); + } + } + + // logger('mod_photo: link_item' . print_r($link_item,true)); + + // FIXME - remove this when we move to conversation module + + $comment_items = $sorted_items[0]['children']; + + $edit = null; + if ($can_post) { + $album_e = $ph[0]['album']; + $caption_e = $ph[0]['description']; + $aclselect_e = (($_is_owner) ? Libacl::populate($ph[0], true, PermissionDescription::fromGlobalPermission('view_storage')) : ''); + $albums = ((array_key_exists('albums', App::$data)) ? App::$data['albums'] : photos_albums_list(App::$data['channel'], App::$data['observer'])); + + $_SESSION['album_return'] = bin2hex($ph[0]['album']); + + $folder_list = attach_folder_select_list($ph[0]['uid']); + $edit_body = htmlspecialchars_decode(undo_post_tagging($link_item['body']), ENT_COMPAT); + // We will regenerate the body footer + $edit_body = preg_replace('/\[footer\](.*?)\[\/footer\]/ism', '', $edit_body); + + $edit = [ + 'edit' => t('Edit photo'), + 'id' => $link_item['id'], + 'albums' => $albums['albums'], + 'album' => $album_e, + 'album_select' => ['move_to_album', t('Move photo to album'), $x[0]['folder'], '', $folder_list], + 'newalbum_label' => t('Enter a new album name'), + 'newalbum_placeholder' => t('or select an existing one (doubleclick)'), + 'nickname' => App::$data['channel']['channel_address'], + 'resource_id' => $ph[0]['resource_id'], + 'desc' => ['desc', t('Please briefly describe this photo for vision-impaired viewers'), $ph[0]['description']], + 'title' => ['title', t('Title (optional)'), $ph[0]['title']], + 'body' => ['body', t('Your message (optional)'), $edit_body, t('This will only appear in the optional status post attached to this photo')], + 'tag_label' => t('Add a Tag'), + 'permissions' => t('Permissions'), + 'aclselect' => $aclselect_e, + 'allow_cid' => acl2json($ph[0]['allow_cid']), + 'allow_gid' => acl2json($ph[0]['allow_gid']), + 'deny_cid' => acl2json($ph[0]['deny_cid']), + 'deny_gid' => acl2json($ph[0]['deny_gid']), + 'lockstate' => $lockstate[0], + 'help_tags' => t('Example: @bob, @Barbara_Jensen, @jim@example.com'), + 'item_id' => ((count($linked_items)) ? $link_item['id'] : 0), + 'adult_enabled' => Features::enabled($owner_uid, 'adult_photo_flagging'), + 'adult' => array('adult', t('Flag as adult in album view'), intval($ph[0]['is_nsfw']), ''), + 'submit' => t('Submit'), + 'delete' => t('Delete Photo'), + 'expandform' => ((x($_GET, 'expandform')) ? true : false) + ]; + } + + if (count($linked_items)) { + $cmnt_tpl = Theme::get_template('comment_item.tpl'); + $tpl = Theme::get_template('photo_item.tpl'); + $return_url = App::$cmd; + + $like_tpl = Theme::get_template('like_noshare.tpl'); + + $likebuttons = ''; + $ilike = false; + $inolike = false; + + + if ($items) { + foreach ($items as $i) { + if ($i['verb'] === 'Like' && $i['author_xchan'] === get_observer_hash() && $i['thr_parent'] = $link_item['mid']) { + $ilike = true; + } + if ($i['verb'] === 'Dislike' && $i['author_xchan'] === get_observer_hash() && $i['thr_parent'] === $link_item['mid']) { + $inolike = true; + } + } + } + + if ($observer && ($can_post || $can_comment)) { + $likebuttons = [ + 'id' => $link_item['id'], + 'likethis' => t('I like this'), + 'ilike' => $ilike, + 'inolike' => $inolike, + 'nolike' => t('I don\'t like this'), + 'unlikethis' => t('Undo like'), + 'unnolike' => t('Undo dislike'), + 'share' => t('Share'), + 'wait' => t('Please wait') + ]; + } + + $comments = ''; + if (!$comment_items) { + if ($observer && ($can_post || $can_comment)) { + $commentbox = replace_macros($cmnt_tpl, array( + '$return_path' => '', + '$mode' => 'photos', + '$jsreload' => $return_url, + '$type' => 'wall-comment', + '$id' => $link_item['id'], + '$parent' => $link_item['id'], + '$profile_uid' => $owner_uid, + '$mylink' => $observer['xchan_url'], + '$mytitle' => t('This is you'), + '$myphoto' => $observer['xchan_photo_s'], + '$comment' => t('Comment'), + '$submit' => t('Submit'), + '$preview' => t('Preview'), + '$auto_save_draft' => 'true', + '$ww' => '', + '$feature_encrypt' => false + )); + } + } + + $alike = []; + $dlike = []; + + $like = ''; + $dislike = ''; + + $conv_responses = [ + 'like' => ['title' => t('Likes', 'title')], + 'dislike' => ['title' => t('Dislikes', 'title')], + 'attendyes' => ['title' => t('Attending', 'title')], + 'attendno' => ['title' => t('Not attending', 'title')], + 'attendmaybe' => ['title' => t('Might attend', 'title')] + ]; + + if ($r) { + foreach ($r as $item) { + builtin_activity_puller($item, $conv_responses); + } + + $like_count = ((x($alike, $link_item['mid'])) ? $alike[$link_item['mid']] : ''); + $like_list = ((x($alike, $link_item['mid'])) ? $alike[$link_item['mid'] . '-l'] : ''); + + if (is_array($like_list) && (count($like_list) > MAX_LIKERS)) { + $like_list_part = array_slice($like_list, 0, MAX_LIKERS); + array_push($like_list_part, '' . t('View all') . ''); + } else { + $like_list_part = ''; + } + $like_button_label = tt('Like', 'Likes', $like_count, 'noun'); + + $dislike_count = ((x($dlike, $link_item['mid'])) ? $dlike[$link_item['mid']] : ''); + $dislike_list = ((x($dlike, $link_item['mid'])) ? $dlike[$link_item['mid'] . '-l'] : ''); + $dislike_button_label = tt('Dislike', 'Dislikes', $dislike_count, 'noun'); + if (is_array($dislike_list) && (count($dislike_list) > MAX_LIKERS)) { + $dislike_list_part = array_slice($dislike_list, 0, MAX_LIKERS); + array_push($dislike_list_part, '' . t('View all') . ''); + } else { + $dislike_list_part = ''; + } + + + $like = ((isset($alike[$link_item['mid']])) ? format_like($alike[$link_item['mid']], $alike[$link_item['mid'] . '-l'], 'like', $link_item['mid']) : ''); + $dislike = ((isset($dlike[$link_item['mid']])) ? format_like($dlike[$link_item['mid']], $dlike[$link_item['mid'] . '-l'], 'dislike', $link_item['mid']) : ''); + + // display comments + + foreach ($comment_items as $item) { + $comment = ''; + $template = $tpl; + $sparkle = ''; + + if (!visible_activity($item)) { + continue; + } + + $profile_url = zid($item['author']['xchan_url']); + $profile_name = $item['author']['xchan_name']; + $profile_avatar = $item['author']['xchan_photo_m']; + + $profile_link = $profile_url; + + $drop = ''; + + if ($observer['xchan_hash'] === $item['author_xchan'] || $observer['xchan_hash'] === $item['owner_xchan']) { + $drop = replace_macros(Theme::get_template('photo_drop.tpl'), array('$id' => $item['id'], '$delete' => t('Delete'))); + } + + + $name_e = $profile_name; + $title_e = $item['title']; + unobscure($item); + $body_e = prepare_text($item['body'], $item['mimetype']); + + $comments .= replace_macros($template, array( + '$id' => $item['id'], + '$mode' => 'photos', + '$profile_url' => $profile_link, + '$name' => $name_e, + '$thumb' => $profile_avatar, + '$sparkle' => $sparkle, + '$title' => $title_e, + '$body' => $body_e, + '$ago' => relative_date($item['created']), + '$indent' => (($item['parent'] != $item['id']) ? ' comment' : ''), + '$drop' => $drop, + '$comment' => $comment + )); + } + + if ($observer && ($can_post || $can_comment)) { + $commentbox = replace_macros($cmnt_tpl, array( + '$return_path' => '', + '$jsreload' => $return_url, + '$type' => 'wall-comment', + '$id' => $link_item['id'], + '$parent' => $link_item['id'], + '$profile_uid' => $owner_uid, + '$mylink' => $observer['xchan_url'], + '$mytitle' => t('This is you'), + '$myphoto' => $observer['xchan_photo_s'], + '$comment' => t('Comment'), + '$submit' => t('Submit'), + '$ww' => '' + )); + } + } + $paginate = paginate($a); + } + + $album_e = [$album_link, $ph[0]['album']]; + $like_e = $like; + $dislike_e = $dislike; + + + $response_verbs = array('like', 'dislike'); + + $responses = get_responses($conv_responses, $response_verbs, '', $link_item); + + $o .= replace_macros(Theme::get_template('photo_view.tpl'), [ + '$id' => $ph[0]['id'], + '$album' => $album_e, + '$tools_label' => t('Photo Tools'), + '$tools' => $tools, + '$lock' => $lockstate[1], + '$photo' => $photo, + '$prevlink' => $prevlink, + '$nextlink' => $nextlink, + '$title' => $ph[0]['title'], + '$desc' => $ph[0]['description'], + '$filename' => $ph[0]['filename'], + '$unknown' => t('Unknown'), + '$tag_hdr' => t('In This Photo:'), + '$tags' => $tags, + 'responses' => $responses, + '$edit' => $edit, + '$map' => $map, + '$map_text' => t('Map'), + '$likebuttons' => $likebuttons, + '$like' => $like_e, + '$dislike' => $dislike_e, + '$like_count' => $like_count, + '$like_list' => $like_list, + '$like_list_part' => $like_list_part, + '$like_button_label' => $like_button_label, + '$like_modal_title' => t('Likes', 'noun'), + '$dislike_modal_title' => t('Dislikes', 'noun'), + '$dislike_count' => $dislike_count, + '$dislike_list' => $dislike_list, + '$dislike_list_part' => $dislike_list_part, + '$dislike_button_label' => $dislike_button_label, + '$modal_dismiss' => t('Close'), + '$comments' => $comments, + '$commentbox' => $commentbox, + '$paginate' => $paginate, + ]); + + App::$data['photo_html'] = $o; + return $o; + } + + // Default - show recent photos + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/json+oembed', + 'href' => z_root() . '/oep?f=&url=' . urlencode(z_root() . '/' . App::$query_string), + 'title' => 'oembed' + ]); + + App::set_pager_itemspage(60); + + $r = q( + "SELECT p.resource_id, p.id, p.filename, p.mimetype, p.album, p.imgscale, p.created, p.display_path FROM photo p + INNER JOIN ( SELECT resource_id, max(imgscale) imgscale FROM photo WHERE photo.uid = %d AND photo_usage IN ( %d, %d ) + AND is_nsfw = %d $sql_extra group by resource_id ) ph ON (p.resource_id = ph.resource_id and p.imgscale = ph.imgscale) + ORDER by p.created DESC LIMIT %d OFFSET %d", + intval(App::$data['channel']['channel_id']), + intval(PHOTO_NORMAL), + intval(PHOTO_PROFILE), + intval($unsafe), + intval(App::$pager['itemspage']), + intval(App::$pager['start']) + ); + + $photos = []; + if ($r) { + $twist = 'rotright'; + foreach ($r as $rr) { + if (!attach_can_view_folder(App::$data['channel']['channel_id'], get_observer_hash(), $rr['resource_id'])) { + continue; + } + + if ($twist == 'rotright') { + $twist = 'rotleft'; + } else { + $twist = 'rotright'; + } + $ext = $phototypes[$rr['mimetype']]; + + $alt_e = $rr['filename']; + $name_e = dirname($rr['display_path']); + + $photos[] = [ + 'id' => $rr['id'], + 'twist' => ' ' . $twist . rand(2, 4), + 'link' => z_root() . '/photos/' . App::$data['channel']['channel_address'] . '/image/' . $rr['resource_id'], + 'title' => t('View Photo'), + 'src' => z_root() . '/photo/' . $rr['resource_id'] . '-' . ((($rr['imgscale']) == 6) ? 4 : $rr['imgscale']) . '.' . $ext, + 'alt' => $alt_e, + 'album' => ['name' => $name_e], + ]; + } + } + + if ($_REQUEST['aj']) { + if ($photos) { + $o = replace_macros(Theme::get_template('photosajax.tpl'), [ + '$photos' => $photos, + '$album_id' => bin2hex(t('Recent Photos')) + ]); + } else { + $o = '
      '; + } + echo $o; + killme(); + } else { + $o .= ""; + + $o .= replace_macros(Theme::get_template('photos_recent.tpl'), [ + '$title' => t('Recent Photos'), + '$album_id' => bin2hex(t('Recent Photos')), + '$file_view' => t('View files'), + '$files_path' => z_root() . '/cloud/' . App::$data['channel']['channel_address'], + '$can_post' => $can_post, + '$upload' => t('Add Photos'), + '$photos' => $photos, + '$upload_form' => $upload_form, + '$usage' => $usage_message + ]); + + return $o; + } + } +} diff --git a/Code/Module/Pin.php b/Code/Module/Pin.php new file mode 100644 index 000000000..1d110bd5d --- /dev/null +++ b/Code/Module/Pin.php @@ -0,0 +1,68 @@ + '%s' + $seenstr + $item_normal + $sql_extra", + intval($sys['channel_id']), + dbesc($loadtime) + ); + + if ($pubs) { + foreach ($pubs as $p) { + if ($p['author_xchan'] === get_observer_hash()) { + $my_activity++; + } else { + $result['pubs']++; + } + } + } + } + + if ((argc() > 1) && (argv(1) === 'pubs') && ($notify_pubs)) { + $local_result = []; + + $r = q( + "SELECT * FROM item + WHERE uid = %d + AND author_xchan != '%s' + AND created > '%s' + $seenstr + $item_normal + $sql_extra + ORDER BY created DESC + LIMIT 300", + intval($sys['channel_id']), + dbesc(get_observer_hash()), + dbesc($loadtime) + ); + + if ($r) { + xchan_query($r); + foreach ($r as $rr) { + $rr['llink'] = str_replace('display/', 'pubstream/?f=&mid=', $rr['llink']); + $z = Enotify::format($rr); + if ($z) { + $local_result[] = $z; + } + } + } + + json_return_and_die(['notify' => $local_result]); + } + + if ((!local_channel()) || ($result['invalid'])) { + json_return_and_die($result); + } + + /** + * Everything following is only permitted under the context of a locally authenticated site member. + */ + + /** + * Handle "mark all xyz notifications read" requests. + */ + + // mark all items read + if (x($_REQUEST, 'markRead') && local_channel() && (!$_SESSION['sudo'])) { + switch ($_REQUEST['markRead']) { + case 'stream': + $r = q( + "UPDATE item SET item_unseen = 0 WHERE uid = %d AND item_unseen = 1", + intval(local_channel()) + ); + $_SESSION['loadtime_stream'] = datetime_convert(); + PConfig::Set(local_channel(), 'system', 'loadtime_stream', $_SESSION['loadtime_stream']); + $_SESSION['loadtime_channel'] = datetime_convert(); + PConfig::Set(local_channel(), 'system', 'loadtime_channel', $_SESSION['loadtime_channel']); + break; + case 'home': + $r = q( + "UPDATE item SET item_unseen = 0 WHERE uid = %d AND item_unseen = 1 AND item_wall = 1", + intval(local_channel()) + ); + $_SESSION['loadtime_channel'] = datetime_convert(); + PConfig::Set(local_channel(), 'system', 'loadtime_channel', $_SESSION['loadtime_channel']); + break; + case 'all_events': + $r = q( + "UPDATE event SET dismissed = 1 WHERE uid = %d AND dismissed = 0 AND dtstart < '%s' AND dtstart > '%s' ", + intval(local_channel()), + dbesc(datetime_convert('UTC', date_default_timezone_get(), 'now + ' . intval($evdays) . ' days')), + dbesc(datetime_convert('UTC', date_default_timezone_get(), 'now - 1 days')) + ); + break; + case 'notify': + $r = q( + "update notify set seen = 1 where uid = %d", + intval(local_channel()) + ); + break; + case 'pubs': + $_SESSION['loadtime_pubstream'] = datetime_convert(); + PConfig::Set(local_channel(), 'system', 'loadtime_pubstream', $_SESSION['loadtime_pubstream']); + break; + default: + break; + } + } + + if (x($_REQUEST, 'markItemRead') && local_channel() && (!$_SESSION['sudo'])) { + $r = q( + "UPDATE item SET item_unseen = 0 WHERE uid = %d AND parent = %d", + intval(local_channel()), + intval($_REQUEST['markItemRead']) + ); + $id = intval($_REQUEST['markItemRead']); + $seen = PConfig::Get(local_channel(), 'system', 'seen_items', []); + if (!in_array($id, $seen)) { + $seen[] = $id; + } + PConfig::Set(local_channel(), 'system', 'seen_items', $seen); + } + + /** + * URL ping/something will return detail for "something", e.g. a json list with which to populate a notification + * dropdown menu. + */ + + if (argc() > 1 && argv(1) === 'notify') { + $t = q( + "SELECT * FROM notify WHERE uid = %d AND seen = 0 ORDER BY CREATED DESC", + intval(local_channel()) + ); + + if ($t) { + foreach ($t as $tt) { + $message = trim(strip_tags(bbcode($tt['msg']))); + + if (strpos($message, $tt['xname']) === 0) { + $message = substr($message, strlen($tt['xname']) + 1); + } + + + $mid = basename($tt['link']); + $mid = unpack_link_id($mid); + + if (in_array($tt['verb'], [ACTIVITY_LIKE, ACTIVITY_DISLIKE])) { + // we need the thread parent + $r = q( + "select thr_parent from item where mid = '%s' and uid = %d limit 1", + dbesc($mid), + intval(local_channel()) + ); + + $b64mid = ((strpos($r[0]['thr_parent'], 'b64.') === 0) ? $r[0]['thr_parent'] : gen_link_id($r[0]['thr_parent'])); + } else { + $b64mid = ((strpos($mid, 'b64.') === 0) ? $mid : gen_link_id($mid)); + } + + $notifs[] = array( + 'notify_link' => z_root() . '/notify/view/' . $tt['id'], + 'name' => $tt['xname'], + 'url' => $tt['url'], + 'photo' => $tt['photo'], + 'when' => relative_date($tt['created']), + 'hclass' => (($tt['seen']) ? 'notify-seen' : 'notify-unseen'), + 'b64mid' => (($tt['otype'] == 'item') ? $b64mid : 'undefined'), + 'notify_id' => (($tt['otype'] == 'item') ? $tt['id'] : 'undefined'), + 'message' => $message + ); + } + } + + json_return_and_die(['notify' => $notifs]); + } + + if (argc() > 1 && (argv(1) === 'stream')) { + $local_result = []; + + $item_normal_moderate = $item_normal; + $loadtime = get_loadtime('stream'); + + $r = q( + "SELECT * FROM item + WHERE uid = %d + AND author_xchan != '%s' + AND edited > '%s' + $seenstr + $item_normal_moderate + $sql_extra + ORDER BY created DESC + LIMIT 300", + intval(local_channel()), + dbesc($ob_hash), + dbesc($loadtime) + ); + if ($r) { + xchan_query($r); + foreach ($r as $item) { + $z = Enotify::format($item); + + if ($z) { + $local_result[] = $z; + } + } + } + + json_return_and_die(['notify' => $local_result]); + } + + if (argc() > 1 && (argv(1) === 'home')) { + $local_result = []; + $item_normal_moderate = $item_normal; + + $sql_extra .= " and item_wall = 1 "; + $item_normal_moderate = item_normal_moderate(); + + $loadtime = get_loadtime('channel'); + + $r = q( + "SELECT * FROM item + WHERE uid = %d + AND author_xchan != '%s' + AND edited > '%s' + $seenstr + $item_normal_moderate + $sql_extra + ORDER BY created DESC + LIMIT 300", + intval(local_channel()), + dbesc($ob_hash), + dbesc($loadtime) + ); + if ($r) { + xchan_query($r); + foreach ($r as $item) { + $z = Enotify::format($item); + + if ($z) { + $local_result[] = $z; + } + } + } + + json_return_and_die(['notify' => $local_result]); + } + + + if (argc() > 1 && (argv(1) === 'intros')) { + $local_result = []; + + $r = q( + "SELECT * FROM abook left join xchan on abook.abook_xchan = xchan.xchan_hash where abook_channel = %d and abook_pending = 1 and abook_self = 0 and abook_ignored = 0 and xchan_deleted = 0 and xchan_orphan = 0 ORDER BY abook_created DESC LIMIT 50", + intval(local_channel()) + ); + + if ($r) { + foreach ($r as $rr) { + $local_result[] = [ + 'notify_link' => z_root() . '/connections/' . $rr['abook_id'], + 'name' => $rr['xchan_name'], + 'addr' => $rr['xchan_addr'], + 'url' => $rr['xchan_url'], + 'photo' => $rr['xchan_photo_s'], + 'when' => relative_date($rr['abook_created']), + 'hclass' => ('notify-unseen'), + 'message' => t('added your channel') + ]; + } + } + + json_return_and_die(['notify' => $local_result]); + } + + if ((argc() > 1 && (argv(1) === 'register')) && is_site_admin()) { + $result = []; + + $r = q( + "SELECT account_email, account_created from account where (account_flags & %d) > 0", + intval(ACCOUNT_PENDING) + ); + if ($r) { + foreach ($r as $rr) { + $result[] = array( + 'notify_link' => z_root() . '/admin/accounts', + 'name' => $rr['account_email'], + 'addr' => $rr['account_email'], + 'url' => '', + 'photo' => z_root() . '/' . Channel::get_default_profile_photo(48), + 'when' => relative_date($rr['account_created']), + 'hclass' => ('notify-unseen'), + 'message' => t('requires approval') + ); + } + } + + json_return_and_die(['notify' => $result]); + } + + if (argc() > 1 && (argv(1) === 'all_events')) { + $bd_format = t('g A l F d'); // 8 AM Friday January 18 + + $result = []; + + $r = q( + "SELECT * FROM event left join xchan on event_xchan = xchan_hash + WHERE event.uid = %d AND dtstart < '%s' AND dtstart > '%s' and dismissed = 0 + and etype in ( 'event', 'birthday' ) + ORDER BY dtstart DESC LIMIT 1000", + intval(local_channel()), + dbesc(datetime_convert('UTC', date_default_timezone_get(), 'now + ' . intval($evdays) . ' days')), + dbesc(datetime_convert('UTC', date_default_timezone_get(), 'now - 1 days')) + ); + + if ($r) { + foreach ($r as $rr) { + $strt = datetime_convert('UTC', (($rr['adjust']) ? date_default_timezone_get() : 'UTC'), $rr['dtstart']); + $today = ((substr($strt, 0, 10) === datetime_convert('UTC', date_default_timezone_get(), 'now', 'Y-m-d')) ? true : false); + $when = day_translate(datetime_convert('UTC', (($rr['adjust']) ? date_default_timezone_get() : 'UTC'), $rr['dtstart'], $bd_format)) . (($today) ? ' ' . t('[today]') : ''); + + $result[] = array( + 'notify_link' => z_root() . '/events', /// @FIXME this takes you to an edit page and it may not be yours, we really want to just view the single event --> '/events/event/' . $rr['event_hash'], + 'name' => $rr['xchan_name'], + 'addr' => $rr['xchan_addr'], + 'url' => $rr['xchan_url'], + 'photo' => $rr['xchan_photo_s'], + 'when' => $when, + 'hclass' => ('notify-unseen'), + 'message' => t('posted an event') + ); + } + } + + json_return_and_die(['notify' => $result]); + } + + if (argc() > 1 && (argv(1) === 'files')) { + $result = []; + + $r = q( + "SELECT item.created, xchan.xchan_name, xchan.xchan_addr, xchan.xchan_url, xchan.xchan_photo_s FROM item + LEFT JOIN xchan on author_xchan = xchan_hash + WHERE item.verb = '%s' + AND item.obj_type = '%s' + AND item.uid = %d + AND item.owner_xchan != '%s' + AND item.item_unseen = 1", + dbesc(ACTIVITY_POST), + dbesc(ACTIVITY_OBJ_FILE), + intval(local_channel()), + dbesc($ob_hash) + ); + if ($r) { + foreach ($r as $rr) { + $result[] = array( + 'notify_link' => z_root() . '/sharedwithme', + 'name' => $rr['xchan_name'], + 'addr' => $rr['xchan_addr'], + 'url' => $rr['xchan_url'], + 'photo' => $rr['xchan_photo_s'], + 'when' => relative_date($rr['created']), + 'hclass' => ('notify-unseen'), + 'message' => t('shared a file with you') + ); + } + } + + json_return_and_die(['notify' => $result]); + } + + if (argc() > 1 && (argv(1) === 'reports') && is_site_admin()) { + $local_result = []; + + $r = q( + "SELECT item.created, xchan.xchan_name, xchan.xchan_addr, xchan.xchan_url, xchan.xchan_photo_s FROM item + LEFT JOIN xchan on author_xchan = xchan_hash + WHERE item.type = '%s' AND item.item_unseen = 1", + dbesc(ITEM_TYPE_REPORT) + ); + + if ($r) { + foreach ($r as $rv) { + $result[] = [ + 'notify_link' => z_root() . '/reports', + 'name' => $rv['xchan_name'], + 'addr' => $rv['xchan_addr'], + 'url' => $rv['xchan_url'], + 'photo' => $rv['xchan_photo_s'], + 'when' => relative_date($rv['created']), + 'hclass' => ('notify-unseen'), + 'message' => t('reported content') + ]; + } + } + + json_return_and_die(['notify' => $result]); + } + + + /** + * Normal ping - just the counts, no detail + */ + + + if ($vnotify & VNOTIFY_SYSTEM) { + $t = q( + "select count(*) as total from notify where uid = %d and seen = 0", + intval(local_channel()) + ); + if ($t) { + $result['notify'] = intval($t[0]['total']); + } + } + + if ($vnotify & VNOTIFY_FILES) { + $files = q( + "SELECT count(id) as total FROM item + WHERE verb = '%s' + AND obj_type = '%s' + AND uid = %d + AND owner_xchan != '%s' + AND item_unseen = 1", + dbesc(ACTIVITY_POST), + dbesc(ACTIVITY_OBJ_FILE), + intval(local_channel()), + dbesc($ob_hash) + ); + if ($files) { + $result['files'] = intval($files[0]['total']); + } + } + + + if ($vnotify & VNOTIFY_NETWORK) { + $loadtime = get_loadtime('stream'); + $r = q( + "SELECT id, author_xchan FROM item + WHERE uid = %d and edited > '%s' + $seenstr + $item_normal + $sql_extra ", + intval(local_channel()), + dbesc($loadtime) + ); + + if ($r) { + $arr = array('items' => $r); + Hook::call('network_ping', $arr); + + foreach ($r as $it) { + if ($it['author_xchan'] === $ob_hash) { + $my_activity++; + } else { + $result['stream']++; + } + } + } + } + if (!($vnotify & VNOTIFY_NETWORK)) { + $result['stream'] = 0; + } + + if ($vnotify & VNOTIFY_CHANNEL) { + $loadtime = get_loadtime('channel'); + $r = q( + "SELECT id, author_xchan FROM item + WHERE item_wall = 1 and uid = %d and edited > '%s' + $seenstr + $item_normal + $sql_extra ", + intval(local_channel()), + dbesc($loadtime) + ); + + if ($r) { + foreach ($r as $it) { + if ($it['author_xchan'] === $ob_hash) { + $my_activity++; + } else { + $result['home']++; + } + } + } + } + if (!($vnotify & VNOTIFY_CHANNEL)) { + $result['home'] = 0; + } + + + if ($vnotify & VNOTIFY_INTRO) { + $intr = q( + "SELECT COUNT(abook.abook_id) AS total FROM abook left join xchan on abook.abook_xchan = xchan.xchan_hash where abook_channel = %d and abook_pending = 1 and abook_self = 0 and abook_ignored = 0 and xchan_deleted = 0 and xchan_orphan = 0 ", + intval(local_channel()) + ); + + if ($intr) { + $result['intros'] = intval($intr[0]['total']); + } + } + + + $channel = App::get_channel(); + + if ($vnotify & VNOTIFY_REGISTER) { + if (App::$config['system']['register_policy'] == REGISTER_APPROVE && is_site_admin()) { + $regs = q( + "SELECT count(account_id) as total from account where (account_flags & %d) > 0", + intval(ACCOUNT_PENDING) + ); + if ($regs) { + $result['register'] = intval($regs[0]['total']); + } + } + } + + if ($vnotify & VNOTIFY_REPORTS) { + if (is_site_admin()) { + $reps = q( + "SELECT count(id) as total from item where item_type = %d", + intval(ITEM_TYPE_REPORT) + ); + if ($reps) { + $result['reports'] = intval($reps[0]['total']); + } + } + } + + if ($vnotify & (VNOTIFY_EVENT | VNOTIFY_EVENTTODAY | VNOTIFY_BIRTHDAY)) { + $events = q( + "SELECT etype, dtstart, adjust FROM event + WHERE event.uid = %d AND dtstart < '%s' AND dtstart > '%s' and dismissed = 0 + and etype in ( 'event', 'birthday' ) + ORDER BY dtstart ASC ", + intval(local_channel()), + dbesc(datetime_convert('UTC', date_default_timezone_get(), 'now + ' . intval($evdays) . ' days')), + dbesc(datetime_convert('UTC', date_default_timezone_get(), 'now - 1 days')) + ); + + if ($events) { + $result['all_events'] = count($events); + + if ($result['all_events']) { + $str_now = datetime_convert('UTC', date_default_timezone_get(), 'now', 'Y-m-d'); + foreach ($events as $x) { + $bd = false; + if ($x['etype'] === 'birthday') { + $result['birthdays']++; + $bd = true; + } else { + $result['events']++; + } + if (datetime_convert('UTC', ((intval($x['adjust'])) ? date_default_timezone_get() : 'UTC'), $x['dtstart'], 'Y-m-d') === $str_now) { + $result['all_events_today']++; + if ($bd) { + $result['birthdays_today']++; + } else { + $result['events_today']++; + } + } + } + } + } + } + + if (!($vnotify & VNOTIFY_EVENT)) { + $result['all_events'] = $result['events'] = 0; + } + if (!($vnotify & VNOTIFY_EVENTTODAY)) { + $result['all_events_today'] = $result['events_today'] = 0; + } + if (!($vnotify & VNOTIFY_BIRTHDAY)) { + $result['birthdays'] = 0; + } + + + if ($vnotify & VNOTIFY_FORUMS) { + $forums = get_forum_channels(local_channel()); + + if ($forums) { + $perms_sql = item_permissions_sql(local_channel()) . item_normal(); + $fcount = count($forums); + $forums['total'] = 0; + + for ($x = 0; $x < $fcount; $x++) { + $ttype = TERM_FORUM; + $p = q("SELECT oid AS parent FROM term WHERE uid = " . intval(local_channel()) . " AND ttype = $ttype AND term = '" . protect_sprintf(dbesc($forums[$x]['xchan_name'])) . "'"); + + $p = ids_to_querystr($p, 'parent'); + $pquery = (($p) ? "OR parent IN ( $p )" : ''); + + $r = q( + "select sum(item_unseen) as unseen from item + where uid = %d and ( owner_xchan = '%s' $pquery ) and item_unseen = 1 $perms_sql ", + intval(local_channel()), + dbesc($forums[$x]['xchan_hash']) + ); + if ($r[0]['unseen']) { + $forums[$x]['notify_link'] = (($forums[$x]['private_forum']) ? $forums[$x]['xchan_url'] : z_root() . '/stream/?f=&pf=1&cid=' . $forums[$x]['abook_id']); + $forums[$x]['name'] = $forums[$x]['xchan_name']; + $forums[$x]['addr'] = $forums[$x]['xchan_addr']; + $forums[$x]['url'] = $forums[$x]['xchan_url']; + $forums[$x]['photo'] = $forums[$x]['xchan_photo_s']; + $forums[$x]['unseen'] = $r[0]['unseen']; + $forums[$x]['private_forum'] = (($forums[$x]['private_forum']) ? 'lock' : ''); + $forums[$x]['message'] = (($forums[$x]['private_forum']) ? t('Private group') : t('Public group')); + + $forums['total'] = $forums['total'] + $r[0]['unseen']; + + unset($forums[$x]['abook_id']); + unset($forums[$x]['xchan_hash']); + unset($forums[$x]['xchan_name']); + unset($forums[$x]['xchan_url']); + unset($forums[$x]['xchan_photo_s']); + } else { + unset($forums[$x]); + } + } + $result['forums'] = $forums['total']; + unset($forums['total']); + + $result['forums_sub'] = $forums; + } + } + + // Mark all of the stream notifications seen if all three of them are caught up. + // This also resets the pconfig storage for items_seen + + if ((!$my_activity) && (!(intval($result['home']) + intval($result['stream']) + intval($result['pubs'])))) { + PConfig::Delete(local_channel(), 'system', 'seen_items'); + + $_SESSION['loadtime_channel'] = datetime_convert(); + $_SESSION['loadtime_stream'] = datetime_convert(); + $_SESSION['loadtime_pubstream'] = datetime_convert(); + + PConfig::Set(local_channel(), 'system', 'loadtime_channel', $_SESSION['loadtime_channel']); + PConfig::Set(local_channel(), 'system', 'loadtime_stream', $_SESSION['loadtime_stream']); + PConfig::Set(local_channel(), 'system', 'loadtime_pubstream', $_SESSION['loadtime_pubstream']); + } + + json_return_and_die($result); + } +} diff --git a/Code/Module/Plike.php b/Code/Module/Plike.php new file mode 100644 index 000000000..e535f9133 --- /dev/null +++ b/Code/Module/Plike.php @@ -0,0 +1,312 @@ + 'Like', + 'dislike' => 'Dislike', + ]; + + // unlike (etc.) reactions are an undo of positive reactions, rather than a negative action. + // The activity is the same in undo actions and will have the same activity mapping + + if (substr($reaction, 0, 2) === 'un') { + $undo = true; + $reaction = substr($reaction, 2); + } + + if (array_key_exists($reaction, $acts)) { + return (($undo) ? 'Undo/' : EMPTY_STR) . $acts[$reaction]; + } + + return EMPTY_STR; + } + + + + public function get() + { + + $undo = false; + $object = $target = null; + $owner_uid = 0; + $post_type = EMPTY_STR; + $objtype = EMPTY_STR; + $allow_cid = $allow_gid = $deny_cid = $deny_gid = ''; + $output = EMPTY_STR; + + $sys_channel = Channel::get_system(); + $sys_channel_id = (($sys_channel) ? $sys_channel['channel_id'] : 0); + + $observer = App::get_observer(); + + $verb = ((array_key_exists('verb', $_GET)) ? notags(trim($_GET['verb'])) : EMPTY_STR); + + // Figure out what action we're performing + + $activity = $this->reaction_to_activity($verb); + + if (! $activity) { + return EMPTY_STR; + } + + // Check for negative (undo) condition + // eg: 'Undo/Like' results in $undo conditional and $activity set to 'Like' + + $test = explode('/', $activity); + if (count($test) > 1) { + $undo = true; + $activity = $test[1]; + } + + $is_rsvp = in_array($activity, [ 'Accept', 'Reject', 'TentativeAccept' ]); + + // Check for when target is something besides messages, where argv(1) is the type of thing + // and argv(2) is an identifier of things of that type + // We currently only recognise 'profile' but other types could be handled + + if (argc() == 3) { + if (! $observer) { + killme(); + } + + if ($obj_type == 'profile') { + $r = q( + "select * from profile where profile_guid = '%s' limit 1", + dbesc($obj_id) + ); + + if (! $r) { + killme(); + } + + $profile = array_shift($r); + + $owner_uid = $profile['uid']; + + $public = ((intval($profile['is_default'])) ? true : false); + + // if this is a private profile, select the destination recipients + + if (! $public) { + $d = q( + "select abook_xchan from abook where abook_profile = '%s' and abook_channel = %d", + dbesc($profile['profile_guid']), + intval($owner_uid) + ); + if (! $d) { + // No profile could be found. + killme(); + } + + // $d now contains a list of those who can see this profile. + // Set the access accordingly. + + foreach ($d as $dd) { + $allow_cid .= '<' . $dd['abook_xchan'] . '>'; + } + } + + $post_type = t('channel'); + $objtype = ACTIVITY_OBJ_PROFILE; + } + + // We'll need the owner of the thing from up above to figure out what channel is the target + + if (! ($owner_uid)) { + killme(); + } + + // Check permissions of the observer. If this is the owner (mostly this is the case) + // this will return true for all permissions. + + $perms = get_all_perms($owner_uid, $observer['xchan_hash']); + + if (! ($perms['post_like'] && $perms['view_profile'])) { + killme(); + } + + $channel = Channel::from_id($owner_uid); + + if (! $channel) { + killme(); + } + + $object = json_encode(Activity::fetch_profile([ 'id' => Channel::url($channel) ])); + + // second like of the same thing is "undo" for the first like + + $z = q( + "select * from likes where channel_id = %d and liker = '%s' and verb = '%s' and target_type = '%s' and target_id = '%s' limit 1", + intval($channel['channel_id']), + dbesc($observer['xchan_hash']), + dbesc($activity), + dbesc(($tgttype) ? $tgttype : $objtype), + dbesc($obj_id) + ); + + if ($z) { + $z[0]['deleted'] = 1; + Libsync::build_sync_packet($channel['channel_id'], array('likes' => $z)); + + q( + "delete from likes where id = %d", + intval($z[0]['id']) + ); + if ($z[0]['i_mid']) { + $r = q( + "select id from item where mid = '%s' and uid = %d limit 1", + dbesc($z[0]['i_mid']), + intval($channel['channel_id']) + ); + if ($r) { + drop_item($r[0]['id'], false); + } + } + killme(); + } + } + + $uuid = new_uuid(); + + $arr = []; + + $arr['uuid'] = $uuid; + $arr['mid'] = z_root() . (($is_rsvp) ? '/activity/' : '/item/' ) . $uuid; + + + $arr['item_thread_top'] = 1; + $arr['item_origin'] = 1; + $arr['item_wall'] = 1; + + + if ($verb === 'like') { + $bodyverb = t('%1$s likes %2$s\'s %3$s'); + } + if ($verb === 'dislike') { + $bodyverb = t('%1$s doesn\'t like %2$s\'s %3$s'); + } + + if (! isset($bodyverb)) { + killme(); + } + + + $ulink = '[zrl=' . $channel['xchan_url'] . ']' . $channel['xchan_name'] . '[/zrl]'; + $alink = '[zrl=' . $observer['xchan_url'] . ']' . $observer['xchan_name'] . '[/zrl]'; + $plink = '[zrl=' . z_root() . '/profile/' . $channel['channel_address'] . ']' . $post_type . '[/zrl]'; + $private = (($public) ? 0 : 1); + + + $arr['aid'] = $channel['channel_account_id']; + $arr['uid'] = $owner_uid; + + + $arr['item_flags'] = $item['item_flags']; + $arr['item_wall'] = $item['item_wall']; + $arr['parent_mid'] = $arr['mid']; + $arr['owner_xchan'] = $channel['xchan_hash']; + $arr['author_xchan'] = $observer['xchan_hash']; + + + $arr['body'] = sprintf($bodyverb, $alink, $ulink, $plink); + + if ($obj_type === 'profile') { + if ($public) { + $arr['body'] .= "\n\n" . '[embed]' . z_root() . '/profile/' . $channel['channel_address'] . '[/embed]'; + } else { + $arr['body'] .= "\n\n[zmg=80x80]" . $profile['thumb'] . '[/zmg]'; + } + } + + + $arr['verb'] = $activity; + $arr['obj_type'] = $objtype; + $arr['obj'] = $object; + + if ($target) { + $arr['tgt_type'] = $tgttype; + $arr['target'] = $target; + } + + $arr['allow_cid'] = $allow_cid; + $arr['allow_gid'] = $allow_gid; + $arr['deny_cid'] = $deny_cid; + $arr['deny_gid'] = $deny_gid; + $arr['item_private'] = $private; + + Hook::call('post_local', $arr); + + $post = item_store($arr); + $post_id = $post['item_id']; + + // save the conversation from expiration + + if (local_channel() && array_key_exists('item', $post) && (intval($post['item']['id']) != intval($post['item']['parent']))) { + retain_item($post['item']['parent']); + } + + $arr['id'] = $post_id; + + Hook::call('post_local_end', $arr); + + $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($channel['channel_id'], [ 'item' => [ encode_item($sync_item[0], true) ] ]); + } + + $r = q( + "insert into likes (channel_id,liker,likee,iid,i_mid,verb,target_type,target_id,target) values (%d,'%s','%s',%d,'%s','%s','%s','%s','%s')", + intval($channel['channel_id']), + dbesc($observer['xchan_hash']), + dbesc($channel['channel_hash']), + intval($post_id), + dbesc($mid), + dbesc($activity), + dbesc(($tgttype) ? $tgttype : $objtype), + dbesc($obj_id), + dbesc(($target) ? $target : $object) + ); + $r = q( + "select * from likes where liker = '%s' and likee = '%s' and i_mid = '%s' and verb = '%s' and target_type = '%s' and target_id = '%s' ", + dbesc($observer['xchan_hash']), + dbesc($channel['channel_hash']), + dbesc($mid), + dbesc($activity), + dbesc(($tgttype) ? $tgttype : $objtype), + dbesc($obj_id) + ); + if ($r) { + Libsync::build_sync_packet($channel['channel_id'], array('likes' => $r)); + } + + Run::Summon([ 'Notifier', 'like', $post_id ]); + + killme(); + } +} diff --git a/Code/Module/Poco.php b/Code/Module/Poco.php new file mode 100644 index 000000000..0bcf89fb6 --- /dev/null +++ b/Code/Module/Poco.php @@ -0,0 +1,19 @@ +'; + $allow_gid = EMPTY_STR; + $deny_cid = EMPTY_STR; + $deny_gid = EMPTY_STR; + } + + $arr = []; + + $arr['item_wall'] = 1; + $arr['owner_xchan'] = $channel['channel_hash']; + $arr['author_xchan'] = $channel['channel_hash']; + $arr['allow_cid'] = $allow_cid; + $arr['allow_gid'] = $allow_gid; + $arr['deny_cid'] = $deny_cid; + $arr['deny_gid'] = $deny_gid; + $arr['verb'] = 'Create'; + $arr['item_private'] = 1; + $arr['obj_type'] = 'Note'; + $arr['body'] = '[zrl=' . $channel['xchan_url'] . ']' . $channel['xchan_name'] . '[/zrl]' . ' ' . $verbs[$verb][2] . ' ' . '[zrl=' . $target['xchan_url'] . ']' . $target['xchan_name'] . '[/zrl]'; + + + $arr['item_origin'] = 1; + $arr['item_wall'] = 1; + + $obj = Activity::encode_item($arr, ((get_config('system', 'activitypub', ACTIVITYPUB_ENABLED)) ? true : false)); + + $i = post_activity_item($arr); + + if ($i['success']) { + $item_id = $i['item_id']; + $r = q( + "select * from item where id = %d", + intval($item_id) + ); + if ($r) { + xchan_query($r); + $sync_item = fetch_post_tags($r); + Libsync::build_sync_packet($uid, ['item' => [encode_item($sync_item[0], true)]]); + } + + info(sprintf(t('You %1$s %2$s'), $verbs[$verb][2], $target['xchan_name'])); + } + + json_return_and_die(['success' => true]); + } + + + public function get() + { + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + if (!Apps::system_app_installed(local_channel(), 'Poke')) { + $o = '' . t('Poke App (Not Installed)') . '
      '; + $o .= t('Poke or do something else to somebody'); + return $o; + } + + Navbar::set_selected('Poke'); + + $name = ''; + $id = ''; + + $verbs = get_poke_verbs(); + + $shortlist = []; + $current = get_pconfig(local_channel(), 'system', 'pokeverb', 'poke'); + foreach ($verbs as $k => $v) { + $shortlist[] = [$k, $v[1], (($k === $current) ? true : false)]; + } + + + $title = t('Poke'); + $desc = t('Poke, prod or do other things to somebody'); + + $o = replace_macros(Theme::get_template('poke_content.tpl'), array( + '$title' => $title, + '$desc' => $desc, + '$clabel' => t('Recipient'), + '$choice' => t('Choose your default action'), + '$verbs' => $shortlist, + '$submit' => t('Submit'), + '$id' => $id + )); + + return $o; + } +} diff --git a/Code/Module/Poster.php b/Code/Module/Poster.php new file mode 100644 index 000000000..54bc13d13 --- /dev/null +++ b/Code/Module/Poster.php @@ -0,0 +1,46 @@ + get_theme_screenshot($theme), 'desc' => $desc, 'version' => $version, 'credits' => $credits)); + } + killme(); + } +} diff --git a/Code/Module/Profile.php b/Code/Module/Profile.php new file mode 100644 index 000000000..798d8aedd --- /dev/null +++ b/Code/Module/Profile.php @@ -0,0 +1,140 @@ + 1) { + $which = argv(1); + } else { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + + Navbar::set_selected('Profile'); + + $profile = ''; + $channel = App::get_channel(); + + if ((local_channel()) && (argc() > 2) && (argv(2) === 'view')) { + $which = $channel['channel_address']; + $profile = argv(1); + $r = q( + "select profile_guid from profile where id = %d and uid = %d limit 1", + intval($profile), + intval(local_channel()) + ); + if (!$r) { + $profile = ''; + } + $profile = $r[0]['profile_guid']; + } + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/atom+xml', + 'title' => t('Posts and comments'), + 'href' => z_root() . '/feed/' . $which + ]); + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/atom+xml', + 'title' => t('Only posts'), + 'href' => z_root() . '/feed/' . $which . '?f=&top=1' + ]); + + + if (!$profile) { + $x = q( + "select channel_id as profile_uid from channel where channel_address = '%s' limit 1", + dbesc(argv(1)) + ); + if ($x) { + App::$profile = $x[0]; + } + } + + + if (ActivityStreams::is_as_request()) { + $chan = Channel::from_username(argv(1)); + if (!$chan) { + http_status_exit(404, 'Not found'); + } + $p = Activity::encode_person($chan, true, ((get_config('system', 'activitypub', ACTIVITYPUB_ENABLED)) ? true : false)); + if (!$p) { + http_status_exit(404, 'Not found'); + } + + as_return_and_die(['type' => 'Profile', 'describes' => $p], $chan); + } + + Libprofile::load($which, $profile); + } + + public function get() + { + + if (observer_prohibited(true)) { + return login(); + } + + $groups = []; + + + $tab = 'profile'; + $o = ''; + + if (!(perm_is_allowed(App::$profile['profile_uid'], get_observer_hash(), 'view_profile'))) { + notice(t('Permission denied.') . EOL); + return; + } + + + if (argc() > 2 && argv(2) === 'vcard') { + header('Content-type: text/vcard'); + header('Content-Disposition: attachment; filename="' . t('vcard') . '-' . $profile['channel_address'] . '.vcf"'); + echo App::$profile['profile_vcard']; + killme(); + } + + $is_owner = ((local_channel()) && (local_channel() == App::$profile['profile_uid']) ? true : false); + + if (App::$profile['hidewall'] && (!$is_owner) && (!remote_channel())) { + notice(t('Permission denied.') . EOL); + return; + } + + Head::add_link([ + 'rel' => 'alternate', + 'type' => 'application/json+oembed', + 'href' => z_root() . '/oep?f=&url=' . urlencode(z_root() . '/' . App::$query_string), + 'title' => 'oembed' + ]); + + $o .= Libprofile::advanced(); + Hook::call('profile_advanced', $o); + return $o; + } +} diff --git a/Code/Module/Profile_photo.php b/Code/Module/Profile_photo.php new file mode 100644 index 000000000..19d7dd662 --- /dev/null +++ b/Code/Module/Profile_photo.php @@ -0,0 +1,578 @@ +is_valid()) { + $im->cropImage(300, $srcX, $srcY, $srcW, $srcH); + + $aid = get_account_id(); + + $p = [ + 'aid' => $aid, + 'uid' => local_channel(), + 'resource_id' => $base_image['resource_id'], + 'filename' => $base_image['filename'], + 'album' => t('Profile Photos'), + 'os_path' => $base_image['os_path'], + 'display_path' => $base_image['display_path'], + 'created' => $base_image['created'], + 'edited' => $base_image['edited'] + ]; + + $animated = get_config('system', 'animated_avatars', true); + + $p['imgscale'] = PHOTO_RES_PROFILE_300; + $p['photo_usage'] = (($is_default_profile) ? PHOTO_PROFILE : PHOTO_NORMAL); + + $r1 = $im->storeThumbnail($p, PHOTO_RES_PROFILE_300, $animated); + + $im->scaleImage(80); + $p['imgscale'] = PHOTO_RES_PROFILE_80; + + $r2 = $im->storeThumbnail($p, PHOTO_RES_PROFILE_80, $animated); + + $im->scaleImage(48); + $p['imgscale'] = PHOTO_RES_PROFILE_48; + + $r3 = $im->storeThumbnail($p, PHOTO_RES_PROFILE_48, $animated); + + if ($r1 === false || $r2 === false || $r3 === false) { + // if one failed, delete them all so we can start over. + notice(t('Image resize failed.') . EOL); + $x = q( + "delete from photo where resource_id = '%s' and uid = %d and imgscale in ( %d, %d, %d ) ", + dbesc($base_image['resource_id']), + local_channel(), + intval(PHOTO_RES_PROFILE_300), + intval(PHOTO_RES_PROFILE_80), + intval(PHOTO_RES_PROFILE_48) + ); + return; + } + + $channel = App::get_channel(); + + // If setting for the default profile, unset the profile photo flag from any other photos I own + + if ($is_default_profile) { + $r = q( + "update profile set photo = '%s', thumb = '%s' where is_default = 1 and uid = %d", + dbesc(z_root() . '/photo/profile/l/' . local_channel()), + dbesc(z_root() . '/photo/profile/m/' . local_channel()), + intval(local_channel()) + ); + + + $r = q( + "UPDATE photo SET photo_usage = %d WHERE photo_usage = %d + AND resource_id != '%s' AND uid = %d", + intval(PHOTO_NORMAL), + intval(PHOTO_PROFILE), + dbesc($base_image['resource_id']), + intval(local_channel()) + ); + + + send_profile_photo_activity($channel, $base_image, $profile); + } else { + $r = q( + "update profile set photo = '%s', thumb = '%s' where id = %d and uid = %d", + dbesc(z_root() . '/photo/' . $base_image['resource_id'] . '-4'), + dbesc(z_root() . '/photo/' . $base_image['resource_id'] . '-5'), + intval($_REQUEST['profile']), + intval(local_channel()) + ); + } + + // set $send to false in profiles_build_sync() to return the data + // so that we only send one sync packet. + + $sync_profiles = Channel::profiles_build_sync(local_channel(), false); + + // We'll set the updated profile-photo timestamp even if it isn't the default profile, + // so that browsers will do a cache update unconditionally + // Also set links back to site-specific profile photo url in case it was + // changed to a generic URL by a clone operation. Otherwise the new photo may + // not get pushed to other sites correctly. + + $r = q( + "UPDATE xchan set xchan_photo_mimetype = '%s', xchan_photo_date = '%s', xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s' + where xchan_hash = '%s'", + dbesc($im->getType()), + dbesc(datetime_convert()), + dbesc(z_root() . '/photo/profile/l/' . $channel['channel_id']), + dbesc(z_root() . '/photo/profile/m/' . $channel['channel_id']), + dbesc(z_root() . '/photo/profile/s/' . $channel['channel_id']), + dbesc($channel['xchan_hash']) + ); + + photo_profile_setperms(local_channel(), $base_image['resource_id'], $_REQUEST['profile']); + + $sync = attach_export_data($channel, $base_image['resource_id']); + if ($sync) { + Libsync::build_sync_packet($channel['channel_id'], array('file' => array($sync), 'profile' => $sync_profiles)); + } + + // Similarly, tell the nav bar to bypass the cache and update the avatar image. + $_SESSION['reload_avatar'] = true; + + info(t('Shift-reload the page or clear browser cache if the new photo does not display immediately.') . EOL); + + // Update directory in background + Run::Summon(['Directory', $channel['channel_id']]); + } else { + notice(t('Unable to process image') . EOL); + } + } + + goaway(z_root() . '/profiles'); + } + + // A new photo was uploaded. Store it and save some important details + // in App::$data for use in the cropping function + + + $hash = photo_new_resource(); + $importing = false; + $smallest = 0; + + + if ($_REQUEST['importfile']) { + $hash = $_REQUEST['importfile']; + $importing = true; + } else { + $matches = []; + $partial = false; + + if (array_key_exists('HTTP_CONTENT_RANGE', $_SERVER)) { + $pm = preg_match('/bytes (\d*)\-(\d*)\/(\d*)/', $_SERVER['HTTP_CONTENT_RANGE'], $matches); + if ($pm) { + logger('Content-Range: ' . print_r($matches, true), LOGGER_DEBUG); + $partial = true; + } + } + + if ($partial) { + $x = save_chunk($channel, $matches[1], $matches[2], $matches[3]); + + if ($x['partial']) { + header('Range: bytes=0-' . (($x['length']) ? $x['length'] - 1 : 0)); + json_return_and_die($x); + } else { + header('Range: bytes=0-' . (($x['size']) ? $x['size'] - 1 : 0)); + + $_FILES['userfile'] = [ + 'name' => $x['name'], + 'type' => $x['type'], + 'tmp_name' => $x['tmp_name'], + 'error' => $x['error'], + 'size' => $x['size'] + ]; + } + } else { + if (!array_key_exists('userfile', $_FILES)) { + $_FILES['userfile'] = [ + 'name' => $_FILES['files']['name'], + 'type' => $_FILES['files']['type'], + 'tmp_name' => $_FILES['files']['tmp_name'], + 'error' => $_FILES['files']['error'], + 'size' => $_FILES['files']['size'] + ]; + } + } + + $res = attach_store(App::get_channel(), get_observer_hash(), '', [ 'album' => t('Profile Photos'), 'hash' => $hash, 'source' => 'photos' ]); + + logger('attach_store: ' . print_r($res, true), LOGGER_DEBUG); + + json_return_and_die(['message' => $hash]); + } + + if (($res && intval($res['data']['is_photo'])) || $importing) { + $i = q( + "select * from photo where resource_id = '%s' and uid = %d order by imgscale", + dbesc($hash), + intval(local_channel()) + ); + + if (!$i) { + notice(t('Image upload failed.') . EOL); + return; + } + $os_storage = false; + + foreach ($i as $ii) { + if (intval($ii['imgscale']) < PHOTO_RES_640) { + $smallest = intval($ii['imgscale']); + $os_storage = intval($ii['os_storage']); + $imagedata = $ii['content']; + $filetype = $ii['mimetype']; + } + } + } + + $imagedata = (($os_storage) ? @file_get_contents(dbunescbin($imagedata)) : dbunescbin($imagedata)); + $ph = photo_factory($imagedata, $filetype); + + if (!$ph->is_valid()) { + notice(t('Unable to process image.') . EOL); + return; + } + + return $this->profile_photo_crop_ui_head($ph, $hash, $smallest); + + // This will "fall through" to the get() method, and since + // App::$data['imagecrop'] is set, it will proceed to cropping + // rather than present the upload form + } + + + /* @brief Generate content of profile-photo view + * + * @return void + * + */ + + + public function get() + { + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + $channel = App::get_channel(); + $pf = 0; + $newuser = false; + + if (argc() == 2 && argv(1) === 'new') { + $newuser = true; + } + + if (argv(1) === 'use') { + if (argc() < 3) { + notice(t('Permission denied.') . EOL); + return; + } + + $resource_id = argv(2); + + $pf = (($_REQUEST['pf']) ? intval($_REQUEST['pf']) : 0); + + $c = q( + "select id, is_default from profile where uid = %d", + intval(local_channel()) + ); + + $multi_profiles = true; + + if (($c) && (count($c) === 1) && (intval($c[0]['is_default']))) { + $_REQUEST['profile'] = $c[0]['id']; + $multi_profiles = false; + } else { + $_REQUEST['profile'] = $pf; + } + + $r = q( + "SELECT id, album, imgscale FROM photo WHERE uid = %d AND resource_id = '%s' ORDER BY imgscale ASC", + intval(local_channel()), + dbesc($resource_id) + ); + if (!$r) { + notice(t('Photo not available.') . EOL); + return; + } + $havescale = false; + foreach ($r as $rr) { + if ($rr['imgscale'] == PHOTO_RES_PROFILE_80) { + $havescale = true; + } + } + + // set an already loaded and cropped photo as profile photo + + if ($havescale) { + // unset any existing profile photos + $r = q( + "UPDATE photo SET photo_usage = %d WHERE photo_usage = %d AND uid = %d", + intval(PHOTO_NORMAL), + intval(PHOTO_PROFILE), + intval(local_channel()) + ); + + $r = q( + "UPDATE photo SET photo_usage = %d WHERE uid = %d AND resource_id = '%s'", + intval(PHOTO_PROFILE), + intval(local_channel()), + dbesc($resource_id) + ); + + $r = q( + "UPDATE xchan set xchan_photo_date = '%s' where xchan_hash = '%s'", + dbesc(datetime_convert()), + dbesc($channel['xchan_hash']) + ); + + photo_profile_setperms(local_channel(), $resource_id, $_REQUEST['profile']); + + $sync = attach_export_data($channel, $resource_id); + if ($sync) { + Libsync::build_sync_packet($channel['channel_id'], array('file' => array($sync))); + } + + Run::Summon(['Directory', local_channel()]); + goaway(z_root() . '/profiles'); + } + + $r = q( + "SELECT content, mimetype, resource_id, os_storage FROM photo WHERE id = %d and uid = %d limit 1", + intval($r[0]['id']), + intval(local_channel()) + ); + if (!$r) { + notice(t('Photo not available.') . EOL); + return; + } + + if (intval($r[0]['os_storage'])) { + $data = @file_get_contents(dbunescbin($r[0]['content'])); + } else { + $data = dbunescbin($r[0]['content']); + } + + $ph = photo_factory($data, $r[0]['mimetype']); + $smallest = 0; + if ($ph->is_valid()) { + // go ahead as if we have just uploaded a new photo to crop + $i = q( + "select resource_id, imgscale from photo where resource_id = '%s' and uid = %d order by imgscale", + dbesc($r[0]['resource_id']), + intval(local_channel()) + ); + + if ($i) { + $hash = $i[0]['resource_id']; + foreach ($i as $ii) { + if (intval($ii['imgscale']) < PHOTO_RES_640) { + $smallest = intval($ii['imgscale']); + } + } + } + } + + if ($multi_profiles) { + App::$data['importfile'] = $resource_id; + } else { + $this->profile_photo_crop_ui_head($ph, $hash, $smallest); + } + + // falls through with App::$data['imagecrop'] set so we go straight to the cropping section + } + + // present an upload form + + $profiles = q( + "select id, profile_name as name, is_default from profile where uid = %d order by id asc", + intval(local_channel()) + ); + + if ($profiles) { + for ($x = 0; $x < count($profiles); $x++) { + $profiles[$x]['selected'] = false; + if ($pf && $profiles[$x]['id'] == $pf) { + $profiles[$x]['selected'] = true; + } + if ((!$pf) && $profiles[$x]['is_default']) { + $profiles[$x]['selected'] = true; + } + } + } + + $importing = ((array_key_exists('importfile', App::$data)) ? true : false); + + if (!array_key_exists('imagecrop', App::$data)) { + $tpl = Theme::get_template('profile_photo.tpl'); + + $o .= replace_macros($tpl, [ + '$user' => App::$channel['channel_address'], + '$info' => ((count($profiles) > 1) ? t('Your default profile photo is visible to anybody on the internet. Profile photos for alternate profiles will inherit the permissions of the profile') : t('Your profile photo is visible to anybody on the internet and may be distributed to other websites.')), + '$importfile' => (($importing) ? App::$data['importfile'] : ''), + '$lbl_upfile' => t('Upload File:'), + '$lbl_profiles' => t('Select a profile:'), + '$title' => (($importing) ? t('Use Photo for Profile') : t('Change Profile Photo')), + '$submit' => (($importing) ? t('Use') : t('Upload')), + '$profiles' => $profiles, + '$single' => ((count($profiles) == 1) ? true : false), + '$profile0' => $profiles[0], + '$embedPhotos' => t('Use a photo from your albums'), + '$embedPhotosModalTitle' => t('Use a photo from your albums'), + '$embedPhotosModalCancel' => t('Cancel'), + '$embedPhotosModalOK' => t('OK'), + '$modalchooseimages' => t('Choose images to embed'), + '$modalchoosealbum' => t('Choose an album'), + '$modaldiffalbum' => t('Choose a different album'), + '$modalerrorlist' => t('Error getting album list'), + '$modalerrorlink' => t('Error getting photo link'), + '$modalerroralbum' => t('Error getting album'), + '$form_security_token' => get_form_security_token("profile_photo"), + '$select' => t('Select previously uploaded photo'), + ]); + + Hook::call('profile_photo_content_end', $o); + return $o; + } else { + // present a cropping form + + $filename = App::$data['imagecrop'] . '-' . App::$data['imagecrop_resolution']; + $resolution = App::$data['imagecrop_resolution']; + $o .= replace_macros(Theme::get_template('cropbody.tpl'), [ + '$filename' => $filename, + '$profile' => intval($_REQUEST['profile']), + '$resource' => App::$data['imagecrop'] . '-' . App::$data['imagecrop_resolution'], + '$image_url' => z_root() . '/photo/' . $filename, + '$title' => t('Crop Image'), + '$desc' => t('Please adjust the image cropping for optimum viewing.'), + '$form_security_token' => get_form_security_token("profile_photo"), + '$done' => t('Done Editing') + ]); + return $o; + } + } + + /* @brief Generate the UI for photo-cropping + * + * @param $ph Photo-Factory + * @return void + * + */ + + public function profile_photo_crop_ui_head($ph, $hash, $smallest) + { + $max_length = get_config('system', 'max_image_length', MAX_IMAGE_LENGTH); + if ($max_length > 0) { + $ph->scaleImage($max_length); + } + + App::$data['width'] = $ph->getWidth(); + App::$data['height'] = $ph->getHeight(); + + if (App::$data['width'] < 500 || App::$data['height'] < 500) { + $ph->scaleImageUp(400); + App::$data['width'] = $ph->getWidth(); + App::$data['height'] = $ph->getHeight(); + } + + App::$data['imagecrop'] = $hash; + App::$data['imagecrop_resolution'] = $smallest; + App::$page['htmlhead'] .= replace_macros(Theme::get_template('crophead.tpl'), []); + return; + } +} diff --git a/Code/Module/Profiles.php b/Code/Module/Profiles.php new file mode 100644 index 000000000..cbfbdfa91 --- /dev/null +++ b/Code/Module/Profiles.php @@ -0,0 +1,1128 @@ + 2) && (argv(1) === "drop") && intval(argv(2))) { + $r = q( + "SELECT * FROM profile WHERE id = %d AND uid = %d AND is_default = 0 LIMIT 1", + intval(argv(2)), + intval(local_channel()) + ); + if (!$r) { + notice(t('Profile not found.') . EOL); + goaway(z_root() . '/profiles'); + } + $profile_guid = $r[0]['profile_guid']; + + check_form_security_token_redirectOnErr('/profiles', 'profile_drop', 't'); + + // move every contact using this profile as their default to the user default + + $r = q( + "UPDATE abook SET abook_profile = (SELECT profile_guid FROM profile WHERE is_default = 1 AND uid = %d LIMIT 1) WHERE abook_profile = '%s' AND abook_channel = %d ", + intval(local_channel()), + dbesc($profile_guid), + intval(local_channel()) + ); + $r = q( + "DELETE FROM profile WHERE id = %d AND uid = %d", + intval(argv(2)), + intval(local_channel()) + ); + if ($r) { + info(t('Profile deleted.') . EOL); + } + + // @fixme this is a much more complicated sync - add any changed abook entries and + // also add deleted flag to profile structure + // profiles_build_sync is just here as a placeholder - it doesn't work at all here + + // Channel::profiles_build_sync(local_channel()); + + goaway(z_root() . '/profiles'); + return; // NOTREACHED + } + + + if ((argc() > 1) && (argv(1) === 'new')) { + // check_form_security_token_redirectOnErr('/profiles', 'profile_new', 't'); + + $r0 = q( + "SELECT id FROM profile WHERE uid = %d", + intval(local_channel()) + ); + $num_profiles = count($r0); + + $name = t('Profile-') . ($num_profiles + 1); + + $r1 = q( + "SELECT fullname, photo, thumb FROM profile WHERE uid = %d AND is_default = 1 LIMIT 1", + intval(local_channel()) + ); + + $r2 = Channel::profile_store_lowlevel( + [ + 'aid' => intval(get_account_id()), + 'uid' => intval(local_channel()), + 'profile_guid' => new_uuid(), + 'profile_name' => $name, + 'fullname' => $r1[0]['fullname'], + 'photo' => $r1[0]['photo'], + 'thumb' => $r1[0]['thumb'] + ] + ); + + $r3 = q( + "SELECT id FROM profile WHERE uid = %d AND profile_name = '%s' LIMIT 1", + intval(local_channel()), + dbesc($name) + ); + + info(t('New profile created.') . EOL); + if (count($r3) == 1) { + goaway(z_root() . '/profiles/' . $r3[0]['id']); + } + + goaway(z_root() . '/profiles'); + } + + if ((argc() > 2) && (argv(1) === 'clone')) { + check_form_security_token_redirectOnErr('/profiles', 'profile_clone', 't'); + + $r0 = q( + "SELECT id FROM profile WHERE uid = %d", + intval(local_channel()) + ); + $num_profiles = count($r0); + + $name = t('Profile-') . ($num_profiles + 1); + $r1 = q( + "SELECT * FROM profile WHERE uid = %d AND id = %d LIMIT 1", + intval(local_channel()), + intval(argv(2)) + ); + if (!count($r1)) { + notice(t('Profile unavailable to clone.') . EOL); + App::$error = 404; + return; + } + unset($r1[0]['id']); + $r1[0]['is_default'] = 0; + $r1[0]['publish'] = 0; + $r1[0]['profile_name'] = $name; + $r1[0]['profile_guid'] = new_uuid(); + + create_table_from_array('profile', $r1[0]); + + $r3 = q( + "SELECT id FROM profile WHERE uid = %d AND profile_name = '%s' LIMIT 1", + intval(local_channel()), + dbesc($name) + ); + info(t('New profile created.') . EOL); + + Channel::profiles_build_sync(local_channel()); + + if (($r3) && (count($r3) == 1)) { + goaway(z_root() . '/profiles/' . $r3[0]['id']); + } + + goaway(z_root() . '/profiles'); + + return; // NOTREACHED + } + + if ((argc() > 2) && (argv(1) === 'export')) { + $r1 = q( + "SELECT * FROM profile WHERE uid = %d AND id = %d LIMIT 1", + intval(local_channel()), + intval(argv(2)) + ); + if (!$r1) { + notice(t('Profile unavailable to export.') . EOL); + App::$error = 404; + return; + } + header('content-type: application/octet_stream'); + header('Content-Disposition: attachment; filename="' . $r1[0]['profile_name'] . '.json"'); + + unset($r1[0]['id']); + unset($r1[0]['aid']); + unset($r1[0]['uid']); + unset($r1[0]['is_default']); + unset($r1[0]['publish']); + unset($r1[0]['profile_name']); + unset($r1[0]['profile_guid']); + echo json_encode($r1[0]); + killme(); + } + + if (((argc() > 1) && (intval(argv(1)))) || !Features::enabled(local_channel(), 'multi_profiles')) { + if (Features::enabled(local_channel(), 'multi_profiles')) { + $id = argv(1); + } else { + $x = q( + "select id from profile where uid = %d and is_default = 1", + intval(local_channel()) + ); + if ($x) { + $id = $x[0]['id']; + } + } + $r = q( + "SELECT * FROM profile WHERE id = %d AND uid = %d LIMIT 1", + intval($id), + intval(local_channel()) + ); + if (!count($r)) { + notice(t('Profile not found.') . EOL); + App::$error = 404; + return; + } + + $chan = App::get_channel(); + + Libprofile::load($chan['channel_address'], $r[0]['profile_guid']); + } + } + + public function post() + { + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + $namechanged = false; + + // import from json export file. + // Only import fields that are allowed on this hub + + if (x($_FILES, 'userfile')) { + $src = $_FILES['userfile']['tmp_name']; + $filesize = intval($_FILES['userfile']['size']); + if ($filesize) { + $j = @json_decode(@file_get_contents($src), true); + @unlink($src); + if ($j) { + $fields = Channel::get_profile_fields_advanced(); + if ($fields) { + foreach ($j as $jj => $v) { + foreach ($fields as $f => $n) { + if ($jj == $f) { + $_POST[$f] = $v; + break; + } + } + } + } + } + } + } + + Hook::call('profile_post', $_POST); + + + if ((argc() > 1) && (argv(1) !== "new") && intval(argv(1))) { + $orig = q( + "SELECT * FROM profile WHERE id = %d AND uid = %d LIMIT 1", + intval(argv(1)), + intval(local_channel()) + ); + if (!count($orig)) { + notice(t('Profile not found.') . EOL); + return; + } + + check_form_security_token_redirectOnErr('/profiles', 'profile_edit'); + + + $is_default = (($orig[0]['is_default']) ? 1 : 0); + + $profile_name = notags(trim($_POST['profile_name'])); + if (!strlen($profile_name)) { + notice(t('Profile Name is required.') . EOL); + return; + } + + $dob = $_POST['dob'] ? escape_tags(trim($_POST['dob'])) : '0000-00-00'; + + $y = substr($dob, 0, 4); + if ((!ctype_digit($y)) || ($y < 1900)) { + $ignore_year = true; + } else { + $ignore_year = false; + } + + if ($dob !== '0000-00-00') { + if (strpos($dob, '0000-') === 0) { + $ignore_year = true; + $dob = substr($dob, 5); + } + $dob = datetime_convert('UTC', 'UTC', (($ignore_year) ? '1900-' . $dob : $dob), (($ignore_year) ? 'm-d' : 'Y-m-d')); + if ($ignore_year) { + $dob = '0000-' . $dob; + } + } + + $name = escape_tags(trim($_POST['name'])); + + if ($orig[0]['fullname'] != $name) { + $namechanged = true; + + $v = Channel::validate_channelname($name); + if ($v) { + notice($v); + $namechanged = false; + $name = $orig[0]['fullname']; + } + } + + $pdesc = escape_tags(trim($_POST['pdesc'])); + $gender = escape_tags(trim($_POST['gender'])); + $address = escape_tags(trim($_POST['address'])); + $locality = escape_tags(trim($_POST['locality'])); + $region = escape_tags(trim($_POST['region'])); + $postal_code = escape_tags(trim($_POST['postal_code'])); + $country_name = escape_tags(trim($_POST['country_name'])); + $keywords = escape_tags(trim($_POST['keywords'])); + $marital = escape_tags(trim($_POST['marital'])); + $howlong = escape_tags(trim($_POST['howlong'])); + $sexual = escape_tags(trim($_POST['sexual'])); + $pronouns = escape_tags(trim($_POST['pronouns'])); + $homepage = escape_tags(trim($_POST['homepage'])); + $hometown = escape_tags(trim($_POST['hometown'])); + $politic = escape_tags(trim($_POST['politic'])); + $religion = escape_tags(trim($_POST['religion'])); + + $likes = escape_tags(trim($_POST['likes'])); + $dislikes = escape_tags(trim($_POST['dislikes'])); + + $about = escape_tags(trim($_POST['about'])); + $interest = escape_tags(trim($_POST['interest'])); + $contact = escape_tags(trim($_POST['contact'])); + $channels = escape_tags(trim($_POST['channels'])); + $music = escape_tags(trim($_POST['music'])); + $book = escape_tags(trim($_POST['book'])); + $tv = escape_tags(trim($_POST['tv'])); + $film = escape_tags(trim($_POST['film'])); + $romance = escape_tags(trim($_POST['romance'])); + $work = escape_tags(trim($_POST['work'])); + $education = escape_tags(trim($_POST['education'])); + + $hide_friends = ((intval($_POST['hide_friends'])) ? 1 : 0); + + // start fresh and create a new vcard. + // @TODO: preserve the original guid or whatever else needs saving + // $orig_vcard = (($orig[0]['profile_vcard']) ? Reader::read($orig[0]['profile_vcard']) : null); + + $orig_vcard = null; + + $channel = App::get_channel(); + + $default_vcard_cat = ((defined('DEFAULT_VCARD_CAT')) ? DEFAULT_VCARD_CAT : 'HOME'); + + $defcard = [ + 'fn' => $name, + 'title' => $pdesc, + 'photo' => $channel['xchan_photo_l'], + 'adr' => [], + 'adr_type' => [$default_vcard_cat], + 'url' => [$homepage], + 'url_type' => [$default_vcard_cat] + ]; + + $defcard['adr'][] = [ + 0 => '', + 1 => '', + 2 => $address, + 3 => $locality, + 4 => $region, + 5 => $postal_code, + 6 => $country_name + ]; + + $profile_vcard = update_vcard($defcard, $orig_vcard); + + $orig_vcard = Reader::read($profile_vcard); + + $profile_vcard = update_vcard($_REQUEST, $orig_vcard); + + + require_once('include/text.php'); + linkify_tags($likes, local_channel()); + linkify_tags($dislikes, local_channel()); + linkify_tags($about, local_channel()); + linkify_tags($interest, local_channel()); + linkify_tags($interest, local_channel()); + linkify_tags($contact, local_channel()); + linkify_tags($channels, local_channel()); + linkify_tags($music, local_channel()); + linkify_tags($book, local_channel()); + linkify_tags($tv, local_channel()); + linkify_tags($film, local_channel()); + linkify_tags($romance, local_channel()); + linkify_tags($work, local_channel()); + linkify_tags($education, local_channel()); + + + $with = ((x($_POST, 'with')) ? escape_tags(trim($_POST['with'])) : ''); + + if (!strlen($howlong)) { + $howlong = NULL_DATE; + } else { + $howlong = datetime_convert(date_default_timezone_get(), 'UTC', $howlong); + } + + // linkify the relationship target if applicable + + $withchanged = false; + + if (strlen($with)) { + if ($with != strip_tags($orig[0]['partner'])) { + $withchanged = true; + $prf = ''; + $lookup = $with; + if (strpos($lookup, '@') === 0) { + $lookup = substr($lookup, 1); + } + $lookup = str_replace('_', ' ', $lookup); + $newname = $lookup; + + $r = q( + "SELECT * FROM abook left join xchan on abook_xchan = xchan_hash WHERE xchan_name = '%s' AND abook_channel = %d LIMIT 1", + dbesc($newname), + intval(local_channel()) + ); + if (!$r) { + $r = q( + "SELECT * FROM abook left join xchan on abook_xchan = xchan_hash WHERE xchan_addr = '%s' AND abook_channel = %d LIMIT 1", + dbesc($lookup . '@%'), + intval(local_channel()) + ); + } + if ($r) { + $prf = $r[0]['xchan_url']; + $newname = $r[0]['xchan_name']; + } + + + if ($prf) { + $with = str_replace($lookup, '' . $newname . '', $with); + if (strpos($with, '@') === 0) { + $with = substr($with, 1); + } + } + } else { + $with = $orig[0]['partner']; + } + } + + $profile_fields_basic = Channel::get_profile_fields_basic(); + $profile_fields_advanced = Channel::get_profile_fields_advanced(); + $advanced = ((Features::enabled(local_channel(), 'advanced_profiles')) ? true : false); + if ($advanced) { + $fields = $profile_fields_advanced; + } else { + $fields = $profile_fields_basic; + } + + $z = q("select * from profdef where true"); + if ($z) { + foreach ($z as $zz) { + if (array_key_exists($zz['field_name'], $fields)) { + $w = q( + "select * from profext where channel_id = %d and hash = '%s' and k = '%s' limit 1", + intval(local_channel()), + dbesc($orig[0]['profile_guid']), + dbesc($zz['field_name']) + ); + if ($w) { + q( + "update profext set v = '%s' where id = %d", + dbesc(escape_tags(trim($_POST[$zz['field_name']]))), + intval($w[0]['id']) + ); + } else { + q( + "insert into profext ( channel_id, hash, k, v ) values ( %d, '%s', '%s', '%s') ", + intval(local_channel()), + dbesc($orig[0]['profile_guid']), + dbesc($zz['field_name']), + dbesc(escape_tags(trim($_POST[$zz['field_name']]))) + ); + } + } + } + } + + $changes = []; + $value = ''; + if ($is_default) { + if ($marital != $orig[0]['marital']) { + $changes[] = '[color=#ff0000]♥[/color] ' . t('Marital Status'); + $value = $marital; + } + if ($withchanged) { + $changes[] = '[color=#ff0000]♥[/color] ' . t('Romantic Partner'); + $value = strip_tags($with); + } + if ($likes != $orig[0]['likes']) { + $changes[] = t('Likes'); + $value = $likes; + } + if ($dislikes != $orig[0]['dislikes']) { + $changes[] = t('Dislikes'); + $value = $dislikes; + } + if ($work != $orig[0]['employment']) { + $changes[] = t('Work/Employment'); + } + if ($religion != $orig[0]['religion']) { + $changes[] = t('Religion'); + $value = $religion; + } + if ($politic != $orig[0]['politic']) { + $changes[] = t('Political Views'); + $value = $politic; + } + if ($gender != $orig[0]['gender']) { + $changes[] = t('Gender'); + $value = $gender; + } + if ($sexual != $orig[0]['sexual']) { + $changes[] = t('Sexual Preference'); + $value = $sexual; + } + if ($homepage != $orig[0]['homepage']) { + $changes[] = t('Homepage'); + $value = $homepage; + } + if ($interest != $orig[0]['interest']) { + $changes[] = t('Interests'); + $value = $interest; + } + if ($address != $orig[0]['address']) { + $changes[] = t('Address'); + // New address not sent in notifications, potential privacy issues + // in case this leaks to unintended recipients. Yes, it's in the public + // profile but that doesn't mean we have to broadcast it to everybody. + } + if ( + $locality != $orig[0]['locality'] || $region != $orig[0]['region'] + || $country_name != $orig[0]['country_name'] + ) { + $changes[] = t('Location'); + $comma1 = ((($locality) && ($region || $country_name)) ? ', ' : ' '); + $comma2 = (($region && $country_name) ? ', ' : ''); + $value = $locality . $comma1 . $region . $comma2 . $country_name; + } + + self::profile_activity($changes, $value); + } + + $r = q( + "UPDATE profile + SET profile_name = '%s', + fullname = '%s', + pdesc = '%s', + gender = '%s', + dob = '%s', + address = '%s', + locality = '%s', + region = '%s', + postal_code = '%s', + country_name = '%s', + marital = '%s', + partner = '%s', + howlong = '%s', + sexual = '%s', + pronouns = '%s', + homepage = '%s', + hometown = '%s', + politic = '%s', + religion = '%s', + keywords = '%s', + likes = '%s', + dislikes = '%s', + about = '%s', + interest = '%s', + contact = '%s', + channels = '%s', + music = '%s', + book = '%s', + tv = '%s', + film = '%s', + romance = '%s', + employment = '%s', + education = '%s', + hide_friends = %d, + profile_vcard = '%s' + WHERE id = %d AND uid = %d", + dbesc($profile_name), + dbesc($name), + dbesc($pdesc), + dbesc($gender), + dbesc($dob), + dbesc($address), + dbesc($locality), + dbesc($region), + dbesc($postal_code), + dbesc($country_name), + dbesc($marital), + dbesc($with), + dbesc($howlong), + dbesc($sexual), + dbesc($pronouns), + dbesc($homepage), + dbesc($hometown), + dbesc($politic), + dbesc($religion), + dbesc($keywords), + dbesc($likes), + dbesc($dislikes), + dbesc($about), + dbesc($interest), + dbesc($contact), + dbesc($channels), + dbesc($music), + dbesc($book), + dbesc($tv), + dbesc($film), + dbesc($romance), + dbesc($work), + dbesc($education), + intval($hide_friends), + dbesc($profile_vcard), + intval(argv(1)), + intval(local_channel()) + ); + + if ($r) { + info(t('Profile updated.') . EOL); + } + + $sync = q( + "select * from profile where id = %d and uid = %d limit 1", + intval(argv(1)), + intval(local_channel()) + ); + if ($sync) { + Libsync::build_sync_packet(local_channel(), array('profile' => $sync)); + } + + if (Channel::is_system(local_channel())) { + set_config('system', 'siteinfo', $about); + } + + $channel = App::get_channel(); + + if ($namechanged && $is_default) { + $r = q( + "UPDATE xchan SET xchan_name = '%s', xchan_name_date = '%s' WHERE xchan_hash = '%s'", + dbesc($name), + dbesc(datetime_convert()), + dbesc($channel['xchan_hash']) + ); + $r = q( + "UPDATE channel SET channel_name = '%s' WHERE channel_hash = '%s'", + dbesc($name), + dbesc($channel['xchan_hash']) + ); + if (Channel::is_system(local_channel())) { + set_config('system', 'sitename', $name); + } + } + + if ($is_default) { + Run::Summon(['Directory', local_channel()]); + goaway(z_root() . '/profiles/' . $sync[0]['id']); + } + } + } + + + public function get() + { + + $o = ''; + + $channel = App::get_channel(); + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + $profile_fields_basic = Channel::get_profile_fields_basic(); + $profile_fields_advanced = Channel::get_profile_fields_advanced(); + + if (((argc() > 1) && (intval(argv(1)))) || !Features::enabled(local_channel(), 'multi_profiles')) { + if (Features::enabled(local_channel(), 'multi_profiles')) { + $id = argv(1); + } else { + $x = q( + "select id from profile where uid = %d and is_default = 1", + intval(local_channel()) + ); + if ($x) { + $id = $x[0]['id']; + } + } + $r = q( + "SELECT * FROM profile WHERE id = %d AND uid = %d LIMIT 1", + intval($id), + intval(local_channel()) + ); + if (!$r) { + notice(t('Profile not found.') . EOL); + return; + } + + $editselect = 'none'; + + App::$page['htmlhead'] .= replace_macros(Theme::get_template('profed_head.tpl'), array( + '$baseurl' => z_root(), + '$editselect' => $editselect, + )); + + $advanced = ((Features::enabled(local_channel(), 'advanced_profiles')) ? true : false); + if ($advanced) { + $fields = $profile_fields_advanced; + } else { + $fields = $profile_fields_basic; + } + + $hide_friends = array( + 'hide_friends', + t('Hide your connections list from viewers of this profile'), + $r[0]['hide_friends'], + '', + array(t('No'), t('Yes')) + ); + + $q = q("select * from profdef where true"); + if ($q) { + $extra_fields = []; + + foreach ($q as $qq) { + $mine = q( + "select v from profext where k = '%s' and hash = '%s' and channel_id = %d limit 1", + dbesc($qq['field_name']), + dbesc($r[0]['profile_guid']), + intval(local_channel()) + ); + + if (array_key_exists($qq['field_name'], $fields)) { + $extra_fields[] = array($qq['field_name'], $qq['field_desc'], (($mine) ? $mine[0]['v'] : ''), $qq['field_help']); + } + } + } + + //logger('extra_fields: ' . print_r($extra_fields,true)); + + $vc = $r[0]['profile_vcard']; + $vctmp = (($vc) ? Reader::read($vc) : null); + $vcard = (($vctmp) ? get_vcard_array($vctmp, $r[0]['id']) : []); + + $f = get_config('system', 'birthday_input_format'); + if (!$f) { + $f = 'ymd'; + } + + $is_default = (($r[0]['is_default']) ? 1 : 0); + + $tpl = Theme::get_template("profile_edit.tpl"); + $o .= replace_macros($tpl, array( + '$multi_profiles' => ((Features::enabled(local_channel(), 'multi_profiles')) ? true : false), + '$form_security_token' => get_form_security_token("profile_edit"), + '$profile_clone_link' => 'profiles/clone/' . $r[0]['id'] . '?t=' . get_form_security_token("profile_clone"), + '$profile_drop_link' => 'profiles/drop/' . $r[0]['id'] . '?t=' . get_form_security_token("profile_drop"), + '$fields' => $fields, + '$vcard' => $vcard, + '$guid' => $r[0]['profile_guid'], + '$banner' => t('Edit Profile Details'), + '$submit' => t('Submit'), + '$viewprof' => t('View this profile'), + '$editvis' => t('Edit visibility'), + '$tools_label' => t('Profile Tools'), + '$coverpic' => t('Change cover photo'), + '$profpic' => t('Change profile photo'), + '$cr_prof' => t('Create a new profile using these settings'), + '$cl_prof' => t('Clone this profile'), + '$del_prof' => t('Delete this profile'), + '$addthing' => t('Add profile things'), + '$personal' => t('Personal'), + '$location' => t('Location'), + '$relation' => t('Relationship'), + '$miscellaneous' => t('Miscellaneous'), + '$exportable' => Features::enabled(local_channel(), 'profile_export'), + '$lbl_import' => t('Import profile from file'), + '$lbl_export' => t('Export profile to file'), + '$lbl_gender' => t('Your gender'), + '$lbl_marital' => t('Marital status'), + '$lbl_sexual' => t('Sexual preference'), + '$lbl_pronouns' => t('Pronouns'), + '$baseurl' => z_root(), + '$profile_id' => $r[0]['id'], + '$profile_name' => array('profile_name', t('Profile name'), $r[0]['profile_name'], t('Required'), '*'), + '$is_default' => $is_default, + '$default' => '', // t('This is your default profile.') . EOL . translate_scope(map_scope(\Code\Access\PermissionLimits::Get($channel['channel_id'],'view_profile'))), + '$advanced' => $advanced, + '$name' => array('name', t('Your full name'), $r[0]['fullname'], t('Required'), '*'), + '$pdesc' => array('pdesc', t('Title/Description'), $r[0]['pdesc']), + '$dob' => dob($r[0]['dob']), + '$hide_friends' => $hide_friends, + '$address' => array('address', t('Street address'), $r[0]['address']), + '$locality' => array('locality', t('Locality/City'), $r[0]['locality']), + '$region' => array('region', t('Region/State'), $r[0]['region']), + '$postal_code' => array('postal_code', t('Postal/Zip code'), $r[0]['postal_code']), + '$country_name' => array('country_name', t('Country'), $r[0]['country_name']), + '$gender' => self::gender_selector($r[0]['gender']), + '$gender_min' => self::gender_selector_min($r[0]['gender']), + '$gender_text' => self::gender_text($r[0]['gender']), + '$marital' => self::marital_selector($r[0]['marital']), + '$marital_min' => self::marital_selector_min($r[0]['marital']), + '$with' => array('with', t("Who (if applicable)"), $r[0]['partner'], t('Examples: cathy123, Cathy Williams, cathy@example.com')), + '$howlong' => array('howlong', t('Since (date)'), ($r[0]['howlong'] <= NULL_DATE ? '' : datetime_convert('UTC', date_default_timezone_get(), $r[0]['howlong']))), + '$sexual' => self::sexpref_selector($r[0]['sexual']), + '$sexual_min' => self::sexpref_selector_min($r[0]['sexual']), + '$pronouns' => self::pronouns_selector($r[0]['pronouns']), + '$pronouns_min' => self::pronouns_selector($r[0]['pronouns']), + '$about' => array('about', t('Tell us about yourself'), $r[0]['about']), + '$homepage' => array('homepage', t('Homepage URL'), $r[0]['homepage']), + '$hometown' => array('hometown', t('Hometown'), $r[0]['hometown']), + '$politic' => array('politic', t('Political views'), $r[0]['politic']), + '$religion' => array('religion', t('Religious views'), $r[0]['religion']), + '$keywords' => array('keywords', t('Keywords used in directory listings'), $r[0]['keywords'], t('Example: fishing photography software')), + '$likes' => array('likes', t('Likes'), $r[0]['likes']), + '$dislikes' => array('dislikes', t('Dislikes'), $r[0]['dislikes']), + '$music' => array('music', t('Musical interests'), $r[0]['music']), + '$book' => array('book', t('Books, literature'), $r[0]['book']), + '$tv' => array('tv', t('Television'), $r[0]['tv']), + '$film' => array('film', t('Film/Dance/Culture/Entertainment'), $r[0]['film']), + '$interest' => array('interest', t('Hobbies/Interests'), $r[0]['interest']), + '$romance' => array('romance', t('Love/Romance'), $r[0]['romance']), + '$employ' => array('work', t('Work/Employment'), $r[0]['employment']), + '$education' => array('education', t('School/Education'), $r[0]['education']), + '$contact' => array('contact', t('Contact information and social networks'), $r[0]['contact']), + '$channels' => array('channels', t('My other channels'), $r[0]['channels']), + '$extra_fields' => $extra_fields, + '$comms' => t('Communications'), + '$tel_label' => t('Phone'), + '$email_label' => t('Email'), + '$impp_label' => t('Instant messenger'), + '$url_label' => t('Website'), + '$adr_label' => t('Address'), + '$note_label' => t('Note'), + '$mobile' => t('Mobile'), + '$home' => t('Home'), + '$work' => t('Work'), + '$other' => t('Other'), + '$add_card' => t('Add Contact'), + '$add_field' => t('Add Field'), + '$create' => t('Create'), + '$update' => t('Update'), + '$delete' => t('Delete'), + '$cancel' => t('Cancel'), + )); + + $arr = array('profile' => $r[0], 'entry' => $o); + Hook::call('profile_edit', $arr); + + return $o; + } else { + $r = q( + "SELECT * FROM profile WHERE uid = %d", + local_channel() + ); + if ($r) { + $tpl = Theme::get_template('profile_entry.tpl'); + foreach ($r as $rr) { + $profiles .= replace_macros($tpl, array( + '$photo' => $rr['thumb'], + '$id' => $rr['id'], + '$alt' => t('Profile Image'), + '$profile_name' => $rr['profile_name'], + '$visible' => (($rr['is_default']) + ? '' . translate_scope(map_scope(PermissionLimits::Get($channel['channel_id'], 'view_profile'))) . '' + : '' . t('Edit visibility') . '') + )); + } + + $tpl_header = Theme::get_template('profile_listing_header.tpl'); + $o .= replace_macros($tpl_header, array( + '$header' => t('Edit Profiles'), + '$cr_new' => t('Create New'), + '$cr_new_link' => 'profiles/new?t=' . get_form_security_token("profile_new"), + '$profiles' => $profiles + )); + } + return $o; + } + } + + public static function profile_activity($changed, $value) + { + + if (!local_channel() || !is_array($changed) || !count($changed)) { + return; + } + + if (!get_pconfig(local_channel(), 'system', 'post_profilechange')) { + return; + } + + $self = App::get_channel(); + + if (!$self) { + return; + } + + $arr = []; + $uuid = new_uuid(); + $mid = z_root() . '/item/' . $uuid; + + $arr['uuid'] = $uuid; + $arr['mid'] = $arr['parent_mid'] = $mid; + $arr['uid'] = local_channel(); + $arr['aid'] = $self['channel_account_id']; + $arr['owner_xchan'] = $arr['author_xchan'] = $self['xchan_hash']; + + $arr['item_wall'] = 1; + $arr['item_origin'] = 1; + $arr['item_thread_top'] = 1; + $arr['verb'] = ACTIVITY_UPDATE; + $arr['obj_type'] = ACTIVITY_OBJ_PROFILE; + + $arr['plink'] = z_root() . '/channel/' . $self['channel_address'] . '/?f=&mid=' . urlencode($arr['mid']); + + $A = '[url=' . z_root() . '/channel/' . $self['channel_address'] . ']' . $self['channel_name'] . '[/url]'; + + + $changes = ''; + $t = count($changed); + $z = 0; + foreach ($changed as $ch) { + if (strlen($changes)) { + if ($z == ($t - 1)) { + $changes .= t(' and '); + } else { + $changes .= t(', '); + } + } + $z++; + $changes .= $ch; + } + + $prof = '[url=' . z_root() . '/profile/' . $self['channel_address'] . ']' . t('public profile') . '[/url]'; + + if ($t == 1 && strlen($value)) { + // if it's a url, the HTML quotes will mess it up, so link it and don't try and zidify it because we don't know what it points to. + $value = preg_replace_callback("/([^\]\='" . '"' . "]|^|\#\^)(https?\:\/\/[a-zA-Z0-9\pL\:\/\-\?\&\;\.\=\@\_\~\#\%\$\!\+\,]+)/ismu", 'red_zrl_callback', $value); + // take out the bookmark indicator + if (substr($value, 0, 2) === '#^') { + $value = str_replace('#^', '', $value); + } + + $message = sprintf(t('%1$s changed %2$s to “%3$s”'), $A, $changes, $value); + $message .= "\n\n" . sprintf(t('Visit %1$s\'s %2$s'), $A, $prof); + } else { + $message = sprintf(t('%1$s has an updated %2$s, changing %3$s.'), $A, $prof, $changes); + } + + $arr['body'] = $message; + + $arr['obj'] = [ + 'type' => ACTIVITY_OBJ_PROFILE, + 'summary' => bbcode($message), + 'source' => ['mediaType' => 'text/x-multicode', 'summary' => $message], + 'id' => $self['xchan_url'], + 'url' => z_root() . '/profile/' . $self['channel_address'] + ]; + + + $arr['allow_cid'] = $self['channel_allow_cid']; + $arr['allow_gid'] = $self['channel_allow_gid']; + $arr['deny_cid'] = $self['channel_deny_cid']; + $arr['deny_gid'] = $self['channel_deny_gid']; + + $res = item_store($arr); + $i = $res['item_id']; + + if ($i) { + // FIXME - limit delivery in notifier.php to those specificed in the perms argument + Run::Summon(['Notifier', 'activity', $i, 'PERMS_R_PROFILE']); + } + } + + public static function gender_selector($current = "", $suffix = "") + { + $o = ''; + $select = array('', t('Male'), t('Female'), t('Currently Male'), t('Currently Female'), t('Mostly Male'), t('Mostly Female'), t('Transgender'), t('Intersex'), t('Transsexual'), t('Hermaphrodite'), t('Neuter'), t('Non-specific'), t('Other'), t('Undecided')); + + Hook::call('gender_selector', $select); + + $o .= "'; + return $o; + } + + public static function gender_selector_min($current = "", $suffix = "") + { + $o = ''; + $select = array('', t('Male'), t('Female'), t('Other')); + + Hook::call('gender_selector_min', $select); + + $o .= "'; + return $o; + } + + public static function pronouns_selector($current = "", $suffix = "") + { + $o = ''; + $select = array('', t('He/Him'), t('She/Her'), t('They/Them')); + + Hook::call('pronouns_selector', $select); + + $o .= "'; + return $o; + } + + + public static function gender_text($current = "", $suffix = "") + { + $o = ''; + + if (!get_config('system', 'profile_gender_textfield')) { + return $o; + } + + $o .= ""; + return $o; + } + + + public static function sexpref_selector($current = "", $suffix = "") + { + $o = ''; + $select = array('', t('Males'), t('Females'), t('Gay'), t('Lesbian'), t('No Preference'), t('Bisexual'), t('Autosexual'), t('Abstinent'), t('Virgin'), t('Deviant'), t('Fetish'), t('Oodles'), t('Nonsexual')); + + + Hook::call('sexpref_selector', $select); + + $o .= "'; + return $o; + } + + + public static function sexpref_selector_min($current = "", $suffix = "") + { + $o = ''; + $select = array('', t('Males'), t('Females'), t('Other')); + + Hook::call('sexpref_selector_min', $select); + + $o .= "'; + return $o; + } + + + public static function marital_selector($current = "", $suffix = "") + { + $o = ''; + $select = array('', t('Single'), t('Lonely'), t('Available'), t('Unavailable'), t('Has crush'), t('Infatuated'), t('Dating'), t('Unfaithful'), t('Sex Addict'), t('Friends'), t('Friends/Benefits'), t('Casual'), t('Engaged'), t('Married'), t('Imaginarily married'), t('Partners'), t('Cohabiting'), t('Common law'), t('Happy'), t('Not looking'), t('Swinger'), t('Betrayed'), t('Separated'), t('Unstable'), t('Divorced'), t('Imaginarily divorced'), t('Widowed'), t('Uncertain'), t('It\'s complicated'), t('Don\'t care'), t('Ask me')); + + Hook::call('marital_selector', $select); + + $o .= "'; + return $o; + } + + public static function marital_selector_min($current = "", $suffix = "") + { + $o = ''; + $select = array('', t('Single'), t('Dating'), t('Cohabiting'), t('Married'), t('Separated'), t('Divorced'), t('Widowed'), t('It\'s complicated'), t('Other')); + + Hook::call('marital_selector_min', $select); + + $o .= "'; + return $o; + } +} diff --git a/Code/Module/Profperm.php b/Code/Module/Profperm.php new file mode 100644 index 000000000..da1e71295 --- /dev/null +++ b/Code/Module/Profperm.php @@ -0,0 +1,183 @@ + 2) && intval(argv(1)) && intval(argv(2))) { + $r = q( + "SELECT abook_id FROM abook WHERE abook_id = %d and abook_channel = %d limit 1", + intval(argv(2)), + intval(local_channel()) + ); + if ($r) { + $change = intval(argv(2)); + } + } + + + if ((argc() > 1) && (intval(argv(1)))) { + $r = q( + "SELECT * FROM profile WHERE id = %d AND uid = %d AND is_default = 0 LIMIT 1", + intval(argv(1)), + intval(local_channel()) + ); + if (!$r) { + notice(t('Invalid profile identifier.') . EOL); + return; + } + + $profile = $r[0]; + + $r = q( + "SELECT * FROM abook left join xchan on abook_xchan = xchan_hash WHERE abook_channel = %d AND abook_profile = '%s'", + intval(local_channel()), + dbesc($profile['profile_guid']) + ); + + $ingroup = []; + if ($r) { + foreach ($r as $member) { + $ingroup[] = $member['abook_id']; + } + } + + $members = $r; + + if ($change) { + if (in_array($change, $ingroup)) { + q( + "UPDATE abook SET abook_profile = '' WHERE abook_id = %d AND abook_channel = %d", + intval($change), + intval(local_channel()) + ); + } else { + q( + "UPDATE abook SET abook_profile = '%s' WHERE abook_id = %d AND abook_channel = %d", + dbesc($profile['profile_guid']), + intval($change), + intval(local_channel()) + ); + } + + $r = q( + "SELECT * FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d AND abook_profile = '%s'", + intval(local_channel()), + dbesc($profile['profile_guid']) + ); + + $members = $r; + + $ingroup = []; + if (count($r)) { + foreach ($r as $member) { + $ingroup[] = $member['abook_id']; + } + } + } + + $o .= '

      ' . t('Profile Visibility Editor') . '

      '; + + $o .= '

      ' . t('Profile') . ' \'' . $profile['profile_name'] . '\'

      '; + + $o .= '
      ' . t('Click on a contact to add or remove.') . '
      '; + } + + $o .= '
      '; + if ($change) { + $o = ''; + } + + $o .= '
      '; + $o .= '

      ' . t('Visible To') . '

      '; + $o .= '
      '; + $o .= '
      '; + + $textmode = (($switchtotext && (count($members) > $switchtotext)) ? true : false); + + foreach ($members as $member) { + if ($member['xchan_url']) { + $member['click'] = 'profChangeMember(' . $profile['id'] . ',' . $member['abook_id'] . '); return false;'; + $o .= micropro($member, true, 'mpprof', $textmode); + } + } + $o .= '
      '; + $o .= '
      '; + + $o .= '
      '; + $o .= '

      ' . t("All Connections") . '

      '; + $o .= '
      '; + $o .= '
      '; + + $r = abook_connections(local_channel()); + + if ($r) { + $textmode = (($switchtotext && (count($r) > $switchtotext)) ? true : false); + foreach ($r as $member) { + if (!in_array($member['abook_id'], $ingroup)) { + $member['click'] = 'profChangeMember(' . $profile['id'] . ',' . $member['abook_id'] . '); return false;'; + $o .= micropro($member, true, 'mpprof', $textmode); + } + } + } + + $o .= '
      '; + + if ($change) { + echo $o; + killme(); + } + $o .= '
      '; + return $o; + } +} diff --git a/Code/Module/Pubstream.php b/Code/Module/Pubstream.php new file mode 100644 index 000000000..4d90c5156 --- /dev/null +++ b/Code/Module/Pubstream.php @@ -0,0 +1,305 @@ +updating)) { + $channel = App::get_channel(); + + $channel_acl = array( + 'allow_cid' => $channel['channel_allow_cid'], + 'allow_gid' => $channel['channel_allow_gid'], + 'deny_cid' => $channel['channel_deny_cid'], + 'deny_gid' => $channel['channel_deny_gid'] + ); + + $x = array( + 'is_owner' => true, + 'allow_location' => ((intval(get_pconfig($channel['channel_id'], 'system', 'use_browser_location'))) ? '1' : ''), + 'default_location' => $channel['channel_location'], + 'nickname' => $channel['channel_address'], + 'lockstate' => (($channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 'lock' : 'unlock'), + 'acl' => Libacl::populate($channel_acl, true, PermissionDescription::fromGlobalPermission('view_stream'), Libacl::get_post_aclDialogDescription(), 'acl_dialog_post'), + 'permissions' => $channel_acl, + 'bang' => '', + 'visitor' => true, + 'profile_uid' => local_channel(), + 'return_path' => 'channel/' . $channel['channel_address'], + 'expanded' => true, + 'editor_autocomplete' => true, + 'bbco_autocomplete' => 'bbcode', + 'bbcode' => true, + 'jotnets' => true, + 'reset' => t('Reset form') + ); + + $o = '
      '; + $o .= status_editor($x); + $o .= '
      '; + } + + if (!$this->updating && !$this->loading) { + Navbar::set_selected(t('Public Stream')); + + if (!$mid) { + $_SESSION['loadtime_pubstream'] = datetime_convert(); + if (local_channel()) { + PConfig::Set(local_channel(), 'system', 'loadtime_pubstream', $_SESSION['loadtime_pubstream']); + } + } + + $static = ((local_channel()) ? Channel::manual_conv_update(local_channel()) : 1); + + $maxheight = get_config('system', 'home_divmore_height'); + if (!$maxheight) { + $maxheight = 400; + } + + $o .= '
      ' . "\r\n"; + $o .= "\r\n"; + + // if we got a decoded hash we must encode it again before handing to javascript + $mid = gen_link_id($mid); + + App::$page['htmlhead'] .= replace_macros(Theme::get_template("build_query.tpl"), array( + '$baseurl' => z_root(), + '$pgtype' => 'pubstream', + '$uid' => ((local_channel()) ? local_channel() : '0'), + '$gid' => '0', + '$cid' => '0', + '$cmin' => '(-1)', + '$cmax' => '(-1)', + '$star' => '0', + '$liked' => '0', + '$conv' => '0', + '$spam' => '0', + '$fh' => '1', + '$dm' => '0', + '$nouveau' => '0', + '$wall' => '0', + '$draft' => '0', + '$list' => '0', + '$static' => $static, + '$page' => ((App::$pager['page'] != 1) ? App::$pager['page'] : 1), + '$search' => '', + '$xchan' => '', + '$order' => 'comment', + '$file' => '', + '$cats' => '', + '$tags' => (($hashtags) ? urlencode($hashtags) : ''), + '$dend' => '', + '$mid' => (($mid) ? urlencode($mid) : ''), + '$verb' => '', + '$net' => (($net) ? urlencode($net) : ''), + '$dbegin' => '' + )); + } + + if ($this->updating && !$this->loading) { + // only setup pagination on initial page view + $pager_sql = ''; + } else { + $itemspage = ((local_channel()) ? get_pconfig(local_channel(), 'system', 'itemspage', 20) : 20); + App::set_pager_itemspage($itemspage); + $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval(App::$pager['itemspage']), intval(App::$pager['start'])); + } + + require_once('include/channel.php'); + require_once('include/security.php'); + + if ($public_stream_mode === PUBLIC_STREAM_SITE) { + $uids = " and item_private = 0 and item_wall = 1 "; + } else { + $sys = Channel::get_system(); + $uids = " and item_private = 0 and item_wall = 0 and item.uid = " . intval($sys['channel_id']) . " "; + $sql_extra = item_permissions_sql($sys['channel_id']); + App::$data['firehose'] = intval($sys['channel_id']); + } + + if (get_config('system', 'public_list_mode')) { + $page_mode = 'list'; + } else { + $page_mode = 'client'; + } + + + if (x($hashtags)) { + $sql_extra .= protect_sprintf(term_query('item', $hashtags, TERM_HASHTAG, TERM_COMMUNITYTAG)); + } + + $net_query = (($net) ? " left join xchan on xchan_hash = author_xchan " : ''); + $net_query2 = (($net) ? " and xchan_network = '" . protect_sprintf(dbesc($net)) . "' " : ''); + + if (isset(App::$profile) && isset(App::$profile['profile_uid'])) { + $abook_uids = " and abook.abook_channel = " . intval(App::$profile['profile_uid']) . " "; + } + + $simple_update = ((isset($_SESSION['loadtime_pubstream']) && $_SESSION['loadtime_pubstream']) ? " AND item.changed > '" . datetime_convert('UTC', 'UTC', $_SESSION['loadtime_pubstream']) . "' " : ''); + + if ($this->loading) { + $simple_update = ''; + } + + if ($static && $simple_update) { + $simple_update .= " and author_xchan = '" . protect_sprintf(get_observer_hash()) . "' "; + } + + //logger('update: ' . $this->updating . ' load: ' . $this->loading); + + if ($this->updating) { + $ordering = "commented"; + + if ($this->loading) { + if ($mid) { + $r = q( + "SELECT parent AS item_id FROM item + left join abook on item.author_xchan = abook.abook_xchan + $net_query + WHERE mid like '%s' $uids $item_normal + and (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra $net_query2 LIMIT 1", + dbesc($mid . '%') + ); + } else { + // Fetch a page full of parent items for this page + $r = q("SELECT item.id AS item_id FROM item + left join abook on ( item.author_xchan = abook.abook_xchan $abook_uids ) + $net_query + WHERE true $uids and item.item_thread_top = 1 $item_normal + and (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra $net_query2 + ORDER BY $ordering DESC $pager_sql "); + } + } elseif ($this->updating) { + if ($mid) { + $r = q( + "SELECT parent AS item_id FROM item + left join abook on item.author_xchan = abook.abook_xchan + $net_query + WHERE mid like '%s' $uids $item_normal_update $simple_update + and (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra $net_query2 LIMIT 1", + dbesc($mid . '%') + ); + } else { + $r = q("SELECT parent AS item_id FROM item + left join abook on item.author_xchan = abook.abook_xchan + $net_query + WHERE true $uids $item_normal_update + $simple_update + and (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra $net_query2"); + } + } + + // Then fetch all the children of the parents that are on this page + $parents_str = ''; + $update_unseen = ''; + + if ($r) { + $parents_str = ids_to_querystr($r, 'item_id'); + + $items = q( + "SELECT item.*, item.id AS item_id FROM item + WHERE true $uids $item_normal + AND item.parent IN ( %s ) + $sql_extra ", + dbesc($parents_str) + ); + + // use effective_uid param of xchan_query to help sort out comment permission + // for sys_channel owned items. + + xchan_query($items, true, (($sys) ? local_channel() : 0)); + $items = fetch_post_tags($items, true); + $items = conv_sort($items, $ordering); + } else { + $items = []; + } + } + + if ($mid && local_channel()) { + $ids = ids_to_array($items, 'item_id'); + $seen = PConfig::Get(local_channel(), 'system', 'seen_items', []); + if (!$seen) { + $seen = []; + } + $seen = array_merge($ids, $seen); + PConfig::Set(local_channel(), 'system', 'seen_items', $seen); + } + + // fake it + $mode = ('pubstream'); + + $o .= conversation($items, $mode, $this->updating, $page_mode); + + if ($mid) { + $o .= '
      '; + } + + if (($items) && (!$this->updating)) { + $o .= alt_pager(count($items)); + } + + return $o; + } +} diff --git a/Code/Module/Q.php b/Code/Module/Q.php new file mode 100644 index 000000000..d67eac03a --- /dev/null +++ b/Code/Module/Q.php @@ -0,0 +1,30 @@ + false]; + + $h = argv(1); + if (!$h) { + json_return_and_die($ret); + } + + $r = q( + "select * from hubloc left join site on hubloc_url = site_url where hubloc_hash = '%s' and site_dead = 0", + dbesc($h) + ); + if ($r) { + $ret['success'] = true; + $ret['results'] = ids_to_array($r, 'hubloc_id_url'); + } + json_return_and_die($ret); + } +} diff --git a/Code/Module/README.md b/Code/Module/README.md new file mode 100644 index 000000000..a6c1917e1 --- /dev/null +++ b/Code/Module/README.md @@ -0,0 +1,76 @@ +Code/Module +============== + + +This directory contains controller modules for handling web requests. The +lowercase class name indicates the head of the URL path which this module +handles. There are other methods of attaching (routing) URL paths to +controllers, but this is the primary method used in this project. + +Module controllers MUST reside in this directory and namespace to be +autoloaded (unless other specific routing methods are employed). They +typically use and extend the class definition in Code/Web/Controller +as a template. + +Template: + + $item['mid']]); + $n['obj_type'] = ((array_path_exists('obj/type', $n)) ? $n['obj']['type'] : EMPTY_STR); + + $n['tgt_type'] = 'Image'; + + $n['target'] = [ + 'type' => 'Image', + 'name' => $emoji, + 'url' => z_root() . '/images/emoji/' . $emoji . '.png' + ]; + + $x = item_store($n); + + retain_item($postid); + + if ($x['success']) { + $nid = $x['item_id']; + Run::Summon(['Notifier', 'like', $nid]); + } + } + } +} diff --git a/Code/Module/Register.php b/Code/Module/Register.php new file mode 100644 index 000000000..fae8eea39 --- /dev/null +++ b/Code/Module/Register.php @@ -0,0 +1,314 @@ + 1) ? argv(1) : ''); + + // Provide a stored request for somebody desiring a connection + // when they first need to register someplace. Once they've + // created a channel, we'll try to revive the connection request + // and process it. + + if ($_REQUEST['connect']) { + $_SESSION['connect'] = $_REQUEST['connect']; + } + + switch ($cmd) { + case 'invite_check.json': + $result = Account::check_invite($_REQUEST['invite_code']); + break; + case 'email_check.json': + $result = Account::check_email($_REQUEST['email']); + break; + case 'password_check.json': + $result = Account::check_password($_REQUEST['password1']); + break; + default: + break; + } + if ($result) { + json_return_and_die($result); + } + } + + + public function post() + { + // security token is session_id() based and we have a transient session, so we can't use it here. + //check_form_security_token_redirectOnErr('/register', 'register'); + + $max_dailies = intval(get_config('system', 'max_daily_registrations')); + if ($max_dailies) { + $r = q( + "select count(account_id) as total from account where account_created > %s - INTERVAL %s", + db_utcnow(), + db_quoteinterval('1 day') + ); + if ($r && intval($r[0]['total']) >= $max_dailies) { + notice(t('Maximum daily site registrations exceeded. Please try again tomorrow.') . EOL); + return; + } + } + + if (Config::Get('system','tos_required') && !(isset($_POST['tos']) && intval($_POST['tos']))) { + notice(t('Please indicate acceptance of the Terms of Service. Registration failed.') . EOL); + return; + } + + $policy = get_config('system', 'register_policy'); + + $email_verify = get_config('system', 'verify_email'); + + + switch ($policy) { + case REGISTER_OPEN: + $flags = ACCOUNT_OK; + break; + + case REGISTER_APPROVE: + $flags = ACCOUNT_BLOCKED | ACCOUNT_PENDING; + break; + + default: + case REGISTER_CLOSED: + if (!is_site_admin()) { + notice(t('Permission denied.') . EOL); + return; + } + $flags = ACCOUNT_BLOCKED; + break; + } + + if ($email_verify && $policy == REGISTER_OPEN) { + $flags = $flags | ACCOUNT_UNVERIFIED; + } + + + if ((!$_POST['password']) || ($_POST['password'] !== $_POST['password2'])) { + notice(t('Passwords do not match.') . EOL); + return; + } + + $arr = $_POST; + $arr['account_flags'] = $flags; + + $result = Account::create($arr); + + if (!$result['success']) { + notice($result['message']); + return; + } + + require_once('include/security.php'); + + + if ($_REQUEST['name']) { + set_aconfig($result['account']['account_id'], 'register', 'channel_name', $_REQUEST['name']); + } + if ($_REQUEST['nickname']) { + set_aconfig($result['account']['account_id'], 'register', 'channel_address', $_REQUEST['nickname']); + } + if ($_REQUEST['permissions_role']) { + set_aconfig($result['account']['account_id'], 'register', 'permissions_role', $_REQUEST['permissions_role']); + } + + // At this point the account has been created without error. Purge any error messages from prior failed registration + // attempts which haven't yet been delivered to the browser and start fresh. If you're willing to figure out why they + // weren't delivered to the browser please adopt zap issue 34. + + $_SESSION['sysmsg'] = []; + + $using_invites = intval(get_config('system', 'invitation_only')); + $num_invites = intval(get_config('system', 'number_invites')); + $invite_code = ((x($_POST, 'invite_code')) ? notags(trim($_POST['invite_code'])) : ''); + + if ($using_invites && $invite_code && defined('INVITE_WORKING')) { + q("delete from register where hash = '%s'", dbesc($invite_code)); + // @FIXME - this also needs to be considered when using 'invites_remaining' in mod/invite.php + set_aconfig($result['account']['account_id'], 'system', 'invites_remaining', $num_invites); + } + + if ($policy == REGISTER_OPEN) { + if ($email_verify) { + $res = Account::verify_email_address($result); + } else { + $res = Account::send_register_success_email($result['email'], $result['password']); + } + if ($res) { + if ($invite_code) { + info(t('Registration successful. Continue to create your first channel...') . EOL); + } else { + info(t('Registration successful. Please check your email for validation instructions.') . EOL); + } + } + } elseif ($policy == REGISTER_APPROVE) { + $res = Account::send_reg_approval_email($result); + if ($res) { + info(t('Your registration is pending approval by the site owner.') . EOL); + } else { + notice(t('Your registration can not be processed.') . EOL); + } + goaway(z_root()); + } + + if ($email_verify) { + goaway(z_root() . '/email_validation/' . bin2hex($result['email'])); + } + + // fall through and authenticate if no approvals or verifications were required. + + authenticate_success($result['account'], null, true, false, true); + + $new_channel = false; + $next_page = 'new_channel'; + + if (get_config('system', 'auto_channel_create')) { + $new_channel = Channel::auto_create($result['account']['account_id']); + if ($new_channel['success']) { + $channel_id = $new_channel['channel']['channel_id']; + change_channel($channel_id); + $next_page = '~'; + } else { + $new_channel = false; + } + } + + $x = get_config('system', 'workflow_register_next'); + if ($x) { + $next_page = $x; + $_SESSION['workflow'] = true; + } + + unset($_SESSION['login_return_url']); + goaway(z_root() . '/' . $next_page); + } + + + public function get() + { + + $registration_is = EMPTY_STR; + $other_sites = false; + + if (intval(get_config('system', 'register_policy')) === REGISTER_CLOSED) { + notice(t('Registration on this website is disabled.') . EOL); + if (intval(get_config('system', 'directory_mode')) === DIRECTORY_MODE_STANDALONE) { + return EMPTY_STR; + } else { + $other_sites = true; + } + } + + if (intval(get_config('system', 'register_policy')) == REGISTER_APPROVE) { + $registration_is = t('Registration on this website is by approval only.'); + $other_sites = true; + } + + $invitations = false; + + if (intval(get_config('system', 'invitation_only')) && defined('INVITE_WORKING')) { + $invitations = true; + $registration_is = t('Registration on this site is by invitation only.'); + $other_sites = true; + } + + $max_dailies = intval(get_config('system', 'max_daily_registrations')); + if ($max_dailies) { + $r = q( + "select count(account_id) as total from account where account_created > %s - INTERVAL %s", + db_utcnow(), + db_quoteinterval('1 day') + ); + if ($r && $r[0]['total'] >= $max_dailies) { + logger('max daily registrations exceeded.'); + notice(t('This site has exceeded the number of allowed daily account registrations. Please try again tomorrow.') . EOL); + return; + } + } + + $privacy_role = ((x($_REQUEST, 'permissions_role')) ? $_REQUEST['permissions_role'] : ""); + + $perm_roles = PermissionRoles::roles(); + + // Configurable terms of service link + + $tosurl = get_config('system', 'tos_url'); + if (!$tosurl) { + $tosurl = z_root() . '/help/TermsOfService'; + } + + $toslink = '' . t('Terms of Service') . ''; + + // Configurable whether to restrict age or not - default is based on international legal requirements + // This can be relaxed if you are on a restricted server that does not share with public servers + + if (get_config('system', 'no_age_restriction')) { + $label_tos = sprintf(t('I accept the %s for this website'), $toslink); + } else { + $age = get_config('system', 'minimum_age'); + if (!$age) { + $age = 13; + } + $label_tos = sprintf(t('I am over %s years of age and accept the %s for this website'), $age, $toslink); + } + + $enable_tos = Config::Get('system','tos_required'); + + $email = ['email', t('Your email address'), ((x($_REQUEST, 'email')) ? strip_tags(trim($_REQUEST['email'])) : ""), '', '', ' required ']; + $password = ['password', t('Choose a password'), '', '', '', ' required ']; + $password2 = ['password2', t('Please re-enter your password'), '', '', '', ' required ']; + $invite_code = ['invite_code', t('Please enter your invitation code'), ((x($_REQUEST, 'invite_code')) ? strip_tags(trim($_REQUEST['invite_code'])) : "")]; + $name = ['name', t('Your Name'), ((x($_REQUEST, 'name')) ? $_REQUEST['name'] : ''), t('Real names are preferred.')]; + $nickhub = '@' . str_replace(array('http://', 'https://', '/'), '', get_config('system', 'baseurl')); + $nickname = ['nickname', t('Choose a short nickname'), ((x($_REQUEST, 'nickname')) ? $_REQUEST['nickname'] : ''), sprintf(t('Your nickname will be used to create an easy to remember channel address e.g. nickname%s'), $nickhub)]; + $role = ['permissions_role', t('Channel role and privacy'), ($privacy_role) ? $privacy_role : 'social', t('Select a channel permission role for your usage needs and privacy requirements.'), $perm_roles]; + $tos = ['tos', $label_tos, '', '', [t('no'), t('yes')], ' required ']; + + + $auto_create = (get_config('system', 'auto_channel_create') ? true : false); + $default_role = get_config('system', 'default_permissions_role'); + $email_verify = get_config('system', 'verify_email'); + + + $o = replace_macros(Theme::get_template('register.tpl'), [ + '$form_security_token' => get_form_security_token("register"), + '$title' => t('Registration'), + '$reg_is' => $registration_is, + '$registertext' => bbcode(get_config('system', 'register_text')), + '$other_sites' => (($other_sites) ? t('Show affiliated sites - some of which may allow registration.') : EMPTY_STR), + '$invitations' => $invitations, + '$invite_code' => $invite_code, + '$auto_create' => $auto_create, + '$name' => $name, + '$role' => $role, + '$default_role' => $default_role, + '$nickname' => $nickname, + '$enable_tos' => $enable_tos, + '$tos' => $tos, + '$email' => $email, + '$pass1' => $password, + '$pass2' => $password2, + '$submit' => t('Register'), + '$verify_note' => (($email_verify) ? t('This site requires email verification. After completing this form, please check your email for further instructions.') : ''), + ]); + + return $o; + } +} diff --git a/Code/Module/Regmod.php b/Code/Module/Regmod.php new file mode 100644 index 000000000..7e94aac8b --- /dev/null +++ b/Code/Module/Regmod.php @@ -0,0 +1,50 @@ + NULL_DATE) { + $d1 = datetime_convert('UTC', 'UTC', 'now - 48 hours'); + if ($account['account_password_changed'] > d1) { + notice(t('Account removals are not allowed within 48 hours of changing the account password.') . EOL); + return; + } + } + + Account::remove($account_id); + } + + public function get() + { + + if (!local_channel()) { + goaway(z_root()); + } + + $hash = random_string(); + + $_SESSION['remove_account_verify'] = $hash; + + $o .= replace_macros(Theme::get_template('removeaccount.tpl'), [ + '$basedir' => z_root(), + '$hash' => $hash, + '$title' => t('Remove This Account'), + '$desc' => [t('WARNING: '), t('This account and all its channels will be completely removed from this server. '), t('This action is permanent and can not be undone!')], + '$passwd' => t('Please enter your password for verification:'), + '$submit' => t('Remove Account') + ]); + + return $o; + } +} diff --git a/Code/Module/Removeme.php b/Code/Module/Removeme.php new file mode 100644 index 000000000..8cf6f8dfb --- /dev/null +++ b/Code/Module/Removeme.php @@ -0,0 +1,82 @@ + NULL_DATE) { + $d1 = datetime_convert('UTC', 'UTC', 'now - 48 hours'); + if ($account['account_password_changed'] > $d1) { + notice(t('Channel removals are not allowed within 48 hours of changing the account password.') . EOL); + return; + } + } + + Channel::channel_remove(local_channel(), true, true); + } + + + public function get() + { + + if (!local_channel()) { + goaway(z_root()); + } + + $hash = random_string(); + + $_SESSION['remove_channel_verify'] = $hash; + + $o .= replace_macros(Theme::get_template('removeme.tpl'), [ + '$basedir' => z_root(), + '$hash' => $hash, + '$title' => t('Remove This Channel'), + '$desc' => [t('WARNING: '), t('This channel will be completely removed from this server. '), t('This action is permanent and can not be undone!')], + '$passwd' => t('Please enter your password for verification:'), + '$submit' => t('Remove Channel') + ]); + + return $o; + } +} diff --git a/Code/Module/Rmagic.php b/Code/Module/Rmagic.php new file mode 100644 index 000000000..62634a048 --- /dev/null +++ b/Code/Module/Rmagic.php @@ -0,0 +1,96 @@ + $address]; + Hook::call('reverse_magic_auth', $arr); + + // if they're still here... + notice(t('Authentication failed.') . EOL); + return; + } else { + // Presumed Red identity. Perform reverse magic auth + + if (strpos($address, '@') === false) { + notice('Invalid address.'); + return; + } + + $r = null; + if ($address) { + $r = q( + "select hubloc_url from hubloc where hubloc_addr = '%s' limit 1", + dbesc($address) + ); + } + if ($r) { + $url = $r[0]['hubloc_url']; + } else { + $url = 'https://' . substr($address, strpos($address, '@') + 1); + } + + if ($url) { + if ($_SESSION['return_url']) { + $dest = bin2hex(z_root() . '/' . str_replace('zid=', 'zid_=', $_SESSION['return_url'])); + } else { + $dest = bin2hex(z_root() . '/' . str_replace(['rmagic', 'zid='], ['', 'zid_='], App::$query_string)); + } + goaway($url . '/magic' . '?f=&owa=1&bdest=' . $dest); + } + } + } + + + public function get() + { + return replace_macros( + Theme::get_template('rmagic.tpl'), + [ + '$title' => t('Remote Authentication'), + '$address' => ['address', t('Enter your channel address (e.g. channel@example.com)'), '', ''], + '$action' => 'rmagic', + '$method' => 'post', + '$submit' => t('Authenticate') + ] + ); + } +} diff --git a/Code/Module/Rpost.php b/Code/Module/Rpost.php new file mode 100644 index 000000000..c316487cb --- /dev/null +++ b/Code/Module/Rpost.php @@ -0,0 +1,258 @@ + $arg) { + if ($key === 'req') { + continue; + } + $url .= '&' . $key . '=' . $arg; + } + goaway($url); + } + } + + // The login procedure is going to bugger our $_REQUEST variables + // so save them in the session. + + if (array_key_exists('body', $_REQUEST)) { + $_SESSION['rpost'] = $_REQUEST; + } + return login(); + } + + Navbar::set_selected('Post'); + + if (local_channel() && array_key_exists('userfile', $_FILES)) { + $channel = App::get_channel(); + $observer = App::get_observer(); + + $def_album = get_pconfig($channel['channel_id'], 'system', 'photo_path'); + $def_attach = get_pconfig($channel['channel_id'], 'system', 'attach_path'); + + $r = attach_store($channel, (($observer) ? $observer['xchan_hash'] : ''), '', [ + 'source' => 'editor', + 'visible' => 0, + 'album' => $def_album, + 'directory' => $def_attach, + 'flags' => 1, // indicates temporary permissions are created + 'allow_cid' => '<' . $channel['channel_hash'] . '>' + ]); + + if (!$r['success']) { + notice($r['message'] . EOL); + } + + $s = EMPTY_STR; + + if (intval($r['data']['is_photo'])) { + $s .= "\n\n" . $r['body'] . "\n\n"; + } + + $url = z_root() . '/cloud/' . $channel['channel_address'] . '/' . $r['data']['display_path']; + + if (strpos($r['data']['filetype'], 'video') === 0) { + for ($n = 0; $n < 15; $n++) { + $thumb = Linkinfo::get_video_poster($url); + if ($thumb) { + break; + } + sleep(1); + continue; + } + + if ($thumb) { + $s .= "\n\n" . '[zvideo poster=\'' . $thumb . '\']' . $url . '[/zvideo]' . "\n\n"; + } else { + $s .= "\n\n" . '[zvideo]' . $url . '[/zvideo]' . "\n\n"; + } + } + if (strpos($r['data']['filetype'], 'audio') === 0) { + $s .= "\n\n" . '[zaudio]' . $url . '[/zaudio]' . "\n\n"; + } + if ($r['data']['filetype'] === 'image/svg+xml') { + $x = @file_get_contents('store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + if ($x) { + $bb = svg2bb($x); + if ($bb) { + $s .= "\n\n" . $bb; + } else { + logger('empty return from svgbb'); + } + } else { + logger('unable to read svg data file: ' . 'store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + } + } + if ($r['data']['filetype'] === 'text/vnd.abc' && Addon::is_installed('abc')) { + $x = @file_get_contents('store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + if ($x) { + $s .= "\n\n" . '[abc]' . $x . '[/abc]'; + } else { + logger('unable to read ABC data file: ' . 'store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + } + } + if ($r['data']['filetype'] === 'text/calendar') { + $content = @file_get_contents('store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + if ($content) { + $ev = ical_to_ev($content); + if ($ev) { + $s .= "\n\n" . format_event_bbcode($ev[0]) . "\n\n"; + } + } + } + + $s .= "\n\n" . '[attachment]' . $r['data']['hash'] . ',' . $r['data']['revision'] . '[/attachment]' . "\n"; + $_REQUEST['body'] = ((array_key_exists('body', $_REQUEST)) ? $_REQUEST['body'] . $s : $s); + } + + // If we have saved rpost session variables, but nothing in the current $_REQUEST, recover the saved variables + + if ((!array_key_exists('body', $_REQUEST)) && (array_key_exists('rpost', $_SESSION))) { + $_REQUEST = $_SESSION['rpost']; + unset($_SESSION['rpost']); + } + + if (array_key_exists('channel', $_REQUEST)) { + $r = q( + "select channel_id from channel where channel_account_id = %d and channel_address = '%s' limit 1", + intval(get_account_id()), + dbesc($_REQUEST['channel']) + ); + if ($r) { + require_once('include/security.php'); + $change = change_channel($r[0]['channel_id']); + } + } + + if ($_REQUEST['remote_return']) { + $_SESSION['remote_return'] = $_REQUEST['remote_return']; + } + + if (argc() > 1 && argv(1) === 'return') { + if ($_SESSION['remote_return']) { + goaway($_SESSION['remote_return']); + } + goaway(z_root() . '/stream'); + } + + $plaintext = true; + + if (array_key_exists('type', $_REQUEST) && $_REQUEST['type'] === 'html' && isset($_REQUEST['body'])) { + require_once('include/html2bbcode.php'); + $_REQUEST['body'] = html2bbcode($_REQUEST['body']); + } + + $channel = App::get_channel(); + + $acl = new AccessControl($channel); + + if (array_key_exists('to', $_REQUEST) && $_REQUEST['to']) { + $acl->set(['allow_cid' => '<' . $_REQUEST['to'] . '>', + 'allow_gid' => EMPTY_STR, + 'deny_cid' => EMPTY_STR, + 'deny_gid' => EMPTY_STR]); + if (! (isset($_REQUEST['body']) && $_REQUEST['body'])) { + $xchan = q("select * from xchan where xchan_hash = '%s'", + dbesc($_REQUEST['to']) + ); + + if ($xchan) { + $_REQUEST['body'] .= '@!{' . (($xchan[0]['xchan_addr']) ? $xchan[0]['xchan_addr'] : $xchan[0]['xchan_url']) . '} ' ; + } + } + } + + $channel_acl = $acl->get(); + + if ($_REQUEST['url']) { + $x = z_fetch_url(z_root() . '/linkinfo?f=&url=' . urlencode($_REQUEST['url']) . '&oembed=1&zotobj=1'); + if ($x['success']) { + $_REQUEST['body'] = $_REQUEST['body'] . $x['body']; + } + } + + if ($_REQUEST['post_id']) { + $_REQUEST['body'] .= '[share=' . intval($_REQUEST['post_id']) . '][/share]'; + } + + $x = [ + 'is_owner' => true, + 'allow_location' => ((intval(get_pconfig($channel['channel_id'], 'system', 'use_browser_location'))) ? '1' : ''), + 'default_location' => $channel['channel_location'], + 'nickname' => $channel['channel_address'], + 'lockstate' => (($acl->is_private()) ? 'lock' : 'unlock'), + 'acl' => Libacl::populate($channel_acl, true, PermissionDescription::fromGlobalPermission('view_stream'), Libacl::get_post_aclDialogDescription(), 'acl_dialog_post'), + 'permissions' => $channel_acl, + 'bang' => '', + 'visitor' => true, + 'profile_uid' => local_channel(), + 'title' => $_REQUEST['title'], + 'body' => $_REQUEST['body'], + 'attachment' => $_REQUEST['attachment'], + 'source' => ((x($_REQUEST, 'source')) ? strip_tags($_REQUEST['source']) : ''), + 'return_path' => 'rpost/return', + 'bbco_autocomplete' => 'bbcode', + 'editor_autocomplete' => true, + 'bbcode' => true, + 'jotnets' => true, + 'reset' => t('Reset form') + ]; + + $editor = status_editor($x); + + $o .= replace_macros(Theme::get_template('edpost_head.tpl'), [ + '$title' => t('Edit post'), + '$cancel' => '', + '$editor' => $editor + ]); + + return $o; + } +} diff --git a/Code/Module/Safe.php b/Code/Module/Safe.php new file mode 100644 index 000000000..b22fceb10 --- /dev/null +++ b/Code/Module/Safe.php @@ -0,0 +1,21 @@ +loading) { + $_SESSION['loadtime'] = datetime_convert(); + } + Navbar::set_selected('Search'); + + $format = (($_REQUEST['format']) ? $_REQUEST['format'] : ''); + if ($format !== '') { + $this->updating = $this->loading = 1; + } + + $observer = App::get_observer(); + $observer_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $o = '' . "\r\n"; + $o .= '
      ' . "\r\n"; + $o .= '

      ' . t('Search') . '

      '; + + if (x(App::$data, 'search')) { + $search = trim(App::$data['search']); + } else { + $search = ((x($_GET, 'search')) ? trim(escape_tags(rawurldecode($_GET['search']))) : ''); + } + $tag = false; + if (x($_GET, 'tag')) { + $tag = true; + $search = ((x($_GET, 'tag')) ? trim(escape_tags(rawurldecode($_GET['tag']))) : ''); + } + + $static = ((array_key_exists('static', $_REQUEST)) ? intval($_REQUEST['static']) : 0); + + $o .= search($search, 'search-box', '/search', ((local_channel()) ? true : false)); + + // ActivityStreams object fetches from the navbar + + if (local_channel() && strpos($search, 'https://') === 0 && (!$this->updating) && (!$this->loading)) { + logger('searching for ActivityPub'); + if (($pos = strpos($search,'b64.')) !== false) { + $search = substr($search,$pos + 4); + if (($pos2 = strpos($search,'?')) !== false) { + $search = substr($search,0,$pos2); + } + $search = base64_decode($search); + } + logger('Search: ' . $search); + $channel = App::get_channel(); + $hash = EMPTY_STR; + $j = Activity::fetch($search, $channel); + if ($j) { + if (isset($j['type']) && ActivityStreams::is_an_actor($j['type'])) { + Activity::actor_store($j['id'], $j); + goaway(z_root() . '/directory' . '?f=1&navsearch=1&search=' . $search); + } + $AS = new ActivityStreams($j, null, true); + if ($AS->is_valid() && isset($AS->data['type'])) { + if (is_array($AS->obj)) { + // matches Collection and orderedCollection + if (isset($AS->obj['type']) && strpos($AS->obj['type'], 'Collection') !== false) { + // Collections are awkward to process because they can be huge. + // Our strategy is to limit a navbar search to 100 Collection items + // and only fetch the first 10 conversations in the foreground. + // We'll queue the rest, and then send you to a page where + // you can see something we've imported. + // In theory you'll start to see notifications as other conversations + // are fetched in the background while you're looking at the first ones. + + $max = intval(get_config('system', 'max_imported_search_collection', 100)); + + if (intval($max)) { + $obj = new ASCollection($search, $channel, 0, $max); + $messages = $obj->get(); + // logger('received: ' . print_r($messages,true)); + $author = null; + if ($messages) { + logger('received ' . count($messages) . ' items from collection.', LOGGER_DEBUG); + $processed = 0; + foreach ($messages as $message) { + $processed++; + // only process the first several items in the foreground and + // queue the remainder. + if ($processed > 10) { + $fetch_url = ((is_string($message)) ? $message : EMPTY_STR); + $fetch_url = ((is_array($message) && array_key_exists('id', $message)) ? $message['id'] : $fetch_url); + + if (!$fetch_url) { + continue; + } + + $hash = new_uuid(); + Queue::insert( + [ + 'hash' => $hash, + 'account_id' => $channel['channel_account_id'], + 'channel_id' => $channel['channel_id'], + 'posturl' => $fetch_url, + 'notify' => EMPTY_STR, + 'msg' => EMPTY_STR, + 'driver' => 'asfetch' + ] + ); + continue; + } + + if (is_string($message)) { + $message = Activity::fetch($message, App::get_channel()); + } + $AS = new ActivityStreams($message, null, true); + if ($AS->is_valid() && is_array($AS->obj)) { + $item = Activity::decode_note($AS, true); + } + if ($item) { + if (!$author) { + $author = $item['author_xchan']; + } + Activity::store(App::get_channel(), get_observer_hash(), $AS, $item, true, true); + } + } + if ($hash) { + Run::Summon(['Deliver', $hash]); + } + } + + // This will go to the right place most but not all of the time. + // It will go to a relevant place all of the time, so we'll use it. + + if ($author) { + goaway(z_root() . '/stream/?xchan=' . urlencode($author)); + } + goaway(z_root() . '/stream'); + } + } else { + // It wasn't a Collection object and wasn't an Actor object, + // so let's see if it decodes. The boolean flag enables html + // cache of the item + $item = Activity::decode_note($AS, true); + if ($item) { + Activity::store(App::get_channel(), get_observer_hash(), $AS, $item, true, true); + goaway(z_root() . '/display/' . gen_link_id($item['mid'])); + } + else { + notice( t('Item not found.') . EOL); + return EMPTY_STR; + } + } + } + } + } + } + + if (strpos($search, '#') === 0) { + $tag = true; + $search = substr($search, 1); + } + if (strpos($search, '@') === 0) { + $search = substr($search, 1); + goaway(z_root() . '/directory' . '?f=1&navsearch=1&search=' . $search); + } + if (strpos($search, '!') === 0) { + $search = substr($search, 1); + goaway(z_root() . '/directory' . '?f=1&navsearch=1&search=' . $search); + } + if (strpos($search, '?') === 0) { + $search = substr($search, 1); + goaway(z_root() . '/help' . '?f=1&navsearch=1&search=' . $search); + } + + // look for a naked webbie + if (strpos($search, '@') !== false && strpos($search, 'http') !== 0) { + goaway(z_root() . '/directory' . '?f=1&navsearch=1&search=' . $search); + } + + if (!$search) { + return $o; + } + + if ($tag) { + $wildtag = str_replace('*', '%', $search); + $sql_extra = sprintf( + " AND item.id IN (select oid from term where otype = %d and ttype in ( %d , %d) and term like '%s') ", + intval(TERM_OBJ_POST), + intval(TERM_HASHTAG), + intval(TERM_COMMUNITYTAG), + dbesc(protect_sprintf($wildtag)) + ); + } else { + $regstr = db_getfunc('REGEXP'); + $sql_extra = sprintf(" AND (item.title $regstr '%s' OR item.body $regstr '%s') ", dbesc(protect_sprintf(preg_quote($search))), dbesc(protect_sprintf(preg_quote($search)))); + } + + // Here is the way permissions work in the search module... + // Only public posts can be shown + // OR your own posts if you are a logged in member + // No items will be shown if the member has a blocked profile wall. + + + if ((!$this->updating) && (!$this->loading)) { + $static = ((local_channel()) ? Channel::manual_conv_update(local_channel()) : 0); + + + // This is ugly, but we can't pass the profile_uid through the session to the ajax updater, + // because browser prefetching might change it on us. We have to deliver it with the page. + + $o .= '' . "\r\n"; + $o .= "\r\n"; + + App::$page['htmlhead'] .= replace_macros(Theme::get_template("build_query.tpl"), [ + '$baseurl' => z_root(), + '$pgtype' => 'search', + '$uid' => ((App::$profile['profile_uid']) ? App::$profile['profile_uid'] : '0'), + '$gid' => '0', + '$cid' => '0', + '$cmin' => '(-1)', + '$cmax' => '(-1)', + '$star' => '0', + '$liked' => '0', + '$conv' => '0', + '$spam' => '0', + '$fh' => '0', + '$dm' => '0', + '$nouveau' => '0', + '$wall' => '0', + '$draft' => '0', + '$static' => $static, + '$list' => ((x($_REQUEST, 'list')) ? intval($_REQUEST['list']) : 0), + '$page' => ((App::$pager['page'] != 1) ? App::$pager['page'] : 1), + '$search' => (($tag) ? urlencode('#') : '') . $search, + '$xchan' => '', + '$order' => '', + '$file' => '', + '$cats' => '', + '$tags' => '', + '$mid' => '', + '$verb' => '', + '$net' => '', + '$dend' => '', + '$dbegin' => '' + ]); + } + + $item_normal = item_normal_search(); + $pub_sql = item_permissions_sql(0, $observer_hash); + + $sys = Channel::get_system(); + + if (($this->updating) && ($this->loading)) { + $itemspage = get_pconfig(local_channel(), 'system', 'itemspage'); + App::set_pager_itemspage(((intval($itemspage)) ? $itemspage : 20)); + $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval(App::$pager['itemspage']), intval(App::$pager['start'])); + + if ($this->loading) { + $r = null; + + // if logged in locally, first look in the items you own + // and if this returns zero results, resort to searching elsewhere on the site. + // Ideally these results would be merged but this can be difficult + // and results in lots of duplicated content and/or messed up pagination + + if (local_channel()) { + $r = q( + "SELECT mid, MAX(id) as item_id from item where uid = %d + $item_normal + $sql_extra + group by mid, created order by created desc $pager_sql ", + intval(local_channel()) + ); + } + if (!$r) { + $r = q("SELECT mid, MAX(id) as item_id from item WHERE true $pub_sql + $item_normal + $sql_extra + group by mid, created order by created desc $pager_sql"); + } + if ($r) { + $str = ids_to_querystr($r, 'item_id'); + $r = q("select *, id as item_id from item where id in ( " . $str . ") order by created desc "); + } + } else { + $r = []; + } + } + + if ($r) { + xchan_query($r); + $items = fetch_post_tags($r, true); + } else { + $items = []; + } + + if ($format == 'json') { + $result = []; + require_once('include/conversation.php'); + foreach ($items as $item) { + $item['html'] = zidify_links(bbcode($item['body'])); + $x = encode_item($item); + $x['html'] = prepare_text($item['body'], $item['mimetype']); + $result[] = $x; + } + json_return_and_die(array('success' => true, 'messages' => $result)); + } + + if ($tag) { + $o .= '

      ' . sprintf(t('Items tagged with: %s'), $search) . '

      '; + } else { + $o .= '

      ' . sprintf(t('Search results for: %s'), $search) . '

      '; + } + + $o .= conversation($items, 'search', $this->updating, 'client'); + + $o .= '
      '; + + return $o; + } +} diff --git a/Code/Module/Search_ac.php b/Code/Module/Search_ac.php new file mode 100644 index 000000000..dc6edca40 --- /dev/null +++ b/Code/Module/Search_ac.php @@ -0,0 +1,101 @@ + $g['xchan_photo_s'], + 'name' => '@' . $g['xchan_name'], + 'id' => $g['abook_id'], + 'link' => $g['xchan_url'], + 'label' => '', + 'nick' => '', + ]; + } + } + } + + if ($do_tags) { + $r = q( + "select distinct term, tid, url from term + where ttype in ( %d, %d ) $tag_sql_extra group by term order by term asc", + intval(TERM_HASHTAG), + intval(TERM_COMMUNITYTAG) + ); + + if ($r) { + foreach ($r as $g) { + $results[] = [ + 'photo' => z_root() . '/images/hashtag.png', + 'name' => '#' . $g['term'], + 'id' => $g['tid'], + 'link' => $g['url'], + 'label' => '', + 'nick' => '', + ]; + } + } + } + + json_return_and_die([ + 'start' => $start, + 'count' => $count, + 'items' => $results, + ]); + } +} diff --git a/Code/Module/Secrets.php b/Code/Module/Secrets.php new file mode 100644 index 000000000..1a9abc9cb --- /dev/null +++ b/Code/Module/Secrets.php @@ -0,0 +1,29 @@ +' . $desc . ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Secrets'))) { + return $text; + } + + $desc = t('This app is installed. A button to encrypt content may be found in the post editor.'); + + $text = ''; + + return $text; + } +} diff --git a/Code/Module/Service_limits.php b/Code/Module/Service_limits.php new file mode 100644 index 000000000..692ee249e --- /dev/null +++ b/Code/Module/Service_limits.php @@ -0,0 +1,30 @@ +sm = new SubModule(); + } + + + public function post() + { + + if (!local_channel()) { + return; + } + + if ($_SESSION['delegate']) { + return; + } + + // logger('mod_settings: ' . print_r($_REQUEST,true)); + + if (argc() > 1) { + if ($this->sm->call('post') !== false) { + return; + } + } + + goaway(z_root() . '/settings'); + } + + + public function get() + { + + Navbar::set_selected('Settings'); + + if ((!local_channel()) || ($_SESSION['delegate'])) { + notice(t('Permission denied.') . EOL); + return login(); + } + + + $channel = App::get_channel(); + if ($channel) { + head_set_icon($channel['xchan_photo_s']); + } + + $o = $this->sm->call('get'); + if ($o !== false) { + return $o; + } + + $o = EMPTY_STR; + } +} diff --git a/Code/Module/Settings/Account.php b/Code/Module/Settings/Account.php new file mode 100644 index 000000000..3ee53594f --- /dev/null +++ b/Code/Module/Settings/Account.php @@ -0,0 +1,125 @@ + get_form_security_token("settings_account"), + '$title' => t('Account Settings'), + '$origpass' => array('origpass', t('Current Password'), ' ', ''), + '$password1' => array('npassword', t('Enter New Password'), '', ''), + '$password2' => array('confirm', t('Confirm New Password'), '', t('Leave password fields blank unless changing')), + '$submit' => t('Submit'), + '$email' => array('email', t('Email Address:'), $email, ''), + '$removeme' => t('Remove Account'), + '$removeaccount' => t('Remove this account including all its channels'), + '$account_settings' => $account_settings + )); + return $o; + } +} diff --git a/Code/Module/Settings/Channel.php b/Code/Module/Settings/Channel.php new file mode 100644 index 000000000..d6284d467 --- /dev/null +++ b/Code/Module/Settings/Channel.php @@ -0,0 +1,773 @@ + $v) { + PermissionLimits::Set(local_channel(), $k, intval($_POST[$k])); + } + $acl = new AccessControl($channel); + $acl->set_from_array($_POST); + $x = $acl->get(); + + $r = q( + "update channel set channel_allow_cid = '%s', channel_allow_gid = '%s', + channel_deny_cid = '%s', channel_deny_gid = '%s' where channel_id = %d", + dbesc($x['allow_cid']), + dbesc($x['allow_gid']), + dbesc($x['deny_cid']), + dbesc($x['deny_gid']), + intval(local_channel()) + ); + } else { + $role_permissions = PermissionRoles::role_perms($_POST['permissions_role']); + if (!$role_permissions) { + notice('Permissions category could not be found.'); + return; + } + $hide_presence = 1 - (intval($role_permissions['online'])); + if ($role_permissions['default_collection']) { + $r = q( + "select hash from pgrp where uid = %d and gname = '%s' limit 1", + intval(local_channel()), + dbesc(t('Friends')) + ); + if (!$r) { + AccessList::add(local_channel(), t('Friends')); + AccessList::member_add(local_channel(), t('Friends'), $channel['channel_hash']); + $r = q( + "select hash from pgrp where uid = %d and gname = '%s' limit 1", + intval(local_channel()), + dbesc(t('Friends')) + ); + } + if ($r) { + q( + "update channel set channel_default_group = '%s', channel_allow_gid = '%s', channel_allow_cid = '', channel_deny_gid = '', channel_deny_cid = '' where channel_id = %d", + dbesc($r[0]['hash']), + dbesc('<' . $r[0]['hash'] . '>'), + intval(local_channel()) + ); + } else { + notice(sprintf('Default access list \'%s\' not found. Please create and re-submit permission change.', t('Friends')) . EOL); + return; + } + } // no default permissions + else { + q( + "update channel set channel_default_group = '', channel_allow_gid = '', channel_allow_cid = '', channel_deny_gid = '', + channel_deny_cid = '' where channel_id = %d", + intval(local_channel()) + ); + } + + if ($role_permissions['perms_connect']) { + $x = Permissions::FilledPerms($role_permissions['perms_connect']); + $str = Permissions::serialise($x); + set_abconfig(local_channel(), $channel['channel_hash'], 'system', 'my_perms', $str); + + $autoperms = intval($role_permissions['perms_auto']); + } + + if ($role_permissions['limits']) { + foreach ($role_permissions['limits'] as $k => $v) { + PermissionLimits::Set(local_channel(), $k, $v); + } + } + if (array_key_exists('directory_publish', $role_permissions)) { + $publish = intval($role_permissions['directory_publish']); + } + } + + set_pconfig(local_channel(), 'system', 'hide_online_status', $hide_presence); + set_pconfig(local_channel(), 'system', 'permissions_role', $role); + } + + // The post_comments permission is critical to privacy so we always allow you to set it, no matter what + // permission role is in place. + + $post_comments = array_key_exists('post_comments', $_POST) ? intval($_POST['post_comments']) : PERMS_SPECIFIC; + PermissionLimits::Set(local_channel(), 'post_comments', $post_comments); + + $post_mail = array_key_exists('post_mail', $_POST) ? intval($_POST['post_mail']) : PERMS_SPECIFIC; + PermissionLimits::Set(local_channel(), 'post_mail', $post_mail); + + + $publish = (((x($_POST, 'profile_in_directory')) && (intval($_POST['profile_in_directory']) == 1)) ? 1 : 0); + $username = ((x($_POST, 'username')) ? escape_tags(trim($_POST['username'])) : ''); + $timezone = ((x($_POST, 'timezone_select')) ? notags(trim($_POST['timezone_select'])) : ''); + $defloc = ((x($_POST, 'defloc')) ? notags(trim($_POST['defloc'])) : ''); + $openid = ((x($_POST, 'openid_url')) ? notags(trim($_POST['openid_url'])) : ''); + $maxreq = ((x($_POST, 'maxreq')) ? intval($_POST['maxreq']) : 0); + $expire = ((x($_POST, 'expire')) ? intval($_POST['expire']) : 0); + $evdays = ((x($_POST, 'evdays')) ? intval($_POST['evdays']) : 3); + $photo_path = ((x($_POST, 'photo_path')) ? escape_tags(trim($_POST['photo_path'])) : ''); + $attach_path = ((x($_POST, 'attach_path')) ? escape_tags(trim($_POST['attach_path'])) : ''); + $noindex = ((x($_POST, 'noindex')) ? intval($_POST['noindex']) : 0); + $channel_menu = ((x($_POST['channel_menu'])) ? htmlspecialchars_decode(trim($_POST['channel_menu']), ENT_QUOTES) : ''); + + $expire_items = ((x($_POST, 'expire_items')) ? intval($_POST['expire_items']) : 0); + $expire_starred = ((x($_POST, 'expire_starred')) ? intval($_POST['expire_starred']) : 0); + $expire_photos = ((x($_POST, 'expire_photos')) ? intval($_POST['expire_photos']) : 0); + $expire_network_only = ((x($_POST, 'expire_network_only')) ? intval($_POST['expire_network_only']) : 0); + + $allow_location = (((x($_POST, 'allow_location')) && (intval($_POST['allow_location']) == 1)) ? 1 : 0); + + $blocktags = (((x($_POST, 'blocktags')) && (intval($_POST['blocktags']) == 1)) ? 0 : 1); // this setting is inverted! + $unkmail = (((x($_POST, 'unkmail')) && (intval($_POST['unkmail']) == 1)) ? 1 : 0); + $cntunkmail = ((x($_POST, 'cntunkmail')) ? intval($_POST['cntunkmail']) : 0); + $suggestme = ((x($_POST, 'suggestme')) ? intval($_POST['suggestme']) : 0); +// $anymention = ((x($_POST,'anymention')) ? intval($_POST['anymention']) : 0); + $hyperdrive = ((x($_POST, 'hyperdrive')) ? intval($_POST['hyperdrive']) : 0); + $activitypub = ((x($_POST, 'activitypub')) ? intval($_POST['activitypub']) : 0); + $tag_username = ((x($_POST, 'tag_username')) ? intval($_POST['tag_username']) : 0); + $post_newfriend = (($_POST['post_newfriend'] == 1) ? 1 : 0); + $post_joingroup = (($_POST['post_joingroup'] == 1) ? 1 : 0); + $post_profilechange = (($_POST['post_profilechange'] == 1) ? 1 : 0); + $adult = (($_POST['adult'] == 1) ? 1 : 0); + $defpermcat = ((x($_POST, 'defpermcat')) ? notags(trim($_POST['defpermcat'])) : 'default'); + + $hide_friends = 1 - intval($_POST['hide_friends']); + + $cal_first_day = (((x($_POST, 'first_day')) && intval($_POST['first_day']) >= 0 && intval($_POST['first_day']) < 7) ? intval($_POST['first_day']) : 0); + $mailhost = ((array_key_exists('mailhost', $_POST)) ? notags(trim($_POST['mailhost'])) : ''); + $profile_assign = ((x($_POST, 'profile_assign')) ? notags(trim($_POST['profile_assign'])) : ''); + $permit_all_mentions = (($_POST['permit_all_mentions'] == 1) ? 1 : 0); + $close_comment_days = (($_POST['close_comments']) ? intval($_POST['close_comments']) : 0); + if ($close_comment_days) { + set_pconfig(local_channel(), 'system', 'close_comments', $close_comment_days . ' days'); + } else { + set_pconfig(local_channel(), 'system', 'close_comments', EMPTY_STR); + } + + // allow a permission change to over-ride the autoperms setting from the form + if (!isset($autoperms)) { + $autoperms = ((x($_POST, 'autoperms')) ? intval($_POST['autoperms']) : 0); + } + + + $pageflags = $channel['channel_pageflags']; + $existing_adult = (($pageflags & PAGE_ADULT) ? 1 : 0); + if ($adult != $existing_adult) { + $pageflags = ($pageflags ^ PAGE_ADULT); + } + + + $notify = 0; + + if (x($_POST, 'notify1')) { + $notify += intval($_POST['notify1']); + } + if (x($_POST, 'notify2')) { + $notify += intval($_POST['notify2']); + } + if (x($_POST, 'notify3')) { + $notify += intval($_POST['notify3']); + } + if (x($_POST, 'notify4')) { + $notify += intval($_POST['notify4']); + } + if (x($_POST, 'notify5')) { + $notify += intval($_POST['notify5']); + } + if (x($_POST, 'notify6')) { + $notify += intval($_POST['notify6']); + } + if (x($_POST, 'notify7')) { + $notify += intval($_POST['notify7']); + } + if (x($_POST, 'notify8')) { + $notify += intval($_POST['notify8']); + } + if (x($_POST, 'notify10')) { + $notify += intval($_POST['notify10']); + } + + + $vnotify = 0; + + if (x($_POST, 'vnotify1')) { + $vnotify += intval($_POST['vnotify1']); + } + if (x($_POST, 'vnotify2')) { + $vnotify += intval($_POST['vnotify2']); + } + if (x($_POST, 'vnotify3')) { + $vnotify += intval($_POST['vnotify3']); + } + if (x($_POST, 'vnotify4')) { + $vnotify += intval($_POST['vnotify4']); + } + if (x($_POST, 'vnotify5')) { + $vnotify += intval($_POST['vnotify5']); + } + if (x($_POST, 'vnotify6')) { + $vnotify += intval($_POST['vnotify6']); + } + if (x($_POST, 'vnotify7')) { + $vnotify += intval($_POST['vnotify7']); + } + if (x($_POST, 'vnotify8')) { + $vnotify += intval($_POST['vnotify8']); + } + if (x($_POST, 'vnotify9')) { + $vnotify += intval($_POST['vnotify9']); + } + if (x($_POST, 'vnotify10')) { + $vnotify += intval($_POST['vnotify10']); + } + if (x($_POST, 'vnotify11') && is_site_admin()) { + $vnotify += intval($_POST['vnotify11']); + } + if (x($_POST, 'vnotify12')) { + $vnotify += intval($_POST['vnotify12']); + } + if (x($_POST, 'vnotify13')) { + $vnotify += intval($_POST['vnotify13']); + } + if (x($_POST, 'vnotify14')) { + $vnotify += intval($_POST['vnotify14']); + } + if (x($_POST, 'vnotify15')) { + $vnotify += intval($_POST['vnotify15']); + } + if (x($_POST, 'vnotify16')) { + $vnotify += intval($_POST['vnotify16']); + } + + $always_show_in_notices = x($_POST, 'always_show_in_notices') ? 1 : 0; + + $err = ''; + + $name_change = false; + + if ($username != $channel['channel_name']) { + $name_change = true; + require_once('include/channel.php'); + $err = Zlib\Channel::validate_channelname($username); + if ($err) { + notice($err); + return; + } + } + + if ($timezone != $channel['channel_timezone']) { + if (strlen($timezone)) { + date_default_timezone_set($timezone); + } + } + + + $followed_tags = $_POST['followed_tags']; + $ntags = []; + if ($followed_tags) { + $tags = explode(',', $followed_tags); + foreach ($tags as $t) { + $t = trim($t); + if ($t) { + $ntags[] = $t; + } + } + } + + set_pconfig(local_channel(), 'system', 'followed_tags', ($ntags) ? $ntags : EMPTY_STR); + set_pconfig(local_channel(), 'system', 'use_browser_location', $allow_location); + set_pconfig(local_channel(), 'system', 'suggestme', $suggestme); + set_pconfig(local_channel(), 'system', 'post_newfriend', $post_newfriend); + set_pconfig(local_channel(), 'system', 'post_joingroup', $post_joingroup); + set_pconfig(local_channel(), 'system', 'post_profilechange', $post_profilechange); + set_pconfig(local_channel(), 'system', 'blocktags', $blocktags); + set_pconfig(local_channel(), 'system', 'channel_menu', $channel_menu); + set_pconfig(local_channel(), 'system', 'vnotify', $vnotify); + set_pconfig(local_channel(), 'system', 'always_show_in_notices', $always_show_in_notices); + set_pconfig(local_channel(), 'system', 'evdays', $evdays); + set_pconfig(local_channel(), 'system', 'photo_path', $photo_path); + set_pconfig(local_channel(), 'system', 'attach_path', $attach_path); + set_pconfig(local_channel(), 'system', 'cal_first_day', $cal_first_day); + set_pconfig(local_channel(), 'system', 'default_permcat', $defpermcat); + set_pconfig(local_channel(), 'system', 'email_notify_host', $mailhost); + set_pconfig(local_channel(), 'system', 'profile_assign', $profile_assign); +// set_pconfig(local_channel(),'system','anymention',$anymention); + set_pconfig(local_channel(), 'system', 'hyperdrive', $hyperdrive); + set_pconfig(local_channel(), 'system', 'activitypub', $activitypub); + set_pconfig(local_channel(), 'system', 'autoperms', $autoperms); + set_pconfig(local_channel(), 'system', 'tag_username', $tag_username); + set_pconfig(local_channel(), 'system', 'permit_all_mentions', $permit_all_mentions); + set_pconfig(local_channel(), 'system', 'noindex', $noindex); + + + $r = q( + "update channel set channel_name = '%s', channel_pageflags = %d, channel_timezone = '%s', channel_location = '%s', channel_notifyflags = %d, channel_max_anon_mail = %d, channel_max_friend_req = %d, channel_expire_days = %d $set_perms where channel_id = %d", + dbesc($username), + intval($pageflags), + dbesc($timezone), + dbesc($defloc), + intval($notify), + intval($unkmail), + intval($maxreq), + intval($expire), + intval(local_channel()) + ); + if ($r) { + info(t('Settings updated.') . EOL); + } + + + $r = q( + "UPDATE profile SET publish = %d, hide_friends = %d WHERE is_default = 1 AND uid = %d", + intval($publish), + intval($hide_friends), + intval(local_channel()) + ); + $r = q( + "UPDATE xchan SET xchan_hidden = %d WHERE xchan_hash = '%s'", + intval(1 - $publish), + intval($channel['channel_hash']) + ); + + if ($name_change) { + // catch xchans for all protocols by matching the url + $r = q( + "update xchan set xchan_name = '%s', xchan_name_date = '%s' where xchan_url = '%s'", + dbesc($username), + dbesc(datetime_convert()), + dbesc(z_root() . '/channel/' . $channel['channel_address']) + ); + $r = q( + "update profile set fullname = '%s' where uid = %d and is_default = 1", + dbesc($username), + intval($channel['channel_id']) + ); + if (Zlib\Channel::is_system($channel['channel_id'])) { + set_config('system', 'sitename', $username); + } + } + + Run::Summon(['Directory', local_channel()]); + + Libsync::build_sync_packet(); + + + if ($email_changed && App::$config['system']['register_policy'] == REGISTER_VERIFY) { + // FIXME - set to un-verified, blocked and redirect to logout + // Q: Why? Are we verifying people or email addresses? + // A: the policy is to verify email addresses + } + + goaway(z_root() . '/settings'); + return; // NOTREACHED + } + + public function get() + { + + require_once('include/permissions.php'); + + + $yes_no = [t('No'), t('Yes')]; + + + $p = q( + "SELECT * FROM profile WHERE is_default = 1 AND uid = %d LIMIT 1", + intval(local_channel()) + ); + if (count($p)) { + $profile = $p[0]; + } + + load_pconfig(local_channel(), 'expire'); + + $channel = App::get_channel(); + + $global_perms = Permissions::Perms(); + + $permiss = []; + + $perm_opts = [ + [t('Restricted - from connections only'), PERMS_SPECIFIC], + [t('Semi-public - from anybody that can be identified'), PERMS_AUTHED], + [t('Public - from anybody on the internet'), PERMS_PUBLIC] + ]; + + $limits = PermissionLimits::Get(local_channel()); + $anon_comments = get_config('system', 'anonymous_comments'); + + foreach ($global_perms as $k => $perm) { + $options = []; + $can_be_public = ((strstr($k, 'view') || ($k === 'post_comments' && $anon_comments)) ? true : false); + foreach ($perm_opts as $opt) { + if ($opt[1] == PERMS_PUBLIC && (!$can_be_public)) { + continue; + } + $options[$opt[1]] = $opt[0]; + } + if ($k === 'post_comments') { + $comment_perms = [$k, $perm, $limits[$k], '', $options]; + } elseif ($k === 'post_mail') { + $mail_perms = [$k, $perm, $limits[$k], '', $options]; + } else { + $permiss[] = array($k, $perm, $limits[$k], '', $options); + } + } + + // logger('permiss: ' . print_r($permiss,true)); + + $username = $channel['channel_name']; + $nickname = $channel['channel_address']; + $timezone = $channel['channel_timezone']; + $notify = $channel['channel_notifyflags']; + $defloc = $channel['channel_location']; + + $maxreq = $channel['channel_max_friend_req']; + $expire = $channel['channel_expire_days']; + $adult_flag = intval($channel['channel_pageflags'] & PAGE_ADULT); + $sys_expire = get_config('system', 'default_expire_days'); + + $hide_presence = intval(get_pconfig(local_channel(), 'system', 'hide_online_status')); + + $expire_items = get_pconfig(local_channel(), 'expire', 'items'); + $expire_items = (($expire_items === false) ? '1' : $expire_items); // default if not set: 1 + + $expire_notes = get_pconfig(local_channel(), 'expire', 'notes'); + $expire_notes = (($expire_notes === false) ? '1' : $expire_notes); // default if not set: 1 + + $expire_starred = get_pconfig(local_channel(), 'expire', 'starred'); + $expire_starred = (($expire_starred === false) ? '1' : $expire_starred); // default if not set: 1 + + + $expire_photos = get_pconfig(local_channel(), 'expire', 'photos'); + $expire_photos = (($expire_photos === false) ? '0' : $expire_photos); // default if not set: 0 + + $expire_network_only = get_pconfig(local_channel(), 'expire', 'network_only'); + $expire_network_only = (($expire_network_only === false) ? '0' : $expire_network_only); // default if not set: 0 + + + $suggestme = get_pconfig(local_channel(), 'system', 'suggestme'); + $suggestme = (($suggestme === false) ? '0' : $suggestme); // default if not set: 0 + + $post_newfriend = get_pconfig(local_channel(), 'system', 'post_newfriend'); + $post_newfriend = (($post_newfriend === false) ? '0' : $post_newfriend); // default if not set: 0 + + $post_joingroup = get_pconfig(local_channel(), 'system', 'post_joingroup'); + $post_joingroup = (($post_joingroup === false) ? '0' : $post_joingroup); // default if not set: 0 + + $post_profilechange = get_pconfig(local_channel(), 'system', 'post_profilechange'); + $post_profilechange = (($post_profilechange === false) ? '0' : $post_profilechange); // default if not set: 0 + + $blocktags = get_pconfig(local_channel(), 'system', 'blocktags'); + $blocktags = (($blocktags === false) ? '0' : $blocktags); + + $timezone = date_default_timezone_get(); + + $opt_tpl = Theme::get_template("field_checkbox.tpl"); + if (get_config('system', 'publish_all')) { + $profile_in_dir = ''; + } else { + $profile_in_dir = replace_macros($opt_tpl, array( + '$field' => array('profile_in_directory', t('Publish your profile in the network directory'), $profile['publish'], '', $yes_no), + )); + } + + $suggestme = replace_macros($opt_tpl, array( + '$field' => array('suggestme', t('Allow us to suggest you as a potential friend to new members?'), $suggestme, '', $yes_no), + + )); + + $subdir = ((strlen(App::get_path())) ? '
      ' . t('or') . ' ' . z_root() . '/channel/' . $nickname : ''); + + $webbie = $nickname . '@' . App::get_hostname(); + $intl_nickname = unpunify($nickname) . '@' . unpunify(App::get_hostname()); + + $prof_addr = replace_macros(Theme::get_template('channel_settings_header.tpl'), array( + '$desc' => t('Your channel address is'), + '$nickname' => (($intl_nickname === $webbie) ? $webbie : $intl_nickname . ' (' . $webbie . ')'), + '$compat' => t('Friends using compatible applications can use this address to connect with you.'), + '$subdir' => $subdir, + '$davdesc' => t('Your files/photos are accessible as a network drive at'), + '$davpath' => z_root() . '/dav/' . $nickname, + '$windows' => t('(Windows)'), + '$other' => t('(other platforms)'), + '$or' => t('or'), + '$davspath' => 'davs://' . App::get_hostname() . '/dav/' . $nickname, + '$basepath' => App::get_hostname() + )); + + + $pcat = new Permcat(local_channel()); + $pcatlist = $pcat->listing(); + $permcats = []; + if ($pcatlist) { + foreach ($pcatlist as $pc) { + $permcats[$pc['name']] = $pc['localname']; + } + } + + $default_permcat = get_pconfig(local_channel(), 'system', 'default_permcat', 'default'); + + + $acl = new AccessControl($channel); + $perm_defaults = $acl->get(); + + $group_select = AccessList::select(local_channel(), $channel['channel_default_group']); + + + $m1 = Menu::list(local_channel()); + $menu = false; + if ($m1) { + $menu = []; + $current = get_pconfig(local_channel(), 'system', 'channel_menu'); + $menu[] = array('name' => '', 'selected' => ((!$current) ? true : false)); + foreach ($m1 as $m) { + $menu[] = array('name' => htmlspecialchars($m['menu_name'], ENT_COMPAT, 'UTF-8'), 'selected' => (($m['menu_name'] === $current) ? ' selected="selected" ' : false)); + } + } + + $evdays = get_pconfig(local_channel(), 'system', 'evdays'); + if (!$evdays) { + $evdays = 3; + } + + $permissions_role = get_pconfig(local_channel(), 'system', 'permissions_role'); + if (!$permissions_role) { + $permissions_role = 'custom'; + } + + $autoperms = replace_macros(Theme::get_template('field_checkbox.tpl'), [ + '$field' => ['autoperms', t('Automatic connection approval'), ((get_pconfig(local_channel(), 'system', 'autoperms', 0)) ? 1 : 0), + t('If enabled, connection requests will be approved without your interaction'), $yes_no] + ]); + + $hyperdrive = ['hyperdrive', t('Friend-of-friend conversations'), ((get_pconfig(local_channel(), 'system', 'hyperdrive', true)) ? 1 : 0), t('Import public third-party conversations in which your connections participate.'), $yes_no]; + + if (get_config('system', 'activitypub', ACTIVITYPUB_ENABLED)) { + $apconfig = true; + $activitypub = replace_macros(Theme::get_template('field_checkbox.tpl'), ['$field' => ['activitypub', t('Enable ActivityPub protocol'), ((get_pconfig(local_channel(), 'system', 'activitypub', ACTIVITYPUB_ENABLED)) ? 1 : 0), t('ActivityPub is an emerging internet standard for social communications'), $yes_no]]); + } else { + $apconfig = false; + $activitypub = '' . EOL; + } + + $permissions_set = (($permissions_role != 'custom') ? true : false); + + $perm_roles = PermissionRoles::roles(); + // Don't permit changing to a collection (@TODO unless there is a mechanism to select the channel_parent) + unset($perm_roles['Collection']); + + + $vnotify = get_pconfig(local_channel(), 'system', 'vnotify'); + $always_show_in_notices = get_pconfig(local_channel(), 'system', 'always_show_in_notices'); + if ($vnotify === false) { + $vnotify = (-1); + } + + $plugin = ['basic' => '', 'security' => '', 'notify' => '', 'misc' => '']; + Hook::call('channel_settings', $plugin); + + $public_stream_mode = intval(get_config('system', 'public_stream_mode', PUBLIC_STREAM_NONE)); + + $ft = get_pconfig(local_channel(), 'system', 'followed_tags', ''); + if ($ft && is_array($ft)) { + $followed = implode(',', $ft); + } else { + $followed = EMPTY_STR; + } + + + $o .= replace_macros(Theme::get_template('settings.tpl'), [ + '$ptitle' => t('Channel Settings'), + '$submit' => t('Submit'), + '$baseurl' => z_root(), + '$uid' => local_channel(), + '$form_security_token' => get_form_security_token("settings"), + '$nickname_block' => $prof_addr, + '$h_basic' => t('Basic Settings'), + '$username' => array('username', t('Full name'), $username, ''), + '$email' => array('email', t('Email Address'), $email, ''), + '$timezone' => array('timezone_select', t('Your timezone'), $timezone, t('This is important for showing the correct time on shared events'), get_timezones()), + '$defloc' => array('defloc', t('Default post location'), $defloc, t('Optional geographical location to display on your posts')), + '$allowloc' => array('allow_location', t('Obtain post location from your web browser or device'), ((get_pconfig(local_channel(), 'system', 'use_browser_location')) ? 1 : ''), '', $yes_no), + + '$adult' => array('adult', t('Adult content'), $adult_flag, t('Enable to indicate if this channel frequently or regularly publishes adult content. (Please also tag any adult material and/or nudity with #NSFW)'), $yes_no), + + '$h_prv' => t('Security and Privacy'), + '$permissions_set' => $permissions_set, + '$perms_set_msg' => t('Your permissions are already configured. Click to view/adjust'), + + '$hide_presence' => array('hide_presence', t('Hide my online presence'), $hide_presence, t('Prevents displaying in your profile that you are online'), $yes_no), + '$hidefriends' => array('hide_friends', t('Allow others to view your friends and connections'), 1 - intval($profile['hide_friends']), '', $yes_no), + '$permiss_arr' => $permiss, + '$comment_perms' => $comment_perms, + '$mail_perms' => $mail_perms, + '$noindex' => ['noindex', t('Forbid indexing of your channel content by search engines'), get_pconfig($channel['channel_id'], 'system', 'noindex'), '', $yes_no], + '$close_comments' => ['close_comments', t('Disable acceptance of comments on my posts after this many days'), ((intval(get_pconfig(local_channel(), 'system', 'close_comments'))) ? intval(get_pconfig(local_channel(), 'system', 'close_comments')) : EMPTY_STR), t('Leave unset or enter 0 to allow comments indefinitely')], + '$blocktags' => array('blocktags', t('Allow others to tag your posts'), 1 - $blocktags, t('Often used by the community to retro-actively flag inappropriate content'), $yes_no), + + '$lbl_p2macro' => t('Channel Permission Limits'), + + '$expire' => array('expire', t('Expire conversations you have not participated in after this many days'), $expire, t('0 or blank to use the website limit.') . ' ' . ((intval($sys_expire)) ? sprintf(t('This website expires after %d days.'), intval($sys_expire)) : t('This website does not provide an expiration policy.')) . ' ' . t('The website limit takes precedence if lower than your limit.')), + '$maxreq' => array('maxreq', t('Maximum Friend Requests/Day:'), intval($channel['channel_max_friend_req']), t('May reduce spam activity')), + '$permissions' => t('Default Access List'), + '$permdesc' => t("(click to open/close)"), + '$aclselect' => Libacl::populate($perm_defaults, false, PermissionDescription::fromDescription(t('Use my default audience setting for the type of object published'))), + '$profseltxt' => t('Profile to assign new connections'), + '$profselect' => ((Features::enabled(local_channel(), 'multi_profiles')) ? contact_profile_assign(get_pconfig(local_channel(), 'system', 'profile_assign', '')) : ''), + + '$allow_cid' => acl2json($perm_defaults['allow_cid']), + '$allow_gid' => acl2json($perm_defaults['allow_gid']), + '$deny_cid' => acl2json($perm_defaults['deny_cid']), + '$deny_gid' => acl2json($perm_defaults['deny_gid']), + '$suggestme' => $suggestme, + '$group_select' => $group_select, + '$can_change_role' => ((in_array($permissions_role, ['collection', 'collection_restricted'])) ? false : true), + '$permissions_role' => $permissions_role, + '$role' => array('permissions_role', t('Channel type and privacy'), $permissions_role, '', $perm_roles, ' onchange="update_role_text(); return false;"'), + '$defpermcat' => ['defpermcat', t('Default Permissions Role'), $default_permcat, '', $permcats], + '$permcat_enable' => Apps::system_app_installed(local_channel(), 'Roles'), + '$profile_in_dir' => $profile_in_dir, + '$hide_friends' => $hide_friends, + '$hide_wall' => $hide_wall, + '$unkmail' => $unkmail, + '$cntunkmail' => array('cntunkmail', t('Maximum direct messages per day from unknown people:'), intval($channel['channel_max_anon_mail']), t("Useful to reduce spamming if you allow direct messages from unknown people")), + + '$autoperms' => $autoperms, +// '$anymention' => $anymention, + '$hyperdrive' => $hyperdrive, + '$activitypub' => $activitypub, + '$apconfig' => $apconfig, + '$close' => t('Close'), + '$h_not' => t('Notifications'), + '$activity_options' => t('By default post a status message when:'), + '$post_newfriend' => array('post_newfriend', t('accepting a friend request'), $post_newfriend, '', $yes_no), + '$post_joingroup' => array('post_joingroup', t('joining a group/community'), $post_joingroup, '', $yes_no), + '$post_profilechange' => array('post_profilechange', t('making an interesting profile change'), $post_profilechange, '', $yes_no), + '$lbl_not' => t('Send a notification email when:'), + '$notify1' => array('notify1', t('You receive a connection request'), ($notify & NOTIFY_INTRO), NOTIFY_INTRO, '', $yes_no), +// '$notify2' => array('notify2', t('Your connections are confirmed'), ($notify & NOTIFY_CONFIRM), NOTIFY_CONFIRM, '', $yes_no), + '$notify3' => array('notify3', t('Someone writes on your profile wall'), ($notify & NOTIFY_WALL), NOTIFY_WALL, '', $yes_no), + '$notify4' => array('notify4', t('Someone writes a followup comment'), ($notify & NOTIFY_COMMENT), NOTIFY_COMMENT, '', $yes_no), + '$notify10' => array('notify10', t('Someone shares a followed conversation'), ($notify & NOTIFY_RESHARE), NOTIFY_RESHARE, '', $yes_no), + '$notify5' => array('notify5', t('You receive a direct (private) message'), ($notify & NOTIFY_MAIL), NOTIFY_MAIL, '', $yes_no), +// '$notify6' => array('notify6', t('You receive a friend suggestion'), ($notify & NOTIFY_SUGGEST), NOTIFY_SUGGEST, '', $yes_no), + '$notify7' => array('notify7', t('You are tagged in a post'), ($notify & NOTIFY_TAGSELF), NOTIFY_TAGSELF, '', $yes_no), +// '$notify8' => array('notify8', t('You are poked/prodded/etc. in a post'), ($notify & NOTIFY_POKE), NOTIFY_POKE, '', $yes_no), + + '$notify9' => array('notify9', t('Someone likes your post/comment'), ($notify & NOTIFY_LIKE), NOTIFY_LIKE, '', $yes_no), + + + '$lbl_vnot' => t('Show visual notifications including:'), + + '$vnotify1' => array('vnotify1', t('Unseen stream activity'), ($vnotify & VNOTIFY_NETWORK), VNOTIFY_NETWORK, '', $yes_no), + '$vnotify2' => array('vnotify2', t('Unseen channel activity'), ($vnotify & VNOTIFY_CHANNEL), VNOTIFY_CHANNEL, '', $yes_no), + '$vnotify3' => array('vnotify3', t('Unseen direct messages'), ($vnotify & VNOTIFY_MAIL), VNOTIFY_MAIL, t('Recommended'), $yes_no), + '$vnotify4' => array('vnotify4', t('Upcoming events'), ($vnotify & VNOTIFY_EVENT), VNOTIFY_EVENT, '', $yes_no), + '$vnotify5' => array('vnotify5', t('Events today'), ($vnotify & VNOTIFY_EVENTTODAY), VNOTIFY_EVENTTODAY, '', $yes_no), + '$vnotify6' => array('vnotify6', t('Upcoming birthdays'), ($vnotify & VNOTIFY_BIRTHDAY), VNOTIFY_BIRTHDAY, t('Not available in all themes'), $yes_no), + '$vnotify7' => array('vnotify7', t('System (personal) notifications'), ($vnotify & VNOTIFY_SYSTEM), VNOTIFY_SYSTEM, '', $yes_no), + '$vnotify8' => array('vnotify8', t('System info messages'), ($vnotify & VNOTIFY_INFO), VNOTIFY_INFO, t('Recommended'), $yes_no), + '$vnotify9' => array('vnotify9', t('System critical alerts'), ($vnotify & VNOTIFY_ALERT), VNOTIFY_ALERT, t('Recommended'), $yes_no), + '$vnotify10' => array('vnotify10', t('New connections'), ($vnotify & VNOTIFY_INTRO), VNOTIFY_INTRO, t('Recommended'), $yes_no), + '$vnotify11' => ((is_site_admin()) ? array('vnotify11', t('System Registrations'), ($vnotify & VNOTIFY_REGISTER), VNOTIFY_REGISTER, '', $yes_no) : []), +// '$vnotify12' => array('vnotify12', t('Unseen shared files'), ($vnotify & VNOTIFY_FILES), VNOTIFY_FILES, '', $yes_no), + '$vnotify13' => (($public_stream_mode) ? ['vnotify13', t('Unseen public stream activity'), ($vnotify & VNOTIFY_PUBS), VNOTIFY_PUBS, '', $yes_no] : []), + '$vnotify14' => array('vnotify14', t('Unseen likes and dislikes'), ($vnotify & VNOTIFY_LIKE), VNOTIFY_LIKE, '', $yes_no), + '$vnotify15' => array('vnotify15', t('Unseen group posts'), ($vnotify & VNOTIFY_FORUMS), VNOTIFY_FORUMS, '', $yes_no), + '$vnotify16' => ((is_site_admin()) ? array('vnotify16', t('Reported content'), ($vnotify & VNOTIFY_REPORTS), VNOTIFY_REPORTS, '', $yes_no) : []), + '$desktop_notifications_info' => t('Desktop notifications are unavailable because the required browser permission has not been granted'), + '$desktop_notifications_request' => t('Grant permission'), + '$mailhost' => ['mailhost', t('Email notifications sent from (hostname)'), get_pconfig(local_channel(), 'system', 'email_notify_host', App::get_hostname()), sprintf(t('If your channel is mirrored to multiple locations, set this to your preferred location. This will prevent duplicate email notifications. Example: %s'), App::get_hostname())], + '$always_show_in_notices' => array('always_show_in_notices', t('Show new wall posts, private messages and connections under Notices'), $always_show_in_notices, 1, '', $yes_no), + '$permit_all_mentions' => ['permit_all_mentions', t('Accept messages from strangers which mention you'), get_pconfig(local_channel(), 'system', 'permit_all_mentions'), t('This setting bypasses normal permissions'), $yes_no], + '$followed_tags' => ['followed_tags', t('Accept messages from strangers which include any of the following hashtags'), $followed, t('comma separated, do not include the #')], + '$evdays' => array('evdays', t('Notify me of events this many days in advance'), $evdays, t('Must be greater than 0')), + '$basic_addon' => $plugin['basic'], + '$sec_addon' => $plugin['security'], + '$notify_addon' => $plugin['notify'], + '$misc_addon' => $plugin['misc'], + '$lbl_time' => t('Date and time'), + '$miscdoc' => t('This section is reserved for use by optional addons and apps to provide additional settings.'), + '$h_advn' => t('Advanced Account/Page Type Settings'), + '$h_descadvn' => t('Change the behaviour of this account for special situations'), + '$pagetype' => $pagetype, + '$lbl_misc' => t('Miscellaneous'), + '$photo_path' => array('photo_path', t('Default photo upload folder name'), get_pconfig(local_channel(), 'system', 'photo_path'), t('%Y - current year, %m - current month')), + '$attach_path' => array('attach_path', t('Default file upload folder name'), get_pconfig(local_channel(), 'system', 'attach_path'), t('%Y - current year, %m - current month')), + '$menus' => $menu, + '$menu_desc' => t('Personal menu to display in your channel pages'), + '$removeme' => t('Remove Channel'), + '$removechannel' => t('Remove this channel.'), + '$tag_username' => ['tag_username', t('Mentions should display'), intval(get_pconfig(local_channel(), 'system', 'tag_username', get_config('system', 'tag_username', false))), t('Changes to this setting are applied to new posts/comments only. It is not retroactive.'), + [ + 0 => t('the channel display name [example: @Barbara Jenkins]'), + 1 => t('the channel nickname [example: @barbara1976]'), + 2 => t('combined [example: @Barbara Jenkins (barbara1976)]'), + 127 => t('no preference, use the system default'), + ]], + + '$cal_first_day' => array('first_day', t('Calendar week begins on'), intval(get_pconfig(local_channel(), 'system', 'cal_first_day')), t('This varies by country/culture'), + [0 => t('Sunday'), + 1 => t('Monday'), + 2 => t('Tuesday'), + 3 => t('Wednesday'), + 4 => t('Thursday'), + 5 => t('Friday'), + 6 => t('Saturday') + ]), + ]); + + Hook::call('settings_form', $o); + return $o; + } +} diff --git a/Code/Module/Settings/Display.php b/Code/Module/Settings/Display.php new file mode 100644 index 000000000..03883e06b --- /dev/null +++ b/Code/Module/Settings/Display.php @@ -0,0 +1,248 @@ + 100) { + $itemspage = 100; + } + + if ($indentpx < 0) { + $indentpx = 0; + } + if ($indentpx > 20) { + $indentpx = 20; + } + + set_pconfig(local_channel(), 'system', 'preload_images', $preload_images); + set_pconfig(local_channel(), 'system', 'user_scalable', $user_scalable); + set_pconfig(local_channel(), 'system', 'update_interval', $browser_update); + set_pconfig(local_channel(), 'system', 'itemspage', $itemspage); + set_pconfig(local_channel(), 'system', 'no_smilies', 1 - intval($nosmile)); + set_pconfig(local_channel(), 'system', 'channel_divmore_height', $channel_divmore_height); + set_pconfig(local_channel(), 'system', 'stream_divmore_height', $stream_divmore_height); + set_pconfig(local_channel(), 'system', 'channel_menu', $channel_menu); + set_pconfig(local_channel(), 'system', 'thread_indent_px', $indentpx); + + $newschema = ''; + if ($theme) { + // call theme_post only if theme has not been changed + if (($themeconfigfile = $this->get_theme_config_file($theme)) != null) { + require_once($themeconfigfile); + if (class_exists('\\Code\\Theme\\' . ucfirst($theme) . 'Config')) { + $clsname = '\\Code\\Theme\\' . ucfirst($theme) . 'Config'; + $theme_config = new $clsname(); + $schemas = $theme_config->get_schemas(); + if (array_key_exists($_POST['schema'], $schemas)) { + $newschema = $_POST['schema']; + } + if ($newschema === '---') { + $newschema = ''; + } + $theme_config->post(); + } + } + } + + logger('theme: ' . $theme . (($newschema) ? ':' . $newschema : '')); + + $_SESSION['theme'] = $theme . (($newschema) ? ':' . $newschema : ''); + + $r = q( + "UPDATE channel SET channel_theme = '%s' WHERE channel_id = %d", + dbesc($theme . (($newschema) ? ':' . $newschema : '')), + intval(local_channel()) + ); + + Hook::call('display_settings_post', $_POST); + Libsync::build_sync_packet(); + goaway(z_root() . '/settings/display'); + return; // NOTREACHED + } + + + public function get() + { + + $yes_no = array(t('No'), t('Yes')); + + $default_theme = get_config('system', 'theme'); + if (!$default_theme) { + $default_theme = 'redbasic'; + } + + $themespec = explode(':', App::$channel['channel_theme']); + $existing_theme = $themespec[0]; + $existing_schema = $themespec[1]; + + $theme = (($existing_theme) ? $existing_theme : $default_theme); + + $allowed_themes_str = get_config('system', 'allowed_themes'); + $allowed_themes_raw = explode(',', $allowed_themes_str); + $allowed_themes = []; + if (count($allowed_themes_raw)) { + foreach ($allowed_themes_raw as $x) { + if (strlen(trim($x)) && is_dir("view/theme/$x")) { + $allowed_themes[] = trim($x); + } + } + } + + + $themes = []; + $files = glob('view/theme/*'); + if ($allowed_themes) { + foreach ($allowed_themes as $th) { + $f = $th; + + $info = get_theme_info($th); + $compatible = check_plugin_versions($info); + if (!$compatible) { + $themes[$f] = sprintf(t('%s - (Incompatible)'), $f); + continue; + } + + $is_experimental = file_exists('view/theme/' . $th . '/experimental'); + $unsupported = file_exists('view/theme/' . $th . '/unsupported'); + $is_library = file_exists('view/theme/' . $th . '/library'); + + if (!$is_experimental or ($is_experimental && (get_config('experimentals', 'exp_themes') == 1 or get_config('experimentals', 'exp_themes') === false))) { + $theme_name = (($is_experimental) ? sprintf(t('%s - (Experimental)'), $f) : $f); + if (!$is_library) { + $themes[$f] = $theme_name; + } + } + } + } + + $theme_selected = ((array_key_exists('theme', $_SESSION) && $_SESSION['theme']) ? $_SESSION['theme'] : $theme); + + if (strpos($theme_selected, ':')) { + $theme_selected = explode(':', $theme_selected)[0]; + } + + + $preload_images = get_pconfig(local_channel(), 'system', 'preload_images'); + + $user_scalable = get_pconfig(local_channel(), 'system', 'user_scalable', '0'); + + $browser_update = intval(get_pconfig(local_channel(), 'system', 'update_interval', 30000)); // default if not set: 30 seconds + $browser_update = (($browser_update < 15000) ? 15 : $browser_update / 1000); // minimum 15 seconds + + $itemspage = intval(get_pconfig(local_channel(), 'system', 'itemspage')); + $itemspage = (($itemspage > 0 && $itemspage < 101) ? $itemspage : 20); // default if not set: 20 items + + $nosmile = get_pconfig(local_channel(), 'system', 'no_smilies'); + $nosmile = (($nosmile === false) ? '0' : $nosmile); // default if not set: 0 + + $theme_config = ""; + if (($themeconfigfile = $this->get_theme_config_file($theme)) != null) { + require_once($themeconfigfile); + if (class_exists('\\Code\\Theme\\' . ucfirst($theme) . 'Config')) { + $clsname = '\\Code\\Theme\\' . ucfirst($theme) . 'Config'; + $thm_config = new $clsname(); + $schemas = $thm_config->get_schemas(); + $theme_config = $thm_config->get(); + } + } + + // logger('schemas: ' . print_r($schemas,true)); + + $tpl = Theme::get_template("settings_display.tpl"); + $o = replace_macros($tpl, array( + '$ptitle' => t('Display Settings'), + '$d_tset' => t('Theme Settings'), + '$d_ctset' => t('Custom Theme Settings'), + '$d_cset' => t('Content Settings'), + '$form_security_token' => get_form_security_token("settings_display"), + '$submit' => t('Submit'), + '$baseurl' => z_root(), + '$uid' => local_channel(), + + '$theme' => (($themes) ? array('theme', t('Display Theme:'), $theme_selected, '', $themes, 'preview') : false), + '$schema' => array('schema', t('Select scheme'), $existing_schema, '', $schemas), + + '$preload_images' => array('preload_images', t("Preload images before rendering the page"), $preload_images, t("The subjective page load time will be longer but the page will be ready when displayed"), $yes_no), + '$user_scalable' => array('user_scalable', t("Enable user zoom on mobile devices"), $user_scalable, '', $yes_no), + '$ajaxint' => array('browser_update', t("Update notifications every xx seconds"), $browser_update, t('Minimum of 15 seconds, no maximum')), + '$itemspage' => array('itemspage', t("Maximum number of conversations to load at any time:"), $itemspage, t('Maximum of 100 items')), + '$nosmile' => array('nosmile', t("Show emoticons (smilies) as images"), 1 - intval($nosmile), '', $yes_no), + '$channel_menu' => ['channel_menu', t('Provide channel menu in navigation bar'), get_pconfig(local_channel(), 'system', 'channel_menu', get_config('system', 'channel_menu', 0)), t('Default: channel menu located in app menu'), $yes_no], + '$layout_editor' => t('System Page Layout Editor - (advanced)'), + '$theme_config' => $theme_config, + '$expert' => Features::enabled(local_channel(), 'advanced_theming'), + '$channel_divmore_height' => array('channel_divmore_height', t('Channel page max height of content (in pixels)'), ((get_pconfig(local_channel(), 'system', 'channel_divmore_height')) ? get_pconfig(local_channel(), 'system', 'channel_divmore_height') : 400), t('click to expand content exceeding this height')), + '$stream_divmore_height' => array('stream_divmore_height', t('Stream page max height of content (in pixels)'), ((get_pconfig(local_channel(), 'system', 'stream_divmore_height')) ? get_pconfig(local_channel(), 'system', 'stream_divmore_height') : 400), t('click to expand content exceeding this height')), + '$indentpx' => ['indentpx', t('Indent threaded comments this many pixels from the parent'), intval(get_pconfig(local_channel(), 'system', 'thread_indent_px', get_config('system', 'thread_indent_px', 0))), t('0-20')], + + )); + + Hook::call('display_settings', $o); + return $o; + } + + + public function get_theme_config_file($theme) + { + + $base_theme = App::$theme_info['extends']; + + if (file_exists("view/theme/$theme/php/config.php")) { + return "view/theme/$theme/php/config.php"; + } + if (file_exists("view/theme/$base_theme/php/config.php")) { + return "view/theme/$base_theme/php/config.php"; + } + return null; + } +} diff --git a/Code/Module/Settings/Featured.php b/Code/Module/Settings/Featured.php new file mode 100644 index 000000000..6e0f9db83 --- /dev/null +++ b/Code/Module/Settings/Featured.php @@ -0,0 +1,91 @@ + 99) { + $cmax = 99; + } + $cmin = intval($_POST['affinity_cmin']); + if ($cmin < 0 || $cmin > 99) { + $cmin = 0; + } + set_pconfig(local_channel(), 'affinity', 'cmin', $cmin); + set_pconfig(local_channel(), 'affinity', 'cmax', $cmax); + + info(t('Affinity Slider settings updated.') . EOL); + } + + Libsync::build_sync_packet(); + return; + } + + public function get() + { + $settings_addons = ""; + + $o = ''; + + $r = q("SELECT * FROM hook WHERE hook = 'feature_settings' "); + if (!$r) { + $settings_addons = t('No feature settings configured'); + } + + if (Features::enabled(local_channel(), 'affinity')) { + $cmax = intval(get_pconfig(local_channel(), 'affinity', 'cmax')); + $cmax = (($cmax) ? $cmax : 99); + $setting_fields .= replace_macros(Theme::get_template('field_input.tpl'), array( + '$field' => array('affinity_cmax', t('Default maximum affinity level'), $cmax, t('0-99 default 99')) + )); + $cmin = intval(get_pconfig(local_channel(), 'affinity', 'cmin')); + $cmin = (($cmin) ? $cmin : 0); + $setting_fields .= replace_macros(Theme::get_template('field_input.tpl'), array( + '$field' => array('affinity_cmin', t('Default minimum affinity level'), $cmin, t('0-99 - default 0')) + )); + + $settings_addons .= replace_macros(Theme::get_template('generic_addon_settings.tpl'), array( + '$addon' => array('affinity_slider', '' . t('Affinity Slider Settings'), '', t('Submit')), + '$content' => $setting_fields + )); + } + + Hook::call('feature_settings', $settings_addons); + + $this->sortpanels($settings_addons); + + + $tpl = Theme::get_template("settings_addons.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_featured"), + '$title' => t('Addon Settings'), + '$descrip' => t('Please save/submit changes to any panel before opening another.'), + '$settings_addons' => $settings_addons + )); + return $o; + } + + public function sortpanels(&$s) + { + $a = explode('
      ', $s); + if ($a) { + usort($a, 'featured_sort'); + $s = implode('
      ', $a); + } + } +} diff --git a/Code/Module/Settings/Features.php b/Code/Module/Settings/Features.php new file mode 100644 index 000000000..f8d3d3c56 --- /dev/null +++ b/Code/Module/Settings/Features.php @@ -0,0 +1,71 @@ + $fdata) { + foreach (array_slice($fdata, 1) as $f) { + $k = $f[0]; + if (array_key_exists("feature_$k", $_POST)) { + set_pconfig(local_channel(), 'feature', $k, (string)$_POST["feature_$k"]); + } else { + set_pconfig(local_channel(), 'feature', $k, ''); + } + } + } + Libsync::build_sync_packet(); + return; + } + + public function get() + { + + $arr = []; + $harr = []; + + + $all_features_raw = Zlib\Features::get(false); + + foreach ($all_features_raw as $fname => $fdata) { + foreach (array_slice($fdata, 1) as $f) { + $harr[$f[0]] = ((intval(Zlib\Features::enabled(local_channel(), $f[0]))) ? "1" : ''); + } + } + + $features = Zlib\Features::get(true); + + foreach ($features as $fname => $fdata) { + $arr[$fname] = []; + $arr[$fname][0] = $fdata[0]; + foreach (array_slice($fdata, 1) as $f) { + $arr[$fname][1][] = array('feature_' . $f[0], $f[1], ((intval(Zlib\Features::enabled(local_channel(), $f[0]))) ? "1" : ''), $f[2], array(t('Off'), t('On'))); + unset($harr[$f[0]]); + } + } + + $tpl = Theme::get_template("settings_features.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_features"), + '$title' => t('Additional Features'), + '$features' => $arr, + '$hiddens' => $harr, + '$baseurl' => z_root(), + '$submit' => t('Submit'), + )); + + return $o; + } +} diff --git a/Code/Module/Settings/Network.php b/Code/Module/Settings/Network.php new file mode 100644 index 000000000..42605e578 --- /dev/null +++ b/Code/Module/Settings/Network.php @@ -0,0 +1,134 @@ + 'settings/network', + '$form_security_token' => get_form_security_token("settings_network"), + '$title' => t('Activity Settings'), + '$features' => $arr, + '$baseurl' => z_root(), + '$submit' => t('Submit'), + )); + + return $o; + } + + public function get_features() + { + $arr = [ + + [ + 'archives', + t('Search by Date'), + t('Ability to select posts by date ranges'), + false, + get_config('feature_lock', 'archives') + ], + + [ + 'savedsearch', + t('Saved Searches'), + t('Save search terms for re-use'), + false, + get_config('feature_lock', 'savedsearch') + ], + + [ + 'order_tab', + t('Alternate Stream Order'), + t('Ability to order the stream by last post date, last comment date or unthreaded activities'), + false, + get_config('feature_lock', 'order_tab') + ], + + [ + 'name_tab', + t('Contact Filter'), + t('Ability to display only posts of a selected contact'), + false, + get_config('feature_lock', 'name_tab') + ], + + [ + 'forums_tab', + t('Forum Filter'), + t('Ability to display only posts of a specific forum'), + false, + get_config('feature_lock', 'forums_tab') + ], + + [ + 'personal_tab', + t('Personal Posts Filter'), + t('Ability to display only posts that you\'ve interacted on'), + false, + get_config('feature_lock', 'personal_tab') + ], + + [ + 'affinity', + t('Affinity Tool'), + t('Filter stream activity by depth of relationships'), + false, + get_config('feature_lock', 'affinity') + ], + + [ + 'suggest', + t('Suggest Channels'), + t('Show friend and connection suggestions'), + false, + get_config('feature_lock', 'suggest') + ], + + [ + 'connfilter', + t('Connection Filtering'), + t('Filter incoming posts from connections based on keywords/content'), + false, + get_config('feature_lock', 'connfilter') + ] + + ]; + + return $arr; + } +} diff --git a/Code/Module/Settings/Oauth.php b/Code/Module/Settings/Oauth.php new file mode 100644 index 000000000..5f761abbe --- /dev/null +++ b/Code/Module/Settings/Oauth.php @@ -0,0 +1,175 @@ + 2) && (argv(2) === 'edit' || argv(2) === 'add') && x($_POST, 'submit')) { + check_form_security_token_redirectOnErr('/settings/oauth', 'settings_oauth'); + + $name = ((x($_POST, 'name')) ? escape_tags($_POST['name']) : ''); + $key = ((x($_POST, 'key')) ? escape_tags($_POST['key']) : ''); + $secret = ((x($_POST, 'secret')) ? escape_tags($_POST['secret']) : ''); + $redirect = ((x($_POST, 'redirect')) ? escape_tags($_POST['redirect']) : ''); + $icon = ((x($_POST, 'icon')) ? escape_tags($_POST['icon']) : ''); + $oauth2 = ((x($_POST, 'oauth2')) ? intval($_POST['oauth2']) : 0); + $ok = true; + if ($name == '') { + $ok = false; + notice(t('Name is required') . EOL); + } + if ($key == '' || $secret == '') { + $ok = false; + notice(t('Key and Secret are required') . EOL); + } + + if ($ok) { + if ($_POST['submit'] == t("Update")) { + $r = q( + "UPDATE clients SET + client_id='%s', + pw='%s', + clname='%s', + redirect_uri='%s', + icon='%s', + uid=%d + WHERE client_id='%s'", + dbesc($key), + dbesc($secret), + dbesc($name), + dbesc($redirect), + dbesc($icon), + intval(local_channel()), + dbesc($key) + ); + } else { + $r = q( + "INSERT INTO clients (client_id, pw, clname, redirect_uri, icon, uid) + VALUES ('%s','%s','%s','%s','%s',%d)", + dbesc($key), + dbesc($secret), + dbesc($name), + dbesc($redirect), + dbesc($icon), + intval(local_channel()) + ); + $r = q( + "INSERT INTO xperm (xp_client, xp_channel, xp_perm) VALUES ('%s', %d, '%s') ", + dbesc($key), + intval(local_channel()), + dbesc('all') + ); + } + } + goaway(z_root() . "/settings/oauth/"); + return; + } + } + + public function get() + { + + if ((argc() > 2) && (argv(2) === 'add')) { + $tpl = Theme::get_template("settings_oauth_edit.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_oauth"), + '$title' => t('Add application'), + '$submit' => t('Submit'), + '$cancel' => t('Cancel'), + '$name' => array('name', t('Name'), '', t('Name of application')), + '$key' => array('key', t('Consumer Key'), random_string(16), t('Automatically generated - change if desired. Max length 20')), + '$secret' => array('secret', t('Consumer Secret'), random_string(16), t('Automatically generated - change if desired. Max length 20')), + '$redirect' => array('redirect', t('Redirect'), '', t('Redirect URI - leave blank unless your application specifically requires this')), + '$icon' => array('icon', t('Icon url'), '', t('Optional')), + )); + return $o; + } + + if ((argc() > 3) && (argv(2) === 'edit')) { + $r = q( + "SELECT * FROM clients WHERE client_id='%s' AND uid=%d", + dbesc(argv(3)), + local_channel() + ); + + if (!count($r)) { + notice(t('Application not found.')); + return; + } + $app = $r[0]; + + $tpl = Theme::get_template("settings_oauth_edit.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_oauth"), + '$title' => t('Add application'), + '$submit' => t('Update'), + '$cancel' => t('Cancel'), + '$name' => array('name', t('Name'), $app['clname'], ''), + '$key' => array('key', t('Consumer Key'), $app['client_id'], ''), + '$secret' => array('secret', t('Consumer Secret'), $app['pw'], ''), + '$redirect' => array('redirect', t('Redirect'), $app['redirect_uri'], ''), + '$icon' => array('icon', t('Icon url'), $app['icon'], ''), + )); + return $o; + } + + if ((argc() > 3) && (argv(2) === 'delete')) { + check_form_security_token_redirectOnErr('/settings/oauth', 'settings_oauth', 't'); + + $r = q( + "DELETE FROM clients WHERE client_id='%s' AND uid=%d", + dbesc(argv(3)), + local_channel() + ); + goaway(z_root() . "/settings/oauth/"); + return; + } + + + $r = q( + "SELECT clients.*, tokens.id as oauth_token, (clients.uid=%d) AS my + FROM clients + LEFT JOIN tokens ON clients.client_id=tokens.client_id + WHERE clients.uid IN (%d,0)", + local_channel(), + local_channel() + ); + + + $tpl = Theme::get_template("settings_oauth.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_oauth"), + '$baseurl' => z_root(), + '$title' => t('Connected Apps'), + '$add' => t('Add application'), + '$edit' => t('Edit'), + '$delete' => t('Delete'), + '$consumerkey' => t('Client key starts with'), + '$noname' => t('No name'), + '$remove' => t('Remove authorization'), + '$apps' => $r, + )); + return $o; + } +} diff --git a/Code/Module/Settings/Oauth2.php b/Code/Module/Settings/Oauth2.php new file mode 100644 index 000000000..218816312 --- /dev/null +++ b/Code/Module/Settings/Oauth2.php @@ -0,0 +1,222 @@ + 2) && (argv(2) === 'edit' || argv(2) === 'add') && x($_POST, 'submit')) { + check_form_security_token_redirectOnErr('/settings/oauth2', 'settings_oauth2'); + + $name = ((x($_POST, 'name')) ? escape_tags(trim($_POST['name'])) : ''); + $clid = ((x($_POST, 'clid')) ? escape_tags(trim($_POST['clid'])) : ''); + $secret = ((x($_POST, 'secret')) ? escape_tags(trim($_POST['secret'])) : ''); + $redirect = ((x($_POST, 'redirect')) ? escape_tags(trim($_POST['redirect'])) : ''); + $grant = ((x($_POST, 'grant')) ? escape_tags(trim($_POST['grant'])) : ''); + $scope = ((x($_POST, 'scope')) ? escape_tags(trim($_POST['scope'])) : ''); + logger('redirect: ' . $redirect); + $ok = true; + if ($clid == '' || $secret == '') { + $ok = false; + notice(t('ID and Secret are required') . EOL); + } + + if ($ok) { + if ($_POST['submit'] == t("Update")) { + $r = q( + "UPDATE oauth_clients SET + client_name = '%s', + client_id = '%s', + client_secret = '%s', + redirect_uri = '%s', + grant_types = '%s', + scope = '%s', + user_id = %d + WHERE client_id='%s' and user_id = %s", + dbesc($name), + dbesc($clid), + dbesc($secret), + dbesc($redirect), + dbesc($grant), + dbesc($scope), + intval(local_channel()), + dbesc($clid), + intval(local_channel()) + ); + } else { + $r = q( + "INSERT INTO oauth_clients (client_name, client_id, client_secret, redirect_uri, grant_types, scope, user_id) + VALUES ('%s','%s','%s','%s','%s','%s',%d)", + dbesc($name), + dbesc($clid), + dbesc($secret), + dbesc($redirect), + dbesc($grant), + dbesc($scope), + intval(local_channel()) + ); + $r = q( + "INSERT INTO xperm (xp_client, xp_channel, xp_perm) VALUES ('%s', %d, '%s') ", + dbesc($name), + intval(local_channel()), + dbesc('all') + ); + } + } + goaway(z_root() . "/settings/oauth2/"); + return; + } + } + + public function get() + { + + if (!Apps::system_app_installed(local_channel(), 'Clients')) { + return; + } + + if ((argc() > 2) && (argv(2) === 'add')) { + $tpl = Theme::get_template("settings_oauth2_edit.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_oauth2"), + '$title' => t('Add OAuth2 application'), + '$submit' => t('Submit'), + '$cancel' => t('Cancel'), + '$name' => array('name', t('Name'), '', t('Name of application')), + '$clid' => array('clid', t('Consumer ID'), random_string(16), t('Automatically generated - change if desired. Max length 20')), + '$secret' => array('secret', t('Consumer Secret'), random_string(16), t('Automatically generated - change if desired. Max length 20')), + '$redirect' => array('redirect', t('Redirect'), '', t('Redirect URI - leave blank unless your application specifically requires this')), + '$grant' => array('grant', t('Grant Types'), '', t('leave blank unless your application specifically requires this')), + '$scope' => array('scope', t('Authorization scope'), '', t('leave blank unless your application specifically requires this')), + )); + return $o; + } + + if ((argc() > 3) && (argv(2) === 'edit')) { + $r = q( + "SELECT * FROM oauth_clients WHERE client_id='%s' AND user_id= %d", + dbesc(argv(3)), + intval(local_channel()) + ); + + if (!$r) { + notice(t('OAuth2 Application not found.')); + return; + } + + $app = $r[0]; + + $tpl = Theme::get_template("settings_oauth2_edit.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_oauth2"), + '$title' => t('Add application'), + '$submit' => t('Update'), + '$cancel' => t('Cancel'), + '$name' => array('name', t('Name'), $app['client_name'], t('Name of application')), + '$clid' => array('clid', t('Consumer ID'), $app['client_id'], t('Automatically generated - change if desired. Max length 20')), + '$secret' => array('secret', t('Consumer Secret'), $app['client_secret'], t('Automatically generated - change if desired. Max length 20')), + '$redirect' => array('redirect', t('Redirect'), $app['redirect_uri'], t('Redirect URI - leave blank unless your application specifically requires this')), + '$grant' => array('grant', t('Grant Types'), $app['grant_types'], t('leave blank unless your application specifically requires this')), + '$scope' => array('scope', t('Authorization scope'), $app['scope'], t('leave blank unless your application specifically requires this')), + )); + return $o; + } + + if ((argc() > 3) && (argv(2) === 'delete')) { + check_form_security_token_redirectOnErr('/settings/oauth2', 'settings_oauth2', 't'); + + $r = q( + "DELETE FROM oauth_clients WHERE client_id = '%s' AND user_id = %d", + dbesc(argv(3)), + intval(local_channel()) + ); + $r = q( + "DELETE FROM oauth_access_tokens WHERE client_id = '%s' AND user_id = %d", + dbesc(argv(3)), + intval(local_channel()) + ); + $r = q( + "DELETE FROM oauth_authorization_codes WHERE client_id = '%s' AND user_id = %d", + dbesc(argv(3)), + intval(local_channel()) + ); + $r = q( + "DELETE FROM oauth_refresh_tokens WHERE client_id = '%s' AND user_id = %d", + dbesc(argv(3)), + intval(local_channel()) + ); + goaway(z_root() . "/settings/oauth2/"); + return; + } + + + $r = q( + "SELECT * FROM oauth_clients WHERE user_id = %d ", + intval(local_channel()) + ); + + $c = q( + "select client_id, access_token from oauth_access_tokens where user_id = %d", + intval(local_channel()) + ); + if ($r && $c) { + foreach ($c as $cv) { + for ($x = 0; $x < count($r); $x++) { + if ($r[$x]['client_id'] === $cv['client_id']) { + if (!array_key_exists('tokens', $r[$x])) { + $r[$x]['tokens'] = []; + } + $r[$x]['tokens'][] = $cv['access_token']; + } + } + } + } + + $tpl = Theme::get_template("settings_oauth2.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_oauth2"), + '$baseurl' => z_root(), + '$title' => t('Connected OAuth2 Apps'), + '$add' => t('Add application'), + '$edit' => t('Edit'), + '$delete' => t('Delete'), + '$consumerkey' => t('Client key starts with'), + '$noname' => t('No name'), + '$remove' => t('Remove authorization'), + '$apps' => $r, + )); + return $o; + } +} diff --git a/Code/Module/Settings/Permcats.php b/Code/Module/Settings/Permcats.php new file mode 100644 index 000000000..f10d19bc3 --- /dev/null +++ b/Code/Module/Settings/Permcats.php @@ -0,0 +1,150 @@ + $desc) { + if (array_key_exists('perms_' . $perm, $_POST)) { + $pcarr[] = $perm; + } + } + } + + Permcat::update(local_channel(), $name, $pcarr); + + Libsync::build_sync_packet(); + + info(t('Permission role saved.') . EOL); + + return; + } + + + public function get() + { + + if (!local_channel()) { + return; + } + + $channel = App::get_channel(); + + + if (argc() > 2) { + $name = hex2bin(argv(2)); + } + + if (argc() > 3 && argv(3) === 'drop') { + Permcat::delete(local_channel(), $name); + Libsync::build_sync_packet(); + json_return_and_die(['success' => true]); + } + + + $desc = t('Use this form to create permission rules for various classes of people or connections.'); + + $existing = []; + + $pcat = new Permcat(local_channel()); + $pcatlist = $pcat->listing(); + $permcats = []; + if ($pcatlist) { + foreach ($pcatlist as $pc) { + if (($pc['name']) && ($name) && ($pc['name'] == $name)) { + $existing = $pc['perms']; + } + if (!$pc['system']) { + $permcats[bin2hex($pc['name'])] = $pc['localname']; + } + } + } + + $hidden_perms = []; + $global_perms = Permissions::Perms(); + + foreach ($global_perms as $k => $v) { + $thisperm = Permcat::find_permcat($existing, $k); + + $checkinherited = PermissionLimits::Get(local_channel(), $k); + + $inherited = (($checkinherited & PERMS_SPECIFIC) ? false : true); + + $thisperm = 0; + if ($existing) { + foreach ($existing as $ex) { + if ($ex['name'] === $k) { + $thisperm = $ex['value']; + break; + } + } + } + + $perms[] = [ 'perms_' . $k, $v, $inherited ? 1 : intval($thisperm), '', [ t('No'), t('Yes') ], (($inherited) ? ' disabled="disabled" ' : '' )]; + + if ($inherited) { + $hidden_perms[] = ['perms_' . $k, 1 ]; + } + + } + + + $tpl = Theme::get_template("settings_permcats.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_permcats"), + '$title' => t('Permission Roles'), + '$desc' => $desc, + '$desc2' => $desc2, + '$tokens' => $t, + '$permcats' => $permcats, + '$atoken' => $atoken, + '$url1' => z_root() . '/channel/' . $channel['channel_address'], + '$url2' => z_root() . '/photos/' . $channel['channel_address'], + '$name' => array('name', t('Role name') . ' *', (($name) ? $name : ''), ''), + '$me' => t('My Settings'), + '$perms' => $perms, + '$hidden_perms' => $hidden_perms, + '$inherited' => t('inherited'), + '$notself' => 0, + '$self' => 1, + '$permlbl' => t('Individual Permissions'), + '$permnote' => t('Some individual permissions may have been preset or locked based on your channel type and privacy settings.'), + '$submit' => t('Submit') + )); + return $o; + } +} diff --git a/Code/Module/Settings/Tokens.php b/Code/Module/Settings/Tokens.php new file mode 100644 index 000000000..e7039011c --- /dev/null +++ b/Code/Module/Settings/Tokens.php @@ -0,0 +1,321 @@ += $max_tokens) { + notice(sprintf(t('This channel is limited to %d tokens'), $max_tokens) . EOL); + return; + } + } + } + if ($token_errs) { + notice(t('Name and Password are required.') . EOL); + return; + } + + $old_atok = q( + "select * from atoken where atoken_uid = %d and atoken_name = '%s'", + intval($channel['channel_id']), + dbesc($name) + ); + if ($old_atok) { + $old_atok = array_shift($old_atok); + $old_xchan = atoken_xchan($old_atok); + } + + if ($atoken_id) { + $r = q( + "update atoken set atoken_name = '%s', atoken_token = '%s', atoken_expires = '%s' + where atoken_id = %d and atoken_uid = %d", + dbesc($name), + dbesc($token), + dbesc($expires), + intval($atoken_id), + intval($channel['channel_id']) + ); + } else { + $r = q( + "insert into atoken ( atoken_guid, atoken_aid, atoken_uid, atoken_name, atoken_token, atoken_expires ) + values ( '%s', %d, %d, '%s', '%s', '%s' ) ", + dbesc($atoken_guid), + intval($channel['channel_account_id']), + intval($channel['channel_id']), + dbesc($name), + dbesc($token), + dbesc($expires) + ); + } + $atok = q( + "select * from atoken where atoken_uid = %d and atoken_name = '%s'", + intval($channel['channel_id']), + dbesc($name) + ); + if ($atok) { + $xchan = atoken_xchan($atok[0]); + atoken_create_xchan($xchan); + $atoken_xchan = $xchan['xchan_hash']; + if ($old_atok && $old_xchan) { + $r = q( + "update xchan set xchan_name = '%s' where xchan_hash = '%s'", + dbesc($xchan['xchan_name']), + dbesc($old_xchan['xchan_hash']) + ); + } + } + + $all_perms = Permissions::Perms(); + + $p = EMPTY_STR; + + if ($all_perms) { + foreach ($all_perms as $perm => $desc) { + if (array_key_exists('perms_' . $perm, $_POST)) { + if ($p) { + $p .= ','; + } + $p .= $perm; + } + } + set_abconfig(local_channel(), $atoken_xchan, 'system', 'my_perms', $p); + if ($old_atok) { + } + } + + if (!$atoken_id) { + // If this is a new token, create a new abook record + + $closeness = get_pconfig($uid, 'system', 'new_abook_closeness', 80); + $profile_assign = get_pconfig($uid, 'system', 'profile_assign', ''); + + $r = abook_store_lowlevel( + [ + 'abook_account' => $channel['channel_account_id'], + 'abook_channel' => $channel['channel_id'], + 'abook_closeness' => intval($closeness), + 'abook_xchan' => $atoken_xchan, + 'abook_profile' => $profile_assign, + 'abook_feed' => 0, + 'abook_created' => datetime_convert(), + 'abook_updated' => datetime_convert(), + 'abook_instance' => z_root() + ] + ); + + if (!$r) { + logger('abook creation failed'); + } + + /** If there is a default group for this channel, add this connection to it */ + + if ($channel['channel_default_group']) { + $g = AccessList::rec_byhash($uid, $channel['channel_default_group']); + if ($g) { + AccessList::member_add($uid, '', $atoken_xchan, $g['id']); + } + } + + $r = q( + "SELECT abook.*, xchan.* + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d and abook_xchan = '%s' LIMIT 1", + intval($channel['channel_id']), + dbesc($atoken_xchan) + ); + + if (!$r) { + logger('abook or xchan record not saved correctly'); + return; + } + + $clone = array_shift($r); + + unset($clone['abook_id']); + unset($clone['abook_account']); + unset($clone['abook_channel']); + + $abconfig = load_abconfig($channel['channel_id'], $clone['abook_xchan']); + if ($abconfig) { + $clone['abconfig'] = $abconfig; + } + + Libsync::build_sync_packet( + $channel['channel_id'], + ['abook' => [$clone], 'atoken' => $atok], + true + ); + } + + info(t('Token saved.') . EOL); + return; + } + + + public function get() + { + + $channel = App::get_channel(); + + $atoken = null; + $atoken_xchan = ''; + + if (argc() > 2) { + $id = argv(2); + + $atoken = q( + "select * from atoken where atoken_id = %d and atoken_uid = %d", + intval($id), + intval(local_channel()) + ); + + if ($atoken) { + $atoken = $atoken[0]; + $atoken_xchan = substr($channel['channel_hash'], 0, 16) . '.' . $atoken['atoken_guid']; + } + + if ($atoken && argc() > 3 && argv(3) === 'drop') { + $atoken['deleted'] = true; + + $r = q( + "SELECT abook.*, xchan.* + FROM abook left join xchan on abook_xchan = xchan_hash + WHERE abook_channel = %d and abook_xchan = '%s' LIMIT 1", + intval($channel['channel_id']), + dbesc($atoken_xchan) + ); + if (!$r) { + return; + } + + $clone = array_shift($r); + + unset($clone['abook_id']); + unset($clone['abook_account']); + unset($clone['abook_channel']); + + $clone['entry_deleted'] = true; + + $abconfig = load_abconfig($channel['channel_id'], $clone['abook_xchan']); + if ($abconfig) { + $clone['abconfig'] = $abconfig; + } + + atoken_delete($id); + Libsync::build_sync_packet( + $channel['channel_id'], + ['abook' => [$clone], 'atoken' => [$atoken]], + true + ); + + $atoken = null; + $atoken_xchan = ''; + } + } + + $t = q( + "select * from atoken where atoken_uid = %d", + intval(local_channel()) + ); + + $desc = t('Use this form to create temporary access identifiers to share things with non-members. These identities may be used in Access Control Lists and visitors may login using these credentials to access private content.'); + + $desc2 = t('You may also provide dropbox style access links to friends and associates by adding the Login Password to any specific site URL as shown. Examples:'); + + + $global_perms = Permissions::Perms(); + $existing = get_all_perms(local_channel(), (($atoken_xchan) ? $atoken_xchan : EMPTY_STR)); + + $theirs = get_abconfig(local_channel(), $atoken_xchan, 'system', 'their_perms', EMPTY_STR); + + $their_perms = Permissions::FilledPerms(explode(',', $theirs)); + foreach ($global_perms as $k => $v) { + if (!array_key_exists($k, $their_perms)) { + $their_perms[$k] = 1; + } + } + + $my_perms = explode(',', get_abconfig(local_channel(), $atoken_xchan, 'system', 'my_perms', EMPTY_STR)); + + foreach ($global_perms as $k => $v) { + $thisperm = ((in_array($k, $my_perms)) ? 1 : 0); + + $checkinherited = PermissionLimits::Get(local_channel(), $k); + + // For auto permissions (when $self is true) we don't want to look at existing + // permissions because they are enabled for the channel owner + if ((!$self) && ($existing[$k])) { + $thisperm = "1"; + } + + $perms[] = array('perms_' . $k, $v, ((array_key_exists($k, $their_perms)) ? intval($their_perms[$k]) : ''), $thisperm, 1, (($checkinherited & PERMS_SPECIFIC) ? '' : '1'), '', $checkinherited); + } + + + $tpl = Theme::get_template("settings_tokens.tpl"); + $o .= replace_macros($tpl, array( + '$form_security_token' => get_form_security_token("settings_tokens"), + '$title' => t('Guest Access Tokens'), + '$desc' => $desc, + '$desc2' => $desc2, + '$tokens' => $t, + '$atoken' => $atoken, + '$atoken_xchan' => $atoken_chan, + '$url1' => z_root() . '/channel/' . $channel['channel_address'], + '$url2' => z_root() . '/photos/' . $channel['channel_address'], + '$name' => array('name', t('Login Name') . ' *', (($atoken) ? $atoken['atoken_name'] : ''), ''), + '$token' => array('token', t('Login Password') . ' *', (($atoken) ? $atoken['atoken_token'] : new_token()), ''), + '$expires' => array('expires', t('Expires (yyyy-mm-dd)'), (($atoken['atoken_expires'] && $atoken['atoken_expires'] > NULL_DATE) ? datetime_convert('UTC', date_default_timezone_get(), $atoken['atoken_expires']) : ''), ''), + '$them' => t('Their Settings'), + '$me' => t('My Settings'), + '$perms' => $perms, + '$inherited' => t('inherited'), + '$notself' => 1, + '$self' => 0, + '$permlbl' => t('Individual Permissions'), + '$permnote' => t('Some permissions may be inherited from your channel\'s privacy settings, which have higher priority than individual settings. You can not change those settings here.'), + '$submit' => t('Submit') + )); + return $o; + } +} diff --git a/Code/Module/Setup.php b/Code/Module/Setup.php new file mode 100644 index 000000000..18c848325 --- /dev/null +++ b/Code/Module/Setup.php @@ -0,0 +1,902 @@ +install_wizard_pass = intval($_POST['pass']); + } else { + $this->install_wizard_pass = 1; + } + } + + /** + * @brief Handle the actions of the different setup steps. + * + */ + public function post() + { + + switch ($this->install_wizard_pass) { + case 1: + case 2: + return; + // implied break; + case 3: + $urlpath = App::get_path(); + $dbhost = trim($_POST['dbhost']); + $dbport = intval(trim($_POST['dbport'])); + $dbuser = trim($_POST['dbuser']); + $dbpass = trim($_POST['dbpass']); + $dbdata = trim($_POST['dbdata']); + $dbtype = intval(trim($_POST['dbtype'])); + $servertype = intval(trim($_POST['servertype'])); + $phpath = trim($_POST['phpath']); + $adminmail = trim($_POST['adminmail']); + $siteurl = trim($_POST['siteurl']); + + // $siteurl should not have a trailing slash + + $siteurl = rtrim($siteurl, '/'); + + require_once('include/dba/dba_driver.php'); + + $db = DBA::dba_factory($dbhost, $dbport, $dbuser, $dbpass, $dbdata, $dbtype, true); + + if (!DBA::$dba->connected) { + echo 'Database Connect failed: ' . DBA::$dba->error; + killme(); + } + return; + // implied break; + case 4: + $urlpath = App::get_path(); + $dbhost = trim($_POST['dbhost']); + $dbport = intval(trim($_POST['dbport'])); + $dbuser = trim($_POST['dbuser']); + $dbpass = trim($_POST['dbpass']); + $dbdata = trim($_POST['dbdata']); + $dbtype = intval(trim($_POST['dbtype'])); + $servertype = intval(trim($_POST['servertype'])); + $phpath = trim($_POST['phpath']); + $timezone = trim($_POST['timezone']); + $adminmail = trim($_POST['adminmail']); + $siteurl = trim($_POST['siteurl']); + + if ($siteurl != z_root()) { + $test = z_fetch_url($siteurl . '/setup/testrewrite'); + if ((!$test['success']) || ($test['body'] !== 'ok')) { + App::$data['url_fail'] = true; + App::$data['url_error'] = $test['error']; + return; + } + } + + if (!DBA::$dba->connected) { + // connect to db + $db = DBA::dba_factory($dbhost, $dbport, $dbuser, $dbpass, $dbdata, $dbtype, true); + } + + if (!DBA::$dba->connected) { + echo 'CRITICAL: DB not connected.'; + killme(); + } + + $txt = replace_macros(Theme::get_email_template('htconfig.tpl'), [ + '$dbhost' => $dbhost, + '$dbport' => $dbport, + '$dbuser' => $dbuser, + '$dbpass' => $dbpass, + '$dbdata' => $dbdata, + '$dbtype' => $dbtype, + '$servertype' => '', + '$server_role' => 'pro', + '$timezone' => $timezone, + '$platform' => ucfirst(PLATFORM_NAME), + '$siteurl' => $siteurl, + '$site_id' => random_string(), + '$phpath' => $phpath, + '$adminmail' => $adminmail + ]); + + $result = file_put_contents('.htconfig.php', $txt); + if (!$result) { + App::$data['txt'] = $txt; + } + + $errors = $this->load_database($db); + + if ($errors) { + App::$data['db_failed'] = $errors; + } else { + App::$data['db_installed'] = true; + } + + return; + // implied break; + default: + break; + } + } + + /** + * @brief Get output for the setup page. + * + * Depending on the state we are currently in it returns different content. + * + * @return string parsed HTML output + */ + + public function get() + { + + $o = EMPTY_STR; + $wizard_status = EMPTY_STR; + $db_return_text = EMPTY_STR; + $install_title = t('$Projectname Server - Setup'); + + if (x(App::$data, 'db_conn_failed')) { + $this->install_wizard_pass = 2; + $wizard_status = t('Could not connect to database.'); + } + if (x(App::$data, 'url_fail')) { + $this->install_wizard_pass = 3; + $wizard_status = t('Could not connect to specified site URL. Possible SSL certificate or DNS issue.'); + if (App::$data['url_error']) { + $wizard_status .= ' ' . App::$data['url_error']; + } + } + + if (x(App::$data, 'db_create_failed')) { + $this->install_wizard_pass = 2; + $wizard_status = t('Could not create table.'); + } + if (x(App::$data, 'db_installed')) { + $pass = t('Installation succeeded!'); + $icon = 'check'; + $txt = t('Your site database has been installed.') . EOL; + $db_return_text .= $txt; + } + if (x(App::$data, 'db_failed')) { + $pass = t('Database install failed!'); + $icon = 'exclamation-triangle'; + $txt = t('You may need to import the file "install/schema_xxx.sql" manually using a database client.') . EOL; + $txt .= t('Please see the file "install/INSTALL.txt".') . EOL . '
      '; + $txt .= '
      ' . App::$data['db_failed'] . '
      ' . EOL; + $db_return_text .= $txt; + } + if (DBA::$dba && DBA::$dba->connected) { + $r = q("SELECT COUNT(*) as total FROM account"); + if ($r && count($r) && $r[0]['total']) { + return replace_macros(Theme::get_template('install.tpl'), [ + '$title' => $install_title, + '$pass' => '', + '$status' => t('Permission denied.'), + '$text' => '', + ]); + } + } + + if (x(App::$data, 'txt') && strlen(App::$data['txt'])) { + $db_return_text .= $this->manual_config($a); + } + + if ($db_return_text !== EMPTY_STR) { + $tpl = Theme::get_template('install.tpl'); + return replace_macros(Theme::get_template('install.tpl'), [ + '$title' => $install_title, + '$icon' => $icon, + '$pass' => $pass, + '$text' => $db_return_text, + '$what_next' => $this->what_next() + ]); + } + + switch ($this->install_wizard_pass) { + case 1: + { + // System check + + $checks = []; + + $this->check_funcs($checks); + + $this->check_htconfig($checks); + + $this->check_store($checks); + + $this->check_smarty3($checks); + + $this->check_keys($checks); + + if (x($_POST, 'phpath')) { + $phpath = notags(trim($_POST['phpath'])); + } + + $this->check_php($phpath, $checks); + + $this->check_phpconfig($checks); + + $this->check_htaccess($checks); + + $checkspassed = array_reduce($checks, "self::check_passed", true); + + $o .= replace_macros(Theme::get_template('install_checks.tpl'), [ + '$title' => $install_title, + '$pass' => t('System check'), + '$checks' => $checks, + '$passed' => $checkspassed, + '$see_install' => t('Please see the file "install/INSTALL.txt".'), + '$next' => t('Next'), + '$reload' => t('Check again'), + '$phpath' => $phpath, + '$baseurl' => z_root(), + ]); + return $o; + break; + } + + case 2: + { + // Database config + + $dbhost = ((x($_POST, 'dbhost')) ? trim($_POST['dbhost']) : '127.0.0.1'); + $dbuser = ((x($_POST, 'dbuser')) ? trim($_POST['dbuser']) : EMPTY_STR); + $dbport = ((x($_POST, 'dbport')) ? intval(trim($_POST['dbport'])) : 0); + $dbpass = ((x($_POST, 'dbpass')) ? trim($_POST['dbpass']) : EMPTY_STR); + $dbdata = ((x($_POST, 'dbdata')) ? trim($_POST['dbdata']) : EMPTY_STR); + $dbtype = ((x($_POST, 'dbtype')) ? intval(trim($_POST['dbtype'])) : 0); + $phpath = ((x($_POST, 'phpath')) ? trim($_POST['phpath']) : EMPTY_STR); + $adminmail = ((x($_POST, 'adminmail')) ? trim($_POST['adminmail']) : EMPTY_STR); + $siteurl = ((x($_POST, 'siteurl')) ? trim($_POST['siteurl']) : EMPTY_STR); + + $servertype = EMPTY_STR; + + $o .= replace_macros(Theme::get_template('install_db.tpl'), [ + '$title' => $install_title, + '$pass' => t('Database connection'), + '$info_01' => t('In order to install this software we need to know how to connect to your database.'), + '$info_02' => t('Please contact your hosting provider or site administrator if you have questions about these settings.'), + '$info_03' => t('The database you specify below should already exist. If it does not, please create it before continuing.'), + + '$status' => $wizard_status, + + '$dbhost' => array('dbhost', t('Database Server Name'), $dbhost, t('Default is 127.0.0.1')), + '$dbport' => array('dbport', t('Database Port'), $dbport, t('Communication port number - use 0 for default')), + '$dbuser' => array('dbuser', t('Database Login Name'), $dbuser, ''), + '$dbpass' => array('dbpass', t('Database Login Password'), $dbpass, ''), + '$dbdata' => array('dbdata', t('Database Name'), $dbdata, ''), + '$dbtype' => array('dbtype', t('Database Type'), $dbtype, '', array(0 => 'MySQL', 1 => 'PostgreSQL')), + + '$adminmail' => array('adminmail', t('Site administrator email address'), $adminmail, t('Required. Your account email address must match this in order to use the web admin panel.')), + '$siteurl' => array('siteurl', t('Website URL'), z_root(), t('Required. Please use SSL (https) URL if available.')), + '$lbl_10' => t('Please select a default timezone for your website'), + '$baseurl' => z_root(), + '$phpath' => $phpath, + '$submit' => t('Submit'), + ]); + + return $o; + break; + } + case 3: + { + // Site settings + $dbhost = ((x($_POST, 'dbhost')) ? trim($_POST['dbhost']) : '127.0.0.1'); + $dbuser = ((x($_POST, 'dbuser')) ? trim($_POST['dbuser']) : EMPTY_STR); + $dbport = ((x($_POST, 'dbport')) ? intval(trim($_POST['dbport'])) : 0); + $dbpass = ((x($_POST, 'dbpass')) ? trim($_POST['dbpass']) : EMPTY_STR); + $dbdata = ((x($_POST, 'dbdata')) ? trim($_POST['dbdata']) : EMPTY_STR); + $dbtype = ((x($_POST, 'dbtype')) ? intval(trim($_POST['dbtype'])) : 0); + $phpath = ((x($_POST, 'phpath')) ? trim($_POST['phpath']) : EMPTY_STR); + + $servertype = EMPTY_STR; + + $adminmail = ((x($_POST, 'adminmail')) ? trim($_POST['adminmail']) : EMPTY_STR); + $siteurl = ((x($_POST, 'siteurl')) ? trim($_POST['siteurl']) : EMPTY_STR); + $timezone = ((x($_POST, 'timezone')) ? ($_POST['timezone']) : 'America/Los_Angeles'); + + $o .= replace_macros(Theme::get_template('install_settings.tpl'), [ + '$title' => $install_title, + '$pass' => t('Site settings'), + '$status' => $wizard_status, + + '$dbhost' => $dbhost, + '$dbport' => $dbport, + '$dbuser' => $dbuser, + '$dbpass' => $dbpass, + '$dbdata' => $dbdata, + '$phpath' => $phpath, + '$dbtype' => $dbtype, + '$servertype' => $servertype, + + '$adminmail' => ['adminmail', t('Site administrator email address'), $adminmail, t('Required. Your account email address must match this in order to use the web admin panel.')], + + '$siteurl' => ['siteurl', t('Website URL'), z_root(), t('Required. Please use SSL (https) URL if available.')], + + '$timezone' => ['timezone', t('Please select a default timezone for your website'), $timezone, '', get_timezones()], + + '$baseurl' => z_root(), + + '$submit' => t('Submit'), + ]); + return $o; + break; + } + } + } + + /** + * @brief Add a check result to the array for output. + * + * @param[in,out] array &$checks array passed to template + * @param string $title a title for the check + * @param bool $status + * @param bool $required + * @param string $help optional help string + */ + public function check_add(&$checks, $title, $status, $required, $help = '') + { + $checks[] = [ + 'title' => $title, + 'status' => $status, + 'required' => $required, + 'help' => $help + ]; + } + + /** + * @brief Checks the PHP environment. + * + * @param[in,out] string &$phpath + * @param[out] array &$checks + */ + public function check_php(&$phpath, &$checks) + { + $help = ''; + + if (version_compare(PHP_VERSION, '7.1') < 0) { + $help .= t('PHP version 7.1 or greater is required.'); + $this->check_add($checks, t('PHP version'), false, true, $help); + } + + if (strlen($phpath)) { + $passed = file_exists($phpath); + } elseif (function_exists('shell_exec')) { + if (is_windows()) { + $phpath = trim(shell_exec('where php')); + } else { + $phpath = trim(shell_exec('which php')); + } + $passed = strlen($phpath); + } + + if (!$passed) { + $help .= t('Could not find a command line version of PHP in the web server PATH.') . EOL; + $help .= t('If you do not have a command line version of PHP installed on server, you will not be able to run background tasks - including message delivery.') . EOL; + $help .= EOL; + + $help .= replace_macros(Theme::get_template('field_input.tpl'), [ + '$field' => ['phpath', t('PHP executable path'), $phpath, t('Enter full path to php executable. You can leave this blank to continue the installation.')], + ]); + $phpath = ''; + } + + $this->check_add($checks, t('Command line PHP') . ($passed ? " ($phpath)" : EMPTY_STR), $passed, false, $help); + + if ($passed) { + $str = autoname(8); + $cmd = "$phpath install/testargs.php $str"; + $help = ''; + + if (function_exists('shell_exec')) { + $result = trim(shell_exec($cmd)); + } else { + $help .= t('Unable to check command line PHP, as shell_exec() is disabled. This is required.') . EOL; + } + $passed2 = (($result === $str) ? true : false); + if (!$passed2) { + $help .= t('The command line version of PHP on your system does not have "register_argc_argv" enabled.') . EOL; + $help .= t('This is required for message delivery to work.'); + } + + $this->check_add($checks, t('PHP register_argc_argv'), $passed, true, $help); + } + } + + /** + * @brief Some PHP configuration checks. + * + * @param[out] array &$checks + * @todo Change how we display such informational text. Add more description + * how to change them. + * + */ + public function check_phpconfig(&$checks) + { + + $help = ''; + $mem_warning = EMPTY_STR; + + $result = self::getPhpiniUploadLimits(); + if ($result['post_max_size'] < (2 * 1024 * 1024) || $result['max_upload_filesize'] < (2 * 1024 * 1024)) { + $mem_warning = '' . t('This is not sufficient to upload larger images or files. You should be able to upload at least 2MB (2097152 bytes) at once.') . ''; + } + + $help = sprintf( + t('Your max allowed total upload size is set to %s. Maximum size of one file to upload is set to %s. You are allowed to upload up to %d files at once.'), + userReadableSize($result['post_max_size']), + userReadableSize($result['max_upload_filesize']), + $result['max_file_uploads'] + ); + + $help .= (($mem_warning) ? $mem_warning : EMPTY_STR); + $help .= '

      ' . t('You can adjust these settings in the server php.ini file.'); + + $this->check_add($checks, t('PHP upload limits'), true, false, $help); + } + + /** + * @brief Check if the openssl implementation can generate keys. + * + * @param[out] array $checks + */ + public function check_keys(&$checks) + { + $help = ''; + $res = false; + + if (function_exists('openssl_pkey_new')) { + $res = openssl_pkey_new(array( + 'digest_alg' => 'sha1', + 'private_key_bits' => 4096, + 'encrypt_key' => false)); + } + + // Get private key + + if (!$res) { + $help .= t('Error: the "openssl_pkey_new" function on this system is not able to generate encryption keys') . EOL; + $help .= t('If running under Windows, please see "http://www.php.net/manual/en/openssl.installation.php".'); + } + + $this->check_add($checks, t('Generate encryption keys'), $res, true, $help); + } + + /** + * @brief Check for some PHP functions and modules. + * + * @param[in,out] array &$checks + */ + public function check_funcs(&$checks) + { + $ck_funcs = []; + + $disabled = explode(',', ini_get('disable_functions')); + if ($disabled) { + array_walk($disabled, 'array_trim'); + } + + // add check metadata, the real check is done bit later and return values set + + $this->check_add($ck_funcs, t('libCurl PHP module'), true, true); + $this->check_add($ck_funcs, t('GD graphics PHP module'), true, true); + $this->check_add($ck_funcs, t('OpenSSL PHP module'), true, true); + $this->check_add($ck_funcs, t('PDO database PHP module'), true, true); + $this->check_add($ck_funcs, t('mb_string PHP module'), true, true); + $this->check_add($ck_funcs, t('xml PHP module'), true, true); + $this->check_add($ck_funcs, t('zip PHP module'), true, true); + + if (function_exists('apache_get_modules')) { + if (!in_array('mod_rewrite', apache_get_modules())) { + $this->check_add($ck_funcs, t('Apache mod_rewrite module'), false, true, t('Error: Apache webserver mod-rewrite module is required but not installed.')); + } else { + $this->check_add($ck_funcs, t('Apache mod_rewrite module'), true, true); + } + } + if ((!function_exists('exec')) || in_array('exec', $disabled)) { + $this->check_add($ck_funcs, t('exec'), false, true, t('Error: exec is required but is either not installed or has been disabled in php.ini')); + } else { + $this->check_add($ck_funcs, t('exec'), true, true); + } + if ((!function_exists('shell_exec')) || in_array('shell_exec', $disabled)) { + $this->check_add($ck_funcs, t('shell_exec'), false, true, t('Error: shell_exec is required but is either not installed or has been disabled in php.ini')); + } else { + $this->check_add($ck_funcs, t('shell_exec'), true, true); + } + + if (!function_exists('curl_init')) { + $ck_funcs[0]['status'] = false; + $ck_funcs[0]['help'] = t('Error: libCURL PHP module required but not installed.'); + } + if ((!function_exists('imagecreatefromjpeg')) && (!class_exists('\\Imagick'))) { + $ck_funcs[1]['status'] = false; + $ck_funcs[1]['help'] = t('Error: GD PHP module with JPEG support or ImageMagick graphics library required but not installed.'); + } + if (!function_exists('openssl_public_encrypt')) { + $ck_funcs[2]['status'] = false; + $ck_funcs[2]['help'] = t('Error: openssl PHP module required but not installed.'); + } + if (class_exists('\\PDO')) { + $x = PDO::getAvailableDrivers(); + if ((!in_array('mysql', $x)) && (!in_array('pgsql', $x))) { + $ck_funcs[3]['status'] = false; + $ck_funcs[3]['help'] = t('Error: PDO database PHP module missing a driver for either mysql or pgsql.'); + } + } + if (!class_exists('\\PDO')) { + $ck_funcs[3]['status'] = false; + $ck_funcs[3]['help'] = t('Error: PDO database PHP module required but not installed.'); + } + if (!function_exists('mb_strlen')) { + $ck_funcs[4]['status'] = false; + $ck_funcs[4]['help'] = t('Error: mb_string PHP module required but not installed.'); + } + if (!extension_loaded('xml')) { + $ck_funcs[5]['status'] = false; + $ck_funcs[5]['help'] = t('Error: xml PHP module required for DAV but not installed.'); + } + if (!extension_loaded('zip')) { + $ck_funcs[6]['status'] = false; + $ck_funcs[6]['help'] = t('Error: zip PHP module required but not installed.'); + } + + $checks = array_merge($checks, $ck_funcs); + } + + /** + * @brief Check for .htconfig requirements. + * + * @param[out] array &$checks + */ + public function check_htconfig(&$checks) + { + $status = true; + $help = EMPTY_STR; + + $fname = '.htconfig.php'; + + if ( + (file_exists($fname) && is_writable($fname)) || + (!(file_exists($fname) && is_writable('.'))) + ) { + $this->check_add($checks, t('.htconfig.php is writable'), $status, true, $help); + return; + } + + $status = false; + $help .= t('The web installer needs to be able to create a file called ".htconfig.php" in the top folder of your web server and it is unable to do so.') . EOL; + $help .= t('This is most often a permission setting, as the web server may not be able to write files in your folder - even if you can.') . EOL; + $help .= t('Please see install/INSTALL.txt for additional information.'); + + $this->check_add($checks, t('.htconfig.php is writable'), $status, true, $help); + } + + /** + * @brief Checks for our templating engine Smarty3 requirements. + * + * @param[out] array &$checks + */ + public function check_smarty3(&$checks) + { + $status = true; + $help = ''; + + @os_mkdir(TEMPLATE_BUILD_PATH, STORAGE_DEFAULT_PERMISSIONS, true); + + if (!is_writable(TEMPLATE_BUILD_PATH)) { + $status = false; + $help .= t('This software uses the Smarty3 template engine to render its web views. Smarty3 compiles templates to PHP to speed up rendering.') . EOL; + $help .= sprintf(t('In order to store these compiled templates, the web server needs to have write access to the directory %s under the top level web folder.'), TEMPLATE_BUILD_PATH) . EOL; + $help .= t('Please ensure that the user that your web server runs as (e.g. www-data) has write access to this folder.') . EOL; + } + + $this->check_add($checks, sprintf(t('%s is writable'), TEMPLATE_BUILD_PATH), $status, true, $help); + } + + /** + * @brief Check for store directory. + * + * @param[out] array &$checks + */ + public function check_store(&$checks) + { + $status = true; + $help = ''; + + @os_mkdir('store', STORAGE_DEFAULT_PERMISSIONS, true); + + if (!is_writable('store')) { + $status = false; + $help = t('This software uses the store directory to save uploaded files. The web server needs to have write access to the store directory under the top level web folder') . EOL; + $help .= t('Please ensure that the user that your web server runs as (e.g. www-data) has write access to this folder.') . EOL; + } + + $this->check_add($checks, t('store is writable'), $status, true, $help); + } + + /** + * @brief Check URL rewrite und SSL certificate. + * + * @param[out] array &$checks + */ + public function check_htaccess(&$checks) + { + $status = true; + $help = ''; + $ssl_error = false; + + $url = z_root() . '/setup/testrewrite'; + + if (function_exists('curl_init')) { + $test = z_fetch_url($url); + if (!$test['success']) { + if (strstr($url, 'https://')) { + $test = z_fetch_url($url, false, 0, ['novalidate' => true]); + if ($test['success']) { + $ssl_error = true; + } + } else { + $test = z_fetch_url(str_replace('http://', 'https://', $url), false, 0, ['novalidate' => true]); + if ($test['success']) { + $ssl_error = true; + } + } + + if ($ssl_error) { + $help .= t('SSL certificate cannot be validated. Fix certificate or disable https access to this site.') . EOL; + $help .= t('If you have https access to your website or allow connections to TCP port 443 (the https: port), you MUST use a browser-valid certificate. You MUST NOT use self-signed certificates!') . EOL; + $help .= t('This restriction is incorporated because public posts from you may for example contain references to images on your own hub.') . EOL; + $help .= t('If your certificate is not recognized, members of other sites (who may themselves have valid certificates) will get a warning message on their own site complaining about security issues.') . EOL; + $help .= t('This can cause usability issues elsewhere (not just on your own site) so we must insist on this requirement.') . EOL; + $help .= t('Providers are available that issue free certificates which are browser-valid.') . EOL; + + $help .= t('If you are confident that the certificate is valid and signed by a trusted authority, check to see if you have failed to install an intermediate cert. These are not normally required by browsers, but are required for server-to-server communications.') . EOL; + + $this->check_add($checks, t('SSL certificate validation'), false, true, $help); + } + } + + if ((!$test['success']) || ($test['body'] !== 'ok')) { + $status = false; + $help = t('Url rewrite in .htaccess is not working. Check your server configuration.' . 'Test: ' . var_export($test, true)); + } + + $this->check_add($checks, t('Url rewrite is working'), $status, true, $help); + } else { + // cannot check modrewrite if libcurl is not installed + } + } + + /** + * @brief + * + * @param App &$a + * @return string with paresed HTML + */ + public function manual_config(&$a) + { + $data = htmlspecialchars(App::$data['txt'], ENT_COMPAT, 'UTF-8'); + $o = t('The database configuration file ".htconfig.php" could not be written. Please use the enclosed text to create a configuration file in your web server root.'); + $o .= ""; + + return $o; + } + + public function load_database_rem($v, $i) + { + $l = trim($i); + if (strlen($l) > 1 && ($l[0] === '-' || ($l[0] === '/' && $l[1] === '*'))) { + return $v; + } else { + return $v . "\n" . $i; + } + } + + + public function load_database($db) + { + $str = file_get_contents(DBA::$dba->get_install_script()); + $arr = explode(';', $str); + $errors = false; + foreach ($arr as $a) { + if (strlen(trim($a))) { + $r = dbq(trim($a)); + if (!$r) { + $errors .= t('Errors encountered creating database tables.') . $a . EOL; + } + } + } + + return $errors; + } + + /** + * @brief + * + * @return string with parsed HTML + */ + public function what_next() + { + // install the standard theme + set_config('system', 'allowed_themes', 'redbasic'); + + // if imagick converter is installed, use it + if (@is_executable('/usr/bin/convert')) { + set_config('system', 'imagick_convert_path', '/usr/bin/convert'); + } + + // Set a lenient list of ciphers if using openssl. Other ssl engines + // (e.g. NSS used in RedHat) require different syntax, so hopefully + // the default curl cipher list will work for most sites. If not, + // this can set via config. Many distros are now disabling RC4, + // but many existing sites still use it and are unable to change it. + // We do not use SSL for encryption, only to protect session cookies. + // z_fetch_url() is also used to import shared links and other content + // so in theory most any cipher could show up and we should do our best + // to make the content available rather than tell folks that there's a + // weird SSL error which they can't do anything about. This does not affect + // the SSL server, but is only a client negotiation to find something workable. + // Hence it will not make your system susceptible to POODL or other nasties. + + $x = curl_version(); + if (stristr($x['ssl_version'], 'openssl')) { + set_config('system', 'curl_ssl_ciphers', 'ALL:!eNULL'); + } + + // Create a system channel + + Channel::create_system(); + + $register_link = '' . t('registration page') . ''; + + return + t('

      What next?

      ') + . '
      ' + . t('IMPORTANT: You will need to [manually] setup a scheduled task for the poller.') + . EOL + . t('Please see the file "install/INSTALL.txt" for more information and instructions.') + . '
      ' + . sprintf(t('Go to your new hub %s and register as new member. Please use the same email address that you entered for the administrator email as this will allow your new account to enter the site admin panel.'), $register_link) + . '
      '; + } + + /** + * @brief + * + * @param unknown $v + * @param array $c + * @return array + */ + private static function check_passed($v, $c) + { + if ($c['required']) { + $v = $v && $c['status']; + } + return $v; + } + + /** + * @brief Get some upload related limits from php.ini. + * + * This function returns values from php.ini like \b post_max_size, + * \b max_file_uploads, \b upload_max_filesize. + * + * @return array associative array + * * \e int \b post_max_size the maximum size of a complete POST in bytes + * * \e int \b upload_max_filesize the maximum size of one file in bytes + * * \e int \b max_file_uploads maximum number of files in one POST + * * \e int \b max_upload_filesize min(post_max_size, upload_max_filesize) + */ + + private static function getPhpiniUploadLimits() + { + $ret = []; + + // max size of the complete POST + $ret['post_max_size'] = self::phpiniSizeToBytes(ini_get('post_max_size')); + // max size of one file + $ret['upload_max_filesize'] = self::phpiniSizeToBytes(ini_get('upload_max_filesize')); + // catch a configuration error where post_max_size < upload_max_filesize + $ret['max_upload_filesize'] = min( + $ret['post_max_size'], + $ret['upload_max_filesize'] + ); + // maximum number of files in one POST + $ret['max_file_uploads'] = intval(ini_get('max_file_uploads')); + + return $ret; + } + + /** + * @brief Parses php_ini size settings to bytes. + * + * This function parses common size setting from php.ini files to bytes. + * e.g. post_max_size = 8M ==> 8388608 + * + * \note This method does not recognise other human readable formats like + * 8MB, etc. + * + * @param string $val value from php.ini e.g. 2M, 8M + * @return int size in bytes + * @todo Make this function more universal useable. MB, T, etc. + * + */ + private static function phpiniSizeToBytes($val) + { + $val = trim($val); + $unit = strtolower($val[strlen($val) - 1]); + // strip off any non-numeric portion + $val = intval($val); + switch ($unit) { + case 'g': + $val *= 1024; + case 'm': + $val *= 1024; + case 'k': + $val *= 1024; + default: + break; + } + + return (int)$val; + } +} diff --git a/Code/Module/Share.php b/Code/Module/Share.php new file mode 100644 index 000000000..d7f5a2071 --- /dev/null +++ b/Code/Module/Share.php @@ -0,0 +1,151 @@ + 1) ? intval(argv(1)) : 0); + + if (!$post_id) { + killme(); + } + + if (!local_channel()) { + killme(); + } + + $observer = App::get_observer(); + + $channel = App::get_channel(); + + + $r = q( + "SELECT * from item left join xchan on author_xchan = xchan_hash WHERE id = %d LIMIT 1", + intval($post_id) + ); + if (!$r) { + killme(); + } + + if (($r[0]['item_private']) && ($r[0]['xchan_network'] !== 'rss')) { + killme(); + } + + $sql_extra = item_permissions_sql($r[0]['uid']); + + $r = q( + "select * from item where id = %d $sql_extra", + intval($post_id) + ); + if (!$r) { + killme(); + } + + /** @FIXME we only share bbcode */ + + if (!in_array($r[0]['mimetype'], ['text/bbcode', 'text/x-multicode'])) { + killme(); + } + + + xchan_query($r); + + $arr = []; + + $item = $r[0]; + + $owner_uid = $r[0]['uid']; + $owner_aid = $r[0]['aid']; + + $can_comment = false; + if ((array_key_exists('owner', $item)) && intval($item['owner']['abook_self'])) { + $can_comment = perm_is_allowed($item['uid'], $observer['xchan_hash'], 'post_comments'); + } else { + $can_comment = can_comment_on_post($observer['xchan_hash'], $item); + } + + if (!$can_comment) { + notice(t('Permission denied') . EOL); + killme(); + } + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($item['owner_xchan']) + ); + + if ($r) { + $thread_owner = $r[0]; + } else { + killme(); + } + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($item['author_xchan']) + ); + if ($r) { + $item_author = $r[0]; + } else { + killme(); + } + + + $arr['aid'] = $owner_aid; + $arr['uid'] = $owner_uid; + + $arr['item_origin'] = 1; + $arr['item_wall'] = $item['item_wall']; + $arr['uuid'] = new_uuid(); + $arr['mid'] = z_root() . '/item/' . $arr['uuid']; + $arr['mid'] = str_replace('/item/', '/activity/', $arr['mid']); + $arr['parent_mid'] = $item['mid']; + + $mention = '@[zrl=' . $item['author']['xchan_url'] . ']' . $item['author']['xchan_name'] . '[/zrl]'; + $arr['body'] = sprintf(t('🔁 Repeated %1$s\'s %2$s'), $mention, $item['obj_type']); + + $arr['author_xchan'] = $channel['channel_hash']; + $arr['owner_xchan'] = $item['author_xchan']; + $arr['obj'] = $item['obj']; + $arr['obj_type'] = $item['obj_type']; + $arr['verb'] = 'Announce'; + + $post = item_store($arr); + + $post_id = $post['item_id']; + + $arr['id'] = $post_id; + + Hook::call('post_local_end', $arr); + + info(t('Post repeated') . EOL); + + $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($channel['channel_id'], ['item' => [encode_item($sync_item[0], true)]]); + } + + Run::Summon(['Notifier', 'like', $post_id]); + + killme(); + } +} diff --git a/Code/Module/Sharedwithme.php b/Code/Module/Sharedwithme.php new file mode 100644 index 000000000..a9a7d19f1 --- /dev/null +++ b/Code/Module/Sharedwithme.php @@ -0,0 +1,120 @@ + 2) && (argv(2) === 'drop')) { + $id = intval(argv(1)); + + q( + "DELETE FROM item WHERE id = %d AND uid = %d", + intval($id), + intval(local_channel()) + ); + + goaway(z_root() . '/sharedwithme'); + } + + //drop all files - localuser + if ((argc() > 1) && (argv(1) === 'dropall')) { + q( + "DELETE FROM item WHERE verb = '%s' AND obj_type = '%s' AND uid = %d", + dbesc(ACTIVITY_POST), + dbesc(ACTIVITY_OBJ_FILE), + intval(local_channel()) + ); + + goaway(z_root() . '/sharedwithme'); + } + + //list files + $r = q( + "SELECT id, uid, obj, item_unseen FROM item WHERE verb = '%s' AND obj_type = '%s' AND uid = %d AND owner_xchan != '%s'", + dbesc(ACTIVITY_POST), + dbesc(ACTIVITY_OBJ_FILE), + intval(local_channel()), + dbesc($channel['channel_hash']) + ); + + $items = []; + $ids = ''; + + if ($r) { + foreach ($r as $rr) { + $object = json_decode($rr['obj'], true); + + $item = []; + $item['id'] = $rr['id']; + $item['objfiletype'] = $object['filetype']; + $item['objfiletypeclass'] = getIconFromType($object['filetype']); + $item['objurl'] = rawurldecode(get_rel_link($object['link'], 'alternate')) . '?f=&zid=' . $channel['xchan_addr']; + $item['objfilename'] = $object['filename']; + $item['objfilesize'] = userReadableSize($object['filesize']); + $item['objedited'] = $object['edited']; + $item['unseen'] = $rr['item_unseen']; + + $items[] = $item; + + if ($item['unseen'] > 0) { + $ids .= " '" . $rr['id'] . "',"; + } + } + } + + if ($ids) { + //remove trailing , + $ids = rtrim($ids, ","); + + q( + "UPDATE item SET item_unseen = 0 WHERE id IN ( $ids ) AND uid = %d", + intval(local_channel()) + ); + } + + $o = ''; + + $o .= replace_macros(Theme::get_template('sharedwithme.tpl'), array( + '$header' => t('Files: shared with me'), + '$name' => t('Name'), + '$label_new' => t('NEW'), + '$size' => t('Size'), + '$lastmod' => t('Last Modified'), + '$dropall' => t('Remove all files'), + '$drop' => t('Remove this file'), + '$items' => $items + )); + + return $o; + } +} diff --git a/Code/Module/Siteinfo.php b/Code/Module/Siteinfo.php new file mode 100644 index 000000000..f5092dd2f --- /dev/null +++ b/Code/Module/Siteinfo.php @@ -0,0 +1,67 @@ + t('About this site'), + '$url' => z_root(), + '$sitenametxt' => t('Site Name'), + '$sitename' => System::get_site_name(), + '$headline' => t('Site Information'), + '$site_about' => bbcode(get_config('system', 'siteinfo')), + '$admin_headline' => t('Administrator'), + '$admin_about' => bbcode(get_config('system', 'admininfo')), + '$terms' => t('Terms of Service'), + '$prj_header' => t('Software and Project information'), + '$prj_name' => t('This site is powered by $Projectname'), + '$prj_transport' => t('Federated and decentralised networking and identity services provided by Nomad'), + '$transport_link' => 'https://zotlabs.com', + + '$ebs' => System::ebs(), + '$additional_text' => t('Protocols:'), + '$additional_fed' => $federated, + '$prj_version' => ((get_config('system', 'hidden_version_siteinfo')) ? '' : sprintf(t('Version %s'), System::get_project_version())), + '$prj_linktxt' => t('Project homepage'), + '$prj_srctxt' => t('Developer homepage'), + '$prj_link' => System::get_project_link(), + '$prj_src' => System::get_project_srclink(), + '$prj_icon' => System::get_site_icon(), + ] + ); + + Hook::call('about_hook', $siteinfo); + + return $siteinfo; + } +} diff --git a/Code/Module/Sites.php b/Code/Module/Sites.php new file mode 100644 index 000000000..71fc84971 --- /dev/null +++ b/Code/Module/Sites.php @@ -0,0 +1,150 @@ + $rr['site_url'], + 'name' => $sitename, + 'access' => $access, + 'register' => $register_link, + 'sellpage' => $rr['site_sellpage'], + 'location_label' => t('Location'), + 'location' => $rr['site_location'], + 'project' => $rr['site_project'], + 'version' => $rr['site_version'], + 'photo' => $logo, + 'about' => bbcode($about), + 'hash' => substr(hash('sha256', $rr['site_url']), 0, 16), + 'network_label' => t('Project'), + 'network' => $rr['site_project'], + 'version_label' => t('Version'), + 'version' => $rr['site_version'], + 'private' => $disabled, + 'connect' => (($disabled) ? '' : $register_link), + 'connect_label' => $register, + 'access' => (($access === 'private') ? '' : $access), + 'access_label' => t('Access type'), + ]; + } + } + + $o = replace_macros(Theme::get_template('sitentry_header.tpl'), [ + '$dirlbl' => t('Affiliated Sites'), + '$desc' => $desc, + '$entries' => $j, + ]); + + + return $o; + } + + public function sort_sites($a) + { + $ret = []; + if ($a) { + foreach ($a as $e) { + $projectname = explode(' ', $e['project']); + $ret[$projectname[0]][] = $e; + } + } + $projects = array_keys($ret); + rsort($projects); + + $newret = []; + foreach ($projects as $p) { + $newret[$p] = $ret[$p]; + } + + return $newret; + } + + public function sort_versions($a, $b) + { + return version_compare($b['version'], $a['version']); + } +} diff --git a/Code/Module/Smilies.php b/Code/Module/Smilies.php new file mode 100644 index 000000000..b394a51ff --- /dev/null +++ b/Code/Module/Smilies.php @@ -0,0 +1,24 @@ + $tmp['texts'][$i], 'icon' => $tmp['icons'][$i]); + } + json_return_and_die($results); + } else { + return smilies('', true); + } + } +} diff --git a/Code/Module/Sources.php b/Code/Module/Sources.php new file mode 100644 index 000000000..6456a3b5d --- /dev/null +++ b/Code/Module/Sources.php @@ -0,0 +1,202 @@ + t('Channel Sources'), + '$desc' => t('Manage remote sources of content for your channel.'), + '$new' => t('New Source'), + '$sources' => $r + ]); + return $o; + } + + if (argc() == 2 && argv(1) === 'new') { + // TODO add the words 'or RSS feed' and corresponding code to manage feeds and frequency + + $o = replace_macros(Theme::get_template('sources_new.tpl'), [ + '$title' => t('New Source'), + '$desc' => t('Import all or selected content from the following channel into this channel and distribute it according to your channel settings.'), + '$words' => ['words', t('Only import content with these words (one per line)'), '', t('Leave blank to import all public content')], + '$name' => ['name', t('Channel Name'), '', '', '', 'autocomplete="off"'], + '$tags' => ['tags', t('Add the following categories to posts imported from this source (comma separated)'), '', t('Optional')], + '$resend' => ['resend', t('Resend posts with this channel as author'), 0, t('Copyrights may apply'), [t('No'), t('Yes')]], + '$submit' => t('Submit') + ]); + return $o; + } + + if (argc() == 2 && intval(argv(1))) { + // edit source + $r = q( + "select source.*, xchan.* from source left join xchan on src_xchan = xchan_hash where src_id = %d and src_channel_id = %d limit 1", + intval(argv(1)), + intval(local_channel()) + ); + if ($r) { + $x = q( + "select abook_id from abook where abook_xchan = '%s' and abook_channel = %d limit 1", + dbesc($r[0]['src_xchan']), + intval(local_channel()) + ); + } + if (!$r) { + notice(t('Source not found.') . EOL); + return ''; + } + + $r[0]['src_patt'] = htmlspecialchars($r[0]['src_patt'], ENT_QUOTES, 'UTF-8'); + + $o = replace_macros(Theme::get_template('sources_edit.tpl'), array( + '$title' => t('Edit Source'), + '$drop' => t('Delete Source'), + '$id' => $r[0]['src_id'], + '$desc' => t('Import all or selected content from the following channel into this channel and distribute it according to your channel settings.'), + '$words' => array('words', t('Only import content with these words (one per line)'), $r[0]['src_patt'], t('Leave blank to import all public content')), + '$xchan' => $r[0]['src_xchan'], + '$abook' => $x[0]['abook_id'], + '$tags' => array('tags', t('Add the following categories to posts imported from this source (comma separated)'), $r[0]['src_tag'], t('Optional')), + '$resend' => ['resend', t('Resend posts with this channel as author'), get_abconfig(local_channel(), $r[0]['xchan_hash'], 'system', 'rself'), t('Copyrights may apply'), [t('No'), t('Yes')]], + + '$name' => array('name', t('Channel Name'), $r[0]['xchan_name'], ''), + '$submit' => t('Submit') + )); + return $o; + } + + if (argc() == 3 && intval(argv(1)) && argv(2) === 'drop') { + $r = q( + "select * from source where src_id = %d and src_channel_id = %d limit 1", + intval(argv(1)), + intval(local_channel()) + ); + if (!$r) { + notice(t('Source not found.') . EOL); + return ''; + } + $r = q( + "delete from source where src_id = %d and src_channel_id = %d", + intval(argv(1)), + intval(local_channel()) + ); + if ($r) { + info(t('Source removed') . EOL); + } else { + notice(t('Unable to remove source.') . EOL); + } + + goaway(z_root() . '/sources'); + } + + // shouldn't get here. + } +} diff --git a/Code/Module/Sslify.php b/Code/Module/Sslify.php new file mode 100644 index 000000000..836405e03 --- /dev/null +++ b/Code/Module/Sslify.php @@ -0,0 +1,29 @@ + 1) ? intval(argv(1)) : 0); + if (!$message_id) { + killme(); + } + + $r = q( + "SELECT * FROM item WHERE id = %d + and item_type in (0,6,7) and item_deleted = 0 and item_unpublished = 0 + and item_delayed = 0 and item_pending_remove = 0 and item_blocked = 0 LIMIT 1", + intval($message_id) + ); + + // if interacting with a pubstream item, + // create a copy of the parent in your stream. + + + if ($r) { + if (! Channel::is_system(local_channel())) { + $r = [ copy_of_pubitem(App::get_channel(), $r[0]['mid']) ]; + } + } + + if (! $r) { + killme(); + } + + // reset $message_id to the fetched copy of message if applicable + $message_id = $r[0]['id']; + $item_starred = (intval($r[0]['item_starred']) ? 0 : 1); + + $r = q( + "UPDATE item SET item_starred = %d WHERE uid = %d and id = %d", + intval($item_starred), + intval(local_channel()), + intval($message_id) + ); + + $r = q( + "select * from item where id = %d", + intval($message_id) + ); + if ($r) { + xchan_query($r); + $sync_item = fetch_post_tags($r); + Libsync::build_sync_packet(local_channel(), [ + 'item' => [ + encode_item($sync_item[0], true) + ] + ]); + } + + header('Content-type: application/json'); + echo json_encode(array('result' => $item_starred)); + killme(); + } +} diff --git a/Code/Module/Stream.php b/Code/Module/Stream.php new file mode 100644 index 000000000..90904e1e3 --- /dev/null +++ b/Code/Module/Stream.php @@ -0,0 +1,670 @@ +loading) { + $_SESSION['loadtime_stream'] = datetime_convert(); + PConfig::Set(local_channel(), 'system', 'loadtime_stream', $_SESSION['loadtime_stream']); + // stream is a superset of channel when it comes to notifications + $_SESSION['loadtime_channel'] = datetime_convert(); + PConfig::Set(local_channel(), 'system', 'loadtime_channel', $_SESSION['loadtime_channel']); + } + + $arr = ['query' => App::$query_string]; + Hook::call('stream_content_init', $arr); + + $channel = ((isset(App::$data['channel'])) ? App::$data['channel'] : null); + + // if called from liveUpdate() we will not have called Stream->init() on this request and $channel will not be set + + if (!$channel) { + $channel = App::get_channel(); + } + + + $item_normal = item_normal(); + $item_normal_update = item_normal_update(); + + $datequery = $datequery2 = ''; + + $group = 0; + + $nouveau = false; + + $datequery = ((x($_GET, 'dend') && is_a_date_arg($_GET['dend'])) ? notags($_GET['dend']) : ''); + $datequery2 = ((x($_GET, 'dbegin') && is_a_date_arg($_GET['dbegin'])) ? notags($_GET['dbegin']) : ''); + $static = ((x($_GET, 'static')) ? intval($_GET['static']) : 0); + $gid = ((x($_GET, 'gid')) ? $_REQUEST['gid'] : 0); + $category = ((x($_REQUEST, 'cat')) ? $_REQUEST['cat'] : ''); + $hashtags = ((x($_REQUEST, 'tag')) ? $_REQUEST['tag'] : ''); + $verb = ((x($_REQUEST, 'verb')) ? $_REQUEST['verb'] : ''); + $dm = ((x($_REQUEST, 'dm')) ? $_REQUEST['dm'] : 0); + + $c_order = get_pconfig(local_channel(), 'mod_stream', 'order', 0); + switch ($c_order) { + case 0: + $order = 'comment'; + break; + case 1: + $order = 'post'; + break; + case 2: + $nouveau = true; + break; + } + + $search = (isset($_GET['search']) ? $_GET['search'] : ''); + if ($search) { + $_GET['netsearch'] = escape_tags($search); + if (strpos($search, '@') === 0) { + $r = q( + "select abook_id from abook left join xchan on abook_xchan = xchan_hash where xchan_name = '%s' and abook_channel = %d limit 1", + dbesc(substr($search, 1)), + intval(local_channel()) + ); + if ($r) { + $_GET['cid'] = $r[0]['abook_id']; + $search = $_GET['search'] = ''; + } + } elseif (strpos($search, '#') === 0) { + $hashtags = substr($search, 1); + $search = $_GET['search'] = ''; + } + } + + if ($datequery) { + $order = 'post'; + } + + // filter by collection (e.g. group) + + $vg = false; + + if ($gid) { + if (strpos($gid, ':') === 0) { + $g = substr($gid, 1); + switch ($g) { + case '1': + $r = [['hash' => 'connections:' . $channel['channel_hash']]]; + $vg = t('Connections'); + break; + case '2': + $r = [['hash' => 'zot:' . $channel['channel_hash']]]; + $vg = t('Nomad'); + break; + case '3': + $r = [['hash' => 'activitypub:' . $channel['channel_hash']]]; + $vg = t('ActivityPub'); + break; + default: + break; + } + } else { + $r = q( + "SELECT * FROM pgrp WHERE id = %d AND uid = %d LIMIT 1", + intval($gid), + intval(local_channel()) + ); + if (!$r) { + if ($this->updating) { + killme(); + } + notice(t('Access list not found') . EOL); + goaway(z_root() . '/stream'); + } + } + + + $group = $gid; + $group_hash = $r[0]['hash']; + } + + $default_cmin = ((Apps::system_app_installed(local_channel(), 'Friend Zoom')) ? get_pconfig(local_channel(), 'affinity', 'cmin', 0) : (-1)); + $default_cmax = ((Apps::system_app_installed(local_channel(), 'Friend Zoom')) ? get_pconfig(local_channel(), 'affinity', 'cmax', 99) : (-1)); + + $cid = ((x($_GET, 'cid')) ? intval($_GET['cid']) : 0); + $draft = ((x($_GET, 'draft')) ? intval($_GET['draft']) : 0); + $star = ((x($_GET, 'star')) ? intval($_GET['star']) : 0); + $liked = ((x($_GET, 'liked')) ? intval($_GET['liked']) : 0); + $conv = ((x($_GET, 'conv')) ? intval($_GET['conv']) : 0); + $spam = ((x($_GET, 'spam')) ? intval($_GET['spam']) : 0); + $cmin = ((array_key_exists('cmin', $_GET)) ? intval($_GET['cmin']) : $default_cmin); + $cmax = ((array_key_exists('cmax', $_GET)) ? intval($_GET['cmax']) : $default_cmax); + $file = ((x($_GET, 'file')) ? $_GET['file'] : ''); + $xchan = ((x($_GET, 'xchan')) ? $_GET['xchan'] : ''); + $net = ((x($_GET, 'net')) ? $_GET['net'] : ''); + $pf = ((x($_GET, 'pf')) ? $_GET['pf'] : ''); + + $deftag = ''; + + + if (x($_GET, 'search') || $file || (!$pf && $cid)) { + $nouveau = true; + } + + if ($cid) { + $cid_r = q( + "SELECT abook.abook_xchan, xchan.xchan_addr, xchan.xchan_name, xchan.xchan_url, xchan.xchan_photo_s, xchan.xchan_type 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($cid), + intval(local_channel()) + ); + + if (!$cid_r) { + if ($this->updating) { + killme(); + } + notice(t('No such channel') . EOL); + goaway(z_root() . '/stream'); + } + } + + if (!$this->updating) { + // search terms header + + // hide the terms we use to search for videos from + // the activity_filter widget because it doesn't look very good + if ($search && $search !== 'video]') { + $o .= replace_macros( + Theme::get_template("section_title.tpl"), + ['$title' => t('Search Results For:') . ' ' . htmlspecialchars($search, ENT_COMPAT, 'UTF-8')] + ); + } + + $body = EMPTY_STR; + + Navbar::set_selected('Stream'); + + $channel_acl = [ + 'allow_cid' => $channel['channel_allow_cid'], + 'allow_gid' => $channel['channel_allow_gid'], + 'deny_cid' => $channel['channel_deny_cid'], + 'deny_gid' => $channel['channel_deny_gid'] + ]; + + $x = [ + 'is_owner' => true, + 'allow_location' => ((intval(get_pconfig($channel['channel_id'], 'system', 'use_browser_location'))) ? '1' : ''), + 'default_location' => $channel['channel_location'], + 'nickname' => $channel['channel_address'], + 'lockstate' => (($channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 'lock' : 'unlock'), + 'acl' => Libacl::populate($channel_acl, true, PermissionDescription::fromGlobalPermission('view_stream'), Libacl::get_post_aclDialogDescription(), 'acl_dialog_post'), + 'permissions' => $channel_acl, + 'bang' => EMPTY_STR, + 'body' => $body, + 'visitor' => true, + 'profile_uid' => local_channel(), + 'editor_autocomplete' => true, + 'bbco_autocomplete' => 'bbcode', + 'bbcode' => true, + 'jotnets' => true, + 'reset' => t('Reset form') + ]; + + if ($deftag) { + $x['pretext'] = $deftag; + } + + $status_editor = status_editor($x); + $o .= $status_editor; + + $static = Channel::manual_conv_update(local_channel()); + } + + + // We don't have to deal with ACL's on this page. You're looking at everything + // that belongs to you, hence you can see all of it. We will filter by group if + // desired. + + + $sql_options = (($star) + ? " and item_starred = 1 " + : ''); + + $sql_nets = ''; + + $item_thread_top = ' AND item_thread_top = 1 '; + + $sql_extra = ''; + + if ($draft) { + $item_normal = item_normal_draft(); + $sql_extra = " AND item.parent IN ( SELECT DISTINCT parent FROM item WHERE item_unpublished = 1 and item_deleted = 0 ) "; + } + + if ($group) { + $contact_str = ''; + $contacts = AccessList::members(local_channel(), $group); + if ($contacts) { + $contact_str = ids_to_querystr($contacts, 'xchan', true); + } else { + $contact_str = " '0' "; + if (!$this->updating) { + info(t('Access list is empty')); + } + } + $item_thread_top = ''; + + $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($group_hash) . '>%') . "' ) and id = parent $item_normal ) "; + + + if (!$vg) { + $x = AccessList::rec_byhash(local_channel(), $group_hash); + } + + if ($x || $vg) { + $title = replace_macros(Theme::get_template("section_title.tpl"), array( + '$title' => sprintf(t('Access list: %s'), (($vg) ? $vg : $x['gname'])) + )); + } + + $o = $title . $status_editor; + } elseif (isset($cid_r) && $cid_r) { + $item_thread_top = EMPTY_STR; + + if ($this->loading || $this->updating) { + if (!$pf && $nouveau) { + $sql_extra = " AND author_xchan = '" . dbesc($cid_r[0]['abook_xchan']) . "' "; + } else { + $ttype = (($pf) ? TERM_FORUM : TERM_MENTION); + + $p1 = q("SELECT DISTINCT parent FROM item WHERE uid = " . intval(local_channel()) . " AND ( author_xchan = '" . dbesc($cid_r[0]['abook_xchan']) . "' OR owner_xchan = '" . dbesc($cid_r[0]['abook_xchan']) . "' ) $item_normal "); + $p2 = q("SELECT oid AS parent FROM term WHERE uid = " . intval(local_channel()) . " AND ttype = $ttype AND term = '" . dbesc($cid_r[0]['xchan_name']) . "'"); + + $p_str = ids_to_querystr(array_merge($p1, $p2), 'parent'); + $sql_extra = " AND item.parent IN ( $p_str ) "; + } + } + + $title = replace_macros(Theme::get_template('section_title.tpl'), [ + '$title' => '' . urlencode($cid_r[0]['xchan_name']) . ' ' . $cid_r[0]['xchan_name'] . '' + ]); + + $o = $title; + $o .= $status_editor; + } elseif ($xchan) { + $r = q( + "select * from xchan where xchan_hash = '%s'", + dbesc($xchan) + ); + if ($r) { + $item_thread_top = ''; + $sql_extra = " AND item.parent IN ( SELECT DISTINCT parent FROM item WHERE true $sql_options AND uid = " . intval(local_channel()) . " AND ( author_xchan = '" . dbesc($xchan) . "' or owner_xchan = '" . dbesc($xchan) . "' ) $item_normal ) "; + $title = replace_macros(Theme::get_template('section_title.tpl'), [ + '$title' => '' . urlencode($r[0]['xchan_name']) . ' ' . $r[0]['xchan_name'] . '' + ]); + + $o = $title; + $o .= $status_editor; + } else { + notice(t('Invalid channel.') . EOL); + goaway(z_root() . '/stream'); + } + } + + if (x($category)) { + $sql_extra .= protect_sprintf(term_query('item', $category, TERM_CATEGORY)); + } + if (x($hashtags)) { + $sql_extra .= protect_sprintf(term_query('item', $hashtags, TERM_HASHTAG, TERM_COMMUNITYTAG)); + } + + if (!$this->updating) { + // The special div is needed for liveUpdate to kick in for this page. + // We only launch liveUpdate if you aren't filtering in some incompatible + // way and also you aren't writing a comment (discovered in javascript). + + $maxheight = get_pconfig(local_channel(), 'system', 'stream_divmore_height'); + if (!$maxheight) { + $maxheight = 400; + } + + + $o .= '
      ' . "\r\n"; + $o .= "\r\n"; + + App::$page['htmlhead'] .= replace_macros(Theme::get_template('build_query.tpl'), [ + '$baseurl' => z_root(), + '$pgtype' => 'stream', + '$uid' => ((local_channel()) ? local_channel() : '0'), + '$gid' => (($gid) ? $gid : '0'), + '$cid' => (($cid) ? $cid : '0'), + '$cmin' => (($cmin) ? $cmin : '(-1)'), + '$cmax' => (($cmax) ? $cmax : '(-1)'), + '$star' => (($star) ? $star : '0'), + '$liked' => (($liked) ? $liked : '0'), + '$conv' => (($conv) ? $conv : '0'), + '$spam' => (($spam) ? $spam : '0'), + '$fh' => '0', + '$dm' => (($dm) ? $dm : '0'), + '$nouveau' => (($nouveau) ? $nouveau : '0'), + '$wall' => '0', + '$draft' => (($draft) ? $draft : '0'), + '$static' => $static, + '$list' => ((x($_REQUEST, 'list')) ? intval($_REQUEST['list']) : 0), + '$page' => ((App::$pager['page'] != 1) ? App::$pager['page'] : 1), + '$search' => (($search) ? urlencode($search) : ''), + '$xchan' => (($xchan) ? urlencode($xchan) : ''), + '$order' => (($order) ? urlencode($order) : ''), + '$file' => (($file) ? urlencode($file) : ''), + '$cats' => (($category) ? urlencode($category) : ''), + '$tags' => (($hashtags) ? urlencode($hashtags) : ''), + '$dend' => $datequery, + '$mid' => '', + '$verb' => (($verb) ? urlencode($verb) : ''), + '$net' => (($net) ? urlencode($net) : ''), + '$dbegin' => $datequery2, + '$pf' => (($pf) ? intval($pf) : '0'), + ]); + } + + $sql_extra3 = ''; + + if ($datequery) { + $sql_extra3 .= protect_sprintf(sprintf(" AND item.created <= '%s' ", dbesc(datetime_convert(date_default_timezone_get(), '', $datequery)))); + } + if ($datequery2) { + $sql_extra3 .= protect_sprintf(sprintf(" AND item.created >= '%s' ", dbesc(datetime_convert(date_default_timezone_get(), '', $datequery2)))); + } + + $sql_extra2 = (($nouveau) ? '' : " AND item.parent = item.id "); + $sql_extra3 = (($nouveau) ? '' : $sql_extra3); + + if (x($_GET, 'search')) { + $search = escape_tags($_GET['search']); + if (strpos($search, '#') === 0) { + $sql_extra .= term_query('item', substr($search, 1), TERM_HASHTAG, TERM_COMMUNITYTAG); + } else { + $sql_extra .= sprintf( + " AND (item.body like '%s' OR item.title like '%s') ", + dbesc(protect_sprintf('%' . $search . '%')), + dbesc(protect_sprintf('%' . $search . '%')) + ); + } + } + + if ($verb) { + // the presence of a leading dot in the verb determines + // whether to match the type of activity or the child object. + // The name 'verb' is a holdover from the earlier XML + // ActivityStreams specification. + + if (substr($verb, 0, 1) === '.') { + $verb = substr($verb, 1); + $sql_extra .= sprintf( + " AND item.obj_type like '%s' ", + dbesc(protect_sprintf('%' . $verb . '%')) + ); + } else { + $sql_extra .= sprintf( + " AND item.verb like '%s' ", + dbesc(protect_sprintf('%' . $verb . '%')) + ); + } + } + + if (strlen($file)) { + $sql_extra .= term_query('item', $file, TERM_FILE); + } + + if ($dm) { + $sql_extra .= " and item_private = 2 "; + } + + if ($conv) { + $item_thread_top = ''; + + if ($nouveau) { + $sql_extra .= " AND author_xchan = '" . dbesc($channel['channel_hash']) . "' "; + } else { + $sql_extra .= sprintf( + " AND parent IN (SELECT distinct(parent) from item where ( author_xchan = '%s' or item_mentionsme = 1 ) and item_deleted = 0 ) ", + dbesc(protect_sprintf($channel['channel_hash'])) + ); + } + } + + if ($this->updating && !$this->loading) { + // only setup pagination on initial page view + $pager_sql = ''; + } else { + $itemspage = get_pconfig(local_channel(), 'system', 'itemspage'); + App::set_pager_itemspage(((intval($itemspage)) ? $itemspage : 20)); + $pager_sql = sprintf(" LIMIT %d OFFSET %d ", intval(App::$pager['itemspage']), intval(App::$pager['start'])); + } + + // cmin and cmax are both -1 when the affinity tool is disabled + + if (($cmin != (-1)) || ($cmax != (-1))) { + // Not everybody who shows up in the network 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 ($cmax == 99) { + $sql_nets .= " ( "; + } + + $sql_nets .= "( abook.abook_closeness >= " . intval($cmin) . " "; + $sql_nets .= " AND abook.abook_closeness <= " . intval($cmax) . " ) "; + + if ($cmax == 99) { + $sql_nets .= " OR abook.abook_closeness IS NULL ) "; + } + } + + $net_query = (($net) ? " left join xchan on xchan_hash = author_xchan " : ''); + $net_query2 = (($net) ? " and xchan_network = '" . protect_sprintf(dbesc($net)) . "' " : ''); + + $abook_uids = " and abook.abook_channel = " . local_channel() . " "; + $uids = " and item.uid = " . local_channel() . " "; + + if (get_pconfig(local_channel(), 'system', 'stream_list_mode')) { + $page_mode = 'list'; + } else { + $page_mode = 'client'; + } + + $simple_update = (($this->updating) ? " and item_changed > = '" . $_SESSION['loadtime_stream'] . "' " : ''); + + $parents_str = ''; + $update_unseen = ''; + $items = []; + + // This fixes a very subtle bug so I'd better explain it. You wake up in the morning or return after a day + // or three and look at your stream page - after opening up your browser. The first page loads just as it + // should. All of a sudden a few seconds later, page 2 will get inserted at the beginning of the page + // (before the page 1 content). The update code is actually doing just what it's supposed + // to, it's fetching posts that have the ITEM_UNSEEN bit set. But the reason that page 2 content is being + // returned in an UPDATE is because you hadn't gotten that far yet - you're still on page 1 and everything + // that we loaded for page 1 is now marked as seen. But the stuff on page 2 hasn't been. So... it's being + // treated as "new fresh" content because it is unseen. We need to distinguish it somehow from content + // which "arrived as you were reading page 1". We're going to do this + // by storing in your session the current UTC time whenever you LOAD a network page, and only UPDATE items + // which are both ITEM_UNSEEN and have "changed" since that time. Cross fingers... + + if ($this->updating && $_SESSION['loadtime_stream']) { + $simple_update = " AND item.changed > '" . datetime_convert('UTC', 'UTC', $_SESSION['loadtime_stream']) . "' "; + } + if ($this->loading) { + $simple_update = ''; + } + + if ($static && $simple_update) { + $simple_update .= " and item_thread_top = 0 and author_xchan = '" . protect_sprintf(get_observer_hash()) . "' "; + } + + + // we are not yet using this in updates because the content may have just been marked seen + // and this might prevent us from loading the update. Will need to test further. + + $seenstr = EMPTY_STR; + if (local_channel()) { + $seen = PConfig::Get(local_channel(), 'system', 'seen_items', []); + if ($seen) { + $seenstr = " and not item.id in (" . implode(',', $seen) . ") "; + } + } + + if ($nouveau && $this->loading) { + // "New Item View" - show all items unthreaded in reverse created date order + + $items = q("SELECT item.*, item.id AS item_id, created FROM item + left join abook on ( item.owner_xchan = abook.abook_xchan $abook_uids ) + $net_query + WHERE true $uids $item_normal + and (abook.abook_blocked = 0 or abook.abook_flags is null) + $simple_update + $sql_extra $sql_options $sql_nets + $net_query2 + ORDER BY item.created DESC $pager_sql "); + + xchan_query($items); + + $items = fetch_post_tags($items, true); + } elseif ($this->updating) { + // Normal conversation view + + if ($order === 'post') { + $ordering = "created"; + } else { + $ordering = "commented"; + } + + if ($this->loading) { + // Fetch a page full of parent items for this page + $r = q("SELECT item.parent AS item_id FROM item + left join abook on ( item.owner_xchan = abook.abook_xchan $abook_uids ) + $net_query + WHERE true $uids $item_thread_top $item_normal + AND item.mid = item.parent_mid + and (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra3 $sql_extra $sql_options $sql_nets + $net_query2 + ORDER BY $ordering DESC $pager_sql "); + } else { + // this is an update + + $r = q("SELECT item.parent AS item_id FROM item + left join abook on ( item.owner_xchan = abook.abook_xchan $abook_uids ) + $net_query + WHERE true $uids $item_normal_update $simple_update + and (abook.abook_blocked = 0 or abook.abook_flags is null) + $sql_extra3 $sql_extra $sql_options $sql_nets $net_query2"); + } + + if ($r) { + $parents_str = ids_to_querystr($r, 'item_id'); + + $items = q( + "SELECT item.*, item.id AS item_id FROM item + WHERE true $uids $item_normal + AND item.parent IN ( %s ) + $sql_extra ", + dbesc($parents_str) + ); + + xchan_query($items, true); + $items = fetch_post_tags($items, true); + $items = conv_sort($items, $ordering); + } + + if ($page_mode === 'list') { + + /** + * in "list mode", only mark the parent item and any like activities as "seen". + * We won't distinguish between comment likes and post likes. The important thing + * is that the number of unseen comments will be accurate. The SQL to separate the + * comment likes could also get somewhat hairy. + */ + + if ($parents_str) { + $update_unseen = " AND ( id IN ( " . dbesc($parents_str) . " )"; + $update_unseen .= " OR ( parent IN ( " . dbesc($parents_str) . " ) AND verb in ( '" . dbesc(ACTIVITY_LIKE) . "','" . dbesc(ACTIVITY_DISLIKE) . "' ))) "; + } + } else { + if ($parents_str) { + $update_unseen = " AND parent IN ( " . dbesc($parents_str) . " )"; + } + } + } + + if ($update_unseen && (!(isset($_SESSION['sudo']) && $_SESSION['sudo']))) { + $x = ['channel_id' => local_channel(), 'update' => 'unset']; + Hook::call('update_unseen', $x); + if ($x['update'] === 'unset' || intval($x['update'])) { + $r = q( + "UPDATE item SET item_unseen = 0 WHERE item_unseen = 1 AND uid = %d $update_unseen ", + intval(local_channel()) + ); + } + } + + $mode = (($nouveau) ? 'stream-new' : 'stream'); + + if ($search) { + $mode = 'search'; + } + + $o .= conversation($items, $mode, $this->updating, $page_mode); + + if (($items) && (!$this->updating)) { + $o .= alt_pager(count($items)); + } + + return $o; + } +} diff --git a/Code/Module/Subthread.php b/Code/Module/Subthread.php new file mode 100644 index 000000000..3168932f1 --- /dev/null +++ b/Code/Module/Subthread.php @@ -0,0 +1,191 @@ + 2) ? notags(trim(argv(2))) : 0); + + if (argv(1) === 'sub') { + $activity = ACTIVITY_FOLLOW; + } elseif (argv(1) === 'unsub') { + $activity = ACTIVITY_IGNORE; + } + + $i = q( + "select * from item where id = %d and uid = %d", + intval($item_id), + intval(local_channel()) + ); + + if (!$i) { + // try the global public stream + $i = q( + "select * from item where id = %d and uid = %d", + intval($postid), + intval($sys['channel_id']) + ); + // try the local public stream + if (!$i) { + $i = q( + "select * from item where id = %d and item_wall = 1 and item_private = 0", + intval($postid) + ); + } + + if ($i && local_channel() && (!Channel::is_system(local_channel()))) { + $i = [copy_of_pubitem($channel, $i[0]['mid'])]; + $item_id = (($i) ? $i[0]['id'] : 0); + } + } + + if (!$i) { + return; + } + + $r = q( + "select * from item where id = parent and id = %d limit 1", + dbesc($i[0]['parent']) + ); + + if ((!$item_id) || (!$r)) { + logger('subthread: no item ' . $item_id); + return; + } + + $item = $r[0]; + + $owner_uid = $item['uid']; + $observer = App::get_observer(); + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + if (!perm_is_allowed($owner_uid, $ob_hash, 'post_comments')) { + return; + } + + $sys = Channel::get_system(); + + $owner_uid = $item['uid']; + $owner_aid = $item['aid']; + + // if this is a "discover" item, (item['uid'] is the sys channel), + // fallback to the item comment policy, which should've been + // respected when generating the conversation thread. + // Even if the activity is rejected by the item owner, it should still get attached + // to the local discover conversation on this site. + + if (($owner_uid != $sys['channel_id']) && (!perm_is_allowed($owner_uid, $observer['xchan_hash'], 'post_comments'))) { + notice(t('Permission denied') . EOL); + killme(); + } + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($item['owner_xchan']) + ); + if ($r) { + $thread_owner = $r[0]; + } else { + killme(); + } + + $r = q( + "select * from xchan where xchan_hash = '%s' limit 1", + dbesc($item['author_xchan']) + ); + if ($r) { + $item_author = $r[0]; + } else { + killme(); + } + + $uuid = new_uuid(); + $mid = z_root() . '/item/' . $uuid; + + $post_type = (($item['resource_type'] === 'photo') ? t('photo') : t('status')); + + $links = array(array('rel' => 'alternate', 'type' => 'text/html', 'href' => $item['plink'])); + $objtype = (($item['resource_type'] === 'photo') ? ACTIVITY_OBJ_PHOTO : ACTIVITY_OBJ_NOTE); + + $body = $item['body']; + + $obj = Activity::fetch_item(['id' => $item['mid']]); + $objtype = $obj['type']; + + if (!intval($item['item_thread_top'])) { + $post_type = 'comment'; + } + + if ($activity === ACTIVITY_FOLLOW) { + $bodyverb = t('%1$s is following %2$s\'s %3$s'); + } + if ($activity === ACTIVITY_IGNORE) { + $bodyverb = t('%1$s stopped following %2$s\'s %3$s'); + } + + $arr = []; + + $arr['uuid'] = $uuid; + $arr['mid'] = $mid; + $arr['aid'] = $owner_aid; + $arr['uid'] = $owner_uid; + $arr['parent'] = $item['id']; + $arr['parent_mid'] = $item['mid']; + $arr['thr_parent'] = $item['mid']; + $arr['owner_xchan'] = $thread_owner['xchan_hash']; + $arr['author_xchan'] = $observer['xchan_hash']; + $arr['item_origin'] = 1; + $arr['item_notshown'] = 1; + + if (intval($item['item_wall'])) { + $arr['item_wall'] = 1; + } else { + $arr['item_wall'] = 0; + } + + $ulink = '[zrl=' . $item_author['xchan_url'] . ']' . $item_author['xchan_name'] . '[/zrl]'; + $alink = '[zrl=' . $observer['xchan_url'] . ']' . $observer['xchan_name'] . '[/zrl]'; + $plink = '[zrl=' . z_root() . '/display/' . gen_link_id($item['mid']) . ']' . $post_type . '[/zrl]'; + + $arr['body'] = sprintf($bodyverb, $alink, $ulink, $plink); + + $arr['verb'] = $activity; + $arr['obj_type'] = $objtype; + $arr['obj'] = json_encode($obj); + + $arr['allow_cid'] = $item['allow_cid']; + $arr['allow_gid'] = $item['allow_gid']; + $arr['deny_cid'] = $item['deny_cid']; + $arr['deny_gid'] = $item['deny_gid']; + + $post = item_store($arr); + $post_id = $post['item_id']; + + $arr['id'] = $post_id; + + Hook::call('post_local_end', $arr); + + killme(); + } +} diff --git a/Code/Module/Suggestions.php b/Code/Module/Suggestions.php new file mode 100644 index 000000000..a874e35bb --- /dev/null +++ b/Code/Module/Suggestions.php @@ -0,0 +1,47 @@ + [['uid' => local_channel(), 'xchan' => $_GET['ignore']]]]); + } + } + + + public function get() + { + + $o = ''; + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + return; + } + + if (Apps::system_app_installed(local_channel(), 'Suggest Channels')) { + goaway(z_root() . '/directory?f=&suggest=1'); + } + + $desc = t('This app (when installed) displays a small number of friend suggestions on selected pages or you can run the app to display a full list of channel suggestions.'); + + return ''; + } +} diff --git a/Code/Module/Superblock.php b/Code/Module/Superblock.php new file mode 100644 index 000000000..cfdfb9297 --- /dev/null +++ b/Code/Module/Superblock.php @@ -0,0 +1,263 @@ + local_channel(), + 'block_entity' => $blocked, + 'block_type' => $type, + 'block_comment' => t('Added by Superblock') + ]; + + LibBlock::store($bl); + + $sync = []; + + $sync['block'] = [LibBlock::fetch_by_entity(local_channel(), $blocked)]; + + if ($type === BLOCKTYPE_CHANNEL) { + $z = q( + "insert into xign ( uid, xchan ) values ( %d , '%s' ) ", + intval(local_channel()), + dbesc($blocked) + ); + $ab = q( + "select * from abook where abook_channel = %d and abook_xchan = '%s'", + intval(local_channel()), + dbesc($blocked) + ); + if (($ab) && (!intval($ab['abook_blocked']))) { + q( + "update abook set abook_blocked = 1 where abook_channel = %d and abook_xchan = '%s'", + intval(local_channel()), + dbesc($blocked) + ); + + $r = q( + "SELECT abook.*, xchan.* FROM abook left join xchan on abook_xchan = xchan_hash WHERE abook_channel = %d and abook_xchan = '%s' LIMIT 1", + intval(local_channel()), + dbesc($blocked) + ); + if ($r) { + $r = array_shift($r); + $abconfig = load_abconfig(local_channel(), $blocked); + if ($abconfig) { + $r['abconfig'] = $abconfig; + } + unset($r['abook_id']); + unset($r['abook_account']); + unset($r['abook_channel']); + $sync['abook'] = [$r]; + } + } + $sync['xign'] = [['uid' => local_channel(), 'xchan' => $_GET['block']]]; + } + Libsync::build_sync_packet(0, $sync); + } + + $type = BLOCKTYPE_CHANNEL; + $unblocked = trim($_REQUEST['unblock']); + if (!$unblocked) { + $unblocked = trim($_REQUEST['unblocksite']); + if ($unblocked) { + $type = BLOCKTYPE_SERVER; + } + } + if ($unblocked) { + $handled = true; + if (check_form_security_token('superblock', 'sectok')) { + $r = LibBlock::fetch_by_entity(local_channel(), $unblocked); + if ($r) { + LibBlock::remove(local_channel(), $unblocked); + + $sync = []; + $sync['block'] = [[ + 'block_channel_id' => local_channel(), + 'block_entity' => $unblocked, + 'block_type' => $type, + 'deleted' => true, + ]]; + if ($type === BLOCKTYPE_CHANNEL) { + $ab = q( + "select * from abook left join xchan on abook_xchan = xchan_hash where abook_channel = %d and abook_xchan = '%s'", + intval(local_channel()), + dbesc($unblocked) + ); + if (($ab) && (intval($ab['abook_blocked']))) { + q( + "update abook set abook_blocked = 1 where abook_channel = %d and abook_xchan = '%s'", + intval(local_channel()), + dbesc($unblocked) + ); + $ab['abook_blocked'] = 0; + $abconfig = load_abconfig(local_channel(), $unblocked); + if ($abconfig) { + $ab['abconfig'] = $abconfig; + } + unset($ab['abook_id']); + unset($ab['abook_account']); + unset($ab['abook_channel']); + $sync['abook'] = [$ab]; + } + + $z = q( + "delete from xign where uid = %d and xchan = '%s' ", + intval(local_channel()), + dbesc($unblocked) + ); + } + Libsync::build_sync_packet(0, $sync); + } + } + } + + if ($handled) { + info(t('superblock settings updated') . EOL); + + if ($unblocked || $inline) { + return; + } + + killme(); + } + } + + public function get() + { + + $l = LibBlock::fetch(local_channel(), BLOCKTYPE_CHANNEL); + + $list = ids_to_array($l, 'block_entity'); + + stringify_array_elms($list, true); + $query_str = implode(',', $list); + if ($query_str) { + $r = q("select * from xchan where xchan_hash in ( " . $query_str . " ) "); + } else { + $r = []; + } + if ($r) { + for ($x = 0; $x < count($r); $x++) { + $r[$x]['encoded_hash'] = urlencode($r[$x]['xchan_hash']); + } + } + + $sc .= replace_macros(Theme::get_template('superblock_list.tpl'), [ + '$blocked' => t('Blocked channels'), + '$entries' => $r, + '$nothing' => (($r) ? '' : t('No channels currently blocked')), + '$token' => get_form_security_token('superblock'), + '$remove' => t('Remove') + ]); + + + $l = LibBlock::fetch(local_channel(), BLOCKTYPE_SERVER); + $list = ids_to_array($l, 'block_entity'); + if ($list) { + for ($x = 0; $x < count($list); $x++) { + $list[$x] = [$list[$x], urlencode($list[$x])]; + } + } + + $sc .= replace_macros(Theme::get_template('superblock_serverlist.tpl'), [ + '$blocked' => t('Blocked servers'), + '$entries' => $list, + '$nothing' => (($list) ? '' : t('No servers currently blocked')), + '$token' => get_form_security_token('superblock'), + '$remove' => t('Remove') + ]); + + $s .= replace_macros(Theme::get_template('generic_app_settings.tpl'), [ + '$addon' => array('superblock', t('Manage Blocks'), '', t('Submit')), + '$content' => $sc + ]); + + return $s; + } +} diff --git a/Code/Module/Tagadelic.php b/Code/Module/Tagadelic.php new file mode 100644 index 000000000..604ef3de3 --- /dev/null +++ b/Code/Module/Tagadelic.php @@ -0,0 +1,47 @@ +' . $desc . '
      '; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Tagadelic'))) { + return $text; + } + + $desc = t('This app is installed. It displays a hashtag cloud on your channel homepage.'); + + $text = ''; + + + $c = new Comanche(); + return $text . EOL . EOL . $c->widget('tagcloud_wall', EMPTY_STR); + } +} diff --git a/Code/Module/Tagger.php b/Code/Module/Tagger.php new file mode 100644 index 000000000..310bb8544 --- /dev/null +++ b/Code/Module/Tagger.php @@ -0,0 +1,179 @@ + 1) ? notags(trim(argv(1))) : 0); + + logger('tagger: tag ' . $term . ' item ' . $item_id); + + $r = q( + "select * from item where id = %d and uid = %d limit 1", + intval($item_id), + intval(local_channel()) + ); + + if (!$r) { + $r = q( + "select * from item where id = %d and uid = %d limit 1", + intval($item_id), + intval($sys['channel_id']) + ); + if (!$r) { + $r = q( + "select * from item where id = %d and item_private = 0 and item_wall = 1", + intval($item_id) + ); + } + if ($r && local_channel() && (!Channel::is_system(local_channel()))) { + $r = [copy_of_pubitem($channel, $i[0]['mid'])]; + $item_id = (($r) ? $r[0]['id'] : 0); + } + } + + if (!$r) { + notice(t('Post not found.') . EOL); + return; + } + + $r = q( + "SELECT * FROM item left join xchan on xchan_hash = author_xchan WHERE id = %d and uid = %d LIMIT 1", + intval($item_id), + intval(local_channel()) + ); + + if ((!$item_id) || (!$r)) { + logger('tagger: no item ' . $item_id); + return; + } + + $item = $r[0]; + + $owner_uid = $item['uid']; + + switch ($item['resource_type']) { + case 'photo': + $targettype = ACTIVITY_OBJ_PHOTO; + $post_type = t('photo'); + break; + case 'event': + $targgettype = ACTIVITY_OBJ_EVENT; + $post_type = t('event'); + break; + default: + $targettype = ACTIVITY_OBJ_NOTE; + $post_type = t('post'); + if ($item['mid'] != $item['parent_mid']) { + $post_type = t('comment'); + } + break; + } + + + $clean_term = trim($term, '"\' '); + + $links = array(array('rel' => 'alternate', 'type' => 'text/html', + 'href' => z_root() . '/display/' . gen_link_id($item['mid']))); + + $target = json_encode(array( + 'type' => $targettype, + 'id' => $item['mid'], + 'link' => $links, + 'title' => $item['title'], + 'content' => $item['body'], + 'created' => $item['created'], + 'edited' => $item['edited'], + 'author' => array( + 'name' => $item['xchan_name'], + 'address' => $item['xchan_addr'], + 'guid' => $item['xchan_guid'], + 'guid_sig' => $item['xchan_guid_sig'], + 'link' => array( + array('rel' => 'alternate', 'type' => 'text/html', 'href' => $item['xchan_url']), + array('rel' => 'photo', 'type' => $item['xchan_photo_mimetype'], 'href' => $item['xchan_photo_m'])), + ), + )); + + $tagid = z_root() . '/search?tag=' . $clean_term; + $objtype = ACTIVITY_OBJ_TAGTERM; + + $obj = json_encode(array( + 'type' => $objtype, + 'id' => $tagid, + 'link' => array(array('rel' => 'alternate', 'type' => 'text/html', 'href' => $tagid)), + 'title' => $clean_term, + 'content' => $clean_term + )); + + $bodyverb = t('%1$s tagged %2$s\'s %3$s with %4$s'); + + // saving here for reference + // also check out x22d5 and x2317 and x0d6b and x0db8 and x24d0 and xff20 !!! + + $termlink = html_entity_decode('⋕') . '[zrl=' . z_root() . '/search?tag=' . urlencode($clean_term) . ']' . $clean_term . '[/zrl]'; + + $channel = App::get_channel(); + + $arr = []; + + $arr['owner_xchan'] = $item['owner_xchan']; + $arr['author_xchan'] = $channel['channel_hash']; + + $arr['item_origin'] = 1; + $arr['item_wall'] = ((intval($item['item_wall'])) ? 1 : 0); + + $ulink = '[zrl=' . $channel['xchan_url'] . ']' . $channel['channel_name'] . '[/zrl]'; + $alink = '[zrl=' . $item['xchan_url'] . ']' . $item['xchan_name'] . '[/zrl]'; + $plink = '[zrl=' . $item['plink'] . ']' . $post_type . '[/zrl]'; + + $arr['body'] = sprintf($bodyverb, $ulink, $alink, $plink, $termlink); + + $arr['verb'] = ACTIVITY_TAG; + $arr['tgt_type'] = $targettype; + $arr['target'] = $target; + $arr['obj_type'] = $objtype; + $arr['obj'] = $obj; + $arr['parent_mid'] = $item['mid']; + store_item_tag($item['uid'], $item['id'], TERM_OBJ_POST, TERM_COMMUNITYTAG, $clean_term, $tagid); + $ret = post_activity_item($arr); + + if ($ret['success']) { + Libsync::build_sync_packet( + local_channel(), + [ + 'item' => [encode_item($ret['activity'], true)] + ] + ); + } + + killme(); + } +} diff --git a/Code/Module/Tagrm.php b/Code/Module/Tagrm.php new file mode 100644 index 000000000..1491242c1 --- /dev/null +++ b/Code/Module/Tagrm.php @@ -0,0 +1,158 @@ +' . t('Remove Item Tag') . ''; + + $o .= '

      ' . t('Select a tag to remove: ') . '

      '; + + $o .= '
      '; + $o .= ''; + $o .= '
        '; + + + foreach ($r[0]['term'] as $x) { + $o .= '
      • ' . bbcode($x['term']) . '
      • '; + } + + $o .= '
      '; + $o .= ''; + $o .= ''; + $o .= '
      '; + + return $o; + } + } +} diff --git a/Code/Module/Tasks.php b/Code/Module/Tasks.php new file mode 100644 index 000000000..bd95c3cb6 --- /dev/null +++ b/Code/Module/Tasks.php @@ -0,0 +1,117 @@ + 1 && argv(1) === 'fetch') { + if (argc() > 2 && argv(2) === 'all') { + $arr['all'] = 1; + } + + $x = tasks_fetch($arr); + $x['html'] = ''; + if (isset($x['tasks']) && is_array($x['tasks'])) { + foreach ($x['tasks'] as $y) { + $x['html'] .= '
      ' . $y['summary'] . '
      '; + } + } + json_return_and_die($x); + } + } + + + public function post() + { + // logger('post: ' . print_r($_POST,true)); + + + if (!local_channel()) { + return; + } + + $channel = App::get_channel(); + + if ((argc() > 2) && (argv(1) === 'complete') && intval(argv(2))) { + $ret = array('success' => false); + $r = q( + "select * from event where etype = 'task' and uid = %d and id = %d limit 1", + intval(local_channel()), + intval(argv(2)) + ); + if ($r) { + $event = $r[0]; + if ($event['event_status'] === 'COMPLETED') { + $event['event_status'] = 'IN-PROCESS'; + $event['event_status_date'] = NULL_DATE; + $event['event_percent'] = 0; + $event['event_sequence'] = $event['event_sequence'] + 1; + $event['edited'] = datetime_convert(); + } else { + $event['event_status'] = 'COMPLETED'; + $event['event_status_date'] = datetime_convert(); + $event['event_percent'] = 100; + $event['event_sequence'] = $event['event_sequence'] + 1; + $event['edited'] = datetime_convert(); + } + $x = event_store_event($event); + if ($x) { + $ret['success'] = true; + } + } + json_return_and_die($ret); + } + + if (argc() == 2 && argv(1) === 'new') { + $text = escape_tags(trim($_REQUEST['summary'])); + if (!$text) { + return ['success' => false]; + } + $event = []; + $event['account'] = $channel['channel_account_id']; + $event['uid'] = $channel['channel_id']; + $event['event_xchan'] = $channel['channel_hash']; + $event['etype'] = 'task'; + $event['nofinish'] = true; + $event['created'] = $event['edited'] = $event['dtstart'] = datetime_convert(); + $event['adjust'] = 1; + $event['allow_cid'] = '<' . $channel['channel_hash'] . '>'; + $event['summary'] = escape_tags($_REQUEST['summary']); + $x = event_store_event($event); + if ($x) { + $x['success'] = true; + } else { + $x = ['success' => false]; + } + json_return_and_die($x); + } + } + + public function get() + { + $desc = t('This app provides a simple personal and task list.'); + + $text = ''; + + if (!(local_channel() && Apps::system_app_installed(local_channel(), 'Tasks'))) { + return $text; + } + + $obj = new Tasklist(); + return $obj->widget([]); + } +} diff --git a/Code/Module/Theme_info.php b/Code/Module/Theme_info.php new file mode 100644 index 000000000..13168b64e --- /dev/null +++ b/Code/Module/Theme_info.php @@ -0,0 +1,73 @@ +get_theme_config_file($theme)) != null) { + require_once($themeconfigfile); + if (class_exists('\\Code\\Theme\\' . ucfirst($theme) . 'Config')) { + $clsname = '\\Code\\Theme\\' . ucfirst($theme) . 'Config'; + $th_config = new $clsname(); + $schemas = $th_config->get_schemas(); + if ($schemas) { + foreach ($schemas as $k => $v) { + $schemalist[] = ['key' => $k, 'val' => $v]; + } + } + $theme_config = $th_config->get(); + } + } + $info = get_theme_info($theme); + if ($info) { + // unfortunately there will be no translation for this string + $desc = $info['description']; + $version = $info['version']; + $credits = $info['credits']; + } else { + $desc = ''; + $version = ''; + $credits = ''; + } + + $ret = [ + 'theme' => $theme, + 'img' => get_theme_screenshot($theme), + 'desc' => $desc, + 'version' => $version, + 'credits' => $credits, + 'schemas' => $schemalist, + 'config' => $theme_config + ]; + json_return_and_die($ret); + } + + + public function get_theme_config_file($theme) + { + + $base_theme = App::$theme_info['extends']; + + if (file_exists("view/theme/$theme/php/config.php")) { + return "view/theme/$theme/php/config.php"; + } + if (file_exists("view/theme/$base_theme/php/config.php")) { + return "view/theme/$base_theme/php/config.php"; + } + return null; + } +} diff --git a/Code/Module/Thing.php b/Code/Module/Thing.php new file mode 100644 index 000000000..7f00a06f7 --- /dev/null +++ b/Code/Module/Thing.php @@ -0,0 +1,424 @@ +set_from_array($_REQUEST); + } + + $x = $acl->get(); + + if ($term_hash) { + $t = q( + "select * from obj where obj_obj = '%s' and obj_channel = %d limit 1", + dbesc($term_hash), + intval(local_channel()) + ); + if (!$t) { + notice(t('Item not found.') . EOL); + return; + } + $orig_record = $t[0]; + if ($photo != $orig_record['obj_imgurl']) { + delete_thing_photo($orig_record['obj_imgurl'], get_observer_hash()); + $arr = import_remote_xchan_photo($photo, get_observer_hash(), true); + if ($arr) { + $local_photo = $arr[0]; + $local_photo_type = $arr[3]; + } else { + $local_photo = $orig_record['obj_imgurl']; + } + } else { + $local_photo = $orig_record['obj_imgurl']; + } + if ($local_photo) { + $r = q( + "update obj set obj_term = '%s', obj_url = '%s', obj_imgurl = '%s', obj_edited = '%s', allow_cid = '%s', allow_gid = '%s', deny_cid = '%s', deny_gid = '%s' where obj_obj = '%s' and obj_channel = %d ", + dbesc($name), + dbesc(($url) ? $url : z_root() . '/thing/' . $term_hash), + dbesc($local_photo), + dbesc(datetime_convert()), + dbesc($x['allow_cid']), + dbesc($x['allow_gid']), + dbesc($x['deny_cid']), + dbesc($x['deny_gid']), + dbesc($term_hash), + intval(local_channel()) + ); + } + info(t('Thing updated') . EOL); + + $r = q( + "select * from obj where obj_channel = %d and obj_obj = '%s' limit 1", + intval(local_channel()), + dbesc($term_hash) + ); + if ($r) { + Libsync::build_sync_packet(0, array('obj' => $r)); + } + + return; + } + + $sql = (($profile_guid) ? " and profile_guid = '" . dbesc($profile_guid) . "' " : " and is_default = 1 "); + $p = q( + "select profile_guid, is_default from profile where uid = %d $sql limit 1", + intval(local_channel()) + ); + + if ($p) { + $profile = $p[0]; + } else { + return; + } + + $local_photo = null; + + if ($photo) { + $arr = import_remote_xchan_photo($photo, get_observer_hash(), true); + if ($arr) { + $local_photo = $arr[0]; + $local_photo_type = $arr[3]; + } else { + $local_photo = $photo; + } + } + + + $created = datetime_convert(); + $url = (($url) ? $url : z_root() . '/thing/' . $hash); + + $r = q( + "insert into obj ( obj_page, obj_verb, obj_type, obj_channel, obj_obj, obj_term, obj_url, obj_imgurl, obj_created, obj_edited, allow_cid, allow_gid, deny_cid, deny_gid ) values ('%s','%s', %d, %d, '%s','%s','%s','%s','%s','%s','%s','%s','%s','%s') ", + dbesc($profile['profile_guid']), + dbesc($verb), + intval(TERM_OBJ_THING), + intval(local_channel()), + dbesc($hash), + dbesc($name), + dbesc($url), + dbesc(($photo) ? $local_photo : ''), + dbesc($created), + dbesc($created), + dbesc($x['allow_cid']), + dbesc($x['allow_gid']), + dbesc($x['deny_cid']), + dbesc($x['deny_gid']) + ); + + if (!$r) { + notice(t('Object store: failed')); + return; + } + + info(t('Thing added')); + + $r = q( + "select * from obj where obj_channel = %d and obj_obj = '%s' limit 1", + intval(local_channel()), + dbesc($hash) + ); + if ($r) { + Libsync::build_sync_packet(0, array('obj' => $r)); + } + + if ($activity) { + $arr = []; + $links = array(array('rel' => 'alternate', 'type' => 'text/html', 'href' => $url)); + if ($local_photo) { + $links[] = array('rel' => 'photo', 'type' => $local_photo_type, 'href' => $local_photo); + } + + $objtype = ACTIVITY_OBJ_THING; + + $obj = json_encode(array( + 'type' => $objtype, + 'id' => $url, + 'link' => $links, + 'title' => $name, + 'content' => $name + )); + + $bodyverb = str_replace('OBJ: ', '', t('OBJ: %1$s %2$s %3$s')); + + $arr['owner_xchan'] = $channel['channel_hash']; + $arr['author_xchan'] = $channel['channel_hash']; + + $arr['item_origin'] = 1; + $arr['item_wall'] = 1; + $arr['item_thread_top'] = 1; + + $ulink = '[zrl=' . $channel['xchan_url'] . ']' . $channel['channel_name'] . '[/zrl]'; + $plink = '[zrl=' . $url . ']' . $name . '[/zrl]'; + + $arr['body'] = sprintf($bodyverb, $ulink, $translated_verb, $plink); + + if ($local_photo) { + $arr['body'] .= "\n\n[zmg]" . $local_photo . "[/zmg]"; + } + + $arr['verb'] = $verb; + $arr['obj_type'] = $objtype; + $arr['obj'] = $obj; + + if (!$profile['is_default']) { + $arr['item_private'] = true; + $str = ''; + $r = q( + "select abook_xchan from abook where abook_channel = %d and abook_profile = '%s'", + intval(local_channel()), + dbesc($profile_guid) + ); + if ($r) { + $arr['allow_cid'] = ''; + foreach ($r as $rr) { + $arr['allow_cid'] .= '<' . $rr['abook_xchan'] . '>'; + } + } else { + $arr['allow_cid'] = '<' . get_observer_hash() . '>'; + } + } + + $ret = post_activity_item($arr); + } + } + + + public function get() + { + + // @FIXME one problem with things is we can't share them unless we provide the channel in the url + // so we can definitively lookup the owner. + + if (argc() == 2) { + $r = q( + "select obj_channel from obj where obj_type = %d and obj_obj = '%s' limit 1", + intval(TERM_OBJ_THING), + dbesc(argv(1)) + ); + if ($r) { + $sql_extra = permissions_sql($r[0]['obj_channel']); + } + + $r = q( + "select * from obj where obj_type = %d and obj_obj = '%s' $sql_extra limit 1", + intval(TERM_OBJ_THING), + dbesc(argv(1)) + ); + + if ($r) { + return replace_macros(Theme::get_template('show_thing.tpl'), array( + '$header' => t('Show Thing'), + '$edit' => t('Edit'), + '$delete' => t('Delete'), + '$canedit' => ((local_channel() && local_channel() == $r[0]['obj_channel']) ? true : false), + '$thing' => $r[0])); + } else { + notice(t('item not found.') . EOL); + return; + } + } + + $channel = App::get_channel(); + + if (!(local_channel() && $channel)) { + notice(t('Permission denied.') . EOL); + return; + } + + $acl = new AccessControl($channel); + $channel_acl = $acl->get(); + + $lockstate = (($acl->is_private()) ? 'lock' : 'unlock'); + + $thing_hash = ''; + + if (argc() == 3 && argv(1) === 'edit') { + $thing_hash = argv(2); + + $r = q( + "select * from obj where obj_type = %d and obj_obj = '%s' limit 1", + intval(TERM_OBJ_THING), + dbesc($thing_hash) + ); + + if ((!$r) || ($r[0]['obj_channel'] != local_channel())) { + notice(t('Permission denied.') . EOL); + return ''; + } + + $o .= replace_macros(Theme::get_template('thing_edit.tpl'), array( + '$thing_hdr' => t('Edit Thing'), + '$multiprof' => Features::enabled(local_channel(), 'multi_profiles'), + '$profile_lbl' => t('Select a profile'), + '$profile_select' => contact_profile_assign($r[0]['obj_page']), + '$verb_lbl' => $channel['channel_name'], + '$verb_select' => obj_verb_selector($r[0]['obj_verb']), + '$activity' => array('activity', t('Post an activity'), true, t('Only sends to viewers of the applicable profile')), + '$thing_hash' => $thing_hash, + '$thing_lbl' => t('Name of thing e.g. something'), + '$thething' => $r[0]['obj_term'], + '$url_lbl' => t('URL of thing (optional)'), + '$theurl' => $r[0]['obj_url'], + '$img_lbl' => t('URL for photo of thing (optional)'), + '$imgurl' => $r[0]['obj_imgurl'], + '$permissions' => t('Permissions'), + '$aclselect' => Libacl::populate($channel_acl, false), + '$allow_cid' => acl2json($channel_acl['allow_cid']), + '$allow_gid' => acl2json($channel_acl['allow_gid']), + '$deny_cid' => acl2json($channel_acl['deny_cid']), + '$deny_gid' => acl2json($channel_acl['deny_gid']), + '$lockstate' => $lockstate, + '$submit' => t('Submit') + )); + + return $o; + } + + if (argc() == 3 && argv(1) === 'drop') { + $thing_hash = argv(2); + + $r = q( + "select * from obj where obj_type = %d and obj_obj = '%s' limit 1", + intval(TERM_OBJ_THING), + dbesc($thing_hash) + ); + + if ((!$r) || ($r[0]['obj_channel'] != local_channel())) { + notice(t('Permission denied.') . EOL); + return ''; + } + + + delete_thing_photo($r[0]['obj_imgurl'], get_observer_hash()); + + $x = q( + "delete from obj where obj_obj = '%s' and obj_type = %d and obj_channel = %d", + dbesc($thing_hash), + intval(TERM_OBJ_THING), + intval(local_channel()) + ); + + $r[0]['obj_deleted'] = 1; + + Libsync::build_sync_packet(0, array('obj' => $r)); + + return $o; + } + + $o .= replace_macros(Theme::get_template('thing_input.tpl'), array( + '$thing_hdr' => t('Add Thing to your Profile'), + '$multiprof' => Features::enabled(local_channel(), 'multi_profiles'), + '$profile_lbl' => t('Select a profile'), + '$profile_select' => contact_profile_assign(''), + '$verb_lbl' => $channel['channel_name'], + '$activity' => array('activity', t('Post an activity'), ((array_key_exists('activity', $_REQUEST)) ? $_REQUEST['activity'] : true), t('Only sends to viewers of the applicable profile')), + '$verb_select' => obj_verb_selector(), + '$thing_lbl' => t('Name of thing e.g. something'), + '$url_lbl' => t('URL of thing (optional)'), + '$img_lbl' => t('URL for photo of thing (optional)'), + '$permissions' => t('Permissions'), + '$aclselect' => Libacl::populate($channel_acl, false), + '$allow_cid' => acl2json($channel_acl['allow_cid']), + '$allow_gid' => acl2json($channel_acl['allow_gid']), + '$deny_cid' => acl2json($channel_acl['deny_cid']), + '$deny_gid' => acl2json($channel_acl['deny_gid']), + '$lockstate' => $lockstate, + '$submit' => t('Submit') + )); + + return $o; + } +} diff --git a/Code/Module/Toggle_safesearch.php b/Code/Module/Toggle_safesearch.php new file mode 100644 index 000000000..41a501fd2 --- /dev/null +++ b/Code/Module/Toggle_safesearch.php @@ -0,0 +1,37 @@ +db); + $s = new OAuth2Server($storage); + $request = Request::createFromGlobals(); + $response = $s->handleTokenRequest($request); + $response->send(); + killme(); + } +} diff --git a/Code/Module/Uexport.php b/Code/Module/Uexport.php new file mode 100644 index 000000000..f26c6de77 --- /dev/null +++ b/Code/Module/Uexport.php @@ -0,0 +1,83 @@ + 1) { + $channel = App::get_channel(); + + if (argc() > 1 && intval(argv(1)) > 1900) { + $year = intval(argv(1)); + } + + if (argc() > 2 && intval(argv(2)) > 0 && intval(argv(2)) <= 12) { + $month = intval(argv(2)); + } + + header('content-type: application/json'); + header('Content-Disposition: attachment; filename="' . $channel['channel_address'] . (($year) ? '-' . $year : '') . (($month) ? '-' . $month : '') . (($_REQUEST['sections']) ? '-' . $_REQUEST['sections'] : '') . '.json"'); + + $flags = ((version_compare(PHP_VERSION, '7.2.0') >= 0) ? JSON_INVALID_UTF8_SUBSTITUTE : 0); + + if ($year) { + echo json_encode(Channel::export_year(local_channel(), $year, $month), $flags); + killme(); + } + + if (argc() > 1 && argv(1) === 'basic') { + echo json_encode(Channel::basic_export(local_channel(), $sections), $flags); + killme(); + } + + // Warning: this option may consume a lot of memory + + if (argc() > 1 && argv(1) === 'complete') { + $sections[] = 'items'; + echo json_encode(Channel::basic_export(local_channel(), $sections)); + killme(); + } + } + } + + public function get() + { + + $y = datetime_convert('UTC', date_default_timezone_get(), 'now', 'Y'); + + $yearurl = z_root() . '/uexport/' . $y; + $janurl = z_root() . '/uexport/' . $y . '/1'; + $impurl = '/import_items'; + $o = replace_macros(Theme::get_template('uexport.tpl'), array( + '$title' => t('Export Channel'), + '$basictitle' => t('Export Channel'), + '$basic' => t('Export your basic channel information to a file. This acts as a backup of your connections, permissions, profile and basic data, which can be used to import your data to a new server hub, but does not contain your content.'), + '$fulltitle' => t('Export Content'), + '$full' => t('Export your channel information and recent content to a JSON backup that can be restored or imported to another server hub. This backs up all of your connections, permissions, profile data and several months of posts. This file may be VERY large. Please be patient - it may take several minutes for this download to begin.'), + + '$by_year' => t('Export your posts from a given year.'), + + '$extra' => t('You may also export your posts and conversations for a particular year or month. Adjust the date in your browser location bar to select other dates. If the export fails (possibly due to memory exhaustion on your server hub), please try again selecting a more limited date range.'), + '$extra2' => sprintf(t('To select all posts for a given year, such as this year, visit %2$s'), $yearurl, $yearurl), + '$extra3' => sprintf(t('To select all posts for a given month, such as January of this year, visit %2$s'), $janurl, $janurl), + '$extra4' => sprintf(t('These content files may be imported or restored by visiting %2$s on any site containing your channel. For best results please import or restore these in date order (oldest first).'), $impurl, $impurl) + + )); + return $o; + } +} diff --git a/Code/Module/Update.php b/Code/Module/Update.php new file mode 100644 index 000000000..6aacbf89d --- /dev/null +++ b/Code/Module/Update.php @@ -0,0 +1,83 @@ +get() to indicate that + * the existing content should either be replaced or appended to. + * The current modules that support this manner of update are + * channel, hq, stream, display, search, and pubstream. + * The state we are passing is the profile_uid (passed to us as $_GET['p']), and argv(2) === 'load' + * to indicate that we're replacing original content. + * + * module->profile_uid - tell the module who owns this data + * module->loading - tell the module to replace existing content + * module->updating - always true to tell the module that this is a js initiated request + * + * Inside main.js we also append all of the relevant content query params which were initially used on that + * page via buildCmd so those are passed to this instance of update and are therefore available from the module. + */ + +use App; +use Code\Web\Controller; + +class Update extends Controller +{ + + public function get() + { + + $profile_uid = intval($_GET['p']); + + // Change a profile_uid of 0 (not logged in) to (-1) for selected controllers + // as they only respond to liveUpdate with non-zero values + + if ((!$profile_uid) && in_array(argv(1), ['display', 'search', 'pubstream', 'home'])) { + $profile_uid = (-1); + } + + if (argc() < 2) { + killme(); + } + + // These modules don't have a completely working liveUpdate implementation currently + + if (in_array(strtolower(argv(1)), ['articles', 'cards'])) { + killme(); + } + + $module = "\\Code\\Module\\" . ucfirst(argv(1)); + $load = (((argc() > 2) && (argv(2) == 'load')) ? 1 : 0); + + $mod = new $module(); + + // Set the state flags of the relevant module (only conversational + // modules support state flags + + if (isset($mod->profile_uid)) { + $mod->profile_uid = $profile_uid; + } + if (isset($mod->updating)) { + $mod->updating = 1; + } + if (isset($mod->loading) && $load) { + $mod->loading = 1; + } + + header("Content-type: text/html"); + + // Modify the argument parameters to match what the new controller + // expects. They are currently set to what this controller expects. + + App::$argv = [argv(1)]; + App::$argc = 1; + + echo "
      \r\n"; + echo $mod->get(); + echo "
      \r\n"; + + killme(); + } +} diff --git a/Code/Module/Userinfo.php b/Code/Module/Userinfo.php new file mode 100644 index 000000000..16a575ffd --- /dev/null +++ b/Code/Module/Userinfo.php @@ -0,0 +1,21 @@ +db)); + $request = Request::createFromGlobals(); + $s->handleUserInfoRequest($request)->send(); + killme(); + } +} diff --git a/Code/Module/View.php b/Code/Module/View.php new file mode 100644 index 000000000..ae32750bc --- /dev/null +++ b/Code/Module/View.php @@ -0,0 +1,24 @@ + 1) { + Libprofile::load(argv(1)); + } + } + + public function get() + { + + // logger('request: ' . print_r($_REQUEST,true)); + + if (observer_prohibited()) { + notice(t('Public access denied.') . EOL); + return; + } + + if (((!(is_array(App::$profile) && count(App::$profile))) || (App::$profile['hide_friends']))) { + notice(t('Permission denied.') . EOL); + return; + } + + if (!perm_is_allowed(App::$profile['uid'], get_observer_hash(), 'view_contacts')) { + notice(t('Permission denied.') . EOL); + return; + } + + if (!$_REQUEST['aj']) { + $_SESSION['return_url'] = App::$query_string; + } + + $is_owner = ((local_channel() && local_channel() == App::$profile['uid']) ? true : false); + + $abook_flags = " and abook_pending = 0 and abook_self = 0 "; + $sql_extra = ''; + + if (!$is_owner) { + $abook_flags .= " and abook_hidden = 0 "; + $sql_extra = " and xchan_hidden = 0 "; + } + + $r = q( + "SELECT * FROM abook left join xchan on abook_xchan = xchan_hash where abook_channel = %d $abook_flags and xchan_orphan = 0 and xchan_deleted = 0 $sql_extra order by xchan_name LIMIT %d OFFSET %d ", + intval(App::$profile['uid']), + intval(App::$pager['itemspage']), + intval(App::$pager['start']) + ); + + if ((!$r) && (!$_REQUEST['aj'])) { + info(t('No connections.') . EOL); + return $o; + } + + $contacts = []; + + foreach ($r as $rr) { + $oneway = false; + if (!their_perms_contains(App::$profile['uid'], $rr['xchan_hash'], 'post_comments')) { + $oneway = true; + } + + $url = chanlink_hash($rr['xchan_hash']); + if ($url) { + $contacts[] = [ + 'id' => $rr['abook_id'], + 'archived' => (intval($rr['abook_archived']) ? true : false), + 'img_hover' => sprintf(t('Visit %1$s\'s profile [%2$s]'), $rr['xchan_name'], $rr['xchan_url']), + 'thumb' => $rr['xchan_photo_m'], + 'name' => substr($rr['xchan_name'], 0, 20), + 'username' => $rr['xchan_addr'], + 'link' => $url, + 'sparkle' => '', + 'itemurl' => $rr['url'], + 'network' => '', + 'oneway' => $oneway + ]; + } + } + + + if ($_REQUEST['aj']) { + if ($contacts) { + $o = replace_macros(Theme::get_template('viewcontactsajax.tpl'), [ + '$contacts' => $contacts + ]); + } else { + $o = '
      '; + } + echo $o; + killme(); + } else { + $o .= ""; + $o .= replace_macros(Theme::get_template('viewcontact_template.tpl'), [ + '$title' => t('View Connections'), + '$contacts' => $contacts, + ]); + } + + if (!$contacts) { + $o .= '
      '; + } + return $o; + } +} diff --git a/Code/Module/Viewsrc.php b/Code/Module/Viewsrc.php new file mode 100644 index 000000000..dd5b3e5dc --- /dev/null +++ b/Code/Module/Viewsrc.php @@ -0,0 +1,73 @@ + 1) ? intval(argv(1)) : 0); + $json = ((argc() > 2 && argv(2) === 'json') ? true : false); + $dload = ((argc() > 2 && argv(2) === 'download') ? true : false); + + if (!local_channel()) { + notice(t('Permission denied.') . EOL); + } + + if (!$item_id) { + App::$error = 404; + notice(t('Item not found.') . EOL); + } + + $item_normal = item_normal_search(); + + if (local_channel() && $item_id) { + $r = q( + "select id, item_flags, mimetype, item_obscured, body, llink, plink from item where uid in (%d , %d) and id = %d $item_normal limit 1", + intval(local_channel()), + intval($sys['channel_id']), + intval($item_id) + ); + + if ($r) { + if (intval($r[0]['item_obscured'])) { + $dload = true; + } + + if ($dload) { + header('Content-type: ' . $r[0]['mimetype']); + header('Content-Disposition: attachment; filename="' . t('item') . '-' . $item_id . '"'); + echo $r[0]['body']; + killme(); + } + + $content = escape_tags($r[0]['body']); + $o = (($json) ? json_encode($content) : str_replace("\n", '
      ', $content)); + } + } + + $inspect = ((is_site_admin()) ? '| ' . t('Inspect') . '' : EMPTY_STR); + + + if (is_ajax()) { + echo '
      '; + echo '
      ' . t('Local id:') . ' ' . $r[0]['id'] . ' | ' . t('Permanent link') . ' | ' . t('Local link') . '' . $inspect . '
      '; + echo '
      '; + echo '
      ' . $o . '
      '; + echo '
      '; + killme(); + } + + return $o; + } +} diff --git a/Code/Module/Vlists.php b/Code/Module/Vlists.php new file mode 100644 index 000000000..7954296fb --- /dev/null +++ b/Code/Module/Vlists.php @@ -0,0 +1,31 @@ +' . $desc . '
      '; + + $o .= $text; + + + $text2 = ''; + + if (local_channel() && Apps::system_app_installed(local_channel(), 'Virtual Lists')) { + $o .= $text2; + } + + return $o; + } +} diff --git a/Code/Module/Vote.php b/Code/Module/Vote.php new file mode 100644 index 000000000..87ae3947d --- /dev/null +++ b/Code/Module/Vote.php @@ -0,0 +1,135 @@ + false, 'message' => EMPTY_STR]; + + $channel = App::get_channel(); + + if (!$channel) { + $ret['message'] = t('Permission denied.'); + json_return_and_die($ret); + } + + + $fetch = null; + $id = argv(1); + $response = $_REQUEST['answer']; + + if ($id) { + $fetch = q( + "select * from item where id = %d limit 1", + intval($id) + ); + } + + if ($fetch && $fetch[0]['obj_type'] === 'Question') { + $obj = json_decode($fetch[0]['obj'], true); + } else { + $ret['message'] = t('Poll not found.'); + json_return_and_die($ret); + } + + $valid = false; + + if ($obj['oneOf']) { + foreach ($obj['oneOf'] as $selection) { + // logger('selection: ' . $selection); + // logger('response: ' . $response); + if ($selection['name'] && $selection['name'] === $response) { + $valid = true; + } + } + } + + $choices = []; + if ($obj['anyOf']) { + foreach ($obj['anyOf'] as $selection) { + $choices[] = $selection['name']; + } + foreach ($response as $res) { + if (!in_array($res, $choices)) { + $valid = false; + break; + } + $valid = true; + } + } + + if (!$valid) { + $ret['message'] = t('Invalid response.'); + json_return_and_die($ret); + } + + if (!is_array($response)) { + $response = [$response]; + } + + foreach ($response as $res) { + $item = []; + + $item['aid'] = $channel['channel_account_id']; + $item['uid'] = $channel['channel_id']; + $item['item_origin'] = true; + $item['parent'] = $fetch[0]['id']; + $item['parent_mid'] = $fetch[0]['mid']; + $item['thr_parent'] = $fetch[0]['mid']; + $item['uuid'] = new_uuid(); + $item['mid'] = z_root() . '/item/' . $item['uuid']; + $item['verb'] = 'Create'; + $item['title'] = $res; + $item['author_xchan'] = $channel['channel_hash']; + $item['owner_xchan'] = $fetch[0]['author_xchan']; +// $item['allow_cid'] = '<' . $fetch[0]['author_xchan'] . '>'; +// $item['item_private'] = 1; + + // These two are placeholder values that will be reset after + // we encode the item + + $item['obj_type'] = 'Note'; + $item['author'] = Channel::from_id($channel['channel_id']); + + $item['obj'] = Activity::encode_item($item, ((get_config('system', 'activitypub', ACTIVITYPUB_ENABLED)) ? true : false)); + + // now reset the placeholders + + $item['obj_type'] = 'Answer'; + unset($item['author']); + + $x = item_store($item); + + retain_item($fetch[0]['id']); + + if ($x['success']) { + $itemid = $x['item_id']; + Run::Summon(['Notifier', 'like', $itemid]); + } + + $r = q( + "select * from item where id = %d", + intval($itemid) + ); + if ($r) { + xchan_query($r); + $sync_item = fetch_post_tags($r); + Libsync::build_sync_packet($channel['channel_id'], ['item' => [encode_item($sync_item[0], true)]]); + } + } + $ret['success'] = true; + $ret['message'] = t('Response submitted. Updates may not appear instantly.'); + json_return_and_die($ret); + } +} diff --git a/Code/Module/Wall_attach.php b/Code/Module/Wall_attach.php new file mode 100644 index 000000000..2971f5b16 --- /dev/null +++ b/Code/Module/Wall_attach.php @@ -0,0 +1,180 @@ + 1) { + $channel = Channel::from_username(argv(1)); + } + } + + if (!$channel) { + killme(); + } + + $matches = []; + $partial = false; + + if (array_key_exists('HTTP_CONTENT_RANGE', $_SERVER)) { + $pm = preg_match('/bytes (\d*)\-(\d*)\/(\d*)/', $_SERVER['HTTP_CONTENT_RANGE'], $matches); + if ($pm) { + // logger('Content-Range: ' . print_r($matches,true)); + $partial = true; + } + } + + if ($partial) { + $x = save_chunk($channel, $matches[1], $matches[2], $matches[3]); + if ($x['partial']) { + header('Range: bytes=0-' . (($x['length']) ? $x['length'] - 1 : 0)); + json_return_and_die($x); + } else { + header('Range: bytes=0-' . (($x['size']) ? $x['size'] - 1 : 0)); + + $_FILES['userfile'] = [ + 'name' => $x['name'], + 'type' => $x['type'], + 'tmp_name' => $x['tmp_name'], + 'error' => $x['error'], + 'size' => $x['size'] + ]; + } + } else { + if (!array_key_exists('userfile', $_FILES)) { + $_FILES['userfile'] = [ + 'name' => $_FILES['files']['name'], + 'type' => $_FILES['files']['type'], + 'tmp_name' => $_FILES['files']['tmp_name'], + 'error' => $_FILES['files']['error'], + 'size' => $_FILES['files']['size'] + ]; + } + } + + $observer = App::get_observer(); + + + $def_album = get_pconfig($channel['channel_id'], 'system', 'photo_path'); + $def_attach = get_pconfig($channel['channel_id'], 'system', 'attach_path'); + + $r = attach_store($channel, (($observer) ? $observer['xchan_hash'] : ''), '', [ + 'source' => 'editor', + 'visible' => 0, + 'album' => $def_album, + 'directory' => $def_attach, + 'flags' => 1, // indicates temporary permissions are created + 'allow_cid' => '<' . $channel['channel_hash'] . '>' + ]); + + if (!$r['success']) { + notice($r['message'] . EOL); + killme(); + } + + $s = EMPTY_STR; + + if (intval($r['data']['is_photo'])) { + $s .= "\n\n" . $r['body'] . "\n\n"; + } + + $url = z_root() . '/cloud/' . $channel['channel_address'] . '/' . $r['data']['display_path']; + + if (strpos($r['data']['filetype'], 'video') === 0) { + for ($n = 0; $n < 15; $n++) { + $thumb = Linkinfo::get_video_poster($url); + if ($thumb) { + break; + } + sleep(1); + continue; + } + + if ($thumb) { + $s .= "\n\n" . '[zvideo poster=\'' . $thumb . '\']' . $url . '[/zvideo]' . "\n\n"; + } else { + $s .= "\n\n" . '[zvideo]' . $url . '[/zvideo]' . "\n\n"; + } + } + if (strpos($r['data']['filetype'], 'audio') === 0) { + $s .= "\n\n" . '[zaudio]' . $url . '[/zaudio]' . "\n\n"; + } + if ($r['data']['filetype'] === 'image/svg+xml') { + $x = @file_get_contents('store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + if ($x) { + $bb = svg2bb($x); + if ($bb) { + $s .= "\n\n" . $bb; + } else { + logger('empty return from svgbb'); + } + } else { + logger('unable to read svg data file: ' . 'store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + } + } + if ($r['data']['filetype'] === 'text/vnd.abc' && Addon::is_installed('abc')) { + $x = @file_get_contents('store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + if ($x) { + $s .= "\n\n" . '[abc]' . $x . '[/abc]'; + } else { + logger('unable to read ABC data file: ' . 'store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + } + } + if ($r['data']['filetype'] === 'text/calendar') { + $content = @file_get_contents('store/' . $channel['channel_address'] . '/' . $r['data']['os_path']); + if ($content) { + $ev = ical_to_ev($content); + if ($ev) { + $s .= "\n\n" . format_event_bbcode($ev[0]) . "\n\n"; + } + } + } + + $s .= "\n\n" . '[attachment]' . $r['data']['hash'] . ',' . $r['data']['revision'] . '[/attachment]' . "\n"; + + + if ($using_api) { + return $s; + } + + $result['message'] = $s; + json_return_and_die($result); + } +} diff --git a/Code/Module/Wall_upload.php b/Code/Module/Wall_upload.php new file mode 100644 index 000000000..59f3d568b --- /dev/null +++ b/Code/Module/Wall_upload.php @@ -0,0 +1,62 @@ + 1) { + $channel = Channel::from_username(argv(1)); + } + } + + if (!$channel) { + if ($using_api) { + return; + } + notice(t('Channel not found.') . EOL); + killme(); + } + + $observer = App::get_observer(); + + $args = array('source' => 'editor', 'visible' => 0, 'contact_allow' => array($channel['channel_hash'])); + + $ret = photo_upload($channel, $observer, $args); + + if (!$ret['success']) { + if ($using_api) { + return; + } + notice($ret['message']); + killme(); + } + + if ($using_api) { + return ("\n\n" . $ret['body'] . "\n\n"); + } else { + echo "\n\n" . $ret['body'] . "\n\n"; + } + killme(); + } +} diff --git a/Code/Module/Webfinger.php b/Code/Module/Webfinger.php new file mode 100644 index 000000000..3b6227a66 --- /dev/null +++ b/Code/Module/Webfinger.php @@ -0,0 +1,194 @@ + 3 && $arr[2] === 'zotid') { + $hash = $arr[3]; + $channel_target = Channel::from_hash($hash); + } + } + + if (strpos($resource, 'acct:') === 0) { + $channel_nickname = punify(str_replace('acct:', '', $resource)); + if (strrpos($channel_nickname, '@') !== false) { + $host = punify(substr($channel_nickname, strrpos($channel_nickname, '@') + 1)); + + // If the webfinger address points off site, redirect to the correct site + + if (strcasecmp($host, App::get_hostname())) { + goaway('https://' . $host . '/.well-known/webfinger?f=&resource=' . $resource); + } + $channel_nickname = substr($channel_nickname, 0, strrpos($channel_nickname, '@')); + } + } + if (strpos($resource, 'http') === 0) { + $channel_nickname = str_replace(['~', '@'], ['', ''], basename($resource)); + } + + if ($channel_nickname) { + $channel_target = Channel::from_username($channel_nickname); + } + + if ($channel_target || $site_query) { + $h = get_hubloc_addrs_by_hash($channel_target['channel_hash']); + + if (!isset($result['subject'])) { + $result['subject'] = $resource; + } + + $aliases = [ + z_root() . '/channel/' . $channel_target['channel_address'], + z_root() . '/~' . $channel_target['channel_address'], + z_root() . '/@' . $channel_target['channel_address'] + + ]; + + if ($h) { + foreach ($h as $hh) { + $aliases[] = 'acct:' . $hh['hubloc_addr']; + } + } + + $result['aliases'] = []; + + $result['properties'] = [ + 'http://webfinger.net/ns/name' => $site_query ? System::get_site_name() : $channel_target['channel_name'], + 'http://xmlns.com/foaf/0.1/name' => $site_query ? System::get_site_name() : $channel_target['channel_name'], + 'https://w3id.org/security/v1#publicKeyPem' => (($site_query) ? get_config('system', 'pubkey') : $channel_target['xchan_pubkey']), + 'http://purl.org/zot/federation' => ((get_config('system', 'activitypub', ACTIVITYPUB_ENABLED)) ? 'nomad,zot6,activitypub' : 'nomad,zot6') + ]; + + if ($site_query) { + $aliases[] = z_root(); + $aliases[] = 'acct:sys@' . App::get_hostname(); + } + + foreach ($aliases as $alias) { + if ($alias !== $result['subject']) { + $result['aliases'][] = $alias; + } + } + + $result['links'] = [ + + [ + 'rel' => 'http://webfinger.net/rel/avatar', + 'type' => $channel_target['xchan_photo_mimetype'], + 'href' => $channel_target['xchan_photo_l'] + ], + + [ + 'rel' => 'http://webfinger.net/rel/blog', + 'href' => z_root() . '/channel/' . $channel_target['channel_address'], + ], + + [ + 'rel' => 'http://openid.net/specs/connect/1.0/issuer', + 'href' => z_root() + ], + + [ + 'rel' => 'http://purl.org/zot/protocol/6.0', + 'type' => 'application/x-zot+json', + 'href' => (($site_query) ? z_root() : z_root() . '/channel/' . $channel_target['channel_address']), + ], + + [ + 'rel' => 'http://purl.org/nomad', + 'type' => 'application/x-nomad+json', + 'href' => (($site_query) ? z_root() : z_root() . '/channel/' . $channel_target['channel_address']), + ], + + [ + 'rel' => 'http://purl.org/openwebauth/v1', + 'type' => 'application/x-nomad+json', + 'href' => z_root() . '/owa' + ], + + [ + 'rel' => 'http://purl.org/openwebauth/v1', + 'type' => 'application/x-zot+json', + 'href' => z_root() . '/owa' + ], + + [ + 'rel' => 'self', + 'type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'href' => (($site_query) ? z_root() : z_root() . '/channel/' . $channel_target['channel_address']) + ], + + [ + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => (($site_query) ? z_root() : z_root() . '/channel/' . $channel_target['channel_address']) + ], + + [ + 'rel' => 'http://ostatus.org/schema/1.0/subscribe', + 'template' => z_root() . '/follow?url={uri}' + ], + ]; + } + + if (!$result) { + header($_SERVER['SERVER_PROTOCOL'] . ' ' . 400 . ' ' . 'Bad Request'); + killme(); + } + + $arr = ['channel' => $channel_target, 'request' => $_REQUEST, 'result' => $result]; + Hook::call('webfinger', $arr); + + + json_return_and_die($arr['result'], 'application/jrd+json', true); + } +} diff --git a/Code/Module/Webpages.php b/Code/Module/Webpages.php new file mode 100644 index 000000000..f1a18ec94 --- /dev/null +++ b/Code/Module/Webpages.php @@ -0,0 +1,731 @@ + 1 && argv(1) === 'sys' && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + App::$is_sys = true; + } + } + + if (argc() > 1) { + $which = argv(1); + } else { + return; + } + + Libprofile::load($which); + } + + + public function get() + { + + if (!App::$profile) { + notice(t('Requested profile is not available.') . EOL); + App::$error = 404; + return; + } + + if (!Apps::system_app_installed(App::$profile_uid, 'Webpages')) { + //Do not display any associated widgets at this point + App::$pdl = ''; + + $o = 'Webpages App (Not Installed):
      '; + $o .= t('Provide managed web pages on your channel'); + return $o; + } + + Navbar::set_selected('Webpages'); + + $which = argv(1); + + $_SESSION['return_url'] = App::$query_string; + + $uid = local_channel(); + $owner = 0; + $observer = App::get_observer(); + + $channel = App::get_channel(); + + switch ($_SESSION['action']) { + case 'import': + $_SESSION['action'] = null; + $o .= replace_macros(Theme::get_template('webpage_import.tpl'), array( + '$title' => t('Import Webpage Elements'), + '$importbtn' => t('Import selected'), + '$action' => 'import', + '$pages' => $_SESSION['pages'], + '$layouts' => $_SESSION['layouts'], + '$blocks' => $_SESSION['blocks'], + )); + return $o; + + case 'importselected': + $_SESSION['action'] = null; + break; + case 'export_select_list': + $_SESSION['action'] = null; + if (!$uid) { + $_SESSION['export'] = null; + break; + } + require_once('include/import.php'); + + $pages = get_webpage_elements($channel, 'pages'); + $layouts = get_webpage_elements($channel, 'layouts'); + $blocks = get_webpage_elements($channel, 'blocks'); + $o .= replace_macros(Theme::get_template('webpage_export_list.tpl'), array( + '$title' => t('Export Webpage Elements'), + '$exportbtn' => t('Export selected'), + '$action' => $_SESSION['export'], // value should be 'zipfile' or 'cloud' + '$pages' => $pages['pages'], + '$layouts' => $layouts['layouts'], + '$blocks' => $blocks['blocks'], + )); + $_SESSION['export'] = null; + return $o; + + default: + $_SESSION['action'] = null; + break; + } + + + if (App::$is_sys && is_site_admin()) { + $sys = Channel::get_system(); + if ($sys && intval($sys['channel_id'])) { + $uid = $owner = intval($sys['channel_id']); + $channel = $sys; + $observer = $sys; + } + } + + if (!$owner) { + // Figure out who the page owner is. + $r = q( + "select channel_id from channel where channel_address = '%s'", + dbesc($which) + ); + if ($r) { + $owner = intval($r[0]['channel_id']); + } + } + + $ob_hash = (($observer) ? $observer['xchan_hash'] : ''); + + $perms = get_all_perms($owner, $ob_hash); + + if (!$perms['write_pages']) { + notice(t('Permission denied.') . EOL); + return; + } + + $mimetype = (($_REQUEST['mimetype']) ? $_REQUEST['mimetype'] : get_pconfig($owner, 'system', 'page_mimetype')); + + $layout = (($_REQUEST['layout']) ? $_REQUEST['layout'] : get_pconfig($owner, 'system', 'page_layout')); + + // Create a status editor (for now - we'll need a WYSIWYG eventually) to create pages + // Nickname is set to the observers xchan, and profile_uid to the owner's. + // This lets you post pages at other people's channels. + + if ((!$channel) && ($uid) && ($uid == App::$profile_uid)) { + $channel = App::get_channel(); + } + if ($channel) { + $channel_acl = array( + 'allow_cid' => $channel['channel_allow_cid'], + 'allow_gid' => $channel['channel_allow_gid'], + 'deny_cid' => $channel['channel_deny_cid'], + 'deny_gid' => $channel['channel_deny_gid'] + ); + } else { + $channel_acl = ['allow_cid' => '', 'allow_gid' => '', 'deny_cid' => '', 'deny_gid' => '']; + } + + + $is_owner = ($uid && $uid == $owner); + + $o = ''; + + $x = array( + 'webpage' => ITEM_TYPE_WEBPAGE, + 'is_owner' => true, + 'nickname' => App::$profile['channel_address'], + 'lockstate' => (($channel['channel_allow_cid'] || $channel['channel_allow_gid'] || $channel['channel_deny_cid'] || $channel['channel_deny_gid']) ? 'lock' : 'unlock'), + 'acl' => (($is_owner) ? Libacl::populate($channel_acl, false, PermissionDescription::fromGlobalPermission('view_pages')) : ''), + 'permissions' => $channel_acl, + 'showacl' => (($is_owner) ? true : false), + 'visitor' => true, + 'hide_location' => true, + 'hide_voting' => true, + 'profile_uid' => intval($owner), + 'mimetype' => $mimetype, + 'mimeselect' => true, + 'layout' => $layout, + 'layoutselect' => true, + 'expanded' => true, + 'novoting' => true, + 'bbco_autocomplete' => 'bbcode', + 'bbcode' => true + ); + + if ($_REQUEST['title']) { + $x['title'] = $_REQUEST['title']; + } + if ($_REQUEST['body']) { + $x['body'] = $_REQUEST['body']; + } + if ($_REQUEST['pagetitle']) { + $x['pagetitle'] = $_REQUEST['pagetitle']; + } + + + // Get a list of webpages. We can't display all them because endless scroll makes that unusable, + // so just list titles and an edit link. + + + $sql_extra = item_permissions_sql($owner); + + $r = q( + "select * from iconfig left join item on iconfig.iid = item.id + where item.uid = %d and iconfig.cat = 'system' and iconfig.k = 'WEBPAGE' and item_type = %d + $sql_extra order by item.created desc", + intval($owner), + intval(ITEM_TYPE_WEBPAGE) + ); + + if (!$r) { + $x['pagetitle'] = 'home'; + } + + $editor = status_editor($x); + + $pages = null; + + if ($r) { + $pages = []; + foreach ($r as $rr) { + unobscure($rr); + + $lockstate = (($rr['allow_cid'] || $rr['allow_gid'] || $rr['deny_cid'] || $rr['deny_gid']) ? 'lock' : 'unlock'); + + $element_arr = array( + 'type' => 'webpage', + 'title' => $rr['title'], + 'body' => $rr['body'], + 'created' => $rr['created'], + 'edited' => $rr['edited'], + 'mimetype' => $rr['mimetype'], + 'pageurl' => str_replace('%2f', '/', $rr['v']), + 'pagetitle' => urldecode($rr['v']), + 'mid' => $rr['mid'], + 'layout_mid' => $rr['layout_mid'] + ); + $pages[$rr['iid']][] = array( + 'url' => $rr['iid'], + 'pageurl' => str_replace('%2f', '/', $rr['v']), + 'pagetitle' => urldecode($rr['v']), + 'title' => $rr['title'], + 'created' => datetime_convert('UTC', date_default_timezone_get(), $rr['created']), + 'edited' => datetime_convert('UTC', date_default_timezone_get(), $rr['edited']), + 'bb_element' => '[element]' . base64url_encode(json_encode($element_arr)) . '[/element]', + 'lockstate' => $lockstate + ); + } + } + + + //Build the base URL for edit links + $url = z_root() . '/editwebpage/' . $which; + + $o .= replace_macros(Theme::get_template('webpagelist.tpl'), array( + '$listtitle' => t('Webpages'), + '$baseurl' => $url, + '$create' => t('Create'), + '$edit' => t('Edit'), + '$share' => t('Share'), + '$delete' => t('Delete'), + '$pages' => $pages, + '$channel' => $which, + '$editor' => $editor, + '$view' => t('View'), + '$preview' => t('Preview'), + '$actions_txt' => t('Actions'), + '$pagelink_txt' => t('Page Link'), + '$title_txt' => t('Page Title'), + '$created_txt' => t('Created'), + '$edited_txt' => t('Edited') + )); + + return $o; + } + + public function post() + { + $action = $_REQUEST['action']; + if ($action) { + switch ($action) { + case 'scan': + // the state of this variable tracks whether website files have been scanned (null, true, false) + $cloud = null; + + // Website files are to be imported from an uploaded zip file + if (($_FILES) && array_key_exists('zip_file', $_FILES) && isset($_POST['w_upload'])) { + $source = $_FILES["zip_file"]["tmp_name"]; + $type = $_FILES["zip_file"]["type"]; + $okay = false; + $accepted_types = array('application/zip', 'application/x-zip-compressed', 'multipart/x-zip', 'application/x-compressed'); + foreach ($accepted_types as $mime_type) { + if ($mime_type == $type) { + $okay = true; + break; + } + } + if (!$okay) { + notice(t('Invalid file type.') . EOL); + return; + } + $zip = new ZipArchive(); + if ($zip->open($source) === true) { + $tmp_folder_name = random_string(5); + $website = dirname($source) . '/' . $tmp_folder_name; + $zip->extractTo($website); // change this to the correct site path + $zip->close(); + @unlink($source); // delete the compressed file now that the content has been extracted + $cloud = false; + } else { + notice(t('Error opening zip file') . EOL); + return null; + } + } + + // Website files are to be imported from the channel cloud files + if (($_POST) && array_key_exists('path', $_POST) && isset($_POST['cloudsubmit'])) { + $channel = App::get_channel(); + $dirpath = get_dirpath_by_cloudpath($channel, $_POST['path']); + if (!$dirpath) { + notice(t('Invalid folder path.') . EOL); + return null; + } + $cloud = true; + } + + // If the website files were uploaded or specified in the cloud files, then $cloud + // should be either true or false + if ($cloud !== null) { + require_once('include/import.php'); + $elements = []; + if ($cloud) { + $path = $_POST['path']; + } else { + $path = $website; + } + $elements['pages'] = scan_webpage_elements($path, 'page', $cloud); + $elements['layouts'] = scan_webpage_elements($path, 'layout', $cloud); + $elements['blocks'] = scan_webpage_elements($path, 'block', $cloud); + $_SESSION['blocks'] = $elements['blocks']; + $_SESSION['layouts'] = $elements['layouts']; + $_SESSION['pages'] = $elements['pages']; + if (!(empty($elements['pages']) && empty($elements['blocks']) && empty($elements['layouts']))) { + //info( t('Webpages elements detected.') . EOL); + $_SESSION['action'] = 'import'; + } else { + notice(t('No webpage elements detected.') . EOL); + $_SESSION['action'] = null; + } + } + + // If the website elements were imported from a zip file, delete the temporary decompressed files + if ($cloud === false && $website && $elements) { + $_SESSION['tempimportpath'] = $website; + //rrmdir($website); // Delete the temporary decompressed files + } + + break; + + case 'importselected': + require_once('include/import.php'); + $channel = App::get_channel(); + + // Import layout first so that pages that reference new layouts will find + // the mid of layout items in the database + + // Obtain the user-selected layouts to import and import them + $checkedlayouts = $_POST['layout']; + $layouts = []; + if (!empty($checkedlayouts)) { + foreach ($checkedlayouts as $name) { + foreach ($_SESSION['layouts'] as &$layout) { + if ($layout['name'] === $name) { + $layout['import'] = 1; + $layoutstoimport[] = $layout; + } + } + } + foreach ($layoutstoimport as $elementtoimport) { + $layouts[] = import_webpage_element($elementtoimport, $channel, 'layout'); + } + } + $_SESSION['import_layouts'] = $layouts; + + // Obtain the user-selected blocks to import and import them + $checkedblocks = $_POST['block']; + $blocks = []; + if (!empty($checkedblocks)) { + foreach ($checkedblocks as $name) { + foreach ($_SESSION['blocks'] as &$block) { + if ($block['name'] === $name) { + $block['import'] = 1; + $blockstoimport[] = $block; + } + } + } + foreach ($blockstoimport as $elementtoimport) { + $blocks[] = import_webpage_element($elementtoimport, $channel, 'block'); + } + } + $_SESSION['import_blocks'] = $blocks; + + // Obtain the user-selected pages to import and import them + $checkedpages = $_POST['page']; + $pages = []; + if (!empty($checkedpages)) { + foreach ($checkedpages as $pagelink) { + foreach ($_SESSION['pages'] as &$page) { + if ($page['pagelink'] === $pagelink) { + $page['import'] = 1; + $pagestoimport[] = $page; + } + } + } + foreach ($pagestoimport as $elementtoimport) { + $pages[] = import_webpage_element($elementtoimport, $channel, 'page'); + } + } + $_SESSION['import_pages'] = $pages; + if (!(empty($_SESSION['import_pages']) && empty($_SESSION['import_blocks']) && empty($_SESSION['import_layouts']))) { + info(t('Import complete.') . EOL); + } + if (isset($_SESSION['tempimportpath'])) { + rrmdir($_SESSION['tempimportpath']); // Delete the temporary decompressed files + unset($_SESSION['tempimportpath']); + } + break; + + case 'exportzipfile': + if (isset($_POST['w_download'])) { + $_SESSION['action'] = 'export_select_list'; + $_SESSION['export'] = 'zipfile'; + if (isset($_POST['zipfilename']) && $_POST['zipfilename'] !== '') { + $filename = filter_var($_POST['zipfilename'], FILTER_SANITIZE_ENCODED); + } else { + $filename = 'website.zip'; + } + $_SESSION['zipfilename'] = $filename; + } + + break; + + case 'exportcloud': + if (isset($_POST['exportcloudpath']) && $_POST['exportcloudpath'] !== '') { + $_SESSION['action'] = 'export_select_list'; + $_SESSION['export'] = 'cloud'; + $_SESSION['exportcloudpath'] = filter_var($_POST['exportcloudpath'], FILTER_SANITIZE_ENCODED); + } + + break; + + case 'cloud': + case 'zipfile': + $channel = App::get_channel(); + + $tmp_folder_name = random_string(10); + $zip_folder_name = random_string(10); + $zip_filename = $_SESSION['zipfilename']; + $tmp_folderpath = '/tmp/' . $tmp_folder_name; + $zip_folderpath = '/tmp/' . $zip_folder_name; + if (!mkdir($zip_folderpath, 0770, false)) { + logger('Error creating zip file export folder: ' . $zip_folderpath, LOGGER_NORMAL); + json_return_and_die(array('message' => 'Error creating zip file export folder')); + } + $zip_filepath = '/tmp/' . $zip_folder_name . '/' . $zip_filename; + + $checkedblocks = $_POST['block']; + $blocks = []; + if (!empty($checkedblocks)) { + foreach ($checkedblocks as $mid) { + $b = q( + "select iconfig.v, iconfig.k, mimetype, title, body from iconfig + left join item on item.id = iconfig.iid + where mid = '%s' and item.uid = %d and iconfig.cat = 'system' and iconfig.k = 'BUILDBLOCK' order by iconfig.v asc limit 1", + dbesc($mid), + intval($channel['channel_id']) + ); + if ($b) { + $b = $b[0]; + $blockinfo = array( + 'body' => $b['body'], + 'mimetype' => $b['mimetype'], + 'title' => $b['title'], + 'name' => $b['v'], + 'json' => array( + 'title' => $b['title'], + 'name' => $b['v'], + 'mimetype' => $b['mimetype'], + ) + ); + switch ($blockinfo['mimetype']) { + case 'text/html': + $block_ext = 'html'; + break; + case 'text/bbcode': + case 'text/x-multicode': + $block_ext = 'bbcode'; + break; + case 'text/markdown': + $block_ext = 'md'; + break; + case 'application/x-pdl': + $block_ext = 'pdl'; + break; + case 'application/x-php': + $block_ext = 'php'; + break; + default: + $block_ext = 'bbcode'; + break; + } + $block_filename = $blockinfo['name'] . '.' . $block_ext; + $tmp_blockfolder = $tmp_folderpath . '/blocks/' . $blockinfo['name']; + $block_filepath = $tmp_blockfolder . '/' . $block_filename; + $blockinfo['json']['contentfile'] = $block_filename; + $block_jsonpath = $tmp_blockfolder . '/block.json'; + if (!is_dir($tmp_blockfolder) && !mkdir($tmp_blockfolder, 0770, true)) { + logger('Error creating temp export folder: ' . $tmp_blockfolder, LOGGER_NORMAL); + json_return_and_die(array('message' => 'Error creating temp export folder')); + } + file_put_contents($block_filepath, $blockinfo['body']); + file_put_contents($block_jsonpath, json_encode($blockinfo['json'], JSON_UNESCAPED_SLASHES)); + } + } + } + + $checkedlayouts = $_POST['layout']; + $layouts = []; + if (!empty($checkedlayouts)) { + foreach ($checkedlayouts as $mid) { + $l = q( + "select iconfig.v, iconfig.k, mimetype, title, body from iconfig + left join item on item.id = iconfig.iid + where mid = '%s' and item.uid = %d and iconfig.cat = 'system' and iconfig.k = 'PDL' order by iconfig.v asc limit 1", + dbesc($mid), + intval($channel['channel_id']) + ); + if ($l) { + $l = $l[0]; + $layoutinfo = array( + 'body' => $l['body'], + 'mimetype' => $l['mimetype'], + 'description' => $l['title'], + 'name' => $l['v'], + 'json' => array( + 'description' => $l['title'], + 'name' => $l['v'], + 'mimetype' => $l['mimetype'], + ) + ); + switch ($layoutinfo['mimetype']) { + case 'text/bbcode': + case 'text/x-multicode': + default: + $layout_ext = 'bbcode'; + break; + } + $layout_filename = $layoutinfo['name'] . '.' . $layout_ext; + $tmp_layoutfolder = $tmp_folderpath . '/layouts/' . $layoutinfo['name']; + $layout_filepath = $tmp_layoutfolder . '/' . $layout_filename; + $layoutinfo['json']['contentfile'] = $layout_filename; + $layout_jsonpath = $tmp_layoutfolder . '/layout.json'; + if (!is_dir($tmp_layoutfolder) && !mkdir($tmp_layoutfolder, 0770, true)) { + logger('Error creating temp export folder: ' . $tmp_layoutfolder, LOGGER_NORMAL); + json_return_and_die(array('message' => 'Error creating temp export folder')); + } + file_put_contents($layout_filepath, $layoutinfo['body']); + file_put_contents($layout_jsonpath, json_encode($layoutinfo['json'], JSON_UNESCAPED_SLASHES)); + } + } + } + + $checkedpages = $_POST['page']; + $pages = []; + if (!empty($checkedpages)) { + foreach ($checkedpages as $mid) { + $p = q( + "select * from iconfig left join item on iconfig.iid = item.id + where item.uid = %d and item.mid = '%s' and iconfig.cat = 'system' and iconfig.k = 'WEBPAGE' and item_type = %d", + intval($channel['channel_id']), + dbesc($mid), + intval(ITEM_TYPE_WEBPAGE) + ); + + if ($p) { + foreach ($p as $pp) { + // Get the associated layout + $layoutinfo = []; + if ($pp['layout_mid']) { + $l = q( + "select iconfig.v, iconfig.k, mimetype, title, body from iconfig + left join item on item.id = iconfig.iid + where mid = '%s' and item.uid = %d and iconfig.cat = 'system' and iconfig.k = 'PDL' order by iconfig.v asc limit 1", + dbesc($pp['layout_mid']), + intval($channel['channel_id']) + ); + if ($l) { + $l = $l[0]; + $layoutinfo = array( + 'body' => $l['body'], + 'mimetype' => $l['mimetype'], + 'description' => $l['title'], + 'name' => $l['v'], + 'json' => array( + 'description' => $l['title'], + 'name' => $l['v'], + ) + ); + switch ($layoutinfo['mimetype']) { + case 'text/bbcode': + case 'text/x-multicode': + default: + $layout_ext = 'bbcode'; + break; + } + $layout_filename = $layoutinfo['name'] . '.' . $layout_ext; + $tmp_layoutfolder = $tmp_folderpath . '/layouts/' . $layoutinfo['name']; + $layout_filepath = $tmp_layoutfolder . '/' . $layout_filename; + $layoutinfo['json']['contentfile'] = $layout_filename; + $layout_jsonpath = $tmp_layoutfolder . '/layout.json'; + if (!is_dir($tmp_layoutfolder) && !mkdir($tmp_layoutfolder, 0770, true)) { + logger('Error creating temp export folder: ' . $tmp_layoutfolder, LOGGER_NORMAL); + json_return_and_die(array('message' => 'Error creating temp export folder')); + } + file_put_contents($layout_filepath, $layoutinfo['body']); + file_put_contents($layout_jsonpath, json_encode($layoutinfo['json'], JSON_UNESCAPED_SLASHES)); + } + } + switch ($pp['mimetype']) { + case 'text/html': + $page_ext = 'html'; + break; + case 'text/bbcode': + case 'text/x-multicode': + $page_ext = 'bbcode'; + break; + case 'text/markdown': + $page_ext = 'md'; + break; + case 'application/x-pdl': + $page_ext = 'pdl'; + break; + case 'application/x-php': + $page_ext = 'php'; + break; + default: + break; + } + $pageinfo = array( + 'title' => $pp['title'], + 'body' => $pp['body'], + 'pagelink' => $pp['v'], + 'mimetype' => $pp['mimetype'], + 'contentfile' => $pp['v'] . '.' . $page_ext, + 'layout' => ((x($layoutinfo, 'name')) ? $layoutinfo['name'] : ''), + 'json' => array( + 'title' => $pp['title'], + 'pagelink' => $pp['v'], + 'mimetype' => $pp['mimetype'], + 'layout' => ((x($layoutinfo, 'name')) ? $layoutinfo['name'] : ''), + ) + ); + $page_filename = $pageinfo['pagelink'] . '.' . $page_ext; + $tmp_pagefolder = $tmp_folderpath . '/pages/' . $pageinfo['pagelink']; + $page_filepath = $tmp_pagefolder . '/' . $page_filename; + $page_jsonpath = $tmp_pagefolder . '/page.json'; + $pageinfo['json']['contentfile'] = $page_filename; + if (!is_dir($tmp_pagefolder) && !mkdir($tmp_pagefolder, 0770, true)) { + logger('Error creating temp export folder: ' . $tmp_pagefolder, LOGGER_NORMAL); + json_return_and_die(array('message' => 'Error creating temp export folder')); + } + file_put_contents($page_filepath, $pageinfo['body']); + file_put_contents($page_jsonpath, json_encode($pageinfo['json'], JSON_UNESCAPED_SLASHES)); + } + } + } + } + if ($action === 'zipfile') { + // Generate the zip file + ExtendedZip::zipTree($tmp_folderpath, $zip_filepath, ZipArchive::CREATE); + // Output the file for download + header('Content-Disposition: attachment; filename="' . $zip_filename . '"'); + header("Content-Type: application/zip"); + $success = readfile($zip_filepath); + } elseif ($action === 'cloud') { // Only zipfile or cloud should be possible values for $action here + if (isset($_SESSION['exportcloudpath'])) { + require_once('include/attach.php'); + $cloudpath = urldecode($_SESSION['exportcloudpath']); + $channel = App::get_channel(); + $dirpath = get_dirpath_by_cloudpath($channel, $cloudpath); + if (!$dirpath) { + $x = attach_mkdirp($channel, $channel['channel_hash'], array('pathname' => $cloudpath)); + $folder_hash = (($x['success']) ? $x['data']['hash'] : ''); + + if (!$x['success']) { + logger('Failed to create cloud file folder', LOGGER_NORMAL); + } + $dirpath = get_dirpath_by_cloudpath($channel, $cloudpath); + if (!is_dir($dirpath)) { + logger('Failed to create cloud file folder', LOGGER_NORMAL); + } + } + + $success = copy_folder_to_cloudfiles($channel, $channel['channel_hash'], $tmp_folderpath, $cloudpath); + } + } + if (!$success) { + logger('Error exporting webpage elements', LOGGER_NORMAL); + } + + rrmdir($zip_folderpath); + rrmdir($tmp_folderpath); // delete temporary files + killme(); + + break; + default: + break; + } + } + } +} diff --git a/Code/Module/Well_known.php b/Code/Module/Well_known.php new file mode 100644 index 000000000..8c722d5c9 --- /dev/null +++ b/Code/Module/Well_known.php @@ -0,0 +1,71 @@ + 1) { + $arr = ['server' => $_SERVER, 'request' => $_REQUEST]; + Hook::call('well_known', $arr); + + if (!check_siteallowed($_SERVER['REMOTE_ADDR'])) { + logger('well_known: site not allowed. ' . $_SERVER['REMOTE_ADDR']); + killme(); + } + + // from php.net re: REMOTE_HOST: + // Note: Your web server must be configured to create this variable. + // For example in Apache you'll need HostnameLookups On inside httpd.conf + // for it to exist. See also gethostbyaddr(). + + if (get_config('system', 'siteallowed_remote_host') && (!check_siteallowed($_SERVER['REMOTE_HOST']))) { + logger('well_known: site not allowed. ' . $_SERVER['REMOTE_HOST']); + killme(); + } + + switch (argv(1)) { + case 'webfinger': + App::$argc -= 1; + array_shift(App::$argv); + App::$argv[0] = 'webfinger'; + $module = new Webfinger(); + $module->init(); + break; + + case 'oauth-authorization-server': + case 'openid-configuration': + App::$argc -= 1; + array_shift(App::$argv); + App::$argv[0] = 'oauthinfo'; + $module = new Oauthinfo(); + $module->init(); + break; + + case 'dnt-policy.txt': + echo file_get_contents('doc/global/dnt-policy.txt'); + killme(); + + default: + if (file_exists(App::$cmd)) { + echo file_get_contents(App::$cmd); + killme(); + } elseif (file_exists(App::$cmd . '.php')) { + require_once(App::$cmd . '.php'); + } + break; + } + } + + http_status_exit(404); + } +} diff --git a/Code/Module/Xchan.php b/Code/Module/Xchan.php new file mode 100644 index 000000000..9eadf0d07 --- /dev/null +++ b/Code/Module/Xchan.php @@ -0,0 +1,51 @@ +' . t('Xchan Lookup') . ''; + + $o .= '
      '; + $o .= t('Lookup xchan beginning with (or webbie): '); + $o .= ''; + $o .= '
      '; + $o .= '

      '; + + if (x($_GET, 'addr')) { + $addr = trim($_GET['addr']); + + $r = q( + "select * from xchan where xchan_hash like '%s%%' or xchan_addr = '%s' group by xchan_hash", + dbesc($addr), + dbesc($addr) + ); + + if ($r) { + foreach ($r as $rr) { + $o .= str_replace(array("\n", " "), array("
      ", " "), print_r($rr, true)) . EOL; + + $s = q( + "select * from hubloc where hubloc_hash like '%s'", + dbesc($r[0]['xchan_hash']) + ); + + if ($s) { + foreach ($s as $rrr) { + $o .= str_replace(array("\n", " "), array("
      ", " "), print_r($rrr, true)) . EOL; + } + } + } + } else { + notice(t('Not found.') . EOL); + } + } + return $o; + } +} diff --git a/Code/Module/Xp.php b/Code/Module/Xp.php new file mode 100644 index 000000000..c20aeabe4 --- /dev/null +++ b/Code/Module/Xp.php @@ -0,0 +1,66 @@ + 1) { + $path = 'cache/xp/' . substr(argv(1), 0, 2) . '/' . substr(argv(1), 2, 2) . '/' . argv(1); + + if (!file_exists($path)) { + // no longer cached for some reason, perhaps expired + $resolution = substr(argv(1), (-2), 2); + if ($resolution && substr($resolution, 0, 1) === '-') { + switch (substr($resolution, 1, 1)) { + case '4': + $path = Channel::get_default_profile_photo(); + break; + case '5': + $path = Channel::get_default_profile_photo(80); + break; + case '6': + $path = Channel::get_default_profile_photo(48); + break; + default: + break; + } + } + } + + if (!file_exists($path)) { + http_status_exit(404, 'Not found'); + } + + $x = @getimagesize($path); + if ($x) { + header('Content-Type: ' . $x['mime']); + } + + $cache = intval(get_config('system', 'photo_cache_time')); + if (!$cache) { + $cache = (3600 * 24); // 1 day + } + header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $cache) . ' GMT'); + // Set browser cache age as $cache. But set timeout of 'shared caches' + // much lower in the event that infrastructure caching is present. + $smaxage = intval($cache / 12); + header('Cache-Control: s-maxage=' . $smaxage . '; max-age=' . $cache . ';'); + + $infile = fopen($path, 'rb'); + $outfile = fopen('php://output', 'wb'); + if ($infile && $outfile) { + pipe_streams($infile, $outfile); + } + fclose($infile); + fclose($outfile); + killme(); + } + + http_status_exit(404, 'Not found'); + } +} diff --git a/Code/Module/Xref.php b/Code/Module/Xref.php new file mode 100644 index 000000000..d866b8593 --- /dev/null +++ b/Code/Module/Xref.php @@ -0,0 +1,29 @@ + 2) { + $url = argv(2); + } + + goaway(z_root() . '/' . $url); + } +} diff --git a/Code/Module/Zot.php b/Code/Module/Zot.php new file mode 100644 index 000000000..e3015da1a --- /dev/null +++ b/Code/Module/Zot.php @@ -0,0 +1,28 @@ +run(),'application/x-nomad+json'); + } + +} diff --git a/Code/Module/Zot_probe.php b/Code/Module/Zot_probe.php new file mode 100644 index 000000000..f0b538f3c --- /dev/null +++ b/Code/Module/Zot_probe.php @@ -0,0 +1,53 @@ + t('Zot6 Probe Diagnostic'), + '$resource' => ['resource', t('Object URL'), $_REQUEST['resource'], EMPTY_STR], + '$authf' => ['authf', t('Authenticated fetch'), $_REQUEST['authf'], EMPTY_STR, [t('No'), t('Yes')]], + '$submit' => t('Submit') + ]); + + if (x($_GET, 'resource')) { + $resource = $_GET['resource']; + $channel = (($_GET['authf']) ? App::get_channel() : null); + + if (strpos($resource, 'x-zot:') === 0) { + $x = ZotURL::fetch($resource, $channel); + } else { + $x = Zotfinger::exec($resource, $channel); + + $o .= '
      ' . htmlspecialchars(print_array($x)) . '
      '; + + $headers = 'Accept: application/x-nomad+json, application/x-zot+json, application/jrd+json, application/json'; + + $redirects = 0; + $x = z_fetch_url($resource, true, $redirects, ['headers' => [$headers]]); + } + + if ($x['success']) { + $o .= '
      ' . htmlspecialchars($x['header']) . '
      ' . EOL; + + $o .= 'verify returns: ' . str_replace("\n", EOL, print_r(HTTPSig::verify($x, EMPTY_STR, 'zot6'), true)) . EOL; + + $o .= '
      ' . htmlspecialchars(json_encode(json_decode($x['body']), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '
      ' . EOL; + } + } + return $o; + } +} diff --git a/Code/Module/Zotfinger.php b/Code/Module/Zotfinger.php new file mode 100644 index 000000000..9f9a26074 --- /dev/null +++ b/Code/Module/Zotfinger.php @@ -0,0 +1,47 @@ + t('Zotfinger Diagnostic'), + '$resource' => ['resource', t('Lookup URL'), $_GET['resource'], EMPTY_STR], + '$submit' => t('Submit') + ]); + + if ($_GET['resource']) { + $channel = App::get_channel(); + $resource = trim(escape_tags($_GET['resource'])); + $do_import = ((intval($_GET['import']) && is_site_admin()) ? true : false); + + $j = Zfinger::exec($resource, $channel); + + if ($do_import && $j) { + $x = Libzot::import_xchan($j['data']); + } + $o .= '
      ' . str_replace("\n", '
      ', print_array($j)) . '
      '; + } + return $o; + } +} diff --git a/Code/Photo/PhotoDriver.php b/Code/Photo/PhotoDriver.php new file mode 100644 index 000000000..7470ec0c0 --- /dev/null +++ b/Code/Photo/PhotoDriver.php @@ -0,0 +1,581 @@ +types = $this->supportedTypes(); + if (! array_key_exists($type, $this->types)) { + $type = 'image/jpeg'; + } + $this->type = $type; + $this->valid = false; + $this->load($data, $type); + } + + public function __destruct() + { + if ($this->is_valid()) { + $this->destroy(); + } + } + + /** + * @brief Is it a valid image object. + * + * @return bool + */ + public function is_valid() + { + return $this->valid; + } + + /** + * @brief Get the width of the image. + * + * @return bool|number Width of image in pixels, or false on failure + */ + public function getWidth() + { + if (! $this->is_valid()) { + return false; + } + return $this->width; + } + + /** + * @brief Get the height of the image. + * + * @return bool|number Height of image in pixels, or false on failure + */ + public function getHeight() + { + if (! $this->is_valid()) { + return false; + } + return $this->height; + } + + /** + * @brief Saves the image resource to a file in filesystem. + * + * @param string $path Path and filename where to save the image + * @return bool False on failure, otherwise true + */ + public function saveImage($path, $animated = true) + { + if (! $this->is_valid()) { + return false; + } + return (file_put_contents($path, $this->imageString($animated)) ? true : false); + } + + /** + * @brief Return mimetype of the image resource. + * + * @return bool|string False on failure, otherwise mimetype. + */ + public function getType() + { + if (! $this->is_valid()) { + return false; + } + return $this->type; + } + + /** + * @brief Return file extension of the image resource. + * + * @return bool|string False on failure, otherwise file extension. + */ + public function getExt() + { + if (! $this->is_valid()) { + return false; + } + return $this->types[$this->getType()]; + } + + /** + * @brief Scale image to max pixel size in either dimension. + * + * @param int $max maximum pixel size in either dimension + * @param bool $float_height (optional) + * If true allow height to float to any length on tall images, constraining + * only the width + * @return bool|void false on failure, otherwise void + */ + public function scaleImage($max, $float_height = true) + { + if (! $this->is_valid()) { + return false; + } + + $width = $this->width; + $height = $this->height; + + $dest_width = $dest_height = 0; + + if (! ($width && $height)) { + return false; + } + + if ($width > $max && $height > $max) { + // very tall image (greater than 16:9) + // constrain the width - let the height float. + + if (((($height * 9) / 16) > $width) && ($float_height)) { + $dest_width = $max; + $dest_height = intval(($height * $max) / $width); + } // else constrain both dimensions + elseif ($width > $height) { + $dest_width = $max; + $dest_height = intval(($height * $max) / $width); + } else { + $dest_width = intval(($width * $max) / $height); + $dest_height = $max; + } + } else { + if ($width > $max) { + $dest_width = $max; + $dest_height = intval(($height * $max) / $width); + } else { + if ($height > $max) { + // very tall image (greater than 16:9) + // but width is OK - don't do anything + + if (((($height * 9) / 16) > $width) && ($float_height)) { + $dest_width = $width; + $dest_height = $height; + } else { + $dest_width = intval(($width * $max) / $height); + $dest_height = $max; + } + } else { + $dest_width = $width; + $dest_height = $height; + } + } + } + $this->doScaleImage($dest_width, $dest_height); + } + + public function scaleImageUp($min) + { + if (! $this->is_valid()) { + return false; + } + + $width = $this->width; + $height = $this->height; + + $dest_width = $dest_height = 0; + + if (! ($width && $height)) { + return false; + } + + if ($width < $min && $height < $min) { + if ($width > $height) { + $dest_width = $min; + $dest_height = intval(($height * $min) / $width); + } else { + $dest_width = intval(($width * $min) / $height); + $dest_height = $min; + } + } else { + if ($width < $min) { + $dest_width = $min; + $dest_height = intval(($height * $min) / $width); + } else { + if ($height < $min) { + $dest_width = intval(($width * $min) / $height); + $dest_height = $min; + } else { + $dest_width = $width; + $dest_height = $height; + } + } + } + $this->doScaleImage($dest_width, $dest_height); + } + + /** + * @brief Scales image to a square. + * + * @param int $dim Pixel of square image + * @return bool|void false on failure, otherwise void + */ + public function scaleImageSquare($dim) + { + if (! $this->is_valid()) { + return false; + } + $this->doScaleImage($dim, $dim); + } + + /** + * @brief Crops a square image. + * + * @see cropImageRect() + * + * @param int $max size of the new image + * @param int $x x-offset for region + * @param int $y y-offset for region + * @param int $w width of region + * @param int $h height of region + * + * @return bool|void false on failure + */ + public function cropImage($max, $x, $y, $w, $h) + { + if (! $this->is_valid()) { + return false; + } + $this->cropImageRect($max, $max, $x, $y, $w, $h); + } + + /** + * @brief Reads exif data from a given filename. + * + * @param string $filename + * @return bool|array + */ + public function exif($filename) + { + if ((! function_exists('exif_read_data')) || (! in_array($this->getType(), ['image/jpeg', 'image/tiff']))) { + return false; + } + + /* + * PHP 7.2 allows you to use a stream resource, which should reduce/avoid + * memory exhaustion on large images. + */ + + if (version_compare(PHP_VERSION, '7.2.0') >= 0) { + $f = @fopen($filename, 'rb'); + } else { + $f = $filename; + } + + if ($f) { + return @exif_read_data($f, null, true); + } + + return false; + } + + /** + * @brief Orients current image based on exif orientation information. + * + * @param array $exif + * @return bool true if oriented, otherwise false + */ + public function orient($exif) + { + if (! ($this->is_valid() && $exif)) { + return false; + } + + $ort = ((array_key_exists('IFD0', $exif)) ? $exif['IFD0']['Orientation'] : $exif['Orientation']); + + if (! $ort) { + return false; + } + + switch ($ort) { + case 1: // nothing + break; + case 2: // horizontal flip + $this->flip(); + break; + case 3: // 180 rotate left + $this->rotate(180); + break; + case 4: // vertical flip + $this->flip(false, true); + break; + case 5: // vertical flip + 90 rotate right + $this->flip(false, true); + $this->rotate(-90); + break; + case 6: // 90 rotate right + $this->rotate(-90); + break; + case 7: // horizontal flip + 90 rotate right + $this->flip(); + $this->rotate(-90); + break; + case 8: // 90 rotate left + $this->rotate(90); + break; + default: + break; + } + + return true; + } + + /** + * @brief Save photo to database. + * + * @param array $arr + * @param bool $skipcheck (optional) default false + * @return bool|array + */ + public function save($arr, $skipcheck = false) + { + if (! ($skipcheck || $this->is_valid())) { + logger('Attempt to store invalid photo.'); + return false; + } + + $p = []; + + $p['aid'] = ((intval($arr['aid'])) ? intval($arr['aid']) : 0); + $p['uid'] = ((intval($arr['uid'])) ? intval($arr['uid']) : 0); + $p['xchan'] = (($arr['xchan']) ? $arr['xchan'] : ''); + $p['resource_id'] = (($arr['resource_id']) ? $arr['resource_id'] : ''); + $p['filename'] = (($arr['filename']) ? $arr['filename'] : ''); + $p['mimetype'] = (($arr['mimetype']) ? $arr['mimetype'] : $this->getType()); + $p['album'] = (($arr['album']) ? $arr['album'] : ''); + $p['imgscale'] = ((intval($arr['imgscale'])) ? intval($arr['imgscale']) : 0); + $p['allow_cid'] = (($arr['allow_cid']) ? $arr['allow_cid'] : ''); + $p['allow_gid'] = (($arr['allow_gid']) ? $arr['allow_gid'] : ''); + $p['deny_cid'] = (($arr['deny_cid']) ? $arr['deny_cid'] : ''); + $p['deny_gid'] = (($arr['deny_gid']) ? $arr['deny_gid'] : ''); + $p['edited'] = (($arr['edited']) ? $arr['edited'] : datetime_convert()); + $p['title'] = (($arr['title']) ? $arr['title'] : ''); + $p['description'] = (($arr['description']) ? $arr['description'] : ''); + $p['photo_usage'] = intval($arr['photo_usage']); + $p['os_storage'] = intval($arr['os_storage']); + $p['os_path'] = $arr['os_path']; + $p['os_syspath'] = ((array_key_exists('os_syspath', $arr)) ? $arr['os_syspath'] : ''); + $p['display_path'] = (($arr['display_path']) ? $arr['display_path'] : ''); + $p['width'] = (($arr['width']) ? $arr['width'] : $this->getWidth()); + $p['height'] = (($arr['height']) ? $arr['height'] : $this->getHeight()); + $p['expires'] = (($arr['expires']) ? $arr['expires'] : gmdate('Y-m-d H:i:s', time() + get_config('system', 'photo_cache_time', 86400))); + $p['profile'] = ((array_key_exists('profile', $arr)) ? intval($arr['profile']) : 0); + + if (! intval($p['imgscale'])) { + logger('save: ' . print_r($arr, true), LOGGER_DATA); + } + $x = q("select id, created from photo where resource_id = '%s' and uid = %d and xchan = '%s' and imgscale = %d limit 1", dbesc($p['resource_id']), intval($p['uid']), dbesc($p['xchan']), intval($p['imgscale'])); + + if ($x) { + $p['created'] = (($x['created']) ? $x['created'] : $p['edited']); + $r = q( + "UPDATE photo set + aid = %d, + uid = %d, + xchan = '%s', + resource_id = '%s', + created = '%s', + edited = '%s', + filename = '%s', + mimetype = '%s', + album = '%s', + height = %d, + width = %d, + content = '%s', + os_storage = %d, + filesize = %d, + imgscale = %d, + photo_usage = %d, + title = '%s', + description = '%s', + os_path = '%s', + display_path = '%s', + allow_cid = '%s', + allow_gid = '%s', + deny_cid = '%s', + deny_gid = '%s', + expires = '%s', + profile = %d + where id = %d", + intval($p['aid']), + intval($p['uid']), + dbesc($p['xchan']), + dbesc($p['resource_id']), + dbescdate($p['created']), + dbescdate($p['edited']), + dbesc(basename($p['filename'])), + dbesc($p['mimetype']), + dbesc($p['album']), + intval($p['height']), + intval($p['width']), + (intval($p['os_storage']) ? dbescbin($p['os_syspath']) : dbescbin($this->imageString())), + intval($p['os_storage']), + (intval($p['os_storage']) ? @filesize($p['os_syspath']) : strlen($this->imageString())), + intval($p['imgscale']), + intval($p['photo_usage']), + dbesc($p['title']), + dbesc($p['description']), + dbesc($p['os_path']), + dbesc($p['display_path']), + dbesc($p['allow_cid']), + dbesc($p['allow_gid']), + dbesc($p['deny_cid']), + dbesc($p['deny_gid']), + dbescdate($p['expires']), + intval($p['profile']), + intval($x[0]['id']) + ); + } else { + $p['created'] = (($arr['created']) ? $arr['created'] : $p['edited']); + $r = q("INSERT INTO photo + ( aid, uid, xchan, resource_id, created, edited, filename, mimetype, album, height, width, content, os_storage, filesize, imgscale, photo_usage, title, description, os_path, display_path, allow_cid, allow_gid, deny_cid, deny_gid, expires, profile ) + VALUES ( %d, %d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', %d, %d, '%s', %d, %d, %d, %d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %d)", intval($p['aid']), intval($p['uid']), dbesc($p['xchan']), dbesc($p['resource_id']), dbescdate($p['created']), dbescdate($p['edited']), dbesc(basename($p['filename'])), dbesc($p['mimetype']), dbesc($p['album']), intval($p['height']), intval($p['width']), (intval($p['os_storage']) ? dbescbin($p['os_syspath']) : dbescbin($this->imageString())), intval($p['os_storage']), (intval($p['os_storage']) ? @filesize($p['os_syspath']) : strlen($this->imageString())), intval($p['imgscale']), intval($p['photo_usage']), dbesc($p['title']), dbesc($p['description']), dbesc($p['os_path']), dbesc($p['display_path']), dbesc($p['allow_cid']), dbesc($p['allow_gid']), dbesc($p['deny_cid']), dbesc($p['deny_gid']), dbescdate($p['expires']), intval($p['profile'])); + } + logger('Photo save imgscale ' . $p['imgscale'] . ' returned ' . intval($r)); + + return $r; + } + + /** + * @brief Stores thumbnail to database or filesystem. + * + * @param array $arr + * @param scale int + * @return bool|array + */ + + public function storeThumbnail($arr, $scale = 0, $animated = true) + { + + $arr['imgscale'] = $scale; + + if (boolval(get_config('system', 'filesystem_storage_thumbnails', 1)) && $scale > 0) { + $channel = Channel::from_id($arr['uid']); + $arr['os_storage'] = 1; + $arr['os_syspath'] = 'store/' . $channel['channel_address'] . '/' . $arr['os_path'] . '-' . $scale; + if (! $this->saveImage($arr['os_syspath'], $animated)) { + return false; + } + } + + if (! $this->save($arr)) { + if (array_key_exists('os_syspath', $arr)) { + @unlink($arr['os_syspath']); + } + return false; + } + + return true; + } +} diff --git a/Code/Photo/PhotoGd.php b/Code/Photo/PhotoGd.php new file mode 100644 index 000000000..95d79ff50 --- /dev/null +++ b/Code/Photo/PhotoGd.php @@ -0,0 +1,213 @@ +valid = false; + if (! $data) { + return; + } + + $this->image = @imagecreatefromstring($data); + + if ($this->image !== false) { + $this->valid = true; + $this->setDimensions(); + imagealphablending($this->image, false); + imagesavealpha($this->image, true); + } else { + logger('image load failed'); + } + } + + protected function setDimensions() + { + $this->width = imagesx($this->image); + $this->height = imagesy($this->image); + } + + /** + * @brief GD driver does not preserve EXIF, so not need to clear it. + * + * @return void + */ + public function clearexif() + { + return; + } + + protected function destroy() + { + if ($this->is_valid()) { + imagedestroy($this->image); + } + } + + /** + * @brief Return a PHP image resource of the current image. + * + * @see \Code\Photo\PhotoDriver::getImage() + * + * @return bool|resource + */ + public function getImage() + { + if (! $this->is_valid()) { + return false; + } + + return $this->image; + } + + public function doScaleImage($dest_width, $dest_height) + { + + $dest = imagecreatetruecolor($dest_width, $dest_height); + $width = imagesx($this->image); + $height = imagesy($this->image); + + imagealphablending($dest, false); + imagesavealpha($dest, true); + if ($this->type == 'image/png') { + imagefill($dest, 0, 0, imagecolorallocatealpha($dest, 0, 0, 0, 127)); // fill with alpha + } + imagecopyresampled($dest, $this->image, 0, 0, 0, 0, $dest_width, $dest_height, $width, $height); + if ($this->image) { + imagedestroy($this->image); + } + + $this->image = $dest; + $this->setDimensions(); + } + + public function rotate($degrees) + { + if (! $this->is_valid()) { + return false; + } + + $this->image = imagerotate($this->image, $degrees, 0); + $this->setDimensions(); + } + + public function flip($horiz = true, $vert = false) + { + if (! $this->is_valid()) { + return false; + } + + $w = imagesx($this->image); + $h = imagesy($this->image); + $flipped = imagecreate($w, $h); + if ($horiz) { + for ($x = 0; $x < $w; $x++) { + imagecopy($flipped, $this->image, $x, 0, $w - $x - 1, 0, 1, $h); + } + } + if ($vert) { + for ($y = 0; $y < $h; $y++) { + imagecopy($flipped, $this->image, 0, $y, 0, $h - $y - 1, $w, 1); + } + } + $this->image = $flipped; + $this->setDimensions(); // Shouldn't really be necessary + } + + public function cropImageRect($maxx, $maxy, $x, $y, $w, $h) + { + if (! $this->is_valid()) { + return false; + } + + $dest = imagecreatetruecolor($maxx, $maxy); + imagealphablending($dest, false); + imagesavealpha($dest, true); + if ($this->type == 'image/png') { + imagefill($dest, 0, 0, imagecolorallocatealpha($dest, 0, 0, 0, 127)); // fill with alpha + } + imagecopyresampled($dest, $this->image, 0, 0, $x, $y, $maxx, $maxy, $w, $h); + if ($this->image) { + imagedestroy($this->image); + } + $this->image = $dest; + $this->setDimensions(); + } + + /** + * {@inheritDoc} + * @see \Code\Photo\PhotoDriver::imageString() + */ + public function imageString($animated = true) + { + if (! $this->is_valid()) { + return false; + } + + $quality = false; + + ob_start(); + + switch ($this->getType()) { + case 'image/webp': + imagewebp($this->image); + break; + + case 'image/png': + $quality = get_config('system', 'png_quality'); + if ((! $quality) || ($quality > 9)) { + $quality = PNG_QUALITY; + } + + imagepng($this->image, null, $quality); + break; + case 'image/jpeg': + // gd can lack imagejpeg(), but we verify during installation it is available + default: + $quality = get_config('system', 'jpeg_quality'); + if ((! $quality) || ($quality > 100)) { + $quality = JPEG_QUALITY; + } + + imagejpeg($this->image, null, $quality); + break; + } + $string = ob_get_contents(); + ob_end_clean(); + + return $string; + } +} diff --git a/Code/Photo/PhotoImagick.php b/Code/Photo/PhotoImagick.php new file mode 100644 index 000000000..f006b71c9 --- /dev/null +++ b/Code/Photo/PhotoImagick.php @@ -0,0 +1,233 @@ + 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; + } + + private function get_FormatsMap() + { + return [ + 'image/jpeg' => 'JPG', + 'image/png' => 'PNG', + 'image/gif' => 'GIF', + 'image/webp' => 'WEBP' + ]; + } + + + protected function load($data, $type) + { + $this->valid = false; + $this->image = new Imagick(); + + if (! $data) { + return; + } + + try { + $this->image->readImageBlob($data); + } catch (Exception $e) { + logger('Imagick read failed'); + // logger('Imagick readImageBlob() exception:' . print_r($e, true)); + return; + } + + /* + * Setup the image to the format it will be saved to + */ + + $map = $this->get_FormatsMap(); + $format = $map[$type]; + + if ($this->image) { + $this->image->setFormat($format); + + // Always coalesce, if it is not a multi-frame image it won't hurt anyway + $this->image = $this->image->coalesceImages(); + + $this->valid = true; + $this->setDimensions(); + + /* + * setup the compression here, so we'll do it only once + */ + switch ($this->getType()) { + case 'image/png': + $quality = get_config('system', 'png_quality'); + if ((! $quality) || ($quality > 9)) { + $quality = PNG_QUALITY; + } + /* + * From http://www.imagemagick.org/script/command-line-options.php#quality: + * + * 'For the MNG and PNG image formats, the quality value sets + * the zlib compression level (quality / 10) and filter-type (quality % 10). + * The default PNG "quality" is 75, which means compression level 7 with adaptive PNG filtering, + * unless the image has a color map, in which case it means compression level 7 with no PNG filtering' + */ + $quality = $quality * 10; + $this->image->setCompressionQuality($quality); + break; + case 'image/jpeg': + $quality = get_config('system', 'jpeg_quality'); + if ((! $quality) || ($quality > 100)) { + $quality = JPEG_QUALITY; + } + $this->image->setCompressionQuality($quality); + default: + break; + } + } + } + + protected function destroy() + { + if ($this->is_valid()) { + $this->image->clear(); + $this->image->destroy(); + } + } + + protected function setDimensions() + { + $this->width = $this->image->getImageWidth(); + $this->height = $this->image->getImageHeight(); + } + + /** + * @brief Strips the image of all profiles and comments. + * + * Keep ICC profile for better colors. + * + * @see \Code\Photo\PhotoDriver::clearexif() + */ + public function clearexif() + { + $profiles = $this->image->getImageProfiles('icc', true); + + $this->image->stripImage(); + + if (! empty($profiles)) { + $this->image->profileImage('icc', $profiles['icc']); + } + } + + + /** + * @brief Return a \Imagick object of the current image. + * + * @see \Code\Photo\PhotoDriver::getImage() + * + * @return bool|Imagick + */ + public function getImage() + { + if (! $this->is_valid()) { + return false; + } + + $this->image = $this->image->deconstructImages(); + return $this->image; + } + + public function doScaleImage($dest_width, $dest_height) + { + /* + * If it is not animated, there will be only one iteration here, + * so don't bother checking + */ + // Don't forget to go back to the first frame + $this->image->setFirstIterator(); + do { + $this->image->scaleImage($dest_width, $dest_height); + } while ($this->image->nextImage()); + + $this->setDimensions(); + } + + public function rotate($degrees) + { + if (! $this->is_valid()) { + return false; + } + + $this->image->setFirstIterator(); + do { + // ImageMagick rotates in the opposite direction of imagerotate() + $this->image->rotateImage(new ImagickPixel(), -$degrees); + } while ($this->image->nextImage()); + + $this->setDimensions(); + } + + public function flip($horiz = true, $vert = false) + { + if (! $this->is_valid()) { + return false; + } + + $this->image->setFirstIterator(); + do { + if ($horiz) { + $this->image->flipImage(); + } + if ($vert) { + $this->image->flopImage(); + } + } while ($this->image->nextImage()); + + $this->setDimensions(); // Shouldn't really be necessary + } + + public function cropImageRect($maxx, $maxy, $x, $y, $w, $h) + { + if (! $this->is_valid()) { + return false; + } + + $this->image->setFirstIterator(); + do { + $this->image->cropImage($w, $h, $x, $y); + /* + * We need to remove the canvas, + * or the image is not resized to the crop: + * http://php.net/manual/en/imagick.cropimage.php#97232 + */ + $this->image->setImagePage(0, 0, 0, 0); + } while ($this->image->nextImage()); + + $this->doScaleImage($maxx, $maxy); + } + + public function imageString($animated = true) + { + if (! $this->is_valid()) { + return false; + } + + /* Clean it */ + $this->image = $this->image->deconstructImages(); + if ($animated) { + return $this->image->getImagesBlob(); + } + return $this->image->getImageBlob(); + } +} diff --git a/Code/Render/Comanche.php b/Code/Render/Comanche.php new file mode 100644 index 000000000..f6352adfd --- /dev/null +++ b/Code/Render/Comanche.php @@ -0,0 +1,688 @@ +get_condition_var($mtch[1]); + $default = $mtch[3]; + $cases = []; + $cntt = preg_match_all("/\[case (.*?)\](.*?)\[\/case\]/ism", $mtch[2], $cases, PREG_SET_ORDER); + if ($cntt) { + foreach ($cases as $case) { + if ($case[1] === $switch_var) { + $switch_done = 1; + $s = str_replace($mtch[0], $case[2], $s); + break; + } + } + if ($switch_done === 0) { + $s = str_replace($mtch[0], $default, $s); + } + } + } + } + + $cnt = preg_match_all("/\[if (.*?)\](.*?)\[else\](.*?)\[\/if\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + if ($this->test_condition($mtch[1])) { + $s = str_replace($mtch[0], $mtch[2], $s); + } else { + $s = str_replace($mtch[0], $mtch[3], $s); + } + } + } else { + $cnt = preg_match_all("/\[if (.*?)\](.*?)\[\/if\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + if ($this->test_condition($mtch[1])) { + $s = str_replace($mtch[0], $mtch[2], $s); + } else { + $s = str_replace($mtch[0], '', $s); + } + } + } + } + if ($pass == 0) { + $this->parse_pass0($s); + } else { + $this->parse_pass1($s); + } + } + + public function parse_pass0($s) + { + + $matches = null; + + $cnt = preg_match("/\[layout\](.*?)\[\/layout\]/ism", $s, $matches); + if ($cnt) { + App::$page['template'] = trim($matches[1]); + } + + $cnt = preg_match("/\[template=(.*?)\](.*?)\[\/template\]/ism", $s, $matches); + if ($cnt) { + App::$page['template'] = trim($matches[2]); + App::$page['template_style'] = trim($matches[2]) . '_' . $matches[1]; + } + + $cnt = preg_match("/\[template\](.*?)\[\/template\]/ism", $s, $matches); + if ($cnt) { + App::$page['template'] = trim($matches[1]); + } + + $cnt = preg_match("/\[theme=(.*?)\](.*?)\[\/theme\]/ism", $s, $matches); + if ($cnt) { + App::$layout['schema'] = trim($matches[1]); + App::$layout['theme'] = trim($matches[2]); + } + + $cnt = preg_match("/\[theme\](.*?)\[\/theme\]/ism", $s, $matches); + if ($cnt) { + App::$layout['theme'] = trim($matches[1]); + } + + $cnt = preg_match("/\[navbar\](.*?)\[\/navbar\]/ism", $s, $matches); + if ($cnt) { + App::$layout['navbar'] = trim($matches[1]); + } + + $cnt = preg_match_all("/\[webpage\](.*?)\[\/webpage\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + // only the last webpage definition is used if there is more than one + foreach ($matches as $mtch) { + App::$layout['webpage'] = $this->webpage($a, $mtch[1]); + } + } + } + + public function parse_pass1($s) + { + $cnt = preg_match_all("/\[region=(.*?)\](.*?)\[\/region\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + App::$layout['region_' . $mtch[1]] = $this->region($mtch[2], $mtch[1]); + } + } + } + + /** + * @brief Replace conditional variables with real values. + * + * Currently supported condition variables: + * * $config.xxx.yyy - get_config with cat = xxx and k = yyy + * * $request - request uri for this page + * * $observer.language - viewer's preferred language (closest match) + * * $observer.address - xchan_addr or false + * * $observer.name - xchan_name or false + * * $observer - xchan_hash of observer or empty string + * * $local_channel - logged in channel_id or false + * + * @param string $v The conditional variable name + * @return string|bool + */ + public function get_condition_var($v) + { + if ($v) { + $x = explode('.', $v); + if ($x[0] == 'config') { + return get_config($x[1], $x[2]); + } elseif ($x[0] === 'request') { + return $_SERVER['REQUEST_URI']; + } elseif ($x[0] === 'local_channel') { + return local_channel(); + } elseif ($x[0] === 'observer') { + if (count($x) > 1) { + if ($x[1] == 'language') { + return App::$language; + } + $y = App::get_observer(); + if (!$y) { + return false; + } + if ($x[1] == 'address') { + return $y['xchan_addr']; + } elseif ($x[1] == 'name') { + return $y['xchan_name']; + } elseif ($x[1] == 'webname') { + return substr($y['xchan_addr'], 0, strpos($y['xchan_addr'], '@')); + } + return false; + } + return get_observer_hash(); + } else { + return false; + } + } + return false; + } + + /** + * @brief Test for Conditional Execution conditions. + * + * This is extensible. The first version of variable testing supports tests of the forms: + * + * - [if $config.system.foo ~= baz] which will check if get_config('system','foo') contains the string 'baz'; + * - [if $config.system.foo == baz] which will check if get_config('system','foo') is the string 'baz'; + * - [if $config.system.foo != baz] which will check if get_config('system','foo') is not the string 'baz'; + * - [if $config.system.foo >= 3] which will check if get_config('system','foo') is greater than or equal to 3; + * - [if $config.system.foo > 3] which will check if get_config('system','foo') is greater than 3; + * - [if $config.system.foo <= 3] which will check if get_config('system','foo') is less than or equal to 3; + * - [if $config.system.foo < 3] which will check if get_config('system','foo') is less than 3; + * + * - [if $config.system.foo {} baz] which will check if 'baz' is an array element in get_config('system','foo') + * - [if $config.system.foo {*} baz] which will check if 'baz' is an array key in get_config('system','foo') + * - [if $config.system.foo] which will check for a return of a true condition for get_config('system','foo'); + * + * The values 0, '', an empty array, and an unset value will all evaluate to false. + * + * @param int|string $s + * @return bool + */ + public function test_condition($s) + { + + if (preg_match('/[\$](.*?)\s\~\=\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if (stripos($x, trim($matches[2])) !== false) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)\s\=\=\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if ($x == trim($matches[2])) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)\s\!\=\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if ($x != trim($matches[2])) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)\s\>\=\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if ($x >= trim($matches[2])) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)\s\<\=\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if ($x <= trim($matches[2])) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)\s\>\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if ($x > trim($matches[2])) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)\s\>\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if ($x < trim($matches[2])) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)\s\{\}\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if (is_array($x) && in_array(trim($matches[2]), $x)) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)\s\{\*\}\s(.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if (is_array($x) && array_key_exists(trim($matches[2]), $x)) { + return true; + } + return false; + } + + if (preg_match('/[\$](.*?)$/', $s, $matches)) { + $x = $this->get_condition_var($matches[1]); + if ($x) { + return true; + } + return false; + } + return false; + } + + /** + * @brief Return rendered menu for current channel_id. + * + * @param string $s + * @param string $class (optional) default empty + * @return string + * @see menu_render() + */ + public function menu($s, $class = '') + { + + $channel_id = $this->get_channel_id(); + $name = $s; + + $cnt = preg_match_all("/\[var=(.*?)\](.*?)\[\/var\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $var[$mtch[1]] = $mtch[2]; + $name = str_replace($mtch[0], '', $name); + } + } + + if ($channel_id) { + $m = Menu::fetch($name, $channel_id, get_observer_hash()); + return Menu::render($m, $class, $edit = false, $var); + } + } + + + public function replace_region($match) + { + if (array_key_exists($match[1], App::$page)) { + return App::$page[$match[1]]; + } + } + + /** + * @brief Returns the channel_id of the profile owner of the page. + * + * Returns the channel_id of the profile owner of the page, or the local_channel + * if there is no profile owner. Otherwise returns 0. + * + * @return int channel_id + */ + public function get_channel_id() + { + $channel_id = ((is_array(App::$profile)) ? App::$profile['profile_uid'] : 0); + + if ((!$channel_id) && (local_channel())) { + $channel_id = local_channel(); + } + return $channel_id; + } + + /** + * @brief Returns a parsed block. + * + * @param string $s + * @param string $class (optional) default empty + * @return string parsed HTML of block + */ + public function block($s, $class = '') + { + $var = []; + $matches = []; + $name = $s; + $class = (($class) ? $class : 'bblock widget'); + + $cnt = preg_match_all("/\[var=(.*?)\](.*?)\[\/var\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $var[$mtch[1]] = $mtch[2]; + $name = str_replace($mtch[0], '', $name); + } + } + + $o = ''; + $channel_id = $this->get_channel_id(); + + if ($channel_id) { + $r = q( + "select * from item inner join iconfig on iconfig.iid = item.id and item.uid = %d + and iconfig.cat = 'system' and iconfig.k = 'BUILDBLOCK' and iconfig.v = '%s' limit 1", + intval($channel_id), + dbesc($name) + ); + + if ($r) { + //check for eventual menus in the block and parse them + $cnt = preg_match_all("/\[menu\](.*?)\[\/menu\]/ism", $r[0]['body'], $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $r[0]['body'] = str_replace($mtch[0], $this->menu(trim($mtch[1])), $r[0]['body']); + } + } + $cnt = preg_match_all("/\[menu=(.*?)\](.*?)\[\/menu\]/ism", $r[0]['body'], $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $r[0]['body'] = str_replace($mtch[0], $this->menu(trim($mtch[2]), $mtch[1]), $r[0]['body']); + } + } + + // emit the block + $o .= (($var['wrap'] == 'none') ? '' : '
      '); + + if ($r[0]['title'] && trim($r[0]['body']) != '$content') { + $o .= '

      ' . $r[0]['title'] . '

      '; + } + + if (trim($r[0]['body']) === '$content') { + $o .= App::$page['content']; + } else { + $o .= prepare_text($r[0]['body'], $r[0]['mimetype']); + } + + $o .= (($var['wrap'] == 'none') ? '' : '
      '); + } + } + + return $o; + } + + /** + * @brief Include JS depending on framework. + * + * @param string $s + * @return string + */ + public function js($s) + { + + switch ($s) { + case 'jquery': + $path = 'view/js/jquery.js'; + break; + case 'bootstrap': + $path = 'vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js'; + break; + case 'foundation': + $path = 'library/foundation/js/foundation.js'; + $init = "\r\n" . ''; + break; + } + + $ret = ''; + if ($init) { + $ret .= $init; + } + return $ret; + } + + /** + * @brief Include CSS depending on framework. + * + * @param string $s + * @return string + */ + public function css($s) + { + + switch ($s) { + case 'bootstrap': + $path = 'vendor/twbs/bootstrap/dist/css/bootstrap.min.css'; + break; + case 'foundation': + $path = 'library/foundation/css/foundation.min.css'; + break; + } + + $ret = ''; + + return $ret; + } + + /** + * This doesn't really belong in Comanche, but it could also be argued that it is the perfect place. + * We need to be able to select what kind of template and decoration to use for the webpage at the heart of our content. + * For now we'll allow an '[authored]' element which defaults to name and date, or 'none' to remove these, and perhaps + * 'full' to provide a social network style profile photo. + * + * But leave it open to have richer templating options and perhaps ultimately discard this one, once we have a better idea + * of what template and webpage options we might desire. + * + * @param[in,out] array $a + * @param string $s + * @return array + */ + public function webpage(&$a, $s) + { + $ret = []; + $matches = []; + + $cnt = preg_match_all("/\[authored\](.*?)\[\/authored\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $ret['authored'] = $mtch[1]; + } + } + + return $ret; + } + + /** + * @brief Render a widget. + * + * @param string $name + * @param string $text + */ + public function widget($name, $text) + { + $vars = []; + $matches = []; + + $cnt = preg_match_all("/\[var=(.*?)\](.*?)\[\/var\]/ism", $text, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $vars[$mtch[1]] = $mtch[2]; + } + } + + if (!purify_filename($name)) { + return ''; + } + + $clsname = ucfirst($name); + $nsname = "\\Code\\Widget\\" . $clsname; + + $found = false; + $widgets = Widget::get(); + if ($widgets) { + foreach ($widgets as $widget) { + if (is_array($widget) && strtolower($widget[1]) === strtolower($name) && file_exists($widget[0])) { + require_once($widget[0]); + $found = true; + } + } + } + + if (!$found) { + if (file_exists('Code/SiteWidget/' . $clsname . '.php')) { + require_once('Code/SiteWidget/' . $clsname . '.php'); + } elseif (file_exists('widget/' . $clsname . '/' . $clsname . '.php')) { + require_once('widget/' . $clsname . '/' . $clsname . '.php'); + } elseif (file_exists('Code/Widget/' . $clsname . '.php')) { + require_once('Code/Widget/' . $clsname . '.php'); + } else { + $pth = Theme::include($clsname . '.php'); + if ($pth) { + require_once($pth); + } + } + } + + if (class_exists($nsname)) { + $x = new $nsname(); + $f = 'widget'; + if (method_exists($x, $f)) { + return $x->$f($vars); + } + } + + $func = 'widget_' . trim($name); + + if (!function_exists($func)) { + if (file_exists('widget/' . trim($name) . '.php')) { + require_once('widget/' . trim($name) . '.php'); + } elseif (file_exists('widget/' . trim($name) . '/' . trim($name) . '.php')) { + require_once('widget/' . trim($name) . '/' . trim($name) . '.php'); + } + if (!function_exists($func)) { + $theme_widget = $func . '.php'; + if (Theme::include($theme_widget)) { + require_once(Theme::include($theme_widget)); + } + } + } + + if (function_exists($func)) { + return $func($vars); + } + } + + + public function region($s, $region_name) + { + + $s = str_replace('$region', $region_name, $s); + + $matches = []; + + $cnt = preg_match_all("/\[menu\](.*?)\[\/menu\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $s = str_replace($mtch[0], $this->menu(trim($mtch[1])), $s); + } + } + + // menu class e.g. [menu=horizontal]my_menu[/menu] or [menu=tabbed]my_menu[/menu] + // allows different menu renderings to be applied + + $cnt = preg_match_all("/\[menu=(.*?)\](.*?)\[\/menu\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $s = str_replace($mtch[0], $this->menu(trim($mtch[2]), $mtch[1]), $s); + } + } + $cnt = preg_match_all("/\[block\](.*?)\[\/block\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $s = str_replace($mtch[0], $this->block(trim($mtch[1])), $s); + } + } + + $cnt = preg_match_all("/\[block=(.*?)\](.*?)\[\/block\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $s = str_replace($mtch[0], $this->block(trim($mtch[2]), trim($mtch[1])), $s); + } + } + + $cnt = preg_match_all("/\[js\](.*?)\[\/js\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $s = str_replace($mtch[0], $this->js(trim($mtch[1])), $s); + } + } + + $cnt = preg_match_all("/\[css\](.*?)\[\/css\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $s = str_replace($mtch[0], $this->css(trim($mtch[1])), $s); + } + } + + $cnt = preg_match_all("/\[widget=(.*?)\](.*?)\[\/widget\]/ism", $s, $matches, PREG_SET_ORDER); + if ($cnt) { + foreach ($matches as $mtch) { + $s = str_replace($mtch[0], $this->widget(trim($mtch[1]), $mtch[2]), $s); + } + } + + return $s; + } + + + /** + * @brief Registers a page template/variant for use by Comanche selectors. + * + * @param array $arr + * 'template' => template name + * 'variant' => array( + * 'name' => variant name + * 'desc' => text description + * 'regions' => array( + * 'name' => name + * 'desc' => text description + * ) + * ) + */ + public function register_page_template($arr) + { + App::$page_layouts[$arr['template']] = array($arr['variant']); + return; + } +} diff --git a/Code/Render/SimpleTemplate.php b/Code/Render/SimpleTemplate.php new file mode 100644 index 000000000..d4b1ab678 --- /dev/null +++ b/Code/Render/SimpleTemplate.php @@ -0,0 +1,354 @@ + 5.3, not certain how to code around it for unit tests +// case PREG_BAD_UTF8_OFFSET_ERROR: echo('PREG_BAD_UTF8_OFFSET_ERROR'); break; + default: + // die("Unknown preg error."); + return; + } + echo "
      ";
      +        debug_print_backtrace();
      +        die();
      +    }
      +
      +    private function _push_stack()
      +    {
      +        $this->stack[] = [ $this->r, $this->nodes ];
      +    }
      +
      +    private function _pop_stack()
      +    {
      +        list($this->r, $this->nodes) = array_pop($this->stack);
      +    }
      +
      +    private function _get_var($name, $retNoKey = false)
      +    {
      +        $keys = array_map('trim', explode('.', $name));
      +        if ($retNoKey && ! array_key_exists($keys[0], $this->r)) {
      +            return KEY_NOT_EXISTS;
      +        }
      +
      +        $val = $this->r;
      +        foreach ($keys as $k) {
      +            $val = (isset($val[$k]) ? $val[$k] : null);
      +        }
      +
      +        return template_escape($val);
      +    }
      +
      +    /**
      +     * IF node
      +     * \code
      +     * {{ if <$var> }}...[{{ else }} ...] {{ endif }}
      +     * {{ if <$var>== }}...[{{ else }} ...]{{ endif }}
      +     * {{ if <$var>!= }}...[{{ else }} ...]{{ endif }}
      +     * \endcode
      +     */
      +    private function _replcb_if($args)
      +    {
      +        if (strpos($args[2], '==') > 0) {
      +            list($a,$b) = array_map('trim', explode('==', $args[2]));
      +            $a = $this->_get_var($a);
      +            if ($b[0] == '$') {
      +                $b =  $this->_get_var($b);
      +            }
      +            $val = ($a == $b);
      +        } elseif (strpos($args[2], '!=') > 0) {
      +            list($a,$b) = array_map('trim', explode('!=', $args[2]));
      +            $a = $this->_get_var($a);
      +            if ($b[0] == '$') {
      +                $b =  $this->_get_var($b);
      +            }
      +            $val = ($a != $b);
      +        } else {
      +            $val = $this->_get_var($args[2]);
      +        }
      +        $x = preg_split("|{{ *else *}}|", $args[3]);
      +
      +        return ( ($val) ? $x[0] : (isset($x[1]) ? $x[1] : EMPTY_STR));
      +    }
      +
      +    /**
      +     * FOR node
      +     * \code
      +     * {{ for <$var> as $name }}...{{ endfor }}
      +     * {{ for <$var> as $key=>$name }}...{{ endfor }}
      +     * \endcode
      +     */
      +    private function _replcb_for($args)
      +    {
      +        $m = array_map('trim', explode(" as ", $args[2]));
      +        $x = explode('=>', $m[1]);
      +        if (count($x) == 1) {
      +            $varname = $x[0];
      +            $keyname = EMPTY_STR;
      +        } else {
      +            list($keyname, $varname) = $x;
      +        }
      +        if ($m[0] == EMPTY_STR || $varname == EMPTY_STR || is_null($varname)) {
      +            die("template error: 'for " . $m[0] . " as " . $varname . "'") ;
      +        }
      +
      +        $vals = $this->_get_var($m[0]);
      +        $ret = EMPTY_STR;
      +        if (!is_array($vals)) {
      +            return $ret;
      +        }
      +
      +        foreach ($vals as $k => $v) {
      +            $this->_push_stack();
      +            $r = $this->r;
      +            $r[$varname] = $v;
      +            if ($keyname != EMPTY_STR) {
      +                $r[$keyname] = (($k === 0) ? '0' : $k);
      +            }
      +            $ret .=  $this->replace($args[3], $r);
      +            $this->_pop_stack();
      +        }
      +
      +        return $ret;
      +    }
      +
      +    /**
      +     * INC node
      +     * \code
      +     * {{ inc  [with $var1=$var2] }}{{ endinc }}
      +     * \endcode
      +     */
      +    private function _replcb_inc($args)
      +    {
      +        if (strpos($args[2], 'with')) {
      +            list($tplfile, $newctx) = array_map('trim', explode("with", $args[2]));
      +        } else {
      +            $tplfile = trim($args[2]);
      +            $newctx = null;
      +        }
      +
      +        if ($tplfile[0] == '$') {
      +            $tplfile = $this->_get_var($tplfile);
      +        }
      +
      +        $this->_push_stack();
      +        $r = $this->r;
      +        if (! is_null($newctx)) {
      +            list($a,$b) = array_map('trim', explode('=', $newctx));
      +            $r[$a] = $this->_get_var($b);
      +        }
      +        $this->nodes = array();
      +        $tpl = Theme::get_template($tplfile);
      +        $ret = $this->replace($tpl, $r);
      +        $this->_pop_stack();
      +
      +        return $ret;
      +    }
      +
      +    /**
      +     * DEBUG node
      +     * \code
      +     * {{ debug $var [$var [$var [...]]] }}{{ enddebug }}
      +     * \endcode
      +     * replace node with 
      var_dump($var, $var, ...);
      + */ + private function _replcb_debug($args) + { + $vars = array_map('trim', explode(" ", $args[2])); + $vars[] = $args[1]; + + $ret = "
      ";
      +        foreach ($vars as $var) {
      +            $ret .= htmlspecialchars(var_export($this->_get_var($var), true));
      +            $ret .= "\n";
      +        }
      +        $ret .= "
      "; + + return $ret; + } + + private function _replcb_node($m) + { + $node = $this->nodes[$m[1]]; + if (method_exists($this, "_replcb_" . $node[1])) { + $s = call_user_func(array($this, "_replcb_" . $node[1]), $node); + } else { + $s = ""; + } + $s = preg_replace_callback('/\|\|([0-9]+)\|\|/', array($this, "_replcb_node"), $s); + + return $s; + } + + private function _replcb($m) + { + //var_dump(array_map('htmlspecialchars', $m)); + $this->done = false; + $this->nodes[] = (array) $m; + + return "||" . (count($this->nodes) - 1) . "||"; + } + + private function _build_nodes($s) + { + $this->done = false; + while (!$this->done) { + $this->done = true; + $s = preg_replace_callback('|{{ *([a-z]*) *([^}]*)}}([^{]*({{ *else *}}[^{]*)?){{ *end\1 *}}|', array($this, "_replcb"), $s); + if ($s == null) { + $this->_preg_error(); + } + } + //({{ *else *}}[^{]*)? + krsort($this->nodes); + + return $s; + } + + private function var_replace($s) + { + $m = []; + /** regexp: + * \$ literal $ + * (\[)? optional open square bracket + * ([a-zA-Z0-9-_]+\.?)+ var name, followed by optional + * dot, repeated at least 1 time + * (?(1)\]) if there was opened square bracket + * (subgrup 1), match close bracket + */ + if (preg_match_all('/\$(\[)?([a-zA-Z0-9-_]+\.?)+(?(1)\])/', $s, $m)) { + foreach ($m[0] as $var) { + $exp = str_replace(array("[", "]"), array("", ""), $var); + $exptks = explode("|", $exp); + + $varn = $exptks[0]; + unset($exptks[0]); + $val = $this->_get_var($varn, true); + if ($val != KEY_NOT_EXISTS) { + /* run filters */ + /* + * Filter are in form of: + * filtername:arg:arg:arg + * + * "filtername" is function name + * "arg"s are optional, var value is appended to the end + * if one "arg"==='x' , is replaced with var value + * + * examples: + * $item.body|htmlspecialchars // escape html chars + * $item.body|htmlspecialchars|strtoupper // escape html and uppercase result + * $item.created|date:%Y %M %j // format date (created is a timestamp) + * $item.body|str_replace:cat:dog // replace all "cat" with "dog" + * $item.body|str_replace:cat:dog:x:1 // replace one "cat" with "dog" + */ + foreach ($exptks as $filterstr) { + $filter = explode(":", $filterstr); + $filtername = $filter[0]; + unset($filter[0]); + $valkey = array_search("x", $filter); + if ($valkey === false) { + $filter[] = $val; + } else { + $filter[$valkey] = $val; + } + if (function_exists($filtername)) { + $val = call_user_func_array($filtername, $filter); + } + } + $s = str_replace($var, $val, $s); + } + } + } + + return $s; + } + + private function replace($s, $r) + { + $this->replace_macros($s, $r); + } + + // TemplateEngine interface + + public function replace_macros($s, $r) + { + $this->r = $r; + + $s = $this->_build_nodes($s); + + $s = preg_replace_callback('/\|\|([0-9]+)\|\|/', array($this, "_replcb_node"), $s); + if ($s == null) { + $this->_preg_error(); + } + + // remove comments block + $s = preg_replace('/{#[^#]*#}/', "", $s); + + //$t2 = dba_timer(); + + // replace strings recursively (limit to 10 loops) + $os = ""; + $count = 0; + while (($os !== $s) && $count < 10) { + $os = $s; + $count++; + $s = $this->var_replace($s); + } + + return $s; + } + + public function get_template($file, $root = '') + { + $template_file = Theme::include($file, $root); + if ($template_file) { + $content = file_get_contents($template_file); + } + + return $content; + } +} + + +function template_escape($s) +{ + return str_replace(array('$','{{'), array('!_Doll^Ars1Az_!','!_DoubLe^BraceS4Rw_!'), $s); +} + +function template_unescape($s) +{ + return str_replace(array('!_Doll^Ars1Az_!','!_DoubLe^BraceS4Rw_!'), array('$','{{'), $s); +} diff --git a/Code/Render/SmartyInterface.php b/Code/Render/SmartyInterface.php new file mode 100644 index 000000000..2b4733755 --- /dev/null +++ b/Code/Render/SmartyInterface.php @@ -0,0 +1,72 @@ + "view/theme/$thname/tpl/"]; + if (x(App::$theme_info, "extends")) { + $template_dirs = $template_dirs + ['extends' => "view/theme/" . App::$theme_info["extends"] . '/tpl/']; + } + $template_dirs = $template_dirs + array('base' => 'view/tpl/'); + $this->setTemplateDir($template_dirs); + + // Cannot use get_config() here because it is called during installation when there is no DB. + // FIXME: this may leak private information such as system pathnames. + + $basecompiledir = ((array_key_exists('smarty3_folder', App::$config['system'])) + ? App::$config['system']['smarty3_folder'] : ''); + if (!$basecompiledir) { + $basecompiledir = str_replace('Code', '', dirname(__DIR__)) . TEMPLATE_BUILD_PATH; + } + if (!is_dir($basecompiledir)) { + @os_mkdir(TEMPLATE_BUILD_PATH, STORAGE_DEFAULT_PERMISSIONS, true); + } + if (!is_dir($basecompiledir)) { + echo "ERROR: folder $basecompiledir does not exist."; + killme(); + } + + if (!is_writable($basecompiledir)) { + echo "ERROR: folder $basecompiledir must be writable by webserver."; + killme(); + } + App::$config['system']['smarty3_folder'] = $basecompiledir; + + $this->setCompileDir($basecompiledir . '/compiled/'); + $this->setConfigDir($basecompiledir . '/config/'); + $this->setCacheDir($basecompiledir . '/cache/'); + + $this->left_delimiter = App::get_template_ldelim('smarty3'); + $this->right_delimiter = App::get_template_rdelim('smarty3'); + + // Don't report errors so verbosely + $this->error_reporting = (E_ERROR | E_PARSE); + } + + public function parsed($template = '') + { + if ($template) { + return $this->fetch('string:' . $template); + } + return $this->fetch('file:' . $this->filename); + } +} diff --git a/Code/Render/SmartyTemplate.php b/Code/Render/SmartyTemplate.php new file mode 100644 index 000000000..805d8939d --- /dev/null +++ b/Code/Render/SmartyTemplate.php @@ -0,0 +1,98 @@ +ERROR: folder $basecompiledir does not exist."; + killme(); + } + } + if (! is_writable($basecompiledir)) { + echo "ERROR: folder $basecompiledir must be writable by webserver."; + killme(); + } + App::$config['system']['smarty3_folder'] = $basecompiledir; + } + + // TemplateEngine interface + + public function replace_macros($s, $r) + { + $template = ''; + + // macro or macros available for use in all templates + + $r['$z_baseurl'] = z_root(); + + if (gettype($s) === 'string') { + $template = $s; + $s = new SmartyInterface(); + } + foreach ($r as $key => $value) { + if ($key[0] === '$') { + $key = substr($key, 1); + } + $s->assign($key, $value); + } + return $s->parsed($template); + } + + public function get_template($file, $root = '') + { + $template_file = Theme::include($file, $root); + if ($template_file) { + $template = new SmartyInterface(); + $template->filename = $template_file; + + return $template; + } + return EMPTY_STR; + } + + public function get_email_template($file, $root = '') + { + + $lang = App::$language; + if ($root != '' && substr($root, -1) != '/') { + $root .= '/'; + } + foreach ([ $root . "view/$lang/$file", $root . "view/en/$file", '' ] as $template_file) { + if (is_file($template_file)) { + break; + } + } + if ($template_file == '') { + $template_file = Theme::include($file, $root); + } + if ($template_file) { + $template = new SmartyInterface(); + $template->filename = $template_file; + return $template; + } + return EMPTY_STR; + } +} diff --git a/Code/Render/TemplateEngine.php b/Code/Render/TemplateEngine.php new file mode 100644 index 000000000..b576945bd --- /dev/null +++ b/Code/Render/TemplateEngine.php @@ -0,0 +1,13 @@ + 1) ? $theme[1] : ''); + + $opts = ''; + $opts = ((App::$profile_uid) ? '?f=&puid=' . App::$profile_uid : ''); + + $schema_str = ((x(App::$layout, 'schema')) ? '&schema=' . App::$layout['schema'] : ''); + if (($s) && (!$schema_str)) { + $schema_str = '&schema=' . $s; + } + + $opts .= $schema_str; + + if (file_exists('view/theme/' . $t . '/php/style.php')) { + return ('/view/theme/' . $t . '/php/style.pcss' . $opts); + } + + return ('/view/theme/' . $t . '/css/style.css'); + } + + public static function include($file, $root = '') + { + + // Make sure $root ends with a slash / if it's not blank + if ($root) { + $root = rtrim($root,'/') . '/'; + } + + $theme_info = App::$theme_info; + + if (array_key_exists('extends', $theme_info)) { + $parent = $theme_info['extends']; + } else { + $parent = 'NOPATH'; + } + + $theme = self::current(); + $thname = $theme[0]; + + $ext = substr($file, strrpos($file, '.') + 1); + + $paths = array( + "{$root}view/theme/$thname/$ext/$file", + "{$root}view/theme/$parent/$ext/$file", + "{$root}view/site/$ext/$file", + "{$root}view/$ext/$file", + ); + + foreach ($paths as $p) { + + if (strpos($p, 'NOPATH') !== false) { + continue; + } + if (file_exists($p)) { + return $p; + } + } + + return ''; + } + + static public function get_info($theme) { + + $info = null; + if (is_file("view/theme/$theme/$theme.yml")) { + $info = Infocon::from_file("view/theme/$theme.yml"); + } + elseif (is_file("view/theme/$theme/php/theme.php")) { + $info = Infocon::from_c_comment("view/theme/$theme/php/theme.php"); + } + return $info ? $info : [ 'name' => $theme ] ; + + } + + static public function get_email_template($s, $root = '') + { + $testroot = ($root=='') ? $testroot = "ROOT" : $root; + $t = App::template_engine(); + + if (isset(App::$override_intltext_templates[$testroot][$s]["content"])) { + return App::$override_intltext_templates[$testroot][$s]["content"]; + } else { + if (isset(App::$override_intltext_templates[$testroot][$s]["root"]) && + isset(App::$override_intltext_templates[$testroot][$s]["file"])) { + $s = App::$override_intltext_templates[$testroot][$s]["file"]; + $root = App::$override_intltext_templates[$testroot][$s]["root"]; + } elseif (App::$override_templateroot) { + $newroot = App::$override_templateroot.$root; + if ($newroot != '' && substr($newroot, -1) != '/') { + $newroot .= '/'; + } + $template = $t->Theme::get_email_template($s, $newroot); + } + $template = $t->Theme::get_email_template($s, $root); + return $template; + } + } + + static public function get_template($s, $root = '') + { + $testroot = ($root=='') ? $testroot = "ROOT" : $root; + + $t = App::template_engine(); + + if (isset(App::$override_markup_templates[$testroot][$s]["content"])) { + return App::$override_markup_templates[$testroot][$s]["content"]; + } else { + if (isset(App::$override_markup_templates[$testroot][$s]["root"]) && + isset(App::$override_markup_templates[$testroot][$s]["file"])) { + $s = App::$override_markup_templates[$testroot][$s]["file"]; + $root = App::$override_markup_templates[$testroot][$s]["root"]; + } elseif (App::$override_templateroot) { + $newroot = App::$override_templateroot.$root; + if ($newroot != '' && substr($newroot, -1) != '/') { + $newroot .= '/'; + } + $template = $t->get_template($s, $newroot); + } + $template = $t->get_template($s, $root); + return $template; + } + } + + + + + + public function debug() + { + logger('system_theme: ' . self::$system_theme); + logger('session_theme: ' . self::$session_theme); + } +} diff --git a/Code/Storage/BasicAuth.php b/Code/Storage/BasicAuth.php new file mode 100644 index 000000000..59462acf8 --- /dev/null +++ b/Code/Storage/BasicAuth.php @@ -0,0 +1,291 @@ +check_module_access($channel['channel_id'])) { + return $this->setAuthenticated($channel); + } + + if ($this->module_disabled) + $error = 'module not enabled for ' . $username; + else + $error = 'password failed for ' . $username; + logger($error); + log_failed_login($error); + + return false; + } + + /** + * @brief Sets variables and session parameters after successfull authentication. + * + * @param array $r + * Array with the values for the authenticated channel. + * @return bool + */ + protected function setAuthenticated($channel) + { + $this->channel_name = $channel['channel_address']; + $this->channel_id = $channel['channel_id']; + $this->channel_hash = $this->observer = $channel['channel_hash']; + + if ($this->observer) { + $r = q("select * from xchan where xchan_hash = '%s' limit 1", + dbesc($this->observer) + ); + if ($r) { + App::set_observer(array_shift($r)); + } + } + + $_SESSION['uid'] = $channel['channel_id']; + $_SESSION['account_id'] = $channel['channel_account_id']; + $_SESSION['authenticated'] = true; + return true; + } + + /** + * When this method is called, the backend must check if authentication was + * successful. + * + * The returned value must be one of the following + * + * [true, "principals/username"] + * [false, "reason for failure"] + * + * If authentication was successful, it's expected that the authentication + * backend returns a so-called principal url. + * + * Examples of a principal url: + * + * principals/admin + * principals/user1 + * principals/users/joe + * principals/uid/123457 + * + * If you don't use WebDAV ACL (RFC3744) we recommend that you simply + * return a string such as: + * + * principals/users/[username] + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return array + */ + public function check(RequestInterface $request, ResponseInterface $response) + { + + if (local_channel()) { + $this->setAuthenticated(App::get_channel()); + return [true, $this->principalPrefix . $this->channel_name]; + } elseif (remote_channel()) { + return [true, $this->principalPrefix . $this->observer]; + } + + $auth = new Basic( + $this->realm, + $request, + $response + ); + + $userpass = $auth->getCredentials(); + if (!$userpass) { + return [false, "No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured"]; + } + if (!$this->validateUserPass($userpass[0], $userpass[1])) { + return [false, "Username or password was incorrect"]; + } + return [true, $this->principalPrefix . $userpass[0]]; + + } + + protected function check_module_access($channel_id) + { + if ($channel_id && in_array(App::$module, ['dav', 'cdav', 'snap'])) { + return true; + } + $this->module_disabled = true; + return false; + } + + /** + * Sets the channel_name from the currently logged-in channel. + * + * @param string $name + * The channel's name + */ + public function setCurrentUser($name) + { + $this->channel_name = $name; + } + + /** + * Returns information about the currently logged-in channel. + * + * If nobody is currently logged in, this method should return null. + * + * @return string|null + * @see \\Sabre\\DAV\\Auth\\Backend\\AbstractBasic::getCurrentUser + */ + public function getCurrentUser() + { + return $this->channel_name; + } + + /** + * @brief Sets the timezone from the channel in BasicAuth. + * + * Set in mod/cloud.php if the channel has a timezone set. + * + * @param string $timezone + * The channel's timezone. + * @return void + */ + public function setTimezone($timezone) + { + $this->timezone = $timezone; + } + + /** + * @brief Returns the timezone. + * + * @return string + * Return the channel's timezone. + */ + public function getTimezone() + { + return $this->timezone; + } + + /** + * @brief Set browser plugin for SabreDAV. + * + * @param Plugin $browser + * @see RedBrowser::set_writeable() + */ + public function setBrowserPlugin($browser) + { + $this->browser = $browser; + } + + /** + * @brief Prints out all BasicAuth variables to logger(). + * + * @return void + */ + public function log() + { +// logger('channel_name ' . $this->channel_name, LOGGER_DATA); +// logger('channel_id ' . $this->channel_id, LOGGER_DATA); +// logger('channel_hash ' . $this->channel_hash, LOGGER_DATA); +// logger('observer ' . $this->observer, LOGGER_DATA); +// logger('owner_id ' . $this->owner_id, LOGGER_DATA); +// logger('owner_nick ' . $this->owner_nick, LOGGER_DATA); + } +} diff --git a/Code/Storage/Browser.php b/Code/Storage/Browser.php new file mode 100644 index 000000000..82e93b03f --- /dev/null +++ b/Code/Storage/Browser.php @@ -0,0 +1,500 @@ +auth = $auth; + parent::__construct(true, false); + } + + /** + * The DAV browser is instantiated after the auth module and directory classes + * but before we know the current directory and who the owner and observer + * are. So we add a pointer to the browser into the auth module and vice versa. + * Then when we've figured out what directory is actually being accessed, we + * call the following function to decide whether or not to show web elements + * which include writeable objects. + * + * @fixme It only disable/enable the visible parts. Not the POST handler + * which handels the actual requests when uploading files or creating folders. + * + * @todo Maybe this whole way of doing this can be solved with some + * $server->subscribeEvent(). + */ + public function set_writeable() { + if (! $this->auth->owner_id) { + $this->enablePost = false; + } + + if (! perm_is_allowed($this->auth->owner_id, get_observer_hash(), 'write_storage')) { + $this->enablePost = false; + } + else { + $this->enablePost = true; + } + } + + /** + * @brief Creates the directory listing for the given path. + * + * @param string $path which should be displayed + */ + public function generateDirectoryIndex($path) { + // (owner_id = channel_id) is visitor owner of this directory? + $is_owner = ((local_channel() && $this->auth->owner_id == local_channel()) ? true : false); + + if ($this->auth->getTimezone()) { + date_default_timezone_set($this->auth->getTimezone()); + } + + if ($this->auth->owner_nick) { + $html = ''; + } + + $files = $this->server->getPropertiesForPath($path, [ + '{DAV:}displayname', + '{DAV:}resourcetype', + '{DAV:}getcontenttype', + '{DAV:}getcontentlength', + '{DAV:}getlastmodified', + ], 1); + + $parent = $this->server->tree->getNodeForPath($path); + + $parentpath = []; + // only show parent if not leaving /cloud/; TODO how to improve this? + if ($path && $path != "cloud") { + list($parentUri) = \Sabre\Uri\split($path); + $fullPath = encodePath($this->server->getBaseUri() . $parentUri); + + $parentpath['icon'] = $this->enableAssets ? '' . t('parent') . '' : ''; + $parentpath['path'] = $fullPath; + } + + $f = []; + foreach ($files as $file) { + $ft = []; + $type = null; + + // This is the current directory, we can skip it + if (rtrim($file['href'], '/') == $path) { + continue; + } + + list(, $name) = \Sabre\Uri\split($file['href']); + + if (isset($file[200]['{DAV:}resourcetype'])) { + $type = $file[200]['{DAV:}resourcetype']->getValue(); + + // resourcetype can have multiple values + if (! is_array($type)) { + $type = [ $type ]; + } + + foreach ($type as $k => $v) { + // Some name mapping is preferred + switch ($v) { + case '{DAV:}collection' : + $type[$k] = t('Collection'); + break; + case '{DAV:}principal' : + $type[$k] = t('Principal'); + break; + case '{urn:ietf:params:xml:ns:carddav}addressbook' : + $type[$k] = t('Addressbook'); + break; + case '{urn:ietf:params:xml:ns:caldav}calendar' : + $type[$k] = t('Calendar'); + break; + case '{urn:ietf:params:xml:ns:caldav}schedule-inbox' : + $type[$k] = t('Schedule Inbox'); + break; + case '{urn:ietf:params:xml:ns:caldav}schedule-outbox' : + $type[$k] = t('Schedule Outbox'); + break; + case '{http://calendarserver.org/ns/}calendar-proxy-read' : + $type[$k] = 'Proxy-Read'; + break; + case '{http://calendarserver.org/ns/}calendar-proxy-write' : + $type[$k] = 'Proxy-Write'; + break; + } + } + $type = implode(', ', $type); + } + + // If no resourcetype was found, we attempt to use + // the contenttype property + if (! $type && isset($file[200]['{DAV:}getcontenttype'])) { + $type = $file[200]['{DAV:}getcontenttype']; + } + if (! $type) { + $type = t('Unknown'); + } + + $size = isset($file[200]['{DAV:}getcontentlength']) ? (int)$file[200]['{DAV:}getcontentlength'] : ''; + $lastmodified = ((isset($file[200]['{DAV:}getlastmodified'])) ? $file[200]['{DAV:}getlastmodified']->getTime()->format('Y-m-d H:i:s') : ''); + + $fullPath = encodePath('/' . trim($this->server->getBaseUri() . ($path ? $path . '/' : '') . $name, '/')); + + $displayName = isset($file[200]['{DAV:}displayname']) ? $file[200]['{DAV:}displayname'] : $name; + + $displayName = $this->escapeHTML($displayName); + $type = $this->escapeHTML($type); + + + $icon = ''; + + if ($this->enableAssets) { + $node = $this->server->tree->getNodeForPath(($path ? $path . '/' : '') . $name); + foreach (array_reverse($this->iconMap) as $class=>$iconName) { + if ($node instanceof $class) { + $icon = ''; + break; + } + } + } + + $folderHash = ''; + $parentHash = ''; + $owner = $this->auth->owner_id; + $splitPath = explode('/', $fullPath); + if (count($splitPath) > 3) { + for ($i = 3; $i < count($splitPath); $i++) { + $attachName = urldecode($splitPath[$i]); + $folderHash = $parentHash; + $attachHash = $this->findAttachHash($owner, $parentHash, $attachName); + $parentHash = $attachHash; + } + } + + + // generate preview icons for tile view. + // SVG, PDF and office documents have some security concerns and should only be allowed on single-user sites with tightly controlled + // upload access. system.thumbnail_security should be set to 1 if you want to include these types + + $is_creator = false; + $photo_icon = ''; + $preview_style = intval(get_config('system','thumbnail_security',0)); + + $r = q("select content, creator from attach where hash = '%s' and uid = %d limit 1", + dbesc($attachHash), + intval($owner) + ); + + if ($r) { + $is_creator = (($r[0]['creator'] === get_observer_hash()) ? true : false); + if (file_exists(dbunescbin($r[0]['content']) . '.thumb')) { + $photo_icon = 'data:image/jpeg;base64,' . base64_encode(file_get_contents(dbunescbin($r[0]['content']) . '.thumb')); + } + } + + if (strpos($type,'image/') === 0 && $attachHash) { + $r = q("select resource_id, imgscale from photo where resource_id = '%s' and imgscale in ( %d, %d ) order by imgscale asc limit 1", + dbesc($attachHash), + intval(PHOTO_RES_320), + intval(PHOTO_RES_PROFILE_80) + ); + if ($r) { + $photo_icon = 'photo/' . $r[0]['resource_id'] . '-' . $r[0]['imgscale']; + } + if ($type === 'image/svg+xml' && $preview_style > 0) { + $photo_icon = $fullPath; + } + } + + $g = [ 'resource_id' => $attachHash, 'thumbnail' => $photo_icon, 'security' => $preview_style ]; + Hook::call('file_thumbnail', $g); + $photo_icon = $g['thumbnail']; + + + $attachIcon = ""; + + // put the array for this file together + $ft['attachId'] = $this->findAttachIdByHash($attachHash); + $ft['fileStorageUrl'] = substr($fullPath, 0, strpos($fullPath, "cloud/")) . "filestorage/" . $this->auth->owner_nick; + $ft['icon'] = $icon; + $ft['photo_icon'] = $photo_icon; + $ft['attachIcon'] = (($size) ? $attachIcon : ''); + // @todo Should this be an item value, not a global one? + $ft['is_owner'] = $is_owner; + $ft['is_creator'] = $is_creator; + $ft['fullPath'] = $fullPath; + $ft['displayName'] = $displayName; + $ft['type'] = $type; + $ft['size'] = $size; + $ft['sizeFormatted'] = userReadableSize($size); + $ft['lastmodified'] = (($lastmodified) ? datetime_convert('UTC', date_default_timezone_get(), $lastmodified) : ''); + $ft['iconFromType'] = getIconFromType($type); + + $f[] = $ft; + + } + + + $output = ''; + if ($this->enablePost && $parentpath) { + $this->server->emit('onHTMLActionsPanel', array($parent, &$output, $path)); + } + + // "display as tiles" is the default for visitors, and changes to this setting are stored in the session + // so that they apply even to unauthenticated visitors. + + $deftiles = (($is_owner) ? 0 : 1); + $tiles = ((array_key_exists('cloud_tiles',$_SESSION)) ? intval($_SESSION['cloud_tiles']) : $deftiles); + $_SESSION['cloud_tiles'] = $tiles; + + $html .= replace_macros(Theme::get_template('cloud.tpl'), [ + '$header' => t('Files') . ": " . $this->escapeHTML($path) . "/", + '$total' => t('Total'), + '$actionspanel' => $output, + '$shared' => t('Shared'), + '$create' => t('Create'), + '$upload' => t('Add Files'), + '$is_owner' => $is_owner, + '$is_admin' => is_site_admin(), + '$admin_delete' => t('Admin Delete'), + '$parentpath' => $parentpath, + '$cpath' => bin2hex(App::$query_string), + '$tiles' => intval($_SESSION['cloud_tiles']), + '$photo_view' => (($parentpath) ? t('View photos') : EMPTY_STR), + '$photos_path' => z_root() . '/photos/' . $this->auth->owner_nick . '/album/' . $folderHash, + '$entries' => $f, + '$name' => t('Name'), + '$type' => t('Type'), + '$size' => t('Size'), + '$lastmod' => t('Last Modified'), + '$parent' => t('parent'), + '$edit' => t('Edit'), + '$delete' => t('Delete'), + '$nick' => $this->auth->getCurrentUser() + ] + ); + + + $a = false; + + Navbar::set_selected('Files'); + + App::$page['content'] = $html; + load_pdl(); + + $current_theme = Theme::current(); + + $theme_info_file = 'view/theme/' . $current_theme[0] . '/php/theme.php'; + if (file_exists($theme_info_file)) { + require_once($theme_info_file); + if (function_exists(str_replace('-', '_', $current_theme[0]) . '_init')) { + $func = str_replace('-', '_', $current_theme[0]) . '_init'; + $func($a); + } + } + $this->server->httpResponse->setHeader('Content-Security-Policy', "script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"); + $this->build_page = true; + } + + /** + * @brief Creates a form to add new folders and upload files. + * + * @param INode $node + * @param[in,out] string &$output + * @param string $path + */ + public function htmlActionsPanel(INode $node, &$output, $path) { + if (! $node instanceof DAV\ICollection) { + return; + } + + // We also know fairly certain that if an object is a non-extended + // SimpleCollection, we won't need to show the panel either. + + if (get_class($node) === 'Sabre\\DAV\\SimpleCollection') { + return; + } + + $aclselect = null; + $lockstate = ''; + $limit = 0; + + if ($this->auth->owner_id) { + $channel = Channel::from_id($this->auth->owner_id); + if ($channel) { + $acl = new AccessControl($channel); + $channel_acl = $acl->get(); + $lockstate = (($acl->is_private()) ? 'lock' : 'unlock'); + + $aclselect = ((local_channel() == $this->auth->owner_id) ? Libacl::populate($channel_acl,false,PermissionDescription::fromGlobalPermission('view_storage')) : ''); + } + + // Storage and quota for the account (all channels of the owner of this directory)! + $limit = engr_units_to_bytes(ServiceClass::fetch($this->auth->owner_id, 'attach_upload_limit')); + } + + if ((! $limit) && get_config('system','cloud_report_disksize')) { + $limit = engr_units_to_bytes(disk_free_space('store')); + } + + $r = q("SELECT SUM(filesize) AS total FROM attach WHERE aid = %d", + intval($this->auth->channel_account_id) + ); + $used = $r[0]['total']; + if ($used) { + $quotaDesc = t('You are using %1$s of your available file storage.'); + $quotaDesc = sprintf($quotaDesc,userReadableSize($used)); + } + if ($limit && $used) { + $quotaDesc = t('You are using %1$s of %2$s available file storage. (%3$s%)'); + $quotaDesc = sprintf($quotaDesc, + userReadableSize($used), + userReadableSize($limit), + round($used / $limit, 1) * 100); + } + // prepare quota for template + $quota = []; + $quota['used'] = $used; + $quota['limit'] = $limit; + $quota['desc'] = $quotaDesc; + $quota['warning'] = ((($limit) && ((round($used / $limit, 1) * 100) >= 90)) ? t('WARNING:') : ''); // 10485760 bytes = 100MB + + // strip 'cloud/nickname', but only at the beginning of the path + + $special = 'cloud/' . $this->auth->owner_nick; + $count = strlen($special); + + if (strpos($path,$special) === 0) { + $path = trim(substr($path,$count),'/'); + } + + $output .= replace_macros(Theme::get_template('cloud_actionspanel.tpl'), array( + '$folder_header' => t('Create new folder'), + '$folder_submit' => t('Create'), + '$upload_header' => t('Upload file'), + '$upload_submit' => t('Upload'), + '$quota' => $quota, + '$channick' => $this->auth->owner_nick, + '$aclselect' => $aclselect, + '$allow_cid' => acl2json($channel_acl['allow_cid']), + '$allow_gid' => acl2json($channel_acl['allow_gid']), + '$deny_cid' => acl2json($channel_acl['deny_cid']), + '$deny_gid' => acl2json($channel_acl['deny_gid']), + '$lockstate' => $lockstate, + '$return_url' => App::$cmd, + '$path' => $path, + '$folder' => find_folder_hash_by_path($this->auth->owner_id, $path), + '$dragdroptext' => t('Drop files here to immediately upload'), + '$notify' => ['notify', t('Show in your contacts shared folder'), 0, '', [t('No'), t('Yes')]] + )); + } + + /** + * This method takes a path/name of an asset and turns it into url + * suiteable for http access. + * + * @param string $assetName + * @return string + */ + protected function getAssetUrl($assetName) { + return z_root() . '/cloud/?sabreAction=asset&assetName=' . urlencode($assetName); + } + + /** + * @brief Return the hash of an attachment. + * + * Given the owner, the parent folder and and attach name get the attachment + * hash. + * + * @param int $owner + * The owner_id + * @param string $parentHash + * The parent's folder hash + * @param string $attachName + * The name of the attachment + * @return string + */ + protected function findAttachHash($owner, $parentHash, $attachName) { + $r = q("SELECT hash FROM attach WHERE uid = %d AND folder = '%s' AND filename = '%s' ORDER BY edited DESC LIMIT 1", + intval($owner), + dbesc($parentHash), + dbesc($attachName) + ); + $hash = ''; + if ($r) { + foreach ($r as $rr) { + $hash = $rr['hash']; + } + } + + return $hash; + } + + /** + * @brief Returns an attachment's id for a given hash. + * + * This id is used to access the attachment in filestorage/ + * + * @param string $attachHash + * The hash of an attachment + * @return string + */ + + protected function findAttachIdByHash($attachHash) { + $r = q("SELECT id FROM attach WHERE hash = '%s' ORDER BY edited DESC LIMIT 1", + dbesc($attachHash) + ); + $id = EMPTY_STR; + if ($r) { + $id = $r[0]['id']; + } + return $id; + } +} diff --git a/Code/Storage/CalDAVClient.php b/Code/Storage/CalDAVClient.php new file mode 100644 index 000000000..5ca2f305e --- /dev/null +++ b/Code/Storage/CalDAVClient.php @@ -0,0 +1,746 @@ +username = $user; + $this->password = $pass; + $this->url = $url; + } + + private function set_data($s) + { + $this->request_data = $s; + $this->filepos = 0; + } + + public function curl_read($ch, $fh, $size) + { + + if ($this->filepos < 0) { + unset($fh); + return ''; + } + + $s = substr($this->request_data, $this->filepos, $size); + + if (strlen($s) < $size) { + $this->filepos = (-1); + } else { + $this->filepos = $this->filepos + $size; + } + + return $s; + } + + public function ctag_fetch() + { + $headers = ['Depth: 0', 'Prefer: return-minimal', 'Content-Type: application/xml; charset=utf-8']; + + // recommended ctag fetch by sabre + + $this->set_data(' + + + + + + +'); + + // thunderbird uses this - it's a bit more verbose on what capabilities + // are provided by the server + + $this->set_data(' + + + + + + + + + +'); + + + $auth = $this->username . ':' . $this->password; + + $recurse = 0; + + $x = z_fetch_url( + $this->url, + true, + $recurse, + ['headers' => $headers, + 'http_auth' => $auth, + 'custom' => 'PROPFIND', + 'upload' => true, + 'infile' => 3, + 'infilesize' => strlen($this->request_data), + 'readfunc' => [$this, 'curl_read'] + ] + ); + + return $x; + } + + + public function detail_fetch() + { + $headers = ['Depth: 1', 'Prefer: return-minimal', 'Content-Type: application/xml; charset=utf-8']; + + // this query should return all objects in the given calendar, you can filter it appropriately + // using filter options + + $this->set_data(' + + + + + + + + +'); + + $auth = $this->username . ':' . $this->password; + + $recurse = 0; + $x = z_fetch_url( + $this->url, + true, + $recurse, + ['headers' => $headers, + 'http_auth' => $auth, + 'custom' => 'REPORT', + 'upload' => true, + 'infile' => 3, + 'infilesize' => strlen($this->request_data), + 'readfunc' => [$this, 'curl_read'] + ] + ); + + + return $x; + } +} + + + +/* + +PROPFIND /calendars/johndoe/home/ HTTP/1.1 +Depth: 0 +Prefer: return-minimal +Content-Type: application/xml; charset=utf-8 + + + + + + + + +// Responses: success + +HTTP/1.1 207 Multi-status +Content-Type: application/xml; charset=utf-8 + + + + /calendars/johndoe/home/ + + + Home calendar + 3145 + + HTTP/1.1 200 OK + + + + +// Responses: fail + +HTTP/1.1 207 Multi-status +Content-Type: application/xml; charset=utf-8 + + + + /calendars/johndoe/home/ + + + + + + HTTP/1.1 403 Forbidden + + + + + +// sample request body in DOM +// prepare request body +$doc = new DOMDocument('1.0', 'utf-8'); +$doc->formatOutput = true; + +$query = $doc->createElement('c:calendar-query'); +$query->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:c', 'urn:ietf:params:xml:ns:caldav'); +$query->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:d', 'DAV:'); + +$prop = $doc->createElement('d:prop'); +$prop->appendChild($doc->createElement('d:getetag')); +$prop->appendChild($doc->createElement('c:calendar-data')); +$query->appendChild($prop); +$doc->appendChild($query); +$body = $doc->saveXML(); + +echo "Body: " . $body . "
      "; + + +Now we download every single object in this calendar. To do this, we use a REPORT method. + +REPORT /calendars/johndoe/home/ HTTP/1.1 +Depth: 1 +Prefer: return-minimal +Content-Type: application/xml; charset=utf-8 + + + + + + + + + + + +This request will give us every object that's a VCALENDAR object, and its etag. + +If you're only interested in VTODO (because you're writing a todo app) you can also filter for just those: + +REPORT /calendars/johndoe/home/ HTTP/1.1 +Depth: 1 +Prefer: return-minimal +Content-Type: application/xml; charset=utf-8 + + + + + + + + + + + + + +Similarly it's also possible to filter to just events, or only get events within a specific time-range. + +This report will return a multi-status object again: + +HTTP/1.1 207 Multi-status +Content-Type: application/xml; charset=utf-8 + + + + /calendars/johndoe/home/132456762153245.ics + + + "2134-314" + BEGIN:VCALENDAR + VERSION:2.0 + CALSCALE:GREGORIAN + BEGIN:VTODO + UID:132456762153245 + SUMMARY:Do the dishes + DUE:20121028T115600Z + END:VTODO + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + /calendars/johndoe/home/132456-34365.ics + + + "5467-323" + BEGIN:VCALENDAR + VERSION:2.0 + CALSCALE:GREGORIAN + BEGIN:VEVENT + UID:132456-34365 + SUMMARY:Weekly meeting + DTSTART:20120101T120000 + DURATION:PT1H + RRULE:FREQ=WEEKLY + END:VEVENT + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + +This calendar only contained 2 objects. A todo and a weekly event. + +So after you retrieved and processed these, for each object you must retain: + + The calendar data itself + The url + The etag + +In this case all urls ended with .ics. This is often the case, but you must not rely on this. In this case the UID in the calendar object was also identical to a part of the url. This too is often the case, but again not something you can rely on, so don't make any assumptions. + +The url and the UID have no meaningful relationship, so treat both those items as separate unique identifiers. +Finding out if anything changed + +To see if anything in a calendar changed, we simply request the ctag again on the calendar. If the ctag did not change, you still have the latest copy. + +If it did change, you must request all the etags in the entire calendar again: + +REPORT /calendars/johndoe/home/ HTTP/1.1 +Depth: 1 +Prefer: return-minimal +Content-Type: application/xml; charset=utf-8 + + + + + + + + + + + + +Note that this last request is extremely similar to a previous one, but we are only asking fo the etag, not the calendar-data. + +The reason for this, is that calendars can be rather huge. It will save a TON of bandwidth to only check the etag first. + +HTTP/1.1 207 Multi-status +Content-Type: application/xml; charset=utf-8 + + + + /calendars/johndoe/home/132456762153245.ics + + + "xxxx-xxx" + + HTTP/1.1 200 OK + + + + /calendars/johndoe/home/fancy-caldav-client-1234253678.ics + + + "5-12" + + HTTP/1.1 200 OK + + + + +Judging from this last request, 3 things have changed: + + The etag for the task has changed, so the contents must be different + There's a new url, some other client must have added an object + One object is missing, something must have deleted it. + +So based on those 3 items we know that we need to delete an object from our local list, and fetch the contents for the new item, and the updated one. + +To fetch the data for these, you can simply issue GET requests: + +GET /calendars/johndoe/home/132456762153245.ics HTTP/1.1 + +But, because in a worst-case scenario this could result in a LOT of GET requests we can do a 'multiget'. + +REPORT /calendars/johndoe/home/ HTTP/1.1 +Depth: 1 +Prefer: return-minimal +Content-Type: application/xml; charset=utf-8 + + + + + + + /calendars/johndoe/home/132456762153245.ics + /calendars/johndoe/home/fancy-caldav-client-1234253678.ics + + +This request will simply return a multi-status again with the calendar-data and etag. +A small note about application design + +If you read this far and understood what's been said, you may have realized that it's a bit cumbersome to have a separate step for the initial sync, and subsequent updates. + +It would totally be possible to skip the 'initial sync', and just use calendar-query and calendar-multiget REPORTS for the initial sync as well. +Updating a calendar object + +Updating a calendar object is rather simple: + +PUT /calendars/johndoe/home/132456762153245.ics HTTP/1.1 +Content-Type: text/calendar; charset=utf-8 +If-Match: "2134-314" + +BEGIN:VCALENDAR +.... +END:VCALENDAR + +A response to this will be something like this: + +HTTP/1.1 204 No Content +ETag: "2134-315" + +The update gave us back the new ETag. SabreDAV gives this ETag on updates back most of the time, but not always. + +There are cases where the caldav server must modify the iCalendar object right after storage. In those cases an ETag will not be returned, and you should issue a GET request immediately to get the correct object. + +A few notes: + + You must not change the UID of the original object + Every object should hold only 1 event or task. + You cannot change an VEVENT into a VTODO. + +Creating a calendar object + +Creating a calendar object is almost identical, except that you don't have a url yet to a calendar object. + +Instead, it is up to you to determine the new url. + +PUT /calendars/johndoe/home/somerandomstring.ics HTTP/1.1 +Content-Type: text/calendar; charset=utf-8 + +BEGIN:VCALENDAR +.... +END:VCALENDAR + +A response to this will be something like this: + +HTTP/1.1 201 Created +ETag: "21345-324" + +Similar to updating, an ETag is often returned, but there are cases where this is not true. +Deleting a calendar object + +Deleting is simple enough: + +DELETE /calendars/johndoe/home/132456762153245.ics HTTP/1.1 +If-Match: "2134-314" + +Speeding up Sync with WebDAV-Sync + +WebDAV-Sync is a protocol extension that is defined in rfc6578. Because this extension was defined later, some servers may not support this yet. + +SabreDAV supports this since 2.0. + +WebDAV-Sync allows a client to ask just for calendars that have changed. The process on a high-level is as follows: + + Client requests sync-token from server. + Server reports token 15. + Some time passes. + Client does a Sync REPORT on an calendar, and supplied token 15. + Server returns vcard urls that have changed or have been deleted and returns token 17. + +As you can see, after the initial sync, only items that have been created, modified or deleted will ever be sent. + +This has a lot of advantages. The transmitted xml bodies can generally be a lot shorter, and is also easier on both client and server in terms of memory and CPU usage, because only a limited set of items will have to be compared. + +It's important to note, that a client should only do Sync operations, if the server reports that it has support for it. The quickest way to do so, is to request {DAV}sync-token on the calendar you wish to sync. + +Technically, a server may support 'sync' on one calendar, and it may not support it on another, although this is probably rare. +Getting the first sync-token + +Initially, we just request a sync token when asking for calendar information: + +PROPFIND /calendars/johndoe/home/ HTTP/1.1 +Depth: 0 +Content-Type: application/xml; charset=utf-8 + + + + + + + + + +This would return something as follows: + + + + /calendars/johndoe/home/ + + + My calendar + 3145 + http://sabredav.org/ns/sync-token/3145 + + HTTP/1.1 200 OK + + + + +As you can see, the sync-token is a url. It always should be a url. Even though a number appears in the url, you are not allowed to attach any meaning to that url. Some servers may have use an increasing number, another server may use a completely random string. +Receiving changes + +After a sync token has been obtained, and the client already has the initial copy of the calendar, the client is able to request all changes since the token was issued. + +This is done with a REPORT request that may look like this: + +REPORT /calendars/johndoe/home/ HTTP/1.1 +Host: dav.example.org +Content-Type: application/xml; charset="utf-8" + + + + http://sabredav.org/ns/sync/3145 + 1 + + + + + +This requests all the changes since sync-token identified by http://sabredav.org/ns/sync/3145, and for the calendar objects that have been added or modified, we're requesting the etag. + +The response to a query like this is another multistatus xml body. Example: + +HTTP/1.1 207 Multi-Status +Content-Type: application/xml; charset="utf-8" + + + + + /calendars/johndoe/home/newevent.ics + + + "33441-34321" + + HTTP/1.1 200 OK + + + + /calendars/johndoe/home/updatedevent.ics + + + "33541-34696" + + HTTP/1.1 200 OK + + + + /calendars/johndoe/home/deletedevent.ics + HTTP/1.1 404 Not Found + + http://sabredav.org/ns/sync/5001 + + +The last response reported two changes: newevent.ics and updatedevent.ics. There's no way to tell from the response wether those cards got created or updated, you, as a client can only infer this based on the vcards you are already aware of. + +The entry with name deletedevent.ics got deleted as indicated by the 404 status. Note that the status element is here a child of d:response when in all previous examples it has been a child of d:propstat. + +The other difference with the other multi-status examples, is that this one has a sync-token element with the latest sync-token. +Caveats + +Note that a server is free to 'forget' any sync-tokens that have been previously issued. In this case it may be needed to do a full-sync again. + +In case the supplied sync-token is not recognized by the server, a HTTP error is emitted. SabreDAV emits a 403. +Discovery + +Ideally you will want to make sure that all the calendars in an account are automatically discovered. The best user interface would be to just have to ask for three items: + + Username + Password + Server + +And the server should be as short as possible. This is possible with most servers. + +If, for example a user specified 'dav.example.org' for the server, the first thing you should do is attempt to send a PROPFIND request to https://dav.example.org/. Note that you SHOULD try the https url before the http url. + +This PROPFIND request looks as follows: + +PROPFIND / HTTP/1.1 +Depth: 0 +Prefer: return-minimal +Content-Type: application/xml; charset=utf-8 + + + + + + + +This will return a response such as the following: + +HTTP/1.1 207 Multi-status +Content-Type: application/xml; charset=utf-8 + + + + / + + + + /principals/users/johndoe/ + + + HTTP/1.1 200 OK + + + + +A 'principal' is a user. The url that's being returned, is a url that refers to the current user. On this url you can request additional information about the user. + +What we need from this url, is their 'calendar home'. The calendar home is a collection that contains all of the users' calendars. + +To request that, issue the following request: + +PROPFIND /principals/users/johndoe/ HTTP/1.1 +Depth: 0 +Prefer: return-minimal +Content-Type: application/xml; charset=utf-8 + + + + + + + +This will return a response such as the following: + +HTTP/1.1 207 Multi-status +Content-Type: application/xml; charset=utf-8 + + + + /principals/users/johndoe/ + + + + /calendars/johndoe/ + + + HTTP/1.1 200 OK + + + + +Lastly, to list all the calendars for the user, issue a PROPFIND request with Depth: 1. + +PROPFIND /calendars/johndoe/ HTTP/1.1 +Depth: 1 +Prefer: return-minimal +Content-Type: application/xml; charset=utf-8 + + + + + + + + + + +In that last request, we asked for 4 properties. + +The resourcetype tells us what type of object we're getting back. You must read out the resourcetype and ensure that it contains at least a calendar element in the CalDAV namespace. Other items may be returned, including non- calendar, which your application should ignore. + +The displayname is a human-readable string for the calendarname, the ctag was already covered in an earlier section. + +Lastly, supported-calendar-component-set. This gives us a list of components that the calendar accepts. This could be just VTODO, VEVENT, VJOURNAL or a combination of these three. + +If you are just creating a todo-list application, this means you should only list the calendars that support the VTODO component. + +HTTP/1.1 207 Multi-status +Content-Type: application/xml; charset=utf-8 + + + + /calendars/johndoe/ + + + + + + + HTTP/1.1 200 OK + + + + /calendars/johndoe/home/ + + + + + + + Home calendar + 3145 + + + + + HTTP/1.1 200 OK + + + + /calendars/johndoe/tasks/ + + + + + + + My TODO list + 3345 + + + + + HTTP/1.1 200 OK + + + +*/ diff --git a/Code/Storage/Directory.php b/Code/Storage/Directory.php new file mode 100644 index 000000000..8ab901574 --- /dev/null +++ b/Code/Storage/Directory.php @@ -0,0 +1,952 @@ +ext_path = $ext_path; + // remove "/cloud" from the beginning of the path + $modulename = App::$module; + $this->red_path = ((strpos($ext_path, '/' . $modulename) === 0) ? substr($ext_path, strlen($modulename) + 1) : $ext_path); + if (!$this->red_path) { + $this->red_path = '/'; + } + $this->auth = $auth_plugin; + $this->folder_hash = ''; + $this->getDir(); + + if ($this->auth->browser) { + $this->auth->browser->set_writeable(); + } + } + + private function log() + { + logger('ext_path ' . $this->ext_path, LOGGER_DATA); + logger('os_path ' . $this->os_path, LOGGER_DATA); + logger('red_path ' . $this->red_path, LOGGER_DATA); + } + + /** + * @brief Returns an array with all the child nodes. + * + * @throw "\Sabre\DAV\Exception\Forbidden" + * @return array \\Sabre\\DAV\\INode[] + */ + public function getChildren() + { + logger('children for ' . $this->ext_path, LOGGER_DATA); + $this->log(); + + if (get_config('system', 'block_public') && (!$this->auth->channel_id) && (!$this->auth->observer)) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + if (($this->auth->owner_id) && (!perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'view_storage'))) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + $contents = $this->CollectionData($this->red_path, $this->auth); + return $contents; + } + + /** + * @brief Returns a child by name. + * + * @throw "\Sabre\DAV\Exception\Forbidden" + * @throw "\Sabre\DAV\Exception\NotFound" + * @param string $name + */ + public function getChild($name) + { + logger($name, LOGGER_DATA); + + if (get_config('system', 'block_public') && (!$this->auth->channel_id) && (!$this->auth->observer)) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + if (($this->auth->owner_id) && (!perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'view_storage'))) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + $modulename = App::$module; + if ($this->red_path === '/' && $name === $modulename) { + return new Directory('/' . $modulename, $this->auth); + } + + $x = $this->FileData($this->ext_path . '/' . $name, $this->auth); + if ($x) { + return $x; + } + + throw new DAV\Exception\NotFound('The file with name: ' . $name . ' could not be found.'); + } + + /** + * @brief Returns the name of the directory. + * + * @return string + */ + public function getName() + { + return (basename($this->red_path)); + } + + /** + * @brief Renames the directory. + * + * @param string $name The new name of the directory. + * @return void + * @todo handle duplicate directory name + * + * @throw "\Sabre\DAV\Exception\Forbidden" + */ + public function setName($name) + { + logger('old name ' . basename($this->red_path) . ' -> ' . $name, LOGGER_DATA); + + if ((!$name) || (!$this->auth->owner_id)) { + logger('permission denied ' . $name); + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + if (!perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'write_storage')) { + logger('permission denied ' . $name); + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + list($parent_path,) = \Sabre\Uri\split($this->red_path); + $new_path = $parent_path . '/' . $name; + + $r = q("UPDATE attach SET filename = '%s' WHERE hash = '%s' AND uid = %d", + dbesc($name), + dbesc($this->folder_hash), + intval($this->auth->owner_id) + ); + + $x = attach_syspaths($this->auth->owner_id, $this->folder_hash); + + $y = q("update attach set display_path = '%s' where hash = '%s' and uid = %d", + dbesc($x['path']), + dbesc($this->folder_hash), + intval($this->auth->owner_id) + ); + + $ch = Channel::from_id($this->auth->owner_id); + if ($ch) { + $sync = attach_export_data($ch, $this->folder_hash); + if ($sync) { + Libsync::build_sync_packet($ch['channel_id'], array('file' => array($sync))); + } + } + + $this->red_path = $new_path; + } + + /** + * @brief Creates a new file in the directory. + * + * Data will either be supplied as a stream resource, or in certain cases + * as a string. Keep in mind that you may have to support either. + * + * After successful creation of the file, you may choose to return the ETag + * of the new file here. + * + * @throw "\Sabre\DAV\Exception\Forbidden" + * @param string $name Name of the file + * @param resource|string $data Initial payload + * @return null|string ETag + */ + public function createFile($name, $data = null) + { + logger('create file in directory ' . $name, LOGGER_DEBUG); + + if (!$this->auth->owner_id) { + logger('permission denied ' . $name); + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + if (!perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'write_storage')) { + logger('permission denied ' . $name); + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + $mimetype = z_mime_content_type($name); + + $channel = Channel::from_id($this->auth->owner_id); + + if (!$channel) { + logger('no channel'); + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + $filesize = 0; + $hash = new_uuid(); + + $f = 'store/' . $this->auth->owner_nick . '/' . (($this->os_path) ? $this->os_path . '/' : '') . $hash; + + $direct = null; + + if ($this->folder_hash) { + $r = q("select * from attach where hash = '%s' and is_dir = 1 and uid = %d limit 1", + dbesc($this->folder_hash), + intval($channel['channel_id']) + ); + if ($r) { + $direct = array_shift($r); + } + } + + if (($direct) && (($direct['allow_cid']) || ($direct['allow_gid']) || ($direct['deny_cid']) || ($direct['deny_gid']))) { + $allow_cid = $direct['allow_cid']; + $allow_gid = $direct['allow_gid']; + $deny_cid = $direct['deny_cid']; + $deny_gid = $direct['deny_gid']; + } else { + $allow_cid = $channel['channel_allow_cid']; + $allow_gid = $channel['channel_allow_gid']; + $deny_cid = $channel['channel_deny_cid']; + $deny_gid = $channel['channel_deny_gid']; + } + + $created = $edited = datetime_convert(); + + $r = q("INSERT INTO attach ( aid, uid, hash, creator, filename, folder, os_storage, filetype, filesize, revision, is_photo, content, created, edited, os_path, display_path, allow_cid, allow_gid, deny_cid, deny_gid ) + VALUES ( %d, %d, '%s', '%s', '%s', '%s', '%s', '%s', %d, %d, %d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ", + intval($channel['channel_account_id']), + intval($channel['channel_id']), + dbesc($hash), + dbesc($this->auth->observer), + dbesc($name), + dbesc($this->folder_hash), + intval(1), + dbesc($mimetype), + intval($filesize), + intval(0), + intval(0), + dbesc($f), + dbesc($created), + dbesc($edited), + '', + '', + dbesc($allow_cid), + dbesc($allow_gid), + dbesc($deny_cid), + dbesc($deny_gid) + ); + + // fetch the actual storage paths + + $xpath = attach_syspaths($this->auth->owner_id, $hash); + + if (is_resource($data)) { + $fp = fopen($f, 'wb'); + if ($fp) { + pipe_streams($data, $fp); + fclose($fp); + } + $size = filesize($f); + } else { + $size = file_put_contents($f, $data); + } + + // delete attach entry if file_put_contents() failed + if ($size === false) { + logger('file_put_contents() failed to ' . $f); + attach_delete($channel['channel_id'], $hash); + return; + } + + $is_photo = 0; + $gis = @getimagesize($f); + logger('getimagesize: ' . print_r($gis, true), LOGGER_DATA); + if (($gis) && supported_imagetype($gis[2])) { + $is_photo = 1; + } + + // If we know it's a photo, over-ride the type in case the source system could not determine what it was + + if ($is_photo) { + q("update attach set filetype = '%s' where hash = '%s' and uid = %d", + dbesc($gis['mime']), + dbesc($hash), + intval($channel['channel_id']) + ); + } + + // updates entry with path and filesize + $d = q("UPDATE attach SET filesize = '%s', os_path = '%s', display_path = '%s', is_photo = %d WHERE hash = '%s' AND uid = %d", + dbesc($size), + dbesc($xpath['os_path']), + dbesc($xpath['path']), + intval($is_photo), + dbesc($hash), + intval($channel['channel_id']) + ); + + // update the parent folder's lastmodified timestamp + $e = q("UPDATE attach SET edited = '%s' WHERE hash = '%s' AND uid = %d", + dbesc($edited), + dbesc($this->folder_hash), + intval($channel['channel_id']) + ); + + $maxfilesize = get_config('system', 'maxfilesize'); + if (($maxfilesize) && ($size > $maxfilesize)) { + logger('system maxfilesize exceeded. Deleting uploaded file.'); + attach_delete($channel['channel_id'], $hash); + return; + } + + // check against service class quota + $limit = engr_units_to_bytes(ServiceClass::fetch($channel['channel_id'], 'attach_upload_limit')); + if ($limit !== false) { + $z = q("SELECT SUM(filesize) AS total FROM attach WHERE aid = %d ", + intval($channel['channel_account_id']) + ); + if (($z) && ($z[0]['total'] + $size > $limit)) { + logger('service class limit exceeded for ' . $channel['channel_name'] . ' total usage is ' . $z[0]['total'] . ' limit is ' . userReadableSize($limit)); + attach_delete($channel['channel_id'], $hash); + return; + } + } + + if ($is_photo) { + $album = ''; + if ($this->folder_hash) { + $f1 = q("select filename, display_path from attach WHERE hash = '%s' AND uid = %d", + dbesc($this->folder_hash), + intval($channel['channel_id']) + ); + if ($f1) { + $album = (($f1[0]['display_path']) ? $f1[0]['display_path'] : $f1[0]['filename']); + } + } + + $args = [ + 'resource_id' => $hash, + 'album' => $album, + 'folder' => $this->folder_hash, + 'os_syspath' => $f, + 'os_path' => $xpath['os_path'], + 'display_path' => $xpath['path'], + 'filename' => $name, + 'getimagesize' => $gis, + 'directory' => $direct + ]; + $p = photo_upload($channel, App::get_observer(), $args); + } + + Run::Summon(['Thumbnail', $hash]); + + $sync = attach_export_data($channel, $hash); + + if ($sync) { + Libsync::build_sync_packet($channel['channel_id'], array('file' => array($sync))); + } + } + + /** + * @brief Creates a new subdirectory. + * + * @param string $name the directory to create + * @return void + */ + public function createDirectory($name) + { + logger('create directory ' . $name, LOGGER_DEBUG); + + if ((!$this->auth->owner_id) || (!perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'write_storage'))) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + $channel = Channel::from_id($this->auth->owner_id); + + if ($channel) { + + // When initiated from DAV, set the 'force' flag on attach_mkdir(). This will cause the operation to report success even if the + // folder already exists. + + require_once('include/attach.php'); + $result = attach_mkdir($channel, $this->auth->observer, array('filename' => $name, 'folder' => $this->folder_hash, 'force' => true)); + + if ($result['success']) { + $sync = attach_export_data($channel, $result['data']['hash']); + logger('createDirectory: attach_export_data returns $sync:' . print_r($sync, true), LOGGER_DEBUG); + + if ($sync) { + Libsync::build_sync_packet($channel['channel_id'], array('file' => array($sync))); + } + } else { + logger('error ' . print_r($result, true), LOGGER_DEBUG); + } + } + } + + /** + * @brief delete directory + */ + public function delete() + { + logger('delete file ' . basename($this->red_path), LOGGER_DEBUG); + + if ((!$this->auth->owner_id) || (!perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'write_storage'))) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + if ($this->auth->owner_id !== $this->auth->channel_id) { + if (($this->auth->observer !== $this->data['creator']) || intval($this->data['is_dir'])) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + } + + attach_delete($this->auth->owner_id, $this->folder_hash); + + $channel = Channel::from_id($this->auth->owner_id); + if ($channel) { + $sync = attach_export_data($channel, $this->folder_hash, true); + if ($sync) { + Libsync::build_sync_packet($channel['channel_id'], array('file' => array($sync))); + } + } + } + + + /** + * @brief Checks if a child exists. + * + * @param string $name + * The name to check if it exists. + * @return bool + */ + public function childExists($name) + { + // On /cloud we show a list of available channels. + // @todo what happens if no channels are available? + $modulename = App::$module; + if ($this->red_path === '/' && $name === $modulename) { + //logger('We are at ' $modulename . ' show a channel list', LOGGER_DEBUG); + return true; + } + + $x = $this->FileData($this->ext_path . '/' . $name, $this->auth, true); + //logger('FileData returns: ' . print_r($x, true), LOGGER_DATA); + if ($x) { + return true; + } + return false; + } + + + public function moveInto($targetName, $sourcePath, DAV\INode $sourceNode) + { + + if (!$this->auth->owner_id) { + return false; + } + + if (!($sourceNode->data && $sourceNode->data['hash'])) { + return false; + } + + return attach_move($this->auth->owner_id, $sourceNode->data['hash'], $this->folder_hash); + } + + + /** + * @return void + * @todo add description of what this function does. + * + * @throw "\Sabre\DAV\Exception\NotFound" + */ + public function getDir() + { + + logger('GetDir: ' . $this->ext_path, LOGGER_DEBUG); + $this->auth->log(); + $modulename = App::$module; + + $file = $this->ext_path; + + $x = strpos($file, '/' . $modulename); + if ($x === 0) { + $file = substr($file, strlen($modulename) + 1); + } + + if ((!$file) || ($file === '/')) { + return; + } + + $file = trim($file, '/'); + $path_arr = explode('/', $file); + + if (!$path_arr) + return; + + logger('paths: ' . print_r($path_arr, true), LOGGER_DATA); + + $channel_name = $path_arr[0]; + + $channel = Channel::from_username($channel_name); + + if (!$channel) { + throw new DAV\Exception\NotFound('The file with name: ' . $channel_name . ' could not be found.'); + } + + $channel_id = $channel['channel_id']; + $this->auth->owner_id = $channel_id; + $this->auth->owner_nick = $channel_name; + + $path = '/' . $channel_name; + $folder = ''; + $os_path = ''; + + for ($x = 1; $x < count($path_arr); $x++) { + $r = q("select id, hash, filename, flags, is_dir from attach where folder = '%s' and filename = '%s' and uid = %d and is_dir != 0", + dbesc($folder), + dbesc($path_arr[$x]), + intval($channel_id) + ); + if ($r && intval($r[0]['is_dir'])) { + $folder = $r[0]['hash']; + if (strlen($os_path)) { + $os_path .= '/'; + } + $os_path .= $folder; + $path = $path . '/' . $r[0]['filename']; + } + } + $this->folder_hash = $folder; + $this->os_path = $os_path; + } + + /** + * @brief Returns the last modification time for the directory, as a UNIX + * timestamp. + * + * It looks for the last edited file in the folder. If it is an empty folder + * it returns the lastmodified time of the folder itself, to prevent zero + * timestamps. + * + * @return int last modification time in UNIX timestamp + */ + public function getLastModified() + { + $r = q("SELECT edited FROM attach WHERE folder = '%s' AND uid = %d ORDER BY edited DESC LIMIT 1", + dbesc($this->folder_hash), + intval($this->auth->owner_id) + ); + if (!$r) { + $r = q("SELECT edited FROM attach WHERE hash = '%s' AND uid = %d LIMIT 1", + dbesc($this->folder_hash), + intval($this->auth->owner_id) + ); + if (!$r) + return ''; + } + return datetime_convert('UTC', 'UTC', $r[0]['edited'], 'U'); + } + + + /** + * @brief Array with all Directory and File DAV\\Node items for the given path. + * + * @param string $file path to a directory + * @param BasicAuth &$auth + * @returns null|array \\Sabre\\DAV\\INode[] + * @throw "\Sabre\DAV\Exception\Forbidden" + * @throw "\Sabre\DAV\Exception\NotFound" + */ + public function CollectionData($file, &$auth) + { + $ret = []; + + $x = strpos($file, '/cloud'); + if ($x === 0) { + $file = substr($file, 6); + } + + // return a list of channel if we are not inside a channel + if ((!$file) || ($file === '/')) { + return $this->ChannelList($auth); + } + + $file = trim($file, '/'); + $path_arr = explode('/', $file); + + if (!$path_arr) { + return null; + } + + $channel_name = $path_arr[0]; + + $channel = Channel::from_username($channel_name); + + if (!$channel) { + return null; + } + + $channel_id = $channel['channel_id']; + $perms = permissions_sql($channel_id); + + $auth->owner_id = $channel_id; + + $path = '/' . $channel_name; + + $folder = ''; + $errors = false; + $permission_error = false; + + for ($x = 1; $x < count($path_arr); $x++) { + $r = q("SELECT id, hash, filename, flags, is_dir FROM attach WHERE folder = '%s' AND filename = '%s' AND uid = %d AND is_dir != 0 $perms LIMIT 1", + dbesc($folder), + dbesc($path_arr[$x]), + intval($channel_id) + ); + if (!$r) { + // path wasn't found. Try without permissions to see if it was the result of permissions. + $errors = true; + $r = q("select id, hash, filename, flags, is_dir from attach where folder = '%s' and filename = '%s' and uid = %d and is_dir != 0 limit 1", + dbesc($folder), + basename($path_arr[$x]), + intval($channel_id) + ); + if ($r) { + $permission_error = true; + } + break; + } + + if ($r && intval($r[0]['is_dir'])) { + $folder = $r[0]['hash']; + $path = $path . '/' . $r[0]['filename']; + } + } + + if ($errors) { + if ($permission_error) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } else { + throw new DAV\Exception\NotFound('A component of the requested file path could not be found.'); + } + } + + // This should no longer be needed since we just returned errors for paths not found + if ($path !== '/' . $file) { + logger("Path mismatch: $path !== /$file"); + return NULL; + } + + $prefix = ''; + + if (!array_key_exists('cloud_sort', $_SESSION)) + $_SESSION['cloud_sort'] = 'name'; + + switch ($_SESSION['cloud_sort']) { + case 'size': + $suffix = ' order by is_dir desc, filesize asc '; + break; + // The following provides inconsistent results for directories because we re-calculate the date for directories based on the most recent change + case 'date': + $suffix = ' order by is_dir desc, edited asc '; + break; + case 'name': + default: + $suffix = ' order by is_dir desc, filename asc '; + break; + } + + $r = q("select $prefix id, uid, hash, filename, filetype, filesize, revision, folder, flags, is_dir, created, edited from attach where folder = '%s' and uid = %d $perms $suffix", + dbesc($folder), + intval($channel_id) + ); + + foreach ($r as $rr) { + if (App::$module === 'cloud' && (strpos($rr['filename'], '.') === 0) && (!get_pconfig($channel_id, 'system', 'show_dot_files'))) { + continue; + } + + // @FIXME I don't think we use revisions currently in attach structures. + // In case we see any in the wild provide a unique filename. This + // name may or may not be accessible + + if ($rr['revision']) { + $rr['filename'] .= '-' . $rr['revision']; + } + + // logger('filename: ' . $rr['filename'], LOGGER_DEBUG); + if (intval($rr['is_dir'])) { + $ret[] = new Directory($path . '/' . $rr['filename'], $auth); + } else { + $ret[] = new File($path . '/' . $rr['filename'], $rr, $auth); + } + } + + return $ret; + } + + + /** + * @brief Returns an array with viewable channels. + * + * Get a list of Directory objects with all the channels where the visitor + * has view_storage perms. + * + * + * @param BasicAuth &$auth + * @return array Directory[] + */ + public function ChannelList(&$auth) + { + $ret = []; + + $disabled = intval(get_config('system', 'cloud_disable_siteroot', true)); + + $r = q("SELECT channel_id, channel_address, profile.publish FROM channel left join profile on profile.uid = channel.channel_id WHERE channel_removed = 0 AND channel_system = 0 AND (channel_pageflags & %d) = 0 and profile.is_default = 1", + intval(PAGE_HIDDEN) + ); + if ($r) { + foreach ($r as $rr) { + if ((perm_is_allowed($rr['channel_id'], $auth->observer, 'view_storage') && $rr['publish']) || $rr['channel_id'] == $this->auth->channel_id) { + logger('found channel: /cloud/' . $rr['channel_address'], LOGGER_DATA); + if ($disabled) { + $conn = q("select abook_id from abook where abook_channel = %d and abook_xchan = '%s' and abook_pending = 0", + intval($rr['channel_id']), + dbesc($auth->observer) + ); + if (!$conn) { + continue; + } + } + + $ret[] = new Directory($rr['channel_address'], $auth); + } + } + } + return $ret; + } + + + /** + * @brief + * + * @param string $file + * path to file or directory + * @param BasicAuth &$auth + * @param bool $test (optional) enable test mode + * @return File|Directory|bool|null + * @throw "\Sabre\DAV\Exception\Forbidden" + */ + public function FileData($file, &$auth, $test = false) + { + logger($file . (($test) ? ' (test mode) ' : ''), LOGGER_DATA); + + $x = strpos($file, '/cloud'); + if ($x === 0) { + $file = substr($file, 6); + } else { + $x = strpos($file, '/dav'); + if ($x === 0) + $file = substr($file, 4); + } + + if ((!$file) || ($file === '/')) { + return new Directory('/', $auth); + } + + $file = trim($file, '/'); + + $path_arr = explode('/', $file); + + if (!$path_arr) + return null; + + $channel_name = $path_arr[0]; + + $r = q("select channel_id from channel where channel_address = '%s' limit 1", + dbesc($channel_name) + ); + + if (!$r) + return null; + + $channel_id = $r[0]['channel_id']; + + $path = '/' . $channel_name; + + $auth->owner_id = $channel_id; + + $permission_error = false; + + $folder = ''; + + require_once('include/security.php'); + $perms = permissions_sql($channel_id); + + $errors = false; + + for ($x = 1; $x < count($path_arr); $x++) { + $r = q("select id, hash, filename, flags, is_dir from attach where folder = '%s' and filename = '%s' and uid = %d and is_dir != 0 $perms", + dbesc($folder), + dbesc($path_arr[$x]), + intval($channel_id) + ); + + if ($r && intval($r[0]['is_dir'])) { + $folder = $r[0]['hash']; + $path = $path . '/' . $r[0]['filename']; + } + if (!$r) { + $r = q("select id, uid, hash, filename, filetype, filesize, revision, folder, flags, is_dir, os_storage, created, edited from attach + where folder = '%s' and filename = '%s' and uid = %d $perms order by filename limit 1", + dbesc($folder), + dbesc(basename($file)), + intval($channel_id) + ); + } + if (!$r) { + $errors = true; + $r = q("select id, uid, hash, filename, filetype, filesize, revision, folder, flags, is_dir, os_storage, created, edited from attach + where folder = '%s' and filename = '%s' and uid = %d order by filename limit 1", + dbesc($folder), + dbesc(basename($file)), + intval($channel_id) + ); + if ($r) + $permission_error = true; + } + } + + if ($path === '/' . $file) { + if ($test) + return true; + // final component was a directory. + return new Directory($file, $auth); + } + + if ($errors) { + logger('not found ' . $file); + if ($test) + return false; + if ($permission_error) { + logger('permission error ' . $file); + throw new DAV\Exception\Forbidden('Permission denied.'); + } + return; + } + + if ($r) { + if ($test) + return true; + + if (intval($r[0]['is_dir'])) { + return new Directory($path . '/' . $r[0]['filename'], $auth); + } else { + return new File($path . '/' . $r[0]['filename'], $r[0], $auth); + } + } + return false; + } + + public function getQuotaInfo() + { + + /** + * Returns the quota information + * + * This method MUST return an array with 2 values, the first being the total used space, + * the second the available space (in bytes) + */ + + $used = 0; + $limit = 0; + $free = 0; + + if ($this->auth->owner_id) { + $channel = Channel::from_id($this->auth->owner_id); + if ($channel) { + $r = q("SELECT SUM(filesize) AS total FROM attach WHERE aid = %d", + intval($channel['channel_account_id']) + ); + $used = (($r) ? (float)$r[0]['total'] : 0); + $limit = (float)engr_units_to_bytes(ServiceClass::fetch($this->auth->owner_id, 'attach_upload_limit')); + if ($limit) { + // Don't let the result go negative + $free = (($limit > $used) ? $limit - $used : 0); + } + } + } + + if (!$limit) { + $free = disk_free_space('store'); + $used = disk_total_space('store') - $free; + } + + // prevent integer overflow on 32-bit systems + + if ($used > (float)PHP_INT_MAX) + $used = PHP_INT_MAX; + if ($free > (float)PHP_INT_MAX) + $free = PHP_INT_MAX; + + return [(int)$used, (int)$free]; + + } + +} diff --git a/Code/Storage/File.php b/Code/Storage/File.php new file mode 100644 index 000000000..99e8d69f4 --- /dev/null +++ b/Code/Storage/File.php @@ -0,0 +1,446 @@ +name = $name; + $this->data = $data; + $this->auth = $auth; + } + + /** + * @brief Returns the name of the file. + * + * @return string + */ + + public function getName() { + return basename($this->name); + } + + /** + * @brief Renames the file. + * + * @throw "\Sabre\DAV\Exception\Forbidden" + * @param string $newName The new name of the file. + * @return void + */ + + public function setName($newName) { + logger('old name ' . basename($this->name) . ' -> ' . $newName, LOGGER_DATA); + + if ((! $newName) || (! $this->auth->owner_id) || (! perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'write_storage'))) { + logger('permission denied '. $newName); + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + $newName = str_replace('/', '%2F', $newName); + + $r = q("UPDATE attach SET filename = '%s' WHERE hash = '%s' AND id = %d", + dbesc($newName), + dbesc($this->data['hash']), + intval($this->data['id']) + ); + + $x = attach_syspaths($this->auth->owner_id,$this->data['hash']); + + $y = q("update attach set display_path = '%s where hash = '%s' and uid = %d", + dbesc($x['path']), + dbesc($this->data['hash']), + intval($this->auth->owner_id) + ); + + if ($this->data->is_photo) { + $r = q("update photo set filename = '%s', display_path = '%s' where resource_id = '%s' and uid = %d", + dbesc($newName), + dbesc($x['path']), + dbesc($this->data['hash']), + intval($this->auth->owner_id) + ); + } + + $ch = Channel::from_id($this->auth->owner_id); + if ($ch) { + $sync = attach_export_data($ch,$this->data['hash']); + if ($sync) { + Libsync::build_sync_packet($ch['channel_id'], [ 'file' => [ $sync ] ]); + } + } + } + + /** + * @brief Updates the data of the file. + * + * @param resource $data + * @return void + */ + + public function put($data) { + logger('put file: ' . basename($this->name), LOGGER_DEBUG); + $size = 0; + + + if ((! $this->auth->owner_id) || (! perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'write_storage'))) { + logger('permission denied for put operation'); + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + $channel = Channel::from_id($this->auth->owner_id); + + if (! $channel) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + $is_photo = false; + $album = ''; + $os_path = ''; + + + // This hidden config allows you to protect your dav contents from cryptolockers by preventing over-write + // and delete from a networked operating system. In this case you are only allowed to over-write the file + // if it is empty. Some DAV clients create the file and then store the contents so these would be allowed. + + if (get_pconfig($this->auth->owner_id,'system','os_delete_prohibit') && App::$module == 'dav') { + $r = q("select filesize from attach where hash = '%s' and uid = %d limit 1", + dbesc($this->data['hash']), + intval($channel['channel_id']) + ); + if ($r && intval($r[0]['filesize'])) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + } + + $r = q("SELECT flags, folder, os_storage, os_path, display_path, filename, is_photo FROM attach WHERE hash = '%s' AND uid = %d LIMIT 1", + dbesc($this->data['hash']), + intval($channel['channel_id']) + ); + if ($r) { + + $os_path = $r[0]['os_path']; + $display_path = $r[0]['display_path']; + $filename = $r[0]['filename']; + $folder_hash = $r[0]['folder']; + + if (intval($r[0]['os_storage'])) { + $d = q("select folder, content from attach where hash = '%s' and uid = %d limit 1", + dbesc($this->data['hash']), + intval($channel['channel_id']) + ); + if ($d) { + if ($d[0]['folder']) { + $f1 = q("select * from attach where is_dir = 1 and hash = '%s' and uid = %d limit 1", + dbesc($d[0]['folder']), + intval($channel['channel_id']) + ); + if ($f1) { + $album = $f1[0]['filename']; + $direct = $f1[0]; + } + } + $fname = dbunescbin($d[0]['content']); + if (strpos($fname,'store/') === false) { + $f = 'store/' . $this->auth->owner_nick . '/' . $fname ; + } + else { + $f = $fname; + } + + if (is_resource($data)) { + $fp = fopen($f,'wb'); + if ($fp) { + pipe_streams($data,$fp); + fclose($fp); + } + } + else { + file_put_contents($f, $data); + } + + $size = @filesize($f); + + logger('filename: ' . $f . ' size: ' . $size, LOGGER_DEBUG); + } + $gis = @getimagesize($f); + logger('getimagesize: ' . print_r($gis,true), LOGGER_DATA); + if ($gis && supported_imagetype($gis[2])) { + $is_photo = 1; + } + + // If we know it's a photo, over-ride the type in case the source system could not determine what it was + + if ($is_photo) { + q("update attach set filetype = '%s' where hash = '%s' and uid = %d", + dbesc($gis['mime']), + dbesc($this->data['hash']), + intval($this->data['uid']) + ); + } + + } + else { + // this shouldn't happen any more + $r = q("UPDATE attach SET content = '%s' WHERE hash = '%s' AND uid = %d", + dbescbin(stream_get_contents($data)), + dbesc($this->data['hash']), + intval($this->data['uid']) + ); + $r = q("SELECT length(content) AS fsize FROM attach WHERE hash = '%s' AND uid = %d LIMIT 1", + dbesc($this->data['hash']), + intval($this->data['uid']) + ); + if ($r) { + $size = $r[0]['fsize']; + } + } + } + + // returns now() + $edited = datetime_convert(); + + $d = q("UPDATE attach SET filesize = '%s', is_photo = %d, edited = '%s' WHERE hash = '%s' AND uid = %d", + dbesc($size), + intval($is_photo), + dbesc($edited), + dbesc($this->data['hash']), + intval($channel['channel_id']) + ); + + if ($is_photo) { + $args = [ + 'resource_id' => $this->data['hash'], + 'album' => $album, + 'folder' => $folder_hash, + 'os_syspath' => $f, + 'os_path' => $os_path, + 'display_path' => $display_path, + 'filename' => $filename, + 'getimagesize' => $gis, + 'directory' => $direct + ]; + $p = photo_upload($channel, App::get_observer(), $args); + logger('photo_upload: ' . print_r($p,true), LOGGER_DATA); + } + + // update the folder's lastmodified timestamp + $e = q("UPDATE attach SET edited = '%s' WHERE hash = '%s' AND uid = %d", + dbesc($edited), + dbesc($r[0]['folder']), + intval($channel['channel_id']) + ); + + // @todo do we really want to remove the whole file if an update fails + // because of maxfilesize or quota? + // There is an Exception "InsufficientStorage" or "PaymentRequired" for + // our service class from SabreDAV we could use. + + $maxfilesize = get_config('system', 'maxfilesize'); + if (($maxfilesize) && ($size > $maxfilesize)) { + attach_delete($channel['channel_id'], $this->data['hash']); + return; + } + + $limit = engr_units_to_bytes(ServiceClass::fetch($channel['channel_id'], 'attach_upload_limit')); + if ($limit !== false) { + $x = q("select sum(filesize) as total from attach where aid = %d ", + intval($channel['channel_account_id']) + ); + if (($x) && ($x[0]['total'] + $size > $limit)) { + logger('service class limit exceeded for ' . $channel['channel_name'] . ' total usage is ' . $x[0]['total'] . ' limit is ' . userReadableSize($limit)); + attach_delete($channel['channel_id'], $this->data['hash']); + return; + } + } + + Run::Summon([ 'Thumbnail' , $this->data['hash'] ]); + + $sync = attach_export_data($channel,$this->data['hash']); + + if ($sync) { + Libsync::build_sync_packet($channel['channel_id'],array('file' => array($sync))); + } + } + + /** + * @brief Returns the raw data. + * + * @return string || resource + */ + + public function get() { + logger('get file ' . basename($this->name), LOGGER_DEBUG); + logger('os_path: ' . $this->os_path, LOGGER_DATA); + + $r = q("SELECT content, flags, os_storage, filename, filetype FROM attach WHERE hash = '%s' AND uid = %d LIMIT 1", + dbesc($this->data['hash']), + intval($this->data['uid']) + ); + if ($r) { + // @todo this should be a global definition + $unsafe_types = array('text/html', 'text/css', 'application/javascript', 'image/svg+xml'); + + if (in_array($r[0]['filetype'], $unsafe_types) && (!Channel::codeallowed($this->data['uid']))) { + header('Content-Disposition: attachment; filename="' . $r[0]['filename'] . '"'); + header('Content-type: ' . $r[0]['filetype']); + } + + if (intval($r[0]['os_storage'])) { + $x = dbunescbin($r[0]['content']); + if (strpos($x,'store') === false) { + $f = 'store/' . $this->auth->owner_nick . '/' . (($this->os_path) ? $this->os_path . '/' : '') . $x; + } + else { + $f = $x; + } + return @fopen($f, 'rb'); + } + return dbunescbin($r[0]['content']); + } + } + + /** + * @brief Returns the ETag for a file. + * + * An ETag is a unique identifier representing the current version of the file. + * If the file changes, the ETag MUST change. + * The ETag is an arbitrary string, but MUST be surrounded by double-quotes. + * + * Return null if the ETag can not effectively be determined. + * + * @return null|string + */ + public function getETag() { + $ret = null; + if ($this->data['hash']) { + $ret = '"' . $this->data['hash'] . '"'; + } + return $ret; + } + + /** + * @brief Returns the mime-type for a file. + * + * If null is returned, we'll assume application/octet-stream + * + * @return mixed + */ + + public function getContentType() { + return $this->data['filetype']; + } + + /** + * @brief Returns the size of the node, in bytes. + * + * @return int + * filesize in bytes + */ + public function getSize() { + return intval($this->data['filesize']); + } + + /** + * @brief Returns the last modification time for the file, as a unix + * timestamp. + * + * @return int last modification time in UNIX timestamp + */ + + public function getLastModified() { + return datetime_convert('UTC', 'UTC', $this->data['edited'], 'U'); + } + + /** + * @brief Delete the file. + * + * This method checks the permissions and then calls attach_delete() function + * to actually remove the file. + * + * @throw "\Sabre\DAV\Exception\Forbidden" + */ + public function delete() { + logger('delete file ' . basename($this->name), LOGGER_DEBUG); + + if ((! $this->auth->owner_id) || (! perm_is_allowed($this->auth->owner_id, $this->auth->observer, 'write_storage'))) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + if ($this->auth->owner_id !== $this->auth->channel_id) { + if (($this->auth->observer !== $this->data['creator']) || intval($this->data['is_dir'])) { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + } + + // This is a subtle solution to crypto-lockers which can wreak havoc on network resources when + // invoked on a dav-mounted filesystem. By setting system.os_delete_prohibit, one can remove files + // via the web interface but from their operating system the filesystem is treated as read-only. + + if (get_pconfig($this->auth->owner_id,'system','os_delete_prohibit') && App::$module == 'dav') { + throw new DAV\Exception\Forbidden('Permission denied.'); + } + + attach_delete($this->auth->owner_id, $this->data['hash']); + + $channel = Channel::from_id($this->auth->owner_id); + if ($channel) { + $sync = attach_export_data($channel, $this->data['hash'], true); + if ($sync) { + Libsync::build_sync_packet($channel['channel_id'], [ 'file' => [ $sync ] ]); + } + } + } +} diff --git a/Code/Storage/GitRepo.php b/Code/Storage/GitRepo.php new file mode 100644 index 000000000..e9c63b280 --- /dev/null +++ b/Code/Storage/GitRepo.php @@ -0,0 +1,171 @@ + + */ +class GitRepo +{ + + public $url = null; + public $name = null; + private $path = null; + private $channel = null; + public $git = null; + private $repoBasePath = null; + + public function __construct($channel = 'sys', $url = null, $clone = false, $name = null, $path = null) + { + + if ($channel === 'sys' && !is_site_admin()) { + logger('Only admin can use channel sys'); + return null; + } + + $this->repoBasePath = 'store/git'; + $this->channel = $channel; + $this->git = new PHPGit(); + + // Allow custom path for repo in the case of , for example + if ($path) { + $this->path = $path; + } else { + $this->path = $this->repoBasePath . "/" . $this->channel . "/" . $this->name; + } + + if ($this->isValidGitRepoURL($url)) { + $this->url = $url; + } + + if ($name) { + $this->name = $name; + } else { + $this->name = $this->getRepoNameFromURL($url); + } + if (!$this->name) { + logger('Error creating GitRepo. No repo name found.'); + return null; + } + + if (is_dir($this->path)) { + // ignore the $url input if it exists + // TODO: Check if the path is either empty or is a valid git repo and error if not + $this->git->setRepository($this->path); + // TODO: get repo metadata + return; + } + + if ($this->url) { + // create the folder and clone the repo at url to that folder if $clone is true + if ($clone) { + if (mkdir($this->path, 0770, true)) { + $this->git->setRepository($this->path); + if (!$this->cloneRepo()) { + // TODO: throw error + logger('git clone failed: ' . json_encode($this->git)); + } + } else { + logger('git repo path could not be created: ' . json_encode($this->git)); + } + } + } + } + + public function initRepo() + { + if (!$this->path) { + return false; + } + try { + return $this->git->init($this->path); + } catch (GitException $ex) { + return false; + } + } + + public function pull() + { + try { + $success = $this->git->pull(); + } catch (GitException $ex) { + return false; + } + return $success; + } + + public function getRepoPath() + { + return $this->path; + } + + public function setRepoPath($directory) + { + if (is_dir($directory)) { + $this->path->$directory; + $this->git->setRepository($directory); + return true; + } + return false; + } + + public function setIdentity($user_name, $user_email) + { + // setup user for commit messages + $this->git->config->set("user.name", $user_name, ['global' => false, 'system' => false]); + $this->git->config->set("user.email", $user_email, ['global' => false, 'system' => false]); + } + + public function cloneRepo() + { + if (validate_url($this->url) && $this->isValidGitRepoURL($this->url) && is_dir($this->path)) { + return $this->git->clone($this->url, $this->path); + } + } + + public function probeRepo() + { + $git = $this->git; + $repo = []; + $repo['remote'] = $git->remote(); + $repo['branches'] = $git->branch(['all' => true]); + $repo['logs'] = $git->log(array('limit' => 50)); + return $repo; + } + + // Commit changes to the repo. Default is to stage all changes and commit everything. + public function commit($msg, $options = []) + { + try { + return $this->git->commit($msg, $options); + } catch (GitException $ex) { + return false; + } + } + + public static function isValidGitRepoURL($url) + { + if (validate_url($url) && strrpos(parse_url($url, PHP_URL_PATH), '.')) { + return true; + } else { + return false; + } + } + + public static function getRepoNameFromURL($url) + { + $urlpath = parse_url($url, PHP_URL_PATH); + $lastslash = strrpos($urlpath, '/') + 1; + $gitext = strrpos($urlpath, '.'); + if ($gitext) { + return substr($urlpath, $lastslash, $gitext - $lastslash); + } else { + return null; + } + } +} diff --git a/Code/Storage/ZotOauth2Pdo.php b/Code/Storage/ZotOauth2Pdo.php new file mode 100644 index 000000000..cf80a7a47 --- /dev/null +++ b/Code/Storage/ZotOauth2Pdo.php @@ -0,0 +1,13 @@ +config; + } +} diff --git a/Code/Text/Tagadelic.php b/Code/Text/Tagadelic.php new file mode 100644 index 000000000..0d306514e --- /dev/null +++ b/Code/Text/Tagadelic.php @@ -0,0 +1,47 @@ +Cover(); + + if ($data['found']) { + $photo = $data['data']; + } + + if ($photo) { + $image = imagecreatefromstring($photo); + $dest = imagecreatetruecolor($width, $height); + $srcwidth = imagesx($image); + $srcheight = imagesy($image); + + imagealphablending($dest, false); + imagesavealpha($dest, true); + imagecopyresampled($dest, $image, 0, 0, 0, 0, $width, $height, $srcwidth, $srcheight); + imagedestroy($image); + imagejpeg($dest, dbunescbin($attach['content']) . '.thumb'); + } + } +} diff --git a/Code/Thumbs/Mp3audio.php b/Code/Thumbs/Mp3audio.php new file mode 100644 index 000000000..191f40734 --- /dev/null +++ b/Code/Thumbs/Mp3audio.php @@ -0,0 +1,49 @@ +readAllTags(); + + $image = $id3->getImage(); + if (is_array($image)) { + $photo = $image[1]; + } + + fclose($fh); + + if ($photo) { + $image = imagecreatefromstring($photo); + $dest = imagecreatetruecolor($width, $height); + $srcwidth = imagesx($image); + $srcheight = imagesy($image); + + imagealphablending($dest, false); + imagesavealpha($dest, true); + imagecopyresampled($dest, $image, 0, 0, 0, 0, $width, $height, $srcwidth, $srcheight); + imagedestroy($image); + imagejpeg($dest, dbunescbin($attach['content']) . '.thumb'); + } + } +} diff --git a/Code/Thumbs/Pdf.php b/Code/Thumbs/Pdf.php new file mode 100644 index 000000000..89bd020e1 --- /dev/null +++ b/Code/Thumbs/Pdf.php @@ -0,0 +1,49 @@ + $t) { + $l = $l + 1; + $x = 3; + $y = ($l * $lsize) + 3 - $fsize; + imagestring($image, 1, $x, $y, $t, $colour); + if (($l * $lsize) >= $height) { + break; + } + } + imagejpeg($image, dbunescbin($attach['content']) . '.thumb'); + } + } +} diff --git a/Code/Thumbs/Video.php b/Code/Thumbs/Video.php new file mode 100644 index 000000000..adee677e6 --- /dev/null +++ b/Code/Thumbs/Video.php @@ -0,0 +1,67 @@ + 0 "); + + if ($r1 && $r2 && $r3) { + return UPDATE_SUCCESS; + } + return UPDATE_FAILED; + } +} diff --git a/Code/Update/_1137.php b/Code/Update/_1137.php new file mode 100644 index 000000000..4a351c529 --- /dev/null +++ b/Code/Update/_1137.php @@ -0,0 +1,16 @@ +0"); + + // this will fix peertube hubloc_url + $r2 = q("UPDATE hubloc SET hubloc_url = LEFT(hubloc_url, POSITION('/account' IN hubloc_url)-1) WHERE POSITION('/account' IN hubloc_url)>0"); + + if ($r1 && $r2) { + q("COMMIT"); + return UPDATE_SUCCESS; + } else { + q("ROLLBACK"); + return UPDATE_FAILED; + } + } +} diff --git a/Code/Update/_1216.php b/Code/Update/_1216.php new file mode 100644 index 000000000..905b9c68a --- /dev/null +++ b/Code/Update/_1216.php @@ -0,0 +1,19 @@ + intval($rv['channel_id']), + 'block_type' => 0, + 'block_entity' => trim($l), + 'block_comment' => t('Added by superblock') + ]); + } + } + } + del_pconfig($rv['channel_id'], 'system', 'blocked'); + } + } + } + return UPDATE_SUCCESS; + } + + + public function verify() + { + + $r = q("select * from pconfig where cat = 'system' and k = 'blocked'"); + if ($r) { + return false; + } + return true; + } +} diff --git a/Code/Update/_1240.php b/Code/Update/_1240.php new file mode 100644 index 000000000..c307cfc8a --- /dev/null +++ b/Code/Update/_1240.php @@ -0,0 +1,43 @@ +in_progress['k'])) { + $this->in_progress['v'] .= ' ' . ltrim($line); + continue; + } + } else { + if (isset($this->in_progress['k'])) { + $this->parsed[] = [$this->in_progress['k'] => $this->in_progress['v']]; + $this->in_progress = []; + } + $key = strtolower(substr($line, 0, strpos($line, ':'))); + if ($key) { + $this->in_progress['k'] = $key; + $this->in_progress['v'] = ltrim(substr($line, strpos($line, ':') + 1)); + } + } + } + if (isset($this->in_progress['k'])) { + $this->parsed[] = [$this->in_progress['k'] => $this->in_progress['v']]; + $this->in_progress = []; + } + } + } + + public function fetch() + { + return $this->parsed; + } + + public function fetcharr() + { + $ret = []; + if ($this->parsed) { + foreach ($this->parsed as $x) { + foreach ($x as $y => $z) { + $ret[$y] = $z; + } + } + } + return $ret; + } +} diff --git a/Code/Web/HTTPSig.php b/Code/Web/HTTPSig.php new file mode 100644 index 000000000..23dd0cc92 --- /dev/null +++ b/Code/Web/HTTPSig.php @@ -0,0 +1,699 @@ +fetcharr(); + $body = $data['body']; + $headers['(request-target)'] = $data['request_target']; + } else { + $headers = []; + $headers['(request-target)'] = strtolower($_SERVER['REQUEST_METHOD']) . ' ' . $_SERVER['REQUEST_URI']; + $headers['content-type'] = $_SERVER['CONTENT_TYPE']; + $headers['content-length'] = $_SERVER['CONTENT_LENGTH']; + + foreach ($_SERVER as $k => $v) { + if (strpos($k, 'HTTP_') === 0) { + $field = str_replace('_', '-', strtolower(substr($k, 5))); + $headers[$field] = $v; + } + } + } + + //logger('SERVER: ' . print_r($_SERVER,true), LOGGER_ALL); + //logger('headers: ' . print_r($headers,true), LOGGER_ALL); + + return $headers; + } + + + // See draft-cavage-http-signatures-10 + + public static function verify($data, $key = '', $keytype = '') + { + + $body = $data; + $headers = null; + + $result = [ + 'signer' => '', + 'portable_id' => '', + 'header_signed' => false, + 'header_valid' => false, + 'content_signed' => false, + 'content_valid' => false + ]; + + + $headers = self::find_headers($data, $body); + + if (!$headers) { + return $result; + } + + if (is_array($body)) { + btlogger('body is array!' . print_r($body, true)); + } + + + $sig_block = null; + + if (array_key_exists('signature', $headers)) { + $sig_block = self::parse_sigheader($headers['signature']); + } elseif (array_key_exists('authorization', $headers)) { + $sig_block = self::parse_sigheader($headers['authorization']); + } + + if (!$sig_block) { + logger('no signature provided.', LOGGER_DEBUG); + return $result; + } + + // Warning: This log statement includes binary data + // logger('sig_block: ' . print_r($sig_block,true), LOGGER_DATA); + + $result['header_signed'] = true; + + $signed_headers = $sig_block['headers']; + if (!$signed_headers) { + $signed_headers = ['date']; + } + $signed_data = ''; + foreach ($signed_headers as $h) { + if (array_key_exists($h, $headers)) { + $signed_data .= $h . ': ' . $headers[$h] . "\n"; + } + if ($h === '(created)') { + if ($sig_block['algorithm'] && (strpos($sig_block['algorithm'], 'rsa') !== false || strpos($sig_block['algorithm'], 'hmac') !== false || strpos($sig_block['algorithm'], 'ecdsa') !== false)) { + logger('created not allowed here'); + return $result; + } + if ((!isset($sig_block['(created)'])) || (!intval($sig_block['(created)'])) || intval($sig_block['(created)']) > time()) { + logger('created in future'); + return $result; + } + $signed_data .= '(created): ' . $sig_block['(created)'] . "\n"; + } + if ($h === '(expires)') { + if ($sig_block['algorithm'] && (strpos($sig_block['algorithm'], 'rsa') !== false || strpos($sig_block['algorithm'], 'hmac') !== false || strpos($sig_block['algorithm'], 'ecdsa') !== false)) { + logger('expires not allowed here'); + return $result; + } + if ((!isset($sig_block['(expires)'])) || (!intval($sig_block['(expires)'])) || intval($sig_block['(expires)']) < time()) { + logger('signature expired'); + return $result; + } + $signed_data .= '(expires): ' . $sig_block['(expires)'] . "\n"; + } + if ($h === 'date') { + $d = new DateTime($headers[$h]); + $d->setTimeZone(new DateTimeZone('UTC')); + $dplus = datetime_convert('UTC', 'UTC', 'now + 1 day'); + $dminus = datetime_convert('UTC', 'UTC', 'now - 1 day'); + $c = $d->format('Y-m-d H:i:s'); + if ($c > $dplus || $c < $dminus) { + logger('bad time: ' . $c); + return $result; + } + } + } + $signed_data = rtrim($signed_data, "\n"); + + $algorithm = null; + + if ($sig_block['algorithm'] === 'rsa-sha256') { + $algorithm = 'sha256'; + } + if ($sig_block['algorithm'] === 'rsa-sha512') { + $algorithm = 'sha512'; + } + + if (!array_key_exists('keyId', $sig_block)) { + return $result; + } + + $result['signer'] = $sig_block['keyId']; + + $fkey = self::get_key($key, $keytype, $result['signer']); + + if ($sig_block['algorithm'] === 'hs2019') { + if (isset($fkey['algorithm'])) { + if (strpos($fkey['algorithm'], 'rsa-sha256') !== false) { + $algorithm = 'sha256'; + } + if (strpos($fkey['algorithm'], 'rsa-sha512') !== false) { + $algorithm = 'sha512'; + } + } + } + + + if (!($fkey && $fkey['public_key'])) { + return $result; + } + + $x = Crypto::verify($signed_data, $sig_block['signature'], $fkey['public_key'], $algorithm); + + logger('verified: ' . $x, LOGGER_DEBUG); + + if (!$x) { + // try again, ignoring the local actor (xchan) cache and refetching the key + // from its source + + $fkey = self::get_key($key, $keytype, $result['signer'], true); + + if ($fkey && $fkey['public_key']) { + $y = Crypto::verify($signed_data, $sig_block['signature'], $fkey['public_key'], $algorithm); + logger('verified: (cache reload) ' . $x, LOGGER_DEBUG); + } + + if (!$y) { + logger('verify failed for ' . $result['signer'] . ' alg=' . $algorithm . (($fkey['public_key']) ? '' : ' no key')); + $sig_block['signature'] = base64_encode($sig_block['signature']); + logger('affected sigblock: ' . print_r($sig_block, true)); + logger('headers: ' . print_r($headers, true)); + logger('server: ' . print_r($_SERVER, true)); + return $result; + } + } + + $result['portable_id'] = $fkey['portable_id']; + $result['header_valid'] = true; + + if (in_array('digest', $signed_headers)) { + $result['content_signed'] = true; + $digest = explode('=', $headers['digest'], 2); + if ($digest[0] === 'SHA-256') { + $hashalg = 'sha256'; + } + if ($digest[0] === 'SHA-512') { + $hashalg = 'sha512'; + } + + if (base64_encode(hash($hashalg, $body, true)) === $digest[1]) { + $result['content_valid'] = true; + } + + logger('Content_Valid: ' . (($result['content_valid']) ? 'true' : 'false')); + if (!$result['content_valid']) { + logger('invalid content signature: data ' . print_r($data, true)); + logger('invalid content signature: headers ' . print_r($headers, true)); + logger('invalid content signature: body ' . print_r($body, true)); + } + } + + return $result; + } + + public static function get_key($key, $keytype, $id, $force = false) + { + + if ($key) { + if (function_exists($key)) { + return $key($id); + } + return ['public_key' => $key]; + } + + if ($keytype === 'zot6') { + $key = self::get_zotfinger_key($id, $force); + if ($key) { + return $key; + } + } + + + if (strpos($id, '#') === false) { + $key = self::get_webfinger_key($id, $force); + if ($key) { + return $key; + } + } + + $key = self::get_activitystreams_key($id, $force); + return $key; + } + + + public static function convertKey($key) + { + + if (strstr($key, 'RSA ')) { + return Keyutils::rsatopem($key); + } elseif (substr($key, 0, 5) === 'data:') { + return Keyutils::convertSalmonKey($key); + } else { + return $key; + } + } + + + /** + * @brief + * + * @param string $id + * @return bool|string + * false if no pub key found, otherwise return the pub key + */ + + public static function get_activitystreams_key($id, $force = false) + { + + // Check the local cache first, but remove any fragments like #main-key since these won't be present in our cached data + + $cache_url = ((strpos($id, '#')) ? substr($id, 0, strpos($id, '#')) : $id); + + // $force is used to ignore the local cache and only use the remote data; for instance the cached key might be stale + + if (!$force) { + $x = q( + "select * from xchan left join hubloc on xchan_hash = hubloc_hash where ( hubloc_addr = '%s' or hubloc_id_url = '%s' or hubloc_hash = '%s') order by hubloc_id desc", + dbesc(str_replace('acct:', '', $cache_url)), + dbesc($cache_url), + dbesc($cache_url) + ); + + if ($x) { + $best = Libzot::zot_record_preferred($x); + } + + if ($best && $best['xchan_pubkey']) { + return ['portable_id' => $best['xchan_hash'], 'public_key' => $best['xchan_pubkey'], 'algorithm' => get_xconfig($best['xchan_hash'], 'system', 'signing_algorithm'), 'hubloc' => $best]; + } + } + + // The record wasn't in cache. Fetch it now. + + $r = Activity::fetch($id); + $signatureAlgorithm = EMPTY_STR; + + if ($r) { + if (array_key_exists('publicKey', $r) && array_key_exists('publicKeyPem', $r['publicKey']) && array_key_exists('id', $r['publicKey'])) { + if ($r['publicKey']['id'] === $id || $r['id'] === $id) { + $portable_id = ((array_key_exists('owner', $r['publicKey'])) ? $r['publicKey']['owner'] : EMPTY_STR); + + // the w3c sec context has conflicting names and no defined values for this property except + // "http://www.w3.org/2000/09/xmldsig#rsa-sha1" + + // Since the names conflict, it could mess up LD-signatures but we will accept both, and at this + // time we will only look for the substrings 'rsa-sha256' and 'rsa-sha512' within those properties. + // We will also accept a toplevel 'sigAlgorithm' regardless of namespace with the same constraints. + // Default to rsa-sha256 if we can't figure out. If they're sending 'hs2019' we have to + // look for something. + + if (isset($r['publicKey']['signingAlgorithm'])) { + $signatureAlgorithm = $r['publicKey']['signingAlgorithm']; + set_xconfig($portable_id, 'system', 'signing_algorithm', $signatureAlgorithm); + } + if (isset($r['publicKey']['signatureAlgorithm'])) { + $signatureAlgorithm = $r['publicKey']['signatureAlgorithm']; + set_xconfig($portable_id, 'system', 'signing_algorithm', $signatureAlgorithm); + } + + if (isset($r['sigAlgorithm'])) { + $signatureAlgorithm = $r['sigAlgorithm']; + set_xconfig($portable_id, 'system', 'signing_algorithm', $signatureAlgorithm); + } + + return ['public_key' => self::convertKey($r['publicKey']['publicKeyPem']), 'portable_id' => $portable_id, 'algorithm' => (($signatureAlgorithm) ? $signatureAlgorithm : 'rsa-sha256'), 'hubloc' => []]; + } + } + } + + // No key was found + + return false; + } + + + public static function get_webfinger_key($id, $force = false) + { + + if (!$force) { + $x = q( + "select * from xchan left join hubloc on xchan_hash = hubloc_hash where ( hubloc_addr = '%s' or hubloc_id_url = '%s' or hubloc_hash = '%s') order by hubloc_id desc", + dbesc(str_replace('acct:', '', $id)), + dbesc($id), + dbesc($id) + ); + + if ($x) { + $best = Libzot::zot_record_preferred($x); + } + + if ($best && $best['xchan_pubkey']) { + return ['portable_id' => $best['xchan_hash'], 'public_key' => $best['xchan_pubkey'], 'algorithm' => get_xconfig($best['xchan_hash'], 'system', 'signing_algorithm'), 'hubloc' => $best]; + } + } + + $wf = Webfinger::exec($id); + $key = ['portable_id' => '', 'public_key' => '', 'algorithm' => '', 'hubloc' => []]; + + if ($wf) { + if (array_key_exists('properties', $wf) && array_key_exists('https://w3id.org/security/v1#publicKeyPem', $wf['properties'])) { + $key['public_key'] = self::convertKey($wf['properties']['https://w3id.org/security/v1#publicKeyPem']); + } + if (array_key_exists('links', $wf) && is_array($wf['links'])) { + foreach ($wf['links'] as $l) { + if (!(is_array($l) && array_key_exists('rel', $l))) { + continue; + } + if ($l['rel'] === 'magic-public-key' && array_key_exists('href', $l) && $key['public_key'] === EMPTY_STR) { + $key['public_key'] = self::convertKey($l['href']); + } + } + } + } + + return (($key['public_key']) ? $key : false); + } + + + public static function get_zotfinger_key($id, $force = false) + { + + if (!$force) { + $x = q( + "select * from xchan left join hubloc on xchan_hash = hubloc_hash where ( hubloc_addr = '%s' or hubloc_id_url = '%s' ) and and hubloc_network in ('nomad','zot6') order by hubloc_id desc", + dbesc(str_replace('acct:', '', $id)), + dbesc($id) + ); + + if ($x) { + $best = Libzot::zot_record_preferred($x); + } + + if ($best && $best['xchan_pubkey']) { + return ['portable_id' => $best['xchan_hash'], 'public_key' => $best['xchan_pubkey'], 'algorithm' => get_xconfig($best['xchan_hash'], 'system', 'signing_algorithm'), 'hubloc' => $best]; + } + } + + $wf = Webfinger::exec($id); + $key = ['portable_id' => '', 'public_key' => '', 'algorithm' => '', 'hubloc' => []]; + + if ($wf) { + if (array_key_exists('properties', $wf) && array_key_exists('https://w3id.org/security/v1#publicKeyPem', $wf['properties'])) { + $key['public_key'] = self::convertKey($wf['properties']['https://w3id.org/security/v1#publicKeyPem']); + } + if (array_key_exists('links', $wf) && is_array($wf['links'])) { + foreach ($wf['links'] as $l) { + if (!(is_array($l) && array_key_exists('rel', $l))) { + continue; + } + if (in_array($l['rel'], ['http://purl.org/nomad', 'http://purl.org/zot/protocol/6.0']) && array_key_exists('href',$l) && $l['href'] !== EMPTY_STR) { + // The third argument to Zotfinger::exec() tells it not to verify signatures + // Since we're inside a function that is fetching keys with which to verify signatures, + // this is necessary to prevent infinite loops. + + $z = Zotfinger::exec($l['href'], null, false); + if ($z) { + $i = Libzot::import_xchan($z['data']); + if ($i['success']) { + $key['portable_id'] = $i['hash']; + + $x = q( + "select * from xchan left join hubloc on xchan_hash = hubloc_hash where hubloc_id_url = '%s' order by hubloc_id desc limit 1", + dbesc($l['href']) + ); + if ($x) { + $key['hubloc'] = $x[0]; + } + $key['algorithm'] = get_xconfig($i['hash'], 'system', 'signing_algorithm'); + } + } + } + if ($l['rel'] === 'magic-public-key' && array_key_exists('href', $l) && $key['public_key'] === EMPTY_STR) { + $key['public_key'] = self::convertKey($l['href']); + } + } + } + } + + return (($key['public_key']) ? $key : false); + } + + + /** + * @brief + * + * @param array $head + * @param string $prvkey + * @param string $keyid (optional, default '') + * @param bool $auth (optional, default false) + * @param string $alg (optional, default 'sha256') + * @param array $encryption [ 'key', 'algorithm' ] or false + * @return array + */ + public static function create_sig($head, $prvkey, $keyid = EMPTY_STR, $auth = false, $alg = 'sha256', $encryption = false) + { + + $return_headers = []; + + if ($alg === 'sha256') { + $algorithm = 'rsa-sha256'; + } + if ($alg === 'sha512') { + $algorithm = 'rsa-sha512'; + } + + $x = self::sign($head, $prvkey, $alg); + + $headerval = 'keyId="' . $keyid . '",algorithm="' . (($algorithm === 'rsa-sha256') ? 'hs2019' : $algorithm) . '",headers="' . $x['headers'] . '",signature="' . $x['signature'] . '"'; + + if ($encryption) { + $x = Crypto::encapsulate($headerval, $encryption['key'], $encryption['algorithm']); + if (is_array($x)) { + $headerval = 'iv="' . $x['iv'] . '",key="' . $x['key'] . '",alg="' . $x['alg'] . '",data="' . $x['data'] . '"'; + } + } + + if ($auth) { + $sighead = 'Authorization: Signature ' . $headerval; + } else { + $sighead = 'Signature: ' . $headerval; + } + + if ($head) { + foreach ($head as $k => $v) { + // strip the request-target virtual header from the output headers + if ($k === '(request-target)') { + continue; + } + $return_headers[] = $k . ': ' . $v; + } + } + $return_headers[] = $sighead; + + return $return_headers; + } + + /** + * @brief set headers + * + * @param array $headers + * @return void + */ + + public static function set_headers($headers) + { + if ($headers && is_array($headers)) { + foreach ($headers as $h) { + header($h); + } + } + } + + + /** + * @brief + * + * @param array $head + * @param string $prvkey + * @param string $alg (optional) default 'sha256' + * @return array + */ + + public static function sign($head, $prvkey, $alg = 'sha256') + { + + $ret = []; + + $headers = ''; + $fields = ''; + + logger('signing: ' . print_r($head, true), LOGGER_DATA); + + if ($head) { + foreach ($head as $k => $v) { + $headers .= strtolower($k) . ': ' . trim($v) . "\n"; + if ($fields) { + $fields .= ' '; + } + $fields .= strtolower($k); + } + // strip the trailing linefeed + $headers = rtrim($headers, "\n"); + } + + $sig = base64_encode(Crypto::sign($headers, $prvkey, $alg)); + + $ret['headers'] = $fields; + $ret['signature'] = $sig; + + return $ret; + } + + /** + * @brief + * + * @param string $header + * @return array associate array with + * - \e string \b keyID + * - \e string \b algorithm + * - \e array \b headers + * - \e string \b signature + */ + + public static function parse_sigheader($header) + { + + $ret = []; + $matches = []; + + // if the header is encrypted, decrypt with (default) site private key and continue + + if (preg_match('/iv="(.*?)"/ism', $header, $matches)) { + $header = self::decrypt_sigheader($header); + } + + if (preg_match('/keyId="(.*?)"/ism', $header, $matches)) { + $ret['keyId'] = $matches[1]; + } + if (preg_match('/created=([0-9]*)/ism', $header, $matches)) { + $ret['(created)'] = $matches[1]; + } + if (preg_match('/expires=([0-9]*)/ism', $header, $matches)) { + $ret['(expires)'] = $matches[1]; + } + if (preg_match('/algorithm="(.*?)"/ism', $header, $matches)) { + $ret['algorithm'] = $matches[1]; + } + if (preg_match('/headers="(.*?)"/ism', $header, $matches)) { + $ret['headers'] = explode(' ', $matches[1]); + } + if (preg_match('/signature="(.*?)"/ism', $header, $matches)) { + $ret['signature'] = base64_decode(preg_replace('/\s+/', '', $matches[1])); + } + + if (($ret['signature']) && ($ret['algorithm']) && (!$ret['headers'])) { + $ret['headers'] = ['date']; + } + + return $ret; + } + + + /** + * @brief + * + * @param string $header + * @param string $prvkey (optional), if not set use site private key + * @return array|string associative array, empty string if failue + * - \e string \b iv + * - \e string \b key + * - \e string \b alg + * - \e string \b data + */ + + public static function decrypt_sigheader($header, $prvkey = null) + { + + $iv = $key = $alg = $data = null; + + if (!$prvkey) { + $prvkey = get_config('system', 'prvkey'); + } + + $matches = []; + + if (preg_match('/iv="(.*?)"/ism', $header, $matches)) { + $iv = $matches[1]; + } + if (preg_match('/key="(.*?)"/ism', $header, $matches)) { + $key = $matches[1]; + } + if (preg_match('/alg="(.*?)"/ism', $header, $matches)) { + $alg = $matches[1]; + } + if (preg_match('/data="(.*?)"/ism', $header, $matches)) { + $data = $matches[1]; + } + + if ($iv && $key && $alg && $data) { + return Crypto::unencapsulate(['encrypted' => true, 'iv' => $iv, 'key' => $key, 'alg' => $alg, 'data' => $data], $prvkey); + } + + return ''; + } +} diff --git a/Code/Web/HttpMeta.php b/Code/Web/HttpMeta.php new file mode 100644 index 000000000..911ea74c1 --- /dev/null +++ b/Code/Web/HttpMeta.php @@ -0,0 +1,113 @@ +vars = []; + $this->og = []; + $this->ogproperties = []; + } + + //Set Meta Value + // Mode: + // 0 = Default - set if no value currently exists + // 1 = Overwrite - replace existing value(s) + // 2 = Multi - append to the array of values + public function set($property, $value, $mode = 0) + { + $ogallowsmulti = ['image', 'audio', 'video']; + if (strpos($property, 'og:') === 0) { + $count = 0; + foreach ($this->og as $ogk => $ogdata) { + if (strpos($ogdata['property'], $property) === 0) { + if ($mode == 1) { + unset($this->og[$ogk]); + unset($this->ogproperties[$property]); + } elseif ($mode == 0) { + return; + } elseif ($value == $ogdata['value']) { + return; + } else { + $count++; + } + } + } + + if ($value !== null) { + //mode = 1 with value === null will delete the property entirely. + $components = explode(':', $property); + $ogp = $components[1]; + + if (!$count || in_array($ogp, $ogallowsmulti)) { + $this->og[] = ['property' => $property, 'value' => $value]; + $this->ogproperties[$property] = $property; + } + } + } else { + $this->vars[$property] = $value; + } + } + + public function check_required() + { + if ( + in_array('og:title', $this->ogproperties) + && in_array('og:type', $this->ogproperties) + && (in_array('og:image', $this->ogproperties) + || in_array('og:image:url', $this->ogproperties)) + && (array_key_exists('og:url', $this->ogproperties) + || array_key_exists('og:url:secure_url', $this->ogproperties)) + && array_key_exists('og:description', $this->ogproperties) + ) { + return true; + } + return false; + } + + public function get_field($field) + { + if (strpos($field, 'og:') === 0) { + foreach ($this->og as $ogdata) { + if (strpos($ogdata['property'], $field) === 0) { + $arr[$field][] = $ogdata['value']; + } + } + } else { + $arr = $this->vars; + } + + if (isset($arr) && is_array($arr) && array_key_exists($field, $arr) && $arr[$field]) { + return $arr[$field]; + } + return false; + } + + + public function get() + { + // use 'name' for most meta fields, and 'property' for opengraph properties + $o = ''; + if ($this->vars) { + foreach ($this->vars as $k => $v) { + $o .= '' . "\r\n"; + } + } + if ($this->check_required()) { + foreach ($this->og as $ogdata) { + $o .= '' . "\r\n"; + } + } + if ($o) { + return "\r\n" . $o; + } + return $o; + } +} diff --git a/Code/Web/Router.php b/Code/Web/Router.php new file mode 100644 index 000000000..a0ea4d7a7 --- /dev/null +++ b/Code/Web/Router.php @@ -0,0 +1,267 @@ +controller = new $modname(); + App::$module_loaded = true; + } + } + } + } + + + /* + * If the site has a custom module to over-ride the standard module, use it. + * Otherwise, look for the standard program module + */ + + if (!(App::$module_loaded)) { + try { + $filename = 'Code/SiteModule/' . ucfirst($module) . '.php'; + if (file_exists($filename)) { + // This won't be picked up by the autoloader, so load it explicitly + require_once($filename); + $this->controller = new $modname(); + App::$module_loaded = true; + } else { + $filename = 'Code/Module/' . ucfirst($module) . '.php'; + if (file_exists($filename)) { + $this->controller = new $modname(); + App::$module_loaded = true; + } + } + if (!App::$module_loaded) { + throw new Exception('Module not found'); + } + } catch (Exception $e) { + } + } + + $x = [ + 'module' => $module, + 'installed' => App::$module_loaded, + 'controller' => $this->controller + ]; + + /** + * @hooks module_loaded + * Called when a module has been successfully locate to server a URL request. + * This provides a place for plugins to register module handlers which don't otherwise exist + * on the system, or to completely over-ride an existing module. + * If the plugin sets 'installed' to true we won't throw a 404 error for the specified module even if + * there is no specific module file or matching plugin name. + * The plugin should catch at least one of the module hooks for this URL. + * * \e string \b module + * * \e boolean \b installed + * * \e mixed \b controller - The initialized module object + */ + Hook::call('module_loaded', $x); + if ($x['installed']) { + App::$module_loaded = true; + $this->controller = $x['controller']; + } + + /* + * The URL provided does not resolve to a valid module. + */ + + if (!(App::$module_loaded)) { + // undo the setting of a letsencrypt acme-challenge rewrite rule + // which blocks access to our .well-known routes. + // Also provide a config setting for sites that have a legitimate need + // for a custom .htaccess in the .well-known directory; but they should + // make the file read-only so letsencrypt doesn't modify it + + if (strpos($_SERVER['REQUEST_URI'], '/.well-known/') === 0) { + if (file_exists('.well-known/.htaccess') && get_config('system', 'fix_apache_acme', true)) { + rename('.well-known/.htaccess', '.well-known/.htaccess.old'); + } + } + + $x = [ + 'module' => $module, + 'installed' => App::$module_loaded, + 'controller' => $this->controller + ]; + Hook::call('page_not_found', $x); + + // Stupid browser tried to pre-fetch our Javascript img template. + // Don't log the event or return anything - just quietly exit. + + if ((x($_SERVER, 'QUERY_STRING')) && preg_match('/{[0-9]}/', $_SERVER['QUERY_STRING']) !== 0) { + killme(); + } + + if (get_config('system', 'log_404', true)) { + logger("Module {$module} not found.", LOGGER_DEBUG, LOG_WARNING); + logger('index.php: page not found: ' . $_SERVER['REQUEST_URI'] + . ' ADDRESS: ' . $_SERVER['REMOTE_ADDR'] . ' QUERY: ' + . $_SERVER['QUERY_STRING'], LOGGER_DEBUG); + } + + header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); + $tpl = Theme::get_template('404.tpl'); + App::$page['content'] = replace_macros(Theme::get_template('404.tpl'), ['$message' => t('Page not found.')]); + + // pretend this is a module so it will initialise the theme + App::$module = '404'; + App::$module_loaded = true; + App::$error = true; + } + } + } + + /** + * @brief + * + */ + public function Dispatch() + { + + /** + * Call module functions + */ + + if (App::$module_loaded) { + App::$page['page_title'] = App::$module; + $placeholder = ''; + + /* + * No theme has been specified when calling the module_init functions + * For this reason, please restrict the use of templates to those which + * do not provide any presentation details - as themes will not be able + * to over-ride them. + */ + + $arr = ['init' => true, 'replace' => false]; + Hook::call(App::$module . '_mod_init', $arr); + if (!$arr['replace']) { + if ($this->controller && method_exists($this->controller, 'init')) { + $this->controller->init(); + } + } + + /* + * Do all theme initialisation here before calling any additional module functions. + * The module_init function may have changed the theme. + * Additionally any page with a Comanche template may alter the theme. + * So we'll check for those now. + */ + + + /* + * In case a page has overloaded a module, see if we already have a layout defined + * otherwise, if a PDL file exists for this module, use it + * The member may have also created a customised PDL that's stored in the config + */ + + load_pdl(); + + /* + * load current theme info + */ + + $current_theme = Theme::current(); + + $theme_info_file = 'view/theme/' . $current_theme[0] . '/php/theme.php'; + if (file_exists($theme_info_file)) { + require_once($theme_info_file); + } + + if (function_exists(str_replace('-', '_', $current_theme[0]) . '_init')) { + $func = str_replace('-', '_', $current_theme[0]) . '_init'; + $func($a); + } elseif (x(App::$theme_info, 'extends') && file_exists('view/theme/' . App::$theme_info['extends'] . '/php/theme.php')) { + require_once('view/theme/' . App::$theme_info['extends'] . '/php/theme.php'); + if (function_exists(str_replace('-', '_', App::$theme_info['extends']) . '_init')) { + $func = str_replace('-', '_', App::$theme_info['extends']) . '_init'; + $func($a); + } + } + + if (($_SERVER['REQUEST_METHOD'] === 'POST') && (!App::$error) && (!x($_POST, 'auth-params'))) { + Hook::call(App::$module . '_mod_post', $_POST); + if ($this->controller && method_exists($this->controller, 'post')) { + $this->controller->post(); + } + } + + if (!App::$error) { + $arr = ['content' => App::$page['content'], 'replace' => false]; + Hook::call(App::$module . '_mod_content', $arr); + + if (!$arr['replace']) { + if ($this->controller && method_exists($this->controller, 'get')) { + $arr = ['content' => $this->controller->get(), 'replace' => false]; + } + } + Hook::call(App::$module . '_mod_aftercontent', $arr); + App::$page['content'] = (($arr['replace']) ? $arr['content'] : App::$page['content'] . $arr['content']); + } + } + } +} diff --git a/Code/Web/Session.php b/Code/Web/Session.php new file mode 100644 index 000000000..8f461b565 --- /dev/null +++ b/Code/Web/Session.php @@ -0,0 +1,238 @@ +custom_handler = boolval(get_config('system', 'session_custom', false)); + + /* + * Set our session storage functions. + */ + + if ($this->custom_handler) { + /* Custom handler (files, memached, redis..) */ + + $session_save_handler = strval(get_config('system', 'session_save_handler', null)); + $session_save_path = strval(get_config('system', 'session_save_path', null)); + if ($session_save_handler && $session_save_path) { + ini_set('session.save_handler', $session_save_handler); + ini_set('session.save_path', $session_save_path); + } else { + logger('Session save handler or path not set.', LOGGER_NORMAL, LOG_ERR); + } + } else { + $handler = new SessionHandler(); + + $this->handler = $handler; + + $x = session_set_save_handler($handler, false); + if (! $x) { + logger('Session save handler initialisation failed.', LOGGER_NORMAL, LOG_ERR); + } + } + + // Force cookies to be secure (https only) if this site is SSL enabled. + // Must be done before session_start(). + + $arr = session_get_cookie_params(); + + // Note when setting cookies: set the domain to false which creates a single domain + // cookie. If you use a hostname it will create a .domain.com wildcard which will + // have some nasty side effects if you have any other subdomains running the same software. + + session_set_cookie_params([ + 'lifetime' => ((isset($arr['lifetime'])) ? $arr['lifetime'] : 0), + 'path' => ((isset($arr['path'])) ? $arr['path'] : '/'), + 'domain' => (($arr['domain']) ? $arr['domain'] : false), + 'secure' => ((isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') ? true : false), + 'httponly' => ((isset($arr['httponly'])) ? $arr['httponly'] : true), + 'samesite' => 'None' + ]); + + + register_shutdown_function('session_write_close'); + } + + public function start() + { + session_start(); + $this->session_started = true; + } + + /** + * @brief Resets the current session. + * + * @return void + */ + + public function nuke() + { + $this->new_cookie(0); // 0 means delete on browser exit + if ($_SESSION && count($_SESSION)) { + foreach ($_SESSION as $k => $v) { + unset($_SESSION[$k]); + } + } + } + + public function new_cookie($xtime) + { + + $newxtime = (($xtime > 0) ? (time() + $xtime) : 0); + + $old_sid = session_id(); + + $arr = session_get_cookie_params(); + + if (($this->handler || $this->custom_handler) && $this->session_started) { + session_regenerate_id(true); + + // force SessionHandler record creation with the new session_id + // which occurs as a side effect of read() + if (! $this->custom_handler) { + $this->handler->read(session_id()); + } + } else { + logger('no session handler'); + } + + if (x($_COOKIE, 'jsdisabled')) { + setcookie( + 'jsdisabled', + $_COOKIE['jsdisabled'], + [ + 'expires' => $newxtime, + 'path' => '/', + 'domain' => false, + 'secure' => ((isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') ? true : false), + 'httponly' => ((isset($arr['httponly'])) ? $arr['httponly'] : true), + 'samesite' => 'None' + ] + ); + } + + setcookie( + session_name(), + session_id(), + [ + 'expires' => $newxtime, + 'path' => '/', + 'domain' => false, + 'secure' => ((isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') ? true : false), + 'httponly' => ((isset($arr['httponly'])) ? $arr['httponly'] : true), + 'samesite' => 'None' + ] + ); + + $arr = array('expire' => $xtime); + Hook::call('new_cookie', $arr); + } + + public function extend_cookie() + { + + $arr = session_get_cookie_params(); + + // if there's a long-term cookie, extend it + + $xtime = (($_SESSION['remember_me']) ? (60 * 60 * 24 * 365) : 0 ); + + if ($xtime) { + setcookie( + session_name(), + session_id(), + [ + 'expires' => time() + $xtime, + 'path' => '/', + 'domain' => false, + 'secure' => ((isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') ? true : false), + 'httponly' => ((isset($arr['httponly'])) ? $arr['httponly'] : true), + 'samesite' => 'None' + ] + ); + } + + $arr = array('expire' => $xtime); + Hook::call('extend_cookie', $arr); + } + + + public function return_check() + { + + // check a returning visitor against IP changes. + // If the change results in being blocked from re-entry with the current cookie + // nuke the session and logout. + // Returning at all indicates the session is still valid. + + // first check if we're enforcing that sessions can't change IP address + // @todo what to do with IPv6 addresses + + if ($_SESSION['addr'] && $_SESSION['addr'] != $_SERVER['REMOTE_ADDR']) { + logger('SECURITY: Session IP address changed: ' . $_SESSION['addr'] . ' != ' . $_SERVER['REMOTE_ADDR']); + + $partial1 = substr($_SESSION['addr'], 0, strrpos($_SESSION['addr'], '.')); + $partial2 = substr($_SERVER['REMOTE_ADDR'], 0, strrpos($_SERVER['REMOTE_ADDR'], '.')); + + $paranoia = intval(get_pconfig($_SESSION['uid'], 'system', 'paranoia')); + + if (! $paranoia) { + $paranoia = intval(get_config('system', 'paranoia')); + } + + switch ($paranoia) { + case 0: + // no IP checking + break; + case 2: + // check 2 octets + $partial1 = substr($partial1, 0, strrpos($partial1, '.')); + $partial2 = substr($partial2, 0, strrpos($partial2, '.')); + if ($partial1 == $partial2) { + break; + } + case 1: + // check 3 octets + if ($partial1 == $partial2) { + break; + } + case 3: + default: + // check any difference at all + logger('Session address changed. Paranoid setting in effect, blocking session. ' . $_SESSION['addr'] . ' != ' . $_SERVER['REMOTE_ADDR']); + $this->nuke(); + goaway(z_root()); + break; + } + } + return true; + } +} diff --git a/Code/Web/SessionHandler.php b/Code/Web/SessionHandler.php new file mode 100644 index 000000000..bd92518fa --- /dev/null +++ b/Code/Web/SessionHandler.php @@ -0,0 +1,101 @@ +controller = new $modname(); + } + + $routes = Route::get(); + + if ($routes) { + foreach ($routes as $route) { + if (is_array($route) && strtolower($route[1]) === strtolower(argv(0)) . '/' . strtolower(argv($whicharg))) { + include_once($route[0]); + if (class_exists($modname)) { + $this->controller = new $modname(); + } + } + } + } + } + + /** + * @brief + * + * @param string $method + * @return bool|mixed + */ + public function call($method) + { + + if (!$this->controller) { + return false; + } + + if (method_exists($this->controller, $method)) { + return $this->controller->$method(); + } + + return false; + } +} diff --git a/Code/Web/WebServer.php b/Code/Web/WebServer.php new file mode 100644 index 000000000..4eca6e909 --- /dev/null +++ b/Code/Web/WebServer.php @@ -0,0 +1,244 @@ +start_session(); + + $this->set_language(); + + $this->set_identities(); + + $this->initialise_notifications(); + + if (App::$install) { + /* + * During installation, only permit the view module and setup module. + * The view module is required to expand/replace variables in style.css + */ + + if (App::$module !== 'view') { + App::$module = 'setup'; + } + } else { + /* + * check_config() is responsible for running update scripts. These automatically + * update the DB schema whenever we push a new one out. It also checks to see if + * any plugins have been added or removed and reacts accordingly. + */ + + check_config(); + } + + $this->create_channel_links(); + + $this->initialise_content(); + + $Router = new Router(); + $Router->Dispatch(); + + // if the observer is a visitor, add some javascript to the page to let + // the application take them home. + + $this->set_homebase(); + + // now that we've been through the module content, see if the page reported + // a permission problem via session based notifications and if so, a 403 + // response would seem to be in order. + + if (is_array($_SESSION['sysmsg']) && stristr(implode("", $_SESSION['sysmsg']), t('Permission denied'))) { + header($_SERVER['SERVER_PROTOCOL'] . ' 403 ' . t('Permission denied.')); + } + + construct_page(); + + killme(); + } + + private function start_session() + { + + if (App::$session) { + App::$session->start(); + } else { + session_start(); + register_shutdown_function('session_write_close'); + } + } + + private function set_language() + { + + /* + * Determine the language of the interface + */ + + // First use the browser preference, if available. This will fall back to 'en' + // if there is no built-in language support for the preferred languagge + + + App::$language = get_best_language(); + load_translation_table(App::$language, App::$install); + + // See if there's a request to over-ride the language + // store it in the session. + + if (array_key_exists('system_language', $_REQUEST)) { + if (strlen($_REQUEST['system_language'])) { + $_SESSION['language'] = $_REQUEST['system_language']; + } else { + // reset to default if it's an empty string + unset($_SESSION['language']); + } + } + + // If we've over-ridden the language, set it now. + + if ((x($_SESSION, 'language')) && ($_SESSION['language'] !== App::$language)) { + App::$language = $_SESSION['language']; + load_translation_table(App::$language); + } + } + + private function set_identities() + { + + if ((x($_GET, 'zid')) && (! App::$install)) { + App::$query_string = strip_zids(App::$query_string); + if (! local_channel()) { + if ($_SESSION['my_address'] !== $_GET['zid']) { + $_SESSION['my_address'] = $_GET['zid']; + $_SESSION['authenticated'] = 0; + } + Channel::zid_init(); + } + } + + if ((x($_GET, 'zat')) && (! App::$install)) { + App::$query_string = strip_zats(App::$query_string); + if (! local_channel()) { + Channel::zat_init(); + } + } + + if ((x($_REQUEST, 'owt')) && (! App::$install)) { + $token = $_REQUEST['owt']; + App::$query_string = strip_query_param(App::$query_string, 'owt'); + owt_init($token); + } + + if ((x($_SESSION, 'authenticated')) || (x($_POST, 'auth-params')) || (App::$module === 'login')) { + require('include/auth.php'); + } + } + + private function initialise_notifications() + { + if (! x($_SESSION, 'sysmsg')) { + $_SESSION['sysmsg'] = []; + } + + if (! x($_SESSION, 'sysmsg_info')) { + $_SESSION['sysmsg_info'] = []; + } + } + + private function initialise_content() + { + + /* initialise content region */ + + if (! x(App::$page, 'content')) { + App::$page['content'] = EMPTY_STR; + } + + Hook::call('page_content_top', App::$page['content']); + } + + private function create_channel_links() + { + + /* Initialise the Link: response header if this is a channel page. + * This cannot be done inside the channel module because some protocol + * addons over-ride the module functions and these links are common + * to all protocol drivers; thus doing it here avoids duplication. + */ + + if (( App::$module === 'channel' ) && argc() > 1) { + App::$channel_links = [ + [ + 'rel' => 'jrd', + 'type' => 'application/jrd+json', + 'href' => z_root() . '/.well-known/webfinger?f=&resource=acct%3A' . argv(1) . '%40' . App::get_hostname() + ], + + [ + 'rel' => 'alternate', + 'type' => 'application/x-zot+json', + 'href' => z_root() . '/channel/' . argv(1) + ], + + [ + 'rel' => 'alternate', + 'type' => 'application/x-nomad+json', + 'href' => z_root() . '/channel/' . argv(1) + ], + + [ + 'rel' => 'self', + 'type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'href' => z_root() . '/channel/' . argv(1) + ], + + [ + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => z_root() . '/channel/' . argv(1) + ] + ]; + + $x = [ 'channel_address' => argv(1), 'channel_links' => App::$channel_links ]; + Hook::call('channel_links', $x); + App::$channel_links = $x['channel_links']; + header('Link: ' . App::get_channel_links()); + } + } + + private function set_homebase() + { + + // If you're just visiting, let javascript take you home + + if (x($_SESSION, 'visitor_home')) { + $homebase = $_SESSION['visitor_home']; + } elseif (local_channel()) { + $homebase = z_root() . '/channel/' . App::$channel['channel_address']; + } + + if (isset($homebase)) { + App::$page['content'] .= ''; + } + } +} diff --git a/Code/Widget/Activity.php b/Code/Widget/Activity.php new file mode 100644 index 000000000..b62cd9201 --- /dev/null +++ b/Code/Widget/Activity.php @@ -0,0 +1,68 @@ + $v) { + if (!LibBlock::fetch_by_entity(local_channel(), $k)) { + $arr[] = ['author_xchan' => $k, 'total' => $v]; + } + } + usort($arr, 'total_sort'); + xchan_query($arr); + } + + $x = ['entries' => $arr]; + Hook::call('activity_widget', $x); + $arr = $x['entries']; + + if ($arr) { + $o .= '
      '; + $o .= '

      ' . t('Activity', 'widget') . '

      '; + } + return $o; + } +} diff --git a/Code/Widget/Activity_filter.php b/Code/Widget/Activity_filter.php new file mode 100644 index 000000000..659404bc6 --- /dev/null +++ b/Code/Widget/Activity_filter.php @@ -0,0 +1,338 @@ + t('Direct Messages'), + 'icon' => 'envelope-o', + 'url' => z_root() . '/' . $cmd . '/?dm=1', + 'sel' => $dm_active, + 'title' => t('Show direct (private) messages') + ]; + + + $conv_active = ((isset($_GET['conv']) && intval($_GET['conv'])) ? 'active' : ''); + if ($conv_active) { + $filter_active = 'personal'; + } + + $tabs[] = [ + 'label' => t('Personal Posts'), + 'icon' => 'user-circle', + 'url' => z_root() . '/' . $cmd . '/?conv=1', + 'sel' => $conv_active, + 'title' => t('Show posts that mention or involve me') + ]; + + $starred_active = ((isset($_GET['star']) && intval($_GET['star'])) ? 'active' : ''); + if ($starred_active) { + $filter_active = 'star'; + } + + $tabs[] = [ + 'label' => t('Saved Posts'), + 'icon' => 'star', + 'url' => z_root() . '/' . $cmd . '/?star=1', + 'sel' => $starred_active, + 'title' => t('Show posts that I have saved') + ]; + + if (local_channel() && Apps::system_app_installed(local_channel(), 'Drafts')) { + $drafts_active = ((isset($_GET['draft']) && intval($_GET['draft'])) ? 'active' : ''); + if ($drafts_active) { + $filter_active = 'drafts'; + } + + $tabs[] = [ + 'label' => t('Drafts'), + 'icon' => 'floppy-o', + 'url' => z_root() . '/' . $cmd . '/?draft=1', + 'sel' => $drafts_active, + 'title' => t('Show drafts that I have saved') + ]; + } + + if (x($_GET, 'search')) { + $video_active = (($_GET['search'] == 'video]') ? 'active' : ''); + $filter_active = (($events_active) ? 'videos' : 'search'); + } + + $tabs[] = [ + 'label' => t('Videos'), + 'icon' => 'video', + 'url' => z_root() . '/' . $cmd . '/?search=video%5D', + 'sel' => $video_active, + 'title' => t('Show posts that include videos') + ]; + + if (x($_GET, 'verb')) { + $events_active = (($_GET['verb'] == '.Event') ? 'active' : ''); + $polls_active = (($_GET['verb'] == '.Question') ? 'active' : ''); + $filter_active = (($events_active) ? 'events' : 'polls'); + } + + $tabs[] = [ + 'label' => t('Events'), + 'icon' => 'calendar', + 'url' => z_root() . '/' . $cmd . '/?verb=%2EEvent', + 'sel' => $events_active, + 'title' => t('Show posts that include events') + ]; + + $tabs[] = [ + 'label' => t('Polls'), + 'icon' => 'bar-chart', + 'url' => z_root() . '/' . $cmd . '/?verb=%2EQuestion', + 'sel' => $polls_active, + 'title' => t('Show posts that include polls') + ]; + + + $groups = q( + "SELECT * FROM pgrp WHERE deleted = 0 AND uid = %d ORDER BY gname ASC", + intval(local_channel()) + ); + + if ($groups || Apps::system_app_installed(local_channel(), 'Virtual Lists')) { + if ($groups) { + foreach ($groups as $g) { + if (x($_GET, 'gid')) { + $group_active = (($_GET['gid'] == $g['id']) ? 'active' : ''); + $filter_active = 'group'; + } + $gsub[] = [ + 'label' => $g['gname'], + 'icon' => '', + 'url' => z_root() . '/' . $cmd . '/?f=&gid=' . $g['id'], + 'sel' => $group_active, + 'title' => sprintf(t('Show posts related to the %s access list'), $g['gname']) + ]; + } + } + if (Apps::system_app_installed(local_channel(), 'Virtual Lists')) { + foreach ([':1', ':2', ':3'] as $l) { + switch ($l) { + case ':1': + $gname = t('Connections'); + break; + case ':2': + $gname = t('Nomad'); + break; + case ':3': + $gname = t('ActivityPub'); + break; + default: + break; + } + + if (x($_GET, 'gid')) { + $group_active = (($_GET['gid'] == $l) ? 'active' : ''); + $filter_active = 'group'; + } + + $gsub[] = [ + 'label' => $gname, + 'icon' => '', + 'url' => z_root() . '/' . $cmd . '/?f=&gid=' . $l, + 'sel' => $group_active, + 'title' => sprintf(t('Show posts related to the %s access list'), $gname) + ]; + } + } + $tabs[] = [ + 'id' => 'privacy_groups', + 'label' => t('Lists'), + 'icon' => 'users', + 'url' => '#', + 'sel' => (($filter_active == 'group') ? true : false), + 'title' => t('Show my access lists'), + 'sub' => $gsub + ]; + } + + $forums = get_forum_channels(local_channel(), 1); + + if ($forums) { + foreach ($forums as $f) { + if (x($_GET, 'pf') && x($_GET, 'cid')) { + $forum_active = ((x($_GET, 'pf') && $_GET['cid'] == $f['abook_id']) ? 'active' : ''); + $filter_active = 'forums'; + } + $fsub[] = [ + 'label' => $f['xchan_name'], + 'img' => $f['xchan_photo_s'], + 'url' => z_root() . '/' . $cmd . '/?f=&pf=1&cid=' . $f['abook_id'], + 'sel' => $forum_active, + 'title' => t('Show posts to this group'), + 'lock' => ((isset($f['private_forum']) && $f['private_forum']) ? 'lock' : ''), + 'edit' => t('New post'), + 'edit_url' => $f['xchan_url'] + ]; + } + + $tabs[] = [ + 'id' => 'forums', + 'label' => t('Groups'), + 'icon' => 'comments-o', + 'url' => '#', + 'sel' => (($filter_active == 'forums') ? true : false), + 'title' => t('Show groups'), + 'sub' => $fsub + ]; + } + + if (Features::enabled(local_channel(), 'filing')) { + $terms = q( + "select distinct term from term where uid = %d and ttype = %d order by term asc", + intval(local_channel()), + intval(TERM_FILE) + ); + + if ($terms) { + foreach ($terms as $t) { + if (x($_GET, 'file')) { + $file_active = (($_GET['file'] == $t['term']) ? 'active' : ''); + $filter_active = 'file'; + } + $tsub[] = [ + 'label' => $t['term'], + 'icon' => '', + 'url' => z_root() . '/' . $cmd . '/?f=&file=' . $t['term'], + 'sel' => $file_active, + 'title' => sprintf(t('Show posts that I have filed to %s'), $t['term']), + ]; + } + + $tabs[] = [ + 'id' => 'saved_folders', + 'label' => t('Saved Folders'), + 'icon' => 'folder', + 'url' => '#', + 'sel' => (($filter_active == 'file') ? true : false), + 'title' => t('Show filed post categories'), + 'sub' => $tsub + + ]; + } + } + + $ft = get_pconfig(local_channel(), 'system', 'followed_tags', EMPTY_STR); + if (is_array($ft) && $ft) { + foreach ($ft as $t) { + $tag_active = ((isset($_GET['netsearch']) && $_GET['netsearch'] === '#' . $t) ? 'active' : ''); + if ($tag_active) { + $filter_active = 'tags'; + } + + $tsub[] = [ + 'label' => '#' . $t, + 'icon' => '', + 'url' => z_root() . '/' . $cmd . '/?search=' . '%23' . $t, + 'sel' => $tag_active, + 'title' => sprintf(t('Show posts with hashtag %s'), '#' . $t), + ]; + } + + $tabs[] = [ + 'id' => 'followed_tags', + 'label' => t('Followed Hashtags'), + 'icon' => 'bookmark', + 'url' => '#', + 'sel' => (($filter_active == 'tags') ? true : false), + 'title' => t('Show followed hashtags'), + 'sub' => $tsub + ]; + } + + +// if(x($_GET,'search')) { +// $filter_active = 'search'; +// $tabs[] = [ +// 'label' => t('Search'), +// 'icon' => 'search', +// 'url' => z_root() . '/' . $cmd . '/?search=' . $_GET['search'], +// 'sel' => 'active disabled', +// 'title' => t('Panel search') +// ]; +// } + + $name = []; + if (isset($_GET['name']) && $_GET['name']) { + $filter_active = 'name'; + } + $name = [ + 'label' => x($_GET, 'name') ? $_GET['name'] : t('Name'), + 'icon' => 'filter', + 'url' => z_root() . '/' . $cmd . '/', + 'sel' => $filter_active == 'name' ? 'is-valid' : '', + 'title' => '' + ]; + + $reset = []; + if ($filter_active) { + $reset = [ + 'label' => '', + 'icon' => 'remove', + 'url' => z_root() . '/' . $cmd, + 'sel' => '', + 'title' => t('Remove active filter') + ]; + } + + $arr = ['tabs' => $tabs]; + + Hook::call('activity_filter', $arr); + + $o = ''; + + if ($arr['tabs']) { + $content = replace_macros(Theme::get_template('common_pills.tpl'), [ + '$pills' => $arr['tabs'] + ]); + + $o .= replace_macros(Theme::get_template('activity_filter_widget.tpl'), [ + '$title' => t('Stream Filters'), + '$content_id' => 'activity-filter-widget', + '$reset' => $reset, + '$content' => $content, + '$name' => $name + ]); + } + + return $o; + } +} diff --git a/Code/Widget/Admin.php b/Code/Widget/Admin.php new file mode 100644 index 000000000..456d61983 --- /dev/null +++ b/Code/Widget/Admin.php @@ -0,0 +1,75 @@ + array(z_root() . '/admin/site/', t('Site'), 'site'), +// 'profile_photo' => array(z_root() . '/admin/profile_photo', t('Site icon/logo'), 'profile_photo'), +// 'cover_photo' => array(z_root() . '/admin/cover_photo', t('Site photo'), 'cover_photo'), + 'accounts' => array(z_root() . '/admin/accounts/', t('Accounts'), 'accounts', 'pending-update', t('Member registrations waiting for confirmation')), + 'channels' => array(z_root() . '/admin/channels/', t('Channels'), 'channels'), + 'security' => array(z_root() . '/admin/security/', t('Security'), 'security'), +// 'features' => array(z_root() . '/admin/features/', t('Features'), 'features'), + 'addons' => array(z_root() . '/admin/addons/', t('Addons'), 'addons'), + 'themes' => array(z_root() . '/admin/themes/', t('Themes'), 'themes'), + 'queue' => array(z_root() . '/admin/queue', t('Inspect queue'), 'queue'), +// 'profs' => array(z_root() . '/admin/profs', t('Profile Fields'), 'profs'), + 'dbsync' => array(z_root() . '/admin/dbsync/', t('DB updates'), 'dbsync') + ]; + + /* get plugins admin page */ + + $r = q("SELECT * FROM addon WHERE plugin_admin = 1"); + + $plugins = []; + if ($r) { + foreach ($r as $h) { + $plugin = $h['aname']; + $plugins[] = array(z_root() . '/admin/addons/' . $plugin, $plugin, 'plugin'); + // temp plugins with admin + App::$addons_admin[] = $plugin; + } + } + + $logs = array(z_root() . '/admin/logs/', t('Logs'), 'logs'); + + $arr = array('links' => $aside, 'plugins' => $plugins, 'logs' => $logs); + Hook::call('admin_aside', $arr); + + $o .= replace_macros(Theme::get_template('admin_aside.tpl'), array( + '$admin' => $aside, + '$admtxt' => t('Admin'), + '$plugadmtxt' => t('Addon Features'), + '$plugins' => $plugins, + '$logtxt' => t('Logs'), + '$logs' => $logs, + '$h_pending' => t('Member registrations waiting for confirmation'), + '$admurl' => z_root() . '/admin/' + )); + + return $o; + } +} diff --git a/Code/Widget/Affinity.php b/Code/Widget/Affinity.php new file mode 100644 index 000000000..361fb27f7 --- /dev/null +++ b/Code/Widget/Affinity.php @@ -0,0 +1,53 @@ + t('Me'), + 20 => t('Family'), + 40 => t('Friends'), + 60 => t('Peers'), + 80 => t('Connections'), + 99 => t('All') + ); + Hook::call('affinity_labels', $labels); + + $tpl = Theme::get_template('main_slider.tpl'); + $x = replace_macros($tpl, [ + '$cmin' => $cmin, + '$cmax' => $cmax, + '$lbl' => t('Friend zoom in/out'), + '$refresh' => t('Refresh'), + '$labels' => $labels, + ]); + + $arr = array('html' => $x); + Hook::call('main_slider', $arr); + return $arr['html']; + } + return ''; + } +} diff --git a/Code/Widget/Album.php b/Code/Widget/Album.php new file mode 100644 index 000000000..1b21601f0 --- /dev/null +++ b/Code/Widget/Album.php @@ -0,0 +1,117 @@ + $rr['id'], + 'twist' => ' ' . $twist . rand(2, 4), + 'link' => $imagelink, + 'title' => t('View Photo'), + 'src' => z_root() . '/photo/' . $rr['resource_id'] . '-' . $rr['imgscale'] . '.' . $ext, + 'alt' => $imgalt_e, + 'desc' => $desc_e, + 'ext' => $ext, + 'hash' => $rr['resource_id'], + 'unknown' => t('Unknown') + ); + } + } + + + $tpl = Theme::get_template('photo_album.tpl'); + $o .= replace_macros($tpl, array( + '$photos' => $photos, + '$album' => (($title) ? $title : $album), + '$album_id' => rand(), + '$album_edit' => array(t('Edit Album'), $album_edit), + '$can_post' => false, + '$upload' => array(t('Upload'), z_root() . '/photos/' . App::$profile['channel_address'] . '/upload/' . bin2hex($album)), + '$order' => false, + '$upload_form' => $upload_form, + '$usage' => $usage_message + )); + + return $o; + } +} diff --git a/Code/Widget/Appcategories.php b/Code/Widget/Appcategories.php new file mode 100644 index 000000000..db09bc9f9 --- /dev/null +++ b/Code/Widget/Appcategories.php @@ -0,0 +1,62 @@ + 1 && argv(1) === 'available') { + $srchurl .= '/available'; + } + + + $terms = []; + + $r = q( + "select distinct(term.term) + from term join app on term.oid = app.id + where app_channel = %d + and term.uid = app_channel + and term.otype = %d + and term.term != 'nav_featured_app' + and term.term != 'nav_pinned_app' + order by term.term asc", + intval(local_channel()), + intval(TERM_OBJ_APP) + ); + + if ($r) { + foreach ($r as $rr) { + $terms[] = array('name' => $rr['term'], 'selected' => (($selected == $rr['term']) ? 'selected' : '')); + } + + return replace_macros(Theme::get_template('categories_widget.tpl'), array( + '$title' => t('Categories'), + '$desc' => '', + '$sel_all' => (($selected == '') ? 'selected' : ''), + '$all' => t('Everything'), + '$terms' => $terms, + '$base' => $srchurl, + + )); + } + } +} diff --git a/Code/Widget/Appcloud.php b/Code/Widget/Appcloud.php new file mode 100644 index 000000000..a6e45fda0 --- /dev/null +++ b/Code/Widget/Appcloud.php @@ -0,0 +1,15 @@ + 1 && argv(1) === 'available') ? 1 : 0); + return replace_macros(Theme::get_template('appstore.tpl'), [ + '$title' => t('App Collections'), + '$options' => [ + [z_root() . '/apps', t('Installed Apps'), 1 - $store], + [z_root() . '/apps/available', t('Available Apps'), $store] + ] + ]); + } +} diff --git a/Code/Widget/Archive.php b/Code/Widget/Archive.php new file mode 100644 index 000000000..5ae184e16 --- /dev/null +++ b/Code/Widget/Archive.php @@ -0,0 +1,63 @@ + t('Archives'), + '$size' => $visible_years, + '$cutoff_year' => $cutoff_year, + '$cutoff' => $cutoff, + '$url' => $url, + '$style' => $style, + '$showend' => $showend, + '$dates' => $ret + )); + return $o; + } +} diff --git a/Code/Widget/Bookmarkedchats.php b/Code/Widget/Bookmarkedchats.php new file mode 100644 index 000000000..f86f1bfcc --- /dev/null +++ b/Code/Widget/Bookmarkedchats.php @@ -0,0 +1,38 @@ + t('Bookmarked Chatrooms'), + '$rooms' => $r + )); + } +} diff --git a/Code/Widget/Catcloud.php b/Code/Widget/Catcloud.php new file mode 100644 index 000000000..61edbc19a --- /dev/null +++ b/Code/Widget/Catcloud.php @@ -0,0 +1,48 @@ + $rr['term'], 'selected' => (($selected == $rr['term']) ? 'selected' : '')); + } + + return replace_macros(Theme::get_template('categories_widget.tpl'), array( + '$title' => t('Categories'), + '$desc' => '', + '$sel_all' => (($selected == '') ? 'selected' : ''), + '$all' => t('Everything'), + '$terms' => $terms, + '$base' => $baseurl, + + )); + } + return ''; + } + + public static function cardcategories_widget($baseurl, $selected = '') + { + + if (!Apps::system_app_installed(App::$profile['profile_uid'], 'Categories')) { + return ''; + } + + $sql_extra = item_permissions_sql(App::$profile['profile_uid']); + + $item_normal = "and item.item_hidden = 0 and item.item_type = 6 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 "; + + $terms = []; + $r = q( + "select distinct(term.term) + from term join item on term.oid = item.id + where item.uid = %d + and term.uid = item.uid + and term.ttype = %d + and term.otype = %d + and item.owner_xchan = '%s' + $item_normal + $sql_extra + order by term.term asc", + intval(App::$profile['profile_uid']), + intval(TERM_CATEGORY), + intval(TERM_OBJ_POST), + dbesc(App::$profile['channel_hash']) + ); + if ($r && count($r)) { + foreach ($r as $rr) { + $terms[] = array('name' => $rr['term'], 'selected' => (($selected == $rr['term']) ? 'selected' : '')); + } + + return replace_macros(Theme::get_template('categories_widget.tpl'), array( + '$title' => t('Categories'), + '$desc' => '', + '$sel_all' => (($selected == '') ? 'selected' : ''), + '$all' => t('Everything'), + '$terms' => $terms, + '$base' => $baseurl, + + )); + } + return ''; + } + + + public static function categories_widget($baseurl, $selected = '') + { + + if (!Apps::system_app_installed(App::$profile['profile_uid'], 'Categories')) { + return ''; + } + + require_once('include/security.php'); + + $sql_extra = item_permissions_sql(App::$profile['profile_uid']); + + $item_normal = item_normal(); + + $terms = []; + $r = q( + "select distinct(term.term) from term join item on term.oid = item.id + where item.uid = %d + and term.uid = item.uid + and term.ttype = %d + and term.otype = %d + and item.owner_xchan = '%s' + and item.item_wall = 1 + and item.verb != '%s' + $item_normal + $sql_extra + order by term.term asc", + intval(App::$profile['profile_uid']), + intval(TERM_CATEGORY), + intval(TERM_OBJ_POST), + dbesc(App::$profile['channel_hash']), + dbesc(ACTIVITY_UPDATE) + ); + if ($r && count($r)) { + foreach ($r as $rr) { + $terms[] = array('name' => $rr['term'], 'selected' => (($selected == $rr['term']) ? 'selected' : '')); + } + + return replace_macros(Theme::get_template('categories_widget.tpl'), array( + '$title' => t('Categories'), + '$desc' => '', + '$sel_all' => (($selected == '') ? 'selected' : ''), + '$all' => t('Everything'), + '$terms' => $terms, + '$base' => $baseurl, + + )); + } + return ''; + } +} diff --git a/Code/Widget/Cdav.php b/Code/Widget/Cdav.php new file mode 100644 index 000000000..6ffd19bdc --- /dev/null +++ b/Code/Widget/Cdav.php @@ -0,0 +1,197 @@ +db; + + require_once 'vendor/autoload.php'; + + $o = ''; + + if (argc() <= 3 && argv(1) === 'calendar') { + $caldavBackend = new \Sabre\CalDAV\Backend\PDO($pdo); + + $sabrecals = $caldavBackend->getCalendarsForUser($principalUri); + + //TODO: we should probably also check for permission to send stream here + $local_channels = q( + "SELECT * FROM channel LEFT JOIN abook ON abook_xchan = channel_hash WHERE channel_system = 0 AND channel_removed = 0 AND channel_hash != '%s' AND abook_channel = %d", + dbesc($channel['channel_hash']), + intval($channel['channel_id']) + ); + + $sharee_options .= '' . "\r\n"; + foreach ($local_channels as $local_channel) { + $sharee_options .= '' . "\r\n"; + } + + $access_options = '' . "\r\n"; + $access_options .= '' . "\r\n"; + + //list calendars + foreach ($sabrecals as $sabrecal) { + if ($sabrecal['share-access'] == 1) { + $access = ''; + } + if ($sabrecal['share-access'] == 2) { + $access = 'read'; + } + if ($sabrecal['share-access'] == 3) { + $access = 'read-write'; + } + + $invites = $caldavBackend->getInvites($sabrecal['id']); + + $json_source = '/cdav/calendar/json/' . $sabrecal['id'][0] . '/' . $sabrecal['id'][1]; + + $switch = get_pconfig(local_channel(), 'cdav_calendar', $sabrecal['id'][0]); + + $color = (($sabrecal['{http://apple.com/ns/ical/}calendar-color']) ? $sabrecal['{http://apple.com/ns/ical/}calendar-color'] : '#6cad39'); + + $editable = (($sabrecal['share-access'] == 2) ? 'false' : 'true'); // false/true must be string since we're passing it to javascript + + $sharees = []; + $share_displayname = []; + + foreach ($invites as $invite) { + if (strpos($invite->href, 'mailto:') !== false) { + $sharee = Channel::from_username(substr($invite->principal, 11)); + $sharees[] = [ + 'name' => $sharee['channel_name'], + 'access' => (($invite->access == 3) ? ' (RW)' : ' (R)'), + 'hash' => $sharee['channel_hash'] + ]; + } + } + + if (!$access) { + $my_calendars[] = [ + 'ownernick' => $channel['channel_address'], + 'uri' => $sabrecal['uri'], + 'displayname' => $sabrecal['{DAV:}displayname'], + 'calendarid' => $sabrecal['id'][0], + 'instanceid' => $sabrecal['id'][1], + 'json_source' => $json_source, + 'color' => $color, + 'editable' => $editable, + 'switch' => $switch, + 'sharees' => $sharees + ]; + } else { + $shared_calendars[] = [ + 'ownernick' => $channel['channel_address'], + 'uri' => $sabrecal['uri'], + 'displayname' => $sabrecal['{DAV:}displayname'], + 'calendarid' => $sabrecal['id'][0], + 'instanceid' => $sabrecal['id'][1], + 'json_source' => $json_source, + 'color' => $color, + 'editable' => $editable, + 'switch' => $switch, + 'sharer' => $sabrecal['{urn:ietf:params:xml:ns:caldav}calendar-description'], + 'access' => $access + ]; + } + + if (!$access || $access === 'read-write') { + $writable_calendars[] = [ + 'displayname' => ((!$access) ? $sabrecal['{DAV:}displayname'] : $share_displayname[0]), + 'id' => $sabrecal['id'] + ]; + } + } + + $calendars[] = [ + 'ownernick' => $channel['channel_address'], + 'displayname' => $channel['channel_name'], + 'calendarid' => 'calendar', + 'json_source' => '/calendar/json', + 'color' => '#3a87ad', + 'editable' => true, + 'switch' => get_pconfig(local_channel(), 'cdav_calendar', 'calendar') + ]; + + $o .= replace_macros(Theme::get_template('cdav_widget_calendar.tpl'), [ + '$calendars_label' => t('Channel Calendar'), + '$calendars' => $calendars, + '$my_calendars_label' => t('CalDAV Calendars'), + '$my_calendars' => $my_calendars, + '$shared_calendars_label' => t('Shared CalDAV Calendars'), + '$shared_calendars' => $shared_calendars, + '$sharee_options' => $sharee_options, + '$access_options' => $access_options, + '$share_label' => t('Share this calendar'), + '$share' => t('Share'), + '$edit_label' => t('Calendar name and color'), + '$edit' => t('Edit'), + '$create_label' => t('Create new CalDAV calendar'), + '$create' => t('Create'), + '$create_placeholder' => t('Calendar Name'), + '$tools_label' => t('Calendar Tools'), + '$tools_options_label' => [t('Channel Calendars'), t('CalDAV Calendars')], + '$import_label' => t('Import calendar'), + '$import_placeholder' => t('Select a calendar to import to'), + '$upload' => t('Upload'), + '$writable_calendars' => $writable_calendars + ]); + + return $o; + } + + if (argc() >= 2 && argv(1) === 'addressbook') { + $carddavBackend = new PDO($pdo); + + $sabreabooks = $carddavBackend->getAddressBooksForUser($principalUri); + + //list addressbooks + foreach ($sabreabooks as $sabreabook) { + $addressbooks[] = [ + 'ownernick' => $channel['channel_address'], + 'uri' => $sabreabook['uri'], + 'displayname' => $sabreabook['{DAV:}displayname'], + 'id' => $sabreabook['id'] + + ]; + } + + $o .= replace_macros(Theme::get_template('cdav_widget_addressbook.tpl'), [ + '$addressbooks_label' => t('Addressbooks'), + '$addressbooks' => $addressbooks, + '$edit_label' => t('Addressbook name'), + '$edit' => t('Edit'), + '$create_label' => t('Create new addressbook'), + '$create_placeholder' => t('Addressbook Name'), + '$create' => t('Create'), + '$tools_label' => t('Addressbook Tools'), + '$import_label' => t('Import addressbook'), + '$import_placeholder' => t('Select an addressbook to import to'), + '$upload' => t('Upload') + ]); + + return $o; + } + } +} diff --git a/Code/Widget/Chatroom_list.php b/Code/Widget/Chatroom_list.php new file mode 100644 index 000000000..3b74b07d6 --- /dev/null +++ b/Code/Widget/Chatroom_list.php @@ -0,0 +1,32 @@ + t('Chatrooms'), + '$baseurl' => z_root(), + '$nickname' => App::$profile['channel_address'], + '$items' => $r, + '$overview' => t('Overview') + )); + } + } +} diff --git a/Code/Widget/Chatroom_members.php b/Code/Widget/Chatroom_members.php new file mode 100644 index 000000000..79e35a6ad --- /dev/null +++ b/Code/Widget/Chatroom_members.php @@ -0,0 +1,19 @@ + t('Chat Members') + )); + } +} diff --git a/Code/Widget/Clock.php b/Code/Widget/Clock.php new file mode 100644 index 000000000..ba6d55e41 --- /dev/null +++ b/Code/Widget/Clock.php @@ -0,0 +1,62 @@ + +

      + + +EOT; + + return $o; + } +} diff --git a/Code/Widget/Collections.php b/Code/Widget/Collections.php new file mode 100644 index 000000000..543f13e93 --- /dev/null +++ b/Code/Widget/Collections.php @@ -0,0 +1,59 @@ + t('Common Connections'), + '$base' => z_root(), + '$uid' => $profile_uid, + '$cid' => $observer, + '$linkmore' => (($t > $cnt) ? 'true' : ''), + '$more' => sprintf(t('View all %d common connections'), $t), + '$items' => $r + )); + } +} diff --git a/Code/Widget/Cover_photo.php b/Code/Widget/Cover_photo.php new file mode 100644 index 000000000..1f40e6dcc --- /dev/null +++ b/Code/Widget/Cover_photo.php @@ -0,0 +1,102 @@ +') !== false) { + $style = ''; + } + + if (array_key_exists('title', $arr) && isset($arr['title'])) { + $title = $arr['title']; + } else { + $title = $channel['channel_name']; + } + + + if (array_key_exists('subtitle', $arr) && isset($arr['subtitle'])) { + $subtitle = $arr['subtitle']; + } else { + $subtitle = str_replace('@', '@', $channel['xchan_addr']); + } + + + if ($site_banner) { + $title = $site_banner; + $subtitle = ''; + } + + $c = Channel::get_cover_photo($channel_id, 'html'); + + if ($c) { + $c = str_replace('src=', 'data-src=', $c); + $photo_html = (($style) ? str_replace('alt=', ' style="' . $style . '" alt=', $c) : $c); + + $o = replace_macros(Theme::get_template('cover_photo_widget.tpl'), array( + '$photo_html' => $photo_html, + '$title' => $title, + '$subtitle' => $subtitle, + '$hovertitle' => t('Click to show more'), + '$hide_cover' => $hide_cover + )); + } + return $o; + } +} diff --git a/Code/Widget/Design_tools.php b/Code/Widget/Design_tools.php new file mode 100644 index 000000000..714407329 --- /dev/null +++ b/Code/Widget/Design_tools.php @@ -0,0 +1,19 @@ + t('Events Tools'), + '$export' => t('Export Calendar'), + '$import' => t('Import Calendar'), + '$submit' => t('Submit') + )); + } +} diff --git a/Code/Widget/Filer.php b/Code/Widget/Filer.php new file mode 100644 index 000000000..a8400a4ba --- /dev/null +++ b/Code/Widget/Filer.php @@ -0,0 +1,44 @@ + $rr['term'], 'selected' => (($selected == $rr['term']) ? 'selected' : '')); + } + + return replace_macros(Theme::get_template('fileas_widget.tpl'), array( + '$title' => t('Saved Folders'), + '$desc' => '', + '$sel_all' => (($selected == '') ? 'selected' : ''), + '$all' => t('Everything'), + '$terms' => $terms, + '$base' => z_root() . '/' . App::$cmd + )); + } +} diff --git a/Code/Widget/Findpeople.php b/Code/Widget/Findpeople.php new file mode 100644 index 000000000..38e7e0f8f --- /dev/null +++ b/Code/Widget/Findpeople.php @@ -0,0 +1,47 @@ +' + . sprintf(tt('%d invitation available', '%d invitations available', $x), $x) + . '' . $inv; + } + } + + $advanced_search = ((local_channel() && Features::enabled(local_channel(), 'advanced_dirsearch')) ? t('Advanced') : false); + + return replace_macros(Theme::get_template('peoplefind.tpl'), array( + '$findpeople' => t('Find Channels'), + '$desc' => t('Enter name or interest'), + '$label' => t('Connect/Follow'), + '$hint' => t('Examples: Robert Morgenstein, Fishing'), + '$findthem' => t('Find'), + '$suggest' => t('Channel Suggestions'), + '$similar' => '', // FIXME and uncomment when mod/match working // t('Similar Interests'), + '$random' => '', // t('Random Profile'), + '$sites' => t('Affiliated sites'), + '$inv' => '', // t('Invite Friends'), + '$advanced_search' => $advanced_search, + '$advanced_hint' => "\r\n" . t('Advanced example: name=fred and country=iceland'), + '$loggedin' => local_channel() + )); + } +} diff --git a/Code/Widget/Follow.php b/Code/Widget/Follow.php new file mode 100644 index 000000000..6430c8d43 --- /dev/null +++ b/Code/Widget/Follow.php @@ -0,0 +1,45 @@ + t('Add New Connection'), + '$desc' => t('Enter channel address'), + '$hint' => t('Examples: bob@example.com, https://example.com/barbara'), + '$follow' => t('Connect'), + '$abook_usage_message' => $abook_usage_message + ]); + } +} diff --git a/Code/Widget/Fullprofile.php b/Code/Widget/Fullprofile.php new file mode 100644 index 000000000..69c12aaec --- /dev/null +++ b/Code/Widget/Fullprofile.php @@ -0,0 +1,20 @@ += 0) { + $limit = " limit " . intval($arr['limit']) . " "; + } else { + $limit = EMPTY_STR; + } + + $unseen = 0; + if (is_array($arr) && array_key_exists('unseen', $arr) && intval($arr['unseen'])) { + $unseen = 1; + } + + $perms_sql = item_permissions_sql(local_channel()) . item_normal(); + + $xf = false; + + $x1 = q( + "select xchan from abconfig where chan = %d and cat = 'system' and k = 'their_perms' and not v like '%s'", + intval(local_channel()), + dbesc('%send_stream%') + ); + if ($x1) { + $xc = ids_to_querystr($x1, 'xchan', true); + + $x2 = q( + "select xchan from abconfig where chan = %d and cat = 'system' and k = 'their_perms' and v like '%s' and xchan in (" . $xc . ") ", + intval(local_channel()), + dbesc('%tag_deliver%') + ); + + if ($x2) { + $xf = ids_to_querystr($x2, 'xchan', true); + + // private forums + $x3 = q( + "select xchan from abconfig where chan = %d and cat = 'system' and k = 'their_perms' and v like '%s' and xchan in (" . $xc . ") and not xchan in (" . $xf . ") ", + intval(local_channel()), + dbesc('%post_wall%') + ); + if ($x3) { + $xf = ids_to_querystr(array_merge($x2, $x3), 'xchan', true); + } + } + } + + // note: XCHAN_TYPE_GROUP = 1 + $sql_extra = (($xf) ? " and ( xchan_hash in (" . $xf . ") or xchan_type = 1 ) " : " and xchan_type = 1 "); + + $r1 = q( + "select abook_id, xchan_hash, xchan_name, xchan_url, xchan_photo_s from abook left join xchan on abook_xchan = xchan_hash where xchan_deleted = 0 and abook_channel = %d and abook_pending = 0 and abook_ignored = 0 and abook_blocked = 0 and abook_archived = 0 $sql_extra order by xchan_name $limit ", + intval(local_channel()) + ); + + if (!$r1) { + return $output; + } + + $str = EMPTY_STR; + + // Trying to cram all this into a single query with joins and the proper group by's is tough. + // There also should be a way to update this via ajax. + + for ($x = 0; $x < count($r1); $x++) { + $r = q( + "select sum(item_unseen) as unseen from item + where uid = %d and owner_xchan = '%s' and item_unseen = 1 $perms_sql ", + intval(local_channel()), + dbesc($r1[$x]['xchan_hash']) + ); + if ($r) { + $r1[$x]['unseen'] = $r[0]['unseen']; + } + } + + /** + * @FIXME + * This SQL makes the counts correct when you get forum posts arriving from different routes/sources + * (like personal channels). However the stream query for these posts doesn't yet include this + * correction and it makes the SQL for that query pretty hairy so this is left as a future exercise. + * It may make more sense in that query to look for the mention in the body rather than another join, + * but that makes it very inefficient. + * + * $r = q("select sum(item_unseen) as unseen from item left join term on oid = id where otype = %d and owner_xchan != '%s' and item.uid = %d and url = '%s' and ttype = %d $perms_sql ", + * intval(TERM_OBJ_POST), + * dbesc($r1[$x]['xchan_hash']), + * intval(local_channel()), + * dbesc($r1[$x]['xchan_url']), + * intval(TERM_MENTION) + * ); + * if($r) + * $r1[$x]['unseen'] = ((array_key_exists('unseen',$r1[$x])) ? $r1[$x]['unseen'] + $r[0]['unseen'] : $r[0]['unseen']); + * + * end @FIXME + */ + + if ($r1) { + $output .= '
      '; + $output .= '

      ' . t('Groups') . '

      '; + } + return $output; + } +} diff --git a/Code/Widget/Helpindex.php b/Code/Widget/Helpindex.php new file mode 100644 index 000000000..602d620c5 --- /dev/null +++ b/Code/Widget/Helpindex.php @@ -0,0 +1,59 @@ +'; + + $level_0 = get_help_content('sitetoc'); + if (!$level_0) { + $path = 'toc'; + $x = determine_help_language(); + $lang = $x['language']; + if ($lang !== 'en') { + $path = $lang . '/toc'; + } + $level_0 = get_help_content($path); + } + + $level_0 = preg_replace('/\/', '