federator/plugins/federator/contentnation.php
Yannis Vogel d355b5a7cd
integrate queue for NewContent
- also integrated better support for newContent types
- Integrated saving posts to database (posts gotten via outbox request as well as posts received in the NewContent endpoint)
- proper support for handling comments
- support for likes/dislikes
- support for requesting followers / following endpoints
- better inbox support

database needs changes, don't forget to run migration
2025-05-20 16:34:50 +02:00

580 lines
22 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) {
print_r($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) {
print_r($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 string $min min date
* @param string $max max date
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
*/
public function getRemotePostsByUser($userId, $min, $max)
{
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
$userId = $matches[1];
}
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/activities';
if ($min !== '') {
$remoteURL .= '&minTS=' . urlencode($min);
}
if ($max !== '') {
$remoteURL .= '&maxTS=' . urlencode($max);
}
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
if ($info['http_code'] != 200) {
print_r($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'];
$imgpath = $this->config['userdata']['path'];
$userdata = $this->config['userdata']['url'];
foreach ($activities as $activity) {
switch ($activity['type']) {
case 'Article':
$create = new \Federator\Data\ActivityPub\Common\Create();
$create->setAActor('https://' . $domain . '/' . $userId);
$create->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp'])
->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $domain . '/' . $userId . '/followers');
$create->setURL('https://' . $domain . '/' . $activity['profilename'] . '/' . $activity['name']);
$apArticle = new \Federator\Data\ActivityPub\Common\Article();
if (array_key_exists('tags', $activity)) {
foreach ($activity['tags'] as $tag) {
$href = 'https://' . $domain . '/search.htm?tagsearch=' . urlencode($tag);
$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('https://' . $domain . '/' . $activity['profilename'])
->setContent(
$activity['teaser'] ??
$this->main->translate(
$activity['language'],
'article',
'newarticle'
)
)
->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $domain . '/' . $userId . '/followers.json');
$articleimage = $activity['imagealt'] ??
$this->main->translate($activity['language'], 'article', 'image');
$idurl = 'https://' . $domain . '/' . $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('https://' . $domain . '/' . $userId);
$create->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp'])
->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $domain . '/' . $userId . '/followers');
$commentJson = $activity;
$commentJson['type'] = 'Note';
$commentJson['summary'] = $activity['subject'];
$commentJson['id'] = $activity['id'];
$note = \Federator\Data\ActivityPub\Factory::newFromJson($commentJson, "");
if ($note === null) {
error_log("ContentNation::getRemotePostsByUser couldn't create comment");
$comment = new \Federator\Data\ActivityPub\Common\Activity('Comment');
$create->setObject($comment);
break;
}
$url = 'https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $note->getID();
$create->setURL($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('https://' . $domain . '/' . $userId);
$like->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp'])
->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 = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename'];
if ($activity['comment'] !== '') {
$objectUrl .= "#" . $activity['comment'];
}
$like->setURL($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->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
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData)
{
// 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,
];
// Extract actorName as the last segment of the actor URL (after the last '/')
$actorUrl = $jsonData['actor'] ?? null;
$actorName = null;
$replaceUrl = null;
if (is_array($actorUrl)) {
$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
switch ($jsonData['type']) {
case 'comment':
// Set Create-level fields
$ap['published'] = $jsonData['object']['published'] ?? null;
$ap['url'] = $jsonData['object']['url'] ?? null;
$ap['to'] = ['https://www.w3.org/ns/activitystreams#Public'];
$ap['cc'] = [$jsonData['related']['cc']['followers'] ?? null];
// Set object as Note with only required fields
$ap['object'] = [
'id' => $jsonData['object']['id'] ?? null,
'type' => 'Note',
'content' => $jsonData['object']['content'] ?? '',
'summary' => $jsonData['object']['summary'] ?? '',
];
break;
case 'vote':
$ap['id'] .= "_$actorName";
if (
isset($jsonData['vote']['type']) &&
strtolower($jsonData['vote']['type']) === '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 = $jsonData['object']['id'] ?? null;
$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,
];
} */
break;
default:
// Handle unsupported types or fallback to default behavior
throw new \InvalidArgumentException("Unsupported activity type: {$jsonData['type']}");
}
$config = $this->main->getConfig();
$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);
}
/**
* 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);
}