diff --git a/Code/Daemon/Notifier.php b/Code/Daemon/Notifier.php index 885cd8043..0b9c2f928 100644 --- a/Code/Daemon/Notifier.php +++ b/Code/Daemon/Notifier.php @@ -2,6 +2,7 @@ namespace Code\Daemon; +use Code\Lib\IConfig; use Code\Lib\Libzot; use Code\Lib\Queue; use Code\Lib\Activity; @@ -387,44 +388,52 @@ class Notifier implements DaemonInterface if (($relay_to_owner || $uplink) && ($cmd !== 'relay')) { logger('followup relay (upstream delivery)', LOGGER_DEBUG); - $sendto = ($uplink) ? $parent_item['source_xchan'] : $parent_item['owner_xchan']; - self::$recipients = [ $sendto ]; - // over-ride upstream recipients if 'replyTo' was set in the parent. - if ($parent_item['replyto'] && (! $uplink)) { - logger('replyto: over-riding owner ' . $sendto, LOGGER_DEBUG); - // unserialise is a no-op if presented with data that wasn't serialised. - $ptr = unserialise($parent_item['replyto']); - if (is_string($ptr)) { - if (ActivityStreams::is_url($sendto)) { - $sendto = $ptr; - self::$recipients = [ $sendto ]; - } - } elseif (is_array($ptr)) { - $sendto = []; - foreach ($ptr as $rto) { - if (is_string($rto)) { - $sendto[] = $rto; - } elseif (is_array($rto) && isset($rto['id'])) { - $sendto[] = $rto['id']; + + $comment_recipient = IConfig::Get($target_item['id'], 'system', 'comment_recipient'); + if ($comment_recipient) { + $sendto = $comment_recipient; + self::$recipients = [$comment_recipient]; + self::$private = false; + } + else { + $sendto = ($uplink) ? $parent_item['source_xchan'] : $parent_item['owner_xchan']; + self::$recipients = [$sendto]; + // over-ride upstream recipients if 'replyTo' was set in the parent. + if ($parent_item['replyto'] && (!$uplink)) { + logger('replyto: over-riding owner ' . $sendto, LOGGER_DEBUG); + // unserialise is a no-op if presented with data that wasn't serialised. + $ptr = unserialise($parent_item['replyto']); + if (is_string($ptr)) { + if (ActivityStreams::is_url($sendto)) { + $sendto = $ptr; + self::$recipients = [$sendto]; } + } elseif (is_array($ptr)) { + $sendto = []; + foreach ($ptr as $rto) { + if (is_string($rto)) { + $sendto[] = $rto; + } elseif (is_array($rto) && isset($rto['id'])) { + $sendto[] = $rto['id']; + } + } + self::$recipients = $sendto; } - self::$recipients = $sendto; } } - logger('replyto: upstream recipients ' . print_r($sendto, true), LOGGER_DEBUG); - self::$private = true; $upstream = true; self::$packet_type = 'response'; - $is_moderated = their_perms_contains($parent_item['uid'], $sendto, 'moderated'); - if ($relay_to_owner && $thread_is_public && (! $is_moderated) && (! $question) && (! Channel::is_group($parent_item['uid']))) { + $is_moderated = their_perms_contains($parent_item['uid'], (is_array($sendto) ? $sendto[0] : $sendto), 'moderated'); + if ($relay_to_owner && $thread_is_public && $target_item['approved'] && (! $is_moderated) && (! $question) && (! Channel::is_group($parent_item['uid']))) { if (get_pconfig($target_item['uid'], 'system', 'hyperdrive', true)) { Run::Summon([ 'Notifier' , 'hyper', $item_id ]); } } - } else { + } + else { if ($cmd === 'relay') { logger('owner relay (downstream delivery)'); } else { @@ -451,7 +460,8 @@ class Notifier implements DaemonInterface } } - if ($thread_is_public && $cmd === 'hyper') { + + if ($thread_is_public && $target_item['approved'] && $cmd === 'hyper') { self::$recipients = []; $r = q( "select abook_xchan, xchan_network from abook left join xchan on abook_xchan = xchan_hash where abook_channel = %d and abook_self = 0 and abook_pending = 0 and abook_archived = 0 and not abook_xchan in ( '%s', '%s', '%s' ) ", @@ -465,8 +475,8 @@ class Notifier implements DaemonInterface self::$recipients[] = $rv['abook_xchan']; } } - self::$private = false; - } else { + } + else { self::$private = false; self::$recipients = collect_recipients($parent_item, self::$private); } diff --git a/Code/Lib/Activity.php b/Code/Lib/Activity.php index 1d36e2fee..7a0bf10c4 100644 --- a/Code/Lib/Activity.php +++ b/Code/Lib/Activity.php @@ -73,7 +73,7 @@ class Activity } - public static function fetch($url, $channel = null, $debug = false) + public static function fetch($url, $channel = null, $must_verify = false, $debug = false) { if (!$url) { return null; @@ -154,7 +154,9 @@ class Activity if (($y['type']) && (!ActivityStreams::is_an_actor($y['type']))) { $sigblock = HTTPSig::verify($x); - + if ($must_verify && !$sigblock['header_signed']) { + return null; + } if (($sigblock['header_signed']) && (!$sigblock['header_valid'])) { if ($debug) { return array_merge($x, $sigblock); @@ -808,6 +810,11 @@ class Activity $activity['inReplyTo'] = $item['thr_parent']; } + // For comment approvals and rejections + if (in_array($activity['type'], ['Accept','Reject']) && is_string($item['obj']) && strlen($item['obj'])) { + $activity['inReplyTo'] = $item['thr_parent']; + } + $cnv = get_iconfig($item['parent'], 'activitypub', 'context'); if (!$cnv) { $cnv = $activity['parent_mid']; @@ -3002,6 +3009,7 @@ class Activity } } + if (!(array_key_exists('created', $s) && $s['created'])) { $s['created'] = datetime_convert(); } @@ -3573,6 +3581,7 @@ class Activity $is_system = Channel::is_system($channel['channel_id']); $is_child_node = false; + $commentApproval = null; // Pleroma scrobbles can be really noisy and contain lots of duplicate activities. Disable them by default. @@ -3629,7 +3638,46 @@ class Activity $item['obj_type'] = 'Answer'; } } + if ($item['approved']) { + $valid = CommentApproval::verify($item, $channel); + if (!$valid) { + logger('commentApproval failed'); + return; + } + } + if (in_array($item['verb'], ['Accept', 'Reject'])) { + $i = q("select * from item where mid = '%s' and uid = %d", + dbesc(is_array($item['obj']) ? $item['obj']['id'] : $item['obj']), + intval($channel['channel_id']) + ); + if ($i) { + if ($item['verb'] === 'Accept') { + $valid = CommentApproval::verify($i[0],$channel,$act); + if ($valid) { + q("update item set approved = '%s' where mid = '%s' and uid = %d", + dbesc($item['mid']), + dbesc(is_array($item['obj']) ? $item['obj']['id'] : $item['obj']), + intval($channel['channel_id']) + ); + } + } + else { + $valid = CommentApproval::verifyReject($i[0],$channel,$act); + if ($valid) { + q("update item set approved = '%s' where mid = '%s' and uid = %d", + dbesc(''), + dbesc(is_array($item['obj']) ? $item['obj']['id'] : $item['obj']), + intval($channel['channel_id']) + ); + } + } + } + } + + if (!$item['approved'] && $parent_item['owner_xchan'] === $channel['channel_hash'] && $item['author_xchan'] !== $channel['channel_hash']) { + $commentApproval = new CommentApproval($channel, $item); + } // quietly reject group comment boosts by group owner // (usually only sent via ActivityPub so groups will work on microblog platforms) // This catches those activities if they slipped in via a conversation fetch @@ -3649,8 +3697,8 @@ class Activity || $allowed === 'moderated') { $item['item_blocked'] = ITEM_MODERATED; } - if ($item['item_blocked'] !== ITEM_MODERATED) { - self::send_accept_activity($channel, $item['author_xchan'], $item, $parent_item); + if ($item['item_blocked'] !== ITEM_MODERATED && $commentApproval) { + $commentApproval->Accept(); } } @@ -3658,7 +3706,9 @@ class Activity logger('rejected comment from ' . $item['author_xchan'] . ' for ' . $channel['channel_address']); logger('rejected: ' . print_r($item, true), LOGGER_DATA); // let the sender know we received their comment, but we don't permit spam here. - self::send_reject_activity($channel, $item['author_xchan'], $item, $parent_item); + if ($commentApproval) { + $commentApproval->Reject(); + } return; } @@ -3893,12 +3943,12 @@ class Activity } $r = q( - "select id, created, edited from item where mid = '%s' and uid = %d limit 1", + "select id, created, edited, approved from item where mid = '%s' and uid = %d limit 1", dbesc($item['mid']), intval($item['uid']) ); if ($r) { - if ($item['edited'] > $r[0]['edited']) { + if ($item['edited'] > $r[0]['edited'] || $item['approved'] !== $r[0]['approved']) { $item['id'] = $r[0]['id']; ObjCache::Set($item['mid'], $act->raw); $x = item_store_update($item, deliver: false); diff --git a/Code/Lib/CommentApproval.php b/Code/Lib/CommentApproval.php new file mode 100644 index 000000000..242006bd4 --- /dev/null +++ b/Code/Lib/CommentApproval.php @@ -0,0 +1,209 @@ +channel = $channel; + $this->item = $item; + } + + public function Accept() + { + $obj = $this->item['obj']; + if (! is_array($obj)) { + $obj = json_decode($obj, true); + } + $parent = $this->get_parent(); + $activity = post_activity_item( + [ + 'verb' => 'Accept', + 'obj_type' => $obj['type'], + 'obj' => $obj['id'], + 'item_wall' => 1, + 'allow_cid' => '', + 'owner_xchan' => $this->channel['xchan_hash'], + 'author_xchan' => $this->channel['xchan_hash'], + 'parent_mid' => $parent, + 'thr_parent' => $parent, + 'uid' => $this->channel['channel_id'], + 'title' => 'comment accepted' + ], + deliver: false, + channel: $this->channel, + observer: $this->channel + ); + if ($activity['item_id']) { + IConfig::Set($activity['item_id'], 'system', 'comment_recipient', $this->item['author_xchan']); + } + Run::Summon(['Notifier', 'comment_approval', $activity['item_id']]); + } + + public function Reject() + { + $obj = $this->item['obj']; + if (! is_array($obj)) { + $obj = json_decode($obj, true); + } + $parent = $this->get_parent(); + $activity = post_activity_item( + [ + 'verb' => 'Reject', + 'obj_type' => $obj['type'], + 'obj' => $obj['id'], + 'item_wall' => 1, + 'allow_cid' => '', + 'owner_xchan' => $this->channel['xchan_hash'], + 'author_xchan' => $this->channel['xchan_hash'], + 'parent_mid' => $parent, + 'thr_parent' => $parent, + 'uid' => $this->channel['channel_id'], + 'title' => 'comment rejected' + ], + deliver: false, + channel: $this->channel, + observer: $this->channel + ); + if ($activity['item_id']) { + IConfig::Set($activity['item_id'], 'system', 'comment_recipient', $this->item['author_xchan']); + } + Run::Summon(['Notifier', 'comment_approval', $activity['item_id']]); + } + + /** + * To be considered valid, the Accept activity referenced in replyApproval MUST + * satisfy the following properties: + * + * its actor property is the authority + * its authenticity can be asserted + * its object property is the reply under consideration + * its inReplyTo property matches that of the reply under consideration + * + * In addition, if the reply is considered valid, but has no valid replyApproval + * despite the object it is in reply to having a canReply property, the recipient MAY hide + * the reply from certain views. + */ + public static function verify($item, $channel, $approvalActivity = null) + { + + if(!$approvalActivity) { + $approvalActivity = Activity::fetch($item['approved'], $channel, true); + } + $parent_item = q("select * from item where mid = '%s'", $item['parent_mid']); + + if (! $approvalActivity) { + logger('no approval activity'); + return false; + } + if (!$parent_item) { + logger('no parent item'); + return false; + } + + if ($approvalActivity instanceof ActivityStreams) { + $act = $approvalActivity; + } + else { + $act = new ActivityStreams($approvalActivity); + } + if (! $act->is_valid()) { + logger('invalid parse'); + return false; + } + if (!$act->obj) { + logger('no object'); + return false; + } + if ($act->type !== 'Accept') { + logger('not an accept'); + return false; + } + if (!isset($act->actor)) { + logger('no actor'); + return false; + } + $sender = Activity::find_best_identity($act->actor['id']); + if (!$sender || $sender !== $parent_item[0]['owner_xchan']) { + logger('no identity'); + return false; + } + $comment = is_string($act->obj) ? $act->obj : $act->obj['id']; + if ($comment !== $item['mid']) { + logger('incorrect mid'); + return false; + } + if(!in_array($act->parent_id, [$item['thr_parent'], $item['parent_mid']])) { + logger('wrong provenance'); + logger('act: ' . $act->parent_id); + logger('item: ' . print_r($item,true)); + return false; + } + logger('comment verified', LOGGER_DEBUG); + return true; + } + + public static function verifyReject($item, $channel, $approvalActivity = null) + { + + if(!$approvalActivity) { + $approvalActivity = Activity::fetch($item['approved'], $channel, true); + } + $parent_item = q("select * from item where mid = '%s'", $item['parent_mid']); + + if (! $approvalActivity) { + return false; + } + if (!$parent_item) { + return false; + } + + if ($approvalActivity instanceof ActivityStreams) { + $act = $approvalActivity; + } + else { + $act = new ActivityStreams($approvalActivity); + } + if (! $act->is_valid()) { + return false; + } + if (!$act->obj) { + return false; + } + if ($act->type !== 'Reject') { + return false; + } + if (!isset($act->actor)) { + return false; + } + $sender = Activity::find_best_identity($act->actor['id']); + if (!$sender || $sender !== $parent_item['owner_xchan']) { + return false; + } + $comment = is_string($act->obj) ? $act->obj : $act->obj['id']; + if ($comment !== $item['mid']) { + return false; + } + if(!in_array($act->parent_id, [$item['thr_parent'], $item['parent_mid']])) { + return false; + } + logger('comment verified', LOGGER_DEBUG); + return true; + } + + + protected function get_parent() + { + $result = q("select parent_mid from item where mid = '%s'", + dbesc($this->item['parent_mid']) + ); + return $result ? $result[0]['parent_mid'] : ''; + } + +} \ No newline at end of file diff --git a/Code/Lib/Libzot.php b/Code/Lib/Libzot.php index 352964e87..8e166d9c1 100644 --- a/Code/Lib/Libzot.php +++ b/Code/Lib/Libzot.php @@ -1727,12 +1727,62 @@ class Libzot // perform pre-storage check to see if it's "likely" that this is a group or collection post + if (in_array($arr['verb'], ['Accept', 'Reject'])) { + logger('verifying comment accept/reject'); + $i = q("select * from item where mid = '%s' and uid = %d", + dbesc(is_array($arr['obj']) ? $arr['obj']['id'] : $arr['obj']), + intval($channel['channel_id']) + ); + logger('haveItem'); + if ($i) { + if ($arr['verb'] === 'Accept' && !$i[0]['approved']) { + $valid = CommentApproval::verify($i[0],$channel,$act); + if ($valid) { + q("update item set approved = '%s' where mid = '%s' and uid = %d", + dbesc($arr['mid']), + dbesc(is_array($arr['obj']) ? $arr['obj']['id'] : $arr['obj']), + intval($channel['channel_id']) + ); + //Run::Summon(['Notifier', 'activity', $i[0]['id']]); + } + } + elseif ($i[0]['approved']) { + $valid = CommentApproval::verifyReject($i[0],$channel,$act); + if ($valid) { + q("update item set approved = '%s' where mid = '%s' and uid = %d", + dbesc(''), + dbesc(is_array($arr['obj']) ? $arr['obj']['id'] : $arr['obj']), + intval($channel['channel_id']) + ); + } + // Run::Summon(['Notifier', 'activity', $i[0]['id']]); + } + // Do not store these + continue; + } + } + + + + + $tag_delivery = tgroup_check($channel['channel_id'], $arr); $perm = 'send_stream'; if (($arr['mid'] !== $arr['parent_mid']) && ($relay)) { $perm = 'post_comments'; - $commentApproval = new CommentApproval($channel, $arr); + + if ($arr['approved']) { + $valid = CommentApproval::verify($arr, $channel); + if (!$valid) { + logger('commentApproval failed'); + continue; + } + } + + if (!$arr['approved'] && $arr['author_xchan'] !== $channel['channel_hash']) { + $commentApproval = new CommentApproval($channel, $arr); + } } // This is our own post, possibly coming from a channel clone @@ -1818,6 +1868,7 @@ class Libzot } } + if (!$allowed) { if ($arr['mid'] !== $arr['parent_mid']) { if ($commentApproval) { @@ -1979,7 +2030,7 @@ class Libzot continue; } // Maybe it has been edited? - elseif ($arr['edited'] > $r[0]['edited']) { + elseif ($arr['edited'] > $r[0]['edited'] || $arr['approved'] !== $r[0]['approved']) { $arr['id'] = $r[0]['id']; $arr['uid'] = $channel['channel_id']; if (post_is_importable($channel['channel_id'], $arr, $abook)) { diff --git a/Code/Module/Dev/Ap_probe.php b/Code/Module/Dev/Ap_probe.php index ef05099e2..c196e5859 100644 --- a/Code/Module/Dev/Ap_probe.php +++ b/Code/Module/Dev/Ap_probe.php @@ -34,7 +34,7 @@ class Ap_probe extends Controller } } - $j = Activity::fetch($resource, $channel, true); + $j = Activity::fetch($resource, $channel, false,true); if ($j) { $html .= '
' . str_replace('\\n', "\n", htmlspecialchars(json_encode($j, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT))) . '
'; diff --git a/include/items.php b/include/items.php index c707ffe17..012272426 100644 --- a/include/items.php +++ b/include/items.php @@ -377,6 +377,7 @@ function absolutely_no_comments($item) { */ function post_activity_item($arr, $allow_code = false, $deliver = true, $channel = null, $observer = null) { + logger('input: ' . print_r($arr,true), LOGGER_DATA); $ret = [ 'success' => false ]; $is_comment = false; @@ -436,8 +437,8 @@ function post_activity_item($arr, $allow_code = false, $deliver = true, $channel $arr['comment_policy'] = map_scope(PermissionLimits::Get($channel['channel_id'],'post_comments')); - if ((! $arr['plink']) && (intval($arr['item_thread_top']))) { - $arr['plink'] = substr(z_root() . '/channel/' . $channel['channel_address'] . '/?f=&mid=' . urlencode($arr['mid']),0,190); + if (empty($arr['plink'])) { + $arr['plink'] = $arr['mid']; } // for the benefit of plugins, we will behave as if this is an API call rather than a normal online post