proper support for undoing votes and articles

- added option to remove the like/dislike from an article/comment with creating the correct Undo activity
- added option to remove article with creating the correct Undo activity
- fixed problem where post might not be accepted/saved when no receiver was set (posterName was previously excluded from receivers)
This commit is contained in:
Yannis Vogel 2025-05-23 13:42:15 +02:00
parent 572bb376c1
commit 7a5870de95
No known key found for this signature in database
3 changed files with 284 additions and 198 deletions

View file

@ -149,11 +149,6 @@ class NewContent implements \Federator\Api\APIInterface
}
if (str_ends_with($receiver, '/followers')) {
$actor = $newActivity->getAActor();
if ($actor === null || !is_string($actor)) {
error_log("NewContent::post no actor found");
continue;
}
if ($posterName === null) {
error_log("NewContent::post no username found");
@ -177,6 +172,10 @@ class NewContent implements \Federator\Api\APIInterface
$receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? ''));
$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);
continue;
}
@ -327,15 +326,15 @@ class NewContent implements \Federator\Api\APIInterface
error_log("NewContent::postForUser: Failed to remove follower for user $user->id");
}
break;
case 'Vote':
// Undo Vote (remove vote)
case 'Like':
case 'Dislike':
if (method_exists($object, 'getObject')) {
$targetId = $object->getObject();
if (is_string($targetId)) {
// \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId);
\Federator\DIO\Posts::deletePost($dbh, $targetId);
} else {
error_log("NewContent::postForUser: Error in Undo Vote 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;
@ -346,6 +345,15 @@ class NewContent implements \Federator\Api\APIInterface
\Federator\DIO\Posts::deletePost($dbh, $noteId);
}
break;
case 'Article':
// Undo Article (remove article)
if (method_exists($object, 'getID')) {
$articleId = $object->getID();
\Federator\DIO\Posts::deletePost($dbh, $articleId);
// also remove latest saved article-update
\Federator\DIO\Posts::deletePost($dbh, $articleId . '#update');
}
break;
}
} else if (is_string($object)) {
\Federator\DIO\Posts::deletePost($dbh, $object);
@ -355,25 +363,14 @@ class NewContent implements \Federator\Api\APIInterface
break;
case 'Like':
// Add 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);
} else {
error_log("NewContent::postForUser: Error in Add Like for user $user->id, targetId is not a string");
return false;
}
break;
case 'Dislike':
// Add Dislike
$targetId = $newActivity->getObject();
if (is_string($targetId)) {
// \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'dislike');
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
} else {
error_log("NewContent::postForUser: Error in Add 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;

View file

@ -58,6 +58,12 @@ class Factory
case 'Vote':
$return = new Common\Vote();
break;
case 'Like':
$return = new Common\Like();
break;
case 'Dislike':
$return = new Common\Dislike();
break;
case 'Inbox':
$return = new Common\Inbox();
break;

View file

@ -368,7 +368,7 @@ class ContentNation implements Connector
* @param array<string, mixed> $jsonData the json data from our platfrom
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData)
public function jsonToActivity($jsonData)
{
$returnActivity = false;
// Common fields for all activity types
@ -390,190 +390,273 @@ class ContentNation implements Connector
$ap['actor'] = $ourUrl . '/' . $actorName;
// Handle specific fields based on the type
switch ($jsonData['type']) {
case 'article':
$articleName = $jsonData['object']['name'] ?? null;
$articleOwnerName = $jsonData['object']['ownerName'] ?? null;
// Set Create-level fields
$updatedOn = $jsonData['object']['modified'] ?? null;
$originalPublished = $jsonData['object']['published'] ?? null;
$update = $updatedOn !== $originalPublished;
$ap['published'] = $updatedOn ?? $originalPublished;
$ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
$ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
$ap['type'] = $update ? 'Update' : 'Create';
$ap['actor'] = $ourUrl . '/' . $actorName;
// Set Article-level fields
$ap['object'] = [
'type' => 'Article',
'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName,
'name' => $jsonData['object']['title'] ?? null,
'published' => $originalPublished,
'summary' => $jsonData['object']['summary'] ?? null,
'content' => $jsonData['object']['content'] ?? null,
'attributedTo' => $ap['actor'],
'url' => $ap['url'],
'cc' => ['https://www.w3.org/ns/activitystreams#Public'],
];
if ($update) {
$ap['id'] .= '#update';
$ap['url'] .= '#update';
$ap['object']['updated'] = $updatedOn;
}
$ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
if (isset($jsonData['object']['tags'])) {
if (is_array($jsonData['object']['tags'])) {
foreach ($jsonData['object']['tags'] as $tag) {
$ap['object']['tags'][] = $tag;
}
} elseif (is_string($jsonData['object']['tags']) && $jsonData['object']['tags'] !== '') {
// If it's a single tag as a string, add it as a one-element array
$ap['object']['tags'][] = $jsonData['object']['tags'];
}
}
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
$ap['to'][] = $ourUrl . '/' . $actorName . '/followers';
$ap['object']['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create article");
$returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create');
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
break;
case 'comment':
$commentId = $jsonData['object']['id'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
// Set Create-level fields
$ap['published'] = $jsonData['object']['published'] ?? null;
$ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
$ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
$ap['type'] = 'Create';
$ap['object'] = [
'type' => 'Note',
'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId,
'content' => $jsonData['object']['content'] ?? null,
'summary' => $jsonData['object']['summary'] ?? null,
];
$ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
if ($actorName !== $articleOwnerName) {
$ap['to'][] = $ourUrl . '/' . $articleOwnerName;
}
$ap['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
$replyType = $jsonData['object']['inReplyTo']['type'] ?? null;
if ($replyType === "article") {
$ap['object']['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
} elseif ($replyType === "comment") {
$ap['object']['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $jsonData['object']['inReplyTo']['id'];
} else {
error_log("ContentNation::jsonToActivity unknown inReplyTo type: {$replyType}");
}
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create comment");
$returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create');
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
break;
case 'vote':
$votedOn = $jsonData['object']['type'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$voteId = $jsonData['object']['id'] ?? null;
$ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId;
$ap['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId;
if (
isset($jsonData['vote']['type']) &&
strtolower($jsonData['vote']['type']) === 'undo'
) {
if (isset($jsonData['type'])) {
switch ($jsonData['type']) {
case 'undo':
$ap['type'] = 'Undo';
} elseif ($jsonData['vote']['value'] == 1) {
$ap['type'] = 'Like';
} elseif ($jsonData['vote']['value'] == 0) {
$ap['type'] = 'Dislike';
} else {
error_log("ContentNation::jsonToActivity unknown vote type: {$jsonData['vote']['type']} and value: {$jsonData['vote']['value']}");
break;
}
$objectId = $ourUrl . '/' . $articleOwnerName . '/' . $articleName;
if ($votedOn === "comment") {
$objectId .= '#' . $jsonData['object']['commentId'];
}
$ap['object'] = $objectId;
if ($ap['type'] === "Undo") {
$ap['object'] = $ap['id'];
}
/* if ($ap['type'] === 'Undo') {
$ap['object'] = [
'id' => $objectId,
'type' => 'Vote',
];
} else if (
isset($jsonData['object']['type']) &&
$jsonData['object']['type'] === 'Article'
) {
$ap['object'] = [
'id' => $objectId,
'type' => $jsonData['object']['type'],
'name' => $jsonData['object']['name'] ?? null,
'author' => $jsonData['object']['author'] ?? null,
];
} else if ($jsonData['object']['type'] === 'Comment') {
$ap['object'] = [
'id' => $objectId,
'type' => 'Note',
'author' => $jsonData['object']['author'] ?? null,
];
} */
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create vote");
if ($ap['type'] === "Like") {
$returnActivity = new \Federator\Data\ActivityPub\Common\Like();
} elseif ($ap['type'] === "Dislike") {
$returnActivity = new \Federator\Data\ActivityPub\Common\Dislike();
$ap['actor'] = $ourUrl . '/' . $actorName;
$objectType = $jsonData['object']['type'] ?? null;
if ($objectType === "article") {
$articleName = $jsonData['object']['name'] ?? null;
$ownerName = $jsonData['object']['ownerName'] ?? null;
$ap['id'] = $ourUrl . '/' . $ownerName . '/' . $articleName . '/undo';
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
} elseif ($objectType === "comment") {
$articleName = $jsonData['object']['articleName'] ?? null;
$ownerName = $jsonData['object']['articleOwnerName'] ?? null;
$commentId = $jsonData['object']['id'] ?? null;
$ap['id'] = $ourUrl . '/' . $ownerName . '/' . $articleName . '#' . $commentId . '/undo';
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
} elseif ($objectType === "vote") {
$id = $jsonData['object']['id'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id . '/undo';
$ap['published'] = $jsonData['object']['published'] ?? null;
$ap['actor'] = $ourUrl . '/' . $actorName;
$ap['object']['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id;
$ap['object']['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id;
$ap['object']['actor'] = $ourUrl . '/' . $actorName;
if ($jsonData['object']['vote']['value'] == 1) {
$ap['object']['type'] = 'Like';
} elseif ($jsonData['object']['vote']['value'] == 0) {
$ap['object']['type'] = 'Dislike';
} else {
error_log("ContentNation::jsonToActivity unknown vote value: {$jsonData['object']['vote']['value']}");
break;
}
$ap['object']['object'] = self::generateObjectJson($ourUrl, $jsonData);
} else {
$returnActivity = new \Federator\Data\ActivityPub\Common\Undo();
error_log("ContentNation::jsonToActivity unknown undo type: {$objectType}");
break;
}
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
break;
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create undo");
$returnActivity = new \Federator\Data\ActivityPub\Common\Undo();
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['id']);
}
break;
default:
// Handle unsupported types or fallback to default behavior
throw new \InvalidArgumentException("ContentNation::jsonToActivity Unsupported type: {$jsonData['type']}");
}
} else {
// Handle specific fields based on the type
switch ($jsonData['object']['type']) {
case 'article':
$articleName = $jsonData['object']['name'] ?? null;
$articleOwnerName = $jsonData['object']['ownerName'] ?? null;
// Set Create-level fields
$updatedOn = $jsonData['object']['modified'] ?? null;
$originalPublished = $jsonData['object']['published'] ?? null;
$update = $updatedOn !== $originalPublished;
$ap['published'] = $updatedOn ?? $originalPublished;
$ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
$ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
$ap['type'] = $update ? 'Update' : 'Create';
$ap['actor'] = $ourUrl . '/' . $actorName;
if ($update) {
$ap['id'] .= '#update';
$ap['url'] .= '#update';
}
$ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
default:
// Handle unsupported types or fallback to default behavior
throw new \InvalidArgumentException("ContentNation::jsonToActivity Unsupported activity type: {$jsonData['type']}");
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
$ap['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create article");
$returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create');
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
break;
case 'comment':
$commentId = $jsonData['object']['id'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
// Set Create-level fields
$ap['published'] = $jsonData['object']['published'] ?? null;
$ap['actor'] = $ourUrl . '/' . $actorName;
$ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
$ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
$ap['type'] = 'Create';
$ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
if ($actorName !== $articleOwnerName) {
$ap['to'][] = $ourUrl . '/' . $articleOwnerName;
}
$ap['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create comment");
$returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create');
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
break;
case 'vote':
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$voteId = $jsonData['object']['id'] ?? null;
$ap['published'] = $jsonData['object']['published'] ?? null;
$ap['actor'] = $ourUrl . '/' . $actorName;
$ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId;
$ap['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId;
if ($jsonData['object']['vote']['value'] == 1) {
$ap['type'] = 'Like';
} elseif ($jsonData['object']['vote']['value'] == 0) {
$ap['type'] = 'Dislike';
} else {
error_log("ContentNation::jsonToActivity unknown vote value: {$jsonData['object']['vote']['value']}");
break;
}
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create vote");
if ($ap['type'] === "Like") {
$returnActivity = new \Federator\Data\ActivityPub\Common\Like();
} elseif ($ap['type'] === "Dislike") {
$returnActivity = new \Federator\Data\ActivityPub\Common\Dislike();
} else {
$returnActivity = new \Federator\Data\ActivityPub\Common\Undo();
}
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
break;
default:
// Handle unsupported types or fallback to default behavior
throw new \InvalidArgumentException("ContentNation::jsonToActivity Unsupported object type: {$jsonData['type']}");
}
}
return $returnActivity;
}
/**
* Convert jsonData to Activity format
*
* @param string $ourUrl the url of our instance
* @param array<string, mixed> $jsonData the json data from our platfrom
* @return array|string|false the json object data or false
*/
private static function generateObjectJson($ourUrl, $jsonData)
{
$objectType = $jsonData['object']['type'] ?? null;
$actorData = $jsonData['actor'] ?? null;
$actorName = $actorData['name'] ?? null;
$actorUrl = $ourUrl . '/' . $actorName;
if ($objectType === "article") {
$articleName = $jsonData['object']['name'] ?? null;
$articleOwnerName = $jsonData['object']['ownerName'] ?? null;
$updatedOn = $jsonData['object']['modified'] ?? null;
$originalPublished = $jsonData['object']['published'] ?? null;
$update = $updatedOn !== $originalPublished;
$returnJson = [
'type' => 'Article',
'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName,
'name' => $jsonData['object']['title'] ?? null,
'published' => $originalPublished,
'summary' => $jsonData['object']['summary'] ?? null,
'content' => $jsonData['object']['content'] ?? null,
'attributedTo' => $actorUrl,
'url' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName,
'cc' => ['https://www.w3.org/ns/activitystreams#Public'],
];
if ($update) {
$returnJson['updated'] = $updatedOn;
}
if (isset($jsonData['object']['tags'])) {
if (is_array($jsonData['object']['tags'])) {
foreach ($jsonData['object']['tags'] as $tag) {
$returnJson['tags'][] = $tag;
}
} elseif (is_string($jsonData['object']['tags']) && $jsonData['object']['tags'] !== '') {
// If it's a single tag as a string, add it as a one-element array
$returnJson['tags'][] = $jsonData['object']['tags'];
}
}
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
$returnJson['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
} elseif ($objectType === "comment") {
$commentId = $jsonData['object']['id'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$returnJson = [
'type' => 'Note',
'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId,
'url' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId,
'attributedTo' => $actorUrl,
'content' => $jsonData['object']['content'] ?? null,
'summary' => $jsonData['object']['summary'] ?? null,
'published' => $jsonData['object']['published'] ?? null,
'cc' => ['https://www.w3.org/ns/activitystreams#Public'],
];
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
$returnJson['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
$replyType = $jsonData['object']['inReplyTo']['type'] ?? null;
if ($replyType === "article") {
$returnJson['object']['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
} elseif ($replyType === "comment") {
$returnJson['object']['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $jsonData['object']['inReplyTo']['id'];
} else {
error_log("ContentNation::generateObjectJson for comment - unknown inReplyTo type: {$replyType}");
}
} elseif ($objectType === "vote") {
$votedOn = $jsonData['object']['type'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$objectId = $ourUrl . '/' . $articleOwnerName . '/' . $articleName;
if ($votedOn === "comment") {
$objectId .= '#' . $jsonData['object']['commentId'];
}
$returnJson = $objectId;
} else {
error_log("ContentNation::generateObjectJson unknown object type: {$objectType}");
return false;
}
return $returnJson;
}
/**
* check if the headers include a valid signature
*