From 4d0611b7d4be21d9b436348fbe9b7b1a4c0b189c Mon Sep 17 00:00:00 2001 From: zotlabs Date: Wed, 1 Aug 2018 16:12:44 -0700 Subject: hubzilla core issue #1258 --- Zotlabs/Widget/Activity_order.php | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'Zotlabs') diff --git a/Zotlabs/Widget/Activity_order.php b/Zotlabs/Widget/Activity_order.php index 0e660afc3..27d1a971a 100644 --- a/Zotlabs/Widget/Activity_order.php +++ b/Zotlabs/Widget/Activity_order.php @@ -34,6 +34,7 @@ class Activity_order { break; default: $commentord_active = 'active'; + break; } } else { @@ -78,6 +79,9 @@ class Activity_order { if(x($_GET,'file')) $filter .= '&file=' . $_GET['file']; + if(x($_GET,'pf')) + $filter .= '&pf=' . $_GET['pf']; + // tabs $tabs = []; -- cgit v1.2.3 From d908f53607512b8bfa3fbf65cb6fc9623fab5c63 Mon Sep 17 00:00:00 2001 From: zotlabs Date: Thu, 2 Aug 2018 22:49:30 -0700 Subject: add app_options field --- Zotlabs/Lib/Apps.php | 13 ++++++++++--- Zotlabs/Module/Probe.php | 7 +++---- Zotlabs/Module/Zfinger.php | 4 ---- Zotlabs/Update/_1217.php | 22 ++++++++++++++++++++++ 4 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 Zotlabs/Update/_1217.php (limited to 'Zotlabs') diff --git a/Zotlabs/Lib/Apps.php b/Zotlabs/Lib/Apps.php index f7aff1722..9027b13bc 100644 --- a/Zotlabs/Lib/Apps.php +++ b/Zotlabs/Lib/Apps.php @@ -927,10 +927,11 @@ class Apps { $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 ) values ( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %d, '%s', '%s', '%s', '%s', '%s', '%s', %d, '%s', %d )", + $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']), @@ -948,7 +949,8 @@ class Apps { dbesc($created), intval($darray['app_system']), dbesc($darray['app_plugin']), - intval($darray['app_deleted']) + intval($darray['app_deleted']), + intval($darray['app_options']) ); if($r) { @@ -1009,10 +1011,11 @@ class Apps { $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 where app_id = '%s' and app_channel = %d", + $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']), @@ -1028,6 +1031,7 @@ class Apps { intval($darray['app_system']), dbesc($darray['app_plugin']), intval($darray['app_deleted']), + intval($darray['app_options']), dbesc($darray['app_id']), intval($darray['app_channel']) ); @@ -1117,6 +1121,9 @@ class Apps { if($app['app_system']) $ret['system'] = $app['app_system']; + if($app['app_options']) + $ret['options'] = $app['app_options']; + if($app['app_plugin']) $ret['plugin'] = trim($app['app_plugin']); diff --git a/Zotlabs/Module/Probe.php b/Zotlabs/Module/Probe.php index 2e65f107c..2c67c6aae 100644 --- a/Zotlabs/Module/Probe.php +++ b/Zotlabs/Module/Probe.php @@ -27,12 +27,11 @@ class Probe extends \Zotlabs\Web\Controller { $o .= '
';
 			if(! $j['success']) {
-				$o .= sprintf( t('Fetching URL returns error: %1$s'),$res['error'] . "\r\n\r\n");
 				$o .= "https connection failed. Trying again with auto failover to http.\r\n\r\n";
 				$j = \Zotlabs\Zot\Finger::run($addr,$channel,true);
-				if(! $j['success']) 
-					$o .= sprintf( t('Fetching URL returns error: %1$s'),$res['error'] . "\r\n\r\n");
-	
+				if(! $j['success']) {
+					return $o;	
+				}
 			}
 			if($do_import && $j)
 				$x = import_xchan($j);
diff --git a/Zotlabs/Module/Zfinger.php b/Zotlabs/Module/Zfinger.php
index 0f7f6a64b..6ed001df5 100644
--- a/Zotlabs/Module/Zfinger.php
+++ b/Zotlabs/Module/Zfinger.php
@@ -36,10 +36,6 @@ class Zfinger extends \Zotlabs\Web\Controller {
 
 		echo $ret;
 		killme();
-
-
-
-		json_return_and_die($x);
 	
 	}
 	
diff --git a/Zotlabs/Update/_1217.php b/Zotlabs/Update/_1217.php
new file mode 100644
index 000000000..15d2d06b3
--- /dev/null
+++ b/Zotlabs/Update/_1217.php
@@ -0,0 +1,22 @@
+
Date: Mon, 6 Aug 2018 17:43:22 -0700
Subject: bring some Zot6 libraries and interfaces to red/hubzilla

---
 Zotlabs/Lib/Activity.php     | 1725 +++++++++++++++++++++++++
 Zotlabs/Lib/Group.php        |  405 ++++++
 Zotlabs/Lib/Libsync.php      | 1019 +++++++++++++++
 Zotlabs/Lib/Libzot.php       | 2849 ++++++++++++++++++++++++++++++++++++++++++
 Zotlabs/Lib/Libzotdir.php    |  654 ++++++++++
 Zotlabs/Lib/Queue.php        |  278 +++++
 Zotlabs/Lib/Webfinger.php    |  109 ++
 Zotlabs/Lib/Zotfinger.php    |   50 +
 Zotlabs/Module/Zot.php       |   25 +
 Zotlabs/Zot6/Finger.php      |  146 +++
 Zotlabs/Zot6/IHandler.php    |   18 +
 Zotlabs/Zot6/Receiver.php    |  220 ++++
 Zotlabs/Zot6/Zot6Handler.php |  266 ++++
 13 files changed, 7764 insertions(+)
 create mode 100644 Zotlabs/Lib/Activity.php
 create mode 100644 Zotlabs/Lib/Group.php
 create mode 100644 Zotlabs/Lib/Libsync.php
 create mode 100644 Zotlabs/Lib/Libzot.php
 create mode 100644 Zotlabs/Lib/Libzotdir.php
 create mode 100644 Zotlabs/Lib/Queue.php
 create mode 100644 Zotlabs/Lib/Webfinger.php
 create mode 100644 Zotlabs/Lib/Zotfinger.php
 create mode 100644 Zotlabs/Module/Zot.php
 create mode 100644 Zotlabs/Zot6/Finger.php
 create mode 100644 Zotlabs/Zot6/IHandler.php
 create mode 100644 Zotlabs/Zot6/Receiver.php
 create mode 100644 Zotlabs/Zot6/Zot6Handler.php

(limited to 'Zotlabs')

diff --git a/Zotlabs/Lib/Activity.php b/Zotlabs/Lib/Activity.php
new file mode 100644
index 000000000..6ddbbb9db
--- /dev/null
+++ b/Zotlabs/Lib/Activity.php
@@ -0,0 +1,1725 @@
+ '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;
+
+	}
+
+	static function fetch_item($x) {
+
+		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);
+			return self::encode_item($r[0]);
+		}
+	}
+
+	static function encode_item_collection($items,$id,$type,$extra = null) {
+
+		$ret = [
+			'id' => z_root() . '/' . $id,
+			'type' => $type,
+			'totalItems' => count($items),
+		];
+		if($extra)
+			$ret = array_merge($ret,$extra);
+
+		if($items) {
+			$x = [];
+			foreach($items as $i) {
+				$t = self::encode_activity($i);
+				if($t)
+					$x[] = $t;
+			}
+			if($type === 'OrderedCollection')
+				$ret['orderedItems'] = $x;
+			else
+				$ret['items'] = $x;
+		}
+
+		return $ret;
+	}
+
+	static function encode_follow_collection($items,$id,$type,$extra = null) {
+
+		$ret = [
+			'id' => z_root() . '/' . $id,
+			'type' => $type,
+			'totalItems' => count($items),
+		];
+		if($extra)
+			$ret = array_merge($ret,$extra);
+
+		if($items) {
+			$x = [];
+			foreach($items as $i) {
+				if($i['xchan_url']) {
+					$x[] = $i['xchan_url'];
+				}
+			}
+
+			if($type === 'OrderedCollection')
+				$ret['orderedItems'] = $x;
+			else
+				$ret['items'] = $x;
+		}
+
+		return $ret;
+	}
+
+
+
+
+	static function encode_item($i) {
+
+		$ret = [];
+
+		$objtype = self::activity_obj_mapper($i['obj_type']);
+
+		if(intval($i['item_deleted'])) {
+			$ret['type'] = 'Tombstone';
+			$ret['formerType'] = $objtype;
+			$ret['id'] = ((strpos($i['mid'],'http') === 0) ? $i['mid'] : z_root() . '/item/' . urlencode($i['mid']));
+			return $ret;
+		}
+
+		$ret['type'] = $objtype;
+
+		$ret['id']   = ((strpos($i['mid'],'http') === 0) ? $i['mid'] : z_root() . '/item/' . urlencode($i['mid']));
+
+		if($i['title'])
+			$ret['title'] = bbcode($i['title']);
+
+		$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['app']) {
+			$ret['instrument'] = [ 'type' => 'Service', '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];
+			}
+		}
+
+		$ret['attributedTo'] = $i['author']['xchan_url'];
+
+		if($i['id'] != $i['parent']) {
+			$ret['inReplyTo'] = ((strpos($i['parent_mid'],'http') === 0) ? $i['parent_mid'] : z_root() . '/item/' . urlencode($i['parent_mid']));
+		}
+
+		if($i['mimetype'] === 'text/bbcode') {
+			if($i['title'])
+				$ret['name'] = bbcode($i['title']);
+			if($i['summary'])
+				$ret['summary'] = bbcode($i['summary']);
+			$ret['content'] = bbcode($i['body']);
+			$ret['source'] = [ 'content' => $i['body'], 'mediaType' => 'text/bbcode' ];
+		}
+
+		$actor = self::encode_person($i['author'],false);
+		if($actor)
+			$ret['actor'] = $actor;
+		else
+			return [];
+
+		$t = self::encode_taxonomy($i);
+		if($t) {
+			$ret['tag']       = $t;
+		}
+
+		$a = self::encode_attachment($i);
+		if($a) {
+			$ret['attachment'] = $a;
+		}
+
+		return $ret;
+	}
+
+	static function decode_taxonomy($item) {
+
+		$ret = [];
+
+		if($item['tag']) {
+			foreach($item['tag'] as $t) {
+				if(! array_key_exists('type',$t))
+					$t['type'] = 'Hashtag';
+
+				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 '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;
+	
+					default:
+						break;
+				}
+			}
+		}
+
+		return $ret;
+	}
+
+
+	static function encode_taxonomy($item) {
+
+		$ret = [];
+
+		if($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_FORUM:
+						$ret[] = [ 'type' => 'Mention', 'href' => $t['url'], 'name' => '!' . $t['term'] ];
+						break;
+
+					case TERM_MENTION:
+						$ret[] = [ 'type' => 'Mention', 'href' => $t['url'], 'name' => '@' . $t['term'] ];
+						break;
+	
+					default:
+						break;
+				}
+			}
+		}
+
+		return $ret;
+	}
+
+	static function encode_attachment($item) {
+
+		$ret = [];
+
+		if($item['attach']) {
+			$atts = 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'] ];
+					}
+				}
+			}
+		}
+
+		return $ret;
+	}
+
+
+	static function decode_attachment($item) {
+
+		$ret = [];
+
+		if($item['attachment']) {
+			foreach($item['attachment'] as $att) {
+				$entry = [];
+				if($att['href'])
+					$entry['href'] = $att['href'];
+				elseif($att['url'])
+					$entry['href'] = $att['url'];
+				if($att['mediaType'])
+					$entry['type'] = $att['mediaType'];
+				elseif($att['type'] === 'Image')
+					$entry['type'] = 'image/jpeg';
+				if($entry)
+					$ret[] = $entry;
+			}
+		}
+
+		return $ret;
+	}
+
+
+
+	static function encode_activity($i) {
+
+		$ret   = [];
+		$reply = false;
+
+		if(intval($i['item_deleted'])) {
+			$ret['type'] = 'Tombstone';
+			$ret['formerType'] = self::activity_obj_mapper($i['obj_type']);
+			$ret['id'] = ((strpos($i['mid'],'http') === 0) ? $i['mid'] : z_root() . '/item/' . urlencode($i['mid']));
+			return $ret;
+		}
+
+		$ret['type'] = self::activity_mapper($i['verb']);
+		$ret['id']   = ((strpos($i['mid'],'http') === 0) ? $i['mid'] : z_root() . '/activity/' . urlencode($i['mid']));
+
+		if($i['title'])
+			$ret['name'] = html2plain(bbcode($i['title']));
+
+		if($i['summary'])
+			$ret['summary'] = bbcode($i['summary']);
+
+		if($ret['type'] === 'Announce') {
+			$tmp = preg_replace('/\[share(.*?)\[\/share\]/ism',EMPTY_STR, $i['body']);
+			$ret['content'] = bbcode($tmp);
+			$ret['source'] = [
+				'content' => $i['body'],
+				'mediaType' => 'text/bbcode'
+			];
+		}
+
+		$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['app']) {
+			$ret['instrument'] = [ 'type' => 'Service', '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['id'] != $i['parent']) {
+			$ret['inReplyTo'] = ((strpos($i['parent_mid'],'http') === 0) ? $i['parent_mid'] : z_root() . '/item/' . urlencode($i['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) {
+					$is_directmessage = false;
+					$recips = get_iconfig($i['parent'], 'activitypub', 'recips');
+
+					if(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']);
+				}
+			}
+
+		}
+
+		$actor = self::encode_person($i['author'],false);
+		if($actor)
+			$ret['actor'] = $actor;
+		else
+			return []; 
+
+		if($i['obj']) {
+			if(! is_array($i['obj'])) {
+				$i['obj'] = json_decode($i['obj'],true);
+			}
+			$obj = self::encode_object($i['obj']);
+			if($obj)
+				$ret['object'] = $obj;
+			else
+				return [];
+		}
+		else {
+			$obj = self::encode_item($i);
+			if($obj)
+				$ret['object'] = $obj;
+			else
+				return [];
+		}
+
+		if($i['target']) {
+			if(! is_array($i['target'])) {
+				$i['target'] = json_decode($i['target'],true);
+			}
+			$tgt = self::encode_object($i['target']);
+			if($tgt)
+				$ret['target'] = $tgt;
+			else
+				return [];
+		}
+
+		return $ret;
+	}
+
+	static function map_mentions($i) {
+		if(! $i['term']) {
+			return [];
+		}
+
+		$list = [];
+
+		foreach ($i['term'] as $t) {
+			if($t['ttype'] == TERM_MENTION) {
+				$list[] = $t['url'];
+			}
+		}
+
+		return $list;
+	}
+
+	static function map_acl($i,$mentions = false) {
+
+		$private = false;
+		$list = [];
+		$x = collect_recipients($i,$private);
+		if($x) {
+			stringify_array_elms($x);
+			if(! $x)
+				return;
+
+			$strict = (($mentions) ? true : get_config('activitypub','compliance'));
+
+			$sql_extra = (($strict) ? " and xchan_network = 'activitypub' " : '');
+
+			$details = q("select xchan_url, xchan_addr, xchan_name from xchan where xchan_hash in (" . implode(',',$x) . ") $sql_extra");
+
+			if($details) {
+				foreach($details as $d) {
+					if($mentions) {
+						$list[] = [ 'type' => 'Mention', 'href' => $d['xchan_url'], 'name' => '@' . (($d['xchan_addr']) ? $d['xchan_addr'] : $d['xchan_name']) ];
+					}
+					else { 
+						$list[] = $d['xchan_url'];
+					}
+				}
+			}
+		}
+
+		return $list;
+
+	}
+
+
+	static function encode_person($p, $extended = true) {
+
+		if(! $p['xchan_url'])
+			return [];
+
+		if(! $extended) {
+			return $p['xchan_url'];
+		}
+		$ret = [];
+
+		$ret['type']  = 'Person';
+		$ret['id']    = $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'] = [
+			[ 
+				'type'      => 'Link',
+				'mediaType' => 'text/html',
+				'href'      => $p['xchan_url']
+			],
+			[
+				'type'      => 'Link',
+				'mediaType' => 'text/x-zot+json',
+				'href'      => $p['xchan_url']
+			]
+		];
+
+		$arr = [ 'xchan' => $p, 'encoded' => $ret ];
+		call_hooks('encode_person', $arr);
+		$ret = $arr['encoded'];
+
+
+		return $ret;
+	}
+
+
+	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'  => 'Unfollow',
+		];
+
+
+		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 execption  and crash 
+		// the delivery system if encountered by the JSON-LDSignature library
+ 
+		logger('Unmapped activity: ' . $verb);
+		return 'Create';
+	//	return false;
+}
+
+
+	static function activity_obj_mapper($obj) {
+
+		if(strpos($obj,'/') === false) {
+			return $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',
+		
+		];
+
+		if(array_key_exists($obj,$objs)) {
+			return $objs[$obj];
+		}
+
+		logger('Unmapped activity object: ' . $obj);
+		return 'Note';
+
+		//	return false;
+
+	}
+
+
+	static function follow($channel,$act) {
+
+		$contact = null;
+		$their_follow_id = null;
+
+		/*
+		 * 
+		 * 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($act->type === 'Follow') {
+			$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 = \Zotlabs\Access\PermissionRoles::role_perms('social');
+		$p = \Zotlabs\Access\Permissions::FilledPerms($x['perms_connect']);
+		$their_perms = \Zotlabs\Access\Permissions::serialise($p);
+
+		if($contact && $contact['abook_id']) {
+
+			// A relationship of some form already exists on this site. 
+
+			switch($act->type) {
+
+				case 'Follow':
+
+					// 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'],'pubcrawl','their_follow_id', $their_follow_id);
+					\Zotlabs\Daemon\Master::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'],'pubcrawl','their_follow_id', $their_follow_id);
+
+		// 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];
+
+		$p = \Zotlabs\Access\Permissions::connect_perms($channel['channel_id']);
+		$my_perms  = \Zotlabs\Access\Permissions::serialise($p['perms']);
+		$automatic = $p['automatic'];
+
+		$closeness = get_pconfig($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)
+			set_abconfig($channel['channel_id'],$ret['xchan_hash'],'system','my_perms',$my_perms);
+
+		if($their_perms)
+			set_abconfig($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) {
+				\Zotlabs\Lib\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
+					\Zotlabs\Daemon\Master::Summon([ 'Notifier', 'permissions_accept', $new_connection[0]['abook_id'] ]);
+					// Send back a Follow notification to them
+					\Zotlabs\Daemon\Master::Summon([ 'Notifier', 'permissions_create', $new_connection[0]['abook_id'] ]);
+				}
+
+				$clone = array();
+				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' => array($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 = Group::rec_byhash($channel['channel_id'],$channel['channel_default_group']);
+			if($g)
+				Group::member_add($channel['channel_id'],'',$ret['xchan_hash'],$g['id']);
+		}
+
+
+		return;
+
+	}
+
+
+	static function unfollow($channel,$act) {
+
+		$contact = null;
+
+		/* @FIXME This really needs to be a signed request. */
+
+		/* 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;
+	}
+
+
+
+
+	static function actor_store($url,$person_obj) {
+
+		if(! is_array($person_obj))
+			return;
+
+		$name = $person_obj['name'];
+		if(! $name)
+			$name = $person_obj['preferredUsername'];
+		if(! $name)
+			$name = t('Unknown');
+
+		if($person_obj['icon']) {
+			if(is_array($person_obj['icon'])) {
+				if(array_key_exists('url',$person_obj['icon']))
+					$icon = $person_obj['icon']['url'];
+				else
+					$icon = $person_obj['icon'][0]['url'];
+			}
+			else
+				$icon = $person_obj['icon'];
+		}
+
+		if(is_array($person_obj['url']) && array_key_exists('href', $person_obj['url']))
+			$profile = $person_obj['url']['href'];
+		else
+			$profile = $url;
+
+
+		$inbox = $person_obj['inbox'];
+
+		$collections = [];
+
+		if($inbox) {
+			$collections['inbox'] = $inbox;
+			if($person_obj['outbox'])
+				$collections['outbox'] = $person_obj['outbox'];
+			if($person_obj['followers'])
+				$collections['followers'] = $person_obj['followers'];
+			if($person_obj['following'])
+				$collections['following'] = $person_obj['following'];
+			if($person_obj['endpoints'] && $person_obj['endpoints']['sharedInbox'])
+				$collections['sharedInbox'] = $person_obj['endpoints']['sharedInbox'];
+		}
+
+		if(array_key_exists('publicKey',$person_obj) && array_key_exists('publicKeyPem',$person_obj['publicKey'])) {
+			if($person_obj['id'] === $person_obj['publicKey']['owner']) {
+				$pubkey = $person_obj['publicKey']['publicKeyPem'];
+				if(strstr($pubkey,'RSA ')) {
+					$pubkey = rsatopem($pubkey);
+				}
+			}
+		}
+
+		$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'         => '',
+					'xchan_url'          => $profile,
+					'xchan_name'         => $name,
+					'xchan_name_date'    => datetime_convert(),
+					'xchan_network'      => 'activitypub'
+				]
+			);
+		}
+		else {
+
+			// Record exists. Cache existing records for one week at most
+			// then refetch to catch updated profile photos, names, etc. 
+
+			$d = datetime_convert('UTC','UTC','now - 1 week');
+			if($r[0]['xchan_name_date'] > $d)
+				return;
+
+			// update existing record
+			$r = q("update xchan set xchan_name = '%s', xchan_pubkey = '%s', xchan_network = '%s', xchan_name_date = '%s' where xchan_hash = '%s'",
+				dbesc($name),
+				dbesc($pubkey),
+				dbesc('activitypub'),
+				dbesc(datetime_convert()),
+				dbesc($url)
+			);
+		}
+
+		if($collections) {
+			set_xconfig($url,'activitypub','collections',$collections);
+		}
+
+		$r = 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'] . (($m['port']) ? ':' . $m['port'] : '');
+		}
+
+		if(! $r) {
+			$r = hubloc_store_lowlevel(
+				[
+					'hubloc_guid'     => $url,
+					'hubloc_hash'     => $url,
+					'hubloc_addr'     => '',
+					'hubloc_network'  => 'activitypub',
+					'hubloc_url'      => $baseurl,
+					'hubloc_host'     => $hostname,
+					'hubloc_callback' => $inbox,
+					'hubloc_updated'  => datetime_convert(),
+					'hubloc_primary'  => 1
+				]
+			);
+		}
+
+		if(! $icon)
+			$icon = z_root() . '/' . get_default_profile_photo(300);
+
+		$photos = import_xchan_photo($icon,$url);
+		$r = q("update xchan set xchan_photo_date = '%s', xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s', xchan_photo_mimetype = '%s' where xchan_hash = '%s'",
+			dbescdate(datetime_convert('UTC','UTC',$arr['photo_updated'])),
+			dbesc($photos[0]),
+			dbesc($photos[1]),
+			dbesc($photos[2]),
+			dbesc($photos[3]),
+			dbesc($url)
+		);
+
+	}
+
+
+	static function create_action($channel,$observer_hash,$act) {
+
+		if(in_array($act->obj['type'], [ 'Note', 'Article', 'Video' ])) {
+			self::create_note($channel,$observer_hash,$act);
+		}
+
+
+	}
+
+	static function announce_action($channel,$observer_hash,$act) {
+
+		if(in_array($act->type, [ 'Announce' ])) {
+			self::announce_note($channel,$observer_hash,$act);
+		}
+
+	}
+
+
+	static function like_action($channel,$observer_hash,$act) {
+
+		if(in_array($act->obj['type'], [ 'Note', 'Article', 'Video' ])) {
+			self::like_note($channel,$observer_hash,$act);
+		}
+
+
+	}
+
+	// sort function width decreasing
+
+	static function as_vid_sort($a,$b) {
+		if($a['width'] === $b['width'])
+			return 0;
+		return (($a['width'] > $b['width']) ? -1 : 1);
+	}
+
+	static function create_note($channel,$observer_hash,$act) {
+
+		$s = [];
+
+		// 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) && in_array(ACTIVITY_PUBLIC_INBOX, $act->obj['to'])) ? true : false);
+		$is_sys_channel = is_sys_channel($channel['channel_id']);
+
+		$parent = ((array_key_exists('inReplyTo',$act->obj)) ? urldecode($act->obj['inReplyTo']) : '');
+		if($parent) {
+
+			$r = q("select * from item where uid = %d and ( mid = '%s' or  mid = '%s' ) limit 1",
+				intval($channel['channel_id']),
+				dbesc($parent),
+				dbesc(basename($parent))
+			);
+
+			if(! $r) {
+				logger('parent not found.');
+				return;
+			}
+
+			if($r[0]['owner_xchan'] === $channel['channel_hash']) {
+				if(! perm_is_allowed($channel['channel_id'],$observer_hash,'send_stream') && ! ($is_sys_channel && $pubstream)) {
+					logger('no comment permission.');
+					return;
+				}
+			}
+
+			$s['parent_mid'] = $r[0]['mid'];
+			$s['owner_xchan'] = $r[0]['owner_xchan'];
+			$s['author_xchan'] = $observer_hash;
+
+		}
+		else {
+			if(! perm_is_allowed($channel['channel_id'],$observer_hash,'send_stream') && ! ($is_sys_channel && $pubstream)) {
+				logger('no permission');
+				return;
+			}
+			$s['owner_xchan'] = $s['author_xchan'] = $observer_hash;
+		}
+	
+		$abook = q("select * from abook where abook_xchan = '%s' and abook_channel = %d limit 1",
+			dbesc($observer_hash),
+			intval($channel['channel_id'])
+		);
+	
+		$content = self::get_content($act->obj);
+
+		if(! $content) {
+			logger('no content');
+			return;
+		}
+
+		$s['aid'] = $channel['channel_account_id'];
+		$s['uid'] = $channel['channel_id'];
+		$s['mid'] = urldecode($act->obj['id']);
+		$s['plink'] = urldecode($act->obj['id']);
+
+
+		if($act->data['published']) {
+			$s['created'] = datetime_convert('UTC','UTC',$act->data['published']);
+		}
+		elseif($act->obj['published']) {
+			$s['created'] = datetime_convert('UTC','UTC',$act->obj['published']);
+		}
+		if($act->data['updated']) {
+			$s['edited'] = datetime_convert('UTC','UTC',$act->data['updated']);
+		}
+		elseif($act->obj['updated']) {
+			$s['edited'] = datetime_convert('UTC','UTC',$act->obj['updated']);
+		}
+
+		if(! $s['created'])
+			$s['created'] = datetime_convert();
+
+		if(! $s['edited'])
+			$s['edited'] = $s['created'];
+
+
+		if(! $s['parent_mid'])
+			$s['parent_mid'] = $s['mid'];
+
+	
+		$s['title']    = self::bb_content($content,'name');
+		$s['summary']  = self::bb_content($content,'summary'); 
+		$s['body']     = self::bb_content($content,'content');
+		$s['verb']     = ACTIVITY_POST;
+		$s['obj_type'] = ACTIVITY_OBJ_NOTE;
+
+		$instrument = $act->get_property_obj('instrument');
+		if(! $instrument)
+			$instrument = $act->get_property_obj('instrument',$act->obj);
+
+		if($instrument && array_key_exists('type',$instrument) 
+			&& $instrument['type'] === 'Service' && array_key_exists('name',$instrument)) {
+			$s['app'] = escape_tags($instrument['name']);
+		}
+
+		if($channel['channel_system']) {
+			if(! \Zotlabs\Lib\MessageFilter::evaluate($s,get_config('system','pubstream_incl'),get_config('system','pubstream_excl'))) {
+				logger('post is filtered');
+				return;
+			}
+		}
+
+
+		if($abook) {
+			if(! post_is_importable($s,$abook[0])) {
+				logger('post is filtered');
+				return;
+			}
+		}
+
+		if($act->obj['conversation']) {
+			set_iconfig($s,'ostatus','conversation',$act->obj['conversation'],1);
+		}
+
+		$a = self::decode_taxonomy($act->obj);
+		if($a) {
+			$s['term'] = $a;
+		}
+
+		$a = self::decode_attachment($act->obj);
+		if($a) {
+			$s['attach'] = $a;
+		}
+
+		if($act->obj['type'] === 'Note' && $s['attach']) {
+			$s['body'] .= self::bb_attach($s['attach']);
+		}
+
+		// 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($act->obj['type'] === 'Video') {
+
+			$vtypes = [
+				'video/mp4',
+				'video/ogg',
+				'video/webm'
+			];
+
+			$mps = [];
+			if(array_key_exists('url',$act->obj) && is_array($act->obj['url'])) {
+				foreach($act->obj['url'] as $vurl) {
+					if(in_array($vurl['mimeType'], $vtypes)) {
+						if(! array_key_exists('width',$vurl)) {
+							$vurl['width'] = 0;
+						}
+						$mps[] = $vurl;
+					}
+				}
+			}
+			if($mps) {
+				usort($mps,'as_vid_sort');
+				foreach($mps as $m) {
+					if(intval($m['width']) < 500) {
+						$s['body'] .= "\n\n" . '[video]' . $m['href'] . '[/video]';
+						break;
+					}
+				}
+			}
+		}
+
+		if($act->recips && (! in_array(ACTIVITY_PUBLIC_INBOX,$act->recips)))
+			$s['item_private'] = 1;
+
+		set_iconfig($s,'activitypub','recips',$act->raw_recips);
+		if($parent) {
+			set_iconfig($s,'activitypub','rawmsg',$act->raw,1);
+		}
+
+		$x = null;
+
+		$r = q("select created, edited from item where mid = '%s' and uid = %d limit 1",
+			dbesc($s['mid']),
+			intval($s['uid'])
+		);
+		if($r) {
+			if($s['edited'] > $r[0]['edited']) {
+				$x = item_store_update($s);
+			}
+			else {
+				return;
+			}
+		}
+		else {
+			$x = item_store($s);
+		}
+
+		if(is_array($x) && $x['item_id']) {
+			if($parent) {
+				if($s['owner_xchan'] === $channel['channel_hash']) {
+					// We are the owner of this conversation, so send all received comments back downstream
+					Zotlabs\Daemon\Master::Summon(array('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']);
+		}
+
+	}
+
+
+	static function decode_note($act) {
+
+		$s = [];
+
+
+
+		$content = self::get_content($act->obj);
+
+		$s['owner_xchan']  = $act->actor['id'];
+		$s['author_xchan'] = $act->actor['id'];
+
+		$s['mid']        = $act->id;
+		$s['parent_mid'] = $act->parent_id;
+
+
+		if($act->data['published']) {
+			$s['created'] = datetime_convert('UTC','UTC',$act->data['published']);
+		}
+		elseif($act->obj['published']) {
+			$s['created'] = datetime_convert('UTC','UTC',$act->obj['published']);
+		}
+		if($act->data['updated']) {
+			$s['edited'] = datetime_convert('UTC','UTC',$act->data['updated']);
+		}
+		elseif($act->obj['updated']) {
+			$s['edited'] = datetime_convert('UTC','UTC',$act->obj['updated']);
+		}
+
+		if(! $s['created'])
+			$s['created'] = datetime_convert();
+
+		if(! $s['edited'])
+			$s['edited'] = $s['created'];
+
+		if(in_array($act->type,['Announce'])) {
+			$root_content = self::get_content($act->raw);
+
+			$s['title']    = self::bb_content($root_content,'name');
+			$s['summary']  = self::bb_content($root_content,'summary');
+			$s['body']     = (self::bb_content($root_content,'bbcode') ? : self::bb_content($root_content,'content'));
+
+			if(strpos($s['body'],'[share') === false) {
+
+				// @fixme - error check and set defaults
+
+				$name = urlencode($act->obj['actor']['name']);
+				$profile = $act->obj['actor']['id'];
+				$photo = $act->obj['icon']['url'];
+
+				$s['body'] .= "\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'] . 
+				"']";
+			}
+		}
+		else {
+			$s['title']    = self::bb_content($content,'name');
+			$s['summary']  = self::bb_content($content,'summary');
+			$s['body']     = (self::bb_content($content,'bbcode') ? : self::bb_content($content,'content'));
+		}
+
+		$s['verb']     = self::activity_mapper($act->type);
+
+		if($act->type === 'Tombstone') {
+			$s['item_deleted'] = 1;
+		}
+
+		$s['obj_type'] = self::activity_obj_mapper($act->obj['type']);
+		$s['obj']      = $act->obj;
+
+		$instrument = $act->get_property_obj('instrument');
+		if(! $instrument)
+			$instrument = $act->get_property_obj('instrument',$act->obj);
+
+		if($instrument && array_key_exists('type',$instrument) 
+			&& $instrument['type'] === 'Service' && array_key_exists('name',$instrument)) {
+			$s['app'] = escape_tags($instrument['name']);
+		}
+
+		$a = self::decode_taxonomy($act->obj);
+		if($a) {
+			$s['term'] = $a;
+		}
+
+		$a = self::decode_attachment($act->obj);
+		if($a) {
+			$s['attach'] = $a;
+		}
+
+		// 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($act->obj['type'] === 'Video') {
+
+			$vtypes = [
+				'video/mp4',
+				'video/ogg',
+				'video/webm'
+			];
+
+			$mps = [];
+			if(array_key_exists('url',$act->obj) && is_array($act->obj['url'])) {
+				foreach($act->obj['url'] as $vurl) {
+					if(in_array($vurl['mimeType'], $vtypes)) {
+						if(! array_key_exists('width',$vurl)) {
+							$vurl['width'] = 0;
+						}
+						$mps[] = $vurl;
+					}
+				}
+			}
+			if($mps) {
+				usort($mps,'as_vid_sort');
+				foreach($mps as $m) {
+					if(intval($m['width']) < 500) {
+						$s['body'] .= "\n\n" . '[video]' . $m['href'] . '[/video]';
+						break;
+					}
+				}
+			}
+		}
+
+		if($act->recips && (! in_array(ACTIVITY_PUBLIC_INBOX,$act->recips)))
+			$s['item_private'] = 1;
+
+		set_iconfig($s,'activitypub','recips',$act->raw_recips);
+
+		if($parent) {
+			set_iconfig($s,'activitypub','rawmsg',$act->raw,1);
+		}
+
+		return $s;
+
+	}
+
+
+
+	static function announce_note($channel,$observer_hash,$act) {
+
+		$s = [];
+
+		$is_sys_channel = is_sys_channel($channel['channel_id']);
+
+		// 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) && in_array(ACTIVITY_PUBLIC_INBOX, $act->obj['to'])) ? true : false);
+
+		if(! perm_is_allowed($channel['channel_id'],$observer_hash,'send_stream') && ! ($is_sys_channel && $pubstream)) {
+			logger('no permission');
+			return;
+		}
+
+		$content = self::get_content($act->obj);
+
+		if(! $content) {
+			logger('no content');
+			return;
+		}
+
+		$s['owner_xchan'] = $s['author_xchan'] = $observer_hash;
+
+		$s['aid'] = $channel['channel_account_id'];
+		$s['uid'] = $channel['channel_id'];
+		$s['mid'] = urldecode($act->obj['id']);
+		$s['plink'] = urldecode($act->obj['id']);
+
+		if(! $s['created'])
+			$s['created'] = datetime_convert();
+
+		if(! $s['edited'])
+			$s['edited'] = $s['created'];
+
+
+		$s['parent_mid'] = $s['mid'];
+
+		$s['verb']     = ACTIVITY_POST;
+		$s['obj_type'] = ACTIVITY_OBJ_NOTE;
+		$s['app']      = t('ActivityPub');
+
+		if($channel['channel_system']) {
+			if(! \Zotlabs\Lib\MessageFilter::evaluate($s,get_config('system','pubstream_incl'),get_config('system','pubstream_excl'))) {
+				logger('post is filtered');
+				return;
+			}
+		}
+
+		$abook = q("select * from abook where abook_xchan = '%s' and abook_channel = %d limit 1",
+			dbesc($observer_hash),
+			intval($channel['channel_id'])
+		);
+
+		if($abook) {
+			if(! post_is_importable($s,$abook[0])) {
+				logger('post is filtered');
+				return;
+			}
+		}
+
+		if($act->obj['conversation']) {
+			set_iconfig($s,'ostatus','conversation',$act->obj['conversation'],1);
+		}
+
+		$a = self::decode_taxonomy($act->obj);
+		if($a) {
+			$s['term'] = $a;
+		}
+
+		$a = self::decode_attachment($act->obj);
+		if($a) {
+			$s['attach'] = $a;
+		}
+
+		$body = "[share author='" . urlencode($act->sharee['name']) . 
+			"' profile='" . $act->sharee['url'] . 
+			"' avatar='" . $act->sharee['photo_s'] . 
+			"' link='" . ((is_array($act->obj['url'])) ? $act->obj['url']['href'] : $act->obj['url']) . 
+			"' auth='" . ((is_matrix_url($act->obj['url'])) ? 'true' : 'false' ) . 
+			"' posted='" . $act->obj['published'] . 
+			"' message_id='" . $act->obj['id'] . 
+		"']";
+
+		if($content['name'])
+			$body .= self::bb_content($content,'name') . "\r\n";
+
+		$body .= self::bb_content($content,'content');
+
+		if($act->obj['type'] === 'Note' && $s['attach']) {
+			$body .= self::bb_attach($s['attach']);
+		}
+
+		$body .= "[/share]";
+
+		$s['title']    = self::bb_content($content,'name');
+		$s['body']     = $body;
+
+		if($act->recips && (! in_array(ACTIVITY_PUBLIC_INBOX,$act->recips)))
+			$s['item_private'] = 1;
+
+		set_iconfig($s,'activitypub','recips',$act->raw_recips);
+
+		$r = q("select created, edited from item where mid = '%s' and uid = %d limit 1",
+			dbesc($s['mid']),
+			intval($s['uid'])
+		);
+		if($r) {
+			if($s['edited'] > $r[0]['edited']) {
+				$x = item_store_update($s);
+			}
+			else {
+				return;
+			}
+		}
+		else {
+			$x = item_store($s);
+		}
+
+
+		if(is_array($x) && $x['item_id']) {
+			if($parent) {
+				if($s['owner_xchan'] === $channel['channel_hash']) {
+					// We are the owner of this conversation, so send all received comments back downstream
+					Zotlabs\Daemon\Master::Summon(array('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']);
+		}
+
+
+	}
+
+	static function like_note($channel,$observer_hash,$act) {
+
+		$s = [];
+
+		$parent = $act->obj['id'];
+	
+		if($act->type === 'Like')
+			$s['verb'] = ACTIVITY_LIKE;
+		if($act->type === 'Dislike')
+			$s['verb'] = ACTIVITY_DISLIKE;
+
+		if(! $parent)
+			return;
+
+		$r = q("select * from item where uid = %d and ( mid = '%s' or  mid = '%s' ) limit 1",
+			intval($channel['channel_id']),
+			dbesc($parent),
+			dbesc(urldecode(basename($parent)))
+		);
+
+		if(! $r) {
+			logger('parent not found.');
+			return;
+		}
+
+		xchan_query($r);
+		$parent_item = $r[0];
+
+		if($parent_item['owner_xchan'] === $channel['channel_hash']) {
+			if(! perm_is_allowed($channel['channel_id'],$observer_hash,'post_comments')) {
+				logger('no comment permission.');
+				return;
+			}
+		}
+
+		if($parent_item['mid'] === $parent_item['parent_mid']) {
+			$s['parent_mid'] = $parent_item['mid'];
+		}
+		else {
+			$s['thr_parent'] = $parent_item['mid'];
+			$s['parent_mid'] = $parent_item['parent_mid'];
+		}
+
+		$s['owner_xchan'] = $parent_item['owner_xchan'];
+		$s['author_xchan'] = $observer_hash;
+	
+		$s['aid'] = $channel['channel_account_id'];
+		$s['uid'] = $channel['channel_id'];
+		$s['mid'] = $act->id;
+
+		if(! $s['parent_mid'])
+			$s['parent_mid'] = $s['mid'];
+	
+
+		$post_type = (($parent_item['resource_type'] === 'photo') ? t('photo') : t('status'));
+
+		$links = array(array('rel' => 'alternate','type' => 'text/html', 'href' => $parent_item['plink']));
+		$objtype = (($parent_item['resource_type'] === 'photo') ? ACTIVITY_OBJ_PHOTO : ACTIVITY_OBJ_NOTE );
+
+		$body = $parent_item['body'];
+
+		$z = q("select * from xchan where xchan_hash = '%s' limit 1",
+			dbesc($parent_item['author_xchan'])
+		);
+		if($z)
+			$item_author = $z[0];		
+
+		$object = json_encode(array(
+			'type'    => $post_type,
+			'id'      => $parent_item['mid'],
+			'parent'  => (($parent_item['thr_parent']) ? $parent_item['thr_parent'] : $parent_item['parent_mid']),
+			'link'    => $links,
+			'title'   => $parent_item['title'],
+			'content' => $parent_item['body'],
+			'created' => $parent_item['created'],
+			'edited'  => $parent_item['edited'],
+			'author'  => array(
+				'name'     => $item_author['xchan_name'],
+				'address'  => $item_author['xchan_addr'],
+				'guid'     => $item_author['xchan_guid'],
+				'guid_sig' => $item_author['xchan_guid_sig'],
+				'link'     => array(
+					array('rel' => 'alternate', 'type' => 'text/html', 'href' => $item_author['xchan_url']),
+					array('rel' => 'photo', 'type' => $item_author['xchan_photo_mimetype'], 'href' => $item_author['xchan_photo_m'])),
+				),
+			), JSON_UNESCAPED_SLASHES
+		);
+
+		if($act->type === 'Like')
+			$bodyverb = t('%1$s likes %2$s\'s %3$s');
+		if($act->type === 'Dislike')
+			$bodyverb = t('%1$s doesn\'t like %2$s\'s %3$s');
+
+		$ulink = '[url=' . $item_author['xchan_url'] . ']' . $item_author['xchan_name'] . '[/url]';
+		$alink = '[url=' . $parent_item['author']['xchan_url'] . ']' . $parent_item['author']['xchan_name'] . '[/url]';
+		$plink = '[url='. z_root() . '/display/' . urlencode($act->id) . ']' . $post_type . '[/url]';
+		$s['body'] =  sprintf( $bodyverb, $ulink, $alink, $plink );
+
+		$s['app']  = t('ActivityPub');
+
+		// set the route to that of the parent so downstream hubs won't reject it.
+
+		$s['route'] = $parent_item['route'];
+		$s['item_private'] = $parent_item['item_private'];
+		$s['obj_type'] = $objtype;
+		$s['obj'] = $object;
+
+		if($act->obj['conversation']) {
+			set_iconfig($s,'ostatus','conversation',$act->obj['conversation'],1);
+		}
+
+		if($act->recips && (! in_array(ACTIVITY_PUBLIC_INBOX,$act->recips)))
+			$s['item_private'] = 1;
+
+		set_iconfig($s,'activitypub','recips',$act->raw_recips);
+
+		$result = item_store($s);
+
+		if($result['success']) {
+			// if the message isn't already being relayed, notify others
+			if(intval($parent_item['item_origin']))
+					Zotlabs\Daemon\Master::Summon(array('Notifier','comment-import',$result['item_id']));
+				sync_an_item($channel['channel_id'],$result['item_id']);
+		}
+
+		return;
+	}
+
+
+	static function bb_attach($attach) {
+
+		$ret = false;
+
+		foreach($attach as $a) {
+			if(strpos($a['type'],'image') !== false) {
+				$ret .= "\n\n" . '[img]' . $a['href'] . '[/img]';
+			}
+			if(array_key_exists('type',$a) && strpos($a['type'], 'video') === 0) {
+				$ret .= "\n\n" . '[video]' . $a['href'] . '[/video]';
+			}
+			if(array_key_exists('type',$a) && strpos($a['type'], 'audio') === 0) {
+				$ret .= "\n\n" . '[audio]' . $a['href'] . '[/audio]';
+			}
+		}
+
+		return $ret;
+	}
+
+
+
+	static function bb_content($content,$field) {
+
+		require_once('include/html2bbcode.php');
+
+		$ret = false;
+
+		if(is_array($content[$field])) {
+			foreach($content[$field] as $k => $v) {
+				$ret .= '[language=' . $k . ']' . html2bbcode($v) . '[/language]';
+			}
+		}
+		else {
+			if($field === 'bbcode' && array_key_exists('bbcode',$content)) {
+				$ret = $content[$field];
+			}
+			else {
+				$ret = html2bbcode($content[$field]);
+			}
+		}
+
+		return $ret;
+	}
+
+
+	static function get_content($act) {
+
+		$content = [];
+		if (! $act) {
+			return $content;
+		}
+
+		foreach ([ 'name', 'summary', 'content' ] as $a) {
+			if (($x = self::get_textfield($act,$a)) !== false) {
+				$content[$a] = $x;
+			}
+		}
+		if (array_key_exists('source',$act) && array_key_exists('mediaType',$act['source'])) {
+			if ($act['source']['mediaType'] === 'text/bbcode') {
+				$content['bbcode'] = purify_html($act['source']['content']);
+			}
+		}
+
+		return $content;
+	}
+
+
+	static function get_textfield($act,$field) {
+	
+		$content = false;
+
+		if(array_key_exists($field,$act) && $act[$field])
+			$content = 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)] = purify_html($v);
+			}
+		}
+		return $content;
+	}
+}
\ No newline at end of file
diff --git a/Zotlabs/Lib/Group.php b/Zotlabs/Lib/Group.php
new file mode 100644
index 000000000..f136a3614
--- /dev/null
+++ b/Zotlabs/Lib/Group.php
@@ -0,0 +1,405 @@
+may apply to this group and any future members. If this is not what you intended, please create another group with a different name.') . EOL); 
+				}
+				return true;
+			}
+
+			do {
+				$dups = false;
+				$hash = random_string(32) . str_replace(['<','>'],['.','.'], $name);
+
+				$r = q("SELECT id FROM groups WHERE hash = '%s' LIMIT 1", dbesc($hash));
+				if($r)
+					$dups = true;
+			} while($dups == true);
+
+
+			$r = q("INSERT INTO groups ( hash, uid, visible, gname )
+				VALUES( '%s', %d, %d, '%s' ) ",
+				dbesc($hash),
+				intval($uid),
+				intval($public),
+				dbesc($name)
+			);
+			$ret = $r;
+		}
+
+		Libsync::build_sync_packet($uid,null,true);
+		return $ret;
+	}
+
+
+	static function remove($uid,$name) {
+		$ret = false;
+		if(x($uid) && x($name)) {
+			$r = q("SELECT id, hash FROM groups WHERE uid = %d AND gname = '%s' LIMIT 1",
+				intval($uid),
+				dbesc($name)
+			);
+			if($r) {
+				$group_id = $r[0]['id'];
+				$group_hash = $r[0]['hash'];
+			}
+
+			if(! $group_id)
+				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 = $r[0];
+				$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 group_member WHERE uid = %d AND gid = %d ",
+				intval($uid),
+				intval($group_id)
+			);
+
+			// remove group
+			$r = q("UPDATE groups SET deleted = 1 WHERE uid = %d AND gname = '%s'",
+				intval($uid),
+				dbesc($name)
+			);
+
+			$ret = $r;
+
+		}
+
+		Libsync::build_sync_packet($uid,null,true);
+
+		return $ret;
+	}
+
+
+	static function byname($uid,$name) {
+		if((! $uid) || (! strlen($name)))
+			return false;
+		$r = q("SELECT * FROM groups WHERE uid = %d AND gname = '%s' LIMIT 1",
+			intval($uid),
+			dbesc($name)
+		);
+		if($r)
+			return $r[0]['id'];
+		return false;
+	}
+
+
+	static function rec_byhash($uid,$hash) {
+		if((! $uid) || (! strlen($hash)))
+			return false;
+		$r = q("SELECT * FROM groups WHERE uid = %d AND hash = '%s' LIMIT 1",
+			intval($uid),
+			dbesc($hash)
+		);
+		if($r)
+			return $r[0];
+		return false;
+	}
+
+
+	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 group_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;
+	}
+
+
+	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 group_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
+	 	if(! $r)
+			$r = q("INSERT INTO group_member (uid, gid, xchan)
+				VALUES( %d, %d, '%s' ) ",
+				intval($uid),
+				intval($gid),
+				dbesc($member)
+		);
+
+		Libsync::build_sync_packet($uid,null,true);
+
+		return $r;
+	}
+
+
+	static function members($gid) {
+		$ret = array();
+		if(intval($gid)) {
+			$r = q("SELECT * FROM group_member 
+				LEFT JOIN abook ON abook_xchan = group_member.xchan left join xchan on xchan_hash = abook_xchan
+				WHERE gid = %d AND abook_channel = %d and group_member.uid = %d and xchan_deleted = 0 and abook_self = 0 and abook_blocked = 0 and abook_pending = 0 ORDER BY xchan_name ASC ",
+				intval($gid),
+				intval(local_channel()),
+				intval(local_channel())
+			);
+			if($r)
+				$ret = $r;
+		}
+		return $ret;
+	}
+
+	static function members_xchan($gid) {
+		$ret = [];
+		if(intval($gid)) {
+			$r = q("SELECT xchan FROM group_member WHERE gid = %d AND uid = %d",
+				intval($gid),
+				intval(local_channel())
+			);
+			if($r) {
+				foreach($r as $rr) {
+					$ret[] = $rr['xchan'];
+				}
+			}
+		}
+		return $ret;
+	}
+
+	static function members_profile_xchan($uid,$gid) {
+		$ret = [];
+
+		if(intval($gid)) {
+			$r = q("SELECT abook_xchan as xchan from abook left join profile on abook_profile = profile_guid where profile.id = %d and profile.uid = %d",
+				intval($gid),
+				intval($uid)
+			);
+			if($r) {
+				foreach($r as $rr) {
+					$ret[] = $rr['xchan'];
+				}
+			}
+		}
+		return $ret;
+	}
+
+
+
+
+	static function select($uid,$group = '') {
+	
+		$grps = [];
+		$o = '';
+
+		$r = q("SELECT * FROM groups WHERE deleted = 0 AND uid = %d ORDER BY gname ASC",
+			intval($uid)
+		);
+		$grps[] = array('name' => '', 'hash' => '0', 'selected' => '');
+		if($r) {
+			foreach($r as $rr) {
+				$grps[] = array('name' => $rr['gname'], 'id' => $rr['hash'], 'selected' => (($group == $rr['hash']) ? 'true' : ''));
+			}
+
+		}
+		logger('select: ' . print_r($grps,true), LOGGER_DATA);
+
+		$o = replace_macros(get_markup_template('group_selection.tpl'), array(
+			'$label' => t('Add new connections to this privacy group'),
+			'$groups' => $grps 
+		));
+		return $o;
+	}
+
+
+
+
+	static function widget($every="connections",$each="group",$edit = false, $group_id = 0, $cid = '',$mode = 1) {
+
+		$o = '';
+
+		if(! (local_channel() && feature_enabled(local_channel(),'groups'))) {
+			return '';
+		}
+
+		$groups = array();
+
+		$r = q("SELECT * FROM groups WHERE deleted = 0 AND uid = %d ORDER BY gname ASC",
+			intval($_SESSION['uid'])
+		);
+		$member_of = array();
+		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' => "group/".$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),
+				];
+			}
+		}
+	
+	
+		$tpl = get_markup_template("group_side.tpl");
+		$o = replace_macros($tpl, array(
+			'$title'		=> t('Privacy Groups'),
+			'$edittext'     => t('Edit group'),
+			'$createtext' 	=> t('Add privacy group'),
+			'$ungrouped'    => (($every === 'contacts') ? t('Channels not in any privacy group') : ''),
+			'$groups'		=> $groups,
+			'$add'			=> t('add'),
+		));
+		
+	
+		return $o;
+	}
+
+
+	static function expand($g) {
+		if(! (is_array($g) && count($g)))
+			return array();
+
+		$ret = [];
+		$x   = [];
+
+		// private profile linked virtual groups
+
+		foreach($g as $gv) {
+			if(substr($gv,0,3) === 'vp.') {
+				$profile_hash = substr($gv,3);
+				if($profile_hash) {
+					$r = q("select abook_xchan from abook where abook_profile = '%s'",
+						dbesc($profile_hash)
+					);
+					if($r) {
+						foreach($r as $rv) {
+							$ret[] = $rv['abook_xchan'];
+						}
+					}
+				}
+			}
+			else {
+				$x[] = $gv;
+			}
+		}								 
+
+		if($x) {
+			stringify_array_elms($x,true);
+			$groups = implode(',', $x);
+			if($groups) {
+				$r = q("SELECT xchan FROM group_member WHERE gid IN ( select id from groups where hash in ( $groups ))");
+				if($r) {
+					foreach($r as $rr) {
+						$ret[] = $rr['xchan'];
+					}
+				}
+			}
+		}
+		return $ret;
+	}
+
+
+	static function member_of($c) {
+		$r = q("SELECT groups.gname, groups.id FROM groups LEFT JOIN group_member ON group_member.gid = groups.id WHERE group_member.xchan = '%s' AND groups.deleted = 0 ORDER BY groups.gname  ASC ",
+			dbesc($c)
+		);
+
+		return $r;
+
+	}
+
+	static function containing($uid,$c) {
+
+		$r = q("SELECT gid FROM group_member WHERE uid = %d AND group_member.xchan = '%s' ",
+			intval($uid),
+			dbesc($c)
+		);
+
+		$ret = array();
+		if($r) {
+			foreach($r as $rr)
+				$ret[] = $rr['gid'];
+		}
+	
+		return $ret;
+	}
+}
\ No newline at end of file
diff --git a/Zotlabs/Lib/Libsync.php b/Zotlabs/Lib/Libsync.php
new file mode 100644
index 000000000..938d484b7
--- /dev/null
+++ b/Zotlabs/Lib/Libsync.php
@@ -0,0 +1,1019 @@
+ $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'] = array();
+			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, gname as name from groups where uid = %d",
+				intval($uid)
+			);
+			if($r)
+				$info['collections'] = $r;
+
+			$r = q("select groups.hash as collection, group_member.xchan as member from groups left join group_member on groups.id = group_member.gid where group_member.uid = %d",
+				intval($uid)
+			);
+			if($r)
+				$info['collection_members'] = $r;
+		}
+
+		$interval = ((get_config('system','delivery_interval') !== false)
+			? intval(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(array(
+				'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;
+			}
+
+
+			\Zotlabs\Daemon\Master::Summon(array('Deliver', $hash));
+			$total = $total - 1;
+
+			if($interval && $total)
+				@time_sleep_until(microtime(true) + (float) $interval);
+		}
+	}
+
+	/**
+	 * @brief
+	 *
+	 * @param array $sender
+	 * @param array $arr
+	 * @param array $deliveries
+	 * @return array
+	 */
+
+	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) {
+			$r = q("select * from channel where channel_hash = '%s' limit 1",
+				dbesc($sender)
+			);
+
+			$DR = new \Zotlabs\Lib\DReport(z_root(),$sender,$d,'sync');
+
+			if (! $r) {
+				$DR->update('recipient not found');
+				$result[] = $DR->get();
+				continue;
+			}
+
+			$channel = $r[0];
+
+			$DR->set_name($channel['channel_name'] . ' <' . channel_reddress($channel) . '>');
+
+			$max_friends = service_class_fetch($channel['channel_id'],'total_channels');
+			$max_feeds = account_service_class_fetch($channel['channel_account_id'],'total_feeds');
+
+			if($channel['channel_hash'] != $sender) {
+				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('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('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));
+	
+			// deprecated, maintaining for a few months for upward compatibility
+			// this should sync webpages, but the logic is a bit subtle
+
+			if(array_key_exists('item_id',$arr) && $arr['item_id'])
+				sync_items($channel,$arr['item_id']);
+
+			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'];
+
+				}
+
+				$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'
+				];
+
+				$clean = array();
+				foreach($arr['channel'] as $k => $v) {
+					if(in_array($k,$disallowed))
+						continue;
+					$clean[$k] = $v;
+				}
+				if(count($clean)) {
+					foreach($clean as $k => $v) {
+						$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 = array('abook_id','abook_account','abook_channel','abook_rating','abook_rating_text','abook_not_here');
+
+				$fields = db_columns($abook);
+
+				foreach($arr['abook'] as $abook) {
+
+					$abconfig = null;
+
+					if(array_key_exists('abconfig',$abook) && is_array($abook['abconfig']) && count($abook['abconfig']))
+						$abconfig = $abook['abconfig'];
+
+					if(! array_key_exists('abook_blocked',$abook)) {
+						// convert from redmatrix
+						$abook['abook_blocked']     = (($abook['abook_flags'] & 0x0001) ? 1 : 0);
+						$abook['abook_ignored']     = (($abook['abook_flags'] & 0x0002) ? 1 : 0);
+						$abook['abook_hidden']      = (($abook['abook_flags'] & 0x0004) ? 1 : 0);
+						$abook['abook_archived']    = (($abook['abook_flags'] & 0x0008) ? 1 : 0);
+						$abook['abook_pending']     = (($abook['abook_flags'] & 0x0010) ? 1 : 0);
+						$abook['abook_unconnected'] = (($abook['abook_flags'] & 0x0020) ? 1 : 0);
+						$abook['abook_self']        = (($abook['abook_flags'] & 0x0080) ? 1 : 0);
+						$abook['abook_feed']        = (($abook['abook_flags'] & 0x0100) ? 1 : 0);
+					}
+
+					$clean = array();
+					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
+
+					if($abook['abook_xchan'] && $abook['xchan_addr']) {
+						$h = Libzot::get_hublocs($abook['abook_xchan']);
+						if(! $h) {
+							$xhash = import_author_xchan(encode_item_xchan($abook));
+							if(! $xhash) {
+								logger('Import of ' . $abook['xchan_addr'] . ' failed.');
+								continue;
+							}
+						}
+					}
+
+					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;
+
+					if(array_key_exists('abook_instance',$clean) && $clean['abook_instance'] && strpos($clean['abook_instance'],z_root()) === false) {
+						$clean['abook_not_here'] = 1;
+					}
+
+
+					$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']);
+						}
+					}
+				}
+			}
+
+			// sync collections (privacy groups) oh joy...
+
+			if(array_key_exists('collections',$arr) && is_array($arr['collections']) && count($arr['collections'])) {
+				$x = q("select * from groups 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 groups 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 group_member where gid = %d",
+									intval($y['id'])
+								);
+							}
+						}
+					}
+					if(! $found) {
+						$r = q("INSERT INTO groups ( hash, uid, visible, deleted, gname )
+							VALUES( '%s', %d, %d, %d, '%s' ) ",
+							dbesc($cl['collection']),
+							intval($channel['channel_id']),
+							intval($cl['visible']),
+							intval($cl['deleted']),
+							dbesc($cl['name'])
+						);
+					}
+
+					// 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 group_member where gid = %d",
+									intval($y['id'])
+								);
+								q("update groups 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 groups 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 = array();
+					foreach($arr['collection_members'] as $cm) {
+						if(! array_key_exists($cm['collection'],$members))
+							$members[$cm['collection']] = array();
+
+						$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 group_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 group_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 group_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 group_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) {
+						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 = array();
+					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
+			 */
+			call_hooks('process_channel_sync_delivery', $addon);
+
+			$DR = new \Zotlabs\Lib\DReport(z_root(),$d,$d,'sync','channel sync delivered');
+
+			$DR->set_name($channel['channel_name'] . ' <' . channel_reddress($channel) . '>');
+
+			$result[] = $DR->get();
+		}
+
+		return $result;
+	}
+
+	/**
+	 * @brief Synchronises locations.
+	 *
+	 * @param array $sender
+	 * @param array $arr
+	 * @param boolean $absolute (optional) default false
+	 * @return array
+	 */
+
+	static function sync_locations($sender, $arr, $absolute = false) {
+
+		$ret = array();
+
+		if($arr['locations']) {
+
+			if($absolute)
+				self::check_location_move($sender['hash'],$arr['locations']);
+
+			$xisting = q("select * from hubloc where hubloc_hash = '%s'",
+				dbesc($sender['hash'])
+			);
+
+			// 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) {
+				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;
+				}
+
+				// Catch some malformed entries from the past which still exist
+
+				if(strpos($location['address'],'/') !== false)
+					$location['address'] = substr($location['address'],0,strpos($location['address'],'/'));
+
+				// 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_site_id = '%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['site_id']),
+					dbesc($location['host']),
+					dbesc($location['address']),
+					dbesc($location['callback']),
+					dbesc($location['sitekey'])
+				);
+				if($r) {
+					logger('Hub exists: ' . $location['url'], LOGGER_DEBUG);
+	
+					// 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(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_connected < '%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'])) {
+						q("update hubloc set hubloc_error = 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;
+						}
+					}
+					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'   => 'zot6',
+						'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;
+	}
+
+
+	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'];
+				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']);
+
+	}
+
+}
\ No newline at end of file
diff --git a/Zotlabs/Lib/Libzot.php b/Zotlabs/Lib/Libzot.php
new file mode 100644
index 000000000..ec9db4ce1
--- /dev/null
+++ b/Zotlabs/Lib/Libzot.php
@@ -0,0 +1,2849 @@
+ $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 'aes256cbc' if no matches are found.
+	 */
+
+	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
+		 */
+	
+		call_hooks('zot_best_algorithm', $x);
+
+		if($x['result'])
+			return $x['result'];
+
+		if($methods) {
+			$x = explode(',', $methods);
+			if($x) {
+				$y = crypto_methods();
+				if($y) {
+					foreach($y as $yv) {
+						$yv = trim($yv);
+						if(in_array($yv, $x)) {
+							return($yv);
+						}
+					}
+				}
+			}
+		}
+
+		return '';
+	}
+
+
+	/**
+	 * @brief send a zot message
+	 *
+	 * @see z_post_url()
+	 *
+	 * @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
+	 */
+
+	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'
+			];
+
+			$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 targetted discovery (a zot-finger request requesting permissions for the local
+	 * channel). These are decoded here, and if necessary and 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.
+	 *
+	 * @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
+	 */
+
+	static function refresh($them, $channel = null, $force = 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 the old install.
+			// We'll order by reverse id to try and pick off the newest one first and hopefully end up with the
+			// correct hubloc. If this doesn't work we may have to re-write this section to try them all.
+
+			if(array_key_exists('xchan_addr',$them) && $them['xchan_addr']) {
+				$r = q("select hubloc_id_url, hubloc_primary from hubloc where hubloc_addr = '%s' order by hubloc_id desc",
+					dbesc($them['xchan_addr'])
+				);
+			}
+			if(! $r) {
+				$r = q("select hubloc_id_url, hubloc_primary from hubloc where hubloc_hash = '%s' 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'];
+						$record = $rr;
+					}
+				}
+				if (! $url) {
+					$url = $r[0]['hubloc_id_url'];
+				}
+			}
+		}
+		if (! $url) {
+			logger('zot_refresh: no url');
+			return false;
+		}
+
+		$s = q("select site_dead from site where site_url = '%s' limit 1",
+			dbesc($url)
+		);
+
+		if($s && intval($s[0]['site_dead']) && (! $force)) {
+			logger('zot_refresh: 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: ' . print_r($hsig,true));
+			return $result;
+		}
+
+
+		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;
+
+		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'])))
+						\Zotlabs\Daemon\Master::Summon(array('Onepoll',$r[0]['abook_id']));
+				}
+			}
+			else {
+
+				$p = \Zotlabs\Access\Permissions::connect_perms($channel['channel_id']);
+				$my_perms = \Zotlabs\Access\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');
+				if($closeness === false)
+					$closeness = 80;
+
+				$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(! \Zotlabs\Access\Permissions::PermsCompare($new_perms,$previous_perms))
+							\Zotlabs\Daemon\Master::Summon(array('Notifier','permissions_create',$new_connection[0]['abook_id']));
+						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'])))
+								\Zotlabs\Daemon\Master::Summon(array('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 = Group::rec_byhash($channel['channel_id'],$default_group);
+								if($g)
+									Group::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['abook_xchan']);
+						if($abconfig)
+							$new_connection['abconfig'] = $abconfig;
+
+						Libsync::build_sync_packet($channel['channel_id'], array('abook' => $new_connection));
+					}
+				}
+
+			}
+			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 boolean $multiple (optional) default false
+	 *
+	 * @return array|null
+	 *   * null if site is blacklisted or not found
+	 *   * otherwise an array with an hubloc record
+	 */
+
+	static function gethub($arr, $multiple = false) {
+
+		if($arr['id'] && $arr['id_sig'] && $arr['location'] && $arr['location_sig']) {
+
+			if(! check_siteallowed($arr['location'])) {
+				logger('blacklisted 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' $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;
+	}
+
+
+
+
+	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('blacklisted site: ' . $r[0]['hubloc_url']);
+			return null;
+		}
+
+		if(! check_channelallowed($r[0]['hubloc_hash'])) {
+			logger('blacklisted 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
+	 */
+
+	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
+	 */
+
+	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
+		 */
+		call_hooks('import_xchan', $arr);
+
+		$ret = array('success' => false);
+		$dirmode = intval(get_config('system','directory_mode'));
+
+		$changed = false;
+		$what = '';
+
+		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']) && $r[0]['xchan_photo_date'] != $arr['photo']['updated']) {
+				$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 = $pubforum_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(intval($r[0]['xchan_deleted']) != intval($arr['deleted']))
+				$deleted_changed = 1;
+			if(intval($r[0]['xchan_pubforum']) != intval($arr['public_forum']))
+				$pubforum_changed = 1;
+
+			if($arr['protocols']) {
+				$protocols = implode(',',$arr['protocols']);
+				if($protocols !== 'zot6') {
+					set_xconfig($xchan_hash,'system','protocols',$protocols);
+				}
+				else {
+					del_xconfig($xchan_hash,'system','protocols');
+				}
+			}
+
+			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'])
+				|| $hidden_changed || $adult_changed || $deleted_changed || $pubforum_changed ) {
+				$rup = q("update xchan set 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_pubforum = %d,
+					xchan_addr = '%s', xchan_url = '%s' where xchan_hash = '%s'",
+					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($arr['public_forum']),
+					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;
+
+			$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_mimetype'],
+					'xchan_photo_l'        => $arr['photo'],
+					'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'        => 'zot6',
+					'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_pubforum'       => $arr['public_forum']
+				]
+			);
+
+			$what .= 'new_xchan';
+			$changed = true;
+		}
+
+		if($import_photos) {
+
+			require_once('include/photo/photo_driver.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 = z_fetch_url($arr['photo']['url'], true);
+				if($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_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_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($xchan_hash)
+					);
+				}
+				else {
+					$r = q("update xchan set xchan_photo_date = '%s', xchan_photo_l = '%s', xchan_photo_m = '%s', xchan_photo_s = '%s', xchan_photo_mimetype = '%s'
+						where xchan_hash = '%s'",
+						dbescdate(datetime_convert('UTC','UTC',$arr['photo_updated'])),
+						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($s['change_message'])
+				$what .= $s['change_message'];
+			if($s['changed'])
+				$changed = $s['changed'];
+			if($s['message'])
+				$ret['message'] .= $s['message'];
+		}
+
+		// Which entries in the update table are we interested in updating?
+
+		$address = (($ud_arr && $ud_arr['ud_addr']) ? $ud_arr['ud_addr'] : $arr['address']);
+
+
+		// 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
+	 */
+
+	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(! $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(array_key_exists('delivery_report',$x) && is_array($x['delivery_report'])) { 
+			foreach($x['delivery_report'] as $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 ) values ( '%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($xx['date'])),
+						dbesc($xx['sender'])
+					);
+				}
+			}
+
+			// 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.
+	 *
+	 * @see zot_import()
+	 *
+	 * @param array $arr
+	 *     decrypted and json decoded notify packet from remote site
+	 * @return array from zot_import()
+	 */
+
+	static function fetch($arr) {
+
+		logger('zot_fetch: ' . print_r($arr,true), LOGGER_DATA, LOG_DEBUG);
+
+		return self::import($arr);
+
+	}
+
+	/**
+	 * @brief Process incoming array of messages.
+	 *
+	 * Process an incoming array of messages which were obtained via pickup, 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
+	 */
+
+	static function import($arr) {
+
+		$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 = ((array_key_exists('message_id',$env)) ? true : false);
+		if($message_request)
+			logger('processing message request');
+
+		$has_data = array_key_exists('data',$env) && $env['data'];
+		$data = (($has_data) ? $env['data'] : false);
+		
+		$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);
+
+
+		}
+
+		$deliveries = array_unique($deliveries);
+
+		if(! $deliveries) {
+			logger('No deliveries on this site');
+			return;
+		}
+
+
+		if($has_data) {
+
+			if(in_array($env['type'],['activity','response'])) {
+
+				if($env['encoding'] === 'zot') {
+					$arr = get_item_elements($data);
+	
+					$v = validate_item_elements($data,$arr);
+					
+					if(! $v['success']) {
+						logger('Activity rejected: ' . $v['message'] . ' ' . print_r($data,true));
+						return;
+					}
+				}
+				elseif($env['encoding'] === 'activitystreams') {
+
+					$AS = new \Zotlabs\Lib\ActivityStreams($data);
+					if(! $AS->is_valid()) {
+						logger('Activity rejected: ' . print_r($data,true));
+						return;
+					}
+					$arr = \Zotlabs\Lib\Activity::decode_note($AS);
+
+					logger($AS->debug());
+
+					$r = q("select hubloc_hash from hubloc where hubloc_id_url = '%s' limit 1",
+						dbesc($AS->actor['id'])
+					); 
+
+					if($r) {
+						$arr['author_xchan'] = $r[0]['hubloc_hash'];
+					}
+					// @fixme (in individual delivery, change owner if needed)
+					$arr['owner_xchan'] = $env['sender'];						
+					if($private) {
+						$arr['item_private'] = true;
+					}
+					// @fixme - spoofable
+					if($AS->data['hubloc']) {
+						$arr['item_verified'] = true;
+					}
+					if($AS->data['signed_data']) {
+						IConfig::Set($arr,'activitystreams','signed_data',$AS->data['signed_data'],false);
+					}
+
+				}
+
+				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'],$arr,$deliveries,$relay,false,$message_request);
+			}
+			elseif($env['type'] === 'sync') {
+				// $arr = get_channelsync_elements($data);
+
+				$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);
+
+				$result = Libsync::process_channel_sync_delivery($env['sender'],$arr,$deliveries);
+			}
+		}
+		if ($result) {
+			$return = array_merge($return, $result);
+		}		
+		return $return;
+	}
+
+
+	static function is_top_level($env) {
+		if($env['encoding'] === 'zot' && array_key_exists('flags',$env) && in_array('thread_parent', $env['flags'])) {
+			return true;
+		}
+		if($env['encoding'] === 'activitystreams') {
+			if(array_key_exists('inReplyTo',$env['data']) && $env['data']['inReplyTo']) {
+				return false;
+			}
+			return true;
+		}
+		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
+	 */
+
+	static function public_recips($msg) {
+
+		require_once('include/channel.php');
+
+		$check_mentions = false;
+		$include_sys = false;
+
+		if($msg['type'] === 'activity') {
+			$disable_discover_tab = get_config('system','disable_discover_tab') || get_config('system','disable_discover_tab') === false;
+			if(! $disable_discover_tab)
+				$include_sys = true;
+
+			$perm = 'send_stream';
+
+			if(self::is_top_level($msg)) {
+				$check_mentions = true;
+			}
+		}
+		elseif($msg['type'] === 'mail')
+			$perm = 'post_mail';
+
+		$r = [];
+
+		$c = q("select channel_id, channel_hash from channel where channel_removed = 0");
+
+		if($c) {
+			foreach($c as $cc) {
+				if(perm_is_allowed($cc['channel_id'],$msg['sender'],$perm)) {
+					$r[] = $cc['channel_hash'];
+				}
+			}
+		}
+
+		if($include_sys) {
+			$sys = get_sys_channel();
+			if($sys)
+				$r[] = $sys['channel_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(array_path_exists('data/object/tag',$msg)) {
+				if(is_array($msg['data']['object']['tag']) && $msg['data']['object']['tag']) {
+					foreach($msg['data']['object']['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_removed = 0 limit 1",
+									dbesc($address)
+								);
+								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.
+
+			if(array_path_exists('data/inReplyTo',$msg)) {
+				$z = q("select owner_xchan as hash from item where parent_mid = '%s' ",
+					dbesc($msg['data']['inReplyTo'])
+				);
+				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.
+		// It's a bit of work since it's a multi-dimensional array
+
+		if($r) {
+			$r = array_unique($r);
+		}
+
+		logger('public_recips: ' . print_r($r,true), LOGGER_DATA, LOG_DEBUG);
+		return $r;
+	}
+
+
+	/**
+	 * @brief
+	 *
+	 * @param array $sender
+	 * @param array $arr
+	 * @param array $deliveries
+	 * @param boolean $relay
+	 * @param boolean $public (optional) default false
+	 * @param boolean $request (optional) default false
+	 * @return array
+	 */
+
+	static function process_delivery($sender, $arr, $deliveries, $relay, $public = false, $request = false) {
+
+		$result = [];
+
+		// We've validated the sender. Now make sure that the sender is the owner or author
+
+		if(! $public) {
+			if($sender != $arr['owner_xchan'] && $sender != $arr['author_xchan']) {
+				logger("Sender $sender is not owner {$arr['owner_xchan']} or author {$arr['author_xchan']} - mid {$arr['mid']}");
+				return;
+			}
+		}
+
+		foreach($deliveries as $d) {
+
+			$local_public = $public;
+
+			$DR = new \Zotlabs\Lib\DReport(z_root(),$sender,$d,$arr['mid']);
+
+			$channel = channelx_by_hash($d);
+
+			if (! $channel) {
+				$DR->update('recipient not found');
+				$result[] = $DR->get();
+				continue;
+			}
+
+			$DR->set_name($channel['channel_name'] . ' <' . channel_reddress($channel) . '>');
+
+			/**
+			 * 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;
+
+				$r = q("select xchan_selfcensored from xchan where xchan_hash = '%s' limit 1",
+					dbesc($sender['hash'])
+				);
+				// 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;
+				}
+			}
+
+			$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;
+			}
+
+			if((! perm_is_allowed($channel['channel_id'],$sender,$perm)) && (! $tag_delivery) && (! $local_public)) {
+				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']) {
+
+				// 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.
+	
+				$r = q("select route, id from item where mid = '%s' and uid = %d limit 1",
+					dbesc($arr['parent_mid']),
+					intval($channel['channel_id'])
+				);
+				if(! $r) {
+					$DR->update('comment parent not found');
+					$result[] = $DR->get();
+
+					// 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 can detect these packets since they are
+					// delivered via a 'notify' packet type that has a message_id element in the
+					// initial zot packet (just like the corresponding 'request' packet type which
+					// makes the request).
+					// 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')) {
+						\Zotlabs\Daemon\Master::Summon(array('Notifier', 'request', $channel['channel_id'], $sender, $arr['parent_mid']));
+					}
+					continue;
+				}
+				if($relay) {
+					// 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.
+					$arr['route'] = $r[0]['route'];
+				}
+				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;
+				}
+			}
+
+			$ab = q("select * from abook where abook_channel = %d and abook_xchan = '%s'",
+				intval($channel['channel_id']),
+				dbesc($arr['owner_xchan'])
+			);
+			$abook = (($ab) ? $ab[0] : null);
+
+			if(intval($arr['item_deleted'])) {
+
+				// remove_community_tag is a no-op if this isn't a community tag activity
+				self::remove_community_tag($sender,$arr,$channel['channel_id']);
+	
+				// 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 = delete_imported_item($sender,$arr,$channel['channel_id'],$relay);
+				$DR->update(($item_id) ? 'deleted' : 'delete_failed');
+				$result[] = $DR->get();
+
+				if($relay && $item_id) {
+					logger('process_delivery: invoking relay');
+					\Zotlabs\Daemon\Master::Summon(array('Notifier','relay',intval($item_id)));
+					$DR->update('relayed');
+					$result[] = $DR->get();
+				}
+
+				continue;
+			}
+
+
+			$r = q("select * from item where mid = '%s' and uid = %d limit 1",
+				dbesc($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(($arr['mid'] == $arr['parent_mid']) && (! post_is_importable($arr,$abook))) {
+						$DR->update('update ignored');
+						$result[] = $DR->get();
+					}
+					else {
+						$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();
+
+					// 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
+					 */
+					call_hooks('post_local', $arr);
+				}
+
+				$item_id = 0;
+
+				if(($arr['mid'] == $arr['parent_mid']) && (! post_is_importable($arr,$abook))) {
+					$DR->update('post ignored');
+					$result[] = $DR->get();
+				}
+				else {
+					$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
+						 */	
+						call_hooks('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();
+				}
+			}
+
+			// 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');
+				\Zotlabs\Daemon\Master::Summon(array('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;
+	}
+
+	/**
+	 * @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
+	 */
+
+	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.
+	 *
+	 * @see item_store_update()
+	 *
+	 * @param array $sender
+	 * @param array $item
+	 * @param array $orig
+	 * @param int $uid
+	 * @param boolean $tag_delivery
+	 */
+	
+	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 boolean $relay
+	 * @return boolean|int post_id
+	 */
+
+	static function delete_imported_item($sender, $item, $uid, $relay) {
+
+		logger('invoked', LOGGER_DEBUG);
+
+		$ownership_valid = false;
+		$item_found = false;
+		$post_id = 0;
+
+		$r = q("select id, author_xchan, owner_xchan, source_xchan, item_deleted from item where ( author_xchan = '%s' or owner_xchan = '%s' or source_xchan = '%s' )
+			and mid = '%s' and uid = %d limit 1",
+			dbesc($sender['hash']),
+			dbesc($sender['hash']),
+			dbesc($sender['hash']),
+			dbesc($item['mid']),
+			intval($uid)
+		);
+
+		if($r) {
+			if($r[0]['author_xchan'] === $sender || $r[0]['owner_xchan'] === $sender || $r[0]['source_xchan'] === $sender)
+				$ownership_valid = true;
+
+			$post_id = $r[0]['id'];
+			$item_found = true;
+		}
+		else {
+
+			// perhaps the item is still in transit and the delete notification got here before the actual item did. Store it with the deleted flag set.
+			// item_store() won't try to deliver any notifications or start delivery chains if this flag is set.
+			// This means we won't end up with potentially even more delivery threads trying to push this delete notification.
+			// But this will ensure that if the (undeleted) original post comes in at a later date, we'll reject it because it will have an older timestamp.
+
+			logger('delete received for non-existent item - storing item data.');
+
+			if($item['author_xchan'] === $sender || $item['owner_xchan'] === $sender || $item['source_xchan'] === $sender) {
+				$ownership_valid = true;
+				$item_result = item_store($item);
+				$post_id = $item_result['item_id'];
+			}
+		}
+
+		if($ownership_valid === false) {
+			logger('delete_imported_item: failed: ownership issue');
+			return false;
+		}
+
+		if($item_found) {
+			if(intval($r[0]['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 (($r[0]['id'] != $r[0]['parent']) && intval($r[0]['item_origin'])) {
+					q("update item set item_origin = 0 where id = %d and uid = %d",
+						intval($r[0]['id']),
+						intval($r[0]['uid'])
+					);
+				}
+			}
+
+
+			// 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;
+	}
+
+	static function process_mail_delivery($sender, $arr, $deliveries) {
+
+		$result = array();
+
+		if($sender != $arr['from_xchan']) {
+			logger('process_mail_delivery: sender is not mail author');
+			return;
+		}
+
+		foreach($deliveries as $d) {
+	
+			$DR = new \Zotlabs\Lib\DReport(z_root(),$sender,$d,$arr['mid']);
+
+			$r = q("select * from channel where channel_hash = '%s' limit 1",
+				dbesc($d['hash'])
+			);
+
+			if(! $r) {
+				$DR->update('recipient not found');
+				$result[] = $DR->get();
+				continue;
+			}
+
+			$channel = $r[0];
+			$DR->set_name($channel['channel_name'] . ' <' . channel_reddress($channel) . '>');
+
+
+			if(! perm_is_allowed($channel['channel_id'],$sender,'post_mail')) {
+
+				/* 
+				 * Always allow somebody to reply if you initiated the conversation. It's anti-social
+				 * and a bit rude to send a private message to somebody and block their ability to respond.
+				 * If you are being harrassed and want to put an end to it, delete the conversation.
+				 */
+
+				$return = false;
+				if($arr['parent_mid']) {
+					$return = q("select * from mail where mid = '%s' and channel_id = %d limit 1",
+						dbesc($arr['parent_mid']),
+						intval($channel['channel_id'])
+					);
+				}
+				if(! $return) {
+					logger("permission denied for mail delivery {$channel['channel_id']}");
+					$DR->update('permission denied');
+					$result[] = $DR->get();
+					continue;
+				}
+			}
+
+
+			$r = q("select id from mail where mid = '%s' and channel_id = %d limit 1",
+				dbesc($arr['mid']),
+				intval($channel['channel_id'])
+			);
+			if($r) {
+				if(intval($arr['mail_recalled'])) {
+					$x = q("delete from mail where id = %d and channel_id = %d",
+						intval($r[0]['id']),
+						intval($channel['channel_id'])
+					);
+					$DR->update('mail recalled');
+					$result[] = $DR->get();
+					logger('mail_recalled');
+				}
+				else {
+					$DR->update('duplicate mail received');
+					$result[] = $DR->get();
+					logger('duplicate mail received');
+				}
+				continue;
+			}
+			else {
+				$arr['account_id'] = $channel['channel_account_id'];
+				$arr['channel_id'] = $channel['channel_id'];
+				$item_id = mail_store($arr);
+				$DR->update('mail delivered');
+				$result[] = $DR->get();
+			}
+		}
+
+		return $result;
+	}
+
+
+	/**
+	 * @brief Processes delivery of profile.
+	 *
+	 * @see import_directory_profile()
+	 * @param array $sender an associative array
+	 *   * \e string \b hash a xchan_hash
+	 * @param array $arr
+	 * @param array $deliveries (unused)
+	 */
+
+	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
+	 */
+	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
+	 */
+
+	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
+			 */
+			call_hooks('location_move', $arr);
+		}
+	}
+
+
+
+	/**
+	 * @brief Returns an array with all known distinct hubs for this channel.
+	 *
+	 * @see self::get_hublocs()
+	 * @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
+	 */
+
+	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 boolean true if updated or inserted
+	 */
+	
+	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;
+		}
+
+		$directory_url = htmlspecialchars($arr['directory_url'],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);
+		$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) : '');
+
+		// You can have one and only one primary directory per realm.
+		// Downgrade any others claiming to be primary. As they have
+		// flubbed up this badly already, don't let them be directory servers at all.
+
+		if(($site_directory === DIRECTORY_MODE_PRIMARY)
+			&& ($site_realm === get_directory_realm())
+			&& ($arr['url'] != get_directory_primary())) {
+			$site_directory = DIRECTORY_MODE_NORMAL;
+		}
+
+		$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
+	 *
+	 * @todo We probably should make rpost discoverable.
+	 *
+	 * @param array $observer
+	 *   * \e string \b xchan_url
+	 * @return string
+	 */
+	static function get_rpost_path($observer) {
+		if(! $observer)
+			return '';
+
+		$parsed = parse_url($observer['xchan_url']);
+
+		return $parsed['scheme'] . '://' . $parsed['host'] . (($parsed['port']) ? ':' . $parsed['port'] : '') . '/rpost?f=';
+	}
+
+	/**
+	 * @brief
+	 *
+	 * @param array $x
+	 * @return boolean|string return false or a hash
+	 */
+
+	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.
+
+		$hash = self::make_xchan_hash($x['id'],$x['key']);
+
+		$desturl = $x['url'];
+
+		$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'])
+		);
+
+		$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'])
+		);
+
+		$site_dead = false;
+
+		if($r1 && intval($r1[0]['site_dead'])) {
+			$site_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($site_dead) {
+			logger('dead 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_url'], LOGGER_DEBUG,LOG_INFO);
+				$desturl = $r[0]['hubloc_url'];
+			}
+			else {
+				return $hash;
+			}
+		}
+
+		$them = [ 'hubloc_id_url' => $desturl ];
+		if(self::refresh($them))
+			return $hash;
+
+		return false;
+	}
+
+	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' 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;
+
+		$public_forum = false;
+
+		$role = get_pconfig($e['channel_id'],'system','permissions_role');
+		if($role === 'forum' || $role === 'repository') {
+			$public_forum = true;
+		}
+		else {
+			// check if it has characteristics of a public forum based on custom permissions.
+			$m = \Zotlabs\Access\Permissions::FilledAutoperms($e['channel_id']);
+			if($m) {
+				foreach($m as $k => $v) {
+					if($k == 'tag_deliver' && intval($v) == 1)
+						$ch ++;
+					if($k == 'send_stream' && intval($v) == 0)
+						$ch ++;
+				}
+				if($ch == 2)
+					$public_forum = true;
+			}
+		}
+
+
+		//  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 = array();
+
+		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']         = $p[0]['about'];
+			$profile['homepage']      = $p[0]['homepage'];
+			$profile['hometown']      = $p[0]['hometown'];
+
+			if($p[0]['keywords']) {
+				$tags = array();
+				$k = explode(' ',$p[0]['keywords']);
+				if($k) {
+					foreach($k as $kk) {
+						if(trim($kk," \t\n\r\0\x0B,")) {
+							$tags[] = trim($kk," \t\n\r\0\x0B,");
+						}
+					}
+				}
+				if($tags)
+					$profile['keywords'] = $tags;
+			}
+		}
+
+		// 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'],
+		];
+
+		$ret['public_key']     = $e['xchan_pubkey'];
+		$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']
+		];
+
+		$ret['channel_role'] = get_pconfig($e['channel_id'],'system','permissions_role','custom');
+
+		$ret['searchable']     = $searchable;
+		$ret['adult_content']  = $adult_channel;
+		$ret['public_forum']   = $public_forum;
+		
+		$ret['comments']       = map_scope(\Zotlabs\Access\PermissionLimits::Get($e['channel_id'],'post_comments'));
+		$ret['mail']           = map_scope(\Zotlabs\Access\PermissionLimits::Get($e['channel_id'],'post_mail'));
+
+		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(! $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();
+
+		call_hooks('zotinfo',$ret);
+
+		return($ret);
+
+	}
+
+
+	static function site_info() {
+
+		$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) {
+
+			$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']['accounts'] = account_total();
+
+			require_once('include/channel.php');
+			$ret['site']['channels'] = channel_total();
+
+			$ret['site']['admin'] = get_config('system','admin_email');
+
+			$visible_plugins = array();
+			if(is_array(\App::$plugins) && count(\App::$plugins)) {
+				$r = q("select * from addon where hidden = 0");
+				if($r)
+					foreach($r as $rr)
+						$visible_plugins[] = $rr['aname'];
+			}
+
+			$ret['site']['plugins']    = $visible_plugins;
+			$ret['site']['sitehash']   = get_config('system','location_hash');
+			$ret['site']['sitename']   = get_config('system','sitename');
+			$ret['site']['sellpage']   = get_config('system','sellpage');
+			$ret['site']['location']   = get_config('system','site_location');
+			$ret['site']['realm']      = get_directory_realm();
+			$ret['site']['project']    = System::get_platform_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
+	 */
+
+	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'])) {
+			q("update hubloc set hubloc_error = 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'])
+			);
+		}
+
+		return $hub['hubloc_url'];
+	}
+
+
+	static function sign($data,$key,$alg = 'sha256') {
+		if(! $key)
+			return 'no key';
+		$sig = '';
+		openssl_sign($data,$sig,$key,$alg);
+		return $alg . '.' . base64url_encode($sig);
+	}
+
+	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);
+	}
+
+
+
+	static function is_zot_request() {
+
+		$x = getBestSupportedMimeType([ 'application/x-zot+json' ]);
+		return(($x) ? true : false);
+	}
+
+}
diff --git a/Zotlabs/Lib/Libzotdir.php b/Zotlabs/Lib/Libzotdir.php
new file mode 100644
index 000000000..91d089c86
--- /dev/null
+++ b/Zotlabs/Lib/Libzotdir.php
@@ -0,0 +1,654 @@
+ $preferred ];
+		}
+		else {
+			return [];
+		}
+	}
+
+
+	/**
+	 * Directories may come and go over time. We will need to check that our
+	 * directory server is still valid occasionally, and reset to something that
+	 * is if our directory has gone offline for any reason
+	 */
+
+	static function check_upstream_directory() {
+
+		$directory = get_config('system', 'directory_server');
+
+		// it's possible there is no directory server configured and the local hub is being used.
+		// If so, default to preserving the absence of a specific server setting.
+
+		$isadir = true;
+
+		if ($directory) {
+			$j = Zotfinger::exec($directory);
+			if(array_path_exists('data/directory_mode',$j)) {
+				if ($j['data']['directory_mode'] === 'normal') {
+					$isadir = false;
+				}
+			}
+		}
+
+		if (! $isadir)
+			set_config('system', 'directory_server', '');
+	}
+
+
+	static function get_directory_setting($observer, $setting) {
+
+		if ($observer)
+			$ret = get_xconfig($observer, 'directory', $setting);
+		else
+			$ret = ((array_key_exists($setting,$_SESSION)) ? intval($_SESSION[$setting]) : false);
+
+		if($ret === false)
+			$ret = get_config('directory', $setting);
+
+
+		// 'safemode' is the default if there is no observer or no established preference. 
+
+		if($setting === 'safemode' && $ret === false)
+			$ret = 1;
+
+		if($setting === 'globaldir' && intval(get_config('system','localdir_hide')))
+			$ret = 1;
+
+		return $ret;
+	}
+
+	/**
+	 * @brief Called by the directory_sort widget.
+	 */
+	static function dir_sort_links() {
+
+		$safe_mode = 1;
+
+		$observer = get_observer_hash();
+
+		$safe_mode = self::get_directory_setting($observer, 'safemode');
+		$globaldir = self::get_directory_setting($observer, 'globaldir');
+		$pubforums = self::get_directory_setting($observer, 'pubforums');
+
+		$hide_local = intval(get_config('system','localdir_hide'));
+		if($hide_local)
+			$globaldir = 1;
+
+
+		// Build urls without order and pubforums so it's easy to tack on the changed value
+		// Probably there's an easier way to do this
+
+		$directory_sort_order = get_config('system','directory_sort_order');
+		if(! $directory_sort_order)
+			$directory_sort_order = 'date';
+
+		$current_order = (($_REQUEST['order']) ? $_REQUEST['order'] : $directory_sort_order);
+		$suggest = (($_REQUEST['suggest']) ? '&suggest=' . $_REQUEST['suggest'] : '');
+
+		$url = 'directory?f=';
+
+		$tmp = array_merge($_GET,$_POST);
+		unset($tmp['suggest']);
+		unset($tmp['pubforums']);
+		unset($tmp['global']);
+		unset($tmp['safe']);
+		unset($tmp['q']);
+		unset($tmp['f']);
+		$forumsurl = $url . http_build_query($tmp) . $suggest;
+
+		$o = replace_macros(get_markup_template('dir_sort_links.tpl'), [
+			'$header'    => t('Directory Options'),
+			'$forumsurl' => $forumsurl,
+			'$safemode'  => array('safemode', t('Safe Mode'),$safe_mode,'',array(t('No'), t('Yes')),' onchange=\'window.location.href="' . $forumsurl . '&safe="+(this.checked ? 1 : 0)\''),
+			'$pubforums' => array('pubforums', t('Public Forums Only'),$pubforums,'',array(t('No'), t('Yes')),' onchange=\'window.location.href="' . $forumsurl . '&pubforums="+(this.checked ? 1 : 0)\''),
+			'$hide_local' => $hide_local,
+			'$globaldir' => array('globaldir', t('This Website Only'), 1-intval($globaldir),'',array(t('No'), t('Yes')),' onchange=\'window.location.href="' . $forumsurl . '&global="+(this.checked ? 0 : 1)\''),
+		]);
+
+		return $o;
+	}
+
+	/**
+	 * @brief Checks the directory mode of this hub.
+	 *
+	 * Checks the directory mode of this hub to see if it is some form of directory server. If it is,
+	 * get the directory realm of this hub. Fetch a list of all other directory servers in this realm and request
+	 * a directory sync packet. This will contain both directory updates and new ratings. Store these all in the DB. 
+	 * In the case of updates, we will query each of them asynchronously from a poller task. Ratings are stored 
+	 * directly if the rater's signature matches.
+	 *
+	 * @param int $dirmode;
+	 */
+
+	static function sync_directories($dirmode) {
+
+		if ($dirmode == DIRECTORY_MODE_STANDALONE || $dirmode == DIRECTORY_MODE_NORMAL)
+			return;
+
+		$realm = get_directory_realm();
+		if ($realm == DIRECTORY_REALM) {
+			$r = q("select * from site where (site_flags & %d) > 0 and site_url != '%s' and site_type = %d and ( site_realm = '%s' or site_realm = '') ",
+				intval(DIRECTORY_MODE_PRIMARY|DIRECTORY_MODE_SECONDARY),
+				dbesc(z_root()),
+				intval(SITE_TYPE_ZOT),
+				dbesc($realm)
+			);
+		} 
+		else {
+			$r = q("select * from site where (site_flags & %d) > 0 and site_url != '%s' and site_realm like '%s' and site_type = %d ",
+				intval(DIRECTORY_MODE_PRIMARY|DIRECTORY_MODE_SECONDARY),
+				dbesc(z_root()),
+				dbesc(protect_sprintf('%' . $realm . '%')),
+				intval(SITE_TYPE_ZOT)
+			);
+		}
+
+		// If there are no directory servers, setup the fallback master
+		/** @FIXME What to do if we're in a different realm? */
+
+		if ((! $r) && (z_root() != DIRECTORY_FALLBACK_MASTER)) {
+
+			$x = site_store_lowlevel(
+				[
+					'site_url'       => DIRECTORY_FALLBACK_MASTER,
+					'site_flags'     => DIRECTORY_MODE_PRIMARY,
+					'site_update'    => NULL_DATE, 
+					'site_directory' => DIRECTORY_FALLBACK_MASTER . '/dirsearch',
+					'site_realm'     => DIRECTORY_REALM,
+					'site_valid'     => 1,
+				]
+			);
+
+			$r = q("select * from site where site_flags in (%d, %d) and site_url != '%s' and site_type = %d ",
+				intval(DIRECTORY_MODE_PRIMARY),
+				intval(DIRECTORY_MODE_SECONDARY),
+				dbesc(z_root()),
+				intval(SITE_TYPE_ZOT)
+			);
+		}
+		if (! $r)
+			return;
+
+		foreach ($r as $rr) {
+			if (! $rr['site_directory'])
+				continue;
+
+			logger('sync directories: ' . $rr['site_directory']);
+
+			// for brand new directory servers, only load the last couple of days.
+			// It will take about a month for a new directory to obtain the full current repertoire of channels.
+			/** @FIXME Go back and pick up earlier ratings if this is a new directory server. These do not get refreshed. */
+
+			$token = get_config('system','realm_token');
+
+			$syncdate = (($rr['site_sync'] <= NULL_DATE) ? datetime_convert('UTC','UTC','now - 2 days') : $rr['site_sync']);
+			$x = z_fetch_url($rr['site_directory'] . '?f=&sync=' . urlencode($syncdate) . (($token) ? '&t=' . $token : ''));
+
+			if (! $x['success'])
+				continue;
+
+			$j = json_decode($x['body'],true);
+			if (!($j['transactions']) || ($j['ratings']))
+				continue;
+
+			q("update site set site_sync = '%s' where site_url = '%s'",
+				dbesc(datetime_convert()),
+				dbesc($rr['site_url'])
+			);
+
+			logger('sync_directories: ' . $rr['site_url'] . ': ' . print_r($j,true), LOGGER_DATA);
+
+			if (is_array($j['transactions']) && count($j['transactions'])) {
+				foreach ($j['transactions'] as $t) {
+					$r = q("select * from updates where ud_guid = '%s' limit 1",
+						dbesc($t['transaction_id'])
+					);
+					if($r)
+						continue;
+
+					$ud_flags = 0;
+					if (is_array($t['flags']) && in_array('deleted',$t['flags']))
+						$ud_flags |= UPDATE_FLAGS_DELETED;
+					if (is_array($t['flags']) && in_array('forced',$t['flags']))
+						$ud_flags |= UPDATE_FLAGS_FORCED;
+	
+					$z = q("insert into updates ( ud_hash, ud_guid, ud_date, ud_flags, ud_addr )
+						values ( '%s', '%s', '%s', %d, '%s' ) ",
+						dbesc($t['hash']),
+						dbesc($t['transaction_id']),
+						dbesc($t['timestamp']),
+						intval($ud_flags),
+						dbesc($t['address'])
+					);
+				}
+			}
+		}
+	}
+
+
+
+	/**
+	 * @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
+	 */
+
+	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 = \Zotlabs\Lib\Webfinger::zot_url(punify($url));
+			if($href) {
+				$zf = \Zotlabs\Lib\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 include/directory.php if a profile is to be pushed to the
+	 * directory and the local hub in this case is any kind of directory server.
+	 *
+	 * @param int $uid
+	 * @param boolean $force
+	 */
+
+	static function local_dir_update($uid, $force) {
+
+	
+		logger('local_dir_update: uid: ' . $uid, LOGGER_DEBUG);
+
+		$p = q("select channel.channel_hash, channel_address, channel_timezone, profile.* from profile left join channel on channel_id = uid where uid = %d and is_default = 1",
+			intval($uid)
+		);
+
+		$profile = array();
+		$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 = array();
+				$k = explode(' ', $p[0]['keywords']);
+				if ($k)
+					foreach ($k as $kk)
+						if (trim($kk))
+							$tags[] = trim($kk);
+
+				if ($tags)
+					$profile['keywords'] = $tags;
+			}
+
+			$hidden = (1 - intval($p[0]['publish']));
+
+			logger('hidden: ' . $hidden);
+
+			$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 ];
+			call_hooks('local_dir_update', $arr);
+
+			$address = channel_reddress($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_reddress($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 boolean $updated if something changed
+	 */
+
+	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;
+
+		$arr = array();
+
+		$arr['xprof_hash']         = $hash;
+		$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']          = (($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) : '');
+
+		$clean = array();
+		if (array_key_exists('keywords', $profile) and is_array($profile['keywords'])) {
+			self::import_directory_keywords($hash,$profile['keywords']);
+			foreach ($profile['keywords'] as $kw) {
+				$kw = trim(htmlspecialchars($kw,ENT_COMPAT, 'UTF-8', false));
+				$kw = trim($kw, ',');
+				$clean[] = $kw;
+			}
+		}
+
+		$arr['xprof_keywords'] = implode(' ',$clean);
+
+		// Self censored, make it so
+		// These are not translated, so the German "erwachsenen" keyword will not censor the directory profile. Only the English form - "adult".
+
+
+		if(in_arrayi('nsfw',$clean) || in_arrayi('adult',$clean)) {
+			q("update xchan set xchan_selfcensored = 1 where xchan_hash = '%s'",
+				dbesc($hash)
+			);
+		}
+
+		$r = q("select * from xprof where xprof_hash = '%s' limit 1",
+			dbesc($hash)
+		);
+
+		if ($arr['xprof_age'] > 150)
+			$arr['xprof_age'] = 150;
+		if ($arr['xprof_age'] < 0)
+			$arr['xprof_age'] = 0;
+
+		if ($r) {
+			$update = false;
+			foreach ($r[0] as $k => $v) {
+				if ((array_key_exists($k,$arr)) && ($arr[$k] != $v)) {
+					logger('import_directory_profile: update ' . $k . ' => ' . $arr[$k]);
+					$update = true;
+					break;
+				}
+			}
+			if ($update) {
+				q("update xprof set
+					xprof_desc = '%s',
+					xprof_dob = '%s',
+					xprof_age = %d,
+					xprof_gender = '%s',
+					xprof_marital = '%s',
+					xprof_sexual = '%s',
+					xprof_locale = '%s',
+					xprof_region = '%s',
+					xprof_postcode = '%s',
+					xprof_country = '%s',
+					xprof_about = '%s',
+					xprof_homepage = '%s',
+					xprof_hometown = '%s',
+					xprof_keywords = '%s'
+					where xprof_hash = '%s'",
+					dbesc($arr['xprof_desc']),
+					dbesc($arr['xprof_dob']),
+					intval($arr['xprof_age']),
+					dbesc($arr['xprof_gender']),
+					dbesc($arr['xprof_marital']),
+					dbesc($arr['xprof_sexual']),
+					dbesc($arr['xprof_locale']),
+					dbesc($arr['xprof_region']),
+					dbesc($arr['xprof_postcode']),
+					dbesc($arr['xprof_country']),
+					dbesc($arr['xprof_about']),
+					dbesc($arr['xprof_homepage']),
+					dbesc($arr['xprof_hometown']),
+					dbesc($arr['xprof_keywords']),
+					dbesc($arr['xprof_hash'])
+				);
+			}
+		} else {
+			$update = true;
+			logger('New profile');
+			q("insert into xprof (xprof_hash, xprof_desc, xprof_dob, xprof_age, xprof_gender, xprof_marital, xprof_sexual, xprof_locale, xprof_region, xprof_postcode, xprof_country, xprof_about, xprof_homepage, xprof_hometown, xprof_keywords) values ('%s', '%s', '%s', %d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s') ",
+				dbesc($arr['xprof_hash']),
+				dbesc($arr['xprof_desc']),
+				dbesc($arr['xprof_dob']),
+				intval($arr['xprof_age']),
+				dbesc($arr['xprof_gender']),
+				dbesc($arr['xprof_marital']),
+				dbesc($arr['xprof_sexual']),
+				dbesc($arr['xprof_locale']),
+				dbesc($arr['xprof_region']),
+				dbesc($arr['xprof_postcode']),
+				dbesc($arr['xprof_country']),
+				dbesc($arr['xprof_about']),
+				dbesc($arr['xprof_homepage']),
+				dbesc($arr['xprof_hometown']),
+				dbesc($arr['xprof_keywords'])
+			);
+		}
+
+		$d = [
+			'xprof' => $arr,
+			'profile' => $profile,
+			'update' => $update
+		];
+		/**
+		 * @hooks import_directory_profile
+		 *   Called when processing delivery of a profile structure from an external source (usually for directory storage).
+		 *   * \e array \b xprof
+		 *   * \e array \b profile
+		 *   * \e boolean \b update
+		 */
+		call_hooks('import_directory_profile', $d);
+
+		if (($d['update']) && (! $suppress_update))
+			self::update_modtime($arr['xprof_hash'],random_string() . '@' . \App::get_hostname(), $addr, $ud_flags);
+
+		return $d['update'];
+	}
+
+	/**
+	 * @brief
+	 *
+	 * @param string $hash An xtag_hash
+	 * @param array $keywords
+	 */
+
+	static function import_directory_keywords($hash, $keywords) {
+
+		$existing = array();
+		$r = q("select * from xtag where xtag_hash = '%s' and xtag_flags = 0",
+			dbesc($hash)
+		);
+
+		if($r) {
+			foreach($r as $rr)
+				$existing[] = $rr['xtag_term'];
+		}
+
+		$clean = array();
+		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
+	 */
+
+	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 not (ud_flags & %d)>0 ",
+				intval(UPDATE_FLAGS_UPDATED),
+				dbesc($addr),
+				intval(UPDATE_FLAGS_UPDATED)
+			);
+		}
+	}
+
+
+
+
+
+
+}
\ No newline at end of file
diff --git a/Zotlabs/Lib/Queue.php b/Zotlabs/Lib/Queue.php
new file mode 100644
index 000000000..baa1da70d
--- /dev/null
+++ b/Zotlabs/Lib/Queue.php
@@ -0,0 +1,278 @@
+ $base,
+						'site_update' => datetime_convert(),
+						'site_dead'   => 0,
+						'site_type'   => intval(($outq['outq_driver'] === 'post') ? SITE_TYPE_NOTZOT : SITE_TYPE_UNKNOWN),
+						'site_crypto' => ''
+					]
+				);
+			}
+		}
+
+		$arr = array('outq' => $outq, 'base' => $base, 'handled' => false, 'immediate' => $immediate);
+		call_hooks('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 = array();
+					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;
+		}
+
+		// normal zot delivery
+
+		logger('deliver: dest: ' . $outq['outq_posturl'], LOGGER_DEBUG);
+
+
+		if($outq['outq_posturl'] === z_root() . '/zot') {
+			// local delivery
+			$zot = new \Zotlabs\Zot6\Receiver(new \Zotlabs\Zot6\Zot6Handler(),$outq['outq_notify']);
+			$result = $zot->run(true);
+			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);
+		}
+		else {
+			logger('remote');
+			$channel = null;
+
+			if($outq['outq_channel']) {
+				$channel = channelx_by_n($outq['outq_channel']);
+			}
+
+			$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' order by hubloc_id desc limit 1",
+					dbesc($base)
+				);
+				if($h) {
+					$host_crypto = $h[0];
+				}
+			}
+
+			$msg = $outq['outq_notify'];
+
+			$result = Libzot::zot($outq['outq_posturl'],$msg,$channel,$host_crypto);
+
+			if($result['success']) {
+				logger('deliver: remote zot delivery succeeded to ' . $outq['outq_posturl']);
+				Libzot::process_response($outq['outq_posturl'],$result, $outq);
+			}
+			else {
+				logger('deliver: remote zot delivery failed to ' . $outq['outq_posturl']);
+				logger('deliver: remote zot delivery fail data: ' . print_r($result,true), LOGGER_DATA);
+				self::update($outq['outq_hash'],10);
+			}
+		}
+		return;
+	}
+}
+
diff --git a/Zotlabs/Lib/Webfinger.php b/Zotlabs/Lib/Webfinger.php
new file mode 100644
index 000000000..c2364ac4d
--- /dev/null
+++ b/Zotlabs/Lib/Webfinger.php
@@ -0,0 +1,109 @@
+ [ 'Accept: application/jrd+json, */*' ] ]);
+
+		if($s['success']) {
+			$j = json_decode($s['body'], true);
+			return($j);
+		}
+
+		return false;
+	}
+
+	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);
+			$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
+	 *
+	 */ 
+
+	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) && $link['rel'] === PROTOCOL_ZOT6) {
+					if(array_key_exists('href',$link) && $link['href'] !== EMPTY_STR) {
+						return $link['href'];
+					}
+				}
+			}
+		}
+		return false;
+	}
+
+
+
+}
\ No newline at end of file
diff --git a/Zotlabs/Lib/Zotfinger.php b/Zotlabs/Lib/Zotfinger.php
new file mode 100644
index 000000000..537e440d4
--- /dev/null
+++ b/Zotlabs/Lib/Zotfinger.php
@@ -0,0 +1,50 @@
+ 'application/x-zot+json', 
+				'X-Zot-Token' => random_string(),
+			];
+			$h = HTTPSig::create_sig($headers,$channel['channel_prvkey'],channel_url($channel),false);
+		}
+		else {
+			$h = [ 'Accept: application/x-zot+json' ]; 
+		}
+				
+		$result = [];
+
+
+		$redirects = 0;
+		$x = z_fetch_url($resource,false,$redirects, [ 'headers' => $h  ] );
+
+		if($x['success']) {
+			
+			$result['signature'] = HTTPSig::verify($x);
+    
+			$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;
+	}
+
+
+
+}
\ No newline at end of file
diff --git a/Zotlabs/Module/Zot.php b/Zotlabs/Module/Zot.php
new file mode 100644
index 000000000..8c34dced1
--- /dev/null
+++ b/Zotlabs/Module/Zot.php
@@ -0,0 +1,25 @@
+run(),'application/x-zot+jzon');
+	}
+
+}
diff --git a/Zotlabs/Zot6/Finger.php b/Zotlabs/Zot6/Finger.php
new file mode 100644
index 000000000..f1fe41352
--- /dev/null
+++ b/Zotlabs/Zot6/Finger.php
@@ -0,0 +1,146 @@
+ true) or array('success' => false);
+	 */
+
+	static public function run($webbie, $channel = null, $autofallback = true) {
+
+		$ret = array('success' => false);
+
+		self::$token = random_string();
+
+		if (strpos($webbie, '@') === false) {
+			$address = $webbie;
+			$host = \App::get_hostname();
+		} else {
+			$address = substr($webbie,0,strpos($webbie,'@'));
+			$host = substr($webbie,strpos($webbie,'@')+1);
+			if(strpos($host,'/'))
+				$host = substr($host,0,strpos($host,'/'));
+		}
+
+		$xchan_addr = $address . '@' . $host;
+
+		if ((! $address) || (! $xchan_addr)) {
+			logger('zot_finger: no address :' . $webbie);
+
+			return $ret;
+		}
+
+		logger('using xchan_addr: ' . $xchan_addr, LOGGER_DATA, LOG_DEBUG);
+
+		// potential issue here; the xchan_addr points to the primary hub.
+		// The webbie we were called with may not, so it might not be found
+		// unless we query for hubloc_addr instead of xchan_addr
+
+		$r = q("select xchan.*, hubloc.* from xchan
+			left join hubloc on xchan_hash = hubloc_hash
+			where xchan_addr = '%s' and hubloc_primary = 1 limit 1",
+			dbesc($xchan_addr)
+		);
+
+		if($r) {
+			$url = $r[0]['hubloc_url'];
+
+			if($r[0]['hubloc_network'] && $r[0]['hubloc_network'] !== 'zot') {
+				logger('zot_finger: alternate network: ' . $webbie);
+				logger('url: ' . $url . ', net: ' . var_export($r[0]['hubloc_network'],true), LOGGER_DATA, LOG_DEBUG);
+				return $ret;
+			}
+		} else {
+			$url = 'https://' . $host;
+		}
+
+		$rhs = '/.well-known/zot-info';
+		$https = ((strpos($url,'https://') === 0) ? true : false);
+
+		logger('zot_finger: ' . $address . ' at ' . $url, LOGGER_DEBUG);
+
+		if ($channel) {
+			$postvars = array(
+				'address'    => $address,
+				'target'     => $channel['channel_guid'],
+				'target_sig' => $channel['channel_guid_sig'],
+				'key'        => $channel['channel_pubkey'],
+				'token'      => self::$token
+			);
+
+			$headers = [];
+			$headers['X-Zot-Channel'] = $channel['channel_address'] . '@' . \App::get_hostname();
+			$headers['X-Zot-Nonce']   = random_string();
+			$xhead = \Zotlabs\Web\HTTPSig::create_sig('',$headers,$channel['channel_prvkey'],
+				'acct:' . $channel['channel_address'] . '@' . \App::get_hostname(),false);
+
+			$retries = 0;
+
+			$result = z_post_url($url . $rhs,$postvars,$retries, [ 'headers' => $xhead ]);
+
+			if ((! $result['success']) && ($autofallback)) {
+				if ($https) {
+					logger('zot_finger: https failed. falling back to http');
+					$result = z_post_url('http://' . $host . $rhs,$postvars, $retries, [ 'headers' => $xhead ]);
+				}
+			}
+		} 
+		else {
+			$rhs .= '?f=&address=' . urlencode($address) . '&token=' . self::$token;
+
+			$result = z_fetch_url($url . $rhs);
+			if((! $result['success']) && ($autofallback)) {
+				if($https) {
+					logger('zot_finger: https failed. falling back to http');
+					$result = z_fetch_url('http://' . $host . $rhs);
+				}
+			}
+		}
+
+		if(! $result['success']) {
+			logger('zot_finger: no results');
+
+			return $ret;
+		}
+
+		$x = json_decode($result['body'], true);
+
+		$verify = \Zotlabs\Web\HTTPSig::verify($result,(($x) ? $x['key'] : ''));
+
+		if($x && (! $verify['header_valid'])) {
+			$signed_token = ((is_array($x) && array_key_exists('signed_token', $x)) ? $x['signed_token'] : null);
+			if($signed_token) {
+				$valid = zot_verify('token.' . self::$token, base64url_decode($signed_token), $x['key']);
+				if(! $valid) {
+					logger('invalid signed token: ' . $url . $rhs, LOGGER_NORMAL, LOG_ERR);
+
+					return $ret;
+				}
+			}
+			else {
+				logger('No signed token from '  . $url . $rhs, LOGGER_NORMAL, LOG_WARNING);
+				return $ret;
+			}
+		}
+
+		return $x;
+	}
+
+}
diff --git a/Zotlabs/Zot6/IHandler.php b/Zotlabs/Zot6/IHandler.php
new file mode 100644
index 000000000..53b6caa89
--- /dev/null
+++ b/Zotlabs/Zot6/IHandler.php
@@ -0,0 +1,18 @@
+error       = false;
+		$this->validated   = false;
+		$this->messagetype = '';
+		$this->response    = [ 'success' => false ];
+		$this->handler     = $handler;
+		$this->data        = null;
+		$this->rawdata     = null;
+		$this->site_id     = null;
+		$this->prvkey      = Config::get('system','prvkey');
+
+		if($localdata) {
+			$this->rawdata = $localdata;
+		}
+		else {
+			$this->rawdata = file_get_contents('php://input');
+
+			// All access to the zot endpoint must use http signatures
+
+			if (! $this->Valid_Httpsig()) {
+				logger('signature failed');
+				$this->error = true;
+				$this->response['message'] = 'signature invalid';
+				return;
+			}
+		}
+
+		logger('received raw: ' . print_r($this->rawdata,true), LOGGER_DATA);
+
+
+		if ($this->rawdata) {
+			$this->data = json_decode($this->rawdata,true);
+		}
+		else {
+			$this->error = true;
+			$this->response['message'] = 'no data';
+		}
+
+		logger('received_json: ' . json_encode($this->data,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOGGER_DATA);
+
+		logger('received: ' . print_r($this->data,true), LOGGER_DATA);
+
+		if ($this->data && is_array($this->data)) {
+			$this->encrypted = ((array_key_exists('encrypted',$this->data) && intval($this->data['encrypted'])) ? true : false);
+
+			if ($this->encrypted && $this->prvkey) {
+				$uncrypted = crypto_unencapsulate($this->data,$this->prvkey);
+				if ($uncrypted) {
+					$this->data = json_decode($uncrypted,true);
+				}
+				else {
+					$this->error = true;
+					$this->response['message'] = 'no data';
+				}
+			}
+		}
+	}
+
+
+	function run() {
+
+		if ($this->error) {
+			// make timing attacks on the decryption engine a bit more difficult
+			usleep(mt_rand(10000,100000));
+			return($this->response); 
+		}
+
+		if ($this->data) {
+			if (array_key_exists('type',$this->data)) {
+				$this->messagetype = $this->data['type'];
+			}
+
+			if (! $this->messagetype) {
+				$this->error = true;
+				$this->response['message'] = 'no datatype';
+				return $this->response;
+			}
+
+			$this->sender     = ((array_key_exists('sender',$this->data))     ? $this->data['sender'] : null);
+			$this->recipients = ((array_key_exists('recipients',$this->data)) ? $this->data['recipients'] : null);
+			$this->site_id    = ((array_key_exists('site_id',$this->data))    ? $this->data['site_id'] : null);
+		}
+
+		if ($this->sender) {
+			$result = $this->ValidateSender();
+			if (! $result) {
+				$this->error = true;
+				return $this->response;
+			}
+		}
+
+		return $this->Dispatch();
+	}
+
+	function ValidateSender() {
+
+		$hub = Libzot::valid_hub($this->sender,$this->site_id);
+
+		if (! $hub) {
+			$x = Libzot::register_hub($this->sigdata['signer']);
+			if($x['success']) {
+				$hub = Libzot::valid_hub($this->sender,$this->site_id);
+			}	
+			if(! $hub) {
+	           	$this->response['message'] = 'sender unknown';
+				return false;
+			}
+		}
+
+		if (! check_siteallowed($hub['hubloc_url'])) {
+			$this->response['message'] = 'forbidden';
+			return false;
+		}
+
+		if (! check_channelallowed($this->sender)) {
+			$this->response['message'] = 'forbidden';
+			return false;
+		}
+
+		Libzot::update_hub_connected($hub,$this->site_id);
+
+		$this->validated = true;
+		$this->hub = $hub;
+		return true;
+    }
+
+
+	function Valid_Httpsig() {
+
+		$result = false;
+
+		$this->sigdata = HTTPSig::verify($this->rawdata);
+
+		if ($this->sigdata && $this->sigdata['header_signed'] && $this->sigdata['header_valid']) {
+			$result = true;
+
+			// It is OK to not have signed content - not all messages provide content.
+			// But if it is signed, it has to be valid
+
+			if (($this->sigdata['content_signed']) && (! $this->sigdata['content_valid'])) {
+					$result = false;
+			}
+		}
+		return $result;
+	}	
+		
+	function Dispatch() {
+
+		switch ($this->messagetype) {
+
+			case 'request':
+				$this->response = $this->handler->Request($this->data,$this->hub);
+				break;
+
+			case 'purge':
+				$this->response = $this->handler->Purge($this->sender,$this->recipients,$this->hub);
+				break;
+
+			case 'refresh':
+				$this->response = $this->handler->Refresh($this->sender,$this->recipients,$this->hub);
+				break;
+
+			case 'rekey':
+				$this->response = $this->handler->Rekey($this->sender, $this->data,$this->hub);
+				break;
+
+			case 'activity':
+			case 'response': // upstream message
+			case 'sync':
+			default:
+				$this->response = $this->handler->Notify($this->data,$this->hub);
+				break;
+
+		}
+
+		logger('response_to_return: ' . print_r($this->response,true),LOGGER_DATA);
+
+		if ($this->encrypted) {
+			$this->EncryptResponse();
+		}
+
+		return($this->response); 
+	}
+
+	function EncryptResponse() {
+		$algorithm = Libzot::best_algorithm($this->hub['site_crypto']);
+		if ($algorithm) {
+			$this->response = crypto_encapsulate(json_encode($this->response),$this->hub['hubloc_sitekey'], $algorithm);
+		}
+	}
+
+}
+
+
+
diff --git a/Zotlabs/Zot6/Zot6Handler.php b/Zotlabs/Zot6/Zot6Handler.php
new file mode 100644
index 000000000..5597921cc
--- /dev/null
+++ b/Zotlabs/Zot6/Zot6Handler.php
@@ -0,0 +1,266 @@
+ false ];
+
+		logger('notify received from ' . $hub['hubloc_url']);
+
+		$x = Libzot::fetch($data);
+		$ret['delivery_report'] = $x;
+	
+
+		$ret['success'] = true;
+		return $ret;
+	}
+
+
+
+	/**
+	 * @brief Remote channel info (such as permissions or photo or something)
+	 * has been updated. Grab a fresh copy and sync it.
+	 *
+	 * The difference between refresh and force_refresh is that force_refresh
+	 * unconditionally creates a directory update record, even if no changes were
+	 * detected upon processing.
+	 *
+	 * @param array $sender
+	 * @param array $recipients
+	 *
+	 * @return json_return_and_die()
+	 */
+
+	static function reply_refresh($sender, $recipients,$hub) {
+		$ret = array('success' => false);
+
+		if($recipients) {
+
+			// This would be a permissions update, typically for one connection
+
+			foreach ($recipients as $recip) {
+				$r = q("select channel.*,xchan.* from channel
+					left join xchan on channel_hash = xchan_hash
+					where channel_hash ='%s' limit 1",
+					dbesc($recip)
+				);
+
+				$x = Libzot::refresh( [ 'hubloc_id_url' => $hub['hubloc_id_url'] ], $r[0], (($msgtype === 'force_refresh') ? true : false));
+			}
+		}
+		else {
+			// system wide refresh
+
+			$x = Libzot::refresh( [ 'hubloc_id_url' => $hub['hubloc_id_url'] ], null, (($msgtype === 'force_refresh') ? true : false));
+		}
+
+		$ret['success'] = true;
+		return $ret;
+	}
+
+
+
+	/**
+	 * @brief Process a message request.
+	 *
+	 * If a site receives a comment to a post but finds they have no parent to attach it with, they
+	 * may send a 'request' packet containing the message_id of the missing parent. This is the handler
+	 * for that packet. We will create a message_list array of the entire conversation starting with
+	 * the missing parent and invoke delivery to the sender of the packet.
+	 *
+	 * Zotlabs/Daemon/Deliver.php (for local delivery) and 
+	 * mod/post.php???? @fixme (for web delivery) detect the existence of
+	 * this 'message_list' at the destination and split it into individual messages which are
+	 * processed/delivered in order.
+	 *
+	 *
+	 * @param array $data
+	 * @return array
+	 */
+	
+	static function reply_message_request($data,$hub) {
+		$ret = [ 'success' => false ];
+
+		$message_id = EMPTY_STR;
+
+		if(array_key_exists('data',$data))
+		$ptr = $data['data'];
+		if(is_array($ptr) && array_key_exists(0,$ptr)) {
+			$ptr = $ptr[0];
+		}
+		if(is_string($ptr)) {
+			$message_id = $ptr;
+		}
+		if(is_array($ptr) && array_key_exists('id',$ptr)) {
+			$message_id = $ptr['id'];
+		}
+
+		if (! $message_id) {
+			$ret['message'] = 'no message_id';
+			logger('no message_id');
+			return $ret;
+		}
+
+		$sender = $hub['hubloc_hash'];
+
+		/*
+		 * Find the local channel in charge of this post (the first and only recipient of the request packet)
+		 */
+
+		$arr = $data['recipients'][0];
+
+		$c = q("select * from channel left join xchan on channel_hash = xchan_hash where channel_hash = '%s' limit 1",
+			dbesc($arr['portable_id'])
+		);
+		if (! $c) {
+			logger('recipient channel not found.');
+			$ret['message'] .= 'recipient not found.' . EOL;
+			return $ret;
+		}
+
+		/*
+		 * fetch the requested conversation
+		 */
+
+		$messages = zot_feed($c[0]['channel_id'],$sender_hash, [ 'message_id' => $data['message_id'], 'encoding' => 'activitystreams' ]);
+
+		return (($messages) ? : [] );
+
+	}
+
+	static function rekey_request($sender,$data,$hub) {
+
+		$ret = array('success' => false);
+
+		//	newsig is newkey signed with oldkey
+
+		// The original xchan will remain. In Zot/Receiver we will have imported the new xchan and hubloc to verify
+		// the packet authenticity. What we will do now is verify that the keychange operation was signed by the
+		// oldkey, and if so change all the abook, abconfig, group, and permission elements which reference the
+		// old xchan_hash.
+
+		if((! $data['old_key']) && (! $data['new_key']) && (! $data['new_sig']))
+			return $ret;
+
+
+		$old = null;
+
+		if(Libzot::verify($data['old_guid'],$data['old_guid_sig'],$data['old_key'])) {
+			$oldhash = make_xchan_hash($data['old_guid'],$data['old_key']);
+			$old = q("select * from xchan where xchan_hash = '%s' limit 1",
+				dbesc($oldhash)
+			);
+		}
+		else 
+			return $ret;
+
+
+		if(! $old) {
+			return $ret;
+		}
+
+		$xchan = $old[0];
+
+		if(! Libzot::verify($data['new_key'],$data['new_sig'],$xchan['xchan_pubkey'])) {
+			return $ret;
+		}
+
+		$r = q("select * from xchan where xchan_hash = '%s' limit 1",
+			dbesc($sender)
+		);
+
+		$newxchan = $r[0];
+
+		// @todo
+		// if ! $update create a linked identity
+
+
+		xchan_change_key($xchan,$newxchan,$data);
+
+		$ret['success'] = true;
+		return $ret;
+	}
+
+
+	/**
+	 * @brief
+	 *
+	 * @param array $sender
+	 * @param array $recipients
+	 *
+	 * return json_return_and_die()
+	 */
+
+	static function reply_purge($sender, $recipients, $hub) {
+
+		$ret = array('success' => false);
+
+		if ($recipients) {
+			// basically this means "unfriend"
+			foreach ($recipients as $recip) {
+				$r = q("select channel.*,xchan.* from channel
+					left join xchan on channel_hash = xchan_hash
+					where channel_hash = '%s' and channel_guid_sig = '%s' limit 1",
+					dbesc($recip)
+				);
+				if ($r) {
+					$r = q("select abook_id from abook where uid = %d and abook_xchan = '%s' limit 1",
+						intval($r[0]['channel_id']),
+						dbesc($sender)
+					);
+					if ($r) {
+						contact_remove($r[0]['channel_id'],$r[0]['abook_id']);
+					}
+				}
+			}
+			$ret['success'] = true;
+		}
+		else {
+
+			// Unfriend everybody - basically this means the channel has committed suicide
+
+			remove_all_xchan_resources($sender);
+
+			$ret['success'] = true;
+		}
+
+		return $ret;
+	}
+
+
+
+
+
+
+}
-- 
cgit v1.2.3