From 6a1f9a7fa358062e7b360e9c017b5a6887d2da0c Mon Sep 17 00:00:00 2001
From: Hypolite Petovan <hypolite@mrpetovan.com>
Date: Sat, 16 Oct 2021 21:24:34 -0400
Subject: [PATCH] Move unfollow protocol to worker task

- Add uid parameter to unfollow hook
- Rework and unify Contact::unfollow, Contact::removeSharer and Contact::terminateFriendship
- Add missing twitterUser property to Twitter\Friendships\Destroy module
---
 doc/Addons.md                                 |  3 +-
 mod/unfollow.php                              | 14 +--
 src/Console/Contact.php                       | 11 +--
 src/Core/Protocol.php                         | 11 ++-
 src/Model/Contact.php                         | 95 +++++++++++--------
 src/Module/Api/Mastodon/Accounts/Block.php    |  3 +-
 src/Module/Api/Mastodon/Accounts/Unfollow.php |  9 +-
 .../Api/Twitter/Friendships/Destroy.php       | 30 +++---
 src/Protocol/DFRN.php                         |  2 +-
 src/Worker/Contact/Unfollow.php               | 57 +++++++++++
 src/Worker/Notifier.php                       |  2 +-
 11 files changed, 159 insertions(+), 78 deletions(-)
 create mode 100644 src/Worker/Contact/Unfollow.php

diff --git a/doc/Addons.md b/doc/Addons.md
index 578cffe7ca..504ab43984 100644
--- a/doc/Addons.md
+++ b/doc/Addons.md
@@ -626,7 +626,8 @@ Hook data:
 Called when unfollowing a remote contact on a non-native network (like Twitter)
 
 Hook data:
-- **contact** (input): the remote contact (uid = local unfollowing user id) array.
+- **contact** (input): the target public contact (uid = 0) array.
+- **uid** (input): the id of the source local user.
 - **result** (output): wether the unfollowing is successful or not.
 
 ### revoke_follow
diff --git a/mod/unfollow.php b/mod/unfollow.php
index 0aa8a87b50..10830bd103 100644
--- a/mod/unfollow.php
+++ b/mod/unfollow.php
@@ -122,8 +122,7 @@ function unfollow_process(string $url)
 
 	$owner = User::getOwnerDataById($uid);
 	if (!$owner) {
-		(new \Friendica\Module\Security\Logout())->init();
-		// NOTREACHED
+		throw new \Friendica\Network\HTTPException\NotFoundException();
 	}
 
 	$condition = ["`uid` = ? AND (`rel` = ? OR `rel` = ?) AND (`nurl` = ? OR `alias` = ? OR `alias` = ?)",
@@ -140,15 +139,10 @@ function unfollow_process(string $url)
 	$return_path = $base_return_path . '/' . $contact['id'];
 
 	try {
-		$result = Contact::terminateFriendship($owner, $contact);
-
-		if ($result === false) {
-			$notice_message = DI::l10n()->t('Unable to unfollow this contact, please retry in a few minutes or contact your administrator.');
-		} else {
-			$notice_message = DI::l10n()->t('Contact was successfully unfollowed');
-		}
+		Contact::unfollow($contact);
+		$notice_message = DI::l10n()->t('Contact was successfully unfollowed');
 	} catch (Exception $e) {
-		DI::logger()->error($e->getMessage(), ['owner' => $owner, 'contact' => $contact]);
+		DI::logger()->error($e->getMessage(), ['contact' => $contact]);
 		$notice_message = DI::l10n()->t('Unable to unfollow this contact, please contact your administrator');
 	}
 
diff --git a/src/Console/Contact.php b/src/Console/Contact.php
index 11f7f87ced..f051d870a2 100644
--- a/src/Console/Contact.php
+++ b/src/Console/Contact.php
@@ -199,19 +199,18 @@ HELP;
 			throw new RuntimeException('Contact not found');
 		}
 
-		$user = UserModel::getById($contact['uid']);
+		if (empty($contact['uid'])) {
+			throw new RuntimeException('Contact must be user-specific (uid != 0)');
+		}
 
 		try {
-			$result = ContactModel::terminateFriendship($user, $contact);
-			if ($result === false) {
-				throw new RuntimeException('Unable to unfollow this contact, please retry in a few minutes or check the logs.');
-			}
+			ContactModel::unfollow($contact);
 
 			$this->out('Contact was successfully unfollowed');
 
 			return true;
 		} catch (\Exception $e) {
-			DI::logger()->error($e->getMessage(), ['owner' => $user, 'contact' => $contact]);
+			DI::logger()->error($e->getMessage(), ['contact' => $contact]);
 			throw new RuntimeException('Unable to unfollow this contact, please check the log');
 		}
 	}
diff --git a/src/Core/Protocol.php b/src/Core/Protocol.php
index 1b26265255..47d4b4539f 100644
--- a/src/Core/Protocol.php
+++ b/src/Core/Protocol.php
@@ -171,15 +171,15 @@ class Protocol
 	}
 
 	/**
-	 * Sends an unfriend message. Does not remove the contact
+	 * Sends an unfollow message. Does not remove the contact
 	 *
-	 * @param array   $user    User unfriending
-	 * @param array   $contact Contact unfriended
+	 * @param array $contact Target public contact (uid = 0) array
+	 * @param array $user    Source local user array
 	 * @return bool|null true if successful, false if not, null if no remote action was performed
 	 * @throws HTTPException\InternalServerErrorException
 	 * @throws \ImagickException
 	 */
-	public static function terminateFriendship(array $user, array $contact): ?bool
+	public static function unfollow(array $contact, array $user): ?bool
 	{
 		if (empty($contact['network'])) {
 			throw new \InvalidArgumentException('Missing network key in contact array');
@@ -216,7 +216,8 @@ class Protocol
 		// Catch-all hook for connector addons
 		$hook_data = [
 			'contact' => $contact,
-			'result' => null
+			'uid'     => $user['uid'],
+			'result'  => null,
 		];
 		Hook::callAll('unfollow', $hook_data);
 
diff --git a/src/Model/Contact.php b/src/Model/Contact.php
index 9b0020d009..99b1c0971f 100644
--- a/src/Model/Contact.php
+++ b/src/Model/Contact.php
@@ -813,26 +813,28 @@ class Contact
 	}
 
 	/**
-	 * Sends an unfriend message. Removes the contact for two-way unfriending or sharing only protocols (feed an mail)
+	 * Unfollow the remote contact
 	 *
-	 * @param array   $user    User unfriending
-	 * @param array   $contact Contact (uid != 0) unfriended
-	 * @param boolean $two_way Revoke eventual inbound follow as well
-	 * @return bool|null true if successful, false if not, null if no remote action was performed
+	 * @param array $contact Target user-specific contact (uid != 0) array
 	 * @throws HTTPException\InternalServerErrorException
 	 * @throws \ImagickException
 	 */
-	public static function terminateFriendship(array $user, array $contact): ?bool
+	public static function unfollow(array $contact): void
 	{
-		$result = Protocol::terminateFriendship($user, $contact);
-
-		if ($contact['rel'] == Contact::SHARING || in_array($contact['network'], [Protocol::FEED, Protocol::MAIL])) {
-			self::remove($contact['id']);
-		} else {
-			self::update(['rel' => Contact::FOLLOWER], ['id' => $contact['id']]);
+		if (empty($contact['network'])) {
+			throw new \InvalidArgumentException('Empty network in contact array');
 		}
 
-		return $result;
+		if (empty($contact['uid'])) {
+			throw new \InvalidArgumentException('Unexpected public contact record');
+		}
+
+		if (in_array($contact['rel'], [self::SHARING, self::FRIEND])) {
+			$cdata = Contact::getPublicAndUserContactID($contact['id'], $contact['uid']);
+			Worker::add(PRIORITY_HIGH, 'Contact\Unfollow', $cdata['public'], $contact['uid']);
+		}
+
+		self::removeSharer($contact);
 	}
 
 	/**
@@ -868,6 +870,36 @@ class Contact
 		return $result;
 	}
 
+	/**
+	 * Completely severs a relationship with a contact
+	 *
+	 * @param array $contact User-specific contact (uid != 0) array
+	 * @throws HTTPException\InternalServerErrorException
+	 * @throws \ImagickException
+	 */
+	public static function terminateFriendship(array $contact)
+	{
+		if (empty($contact['network'])) {
+			throw new \InvalidArgumentException('Empty network in contact array');
+		}
+
+		if (empty($contact['uid'])) {
+			throw new \InvalidArgumentException('Unexpected public contact record');
+		}
+
+		$cdata = Contact::getPublicAndUserContactID($contact['id'], $contact['uid']);
+
+		if (in_array($contact['rel'], [self::SHARING, self::FRIEND])) {
+			Worker::add(PRIORITY_HIGH, 'Contact\Unfollow', $cdata['public'], $contact['uid']);
+		}
+
+		if (in_array($contact['rel'], [self::FOLLOWER, self::FRIEND])) {
+			Worker::add(PRIORITY_HIGH, 'Contact\RevokeFollow', $cdata['public'], $contact['uid']);
+		}
+
+		self::remove($contact['id']);
+	}
+
 
 	/**
 	 * Marks a contact for archival after a communication issue delay
@@ -2574,28 +2606,6 @@ class Contact
 		return $result;
 	}
 
-	/**
-	 * Unfollow a contact
-	 *
-	 * @param int $cid Public contact id
-	 * @param int $uid  User ID
-	 *
-	 * @return bool "true" if unfollowing had been successful
-	 */
-	public static function unfollow(int $cid, int $uid)
-	{
-		$cdata = self::getPublicAndUserContactID($cid, $uid);
-		if (empty($cdata['user'])) {
-			return false;
-		}
-
-		$contact = self::getById($cdata['user']);
-
-		self::removeSharer([], $contact);
-
-		return true;
-	}
-
 	/**
 	 * @param array  $importer Owner (local user) data
 	 * @param array  $contact  Existing owner-specific contact data we want to expand the relationship with. Optional.
@@ -2755,12 +2765,19 @@ class Contact
 		}
 	}
 
-	public static function removeSharer($importer, $contact)
+	/**
+	 * Update the local relationship when a local user unfollow a contact.
+	 * Removes the contact for sharing-only protocols (feed and mail).
+	 *
+	 * @param array $contact User-specific contact (uid != 0) array
+	 * @throws HTTPException\InternalServerErrorException
+	 */
+	public static function removeSharer(array $contact)
 	{
-		if (($contact['rel'] == self::FRIEND) || ($contact['rel'] == self::FOLLOWER)) {
-			self::update(['rel' => self::FOLLOWER], ['id' => $contact['id']]);
-		} else {
+		if ($contact['rel'] == self::SHARING || in_array($contact['network'], [Protocol::FEED, Protocol::MAIL])) {
 			self::remove($contact['id']);
+		} else {
+			self::update(['rel' => self::FOLLOWER], ['id' => $contact['id']]);
 		}
 	}
 
diff --git a/src/Module/Api/Mastodon/Accounts/Block.php b/src/Module/Api/Mastodon/Accounts/Block.php
index 94c0e3712a..41d0a6f5e3 100644
--- a/src/Module/Api/Mastodon/Accounts/Block.php
+++ b/src/Module/Api/Mastodon/Accounts/Block.php
@@ -59,8 +59,7 @@ class Block extends BaseApi
 		Contact\User::setBlocked($cdata['user'], $uid, true);
 
 		// Mastodon-expected behavior: relationship is severed on block
-		Contact::terminateFriendship($owner, $contact);
-		Contact::revokeFollow($contact);
+		Contact::terminateFriendship($contact);
 
 		System::jsonExit(DI::mstdnRelationship()->createFromContactId($this->parameters['id'], $uid)->toArray());
 	}
diff --git a/src/Module/Api/Mastodon/Accounts/Unfollow.php b/src/Module/Api/Mastodon/Accounts/Unfollow.php
index db1e049db9..29aa82b49e 100644
--- a/src/Module/Api/Mastodon/Accounts/Unfollow.php
+++ b/src/Module/Api/Mastodon/Accounts/Unfollow.php
@@ -40,7 +40,14 @@ class Unfollow extends BaseApi
 			DI::mstdnError()->UnprocessableEntity();
 		}
 
-		Contact::unfollow($this->parameters['id'], $uid);
+		$cdata = Contact::getPublicAndUserContactID($this->parameters['id'], $uid);
+		if (empty($cdata['user'])) {
+			DI::mstdnError()->RecordNotFound();
+		}
+
+		$contact = Contact::getById($cdata['user']);
+
+		Contact::unfollow($contact);
 
 		System::jsonExit(DI::mstdnRelationship()->createFromContactId($this->parameters['id'], $uid)->toArray());
 	}
diff --git a/src/Module/Api/Twitter/Friendships/Destroy.php b/src/Module/Api/Twitter/Friendships/Destroy.php
index e2e0dd70c5..b730f06636 100644
--- a/src/Module/Api/Twitter/Friendships/Destroy.php
+++ b/src/Module/Api/Twitter/Friendships/Destroy.php
@@ -22,13 +22,18 @@
 namespace Friendica\Module\Api\Twitter\Friendships;
 
 use Exception;
+use Friendica\App;
+use Friendica\Core\L10n;
 use Friendica\Core\Logger;
-use Friendica\DI;
+use Friendica\Factory\Api\Twitter\User as TwitterUser;
 use Friendica\Model\Contact;
 use Friendica\Model\User;
+use Friendica\Module\Api\ApiResponse;
 use Friendica\Module\Api\Twitter\ContactEndpoint;
 use Friendica\Module\BaseApi;
 use Friendica\Network\HTTPException;
+use Friendica\Util\Profiler;
+use Psr\Log\LoggerInterface;
 
 /**
  * Unfollow Contact
@@ -37,6 +42,16 @@ use Friendica\Network\HTTPException;
  */
 class Destroy extends ContactEndpoint
 {
+	/** @var TwitterUser */
+	private $twitterUser;
+
+	public function __construct(App $app, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, ApiResponse $response, TwitterUser $twitterUser, array $server, array $parameters = [])
+	{
+		parent::__construct($app, $l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters);
+
+		$this->twitterUser = $twitterUser;
+	}
+
 	protected function post(array $request = [])
 	{
 		BaseApi::checkAllowedScope(BaseApi::SCOPE_WRITE);
@@ -66,18 +81,9 @@ class Destroy extends ContactEndpoint
 		$user    = $this->twitterUser->createFromContactId($contact_id, $uid, true)->toArray();
 
 		try {
-			$result = Contact::terminateFriendship($owner, $contact);
-
-			if ($result === null) {
-				Logger::notice(BaseApi::LOG_PREFIX . 'Not supported for {network}', ['module' => 'api', 'action' => 'friendships_destroy', 'network' => $contact['network']]);
-				throw new HTTPException\ExpectationFailedException('Unfollowing is currently not supported by this contact\'s network.');
-			}
-
-			if ($result === false) {
-				throw new HTTPException\ServiceUnavailableException('Unable to unfollow this contact, please retry in a few minutes or contact your administrator.');
-			}
+			Contact::unfollow($contact);
 		} catch (Exception $e) {
-			Logger::error(BaseApi::LOG_PREFIX . $e->getMessage(), ['owner' => $owner, 'contact' => $contact]);
+			Logger::error(BaseApi::LOG_PREFIX . $e->getMessage(), ['contact' => $contact]);
 			throw new HTTPException\InternalServerErrorException('Unable to unfollow this contact, please contact your administrator');
 		}
 
diff --git a/src/Protocol/DFRN.php b/src/Protocol/DFRN.php
index 50faf987aa..2be55b8a55 100644
--- a/src/Protocol/DFRN.php
+++ b/src/Protocol/DFRN.php
@@ -1668,7 +1668,7 @@ class DFRN
 			}
 			if ($activity->match($item["verb"], Activity::UNFRIEND)) {
 				Logger::notice("Lost sharer");
-				Contact::removeSharer($importer, $contact, $item);
+				Contact::removeSharer($contact);
 				return false;
 			}
 		} else {
diff --git a/src/Worker/Contact/Unfollow.php b/src/Worker/Contact/Unfollow.php
new file mode 100644
index 0000000000..a6d8c59445
--- /dev/null
+++ b/src/Worker/Contact/Unfollow.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * @copyright Copyright (C) 2010-2022, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace Friendica\Worker\Contact;
+
+use Friendica\Core\Protocol;
+use Friendica\Core\Worker;
+use Friendica\Model\Contact;
+use Friendica\Model\User;
+
+class Unfollow
+{
+	/**
+	 * Issue asynchronous unfollow message to remote servers.
+	 * The local relationship has already been updated, so we can't use the user-specific contact.
+	 *
+	 * @param int $cid Target public contact (uid = 0) id
+	 * @param int $uid Source local user id
+	 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
+	 * @throws \ImagickException
+	 */
+	public static function execute(int $cid, int $uid)
+	{
+		$contact = Contact::getById($cid);
+		if (empty($contact)) {
+			return;
+		}
+
+		$owner = User::getOwnerDataById($uid, false);
+		if (empty($owner)) {
+			return;
+		}
+
+		$result = Protocol::unfollow($contact, $owner);
+		if ($result === false) {
+			Worker::defer();
+		}
+	}
+}
diff --git a/src/Worker/Notifier.php b/src/Worker/Notifier.php
index e570b13fe1..80628d7dba 100644
--- a/src/Worker/Notifier.php
+++ b/src/Worker/Notifier.php
@@ -673,7 +673,7 @@ class Notifier
 		}
 
 		while($contact = DBA::fetch($contacts_stmt)) {
-			Protocol::terminateFriendship($owner, $contact, true);
+			Contact::terminateFriendship($contact);
 		}
 		DBA::close($contacts_stmt);