diff options
-rw-r--r-- | Zotlabs/Lib/Activity.php | 1725 | ||||
-rw-r--r-- | Zotlabs/Lib/Group.php | 405 | ||||
-rw-r--r-- | Zotlabs/Lib/Libsync.php | 1019 | ||||
-rw-r--r-- | Zotlabs/Lib/Libzot.php | 2849 | ||||
-rw-r--r-- | Zotlabs/Lib/Libzotdir.php | 654 | ||||
-rw-r--r-- | Zotlabs/Lib/Queue.php | 278 | ||||
-rw-r--r-- | Zotlabs/Lib/Webfinger.php | 109 | ||||
-rw-r--r-- | Zotlabs/Lib/Zotfinger.php | 50 | ||||
-rw-r--r-- | Zotlabs/Module/Zot.php | 25 | ||||
-rw-r--r-- | Zotlabs/Zot6/Finger.php | 146 | ||||
-rw-r--r-- | Zotlabs/Zot6/IHandler.php | 18 | ||||
-rw-r--r-- | Zotlabs/Zot6/Receiver.php | 220 | ||||
-rw-r--r-- | Zotlabs/Zot6/Zot6Handler.php | 266 |
13 files changed, 7764 insertions, 0 deletions
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 @@ +<?php + +namespace Zotlabs\Lib; + +use Zotlabs\Lib\Libzot; +use Zotlabs\Lib\Libsync; +use Zotlabs\Lib\ActivityStreams; +use Zotlabs\Lib\Group; + +class Activity { + + static function encode_object($x) { + + if(($x) && (! is_array($x)) && (substr(trim($x),0,1)) === '{' ) { + $x = json_decode($x,true); + } + if($x['type'] === ACTIVITY_OBJ_PERSON) { + return self::fetch_person($x); + } + if($x['type'] === ACTIVITY_OBJ_PROFILE) { + return self::fetch_profile($x); + } + if(in_array($x['type'], [ ACTIVITY_OBJ_NOTE, ACTIVITY_OBJ_ARTICLE ] )) { + return self::fetch_item($x); + } + if($x['type'] === ACTIVITY_OBJ_THING) { + return self::fetch_thing($x); + } + + return $x; + + } + + + static function fetch_person($x) { + return self::fetch_profile($x); + } + + static function fetch_profile($x) { + $r = q("select * from xchan where xchan_url like '%s' limit 1", + dbesc($x['id'] . '/%') + ); + if(! $r) { + $r = q("select * from xchan where xchan_hash = '%s' limit 1", + dbesc($x['id']) + ); + + } + if(! $r) + return []; + + return self::encode_person($r[0]); + + } + + static function fetch_thing($x) { + + $r = q("select * from obj where obj_type = %d and obj_obj = '%s' limit 1", + intval(TERM_OBJ_THING), + dbesc($x['id']) + ); + + if(! $r) + return []; + + $x = [ + 'type' => 'Object', + 'id' => z_root() . '/thing/' . $r[0]['obj_obj'], + 'name' => $r[0]['obj_term'] + ]; + + if($r[0]['obj_image']) + $x['image'] = $r[0]['obj_image']; + + return $x; + + } + + 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 @@ +<?php + +namespace Zotlabs\Lib; + +use Zotlabs\Lib\Libsync; + + +class Group { + + static function add($uid,$name,$public = 0) { + + $ret = false; + if(x($uid) && x($name)) { + $r = self::byname($uid,$name); // check for dups + if($r !== false) { + + // This could be a problem. + // Let's assume we've just created a group which we once deleted + // all the old members are gone, but the group remains so we don't break any security + // access lists. What we're doing here is reviving the dead group, but old content which + // was restricted to this group may now be seen by the new group members. + + $z = q("SELECT * FROM groups WHERE id = %d LIMIT 1", + intval($r) + ); + if(($z) && $z[0]['deleted']) { + q('UPDATE groups SET deleted = 0 WHERE id = %d', intval($z[0]['id'])); + notice( t('A deleted group with this name was revived. Existing item permissions <strong>may</strong> 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 @@ +<?php + +namespace Zotlabs\Lib; + +use Zotlabs\Lib\Libzot; +use Zotlabs\Lib\Queue; + + +class Libsync { + + /** + * @brief Builds and sends a sync packet. + * + * Send a zot packet to all hubs where this channel is duplicated, refreshing + * such things as personal settings, channel permissions, address book updates, etc. + * + * @param int $uid (optional) default 0 + * @param array $packet (optional) default null + * @param boolean $groups_changed (optional) default false + */ + + static function build_sync_packet($uid = 0, $packet = null, $groups_changed = false) { + + logger('build_sync_packet'); + + $keychange = (($packet && array_key_exists('keychange',$packet)) ? true : false); + if($keychange) { + logger('keychange sync'); + } + + if(! $uid) + $uid = local_channel(); + + if(! $uid) + return; + + $r = q("select * from channel where channel_id = %d limit 1", + intval($uid) + ); + if(! $r) + return; + + $channel = $r[0]; + + // don't provide these in the export + + unset($channel['channel_active']); + unset($channel['channel_password']); + unset($channel['channel_salt']); + + + if(intval($channel['channel_removed'])) + return; + + $h = q("select hubloc.*, site.site_crypto from hubloc left join site on site_url = hubloc_url where hubloc_hash = '%s' and hubloc_deleted = 0", + dbesc(($keychange) ? $packet['keychange']['old_hash'] : $channel['channel_hash']) + ); + + if(! $h) + return; + + $synchubs = array(); + + foreach($h as $x) { + if($x['hubloc_host'] == \App::get_hostname()) + continue; + + $y = q("select site_dead from site where site_url = '%s' limit 1", + dbesc($x['hubloc_url']) + ); + + if((! $y) || ($y[0]['site_dead'] == 0)) + $synchubs[] = $x; + } + + if(! $synchubs) + return; + + $env_recips = [ $channel['channel_hash'] ]; + + if($packet) + logger('packet: ' . print_r($packet, true),LOGGER_DATA, LOG_DEBUG); + + $info = (($packet) ? $packet : array()); + $info['type'] = 'sync'; + $info['encoding'] = 'red'; // note: not zot, this packet is very platform specific + $info['relocate'] = ['channel_address' => $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 @@ +<?php + +namespace Zotlabs\Lib; + +/** + * @brief lowlevel implementation of Zot6 protocol. + * + */ + +use Zotlabs\Lib\DReport; +use Zotlabs\Lib\Enotify; +use Zotlabs\Lib\Group; +use Zotlabs\Lib\Libsync; +use Zotlabs\Lib\Libzotdir; +use Zotlabs\Lib\System; +use Zotlabs\Lib\MessageFilter; +use Zotlabs\Lib\Queue; +use Zotlabs\Lib\Zotfinger; +use Zotlabs\Web\HTTPSig; + +require_once('include/crypto.php'); + + +class Libzot { + + /** + * @brief Generates a unique string for use as a zot guid. + * + * Generates a unique string for use as a zot guid using our DNS-based url, the + * channel nickname and some entropy. + * The entropy ensures uniqueness against re-installs where the same URL and + * nickname are chosen. + * + * @note zot doesn't require this to be unique. Internally we use a whirlpool + * hash of this guid and the signature of this guid signed with the channel + * private key. This can be verified and should make the probability of + * collision of the verified result negligible within the constraints of our + * immediate universe. + * + * @param string $channel_nick a unique nickname of controlling entity + * @returns string + */ + + static function new_uid($channel_nick) { + $rawstr = z_root() . '/' . $channel_nick . '.' . mt_rand(); + return(base64url_encode(hash('whirlpool', $rawstr, true), true)); + } + + + /** + * @brief Generates a portable hash identifier for a channel. + * + * Generates a portable hash identifier for the channel identified by $guid and + * $pubkey. + * + * @note This ID is portable across the network but MUST be calculated locally + * by verifying the signature and can not be trusted as an identity. + * + * @param string $guid + * @param string $pubkey + */ + + static function make_xchan_hash($guid, $pubkey) { + return base64url_encode(hash('whirlpool', $guid . $pubkey, true)); + } + + /** + * @brief Given a zot hash, return all distinct hubs. + * + * This function is used in building the zot discovery packet and therefore + * should only be used by channels which are defined on this hub. + * + * @param string $hash - xchan_hash + * @returns array of hubloc (hub location structures) + * + */ + + static function get_hublocs($hash) { + + /* Only search for active hublocs - e.g. those that haven't been marked deleted */ + + $ret = q("select * from hubloc where hubloc_hash = '%s' and hubloc_deleted = 0 order by hubloc_url ", + dbesc($hash) + ); + + return $ret; + } + + /** + * @brief Builds a zot6 notification packet. + * + * Builds a zot6 notification packet that you can either store in the queue with + * a message array or call zot_zot to immediately zot it to the other side. + * + * @param array $channel + * sender channel structure + * @param string $type + * packet type: one of 'ping', 'pickup', 'purge', 'refresh', 'keychange', 'force_refresh', 'notify', 'auth_check' + * @param array $recipients + * envelope recipients, array of portable_id's; empty for public posts + * @param string msg + * optional message + * @param string $remote_key + * optional public site key of target hub used to encrypt entire packet + * NOTE: remote_key and encrypted packets are required for 'auth_check' packets, optional for all others + * @param string $methods + * optional comma separated list of encryption methods @ref self::best_algorithm() + * @returns string json encoded zot packet + */ + + static function build_packet($channel, $type = 'activity', $recipients = null, $msg = '', $encoding = 'activitystreams', $remote_key = null, $methods = '') { + + $sig_method = get_config('system','signature_algorithm','sha256'); + + $data = [ + 'type' => $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 @@ +<?php + +namespace Zotlabs\Lib; + +use Zotlabs\Lib\Libzot; + +require_once('include/permissions.php'); + + +class Libzotdir { + + /** + * @brief + * + * @param int $dirmode + * @return array + */ + + static function find_upstream_directory($dirmode) { + global $DIRECTORY_FALLBACK_SERVERS; + + $preferred = get_config('system','directory_server'); + + // Thwart attempts to use a private directory + + if(($preferred) && ($preferred != z_root())) { + $r = q("select * from site where site_url = '%s' limit 1", + dbesc($preferred) + ); + if(($r) && ($r[0]['site_flags'] & DIRECTORY_MODE_STANDALONE)) { + $preferred = ''; + } + } + + + if (! $preferred) { + + /* + * No directory has yet been set. For most sites, pick one at random + * from our list of directory servers. However, if we're a directory + * server ourself, point at the local instance + * We will then set this value so this should only ever happen once. + * Ideally there will be an admin setting to change to a different + * directory server if you don't like our choice or if circumstances change. + */ + + $dirmode = intval(get_config('system','directory_mode')); + if ($dirmode == DIRECTORY_MODE_NORMAL) { + $toss = mt_rand(0,count($DIRECTORY_FALLBACK_SERVERS)); + $preferred = $DIRECTORY_FALLBACK_SERVERS[$toss]; + if(! $preferred) { + $preferred = DIRECTORY_FALLBACK_MASTER; + } + set_config('system','directory_server',$preferred); + } + else { + set_config('system','directory_server',z_root()); + } + } + if($preferred) { + return [ 'url' => $preferred ]; + } + else { + return []; + } + } + + + /** + * Directories may come and go over time. We will need to check that our + * directory server is still valid occasionally, and reset to something that + * is if our directory has gone offline for any reason + */ + + static function check_upstream_directory() { + + $directory = 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 @@ +<?php /** @file */ + +namespace Zotlabs\Lib; + +use Zotlabs\Lib\Libzot; + + +class Queue { + + static function update($id, $add_priority = 0) { + + logger('queue: requeue item ' . $id,LOGGER_DEBUG); + $x = q("select outq_created, outq_posturl from outq where outq_hash = '%s' limit 1", + dbesc($id) + ); + if(! $x) + return; + + + $y = q("select min(outq_created) as earliest from outq where outq_posturl = '%s'", + dbesc($x[0]['outq_posturl']) + ); + + // look for the oldest queue entry with this destination URL. If it's older than a couple of days, + // the destination is considered to be down and only scheduled once an hour, regardless of the + // age of the current queue item. + + $might_be_down = false; + + if($y) + $might_be_down = ((datetime_convert('UTC','UTC',$y[0]['earliest']) < datetime_convert('UTC','UTC','now - 2 days')) ? true : false); + + + // Set all other records for this destination way into the future. + // The queue delivers by destination. We'll keep one queue item for + // this destination (this one) with a shorter delivery. If we succeed + // once, we'll try to deliver everything for that destination. + // The delivery will be set to at most once per hour, and if the + // queue item is less than 12 hours old, we'll schedule for fifteen + // minutes. + + $r = q("UPDATE outq SET outq_scheduled = '%s' WHERE outq_posturl = '%s'", + dbesc(datetime_convert('UTC','UTC','now + 5 days')), + dbesc($x[0]['outq_posturl']) + ); + + $since = datetime_convert('UTC','UTC',$x[0]['outq_created']); + + if(($might_be_down) || ($since < datetime_convert('UTC','UTC','now - 12 hour'))) { + $next = datetime_convert('UTC','UTC','now + 1 hour'); + } + else { + $next = datetime_convert('UTC','UTC','now + ' . intval($add_priority) . ' minutes'); + } + + q("UPDATE outq SET outq_updated = '%s', + outq_priority = outq_priority + %d, + outq_scheduled = '%s' + WHERE outq_hash = '%s'", + + dbesc(datetime_convert()), + intval($add_priority), + dbesc($next), + dbesc($id) + ); + } + + + static function remove($id,$channel_id = 0) { + logger('queue: remove queue item ' . $id,LOGGER_DEBUG); + $sql_extra = (($channel_id) ? " and outq_channel = " . intval($channel_id) . " " : ''); + + q("DELETE FROM outq WHERE outq_hash = '%s' $sql_extra", + dbesc($id) + ); + } + + + static function remove_by_posturl($posturl) { + logger('queue: remove queue posturl ' . $posturl,LOGGER_DEBUG); + + q("DELETE FROM outq WHERE outq_posturl = '%s' ", + dbesc($posturl) + ); + } + + + + static function set_delivered($id,$channel = 0) { + logger('queue: set delivered ' . $id,LOGGER_DEBUG); + $sql_extra = (($channel_id) ? " and outq_channel = " . intval($channel_id) . " " : ''); + + // Set the next scheduled run date so far in the future that it will be expired + // long before it ever makes it back into the delivery chain. + + q("update outq set outq_delivered = 1, outq_updated = '%s', outq_scheduled = '%s' where outq_hash = '%s' $sql_extra ", + dbesc(datetime_convert()), + dbesc(datetime_convert('UTC','UTC','now + 5 days')), + dbesc($id) + ); + } + + + + static function insert($arr) { + + // do not queue anything with no destination + + if(! (array_key_exists('posturl',$arr) && trim($arr['posturl']))) { + return false; + } + + $x = q("insert into outq ( outq_hash, outq_account, outq_channel, outq_driver, outq_posturl, outq_async, outq_priority, + outq_created, outq_updated, outq_scheduled, outq_notify, outq_msg ) + values ( '%s', %d, %d, '%s', '%s', %d, %d, '%s', '%s', '%s', '%s', '%s' )", + dbesc($arr['hash']), + intval($arr['account_id']), + intval($arr['channel_id']), + dbesc(($arr['driver']) ? $arr['driver'] : 'zot'), + dbesc($arr['posturl']), + intval(1), + intval(($arr['priority']) ? $arr['priority'] : 0), + dbesc(datetime_convert()), + dbesc(datetime_convert()), + dbesc(datetime_convert()), + dbesc($arr['notify']), + dbesc(($arr['msg']) ? $arr['msg'] : '') + ); + return $x; + + } + + + + static function deliver($outq, $immediate = false) { + + $base = null; + $h = parse_url($outq['outq_posturl']); + if($h !== false) + $base = $h['scheme'] . '://' . $h['host'] . (($h['port']) ? ':' . $h['port'] : ''); + + if(($base) && ($base !== z_root()) && ($immediate)) { + $y = q("select site_update, site_dead from site where site_url = '%s' ", + dbesc($base) + ); + if($y) { + if(intval($y[0]['site_dead'])) { + self::remove_by_posturl($outq['outq_posturl']); + logger('dead site ignored ' . $base); + return; + } + if($y[0]['site_update'] < datetime_convert('UTC','UTC','now - 1 month')) { + self::update($outq['outq_hash'],10); + logger('immediate delivery deferred for site ' . $base); + return; + } + } + else { + + // zot sites should all have a site record, unless they've been dead for as long as + // your site has existed. Since we don't know for sure what these sites are, + // call them unknown + + site_store_lowlevel( + [ + 'site_url' => $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 @@ +<?php + +namespace Zotlabs\Lib; + +/** + * @brief Fetch and return a webfinger for a resource + * + * @param string $resource - The resource + * @return boolean|string false or associative array from result JSON + */ + +class Webfinger { + + static private $server = EMPTY_STR; + static private $resource = EMPTY_STR; + + static function exec($resource) { + + if(! $resource) { + return false; + } + + self::parse_resource($resource); + + if(! ( self::$server && self::$resource)) { + return false; + } + + if(! check_siteallowed(self::$server)) { + logger('blacklisted: ' . self::$server); + return false; + } + + btlogger('fetching resource: ' . self::$resource . ' from ' . self::$server, LOGGER_DEBUG, LOG_INFO); + + $url = 'https://' . self::$server . '/.well-known/webfinger?f=&resource=' . self::$resource ; + + $counter = 0; + $s = z_fetch_url($url, false, $counter, [ 'headers' => [ '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 @@ +<?php + +namespace Zotlabs\Lib; + +use Zotlabs\Web\HTTPSig; + +class Zotfinger { + + static function exec($resource,$channel = null) { + + if(! $resource) { + return false; + } + + if($channel) { + $headers = [ + 'Accept' => '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 @@ +<?php +/** + * @file Zotlabs/Module/Zot.php + * + * @brief Zot endpoint. + * + */ + +namespace Zotlabs\Module; + +use Zotlabs\Zot6 as ZotProtocol; + +/** + * @brief Zot module. + * + */ + +class Zot extends \Zotlabs\Web\Controller { + + function init() { + $zot = new ZotProtocol\Receiver(new ZotProtocol\Zot6Handler()); + json_return_and_die($zot->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 @@ +<?php + +namespace Zotlabs\Zot6; + +/** + * @brief Finger + * + */ +class Finger { + + static private $token; + + /** + * @brief Look up information about channel. + * + * @param string $webbie + * does not have to be host qualified e.g. 'foo' is treated as 'foo\@thishub' + * @param array $channel + * (optional), if supplied permissions will be enumerated specifically for $channel + * @param boolean $autofallback + * fallback/failover to http if https connection cannot be established. Default is true. + * + * @return zotinfo array (with 'success' => 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 @@ +<?php + +namespace Zotlabs\Zot6; + +interface IHandler { + + function Notify($data,$hub); + + function Request($data,$hub); + + function Rekey($sender,$data,$hub); + + function Refresh($sender,$recipients,$hub); + + function Purge($sender,$recipients,$hub); + +} + diff --git a/Zotlabs/Zot6/Receiver.php b/Zotlabs/Zot6/Receiver.php new file mode 100644 index 000000000..4f26e2b0c --- /dev/null +++ b/Zotlabs/Zot6/Receiver.php @@ -0,0 +1,220 @@ +<?php + +namespace Zotlabs\Zot6; + +use Zotlabs\Lib\Config; +use Zotlabs\Lib\Libzot; +use Zotlabs\Web\HTTPSig; + +class Receiver { + + protected $data; + protected $encrypted; + protected $error; + protected $messagetype; + protected $sender; + protected $site_id; + protected $validated; + protected $recipients; + protected $response; + protected $handler; + protected $prvkey; + protected $rawdata; + protected $sigdata; + + function __construct($handler, $localdata = null) { + + $this->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 @@ +<?php + +namespace Zotlabs\Zot6; + +use Zotlabs\Lib\Libzot; +use Zotlabs\Lib\Queue; + +class Zot6Handler implements IHandler { + + function Notify($data,$hub) { + return self::reply_notify($data,$hub); + } + + function Request($data,$hub) { + return self::reply_message_request($data,$hub); + } + + function Rekey($sender,$data,$hub) { + return self::reply_rekey_request($sender,$data,$hub); + } + + function Refresh($sender,$recipients,$hub) { + return self::reply_refresh($sender,$recipients,$hub); + } + + function Purge($sender,$recipients,$hub) { + return self::reply_purge($sender,$recipients,$hub); + } + + + // Implementation of specific methods follows; + // These generally do a small amout of validation and call Libzot + // to do any heavy lifting + + static function reply_notify($data,$hub) { + + $ret = [ 'success' => 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; + } + + + + + + +} |