initial support for actually sending NewContent

- integrated functionality to actually send new content to federated recipients and followers (IT WORKS!!)
- changed the way we remove a follow to return the removed followId (used in order to build the undo follow activity)
This commit is contained in:
Yannis Vogel 2025-05-23 19:58:47 +02:00
parent 7a5870de95
commit 5c90b4cfc9
No known key found for this signature in database
5 changed files with 229 additions and 41 deletions

View file

@ -140,9 +140,6 @@ class NewContent implements \Federator\Api\APIInterface
&& (filter_var($receiver, FILTER_VALIDATE_URL) !== false); && (filter_var($receiver, FILTER_VALIDATE_URL) !== false);
}); });
if (!in_array($posterName, $receivers, true)) {
$receivers[] = $posterName;
}
foreach ($receivers as $receiver) { foreach ($receivers as $receiver) {
if ($receiver === '' || !is_string($receiver)) { if ($receiver === '' || !is_string($receiver)) {
continue; continue;
@ -173,7 +170,6 @@ class NewContent implements \Federator\Api\APIInterface
$domain = parse_url($receiver, PHP_URL_HOST); $domain = parse_url($receiver, PHP_URL_HOST);
if ($receiverName === null || $domain === null) { if ($receiverName === null || $domain === null) {
if ($receiver === $posterName) { if ($receiver === $posterName) {
$users[] = $receiver;
continue; continue;
} }
error_log("NewContent::post no receiverName or domain found for receiver: " . $receiver); 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 * connector to fetch use with
* @param \Federator\Cache\Cache|null $cache * @param \Federator\Cache\Cache|null $cache
* optional caching service * 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 $_user user that triggered the post
* @param string $_recipientId recipient of the post * @param string $_recipientId recipient of the post
* @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received * @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received
* @return boolean response * @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)) { if (!isset($_user)) {
error_log("NewContent::postForUser no user given"); error_log("NewContent::postForUser no user given");
@ -284,13 +281,15 @@ class NewContent implements \Federator\Api\APIInterface
if ($actor !== '') { if ($actor !== '') {
$followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? '')); $followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? ''));
$followerDomain = parse_url($actor, PHP_URL_HOST); $followerDomain = parse_url($actor, PHP_URL_HOST);
$newIdUrl = \Federator\DIO\Followers::generateNewFollowId($dbh, $host);
$newActivity->setID($newIdUrl);
if (is_string($followerDomain)) { if (is_string($followerDomain)) {
$followerId = "{$followerUsername}@{$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) { 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; break;
@ -318,12 +317,19 @@ class NewContent implements \Federator\Api\APIInterface
$followerDomain = parse_url($actor, PHP_URL_HOST); $followerDomain = parse_url($actor, PHP_URL_HOST);
if (is_string($followerDomain)) { if (is_string($followerDomain)) {
$followerId = "{$followerUsername}@{$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) { 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; break;
case 'Like': case 'Like':
@ -334,7 +340,7 @@ class NewContent implements \Federator\Api\APIInterface
// \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId); // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId);
\Federator\DIO\Posts::deletePost($dbh, $targetId); \Federator\DIO\Posts::deletePost($dbh, $targetId);
} else { } 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; break;
@ -358,7 +364,7 @@ class NewContent implements \Federator\Api\APIInterface
} else if (is_string($object)) { } else if (is_string($object)) {
\Federator\DIO\Posts::deletePost($dbh, $object); \Federator\DIO\Posts::deletePost($dbh, $object);
} else { } 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; break;
@ -370,7 +376,7 @@ class NewContent implements \Federator\Api\APIInterface
// \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like'); // \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);
} else { } 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; return false;
} }
break; break;
@ -391,13 +397,118 @@ class NewContent implements \Federator\Api\APIInterface
break; break;
default: 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; 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; 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 * get internal represenation as json string
* @return string json string or html * @return string json string or html

View file

@ -388,25 +388,97 @@ class Followers
return $idurl; // Return the generated follow ID 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 * remove follow
* *
* @param \mysqli $dbh database handle * @param \mysqli $dbh database handle
* @param string $sourceUser source user id * @param string $sourceUser source user id
* @param string $targetUserId target 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) 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); $stmt = $dbh->prepare($sql);
if ($stmt === false) { if ($stmt !== false) {
throw new \Federator\Exceptions\ServerError("Followers::removeFollow Failed to prepare statement"); $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); return false;
$stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close();
return $affectedRows > 0;
} }
} }

View file

@ -52,7 +52,7 @@ class InboxJob extends \Federator\Api
*/ */
public function perform(): bool public function perform(): bool
{ {
error_log("InboxJob: Starting inbox job"); error_log("InboxJob: Starting job");
$user = $this->args['user']; $user = $this->args['user'];
$recipientId = $this->args['recipientId']; $recipientId = $this->args['recipientId'];
$activity = $this->args['activity']; $activity = $this->args['activity'];

View file

@ -52,7 +52,7 @@ class NewContentJob extends \Federator\Api
*/ */
public function perform(): bool public function perform(): bool
{ {
error_log("NewContentJob: Starting inbox job"); error_log("NewContentJob: Starting job");
$user = $this->args['user']; $user = $this->args['user'];
$recipientId = $this->args['recipientId']; $recipientId = $this->args['recipientId'];
$activity = $this->args['activity']; $activity = $this->args['activity'];
@ -63,8 +63,11 @@ class NewContentJob extends \Federator\Api
error_log("NewContentJob: Failed to create activity from JSON"); error_log("NewContentJob: Failed to create activity from JSON");
return false; 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; return true;
} }
} }

View file

@ -139,23 +139,25 @@ class ContentNation implements Connector
$activities = $r['activities']; $activities = $r['activities'];
$config = $this->main->getConfig(); $config = $this->main->getConfig();
$domain = $config['generic']['externaldomain']; $domain = $config['generic']['externaldomain'];
$ourUrl = 'https://' . $domain;
$imgpath = $this->config['userdata']['path']; $imgpath = $this->config['userdata']['path'];
$userdata = $this->config['userdata']['url']; $userdata = $this->config['userdata']['url'];
foreach ($activities as $activity) { foreach ($activities as $activity) {
switch ($activity['type']) { switch ($activity['type']) {
case 'Article': case 'Article':
$create = new \Federator\Data\ActivityPub\Common\Create(); $create = new \Federator\Data\ActivityPub\Common\Create();
$create->setAActor('https://' . $domain . '/' . $userId); $create->setAActor($ourUrl . '/' . $userId);
$create->setID($activity['id']) $create->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp']) ->setPublished($activity['published'] ?? $activity['timestamp'])
->addTo('https://' . $domain . '/' . $userId . '/followers') ->addTo($ourUrl . '/' . $userId . '/followers')
->addCC("https://www.w3.org/ns/activitystreams#Public"); ->addCC("https://www.w3.org/ns/activitystreams#Public");
$create->setURL('https://' . $domain . '/' . $activity['profilename'] . '/' . $activity['name']); $create->setURL($ourUrl . '/' . $activity['profilename'] . '/' . $activity['name']);
$create->setID('https://' . $domain . '/' . $activity['profilename'] . '/' . $activity['id']); $create->setID($ourUrl . '/' . $activity['profilename'] . '/' . $activity['id']);
$apArticle = new \Federator\Data\ActivityPub\Common\Article(); $apArticle = new \Federator\Data\ActivityPub\Common\Article();
if (array_key_exists('tags', $activity)) { if (array_key_exists('tags', $activity)) {
foreach ($activity['tags'] as $tag) { 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 = new \Federator\Data\ActivityPub\Common\Tag();
$tagObj->setHref($href) $tagObj->setHref($href)
->setName('#' . urlencode(str_replace(' ', '', $tag))) ->setName('#' . urlencode(str_replace(' ', '', $tag)))
@ -165,7 +167,7 @@ class ContentNation implements Connector
} }
$apArticle->setPublished($activity['published']) $apArticle->setPublished($activity['published'])
->setName($activity['title']) ->setName($activity['title'])
->setAttributedTo('https://' . $domain . '/' . $activity['profilename']) ->setAttributedTo($ourUrl . '/' . $activity['profilename'])
->setContent( ->setContent(
$activity['teaser'] ?? $activity['teaser'] ??
$this->main->translate( $this->main->translate(
@ -175,10 +177,10 @@ class ContentNation implements Connector
) )
) )
->addTo("https://www.w3.org/ns/activitystreams#Public") ->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $domain . '/' . $userId . '/followers.json'); ->addCC($ourUrl . '/' . $userId . '/followers.json');
$articleimage = $activity['imagealt'] ?? $articleimage = $activity['imagealt'] ??
$this->main->translate($activity['language'], 'article', 'image'); $this->main->translate($activity['language'], 'article', 'image');
$idurl = 'https://' . $domain . '/' . $userId . '/' . $activity['name']; $idurl = $ourUrl . '/' . $userId . '/' . $activity['name'];
$apArticle->setID($idurl) $apArticle->setID($idurl)
->setURL($idurl); ->setURL($idurl);
$image = $activity['image'] ?? $activity['profileimg']; $image = $activity['image'] ?? $activity['profileimg'];
@ -200,15 +202,15 @@ class ContentNation implements Connector
case 'Comment': case 'Comment':
$create = new \Federator\Data\ActivityPub\Common\Create(); $create = new \Federator\Data\ActivityPub\Common\Create();
$create->setAActor('https://' . $domain . '/' . $userId); $create->setAActor($ourUrl . '/' . $userId);
$create->setID($activity['id']) $create->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp']) ->setPublished($activity['published'] ?? $activity['timestamp'])
->addTo('https://' . $domain . '/' . $userId . '/followers') ->addTo($ourUrl . '/' . $userId . '/followers')
->addCC("https://www.w3.org/ns/activitystreams#Public"); ->addCC("https://www.w3.org/ns/activitystreams#Public");
$commentJson = $activity; $commentJson = $activity;
$commentJson['type'] = 'Note'; $commentJson['type'] = 'Note';
$commentJson['summary'] = $activity['subject']; $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, ""); $note = \Federator\Data\ActivityPub\Factory::newFromJson($commentJson, "");
if ($note === null) { if ($note === null) {
error_log("ContentNation::getRemotePostsByUser couldn't create comment"); error_log("ContentNation::getRemotePostsByUser couldn't create comment");
@ -218,11 +220,11 @@ class ContentNation implements Connector
} }
$note->setID($commentJson['id']); $note->setID($commentJson['id']);
if (!isset($commentJson['parent']) || $commentJson['parent'] === null) { if (!isset($commentJson['parent']) || $commentJson['parent'] === null) {
$note->setInReplyTo('https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName']); $note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName']);
} else { } 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->setURL($url);
$create->setID($url); $create->setID($url);
$create->setObject($note); $create->setObject($note);
@ -233,7 +235,7 @@ class ContentNation implements Connector
// Build Like/Dislike as top-level activity // Build Like/Dislike as top-level activity
$likeType = $activity['upvote'] === true ? 'Like' : 'Dislike'; $likeType = $activity['upvote'] === true ? 'Like' : 'Dislike';
$like = new \Federator\Data\ActivityPub\Common\Activity($likeType); $like = new \Federator\Data\ActivityPub\Common\Activity($likeType);
$like->setAActor('https://' . $domain . '/' . $userId); $like->setAActor($ourUrl . '/' . $userId);
$like->setID($activity['id']) $like->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp']); ->setPublished($activity['published'] ?? $activity['timestamp']);
// $like->addTo("https://www.w3.org/ns/activitystreams#Public") // $like->addTo("https://www.w3.org/ns/activitystreams#Public")
@ -246,7 +248,7 @@ class ContentNation implements Connector
[$activity['username']] [$activity['username']]
) )
); );
$objectUrl = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename']; $objectUrl = $ourUrl . '/' . $userId . '/' . $activity['articlename'];
$like->setURL($objectUrl . '#' . $activity['id']); $like->setURL($objectUrl . '#' . $activity['id']);
$like->setID($objectUrl . '#' . $activity['id']); $like->setID($objectUrl . '#' . $activity['id']);
$like->setObject($objectUrl); $like->setObject($objectUrl);