diff --git a/README.md b/README.md index 3999cf4..37a2927 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ To configure an apache server, add the following rewrite rules: RewriteCond expr "%{HTTP:content-type} -strcmatch '*application/activity+json*'" RewriteRule ^@(.*)$ /federator.php?_call=fedusers/$1 [L,END] RewriteRule ^users/(.*)$ /federator.php?_call=fedusers/$1 [L,END] - RewriteRule ^inbox[/]?$ /federator.php?_call=fedusers/$1 [L,END] + RewriteRule ^inbox[/]?$ /federator.php?_call=fedusers/inbox [L,END] RewriteRule ^api/federator/(.+)$ federator.php?_call=$1 [L,END] RewriteRule ^(\.well-known/.*)$ /federator.php?_call=$1 [L,END] RewriteRule ^(nodeinfo/2\.[01])$ /federator.php?_call=$1 [L,END] diff --git a/php/federator/api.php b/php/federator/api.php index f5b3e99..d6c07b1 100644 --- a/php/federator/api.php +++ b/php/federator/api.php @@ -46,7 +46,7 @@ class Api extends Main */ public function __construct() { - $this->contentType = "application/activity+json"; + $this->contentType = "application/json"; Main::__construct(); } diff --git a/php/federator/api/fedusers.php b/php/federator/api/fedusers.php index 8fa68be..f3d1199 100644 --- a/php/federator/api/fedusers.php +++ b/php/federator/api/fedusers.php @@ -49,12 +49,23 @@ class FedUsers implements APIInterface { $method = $_SERVER["REQUEST_METHOD"]; $handler = null; - $username = ''; + $_username = $paths[1]; + if (preg_match("#^([^@]+)@([^/]+)#", $_username, $matches) != 1) { + $hostUrl = $this->main->getHost(); + if ($hostUrl !== false) { + $host = parse_url($hostUrl, PHP_URL_HOST); + $port = parse_url($hostUrl, PHP_URL_PORT); + if ($port !== null) { + $host .= ":$port"; + } + $_username = "$_username@$host"; + } + } switch (sizeof($paths)) { case 2: if ($method === 'GET') { // /fedusers/username or /@username - return $this->returnUserProfile($paths[1]); + return $this->returnUserProfile($_username); } else { switch ($paths[1]) { case 'inbox': @@ -76,33 +87,9 @@ class FedUsers implements APIInterface break; case 'inbox': $handler = new FedUsers\Inbox($this->main); - $username = $paths[1]; - if (preg_match("#^([^@]+)@([^/]+)#", $username, $matches) != 1) { - $hostUrl = $this->main->getHost(); - if ($hostUrl !== false) { - $host = parse_url($hostUrl, PHP_URL_HOST); - $port = parse_url($hostUrl, PHP_URL_PORT); - if ($port !== null) { - $host .= `:$port`; - } - $username = `$username@$host`; - } - } break; case 'outbox': $handler = new FedUsers\Outbox($this->main); - $username = $paths[1]; - if (preg_match("#^([^@]+)@([^/]+)#", $username, $matches) != 1) { - $hostUrl = $this->main->getHost(); - if ($hostUrl !== false) { - $host = parse_url($hostUrl, PHP_URL_HOST); - $port = parse_url($hostUrl, PHP_URL_PORT); - if ($port !== null) { - $host .= `:$port`; - } - $username = `$username@$host`; - } - } break; } break; @@ -115,10 +102,10 @@ class FedUsers implements APIInterface $ret = false; switch ($method) { case 'GET': - $ret = $handler->get($username); + $ret = $handler->get($_username); break; case 'POST': - $ret = $handler->post($username); + $ret = $handler->post($_username); break; } if ($ret !== false) { diff --git a/php/federator/api/fedusers/inbox.php b/php/federator/api/fedusers/inbox.php index 3f36fc0..0f16f04 100644 --- a/php/federator/api/fedusers/inbox.php +++ b/php/federator/api/fedusers/inbox.php @@ -48,7 +48,6 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface */ public function post($_user) { - $inboxActivity = null; $_rawInput = file_get_contents('php://input'); $allHeaders = getallheaders(); @@ -57,231 +56,18 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface } catch (\Federator\Exceptions\PermissionDenied $e) { error_log("Inbox::post Signature check failed: " . $e->getMessage()); http_response_code(401); - exit("Access denied"); + return false; } $activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null; $host = $_SERVER['SERVER_NAME']; if (!is_array($activity)) { - throw new \RuntimeException('Invalid activity format.'); + error_log("Inbox::post Input wasn't of type array"); + return false; } - switch ($activity['type']) { - case 'Create': - if (!isset($activity['object'])) { - break; - } - - $obj = $activity['object']; - $published = strtotime($activity['published'] ?? $obj['published'] ?? 'now'); - $create = new \Federator\Data\ActivityPub\Common\Create(); - $create->setAActor($activity['actor']) - ->setID($activity['id']) - ->setURL($activity['id']) - ->setPublished($published !== false ? $published : time()); - - if (array_key_exists('cc', $activity)) { - foreach ($activity['cc'] as $cc) { - $create->addCC($cc); - } - } - - if (array_key_exists('to', $activity)) { - foreach ($activity['to'] as $to) { - $create->addTo($to); - } - } - - switch ($obj['type']) { - case 'Note': - $published = strtotime($obj['published'] ?? 'now'); - $apNote = new \Federator\Data\ActivityPub\Common\Note(); - $apNote->setID($obj['id']) - ->setPublished($published !== false ? $published : time()) - ->setContent($obj['content'] ?? '') - ->setSummary($obj['summary']) - ->setURL($obj['url']) - ->setAttributedTo($obj['attributedTo'] ?? $activity['actor']) - ->addTo("https://www.w3.org/ns/activitystreams#Public"); - - if (!empty($obj['sensitive'])) { - $apNote->setSensitive($obj['sensitive']); - } - if (!empty($obj['conversation'])) { - $apNote->setConversation($obj['conversation']); - } - if (!empty($obj['inReplyTo'])) { - $apNote->setInReplyTo($obj['inReplyTo']); - } - - // Handle attachments - if (!empty($obj['attachment']) && is_array($obj['attachment'])) { - foreach ($obj['attachment'] as $media) { - if (!isset($media['type'], $media['url'])) - continue; - $mediaObj = new \Federator\Data\ActivityPub\Common\APObject($media['type']); - $mediaObj->setURL($media['url']); - $apNote->addAttachment($mediaObj); - } - } - - if (array_key_exists('tag', $obj)) { - foreach ($obj['tag'] as $tag) { - $tagName = is_array($tag) && isset($tag['name']) ? $tag['name'] : (string) $tag; - $cleanName = preg_replace('/\s+/', '', ltrim($tagName, '#')); // Remove space and leading # - $tagObj = new \Federator\Data\ActivityPub\Common\Tag(); - $tagObj->setName('#' . $cleanName) - ->setHref("https://$host/tags/" . urlencode($cleanName)) - ->setType('Hashtag'); - $apNote->addTag($tagObj); - } - } - if (array_key_exists('cc', $obj)) { - foreach ($obj['cc'] as $cc) { - $apNote->addCC($cc); - } - } - - $create->setObject($apNote); - break; - default: - error_log("Inbox::post we currently don't support the obj type " . $obj['type'] . "\n"); - break; - } - - $inboxActivity = $create; - - break; - case 'Announce': - if (!isset($activity['object'])) { - break; - } - - $objectURL = is_array($activity['object']) ? $activity['object']['id'] : $activity['object']; - - // Fetch the original object (e.g. Note) - [$response, $info] = \Federator\Main::getFromRemote($objectURL, ['Accept: application/activity+json']); - if ($info['http_code'] != 200) { - print_r($info); - error_log("Inbox::post Failed to fetch original object for Announce: $objectURL\n"); - break; - } - $objData = json_decode($response, true); - if ($objData === false || $objData === null || !is_array($objData)) { - break; - } - - $published = strtotime((string) $activity['published']); - $announce = new \Federator\Data\ActivityPub\Common\Announce(); - $announce->setAActor((string) $activity['actor']) - ->setPublished($published !== false ? $published : time()) - ->setID((string) $activity['id']) - ->setURL((string) $activity['id']) - ->addTo("https://www.w3.org/ns/activitystreams#Public"); - - if (array_key_exists('cc', $activity)) { - foreach ($activity['cc'] as $cc) { - $announce->addCC($cc); - } - } - if (array_key_exists('to', $activity)) { - foreach ($activity['to'] as $to) { - $announce->addTo($to); - } - } - - // Parse the shared object as a Note or something else - switch ($objData['type']) { - case 'Note': - $published = strtotime($objData['published'] ?? 'now'); - $note = new \Federator\Data\ActivityPub\Common\Note(); - $note->setPublished($published !== false ? $published : time()) - ->setID($objData['id']) - ->setSummary($objData['summary']) - ->setContent($objData['content'] ?? '') - ->setURL($objData['url'] ?? $objData['id']) - ->setAttributedTo($objData['attributedTo'] ?? null) - ->addTo("https://www.w3.org/ns/activitystreams#Public"); - - if (array_key_exists('cc', $objData)) { - foreach ($objData['cc'] as $cc) { - $note->addCC($cc); - } - } - $announce->setObject($note); - break; - default: - // fallback object - $fallback = new \Federator\Data\ActivityPub\Common\APObject($objData['type']); - $fallback->setID($objData['id'] ?? $objectURL); - $announce->setObject($fallback); - break; - } - - $inboxActivity = $announce; - break; - case 'Undo': - if (!isset($activity['object'])) { - break; - } - - $undo = new \Federator\Data\ActivityPub\Common\Undo(); - $undo->setActor($activity['actor'] ?? null) - ->setID($activity['id'] ?? "test") - ->setURL($activity['url'] ?? $activity['id']); - - if (array_key_exists('cc', $activity)) { - foreach ($activity['cc'] as $cc) { - $undo->addCC($cc); - } - } - if (array_key_exists('to', $activity)) { - foreach ($activity['to'] as $to) { - $undo->addTo($to); - } - } - - // what was undone - $undone = $activity['object']; - if (is_array($undone) && isset($undone['type'])) { - switch ($undone['type']) { - case 'Announce': - $published = strtotime($undone['published'] ?? 'now'); - $announce = new \Federator\Data\ActivityPub\Common\Announce(); - $announce->setAActor($undone['actor'] ?? null) - ->setPublished($published !== false ? $published : time()) - ->setID($undone['id'] ?? null) - ->setURL($undone['url'] ?? $undone['id']); - - if (array_key_exists('cc', $undone)) { - foreach ($undone['cc'] as $cc) { - $announce->addCC($cc); - } - } - $undo->setObject($announce); - break; - case 'Follow': - // Implement if needed - break; - default: - // Fallback for unknown types - $apObject = new \Federator\Data\ActivityPub\Common\APObject($undone['type']); - $apObject->setID($undone['id'] ?? null); - $undo->setObject($apObject); - break; - } - } - - $inboxActivity = $undo; - break; - default: - error_log("Inbox::post we currently don't support the activity type " . $activity['type'] . "\n"); - $apObject = new \Federator\Data\ActivityPub\Common\Activity($activity['type']); - $apObject->setID($activity['id'] ?? null); - $inboxActivity = $apObject; - break; - } + $inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity); // Shared inbox if (!isset($_user)) { @@ -300,13 +86,13 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface ); } - if (!isset($inboxActivity)) { + if ($inboxActivity === false) { error_log("Inbox::post couldn't create inboxActivity, aborting"); return false; } $sendTo = $inboxActivity->getCC(); - if ($inboxActivity->getType() === 'Undo') { + if ($inboxActivity->getType() === 'Undo') { // for undo the object holds the proper cc $object = $inboxActivity->getObject(); if ($object !== null) { $sendTo = $object->getCC(); diff --git a/php/federator/api/fedusers/outbox.php b/php/federator/api/fedusers/outbox.php index f88d0c7..76f296f 100644 --- a/php/federator/api/fedusers/outbox.php +++ b/php/federator/api/fedusers/outbox.php @@ -67,7 +67,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface $items = []; } $host = $_SERVER['SERVER_NAME']; - $id = 'https://' . $host . '/' . $_user . '/outbox'; + $id = 'https://' . $host . '/users/' . $_user . '/outbox'; $outbox->setPartOf($id); $outbox->setID($id); if ($page !== '') { @@ -84,7 +84,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface $outbox->setPrev($id . '&min=' . $oldestId); } $obj = $outbox->toObject(); - return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); // todo remove pretty } /** @@ -95,7 +95,6 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface */ public function post($_user) { - $outboxActivity = null; $_rawInput = file_get_contents('php://input'); $allHeaders = getallheaders(); @@ -104,233 +103,20 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface } catch (\Federator\Exceptions\PermissionDenied $e) { error_log("Outbox::post Signature check failed: " . $e->getMessage()); http_response_code(401); - exit("Access denied"); + return false; } $activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null; $host = $_SERVER['SERVER_NAME']; if (!is_array($activity)) { - throw new \RuntimeException('Invalid activity format.'); + error_log("Outbox::post Input wasn't of type array"); + return false; } + $outboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity); - switch ($activity['type']) { - case 'Create': - if (!isset($activity['object'])) { - break; - } - - $obj = $activity['object']; - $published = strtotime($activity['published'] ?? $obj['published'] ?? 'now'); - $create = new \Federator\Data\ActivityPub\Common\Create(); - $create->setAActor($activity['actor']) - ->setID($activity['id']) - ->setURL($activity['id']) - ->setPublished($published !== false ? $published : time()); - - if (array_key_exists('cc', $activity)) { - foreach ($activity['cc'] as $cc) { - $create->addCC($cc); - } - } - - if (array_key_exists('to', $activity)) { - foreach ($activity['to'] as $to) { - $create->addTo($to); - } - } - - switch ($obj['type']) { - case 'Note': - $published = strtotime($obj['published'] ?? 'now'); - $apNote = new \Federator\Data\ActivityPub\Common\Note(); - $apNote->setID($obj['id']) - ->setPublished($published !== false ? $published : time()) - ->setContent($obj['content'] ?? '') - ->setSummary($obj['summary']) - ->setURL($obj['url']) - ->setAttributedTo($obj['attributedTo'] ?? $activity['actor']) - ->addTo("https://www.w3.org/ns/activitystreams#Public"); - - if (!empty($obj['sensitive'])) { - $apNote->setSensitive($obj['sensitive']); - } - if (!empty($obj['conversation'])) { - $apNote->setConversation($obj['conversation']); - } - if (!empty($obj['inReplyTo'])) { - $apNote->setInReplyTo($obj['inReplyTo']); - } - - // Handle attachments - if (!empty($obj['attachment']) && is_array($obj['attachment'])) { - foreach ($obj['attachment'] as $media) { - if (!isset($media['type'], $media['url'])) - continue; - $mediaObj = new \Federator\Data\ActivityPub\Common\APObject($media['type']); - $mediaObj->setURL($media['url']); - $apNote->addAttachment($mediaObj); - } - } - - if (array_key_exists('tag', $obj)) { - foreach ($obj['tag'] as $tag) { - $tagName = is_array($tag) && isset($tag['name']) ? $tag['name'] : (string) $tag; - $cleanName = preg_replace('/\s+/', '', ltrim($tagName, '#')); // Remove space and leading # - $tagObj = new \Federator\Data\ActivityPub\Common\Tag(); - $tagObj->setName('#' . $cleanName) - ->setHref("https://$host/tags/" . urlencode($cleanName)) - ->setType('Hashtag'); - $apNote->addTag($tagObj); - } - } - if (array_key_exists('cc', $obj)) { - foreach ($obj['cc'] as $cc) { - $apNote->addCC($cc); - } - } - - $create->setObject($apNote); - break; - default: - error_log("Outbox::post we currently don't support the obj type " . $obj['type'] . "\n"); - break; - } - - $outboxActivity = $create; - - break; - case 'Announce': - if (!isset($activity['object'])) { - break; - } - - $objectURL = is_array($activity['object']) ? $activity['object']['id'] : $activity['object']; - - // Fetch the original object (e.g. Note) - [$response, $info] = \Federator\Main::getFromRemote($objectURL, ['Accept: application/activity+json']); - if ($info['http_code'] != 200) { - print_r($info); - error_log("Outbox::post Failed to fetch original object for Announce: $objectURL\n"); - break; - } - $objData = json_decode($response, true); - if ($objData === false || $objData === null || !is_array($objData)) { - break; - } - - $published = strtotime((string) $activity['published']); - $announce = new \Federator\Data\ActivityPub\Common\Announce(); - $announce->setAActor((string) $activity['actor']) - ->setPublished($published !== false ? $published : time()) - ->setID((string) $activity['id']) - ->setURL((string) $activity['id']); - - if (array_key_exists('cc', $activity)) { - foreach ($activity['cc'] as $cc) { - $announce->addCC($cc); - } - } - if (array_key_exists('to', $activity)) { - foreach ($activity['to'] as $to) { - $announce->addTo($to); - } - } - - // Parse the shared object as a Note or something else - switch ($objData['type']) { - case 'Note': - $published = strtotime($objData['published'] ?? 'now'); - $note = new \Federator\Data\ActivityPub\Common\Note(); - $note->setPublished($published !== false ? $published : time()) - ->setID($objData['id']) - ->setSummary($objData['summary']) - ->setContent($objData['content'] ?? '') - ->setURL($objData['url'] ?? $objData['id']) - ->setAttributedTo($objData['attributedTo'] ?? null) - ->addTo("https://www.w3.org/ns/activitystreams#Public"); - - if (array_key_exists('cc', $objData)) { - foreach ($objData['cc'] as $cc) { - $note->addCC($cc); - } - } - $announce->setObject($note); - break; - default: - // fallback object - $fallback = new \Federator\Data\ActivityPub\Common\APObject($objData['type']); - $fallback->setID($objData['id'] ?? $objectURL); - $announce->setObject($fallback); - break; - } - - $outboxActivity = $announce; - break; - case 'Undo': - if (!isset($activity['object'])) { - break; - } - - $undo = new \Federator\Data\ActivityPub\Common\Undo(); - $undo->setActor($activity['actor'] ?? null) - ->setID($activity['id'] ?? "test") - ->setURL($activity['url'] ?? $activity['id']); - - if (array_key_exists('cc', $activity)) { - foreach ($activity['cc'] as $cc) { - $undo->addCC($cc); - } - } - if (array_key_exists('to', $activity)) { - foreach ($activity['to'] as $to) { - $undo->addTo($to); - } - } - - // what was undone - $undone = $activity['object']; - if (is_array($undone) && isset($undone['type'])) { - switch ($undone['type']) { - case 'Announce': - $published = strtotime($undone['published'] ?? 'now'); - $announce = new \Federator\Data\ActivityPub\Common\Announce(); - $announce->setAActor($undone['actor'] ?? null) - ->setPublished($published !== false ? $published : time()) - ->setID($undone['id'] ?? null) - ->setURL($undone['url'] ?? $undone['id']); - - if (array_key_exists('cc', $undone)) { - foreach ($undone['cc'] as $cc) { - $announce->addCC($cc); - } - } - $undo->setObject($announce); - break; - case 'Follow': - // Implement if needed - break; - default: - // Fallback for unknown types - $apObject = new \Federator\Data\ActivityPub\Common\APObject($undone['type']); - $apObject->setID($undone['id'] ?? null); - $undo->setObject($apObject); - break; - } - } - - $outboxActivity = $undo; - break; - default: - error_log("Outbox::post we currently don't support the activity type " . $activity['type'] . "\n"); - $apObject = new \Federator\Data\ActivityPub\Common\Activity($activity['type']); - $apObject->setID($activity['id'] ?? null); - $outboxActivity = $apObject; - break; - } - - if (!isset($outboxActivity)) { - error_log("Outbox::post couldn't create outboxActivity, aborting"); + if ($outboxActivity === false) { + error_log("Outbox::post couldn't create outboxActivity"); return false; } diff --git a/php/federator/cache/cache.php b/php/federator/cache/cache.php index 3e692ef..2d3b638 100644 --- a/php/federator/cache/cache.php +++ b/php/federator/cache/cache.php @@ -13,6 +13,15 @@ namespace Federator\Cache; */ interface Cache extends \Federator\Connector\Connector { + /** + * save remote followers of user + * + * @param string $user user name + * @param \Federator\Data\ActivityPub\Common\APObject[]|false $followers user followers + * @return void + */ + public function saveRemoteFollowersOfUser($user, $followers); + /** * save remote posts by user * diff --git a/php/federator/connector/connector.php b/php/federator/connector/connector.php index aab10e7..77ac69b 100644 --- a/php/federator/connector/connector.php +++ b/php/federator/connector/connector.php @@ -14,15 +14,24 @@ namespace Federator\Connector; interface Connector { /** - * get posts by given user + * get followers of given user * * @param string $id user id - * @param string $minId min ID - * @param string $maxId max ID * @return \Federator\Data\ActivityPub\Common\APObject[]|false */ - public function getRemotePostsByUser($id, $minId, $maxId); + public function getRemoteFollowersOfUser($id); + + /** + * get posts by given user + * + * @param string $id user id + * @param string $min min date + * @param string $max max date + + * @return \Federator\Data\ActivityPub\Common\APObject[]|false + */ + public function getRemotePostsByUser($id, $min, $max); /** * get remote user by given name diff --git a/php/federator/data/activitypub/common/create.php b/php/federator/data/activitypub/common/create.php index 0dfa555..c9e83b3 100644 --- a/php/federator/data/activitypub/common/create.php +++ b/php/federator/data/activitypub/common/create.php @@ -26,7 +26,9 @@ class Create extends Activity $return = parent::toObject(); $return['type'] = 'Create'; // overwrite id from url - $return['id'] = $this->getURL(); + if ($this->getURL() !== '') { + $return['id'] = $this->getURL(); + } return $return; } diff --git a/php/federator/data/activitypub/common/inbox.php b/php/federator/data/activitypub/common/inbox.php new file mode 100644 index 0000000..5c06152 --- /dev/null +++ b/php/federator/data/activitypub/common/inbox.php @@ -0,0 +1,50 @@ + "http://ostatus.org#", + "atomUri" => "ostatus:atomUri", + "inReplyToAtomUri" => "ostatus:inReplyToAtomUri", + "conversation" => "ostatus:conversation", + "sensitive" => "as:sensitive", + "toot" => "http://joinmastodon.org/ns#", + "votersCount" => "toot:votersCount", + "Hashtag" => "as:Hashtag" + ]); + } + + /** + * convert internal state to php array + * + * @return array + */ + public function toObject() + { + $return = parent::toObject(); + return $return; + } + + /** + * create object from json + * + * @param array $json input json + * @return bool true on success + */ + public function fromJson($json) + { + return parent::fromJson($json); + } +} diff --git a/php/federator/data/activitypub/factory.php b/php/federator/data/activitypub/factory.php index ef88ff8..abbd5ef 100644 --- a/php/federator/data/activitypub/factory.php +++ b/php/federator/data/activitypub/factory.php @@ -46,14 +46,20 @@ class Factory case 'Image': $return = new Common\Image(); break; - /*case 'Note': + case 'Note': $return = new Common\Note(); break; - case 'Question': - $return = new \Common\Question(); + case 'Outbox': + $return = new Common\Outbox(); + break; + case 'Inbox': + $return = new Common\Inbox(); + break; + /*case 'Question': + $return = new Common\Question(); break; case 'Video': - $return = new \Common\Video(); + $return = new Common\Video(); break;*/ default: error_log("newFromJson: unknown type: '" . $json['type'] . "' " . $jsonstring); @@ -77,32 +83,30 @@ class Factory } //$return = false; switch ($json['type']) { - case 'MakePhanHappy': - break; -/* case 'Accept': + /* case 'Accept': $return = new Common\Accept(); - break; + break; */ case 'Announce': $return = new Common\Announce(); break; case 'Create': $return = new Common\Create(); break; - case 'Delete': + /*case 'Delete': $return = new Common\Delete(); break; case 'Follow': $return = new Common\Follow(); - break; - case 'Undo': - $return = new \Common\Undo(); break;*/ + case 'Undo': + $return = new Common\Undo(); + break; default: error_log("newActivityFromJson " . print_r($json, true)); } - /*if ($return !== false && $return->fromJson($json) !== null) { + if (isset($return) && $return->fromJson($json) !== null) { return $return; - }*/ + } return false; } } diff --git a/php/federator/dio/followers.php b/php/federator/dio/followers.php new file mode 100644 index 0000000..b1dfbfc --- /dev/null +++ b/php/federator/dio/followers.php @@ -0,0 +1,55 @@ +getRemoteFollowersOfUser($id); + if ($followers !== false) { + return $followers; + } + } + $followers = []; + // TODO: check our db + + if ($followers === []) { + // ask connector for user-id + $followers = $connector->getRemoteFollowersOfUser($id); + if ($followers === false) { + $followers = []; + } + } + // save posts to DB + if ($cache !== null) { + $cache->saveRemoteFollowersOfUser($id, $followers); + } + return $followers; + } +} diff --git a/php/federator/dio/posts.php b/php/federator/dio/posts.php index 7be8c0e..6e6486c 100644 --- a/php/federator/dio/posts.php +++ b/php/federator/dio/posts.php @@ -25,17 +25,17 @@ class Posts * connector to fetch use with * @param \Federator\Cache\Cache|null $cache * optional caching service - * @param string $minId - * minimum ID - * @param string $maxId - * maximum ID + * @param string $min + * minimum date + * @param string $max + * maximum date * @return \Federator\Data\ActivityPub\Common\APObject[] */ - public static function getPostsByUser($dbh, $id, $connector, $cache, $minId, $maxId) + public static function getPostsByUser($dbh, $id, $connector, $cache, $min, $max) { // ask cache if ($cache !== null) { - $posts = $cache->getRemotePostsByUser($id, $minId, $maxId); + $posts = $cache->getRemotePostsByUser($id, $min, $max); if ($posts !== false) { return $posts; } @@ -45,7 +45,7 @@ class Posts if ($posts === []) { // ask connector for user-id - $posts = $connector->getRemotePostsByUser($id, $minId, $maxId); + $posts = $connector->getRemotePostsByUser($id, $min, $max); if ($posts === false) { $posts = []; } diff --git a/plugins/federator/contentnation.php b/plugins/federator/contentnation.php index e03c34f..2705dfa 100644 --- a/plugins/federator/contentnation.php +++ b/plugins/federator/contentnation.php @@ -50,6 +50,32 @@ class ContentNation implements Connector $this->main->setHost($this->service); } + /** + * get followers of given user + * + * @param string $userId user id + * @return \Federator\Data\ActivityPub\Common\APObject[]|false + */ + public function getRemoteFollowersOfUser($userId) + { + if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) { + $userId = $matches[1]; + } + $remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/followers'; // todo implement/change + + [$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 * @@ -231,14 +257,17 @@ class ContentNation implements Connector */ public function getRemoteUserByName(string $_name) { - if (preg_match("#^([^@]+)@([^/]+)#", $_name, $matches) == 1) { - $_name = $matches[1]; - } // validate name - if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_name) != 1) { + if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_name) != 1) { return false; } - $remoteURL = $this->service . '/api/users/info?user=' . urlencode($_name); + // 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) { @@ -274,7 +303,7 @@ class ContentNation implements Connector if (preg_match("/^[a-z0-9]{16}$/", $_session) != 1) { return false; } - if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_user) != 1) { + if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_user) != 1) { return false; } $remoteURL = $this->service . '/api/users/permissions?profile=' . urlencode($_user); diff --git a/plugins/federator/dummyconnector.php b/plugins/federator/dummyconnector.php index 6a011cc..822c27b 100644 --- a/plugins/federator/dummyconnector.php +++ b/plugins/federator/dummyconnector.php @@ -19,15 +19,26 @@ class DummyConnector implements Connector { } + /** + * get followers of given user + * + * @param string $userId user id @unused-param + * @return \Federator\Data\ActivityPub\Common\APObject[]|false + */ + public function getRemoteFollowersOfUser($userId) + { + return false; + } + /** * get posts by given user * * @param string $id user id @unused-param - * @param string $minId min ID @unused-param - * @param string $maxId max ID @unused-param + * @param string $min min date @unused-param + * @param string $max max date @unused-param * @return \Federator\Data\ActivityPub\Common\APObject[]|false */ - public function getRemotePostsByUser($id, $minId, $maxId) + public function getRemotePostsByUser($id, $min, $max) { return false; } diff --git a/plugins/federator/mastodon.php b/plugins/federator/mastodon.php deleted file mode 100644 index 64495de..0000000 --- a/plugins/federator/mastodon.php +++ /dev/null @@ -1,417 +0,0 @@ - $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($_SERVER['DOCUMENT_ROOT'] . '../mastodon.ini', true); - if ($config !== false) { - $this->config = $config; - } - $this->service = $config['mastodon']['service-uri']; - $this->main = $main; - $this->main->setHost($this->service); - } - - /** - * 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\APObject[]|false - */ - public function getRemotePostsByUser($userId, $min = null, $max = null) - { - if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) { - $name = $matches[1]; - } else { - $name = $userId; - } - - $remoteURL = $this->service . '/users/' . $name . '/outbox'; - if ($min !== '') { - $remoteURL .= '&minTS=' . urlencode($min); - } - if ($max !== '') { - $remoteURL .= '&maxTS=' . urlencode($max); - } - - $items = []; - - [$outboxResponse, $outboxInfo] = \Federator\Main::getFromRemote($remoteURL, ['Accept: application/activity+json']); - - if ($outboxInfo['http_code'] != 200) { - echo "MastodonConnector::getRemotePostsByUser HTTP call failed for remoteURL $remoteURL\n"; - return false; - } - - $outbox = json_decode($outboxResponse, true); - - // retrieve ALL outbox items - disabled for now - /* do { - // Fetch the current page of items (first or subsequent pages) - [$outboxResponse, $outboxInfo] = \Federator\Main::getFromRemote($remoteURL, ['Accept: application/activity+json']); - - if ($outboxInfo['http_code'] !== 200) { - echo "MastodonConnector::getRemotePostsByUser HTTP call failed for remoteURL $remoteURL\n"; - return false; - } - - $outbox = json_decode($outboxResponse, true); - - // Extract orderedItems from the current page - if (isset($outbox['orderedItems'])) { - $items = array_merge($items, $outbox['orderedItems']); - } - - // Use 'next' or 'last' URL to determine pagination - if (isset($outbox['next'])) { - $remoteURL = $outbox['next']; // Update target URL for the next page of items - } else if (isset($outbox['last'])) { - $remoteURL = $outbox['last']; // Update target URL for the next page of items - } else { - $remoteURL = ""; - break; // No more pages, exit pagination - } - if ($remoteURL !== "") { - if ($min !== '') { - $remoteURL .= '&minTS=' . urlencode($min); - } - if ($max !== '') { - $remoteURL .= '&maxTS=' . urlencode($max); - } - } - - } while ($remoteURL !== ""); // Continue fetching until no 'last' URL */ - - // Follow `first` page (or get orderedItems directly) - if (isset($outbox['orderedItems'])) { - $items = $outbox['orderedItems']; - } elseif (isset($outbox['first'])) { - $firstURL = is_array($outbox['first']) ? $outbox['first']['id'] : $outbox['first']; - [$pageResponse, $pageInfo] = \Federator\Main::getFromRemote($firstURL, ['Accept: application/activity+json']); - if ($pageInfo['http_code'] != 200) { - return false; - } - $page = json_decode($pageResponse, true); - $items = $page['orderedItems'] ?? []; - } - - // Convert to internal representation - $posts = []; - $host = $_SERVER['SERVER_NAME']; - foreach ($items as $activity) { - switch ($activity['type']) { - case 'Create': - if (!isset($activity['object'])) { - break; - } - - $obj = $activity['object']; - $published = strtotime($activity['published'] ?? $obj['published'] ?? 'now'); - $create = new \Federator\Data\ActivityPub\Common\Create(); - $create->setAActor($activity['actor']) - ->setID($activity['id']) - ->setURL($activity['id']) - ->setPublished($published !== false ? $published : time()) - ->addCC($activity['cc']); - - if (array_key_exists('to', $activity)) { - foreach ($activity['to'] as $to) { - $create->addTo($to); - } - } - - switch ($obj['type']) { - case 'Note': - $published = strtotime($obj['published'] ?? 'now'); - $apNote = new \Federator\Data\ActivityPub\Common\Note(); - $apNote->setID($obj['id']) - ->setPublished($published !== false ? $published : time()) - ->setContent($obj['content'] ?? '') - ->setSummary($obj['summary']) - ->setURL($obj['url']) - ->setAttributedTo($obj['attributedTo'] ?? $activity['actor']) - ->addTo("https://www.w3.org/ns/activitystreams#Public"); - - if (!empty($obj['sensitive'])) { - $apNote->setSensitive($obj['sensitive']); - } - if (!empty($obj['conversation'])) { - $apNote->setConversation($obj['conversation']); - } - if (!empty($obj['inReplyTo'])) { - $apNote->setInReplyTo($obj['inReplyTo']); - } - - // Handle attachments - if (!empty($obj['attachment']) && is_array($obj['attachment'])) { - foreach ($obj['attachment'] as $media) { - if (!isset($media['type'], $media['url'])) - continue; - $mediaObj = new \Federator\Data\ActivityPub\Common\APObject($media['type']); - $mediaObj->setURL($media['url']); - $apNote->addAttachment($mediaObj); - } - } - - if (array_key_exists('tag', $obj)) { - foreach ($obj['tag'] as $tag) { - $tagName = is_array($tag) && isset($tag['name']) ? $tag['name'] : (string) $tag; - $cleanName = preg_replace('/\s+/', '', ltrim($tagName, '#')); // Remove space and leading # - $tagObj = new \Federator\Data\ActivityPub\Common\Tag(); - $tagObj->setName('#' . $cleanName) - ->setHref("https://$host/tags/" . urlencode($cleanName)) - ->setType('Hashtag'); - $apNote->addTag($tagObj); - } - } - - $create->setObject($apNote); - break; - default: - echo "MastodonConnector::getRemotePostsByUser we currently don't support the obj type " . $obj['type'] . "\n"; - break; - } - - $posts[] = $create; - - break; - case 'Announce': - if (!isset($activity['object'])) { - break; - } - - $objectURL = is_array($activity['object']) ? $activity['object']['id'] : $activity['object']; - - if (!is_string($objectURL)) { - // Handle error or cast/normalize - throw new \InvalidArgumentException('objectURL must be a string, got ' . gettype($objectURL)); - } - - // Fetch the original object (e.g. Note) - [$response, $info] = \Federator\Main::getFromRemote($objectURL, ['Accept: application/activity+json']); - if ($info['http_code'] != 200) { - print_r($info); - echo "MastodonConnector::getRemotePostsByUser Failed to fetch original object for Announce: $objectURL\n"; - break; - } - $objData = json_decode($response, true); - if ($objData === false || $objData === null || !is_array($objData)) { - break; - } - - assert(is_string($activity['id'])); - assert(is_string($activity['actor'])); - - $published = strtotime((string) $activity['published']); - $announce = new \Federator\Data\ActivityPub\Common\Announce(); - $announce->setAActor($activity['actor']) - ->setPublished($published !== false ? $published : time()) - ->setID($activity['id']) - ->setURL($activity['id']) - ->addTo("https://www.w3.org/ns/activitystreams#Public"); - - if (array_key_exists('to', $activity)) { - foreach ($activity['to'] as $to) { - $announce->addTo($to); - } - } - - // Optionally parse the shared object as a Note or something else - switch ($objData['type']) { - case 'Note': - $published = strtotime($objData['published'] ?? 'now'); - $note = new \Federator\Data\ActivityPub\Common\Note(); - $note->setID($objData['id']) - ->setContent($objData['content'] ?? '') - ->setPublished($published !== false ? $published : time()) - ->setURL($objData['url'] ?? $objData['id']) - ->setAttributedTo($objData['attributedTo'] ?? null) - ->addTo("https://www.w3.org/ns/activitystreams#Public"); - - $announce->setObject($note); - break; - default: - // fallback object - $fallback = new \Federator\Data\ActivityPub\Common\APObject($objData['type']); - $fallback->setID($objData['id'] ?? $objectURL); - $announce->setObject($fallback); - break; - } - - $posts[] = $announce; - break; - default: - echo "MastodonConnector::getRemotePostsByUser we currently don't support the activity type " . $activity['type'] . "\n"; - break; - } - } - - 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 username (Mastodon usernames can include @ and domain parts) - if (preg_match("/^[a-zA-Z0-9_\-@.]+$/", $_name) != 1) { - return false; - } - - // Mastodon lookup API endpoint - $remoteURL = $this->service . '/api/v1/accounts/lookup?acct=' . urlencode($_name); - // Set headers - $headers = ['Accept: application/json']; - - // Fetch data from Mastodon instance - [$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers); - - // Handle HTTP errors - if ($info['http_code'] != 200) { - return false; - } - - // Decode response - $r = json_decode($response, true); - if ($r === false || $r === null || !is_array($r)) { - return false; - } - - $createdAt = strtotime($r['created_at']); - // Map response to User object - $user = new \Federator\Data\User(); - $user->externalid = (string) $r['id']; // Mastodon uses numeric IDs - $user->iconMediaType = 'image/png'; // Mastodon doesn't explicitly return this, assume PNG - $user->iconURL = $r['avatar'] ?? null; - $user->imageMediaType = 'image/png'; - $user->imageURL = $r['header'] ?? null; - $user->name = $r['display_name'] ?: $r['username']; - $user->summary = $r['note']; - $user->type = 'Person'; // Mastodon profiles are ActivityPub "Person" objects - $user->registered = $createdAt !== false ? $createdAt : time(); - - 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-Z0-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; - } -} - -namespace Federator; - -/** - * Function to initialize plugin - * - * @param \Federator\Main $main main instance - * @return void - */ -function mastodon_load($main) -{ - $mast = new Connector\Mastodon($main); - # echo "mastodon::mastodon_load Loaded new connector, adding to main\n"; // TODO change to proper log - $main->setConnector($mast); -} diff --git a/plugins/federator/rediscache.php b/plugins/federator/rediscache.php index 5c96eeb..67b056c 100644 --- a/plugins/federator/rediscache.php +++ b/plugins/federator/rediscache.php @@ -78,15 +78,28 @@ class RedisCache implements Cache } /** - * get posts by given user + * get followers of given user * * @param string $id user id @unused-param - * @param string $minId min ID @unused-param - * @param string $maxId max ID @unused-param * @return \Federator\Data\ActivityPub\Common\APObject[]|false */ - public function getRemotePostsByUser($id, $minId, $maxId) + public function getRemoteFollowersOfUser($id) + { + error_log("rediscache::getRemoteFollowersOfUser not implemented"); + return false; + } + + /** + * get posts by given user + * + * @param string $id user id @unused-param + * @param string $min min date @unused-param + * @param string $max max date @unused-param + + * @return \Federator\Data\ActivityPub\Common\APObject[]|false + */ + public function getRemotePostsByUser($id, $min, $max) { error_log("rediscache::getRemotePostsByUser not implemented"); return false; @@ -152,6 +165,18 @@ class RedisCache implements Cache return $user; } + /** + * save remote followers by user + * + * @param string $user user name @unused-param + * @param \Federator\Data\ActivityPub\Common\APObject[]|false $followers user followers @unused-param + * @return void + */ + public function saveRemoteFollowersOfUser($user, $followers) + { + error_log("rediscache::saveRemoteFollowersOfUser not implemented"); + } + /** * save remote posts by user *