diff --git a/Code/Extend/Hook.php b/Code/Extend/Hook.php index b0c79476c..c1d4ac8ff 100644 --- a/Code/Extend/Hook.php +++ b/Code/Extend/Hook.php @@ -3,6 +3,7 @@ namespace Code\Extend; use App; +use DBA; /** * @brief Hook class. diff --git a/Code/Lib/Account.php b/Code/Lib/Account.php index de415db26..97a83aa6c 100644 --- a/Code/Lib/Account.php +++ b/Code/Lib/Account.php @@ -625,7 +625,7 @@ class Account { Channel::auto_create($register[0]['uid']); } else { $_SESSION['login_return_url'] = 'new_channel'; - authenticate_success($account[0], null, true, true, false, true); + authenticate_success($account[0], false, true, true, false, true); } return true; diff --git a/Code/Lib/Activity.php b/Code/Lib/Activity.php index 83d21ba85..93d1b06ad 100644 --- a/Code/Lib/Activity.php +++ b/Code/Lib/Activity.php @@ -778,13 +778,14 @@ class Activity if (!in_array($ret['type'], ['Create', 'Update', 'Accept', 'Reject', 'TentativeAccept', 'TentativeReject'])) { $ret['inReplyTo'] = $i['thr_parent']; - $cnv = get_iconfig($i['parent'], 'activitypub', 'context'); - if (!$cnv) { - $cnv = get_iconfig($i['parent'], 'ostatus', 'conversation'); - } - if (!$cnv) { - $cnv = $ret['parent_mid']; - } + } + + $cnv = get_iconfig($i['parent'], 'activitypub', 'context'); + if (!$cnv) { + $cnv = get_iconfig($i['parent'], 'ostatus', 'conversation'); + } + if (!$cnv) { + $cnv = $ret['parent_mid']; } } @@ -3822,17 +3823,24 @@ class Activity $item['allow_gid'] = $item['deny_cid'] = $item['deny_gid'] = ''; } } - - // Private conversation, but this comment went rogue and was published publicly - // Set item_restrict to indicate this condition so we can flag it in the UI - - if (intval($parent[0]['item_private']) !== 0 && $act->recips && (in_array(ACTIVITY_PUBLIC_INBOX, $act->recips) || in_array('Public', $act->recips) || in_array('as:Public', $act->recips))) { - $item['item_restrict'] = $item['item_restrict'] | 2; - } } self::rewrite_mentions($item); + if (! isset($item['replyto'])) { + if (strpos($item['owner_xchan'],'http') === 0) { + $item['replyto'] = $item['owner_xchan']; + } + else { + $r = q("select hubloc_id_url from hubloc where hubloc_hash = '%s' and hubloc_primary = 1", + dbesc($item['owner_xchan']) + ); + if ($r) { + $item['replyto'] = $r[0]['hubloc_id_url']; + } + } + } + $r = q( "select id, created, edited from item where mid = '%s' and uid = %d limit 1", dbesc($item['mid']), @@ -3887,13 +3895,16 @@ class Activity // We are the owner of this conversation, so send all received comments back downstream Run::Summon(['Notifier', 'comment-import', $x['item_id']]); } - $r = q( - "select * from item where id = %d limit 1", - intval($x['item_id']) - ); - if ($r) { - send_status_notifications($x['item_id'], $r[0]); - } + } + elseif ($act->client && $channel['channel_hash'] === $observer_hash) { + Run::Summon(['Notifier', 'wall-new', $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']); } diff --git a/Code/Lib/ActivityStreams.php b/Code/Lib/ActivityStreams.php index b0f64ae6b..e85c274f6 100644 --- a/Code/Lib/ActivityStreams.php +++ b/Code/Lib/ActivityStreams.php @@ -16,6 +16,7 @@ class ActivityStreams public $data = null; public $meta = null; public $hub = null; + public $client = false; public $valid = false; public $deleted = false; public $id = ''; @@ -48,7 +49,8 @@ class ActivityStreams $this->raw = $string; $this->hub = $hub; - + $this->client = $client; + if (is_array($string)) { $this->data = $string; $this->raw = json_encode($string, JSON_UNESCAPED_SLASHES); diff --git a/Code/Module/Item.php b/Code/Module/Item.php index 9ccd8ac9e..194340b5c 100644 --- a/Code/Module/Item.php +++ b/Code/Module/Item.php @@ -219,7 +219,8 @@ class Item extends Controller dbesc($r[0]['parent_mid']), dbesc($portable_id) ); - } elseif (Config::get('system', 'require_authenticated_fetch', false)) { + } + elseif (Config::get('system', 'require_authenticated_fetch', false)) { http_status_exit(403, 'Permission denied'); } @@ -738,7 +739,7 @@ class Item extends Controller dbesc($channel['channel_hash']) ); if ($r && count($r)) { - $owner_xchan = $r[0]; + $owner_xchan = array_shift($r); } else { logger("mod_item: no owner."); if ($api_source) { @@ -775,15 +776,6 @@ class Item extends Controller } } - if (!isset($replyto)) { - if (strpos($owner_xchan['xchan_hash'], 'http') === 0) { - $replyto = $owner_xchan['xchan_hash']; - } else { - $replyto = $owner_xchan['xchan_url']; - } - } - - $acl = new AccessControl($channel); $view_policy = PermissionLimits::Get($channel['channel_id'], 'view_stream'); @@ -1396,6 +1388,24 @@ class Item extends Controller $datarray['obj']['id'] = $mid; } + + if (! (isset($replyto) && $replyto)) { + if ($owner_hash && strpos($owner_hash,'http') === 0) { + $replyto = $owner_hash; + } + else { + $tmp = $owner_hash ? $owner_hash : $owner_xchan['xchan_hash']; + if ($tmp) { + $r = q("select hubloc_id_url from hubloc where hubloc_hash = '%s' and hubloc_primary = 1", + dbesc($tmp) + ); + if ($r) { + $replyto = $r[0]['hubloc_id_url']; + } + } + } + } + if ($private && !$parent) { if ( intval($private) === 1 && (!$str_group_allow) && in_array(substr_count($str_contact_allow,'<'), [ 1, 2 ])) { $private = 2; diff --git a/Code/Module/Lockview.php b/Code/Module/Lockview.php index b53d810e6..47a9abb76 100644 --- a/Code/Module/Lockview.php +++ b/Code/Module/Lockview.php @@ -98,7 +98,7 @@ class Lockview extends Controller $l = array_merge($l, $recips['cc']); } for ($x = 0; $x < count($l); $x++) { - if ($l[$x] === ACTIVITY_PUBLIC_INBOX) { + if ($l[$x] === ACTIVITY_PUBLIC_INBOX || $l[$x] === 'Public' || $l[$x] === 'as:Public') { $l[$x] = '' . t('Everybody') . ''; } else { $l[$x] = '' . $l[$x] . ''; diff --git a/Code/Module/Outbox.php b/Code/Module/Outbox.php index bc26f40ef..cdafe5c33 100644 --- a/Code/Module/Outbox.php +++ b/Code/Module/Outbox.php @@ -11,20 +11,34 @@ use Code\Web\HTTPSig; use Code\Lib\Activity; use Code\Lib\ActivityPub; use Code\Lib\Config; +use Code\Lib\PConfig; use Code\Lib\Channel; +require_once('include/api_auth.php'); +require_once('include/api.php'); + /** * Implements an ActivityPub outbox. */ class Outbox extends Controller { + public function init() { + if (! api_user()) { + api_login(); + } + } + public function post() { if (argc() < 2) { killme(); } + if (! api_user()) { + killme(); + } + $channel = Channel::from_username(argv(1)); if (!$channel) { killme(); @@ -34,25 +48,20 @@ class Outbox extends Controller killme(); } - // At this point there is unlikely to be an authenticated observer using the C2S ActivityPub API. - // Mostly we're protecting the page from malicious mischief until the project's OAuth2 interface - // is linked to this page. - $observer = App::get_observer(); if (!$observer) { killme(); } - + if ($observer['xchan_hash'] !== $channel['channel_hash']) { if (!perm_is_allowed($channel['channel_id'], $observer['xchan_hash'], 'post_wall')) { logger('outbox post permission denied to ' . $observer['xchan_name']); killme(); } } -// disable C2S until we've fully tested it. - return; - + $observer_hash = get_observer_hash(); + $data = file_get_contents('php://input'); if (!$data) { return; @@ -60,7 +69,8 @@ class Outbox extends Controller logger('outbox_activity: ' . jindent($data), LOGGER_DATA); - $AS = new ActivityStreams($data); + // the third parameter signals to the parser that we are using C2S and that implied Create activities are supported + $AS = new ActivityStreams($data, null, true); if (!$AS->is_valid()) { return; @@ -70,41 +80,57 @@ class Outbox extends Controller return; } + // ensure the posted activity has required attributes + + $uuid = new_uuid(); + + if (! $AS->id) { + $AS->id = z_root() . '/activity/' . $uuid; + } + + if (isset($AS->obj) && (! isset($AS->obj['id']))) { + $AS->obj['id'] = z_root() . '/item/' . $uuid; + } + + if (! isset($AS->actor)) { + $AS->actor = Channel::url($channel); + } + logger('outbox_channel: ' . $channel['channel_address'], LOGGER_DEBUG); -// switch ($AS->type) { -// case 'Follow': -// if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStreams::is_an_actor($AS->obj['type']) && isset($AS->obj['id'])) { -// // do follow activity -// Activity::follow($channel,$AS); -// } -// break; -// case 'Invite': -// if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { -// // do follow activity -// Activity::follow($channel,$AS); -// } -// break; -// case 'Join': -// if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { -// // do follow activity -// Activity::follow($channel,$AS); -// } -// break; -// case 'Accept': -// // Activitypub for wordpress sends lowercase 'follow' on accept. -// // https://github.com/pfefferle/wordpress-activitypub/issues/97 -// // Mobilizon sends Accept/"Member" (not in vocabulary) in response to Join/Group -// if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && in_array($AS->obj['type'], ['Follow','follow', 'Member'])) { -// // do follow activity -// Activity::follow($channel,$AS); -// } -// break; -// case 'Reject': -// default: -// break; -// -// } + switch ($AS->type) { + case 'Follow': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStreams::is_an_actor($AS->obj['type']) && isset($AS->obj['id'])) { + // do follow activity + Activity::follow($channel,$AS); + } + break; + case 'Invite': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { + // do follow activity + Activity::follow($channel,$AS); + } + break; + case 'Join': + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && $AS->obj['type'] === 'Group') { + // do follow activity + Activity::follow($channel,$AS); + } + break; + case 'Accept': + // Activitypub for wordpress sends lowercase 'follow' on accept. + // https://github.com/pfefferle/wordpress-activitypub/issues/97 + // Mobilizon sends Accept/"Member" (not in vocabulary) in response to Join/Group + if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && in_array($AS->obj['type'], ['Follow','follow', 'Member'])) { + // do follow activity + Activity::follow($channel,$AS); + } + break; + case 'Reject': + default: + break; + + } // These activities require permissions @@ -113,10 +139,7 @@ class Outbox extends Controller switch ($AS->type) { case 'Update': if (is_array($AS->obj) && array_key_exists('type', $AS->obj) && ActivityStreams::is_an_actor($AS->obj['type'])) { - // pretend this is an old cache entry to force an update of all the actor details - $AS->obj['cached'] = true; - $AS->obj['updated'] = datetime_convert('UTC', 'UTC', '1980-01-01', ATOM_TIME); - Activity::actor_store($AS->obj['id'], $AS->obj); + Activity::actor_store($AS->obj['id'], $AS->obj, true /* force cache refresh */); break; } case 'Accept': @@ -186,8 +209,69 @@ class Outbox extends Controller } if ($item) { + // fixup some of the item fields when using C2S + + if (! (isset($item['parent_mid']) && $item['parent_mid'])) { + $item['parent_mid'] = $item['mid']; + } + // map ActivityPub recipients to Nomad ACLs to the extent possible. + if (isset($AS->recips)) { + $item['item_private'] = ((in_array(ACTIVITY_PUBLIC_INBOX, $AS->recips) + || in_array('Public', $AS->recips) + || in_array('as:Public', $AS->recips)) + ? 0 + : 1 + ); + + if ($item['item_private']) { + foreach ($AS->recips as $recip) { + if (strpos($recip,'/lists/')) { + $r = q("select * from pgrp where hash = '%s' and uid = %d", + dbesc(basename($recip)), + intval($channel['channel_id']) + ); + if ($r) { + if (! isset($item['allow_gid'])) { + $item['allow_gid'] = EMPTY_STR; + } + $item['allow_gid'] .= '<' . $r[0]['hash'] . '>'; + } + continue; + } + if ($recip === z_root() . '/followers/' . $channel['channel_address']) { + // map to a virtual list/group even if the app isn't installed. This should do the right + // thing and create a followers-only post with the correct ACL as long as the public stream + // isn't addressed. And if it is, the post will still go to all your connections - so the ACL isn't + // necessary. + if (! isset($item['allow_gid'])) { + $item['allow_gid'] = EMPTY_STR; + } + $item['allow_gid'] .= ''; + continue; + } + $r = q("select * from hubloc where hubloc_id_url = '%s'", + dbesc($recip) + ); + if ($r) { + if (! isset($item['allow_cid'])) { + $item['allow_cid'] = EMPTY_STR; + } + $item['allow_cid'] .= '<' . $r[0]['hubloc_hash'] . '>'; + } + } + } + // set the DM flag if needed + if ($item['item_private'] && isset($item['allow_cid']) && ! isset($item['allow_gid']) + && in_array(substr_count($item['allow_cid'],'<'), [ 1, 2 ])) { + $item['item_private'] = 2; + } + } + + $item['item_wall'] = 1; + logger('parsed_item: ' . print_r($item, true), LOGGER_DATA); Activity::store($channel, $observer_hash, $AS, $item); + } http_status_exit(200, 'OK'); diff --git a/Code/Module/Register.php b/Code/Module/Register.php index fae8eea39..0c5287a0f 100644 --- a/Code/Module/Register.php +++ b/Code/Module/Register.php @@ -174,7 +174,7 @@ class Register extends Controller // fall through and authenticate if no approvals or verifications were required. - authenticate_success($result['account'], null, true, false, true); + authenticate_success($result['account'], false, true, false, true); $new_channel = false; $next_page = 'new_channel'; diff --git a/Code/Module/Search.php b/Code/Module/Search.php index 82f4c6ae4..c598d3ef0 100644 --- a/Code/Module/Search.php +++ b/Code/Module/Search.php @@ -12,6 +12,8 @@ use Code\Daemon\Run; use Code\Lib\Channel; use Code\Lib\Navbar; use Code\Render\Theme; +use Code\Lib\LDSignatures; +use Code\Web\HTTPSig; require_once("include/bbcode.php"); @@ -52,7 +54,7 @@ class Search extends Controller } Navbar::set_selected('Search'); - $format = (($_REQUEST['format']) ? $_REQUEST['format'] : ''); + $format = (($_REQUEST['module_format']) ? $_REQUEST['module_format'] : ''); if ($format !== '') { $this->updating = $this->loading = 1; } @@ -66,15 +68,19 @@ class Search extends Controller if (x(App::$data, 'search')) { $search = trim(App::$data['search']); + $saved_id = 'search=' . urlencode($_GET['search']); } else { $search = ((x($_GET, 'search')) ? trim(escape_tags(rawurldecode($_GET['search']))) : ''); + $saved_id = 'search=' . urlencode($_GET['search']); } $tag = false; if (x($_GET, 'tag')) { $tag = true; $search = ((x($_GET, 'tag')) ? trim(escape_tags(rawurldecode($_GET['tag']))) : ''); + $saved_id = 'tag=' . urlencode($_GET['tag']); } + $static = ((array_key_exists('static', $_REQUEST)) ? intval($_REQUEST['static']) : 0); $o .= search($search, 'search-box', '/search', ((local_channel()) ? true : false)); @@ -199,21 +205,21 @@ class Search extends Controller $tag = true; $search = substr($search, 1); } - if (strpos($search, '@') === 0) { + if (strpos($search, '@') === 0 && $format === '') { $search = substr($search, 1); goaway(z_root() . '/directory' . '?f=1&navsearch=1&search=' . $search); } - if (strpos($search, '!') === 0) { + if (strpos($search, '!') === 0 && $format === '') { $search = substr($search, 1); goaway(z_root() . '/directory' . '?f=1&navsearch=1&search=' . $search); } - if (strpos($search, '?') === 0) { + if (strpos($search, '?') === 0 && $format === '') { $search = substr($search, 1); goaway(z_root() . '/help' . '?f=1&navsearch=1&search=' . $search); } // look for a naked webbie - if (strpos($search, '@') !== false && strpos($search, 'http') !== 0) { + if (strpos($search, '@') !== false && strpos($search, 'http') !== 0 && $format === '') { goaway(z_root() . '/directory' . '?f=1&navsearch=1&search=' . $search); } @@ -335,16 +341,28 @@ class Search extends Controller $items = []; } - if ($format == 'json') { - $result = []; - require_once('include/conversation.php'); - foreach ($items as $item) { - $item['html'] = zidify_links(bbcode($item['body'])); - $x = encode_item($item); - $x['html'] = prepare_text($item['body'], $item['mimetype']); - $result[] = $x; - } - json_return_and_die(array('success' => true, 'messages' => $result)); + if ($format === 'json') { + + $chan = Channel::get_system(); + + $i = Activity::encode_item_collection($items, 'search?' . $saved_id , 'OrderedCollection', true, count($items)); + + $x = array_merge(['@context' => [ + ACTIVITYSTREAMS_JSONLD_REV, + 'https://w3id.org/security/v1', + Activity::ap_schema() + ]], $i); + + $headers = []; + $headers['Content-Type'] = 'application/x-nomad+json'; + $x['signature'] = LDSignatures::sign($x, $chan); + $ret = json_encode($x, JSON_UNESCAPED_SLASHES); + $headers['Digest'] = HTTPSig::generate_digest_header($ret); + $headers['(request-target)'] = strtolower($_SERVER['REQUEST_METHOD']) . ' ' . $_SERVER['REQUEST_URI']; + $h = HTTPSig::create_sig($headers, $chan['channel_prvkey'], Channel::url($chan)); + HTTPSig::set_headers($h); + echo $ret; + killme(); } if ($tag) { diff --git a/FEDERATION.md b/FEDERATION.md index 9cf2ac39a..6ce5eb654 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -3,7 +3,19 @@ Federation The ActivityPub implementation in this project strives to be compliant to the core spec where possible, while offering a range of services and features which normally aren't provided by ActivityPub projects. +C2S +This project supports ActivityPub C2S. You may authenticate with HTTP basic-auth, OAuth2, or OpenWebAuth. There is no media upload endpoint since the (deprecated) specification of that service has no workarounds for working in memory-restricted environments and most mobile phone photos exceed PHP's default upload size limits. + +Client search interface + +If public access is allowed to the content search interface (a site security setting), clients may search the content of public messages or tags and are returned an ActivityStreams Collection of search results. When authenticated via OpenWebAuth, the search results may contain their own content or private content which they are permitted to access. + +The URL endpoints are: + + https://example.com/search?search=banana + https://example.com/search?tag=banana + Direct Messages Direct Messages (DM) are differentiated from other private messaging using the zot:directMessage flag (boolean). This is compatible with the same facility provided by other projects in other namespaces and is not prefixed within activities so that these may potentially be aggregated. diff --git a/include/api_auth.php b/include/api_auth.php index 78b871d61..5031ff476 100644 --- a/include/api_auth.php +++ b/include/api_auth.php @@ -14,7 +14,7 @@ require_once('include/security.php'); /** - * API Login via basic-auth or OAuth + * API Login via basic-auth, OpenWebAuth, or OAuth2 */ function api_login() @@ -58,8 +58,7 @@ function api_login() intval($record['channel_account_id']) ); if ($x) { - require_once('include/security.php'); - authenticate_success($x[0], null, true, false, true, true); + authenticate_success($x[0], false, true, false, true, true); $_SESSION['allow_api'] = true; Hook::call('logged_in', App::$user); return; @@ -164,8 +163,8 @@ function api_login() function retry_basic_auth($method = 'Basic') { - header('WWW-Authenticate: ' . $method . ' realm="' . System::get_platform_name() . '"'); + header('WWW-Authenticate: ' . $method . ' realm="' . System::get_project_name() . '"'); header('HTTP/1.0 401 Unauthorized'); - echo( t('This api method requires authentication')); + echo( t('This API method requires authentication.')); killme(); } diff --git a/include/auth.php b/include/auth.php index 010c77641..08c37438b 100644 --- a/include/auth.php +++ b/include/auth.php @@ -43,15 +43,10 @@ function account_verify_password($login, $pass) { $ret = [ 'account' => null, 'channel' => null, 'xchan' => null ]; - $login = punify($login); $email_verify = get_config('system', 'verify_email'); $register_policy = get_config('system', 'register_policy'); - if (! $login) { - return null; - } - $account = null; $channel = null; $xchan = null; @@ -76,7 +71,13 @@ function account_verify_password($login, $pass) if (($addon_auth['authenticated']) && is_array($addon_auth['user_record']) && (! empty($addon_auth['user_record']))) { $ret['account'] = $addon_auth['user_record']; return $ret; - } else { + } + else { + if (! $login) { + logger('No login identity provided or authenticate addon failed.'); + return false; + } + $login = punify($login); if (! strpos($login, '@')) { $channel = Channel::from_username($login); if (! $channel) { @@ -230,7 +231,7 @@ if ( App::$session->new_cookie(60 * 60 * 24); // one day $_SESSION['last_login_date'] = datetime_convert(); unset($_SESSION['visitor_id']); // no longer a visitor - authenticate_success($x[0], null, true, true); + authenticate_success($x[0], false, true, true); } } if (array_key_exists('atoken', $_SESSION)) { @@ -279,7 +280,7 @@ if ( $login_refresh = true; } $ch = (($_SESSION['uid']) ? Channel::from_id($_SESSION['uid']) : null); - authenticate_success($r[0], null, $ch, false, false, $login_refresh); + authenticate_success($r[0], false, $ch, false, false, $login_refresh); } else { $_SESSION['account_id'] = 0; App::$session->nuke(); diff --git a/include/security.php b/include/security.php index ef060b85e..3a917f617 100644 --- a/include/security.php +++ b/include/security.php @@ -17,7 +17,7 @@ use Code\Extend\Hook; * @param bool $return * @param bool $update_lastlog */ -function authenticate_success($user_record, $channel = null, $login_initial = false, $interactive = false, $return = false, $update_lastlog = false) +function authenticate_success($user_record, $channel = false, $login_initial = false, $interactive = false, $return = false, $update_lastlog = false) { $_SESSION['addr'] = $_SERVER['REMOTE_ADDR']; diff --git a/include/text.php b/include/text.php index 0fd06f5a8..8ec893048 100644 --- a/include/text.php +++ b/include/text.php @@ -948,7 +948,7 @@ function get_tags($s) // Pull out single word tags. These can be @nickname, @first_last // and #hash tags. - if (preg_match_all('/(?