diff --git a/php/federator/api/v1/newcontent.php b/php/federator/api/v1/newcontent.php index 43edcf1..9efd176 100644 --- a/php/federator/api/v1/newcontent.php +++ b/php/federator/api/v1/newcontent.php @@ -140,9 +140,6 @@ class NewContent implements \Federator\Api\APIInterface && (filter_var($receiver, FILTER_VALIDATE_URL) !== false); }); - if (!in_array($posterName, $receivers, true)) { - $receivers[] = $posterName; - } foreach ($receivers as $receiver) { if ($receiver === '' || !is_string($receiver)) { continue; @@ -173,7 +170,6 @@ class NewContent implements \Federator\Api\APIInterface $domain = parse_url($receiver, PHP_URL_HOST); if ($receiverName === null || $domain === null) { if ($receiver === $posterName) { - $users[] = $receiver; continue; } error_log("NewContent::post no receiverName or domain found for receiver: " . $receiver); @@ -232,12 +228,13 @@ class NewContent implements \Federator\Api\APIInterface * connector to fetch use with * @param \Federator\Cache\Cache|null $cache * optional caching service + * @param string $host host url of our server (e.g. https://federator.com) * @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 * @return boolean response */ - public static function postForUser($dbh, $connector, $cache, $_user, $_recipientId, $newActivity) + public static function postForUser($dbh, $connector, $cache, $host, $_user, $_recipientId, $newActivity) { if (!isset($_user)) { error_log("NewContent::postForUser no user given"); @@ -284,13 +281,15 @@ class NewContent implements \Federator\Api\APIInterface if ($actor !== '') { $followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? '')); $followerDomain = parse_url($actor, PHP_URL_HOST); + $newIdUrl = \Federator\DIO\Followers::generateNewFollowId($dbh, $host); + $newActivity->setID($newIdUrl); if (is_string($followerDomain)) { $followerId = "{$followerUsername}@{$followerDomain}"; - $success = \Federator\DIO\Followers::addFollow($dbh, $followerId, $user->id, $followerDomain); + // $success = \Federator\DIO\Followers::sendFollowRequest($dbh, $connector, $cache, $user->id, $followerId, $followerDomain); } } if ($success === false) { - error_log("NewContent::postForUser: Failed to add follower for user $user->id"); + error_log("NewContent::postForUser Failed to add follower for user $user->id"); } break; @@ -318,12 +317,19 @@ class NewContent implements \Federator\Api\APIInterface $followerDomain = parse_url($actor, PHP_URL_HOST); if (is_string($followerDomain)) { $followerId = "{$followerUsername}@{$followerDomain}"; - $success = \Federator\DIO\Followers::removeFollow($dbh, $followerId, $user->id); + $removedId = \Federator\DIO\Followers::removeFollow($dbh, $followerId, $user->id); + if ($removedId !== false) { + $object->setID($removedId); + $newActivity->setObject($object); + $success = true; + } else { + error_log("NewContent::postForUser Failed to remove follow for user $user->id"); + } } } } if ($success === false) { - error_log("NewContent::postForUser: Failed to remove follower for user $user->id"); + error_log("NewContent::postForUser Failed to remove follower for user $user->id"); } break; case 'Like': @@ -334,7 +340,7 @@ class NewContent implements \Federator\Api\APIInterface // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId); \Federator\DIO\Posts::deletePost($dbh, $targetId); } else { - error_log("NewContent::postForUser: Error in Undo Like/Dislike for user $user->id, targetId is not a string"); + error_log("NewContent::postForUser Error in Undo Like/Dislike for user $user->id, targetId is not a string"); } } break; @@ -358,7 +364,7 @@ class NewContent implements \Federator\Api\APIInterface } else if (is_string($object)) { \Federator\DIO\Posts::deletePost($dbh, $object); } else { - error_log("NewContent::postForUser: Error in Undo for user $user->id, object is not a string or object"); + error_log("NewContent::postForUser Error in Undo for user $user->id, object is not a string or object"); } break; @@ -370,7 +376,7 @@ class NewContent implements \Federator\Api\APIInterface // \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like'); \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); } else { - error_log("NewContent::postForUser: Error in Add Like/Dislike for user $user->id, targetId is not a string"); + error_log("NewContent::postForUser Error in Add Like/Dislike for user $user->id, targetId is not a string"); return false; } break; @@ -391,13 +397,118 @@ class NewContent implements \Federator\Api\APIInterface break; default: - error_log("NewContent::postForUser: Unhandled activity type $type for user $user->id"); + error_log("NewContent::postForUser Unhandled activity type $type for user $user->id"); break; } + try { + $response = self::sendActivity($dbh, $host, $user, $recipient, $newActivity); + } catch (\Exception $e) { + error_log("NewContent::postForUser Failed to send activity: " . $e->getMessage()); + return false; + } + if (empty($response)) { + error_log("NewContent::postForUser Sent activity to $recipient->id"); + } else { + error_log("NewContent::postForUser Sent activity to $recipient->id with response: " . json_encode($response, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + return true; } + /** + * send activity to federated server + * + * @param \mysqli $dbh database handle + * @param string $host host url of our server (e.g. federator) + * @param \Federator\Data\User $sender source user + * @param \Federator\Data\FedUser $target federated target user + * @param \Federator\Data\ActivityPub\Common\Activity $activity activity to send + * @return string|true the generated follow ID on success, false on failure + */ + public static function sendActivity($dbh, $host, $sender, $target, $activity) + { + if ($dbh === false) { + throw new \Federator\Exceptions\ServerError("NewContent::sendActivity Failed to get database handle"); + } + + $inboxUrl = $target->inboxURL; + + $json = json_encode($activity, 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($inboxUrl); + if ($parsed === false) { + throw new \Exception('Failed to parse URL: ' . $inboxUrl); + } + + if (!isset($parsed['host']) || !isset($parsed['path'])) { + throw new \Exception('Invalid inbox 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}"; + + // Get rsa private key + $privateKey = \Federator\DIO\User::getrsaprivate($dbh, $sender->id); // OR from DB + if ($privateKey === false) { + throw new \Exception('Failed to get private key'); + } + $pkeyId = openssl_pkey_get_private($privateKey); + + if ($pkeyId === false) { + throw new \Exception('Invalid private key'); + } + + openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256); + $signature_b64 = base64_encode($signature); + + // Build keyId (public key ID from your actor object) + $keyId = $host . '/' . $sender->id . '#main-key'; + + $signatureHeader = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; + + $ch = curl_init($inboxUrl); + if ($ch === false) { + throw new \Exception('Failed to initialize cURL'); + } + $headers = [ + 'Host: ' . $extHost, + 'Date: ' . $date, + 'Digest: ' . $digest, + 'Content-Type: application/activity+json', + 'Signature: ' . $signatureHeader, + 'Accept: application/activity+json', + ]; + + 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); + + // Log the response for debugging if needed + 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 $response; + } + /** * get internal represenation as json string * @return string json string or html diff --git a/php/federator/dio/followers.php b/php/federator/dio/followers.php index 0379e24..f7358d5 100644 --- a/php/federator/dio/followers.php +++ b/php/federator/dio/followers.php @@ -388,25 +388,97 @@ class Followers return $idurl; // Return the generated follow ID } + /** + * generate new follow id + * + * @param \mysqli $dbh database handle + * @param string $hostUrl the host URL (e.g. federator URL) + * @return string the new follow id + */ + public static function generateNewFollowId($dbh, $hostUrl) + { + // Generate a new unique follow ID + do { + $newId = bin2hex(openssl_random_pseudo_bytes(16)); + $newIdUrl = $hostUrl . '/' . $newId; + + // Check if the generated ID is unique + $sql = 'select id from follows where id = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError("Followers::generateNewFollowId Failed to prepare id-check statement"); + } + $stmt->bind_param("s", $newIdUrl); + $foundId = 0; + $ret = $stmt->bind_result($foundId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + } while ($foundId > 0); + + return $newIdUrl; + } + /** * remove follow * * @param \mysqli $dbh database handle * @param string $sourceUser source user id * @param string $targetUserId target user id - * @return bool true on success + * @return string|false removed followId on success, false on failure */ public static function removeFollow($dbh, $sourceUser, $targetUserId) { - $sql = 'delete from follows where source_user = ? and target_user = ?'; + // Combine retrieval and removal in one query using MySQL's RETURNING (if supported) + $sql = 'delete from follows where source_user = ? and target_user = ? RETURNING id'; $stmt = $dbh->prepare($sql); - if ($stmt === false) { - throw new \Federator\Exceptions\ServerError("Followers::removeFollow Failed to prepare statement"); + if ($stmt !== false) { + $stmt->bind_param("ss", $sourceUser, $targetUserId); + if ($stmt->execute()) { + $stmt->bind_result($followId); + if ($stmt->fetch() === true) { + $stmt->close(); + if (!empty($followId)) { + return $followId; + } else { + return false; + } + } + } + $stmt->close(); + } else { + // Fallback for MySQL versions that do not support RETURNING + // First, fetch the id of the follow to be removed + $sql = 'select id from follows where source_user = ? and target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError("Followers::removeFollow Failed to prepare select statement"); + } + $stmt->bind_param("ss", $sourceUser, $targetUserId); + $stmt->execute(); + $stmt->bind_result($followId); + $found = $stmt->fetch(); + $stmt->close(); + + if ($found === false || empty($followId)) { + return false; // No such follow found + } + + // Now, delete the row + $sql = 'delete from follows where source_user = ? and target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError("Followers::removeFollow Failed to prepare delete statement"); + } + $stmt->bind_param("ss", $sourceUser, $targetUserId); + $stmt->execute(); + $affectedRows = $stmt->affected_rows; + $stmt->close(); + + return $affectedRows > 0 ? $followId : false; } - $stmt->bind_param("ss", $sourceUser, $targetUserId); - $stmt->execute(); - $affectedRows = $stmt->affected_rows; - $stmt->close(); - return $affectedRows > 0; + return false; } } diff --git a/php/federator/jobs/inboxJob.php b/php/federator/jobs/inboxJob.php index 134a820..fea27bd 100644 --- a/php/federator/jobs/inboxJob.php +++ b/php/federator/jobs/inboxJob.php @@ -52,7 +52,7 @@ class InboxJob extends \Federator\Api */ public function perform(): bool { - error_log("InboxJob: Starting inbox job"); + error_log("InboxJob: Starting job"); $user = $this->args['user']; $recipientId = $this->args['recipientId']; $activity = $this->args['activity']; diff --git a/php/federator/jobs/newContentJob.php b/php/federator/jobs/newContentJob.php index 3c26a35..480fe32 100644 --- a/php/federator/jobs/newContentJob.php +++ b/php/federator/jobs/newContentJob.php @@ -52,7 +52,7 @@ class NewContentJob extends \Federator\Api */ public function perform(): bool { - error_log("NewContentJob: Starting inbox job"); + error_log("NewContentJob: Starting job"); $user = $this->args['user']; $recipientId = $this->args['recipientId']; $activity = $this->args['activity']; @@ -63,8 +63,11 @@ class NewContentJob extends \Federator\Api error_log("NewContentJob: Failed to create activity from JSON"); return false; } + $domain = $this->config['generic']['externaldomain']; - \Federator\Api\V1\NewContent::postForUser($this->dbh, $this->connector, $this->cache, $user, $recipientId, $activity); + $ourUrl = 'https://' . $domain; + + \Federator\Api\V1\NewContent::postForUser($this->dbh, $this->connector, $this->cache, $ourUrl, $user, $recipientId, $activity); return true; } } \ No newline at end of file diff --git a/plugins/federator/contentnation.php b/plugins/federator/contentnation.php index 9886b33..199b834 100644 --- a/plugins/federator/contentnation.php +++ b/plugins/federator/contentnation.php @@ -139,23 +139,25 @@ class ContentNation implements Connector $activities = $r['activities']; $config = $this->main->getConfig(); $domain = $config['generic']['externaldomain']; + $ourUrl = 'https://' . $domain; + $imgpath = $this->config['userdata']['path']; $userdata = $this->config['userdata']['url']; foreach ($activities as $activity) { switch ($activity['type']) { case 'Article': $create = new \Federator\Data\ActivityPub\Common\Create(); - $create->setAActor('https://' . $domain . '/' . $userId); + $create->setAActor($ourUrl . '/' . $userId); $create->setID($activity['id']) ->setPublished($activity['published'] ?? $activity['timestamp']) - ->addTo('https://' . $domain . '/' . $userId . '/followers') + ->addTo($ourUrl . '/' . $userId . '/followers') ->addCC("https://www.w3.org/ns/activitystreams#Public"); - $create->setURL('https://' . $domain . '/' . $activity['profilename'] . '/' . $activity['name']); - $create->setID('https://' . $domain . '/' . $activity['profilename'] . '/' . $activity['id']); + $create->setURL($ourUrl . '/' . $activity['profilename'] . '/' . $activity['name']); + $create->setID($ourUrl . '/' . $activity['profilename'] . '/' . $activity['id']); $apArticle = new \Federator\Data\ActivityPub\Common\Article(); if (array_key_exists('tags', $activity)) { foreach ($activity['tags'] as $tag) { - $href = 'https://' . $domain . '/search.htm?tagsearch=' . urlencode($tag); + $href = $ourUrl . '/search.htm?tagsearch=' . urlencode($tag); $tagObj = new \Federator\Data\ActivityPub\Common\Tag(); $tagObj->setHref($href) ->setName('#' . urlencode(str_replace(' ', '', $tag))) @@ -165,7 +167,7 @@ class ContentNation implements Connector } $apArticle->setPublished($activity['published']) ->setName($activity['title']) - ->setAttributedTo('https://' . $domain . '/' . $activity['profilename']) + ->setAttributedTo($ourUrl . '/' . $activity['profilename']) ->setContent( $activity['teaser'] ?? $this->main->translate( @@ -175,10 +177,10 @@ class ContentNation implements Connector ) ) ->addTo("https://www.w3.org/ns/activitystreams#Public") - ->addCC('https://' . $domain . '/' . $userId . '/followers.json'); + ->addCC($ourUrl . '/' . $userId . '/followers.json'); $articleimage = $activity['imagealt'] ?? $this->main->translate($activity['language'], 'article', 'image'); - $idurl = 'https://' . $domain . '/' . $userId . '/' . $activity['name']; + $idurl = $ourUrl . '/' . $userId . '/' . $activity['name']; $apArticle->setID($idurl) ->setURL($idurl); $image = $activity['image'] ?? $activity['profileimg']; @@ -200,15 +202,15 @@ class ContentNation implements Connector case 'Comment': $create = new \Federator\Data\ActivityPub\Common\Create(); - $create->setAActor('https://' . $domain . '/' . $userId); + $create->setAActor($ourUrl . '/' . $userId); $create->setID($activity['id']) ->setPublished($activity['published'] ?? $activity['timestamp']) - ->addTo('https://' . $domain . '/' . $userId . '/followers') + ->addTo($ourUrl . '/' . $userId . '/followers') ->addCC("https://www.w3.org/ns/activitystreams#Public"); $commentJson = $activity; $commentJson['type'] = 'Note'; $commentJson['summary'] = $activity['subject']; - $commentJson['id'] = 'https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id']; + $commentJson['id'] = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id']; $note = \Federator\Data\ActivityPub\Factory::newFromJson($commentJson, ""); if ($note === null) { error_log("ContentNation::getRemotePostsByUser couldn't create comment"); @@ -218,11 +220,11 @@ class ContentNation implements Connector } $note->setID($commentJson['id']); if (!isset($commentJson['parent']) || $commentJson['parent'] === null) { - $note->setInReplyTo('https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName']); + $note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName']); } else { - $note->setInReplyTo('https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . "#" . $commentJson['parent']); + $note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . "#" . $commentJson['parent']); } - $url = 'https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id']; + $url = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id']; $create->setURL($url); $create->setID($url); $create->setObject($note); @@ -233,7 +235,7 @@ class ContentNation implements Connector // Build Like/Dislike as top-level activity $likeType = $activity['upvote'] === true ? 'Like' : 'Dislike'; $like = new \Federator\Data\ActivityPub\Common\Activity($likeType); - $like->setAActor('https://' . $domain . '/' . $userId); + $like->setAActor($ourUrl . '/' . $userId); $like->setID($activity['id']) ->setPublished($activity['published'] ?? $activity['timestamp']); // $like->addTo("https://www.w3.org/ns/activitystreams#Public") @@ -246,7 +248,7 @@ class ContentNation implements Connector [$activity['username']] ) ); - $objectUrl = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename']; + $objectUrl = $ourUrl . '/' . $userId . '/' . $activity['articlename']; $like->setURL($objectUrl . '#' . $activity['id']); $like->setID($objectUrl . '#' . $activity['id']); $like->setObject($objectUrl);