refactored ContentNation data-format

- also fixed issue where we didn't retrieve posts from the DB
- also fixed issue where posts from db were malformatted
- changed contentnation data-converter for comments & votes to support the new data-structure (more consistent, easier to read)
This commit is contained in:
Yannis Vogel 2025-05-21 20:06:43 +02:00
parent d355b5a7cd
commit ba88adcebd
No known key found for this signature in database
4 changed files with 109 additions and 93 deletions

View file

@ -89,6 +89,11 @@ class NewContent implements \Federator\Api\APIInterface
$input = is_string($_rawInput) ? json_decode($_rawInput, true) : null; $input = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
$dbh = $this->main->getDatabase();
$cache = $this->main->getCache();
$connector = $this->main->getConnector();
$config = $this->main->getConfig(); $config = $this->main->getConfig();
$domain = $config['generic']['externaldomain']; $domain = $config['generic']['externaldomain'];
if (!is_array($input)) { if (!is_array($input)) {
@ -97,21 +102,16 @@ class NewContent implements \Federator\Api\APIInterface
} }
if (isset($allHeaders['X-Sender'])) { if (isset($allHeaders['X-Sender'])) {
$newActivity = $this->main->getConnector()->jsonToActivity($input); $newActivity = $connector->jsonToActivity($input);
} else { } else {
error_log("NewContent::post No X-Sender header found"); error_log("NewContent::post No X-Sender header found");
return false; return false;
} }
if ($newActivity === false) { if ($newActivity === false) {
error_log("NewContent::post couldn't create newActivity"); error_log("NewContent::post couldn't create newActivity");
return false; return false;
} }
$dbh = $this->main->getDatabase();
$cache = $this->main->getCache();
$connector = $this->main->getConnector();
if (!isset($_user)) { if (!isset($_user)) {
$user = $newActivity->getAActor(); // url of the sender https://contentnation.net/username $user = $newActivity->getAActor(); // url of the sender https://contentnation.net/username
$user = str_replace( $user = str_replace(

View file

@ -117,7 +117,7 @@ class Factory
$return = new Common\Undo(); $return = new Common\Undo();
break; break;
default: default:
error_log("newActivityFromJson " . print_r($json, true)); error_log("newActivityFromJson unsupported type: " . print_r($json, true));
} }
if (isset($return) && $return->fromJson($json) !== null) { if (isset($return) && $return->fromJson($json) !== null) {
return $return; return $return;

View file

@ -43,6 +43,7 @@ class Posts
if ($posts === false) { if ($posts === false) {
$posts = []; $posts = [];
} }
echo "Found " . count($posts) . " posts in DB\n";
// Only override $min if we found posts in our DB // Only override $min if we found posts in our DB
$remoteMin = $min; $remoteMin = $min;
@ -102,15 +103,15 @@ class Posts
*/ */
public static function getPostsFromDb($dbh, $userId, $min = null, $max = null) public static function getPostsFromDb($dbh, $userId, $min = null, $max = null)
{ {
$sql = 'SELECT id, user_id, type, object, published FROM posts WHERE user_id = ?'; $sql = 'SELECT `id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published` FROM posts WHERE user_id = ?';
$params = [$userId]; $params = [$userId];
$types = 's'; $types = 's';
if ($min !== null) { if ($min !== null && $min !== "") {
$sql .= ' AND published >= ?'; $sql .= ' AND published >= ?';
$params[] = $min; $params[] = $min;
$types .= 's'; $types .= 's';
} }
if ($max !== null) { if ($max !== null && $max !== "") {
$sql .= ' AND published <= ?'; $sql .= ' AND published <= ?';
$params[] = $max; $params[] = $max;
$types .= 's'; $types .= 's';
@ -130,15 +131,32 @@ class Posts
} }
$posts = []; $posts = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
if (!empty($row['object'])) { if (isset($row['to']) && $row['to'] !== null) {
$objectData = json_decode($row['object'], true); $row['to'] = json_decode($row['to'], true);
if (is_array($objectData)) { }
// Use the ActivityPub factory to create the APObject if (isset($row['cc']) && $row['cc'] !== null) {
$object = \Federator\Data\ActivityPub\Factory::newActivityFromJson($objectData); $row['cc'] = json_decode($row['cc'], true);
if ($object !== false) { }
$posts[] = $object; if (isset($row['object']) && $row['object'] !== null) {
$decoded = json_decode($row['object'], true);
// Only use decoded value if it's an array/object
if (is_array($decoded)) {
$row['object'] = $decoded;
} }
} }
if (isset($row['published']) && $row['published'] !== null) {
// If it's numeric, keep as int. If it's a string, try to parse as ISO 8601.
if (is_numeric($row['published'])) {
$row['published'] = intval($row['published'], 10);
} else {
// Try to parse as datetime string
$timestamp = strtotime($row['published']);
$row['published'] = $timestamp !== false ? $timestamp : null;
}
}
$activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($row);
if ($activity !== false) {
$posts[] = $activity;
} }
} }
$stmt->close(); $stmt->close();

View file

@ -151,6 +151,7 @@ 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'); ->addCC('https://' . $domain . '/' . $userId . '/followers');
$create->setURL('https://' . $domain . '/' . $activity['profilename'] . '/' . $activity['name']); $create->setURL('https://' . $domain . '/' . $activity['profilename'] . '/' . $activity['name']);
$create->setID('https://' . $domain . '/' . $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) {
@ -207,16 +208,23 @@ class ContentNation implements Connector
$commentJson = $activity; $commentJson = $activity;
$commentJson['type'] = 'Note'; $commentJson['type'] = 'Note';
$commentJson['summary'] = $activity['subject']; $commentJson['summary'] = $activity['subject'];
$commentJson['id'] = $activity['id']; $commentJson['id'] = 'https://' . $domain . '/' . $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");
$comment = new \Federator\Data\ActivityPub\Common\Activity('Comment'); $note = new \Federator\Data\ActivityPub\Common\Activity('Comment');
$create->setObject($comment); $create->setObject($note);
break; break;
} }
$url = 'https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $note->getID(); $note->setID($commentJson['id']);
if (!isset($commentJson['parent']) || $commentJson['parent'] === null) {
$note->setInReplyTo('https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName']);
} elseif ($replyType === "comment") {
$note->setInReplyTo('https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . "#" . $commentJson['parent']);
}
$url = 'https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id'];
$create->setURL($url); $create->setURL($url);
$create->setID($url);
$create->setObject($note); $create->setObject($note);
$posts[] = $create; $posts[] = $create;
break; // Comment break; // Comment
@ -227,9 +235,9 @@ class ContentNation implements Connector
$like = new \Federator\Data\ActivityPub\Common\Activity($likeType); $like = new \Federator\Data\ActivityPub\Common\Activity($likeType);
$like->setAActor('https://' . $domain . '/' . $userId); $like->setAActor('https://' . $domain . '/' . $userId);
$like->setID($activity['id']) $like->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp']) ->setPublished($activity['published'] ?? $activity['timestamp']);
->addTo("https://www.w3.org/ns/activitystreams#Public") // $like->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $domain . '/' . $userId . '/followers'); // ->addCC('https://' . $domain . '/' . $userId . '/followers');
$like->setSummary( $like->setSummary(
$this->main->translate( $this->main->translate(
$activity['articlelang'], $activity['articlelang'],
@ -239,10 +247,8 @@ class ContentNation implements Connector
) )
); );
$objectUrl = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename']; $objectUrl = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename'];
if ($activity['comment'] !== '') {
$objectUrl .= "#" . $activity['comment'];
}
$like->setURL($objectUrl . '#' . $activity['id']); $like->setURL($objectUrl . '#' . $activity['id']);
$like->setID($objectUrl . '#' . $activity['id']);
$like->setObject($objectUrl); $like->setObject($objectUrl);
$posts[] = $like; $posts[] = $like;
break; // Vote break; // Vote
@ -364,6 +370,7 @@ class ContentNation implements Connector
*/ */
public function jsonToActivity(array $jsonData) public function jsonToActivity(array $jsonData)
{ {
$returnActivity = false;
// Common fields for all activity types // Common fields for all activity types
$ap = [ $ap = [
'@context' => 'https://www.w3.org/ns/activitystreams', '@context' => 'https://www.w3.org/ns/activitystreams',
@ -372,52 +379,65 @@ class ContentNation implements Connector
'actor' => $jsonData['actor'] ?? null, 'actor' => $jsonData['actor'] ?? null,
]; ];
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$ourUrl = 'https://' . $domain;
// Extract actorName as the last segment of the actor URL (after the last '/') // Extract actorName as the last segment of the actor URL (after the last '/')
$actorUrl = $jsonData['actor'] ?? null; $actorData = $jsonData['actor'] ?? null;
$actorName = null; $actorName = $actorData['name'] ?? null;
$replaceUrl = null;
if (is_array($actorUrl)) { $ap['actor'] = $ourUrl . '/' . $actorName;
$actorUrl = $actorUrl['id'];
$replaceUrl = $actorUrl->url ?? null;
}
if ($actorUrl !== null) {
$actorUrlParts = parse_url($actorUrl);
if (isset($actorUrlParts['path'])) {
$pathSegments = array_values(array_filter(explode('/', $actorUrlParts['path'])));
$actorName = end($pathSegments);
// Build replaceUrl as scheme://host if both are set
if (isset($actorUrlParts['scheme'], $actorUrlParts['host'])) {
$replaceUrl = $actorUrlParts['scheme'] . '://' . $actorUrlParts['host'];
} else {
$replaceUrl = $actorUrl;
}
} else {
$actorName = $actorUrl;
$replaceUrl = $actorUrl;
}
}
$ap['actor'] = $actorUrl;
// Handle specific fields based on the type // Handle specific fields based on the type
switch ($jsonData['type']) { switch ($jsonData['type']) {
case 'comment': case 'comment':
$commentId = $jsonData['object']['id'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
// Set Create-level fields // Set Create-level fields
$ap['published'] = $jsonData['object']['published'] ?? null; $ap['published'] = $jsonData['object']['published'] ?? null;
$ap['url'] = $jsonData['object']['url'] ?? null; $ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
$ap['to'] = ['https://www.w3.org/ns/activitystreams#Public']; $ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
$ap['cc'] = [$jsonData['related']['cc']['followers'] ?? null]; $ap['type'] = 'Create';
// Set object as Note with only required fields
$ap['object'] = [ $ap['object'] = [
'id' => $jsonData['object']['id'] ?? null,
'type' => 'Note', 'type' => 'Note',
'content' => $jsonData['object']['content'] ?? '', 'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId,
'summary' => $jsonData['object']['summary'] ?? '', '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);
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
break; break;
case 'vote': case 'vote':
$ap['id'] .= "_$actorName"; $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 ( if (
isset($jsonData['vote']['type']) && isset($jsonData['vote']['type']) &&
strtolower($jsonData['vote']['type']) === 'undo' strtolower($jsonData['vote']['type']) === 'undo'
@ -432,7 +452,10 @@ class ContentNation implements Connector
break; break;
} }
$objectId = $jsonData['object']['id'] ?? null; $objectId = $ourUrl . '/' . $articleOwnerName . '/' . $articleName;
if ($votedOn === "comment") {
$objectId .= '#' . $jsonData['object']['commentId'];
}
$ap['object'] = $objectId; $ap['object'] = $objectId;
@ -462,43 +485,18 @@ class ContentNation implements Connector
'author' => $jsonData['object']['author'] ?? null, 'author' => $jsonData['object']['author'] ?? null,
]; ];
} */ } */
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
break; break;
default: default:
// Handle unsupported types or fallback to default behavior // Handle unsupported types or fallback to default behavior
throw new \InvalidArgumentException("Unsupported activity type: {$jsonData['type']}"); throw new \InvalidArgumentException("ContentNation::jsonToActivity Unsupported activity type: {$jsonData['type']}");
} }
$config = $this->main->getConfig(); return $returnActivity;
$domain = $config['generic']['externaldomain'];
/**
* Recursively replace strings in an array.
*
* @param string $search
* @param string $replace
* @param array<mixed, mixed> $array
* @return array<mixed, mixed>
*/
function array_str_replace_recursive(string $search, string $replace, array $array): array
{
foreach ($array as $key => $value) {
if (is_array($value)) {
$array[$key] = array_str_replace_recursive($search, $replace, $value);
} elseif (is_string($value)) {
$array[$key] = str_replace($search, $replace, $value);
}
}
return $array;
}
if (is_string($replaceUrl)) {
$ap = array_str_replace_recursive(
$replaceUrl,
'https://' . $domain,
$ap
);
}
return \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
} }
/** /**