diff --git a/php/federator/api/fedusers/inbox.php b/php/federator/api/fedusers/inbox.php index 9cb03e1..9ca8ca4 100644 --- a/php/federator/api/fedusers/inbox.php +++ b/php/federator/api/fedusers/inbox.php @@ -43,7 +43,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface /** * handle post call * - * @param string|null $_user user to add data to inbox @unused-param + * @param string|null $_user user to add data to inbox * @return string|false response */ public function post($_user) @@ -107,6 +107,10 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface && (filter_var($receiver, FILTER_VALIDATE_URL) !== false); }); + if (isset($_user)) { + $receivers[] = $dbh->real_escape_string($_user); // Add the target user to the receivers list + } + // Special handling for Follow and Undo follow activities if (strtolower($inboxActivity->getType()) === 'follow') { // For Follow, the object should hold the target @@ -126,6 +130,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface } } } + $ourDomain = $config['generic']['externaldomain']; foreach ($receivers as $receiver) { if ($receiver === '' || !is_string($receiver)) { @@ -157,18 +162,19 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface $users = array_merge($users, array_column($followers, 'id')); } } else { - $ourDomain = $config['generic']['externaldomain']; // check if receiver is an actor url from our domain - if (!str_contains($receiver, $ourDomain)) { + if (!str_contains($receiver, $ourDomain) && $receiver !== $_user) { continue; } - $receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? '')); - $ourDomain = parse_url($receiver, PHP_URL_HOST); - if ($receiverName === null || $ourDomain === null) { - error_log("Inbox::post no receiverName or domain found for receiver: " . $receiver); - continue; + if ($receiver !== $_user) { + $receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? '')); + $ourDomain = parse_url($receiver, PHP_URL_HOST); + if ($receiverName === null || $ourDomain === null) { + error_log("Inbox::post no receiverName or domain found for receiver: " . $receiver); + continue; + } + $receiver = $receiverName; } - $receiver = $receiverName . '@' . $ourDomain; try { $localUser = \Federator\DIO\User::getUserByName( $dbh, @@ -209,6 +215,30 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface ]); error_log("Inbox::post enqueued job for user: $user->id with token: $token"); } + if (empty($users)) { + $type = strtolower($inboxActivity->getType()); + if ($type === 'undo' || $type === 'delete') { + $token = \Resque::enqueue('inbox', 'Federator\\Jobs\\InboxJob', [ + 'user' => $user->id, + 'recipientId' => "", + 'activity' => $inboxActivity->toObject(), + ]); + error_log("Inbox::post enqueued job for user: $user->id with token: $token"); + } else { + error_log("Inbox::post no users found for activity, doing nothing: " . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + } + + try { + $articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $inboxActivity); + if ($articleId !== null) { + $connector->sendActivity($user, $inboxActivity); + } + } catch (\Throwable $e) { + error_log("Inbox::postForUser Error sending activity to connector. Exception: " . $e->getMessage()); + return false; + } + return "success"; } @@ -241,6 +271,56 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface return false; } + $type = strtolower($inboxActivity->getType()); + + if ($_recipientId === '') { + if ($type === 'undo' || $type === 'delete') { + switch ($type) { + case 'delete': + // Delete Note/Post + $object = $inboxActivity->getObject(); + if (is_string($object)) { + \Federator\DIO\Posts::deletePost($dbh, $object); + } elseif (is_object($object)) { + $objectId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $objectId); + } else { + error_log("Inbox::postForUser Error in Delete Post for user $user->id, object is not a string or object"); + error_log(" object of type " . gettype($object)); + return false; + } + break; + + case 'undo': + $object = $inboxActivity->getObject(); + if (is_object($object)) { + switch (strtolower($object->getType())) { + case 'like': + case 'dislike': + // Undo Like/Dislike (remove like/dislike) + $targetId = $object->getID(); + // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike'); + \Federator\DIO\Posts::deletePost($dbh, $targetId); + break; + case 'note': + case 'article': + // Undo Note (remove note) + $noteId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $noteId); + break; + } + } + break; + + default: + error_log("Inbox::postForUser Unhandled activity type $type for user $user->id"); + break; + } + + return true; + } + } + $atPos = strpos($_recipientId, '@'); if ($atPos !== false) { $_recipientId = substr($_recipientId, 0, $atPos); @@ -266,18 +346,16 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface FILE_APPEND ); - $type = $inboxActivity->getType(); - switch ($type) { - case 'Follow': + case 'follow': $success = \Federator\DIO\Followers::addExternalFollow($dbh, $inboxActivity->getID(), $user->id, $recipient->id); if ($success === false) { - error_log("Inbox::postForUser: Failed to add follower for user $user->id"); + error_log("Inbox::postForUser Failed to add follower for user $user->id"); } break; - case 'Delete': + case 'delete': // Delete Note/Post $object = $inboxActivity->getObject(); if (is_string($object)) { @@ -288,11 +366,11 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface } break; - case 'Undo': + case 'undo': $object = $inboxActivity->getObject(); if (is_object($object)) { - switch ($object->getType()) { - case 'Follow': + switch (strtolower($object->getType())) { + case 'follow': $success = false; if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) { $actor = $object->getAActor(); @@ -301,75 +379,60 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface } } if ($success === false) { - error_log("Inbox::postForUser: Failed to remove follower for user $user->id"); + error_log("Inbox::postForUser Failed to remove follower for user $user->id"); } break; - case 'Like': - // Undo Like (remove like) - if (method_exists($object, 'getObject')) { - $targetId = $object->getObject(); - if (is_string($targetId)) { - // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'like'); - \Federator\DIO\Posts::deletePost($dbh, $targetId); - } else { - error_log("Inbox::postForUser: Error in Undo Like for user $user->id, targetId is not a string"); - } - } + case 'like': + case 'dislike': + // Undo Like/Dislike (remove like/dislike) + $targetId = $object->getID(); + // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike'); + \Federator\DIO\Posts::deletePost($dbh, $targetId); break; - case 'Dislike': - // Undo Dislike (remove dislike) - if (method_exists($object, 'getObject')) { - $targetId = $object->getObject(); - if (is_string($targetId)) { - // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike'); - \Federator\DIO\Posts::deletePost($dbh, $targetId); - } else { - error_log("Inbox::postForUser: Error in Undo Dislike for user $user->id, targetId is not a string"); - } - } - break; - case 'Note': + case 'note': // Undo Note (remove note) - if (method_exists($object, 'getID')) { - $noteId = $object->getID(); - \Federator\DIO\Posts::deletePost($dbh, $noteId); - } + $noteId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $noteId); break; } } break; - case 'Like': - // Add Like - $targetId = $inboxActivity->getObject(); - if (is_string($targetId)) { - // \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like'); - \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); - } else { - error_log("Inbox::postForUser: Error in Add Like for user $user->id, targetId is not a string"); - return false; - } - break; - - case 'Dislike': - // Add Dislike + case 'like': + case 'dislike': + // Add Like/Dislike $targetId = $inboxActivity->getObject(); if (is_string($targetId)) { // \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'dislike'); \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); } else { - error_log("Inbox::postForUser: Error in Add Dislike for user $user->id, targetId is not a string"); + error_log("Inbox::postForUser Error in Add Like/Dislike for user $user->id, targetId is not a string"); return false; } break; - case 'Note': - // Post Note - \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + case 'create': + case 'update': + $object = $inboxActivity->getObject(); + if (is_object($object)) { + switch (strtolower($object->getType())) { + case 'note': + \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + + break; + case 'article': + \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + + break; + default: + \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + break; + } + } break; default: - error_log("Inbox::postForUser: Unhandled activity type $type for user $user->id"); + error_log("Inbox::postForUser Unhandled activity type $type for user $user->id"); break; } diff --git a/php/federator/api/v1/newcontent.php b/php/federator/api/v1/newcontent.php index 57a2c14..816afe0 100644 --- a/php/federator/api/v1/newcontent.php +++ b/php/federator/api/v1/newcontent.php @@ -100,8 +100,9 @@ class NewContent implements \Federator\Api\APIInterface return false; } + $articleId = ""; if (isset($allHeaders['X-Sender'])) { - $newActivity = $connector->jsonToActivity($input); + $newActivity = $connector->jsonToActivity($input, $articleId); } else { error_log("NewContent::post No X-Sender header found"); return false; @@ -212,6 +213,7 @@ class NewContent implements \Federator\Api\APIInterface 'user' => $posterName, 'recipientId' => $receiver, 'activity' => $newActivity->toObject(), + 'articleId' => $articleId, ]); error_log("Inbox::post enqueued job for receiver: $receiver with token: $token"); } @@ -232,9 +234,11 @@ class NewContent implements \Federator\Api\APIInterface * @param string $_user user that triggered the post * @param string $_recipientId recipient of the post * @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) * @return boolean response */ - public static function postForUser($dbh, $connector, $cache, $host, $_user, $_recipientId, $newActivity) + public static function postForUser($dbh, $connector, $cache, $host, $_user, $_recipientId, $newActivity, $articleId) { if (!isset($_user)) { error_log("NewContent::postForUser no user given"); @@ -272,10 +276,10 @@ class NewContent implements \Federator\Api\APIInterface FILE_APPEND ); - $type = $newActivity->getType(); + $type = strtolower($newActivity->getType()); switch ($type) { - case 'Follow': + case 'follow': // $success = false; $actor = $newActivity->getAActor(); if ($actor !== '') { @@ -293,7 +297,7 @@ class NewContent implements \Federator\Api\APIInterface } */ break; - case 'Delete': + case 'delete': // Delete Note/Post $object = $newActivity->getObject(); if (is_string($object)) { @@ -304,11 +308,11 @@ class NewContent implements \Federator\Api\APIInterface } break; - case 'Undo': + case 'undo': $object = $newActivity->getObject(); if (is_object($object)) { - switch ($object->getType()) { - case 'Follow': + switch (strtolower($object->getType())) { + case 'follow': $success = false; if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) { $actor = $object->getAActor(); @@ -332,8 +336,8 @@ class NewContent implements \Federator\Api\APIInterface error_log("NewContent::postForUser Failed to remove follower for user $user->id"); } break; - case 'Like': - case 'Dislike': + case 'like': + case 'dislike': if (method_exists($object, 'getObject')) { $targetId = $object->getObject(); if (is_string($targetId)) { @@ -344,13 +348,13 @@ class NewContent implements \Federator\Api\APIInterface } } break; - case 'Note': + case 'note': // Undo Note (remove note) $noteId = $object->getID(); \Federator\DIO\Posts::deletePost($dbh, $noteId); break; - case 'Article': + case 'article': $articleId = $object->getID(); \Federator\DIO\Posts::deletePost($dbh, $articleId); // also remove latest saved article-update @@ -381,30 +385,30 @@ class NewContent implements \Federator\Api\APIInterface } break; - case 'Like': - case 'Dislike': + case 'like': + case 'dislike': // Add Like/Dislike $targetId = $newActivity->getObject(); if (is_string($targetId)) { // \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like'); - \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId); } else { error_log("NewContent::postForUser Error in Add Like/Dislike for recipient $recipient->id, targetId is not a string"); return false; } break; - case 'Create': - case 'Update': + case 'create': + case 'update': $object = $newActivity->getObject(); if (is_object($object)) { - switch ($object->getType()) { - case 'Note': - \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); + switch (strtolower($object->getType())) { + case 'note': + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId); break; - case 'Article': - \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); + case 'article': + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId); $idPart = strrchr($recipient->id, '@'); if ($idPart === false) { @@ -423,7 +427,7 @@ class NewContent implements \Federator\Api\APIInterface break; default: - \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId); break; } } @@ -531,7 +535,6 @@ class NewContent implements \Federator\Api\APIInterface $response = curl_exec($ch); curl_close($ch); - // Log the response for debugging if needed if ($response === false) { throw new \Exception("Failed to send activity: " . curl_error($ch)); } else { diff --git a/php/federator/connector/connector.php b/php/federator/connector/connector.php index f02c02e..c51e42a 100644 --- a/php/federator/connector/connector.php +++ b/php/federator/connector/connector.php @@ -70,9 +70,20 @@ interface Connector * Convert jsonData to Activity format * * @param array $jsonData the json data from our platfrom + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) * @return \Federator\Data\ActivityPub\Common\Activity|false */ - public function jsonToActivity(array $jsonData); + public function jsonToActivity(array $jsonData, &$articleId); + + /** + * send target-friendly json from ActivityPub activity + * + * @param \Federator\Data\FedUser $sender the user of the sender + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity + * @return boolean did we successfully send the activity? + */ + public function sendActivity($sender, $activity); /** * check if the headers include a valid signature diff --git a/php/federator/data/activitypub/common/delete.php b/php/federator/data/activitypub/common/delete.php index bffb89f..aa23167 100644 --- a/php/federator/data/activitypub/common/delete.php +++ b/php/federator/data/activitypub/common/delete.php @@ -10,17 +10,6 @@ namespace Federator\Data\ActivityPub\Common; class Delete extends Activity { - /** - * object overwrite - * @var string - */ - private $object = ""; - - public function setFObject(string $object): void - { - $this->object = $object; - } - public function __construct() { parent::__construct('Delete'); @@ -33,10 +22,6 @@ class Delete extends Activity */ public function fromJson($json): bool { - if (array_key_exists('object', $json)) { - $this->object = $json['object']; - unset($json['object']); - } return parent::fromJson($json); } /** @@ -46,9 +31,6 @@ class Delete extends Activity public function toObject() { $return = parent::toObject(); - if ($this->object !== "") { - $return['object'] = $this->object; - } return $return; } } diff --git a/php/federator/data/activitypub/factory.php b/php/federator/data/activitypub/factory.php index 1c92f1a..3fbf3c1 100644 --- a/php/federator/data/activitypub/factory.php +++ b/php/federator/data/activitypub/factory.php @@ -67,6 +67,9 @@ class Factory case 'Inbox': $return = new Common\Inbox(); break; + case 'Tombstone': + $return = new Common\APObject("Tombstone"); + break; /*case 'Question': $return = new Common\Question(); break; diff --git a/php/federator/dio/posts.php b/php/federator/dio/posts.php index fbd33d7..5527482 100644 --- a/php/federator/dio/posts.php +++ b/php/federator/dio/posts.php @@ -216,13 +216,15 @@ class Posts * @param \mysqli $dbh * @param string $userId * @param \Federator\Data\ActivityPub\Common\Activity $post + * @param string|null $articleId the original id of the article + * (used to identify the source article in the remote system) * @return bool */ - public static function savePost($dbh, $userId, $post) + public static function savePost($dbh, $userId, $post, $articleId = null) { $sql = 'INSERT INTO posts ( - `id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published` - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published`, `article_id` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `url` = VALUES(`url`), `user_id` = VALUES(`user_id`), @@ -231,7 +233,8 @@ class Posts `object` = VALUES(`object`), `to` = VALUES(`to`), `cc` = VALUES(`cc`), - `published` = VALUES(`published`)'; + `published` = VALUES(`published`), + `article_id` = VALUES(`article_id`)'; $stmt = $dbh->prepare($sql); if ($stmt === false) { throw new \Federator\Exceptions\ServerError(); @@ -248,6 +251,9 @@ class Posts if ($objectJson === false) { $objectJson = null; } + if (is_object($object)) { + $id = $object->getID(); + } $to = $post->getTo(); $cc = $post->getCC(); $toJson = is_array($to) ? json_encode($to) : (is_string($to) ? json_encode([$to]) : null); @@ -256,7 +262,7 @@ class Posts $publishedStr = $published ? gmdate('Y-m-d H:i:s', $published) : gmdate('Y-m-d H:i:s'); $stmt->bind_param( - "sssssssss", + "ssssssssss", $id, $url, $userId, @@ -265,7 +271,8 @@ class Posts $objectJson, $toJson, $ccJson, - $publishedStr + $publishedStr, + $articleId, ); $result = $stmt->execute(); $stmt->close(); @@ -292,4 +299,40 @@ class Posts $stmt->close(); return $affectedRows > 0; } + + /** retrieve original article id of post + * + * @param \mysqli $dbh + * @param \Federator\Data\ActivityPub\Common\Activity $post + * @return string|null + */ + public static function getOriginalArticleId($dbh, $post) + { + $sql = 'SELECT `article_id` FROM posts WHERE id = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $id = $post->getID(); + $object = $post->getObject(); + if (is_object($object)) { + $inReplyTo = $object->getInReplyTo(); + if ($inReplyTo !== "") { + $id = $inReplyTo; // Use inReplyTo as ID if it's a string + } else { + $id = $object->getObject(); + } + } elseif (is_string($object)) { + $id = $object; // If object is a string, use it directly + } + $stmt->bind_param("s", $id); + $articleId = null; + $ret = $stmt->bind_result($articleId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + return $articleId; + } } diff --git a/php/federator/jobs/newContentJob.php b/php/federator/jobs/newContentJob.php index 480fe32..2796e30 100644 --- a/php/federator/jobs/newContentJob.php +++ b/php/federator/jobs/newContentJob.php @@ -56,6 +56,7 @@ class NewContentJob extends \Federator\Api $user = $this->args['user']; $recipientId = $this->args['recipientId']; $activity = $this->args['activity']; + $articleId = $this->args['articleId'] ?? null; $activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity); @@ -67,7 +68,7 @@ class NewContentJob extends \Federator\Api $ourUrl = 'https://' . $domain; - \Federator\Api\V1\NewContent::postForUser($this->dbh, $this->connector, $this->cache, $ourUrl, $user, $recipientId, $activity); + \Federator\Api\V1\NewContent::postForUser($this->dbh, $this->connector, $this->cache, $ourUrl, $user, $recipientId, $activity, $articleId); return true; } } \ No newline at end of file diff --git a/plugins/federator/contentnation.php b/plugins/federator/contentnation.php index 1db8e6f..a77f846 100644 --- a/plugins/federator/contentnation.php +++ b/plugins/federator/contentnation.php @@ -368,9 +368,11 @@ class ContentNation implements Connector * Convert jsonData to Activity format * * @param array $jsonData the json data from our platfrom + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) * @return \Federator\Data\ActivityPub\Common\Activity|false */ - public function jsonToActivity($jsonData) + public function jsonToActivity($jsonData, &$articleId) { $returnActivity = false; // Common fields for all activity types @@ -482,6 +484,7 @@ class ContentNation implements Connector $returnActivity->setID($ap['id']); $returnActivity->setURL($ap['url']); } + $articleId = $jsonData['object']['id']; // Set the article ID for the activity break; case 'comment': @@ -514,6 +517,7 @@ class ContentNation implements Connector $returnActivity->setID($ap['id']); $returnActivity->setURL($ap['url']); } + $articleId = $jsonData['object']['articleId']; // Set the article ID for the activity break; case 'vote': @@ -548,6 +552,7 @@ class ContentNation implements Connector $returnActivity->setID($ap['id']); $returnActivity->setURL($ap['url']); } + $articleId = $jsonData['object']['articleId']; // Set the article ID for the activity break; default: @@ -659,6 +664,250 @@ class ContentNation implements Connector return $returnJson; } + /** + * send CN-friendly json from ActivityPub activity + * + * @param \Federator\Data\FedUser $sender the user of the sender + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity + * @return boolean did we successfully send the activity? + */ + public function sendActivity($sender, $activity) + { + $targetUrl = $this->service; + // Convert ActivityPub activity to ContentNation JSON format and retrieve target url + $jsonData = self::activityToJson($this->main->getDatabase(), $this->service, $activity, $targetUrl); + + if ($jsonData === false) { + error_log("ContentNation::sendActivity failed to convert activity to JSON"); + return false; + } + + $json = json_encode($jsonData, JSON_UNESCAPED_SLASHES); + + if ($json === false) { + throw new \Exception('Failed to encode JSON: ' . json_last_error_msg()); + } + $digest = 'SHA-256=' . base64_encode(hash('sha256', $json, true)); + $date = gmdate('D, d M Y H:i:s') . ' GMT'; + $parsed = parse_url($targetUrl); + if ($parsed === false) { + throw new \Exception('Failed to parse URL: ' . $targetUrl); + } + + if (!isset($parsed['host']) || !isset($parsed['path'])) { + throw new \Exception('Invalid target URL: missing host or path'); + } + $extHost = $parsed['host']; + $path = $parsed['path']; + + // Build the signature string + $signatureString = "(request-target): post {$path}\n" . + "host: {$extHost}\n" . + "date: {$date}\n" . + "digest: {$digest}"; + + $pKeyPath = PROJECT_ROOT . '/' . $this->main->getConfig()['keys']['federatorPrivateKeyPath']; + $privateKeyPem = file_get_contents($pKeyPath); + if ($privateKeyPem === false) { + http_response_code(500); + throw new \Federator\Exceptions\PermissionDenied("Private key couldn't be determined"); + } + + $pkeyId = openssl_pkey_get_private($privateKeyPem); + + if ($pkeyId === false) { + throw new \Exception('Invalid private key'); + } + + openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256); + $signature_b64 = base64_encode($signature); + + $signatureHeader = 'algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; + + $ch = curl_init($targetUrl); + if ($ch === false) { + throw new \Exception('Failed to initialize cURL'); + } + $headers = [ + 'Host: ' . $extHost, + 'Date: ' . $date, + 'Digest: ' . $digest, + 'Content-Type: application/json', + 'Signature: ' . $signatureHeader, + 'Accept: application/json', + 'Username: ' . $sender->id, + ]; + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + $response = curl_exec($ch); + curl_close($ch); + + if ($response === false) { + throw new \Exception("Failed to send activity: " . curl_error($ch)); + } else { + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($httpcode != 200 && $httpcode != 202) { + throw new \Exception("Unexpected HTTP code $httpcode: $response"); + } + } + + return true; + } + + /** + * Convert ActivityPub activity to ContentNation JSON format + * + * @param \mysqli $dbh database handle + * @param string $serviceUrl the service URL + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity + * @param string $targetUrl the target URL for the activity + * @return array|false the json data or false on failure + */ + private static function activityToJson($dbh, $serviceUrl, \Federator\Data\ActivityPub\Common\Activity $activity, string &$targetUrl) + { + $type = strtolower($activity->getType()); + switch ($type) { + case 'create': + case 'update': + $object = $activity->getObject(); + if (is_object($object)) { + $objType = strtolower($object->getType()); + $articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity); + if ($articleId === null) { + error_log("ContentNation::activityToJson Failed to get original article ID for create/update activity"); + } + switch ($objType) { + case 'article': + // We don't support article create/update at this point in time + error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}"); + break; + case 'note': + $targetUrl = $serviceUrl . '/api/article/' . $articleId . '/comment'; + $type = 'comment'; + $inReplyTo = $object->getInReplyTo(); + if ($inReplyTo !== '') { + $target = $inReplyTo; + } else { + $target = $object->getObject(); + } + $comment = null; + if (is_string($target)) { + if (strpos($target, '#') !== false) { + $parts = explode('#', $target); + if (count($parts) > 0) { + $comment = $parts[count($parts) - 1]; + } + } + } else { + error_log("ContentNation::activityToJson Unsupported target type for comment with id: " . $activity->getID() . " Type: " . gettype($target)); + return false; + } + return [ + 'type' => $type, + 'id' => $activity->getID(), + 'parent' => $comment, + 'subject' => $object->getSummary(), + 'comment' => $object->getContent(), + ]; + default: + error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}"); + return false; + } + } + break; + + case 'like': + case 'dislike': + $articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity); + if ($articleId === null) { + error_log("ContentNation::activityToJson Failed to get original article ID for vote activity"); + } + $voteValue = $type === 'like' ? true : false; + $activityType = 'vote'; + $inReplyTo = $activity->getInReplyTo(); + if ($inReplyTo !== '') { + $target = $inReplyTo; + } else { + $target = $activity->getObject(); + } + $comment = null; + if (is_string($target)) { + if (strpos($target, '#') !== false) { + $parts = explode('#', $target); + if (count($parts) > 0) { + $comment = $parts[count($parts) - 1]; + } + } + } else { + error_log("ContentNation::activityToJson Unsupported target type for vote with id: " . $activity->getID() . " Type: " . gettype($target)); + return false; + } + $targetUrl = $serviceUrl . '/api/article/' . $articleId . '/vote'; + return [ + 'vote' => $voteValue, + 'type' => $activityType, + 'id' => $activity->getID(), + 'comment' => $comment, + ]; + + case 'undo': + $object = $activity->getObject(); + if (is_object($object)) { + $objType = strtolower($object->getType()); + switch ($objType) { + case 'like': + case 'dislike': + $articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity); + if ($articleId === null) { + error_log("ContentNation::activityToJson Failed to get original article ID for undo vote activity"); + } + $activityType = 'vote'; + $inReplyTo = $object->getInReplyTo(); + if ($inReplyTo !== '') { + $target = $inReplyTo; + } else { + $target = $object->getObject(); + } + $comment = null; + if (is_string($target)) { + if (strpos($target, '#') !== false) { + $parts = explode('#', $target); + if (count($parts) > 0) { + $comment = $parts[count($parts) - 1]; + } + } + } else { + error_log("ContentNation::activityToJson Unsupported target type for undo vote with id: " . $activity->getID() . " Type: " . gettype($target)); + return false; + } + $targetUrl = $serviceUrl . '/api/article/' . $articleId . '/vote'; + return [ + 'vote' => null, + 'type' => $activityType, + 'id' => $object->getID(), + 'comment' => $comment, + ]; + case 'note': + // We don't support comment deletions at this point in time + error_log("ContentNation::activityToJson Unsupported undo object type: {$objType}"); + break; + default: + error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}"); + return false; + } + } + break; + default: + error_log("ContentNation::activityToJson Unsupported activity type: {$type}"); + return false; + } + + return false; + } + /** * check if the headers include a valid signature * diff --git a/plugins/federator/dummyconnector.php b/plugins/federator/dummyconnector.php index 4b9b62d..d4ecb8f 100644 --- a/plugins/federator/dummyconnector.php +++ b/plugins/federator/dummyconnector.php @@ -73,9 +73,11 @@ class DummyConnector implements Connector * Convert jsonData to Activity format * * @param array $jsonData the json data from our platfrom @unused-param + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) @unused-param * @return \Federator\Data\ActivityPub\Common\Activity|false */ - public function jsonToActivity(array $jsonData) { + public function jsonToActivity(array $jsonData, &$articleId) { return false; } @@ -108,6 +110,18 @@ class DummyConnector implements Connector return $user; } + /** + * send target-friendly json from ActivityPub activity + * + * @param \Federator\Data\FedUser $sender the user of the sender @unused-param + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity @unused-param + * @return boolean did we successfully send the activity? + */ + public function sendActivity($sender, $activity) + { + return false; + } + /** * check if the headers include a valid signature * diff --git a/plugins/federator/rediscache.php b/plugins/federator/rediscache.php index 6b1019c..bc3cf16 100644 --- a/plugins/federator/rediscache.php +++ b/plugins/federator/rediscache.php @@ -125,9 +125,11 @@ class RedisCache implements Cache * Convert jsonData to Activity format * * @param array $jsonData the json data from our platfrom @unused-param + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) @unused-param * @return \Federator\Data\ActivityPub\Common\Activity|false */ - public function jsonToActivity(array $jsonData) + public function jsonToActivity(array $jsonData, &$articleId) { error_log("rediscache::jsonToActivity not implemented"); return false; @@ -353,6 +355,18 @@ class RedisCache implements Cache $this->redis->setEx($key, $this->publicKeyPemTTL, $publicKeyPem); } + /** + * send target-friendly json from ActivityPub activity + * + * @param \Federator\Data\FedUser $sender the user of the sender @unused-param + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity @unused-param + * @return boolean did we successfully send the activity? + */ + public function sendActivity($sender, $activity) + { + return false; + } + /** * check if the headers include a valid signature * diff --git a/sql/2025-05-27.sql b/sql/2025-05-27.sql new file mode 100644 index 0000000..0cc7654 --- /dev/null +++ b/sql/2025-05-27.sql @@ -0,0 +1,2 @@ +alter table posts add `article_id` varchar(255) null default null comment 'The optional original article id (of non-federated system, e.g. CN)'; +update settings set `value`="2025-05-27" where `key`="database_version";