
we now send follows to CN to the api/profile/profileName/fedfollow endpoint, either with a post, or a delete. - commit also contains files where line-endings suddenly changed to crlf (now it's back to lf). - targetRequestType (post/delete) now depends on the activity - we now also set the id to username when fetching a user from CN
1095 lines
50 KiB
PHP
1095 lines
50 KiB
PHP
<?php
|
|
/**
|
|
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* @author Sascha Nitsch (grumpydeveloper)
|
|
**/
|
|
|
|
namespace Federator\Connector;
|
|
|
|
/**
|
|
* Connector to ContentNation.net
|
|
*/
|
|
class ContentNation implements Connector
|
|
{
|
|
/**
|
|
* config parameter
|
|
*
|
|
* @var array<string, mixed> $config
|
|
*/
|
|
private $config;
|
|
|
|
/**
|
|
* main instance
|
|
*
|
|
* @var \Federator\Main $main
|
|
*/
|
|
private $main;
|
|
|
|
/**
|
|
* service-URL
|
|
*
|
|
* @var string $service
|
|
*/
|
|
private $service;
|
|
|
|
/**
|
|
* constructor
|
|
*
|
|
* @param \Federator\Main $main
|
|
*/
|
|
public function __construct($main)
|
|
{
|
|
$config = parse_ini_file(PROJECT_ROOT . '/contentnation.ini', true);
|
|
if ($config !== false) {
|
|
$this->config = $config;
|
|
}
|
|
$this->service = $config['contentnation']['service-uri'];
|
|
$this->main = $main;
|
|
$this->main->setHost($this->service);
|
|
}
|
|
|
|
/**
|
|
* get followers of given user
|
|
*
|
|
* @param string $userId user id
|
|
* @return \Federator\Data\FedUser[]|false
|
|
*/
|
|
public function getRemoteFollowersOfUser($userId)
|
|
{
|
|
// todo implement queue for this
|
|
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
|
|
$userId = $matches[1];
|
|
}
|
|
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/followers';
|
|
|
|
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
|
|
if ($info['http_code'] != 200) {
|
|
error_log("ContentNation::getRemoteFollowersOfUser error retrieving followers for userId: $userId . Error: " . json_encode($info));
|
|
return false;
|
|
}
|
|
$r = json_decode($response, true);
|
|
if ($r === false || $r === null || !is_array($r)) {
|
|
return false;
|
|
}
|
|
$followers = [];
|
|
return $followers;
|
|
}
|
|
|
|
/**
|
|
* get following of given user
|
|
*
|
|
* @param string $userId user id
|
|
|
|
* @return \Federator\Data\FedUser[]|false
|
|
*/
|
|
public function getRemoteFollowingForUser($userId)
|
|
{
|
|
// todo implement queue for this
|
|
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
|
|
$userId = $matches[1];
|
|
}
|
|
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/following';
|
|
|
|
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
|
|
if ($info['http_code'] != 200) {
|
|
error_log("ContentNation::getRemoteFollowingForUser error retrieving following for userId: $userId . Error: " . json_encode($info));
|
|
return false;
|
|
}
|
|
$r = json_decode($response, true);
|
|
if ($r === false || $r === null || !is_array($r)) {
|
|
return false;
|
|
}
|
|
$followers = [];
|
|
return $followers;
|
|
}
|
|
|
|
/**
|
|
* get posts by given user
|
|
*
|
|
* @param string $userId user id
|
|
* @param int $min min date
|
|
* @param int $max max date
|
|
* @param int $limit limit results
|
|
* @unused-param $limit
|
|
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
|
|
*/
|
|
public function getRemotePostsByUser($userId, $min, $max, $limit)
|
|
{
|
|
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
|
|
$userId = $matches[1];
|
|
}
|
|
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/activities';
|
|
if ($min > 0) {
|
|
$remoteURL .= '&minTS=' . intval($min, 10);
|
|
}
|
|
if ($max > 0) {
|
|
$remoteURL .= '&maxTS=' . intval($max, 10);
|
|
}
|
|
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
|
|
if ($info['http_code'] != 200) {
|
|
error_log("ContentNation::getRemotePostsByUser error retrieving activities for userId: $userId . Error: " . json_encode($info));
|
|
return false;
|
|
}
|
|
$r = json_decode($response, true);
|
|
if ($r === false || $r === null || !is_array($r)) {
|
|
return false;
|
|
}
|
|
$posts = [];
|
|
if (array_key_exists('activities', $r)) {
|
|
$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($ourUrl . '/' . $userId);
|
|
$create->setPublished($activity['published'] ?? $activity['timestamp'])
|
|
->addTo($ourUrl . '/' . $userId . '/followers')
|
|
->addCC("https://www.w3.org/ns/activitystreams#Public");
|
|
$create->setURL($ourUrl . '/' . $activity['profilename'] . '/' . $activity['name']);
|
|
$create->setID($ourUrl . '/' . $activity['profilename'] . '/' . $activity['name']);
|
|
$apArticle = new \Federator\Data\ActivityPub\Common\Article();
|
|
if (array_key_exists('tags', $activity)) {
|
|
foreach ($activity['tags'] as $tag) {
|
|
$href = $ourUrl . '/search.htm?tagsearch=' . urlencode($tag);
|
|
$tagObj = new \Federator\Data\ActivityPub\Common\Tag();
|
|
$tagObj->setHref($href)
|
|
->setName('#' . urlencode(str_replace(' ', '', $tag)))
|
|
->setType('Hashtag');
|
|
$apArticle->addTag($tagObj);
|
|
}
|
|
}
|
|
$apArticle->setPublished($activity['published'])
|
|
->setName($activity['title'])
|
|
->setAttributedTo($ourUrl . '/' . $activity['profilename'])
|
|
->setContent(
|
|
$activity['teaser'] ??
|
|
$this->main->translate(
|
|
$activity['language'],
|
|
'article',
|
|
'newarticle'
|
|
)
|
|
)
|
|
->addTo("https://www.w3.org/ns/activitystreams#Public")
|
|
->addCC($ourUrl . '/' . $userId . '/followers.json');
|
|
$articleimage = $activity['imagealt'] ??
|
|
$this->main->translate($activity['language'], 'article', 'image');
|
|
$idurl = $ourUrl . '/' . $userId . '/' . $activity['name'];
|
|
$apArticle->setID($idurl)
|
|
->setURL($idurl);
|
|
$image = $activity['image'] ?? $activity['profileimg'];
|
|
$path = $imgpath . $activity['profile'] . '/' . $image;
|
|
|
|
$type = file_exists($path) ? mime_content_type($path) : false;
|
|
$mediaType = ($type !== false && !str_starts_with($type, 'text/'))
|
|
? $type
|
|
: 'image/jpeg';
|
|
|
|
$img = new \Federator\Data\ActivityPub\Common\Image();
|
|
$img->setMediaType($mediaType)
|
|
->setName($articleimage)
|
|
->setURL($userdata . '/' . $activity['profile'] . $image);
|
|
$apArticle->addImage($img);
|
|
$create->setObject($apArticle);
|
|
$posts[] = $create;
|
|
break; // Article
|
|
|
|
case 'Comment':
|
|
$create = new \Federator\Data\ActivityPub\Common\Create();
|
|
$create->setAActor($ourUrl . '/' . $userId);
|
|
$create->setID($activity['id'])
|
|
->setPublished($activity['published'] ?? $activity['timestamp'])
|
|
->addTo($ourUrl . '/' . $userId . '/followers')
|
|
->addCC("https://www.w3.org/ns/activitystreams#Public");
|
|
$commentJson = $activity;
|
|
$commentJson['type'] = 'Note';
|
|
$commentJson['summary'] = $activity['subject'];
|
|
$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");
|
|
$note = new \Federator\Data\ActivityPub\Common\Activity('Comment');
|
|
$create->setObject($note);
|
|
break;
|
|
}
|
|
$note->setID($commentJson['id']);
|
|
if (!isset($commentJson['parent']) || $commentJson['parent'] === null) {
|
|
$note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName']);
|
|
} else {
|
|
$note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . "#" . $commentJson['parent']);
|
|
}
|
|
$url = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id'];
|
|
$create->setURL($url);
|
|
$create->setID($url);
|
|
$create->setObject($note);
|
|
$posts[] = $create;
|
|
break; // Comment
|
|
|
|
case 'Vote':
|
|
// Build Like/Dislike as top-level activity
|
|
$likeType = $activity['upvote'] === true ? 'Like' : 'Dislike';
|
|
$like = new \Federator\Data\ActivityPub\Common\Activity($likeType);
|
|
$like->setAActor($ourUrl . '/' . $userId);
|
|
$like->setID($activity['id'])
|
|
->setPublished($activity['published'] ?? $activity['timestamp']);
|
|
// $like->addTo("https://www.w3.org/ns/activitystreams#Public")
|
|
// ->addCC('https://' . $domain . '/' . $userId . '/followers');
|
|
$like->setSummary(
|
|
$this->main->translate(
|
|
$activity['articlelang'],
|
|
'vote',
|
|
$likeType === 'Like' ? 'like' : 'dislike',
|
|
[$activity['username']]
|
|
)
|
|
);
|
|
$objectUrl = $ourUrl . '/' . $userId . '/' . $activity['articlename'];
|
|
$like->setURL($objectUrl . '#' . $activity['id']);
|
|
$like->setID($objectUrl . '#' . $activity['id']);
|
|
$like->setObject($objectUrl);
|
|
$posts[] = $like;
|
|
break; // Vote
|
|
}
|
|
}
|
|
}
|
|
return $posts;
|
|
}
|
|
|
|
/**
|
|
* get statistics from remote system
|
|
*
|
|
* @return \Federator\Data\Stats|false
|
|
*/
|
|
public function getRemoteStats()
|
|
{
|
|
$remoteURL = $this->service . '/api/stats';
|
|
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
|
|
if ($info['http_code'] != 200) {
|
|
return false;
|
|
}
|
|
$r = json_decode($response, true);
|
|
if ($r === false || $r === null || !is_array($r)) {
|
|
return false;
|
|
}
|
|
$stats = new \Federator\Data\Stats();
|
|
$stats->userCount = array_key_exists('userCount', $r) ? $r['userCount'] : 0;
|
|
$stats->postCount = array_key_exists('pageCount', $r) ? $r['pageCount'] : 0;
|
|
$stats->commentCount = array_key_exists('commentCount', $r) ? $r['commentCount'] : 0;
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* get remote user by given name
|
|
*
|
|
* @param string $_name user/profile name
|
|
* @return \Federator\Data\User | false
|
|
*/
|
|
public function getRemoteUserByName(string $_name)
|
|
{
|
|
// validate name
|
|
if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_name) != 1) {
|
|
return false;
|
|
}
|
|
// make sure we only get name part, without domain
|
|
if (preg_match("#^([^@]+)@([^/]+)#", $_name, $matches) == 1) {
|
|
$name = $matches[1];
|
|
} else {
|
|
$name = $_name;
|
|
}
|
|
$remoteURL = $this->service . '/api/users/info?user=' . urlencode($name);
|
|
$headers = ['Accept: application/json'];
|
|
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
|
|
if ($info['http_code'] != 200) {
|
|
return false;
|
|
}
|
|
$r = json_decode($response, true);
|
|
if ($r === false || $r === null || !is_array($r)) {
|
|
return false;
|
|
}
|
|
$user = new \Federator\Data\User();
|
|
$user->externalid = $_name;
|
|
$user->id = $_name;
|
|
$user->iconMediaType = $r['iconMediaType'];
|
|
$user->iconURL = $r['iconURL'];
|
|
$user->imageMediaType = $r['imageMediaType'];
|
|
$user->imageURL = $r['imageURL'];
|
|
$user->name = $r['name'];
|
|
$user->summary = $r['summary'];
|
|
$user->type = $r['type'];
|
|
$user->registered = intval($r['registered'], 10);
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* get remote user by given session
|
|
*
|
|
* @param string $_session session id
|
|
* @param string $_user user or profile name
|
|
* @return \Federator\Data\User | false
|
|
*/
|
|
public function getRemoteUserBySession(string $_session, string $_user)
|
|
{
|
|
// validate $_session and $user
|
|
if (preg_match("/^[a-z0-9]{16}$/", $_session) != 1) {
|
|
return false;
|
|
}
|
|
if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_user) != 1) {
|
|
return false;
|
|
}
|
|
$remoteURL = $this->service . '/api/users/permissions?profile=' . urlencode($_user);
|
|
$headers = ['Cookie: session=' . $_session, 'Accept: application/json'];
|
|
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
|
|
|
|
if ($info['http_code'] != 200) {
|
|
return false;
|
|
}
|
|
$r = json_decode($response, true);
|
|
if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) {
|
|
return false;
|
|
}
|
|
$user = $this->getRemoteUserByName($_user);
|
|
if ($user === false) {
|
|
return false;
|
|
}
|
|
// extend with permissions
|
|
$user->permissions = [];
|
|
$user->session = $_session;
|
|
foreach ($r[$_user] as $p) {
|
|
$user->permissions[] = $p;
|
|
}
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* Convert jsonData to Activity format
|
|
*
|
|
* @param array<string, mixed> $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, &$articleId)
|
|
{
|
|
$returnActivity = false;
|
|
// Common fields for all activity types
|
|
$ap = [
|
|
'@context' => 'https://www.w3.org/ns/activitystreams',
|
|
'type' => 'Create', // Default to 'Create'
|
|
'id' => $jsonData['id'] ?? 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 '/')
|
|
$actorData = $jsonData['actor'] ?? null;
|
|
$actorName = $actorData['name'] ?? null;
|
|
|
|
$ap['actor'] = $ourUrl . '/' . $actorName;
|
|
|
|
if (isset($jsonData['type'])) {
|
|
switch ($jsonData['type']) {
|
|
case 'undo':
|
|
$ap['type'] = 'Undo';
|
|
$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 {
|
|
error_log("ContentNation::jsonToActivity unknown undo type: {$objectType}");
|
|
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'];
|
|
|
|
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']);
|
|
}
|
|
$articleId = $jsonData['object']['id']; // Set the article ID for the activity
|
|
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']);
|
|
}
|
|
$articleId = $jsonData['object']['articleId']; // Set the article ID for the activity
|
|
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']);
|
|
}
|
|
$articleId = $jsonData['object']['articleId']; // Set the article ID for the activity
|
|
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['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
|
|
} elseif ($replyType === "comment") {
|
|
$returnJson['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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
$targetRequestType = 'post'; // Default request type
|
|
// Convert ActivityPub activity to ContentNation JSON format and retrieve target url
|
|
$jsonData = self::activityToJson($this->main->getDatabase(), $this->service, $activity, $targetUrl, $targetRequestType);
|
|
|
|
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): $targetRequestType {$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: ' . 'ap:' . $sender->id,
|
|
];
|
|
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
switch ($targetRequestType) {
|
|
case 'post':
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
break;
|
|
case 'delete':
|
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
|
break;
|
|
default:
|
|
throw new \Exception("ContentNation::sendActivity Unsupported target request type: $targetRequestType");
|
|
}
|
|
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
|
|
* @param string $targetRequestType the target request type (e.g., 'post', 'delete', etc.)
|
|
* @return array<string, mixed>|false the json data or false on failure
|
|
*/
|
|
private function activityToJson($dbh, $serviceUrl, \Federator\Data\ActivityPub\Common\Activity $activity, string &$targetUrl, string &$targetRequestType)
|
|
{
|
|
$type = strtolower($activity->getType());
|
|
$targetRequestType = 'post'; // Default request type
|
|
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 'follow':
|
|
$profileUrl = $activity->getObject();
|
|
if (!is_string($profileUrl)) {
|
|
error_log("ContentNation::activityToJson Invalid profile URL: " . json_encode($profileUrl));
|
|
return false;
|
|
}
|
|
$receiverName = basename((string) (parse_url($profileUrl, PHP_URL_PATH) ?? ''));
|
|
$ourDomain = parse_url($profileUrl, PHP_URL_HOST);
|
|
if ($receiverName === "" || $ourDomain === "") {
|
|
error_log("ContentNation::activityToJson no profileName or domain found for object url: " . $profileUrl);
|
|
return false;
|
|
}
|
|
$receiver = $receiverName;
|
|
try {
|
|
$localUser = \Federator\DIO\User::getUserByName(
|
|
$dbh,
|
|
$receiver,
|
|
$this,
|
|
null
|
|
);
|
|
} catch (\Throwable $e) {
|
|
error_log("ContentNation::activityToJson get user by name: " . $receiver . ". Exception: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
if ($localUser === null || $localUser->id === null) {
|
|
error_log("ContentNation::activityToJson couldn't find user: $receiver");
|
|
return false;
|
|
}
|
|
$targetUrl = $serviceUrl . '/api/profile/' . $localUser->id . '/fedfollow';
|
|
$type = 'follow';
|
|
$actor = $activity->getAActor();
|
|
$fedUser = \Federator\DIO\FedUser::getUserByName(
|
|
$dbh,
|
|
$actor,
|
|
null
|
|
);
|
|
$from = $fedUser->id;
|
|
return [
|
|
'type' => $type,
|
|
'id' => $activity->getID(),
|
|
'from' => $from,
|
|
'to' => $localUser->id,
|
|
];
|
|
|
|
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 'follow':
|
|
$profileUrl = $object->getObject();
|
|
if (!is_string($profileUrl)) {
|
|
error_log("ContentNation::activityToJson Invalid profile URL: " . json_encode($profileUrl));
|
|
return false;
|
|
}
|
|
$receiverName = basename((string) (parse_url($profileUrl, PHP_URL_PATH) ?? ''));
|
|
$ourDomain = parse_url($profileUrl, PHP_URL_HOST);
|
|
if ($receiverName === "" || $ourDomain === "") {
|
|
error_log("ContentNation::activityToJson no profileName or domain found for object url: " . $profileUrl);
|
|
return false;
|
|
}
|
|
$receiver = $receiverName;
|
|
try {
|
|
$localUser = \Federator\DIO\User::getUserByName(
|
|
$dbh,
|
|
$receiver,
|
|
$this,
|
|
null
|
|
);
|
|
} catch (\Throwable $e) {
|
|
error_log("ContentNation::activityToJson get user by name: " . $receiver . ". Exception: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
if ($localUser === null || $localUser->id === null) {
|
|
error_log("ContentNation::activityToJson couldn't find user: $receiver");
|
|
return false;
|
|
}
|
|
$targetUrl = $serviceUrl . '/api/profile/' . $localUser->id . '/fedfollow';
|
|
$type = 'follow';
|
|
if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) {
|
|
$actor = $object->getAActor();
|
|
if ($actor !== '') {
|
|
$fedUser = \Federator\DIO\FedUser::getUserByName(
|
|
$dbh,
|
|
$actor,
|
|
null
|
|
);
|
|
$from = $fedUser->id;
|
|
$targetRequestType = 'delete';
|
|
return [
|
|
'type' => $type,
|
|
'id' => $object->getID(),
|
|
'from' => $from,
|
|
'to' => $localUser->id,
|
|
];
|
|
}
|
|
}
|
|
return false;
|
|
|
|
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
|
|
*
|
|
* @param string[] $headers the headers
|
|
* @throws \Federator\Exceptions\PermissionDenied
|
|
* @return string|\Federator\Exceptions\PermissionDenied
|
|
*/
|
|
public function checkSignature($headers)
|
|
{
|
|
$signatureHeader = $headers['Signature'] ?? null;
|
|
|
|
if (!isset($signatureHeader)) {
|
|
throw new \Federator\Exceptions\PermissionDenied("Missing Signature header");
|
|
}
|
|
|
|
if (!isset($headers['X-Sender']) || $headers['X-Sender'] !== $this->config['keys']['headerSenderName']) {
|
|
throw new \Federator\Exceptions\PermissionDenied("Invalid sender name");
|
|
}
|
|
|
|
// Parse Signature header
|
|
preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches);
|
|
$signatureParts = array_combine($matches[1], $matches[2]);
|
|
|
|
$signature = base64_decode($signatureParts['signature']);
|
|
$signedHeaders = explode(' ', $signatureParts['headers']);
|
|
|
|
$pKeyPath = PROJECT_ROOT . '/' . $this->config['keys']['publicKeyPath'];
|
|
$publicKeyPem = file_get_contents($pKeyPath);
|
|
if ($publicKeyPem === false) {
|
|
http_response_code(500);
|
|
throw new \Federator\Exceptions\PermissionDenied("Public key couldn't be determined");
|
|
}
|
|
|
|
// Reconstruct the signed string
|
|
$signedString = '';
|
|
foreach ($signedHeaders as $header) {
|
|
if ($header === '(request-target)') {
|
|
$method = strtolower($_SERVER['REQUEST_METHOD']);
|
|
$path = $_SERVER['REQUEST_URI'];
|
|
$headerValue = "$method $path";
|
|
} else {
|
|
$headerValue = $headers[ucwords($header, '-')] ?? '';
|
|
}
|
|
|
|
$signedString .= strtolower($header) . ": " . $headerValue . "\n";
|
|
}
|
|
|
|
$signedString = rtrim($signedString);
|
|
|
|
// Verify the signature
|
|
$pubkeyRes = openssl_pkey_get_public($publicKeyPem);
|
|
$verified = false;
|
|
if ($pubkeyRes instanceof \OpenSSLAsymmetricKey && is_string($signature)) {
|
|
$verified = openssl_verify($signedString, $signature, $pubkeyRes, OPENSSL_ALGO_SHA256);
|
|
}
|
|
if ($verified != 1) {
|
|
http_response_code(500);
|
|
throw new \Federator\Exceptions\PermissionDenied("Signature verification failed");
|
|
}
|
|
return "Signature verified.";
|
|
}
|
|
}
|
|
|
|
namespace Federator;
|
|
|
|
/**
|
|
* Function to initialize plugin
|
|
*
|
|
* @param \Federator\Main $main main instance
|
|
* @return void
|
|
*/
|
|
function contentnation_load($main)
|
|
{
|
|
$cn = new Connector\ContentNation($main);
|
|
# echo "contentnation::contentnation_load Loaded new connector, adding to main\n"; // TODO change to proper log
|
|
$main->setConnector($cn);
|
|
}
|