diff --git a/.gitignore b/.gitignore index d222587..4d8a7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ php-docs .phpdoc phpdoc html +/cache diff --git a/config.ini b/config.ini index dcf9ba1..7411f48 100644 --- a/config.ini +++ b/config.ini @@ -14,6 +14,8 @@ compiledir = '../cache' [plugins] rediscache = 'rediscache.php' dummy = 'dummyconnector.php' +contentnation = 'contentnation.php' +mastodon = 'mastodon.php' [maintenance] username = 'federatoradmin' diff --git a/contentnation.ini b/contentnation.ini new file mode 100644 index 0000000..17de2d5 --- /dev/null +++ b/contentnation.ini @@ -0,0 +1,6 @@ +[contentnation] +service-uri = https://contentnation.net + +[userdata] +path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here +url = 'https://userdata.contentnation.net' \ No newline at end of file diff --git a/htdocs/index.html b/htdocs/index.html new file mode 100644 index 0000000..a3e7f67 --- /dev/null +++ b/htdocs/index.html @@ -0,0 +1,112 @@ + + + + + + + API Request UI + + + + +
+

API Request UI

+ +
+ +
+ + + + + + + + + + + + +
+ +
+ +

Response:

+

Waiting for response

+
+
+ + +
+ + + + + \ No newline at end of file diff --git a/htdocs/info.php b/htdocs/info.php new file mode 100644 index 0000000..79e7d9d --- /dev/null +++ b/htdocs/info.php @@ -0,0 +1,3 @@ +setPath((string)$_REQUEST['_call']); + $this->setPath((string) $_REQUEST['_call']); $this->openDatabase(); $this->loadPlugins(); + + $host = 'dummy'; // fallback + + // Check if path matches something like fedusers/username@domain.tld + if (preg_match("#^fedusers/([^@]+)@([^/]+)$#", $this->path, $matches) === 1) { + $host = strtolower($matches[2]); // extract domain + } else { + $host = 'dummy'; + echo "using dummy host in API::run\n"; + } + + $connector = $this->getConnectorForHost($host); $retval = ""; $handler = null; - if ($this->connector === null) { + if ($connector === null) { http_response_code(500); return; } @@ -85,7 +97,7 @@ class Api extends Main $this->dbh, $_SERVER['HTTP_X_SESSION'], $_SERVER['HTTP_X_PROFILE'], - $this->connector, + $connector, $this->cache ); if ($this->user === false) { @@ -112,7 +124,7 @@ class Api extends Main $printresponse = true; if ($handler !== null) { try { - $printresponse = $handler->exec($this->paths, $this->user); + $printresponse = $handler->exec($this->paths, $this->user, $connector); if ($printresponse) { $retval = $handler->toJson(); } @@ -168,7 +180,7 @@ class Api extends Main * @param string $message optional message * @throws Exceptions\PermissionDenied */ - public function checkPermission($permission, $exception = "\Exceptions\PermissionDenied", $message = null) : void + public function checkPermission($permission, $exception = "\Exceptions\PermissionDenied", $message = null): void { // generic check first if ($this->user === false) { @@ -198,7 +210,7 @@ class Api extends Main * input to strip * @return string stripped input */ - public static function stripHTML(string $_input) : string + public static function stripHTML(string $_input): string { $out = preg_replace('/<(script[^>]*)>/i', '<${1}>', $_input); $out = preg_replace('/<\/(script)>/i', '</${1};>', $out); @@ -212,7 +224,7 @@ class Api extends Main * parameter to check * @return bool true if in */ - public static function hasPost(string $_key) : bool + public static function hasPost(string $_key): bool { return array_key_exists($_key, $_POST); } @@ -228,13 +240,13 @@ class Api extends Main */ public function escapePost(string $key, $int = false) { - if (! array_key_exists($key, $_POST)) { + if (!array_key_exists($key, $_POST)) { return $int ? 0 : ""; } if ($int === true) { return intval($_POST[$key]); } - $ret = $this->dbh->escape_string($this->stripHTML((string)$_POST[$key])); + $ret = $this->dbh->escape_string($this->stripHTML((string) $_POST[$key])); return $ret; } diff --git a/php/federator/api/apiinterface.php b/php/federator/api/apiinterface.php index b8cf7fe..a12ee74 100644 --- a/php/federator/api/apiinterface.php +++ b/php/federator/api/apiinterface.php @@ -19,9 +19,10 @@ interface APIInterface * @param array $paths path array split by / * * @param \Federator\Data\User|false $user user who is calling us + * @param \Federator\Connector\Connector $connector connector to use * @return bool true on success */ - public function exec($paths, $user); + public function exec($paths, $user, $connector); /** * get internal represenation as json string diff --git a/php/federator/api/fedusers.php b/php/federator/api/fedusers.php index 77958e7..401c5be 100644 --- a/php/federator/api/fedusers.php +++ b/php/federator/api/fedusers.php @@ -43,9 +43,10 @@ class FedUsers implements APIInterface * * @param array $paths path array split by / * @param \Federator\Data\User|false $user user who is calling us @unused-param + * @param \Federator\Connector\Connector $connector connector to use * @return bool true on success */ - public function exec($paths, $user) + public function exec($paths, $user, $connector) { $method = $_SERVER["REQUEST_METHOD"]; $handler = null; @@ -53,7 +54,7 @@ class FedUsers implements APIInterface case 2: if ($method === 'GET') { // /users/username or /@username - return $this->returnUserProfile($paths[1]); + return $this->returnUserProfile($paths[1], $connector); } break; case 3: @@ -82,10 +83,10 @@ class FedUsers implements APIInterface $ret = false; switch ($method) { case 'GET': - $ret = $handler->get($paths[1]); + $ret = $handler->get($paths[1], $connector); break; case 'POST': - $ret = $handler->post($paths[1]); + $ret = $handler->post($paths[1], $connector); break; } if ($ret !== false) { @@ -102,14 +103,15 @@ class FedUsers implements APIInterface * return user profile * * @param string $_name + * @param \Federator\Connector\Connector $connector connector to use * @return boolean true on success */ - private function returnUserProfile($_name) + private function returnUserProfile($_name, $connector) { $user = \Federator\DIO\User::getUserByName( $this->main->getDatabase(), $_name, - $this->main->getConnector(), + $connector, $this->main->getCache() ); if ($user === false || $user->id === null) { @@ -123,7 +125,7 @@ class FedUsers implements APIInterface 'fqdn' => $_SERVER['SERVER_NAME'], 'name' => $user->name, 'username' => $user->id, - 'publickey' => str_replace("\n", "\\n", $user->publicKey), + 'publickey' => $user->publicKey, 'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z 'summary' => $user->summary, 'type' => $user->type diff --git a/php/federator/api/fedusers/fedusersinterface.php b/php/federator/api/fedusers/fedusersinterface.php index 1782323..934f95c 100644 --- a/php/federator/api/fedusers/fedusersinterface.php +++ b/php/federator/api/fedusers/fedusersinterface.php @@ -14,15 +14,17 @@ interface FedUsersInterface * get call for user * * @param string $_user user to fetch data for + * @param \Federator\Connector\Connector $connector connector to use * @return string|false response or false in case of error */ - public function get($_user); + public function get($_user, $connector); /** * post call for user * * @param string $_user user to add data to + * @param \Federator\Connector\Connector $connector connector to use * @return string|false response or false in case of error */ - public function post($_user); + public function post($_user, $connector); } diff --git a/php/federator/api/fedusers/outbox.php b/php/federator/api/fedusers/outbox.php index a18fae9..16ea175 100644 --- a/php/federator/api/fedusers/outbox.php +++ b/php/federator/api/fedusers/outbox.php @@ -33,13 +33,13 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface * handle get call * * @param string $_user user to fetch outbox for + * @param \Federator\Connector\Connector $connector connector to use * @return string|false response */ - public function get($_user) + public function get($_user, $connector) { $dbh = $this->main->getDatabase(); $cache = $this->main->getCache(); - $connector = $this->main->getConnector(); // get user $user = \Federator\DIO\User::getUserByName( $dbh, @@ -50,6 +50,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface if ($user->id === null) { return false; } + // get posts from user $outbox = new \Federator\Data\ActivityPub\Common\Outbox(); $min = $this->main->extractFromURI("min", ""); @@ -79,16 +80,17 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface $outbox->setPrev($id . '&min=' . $oldestId); } $obj = $outbox->toObject(); - return json_encode($obj); + return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); } /** * handle post call * * @param string $_user user to add data to outbox @unused-param + * @param \Federator\Connector\Connector $connector connector to use * @return string|false response */ - public function post($_user) + public function post($_user, $connector) { return false; } diff --git a/php/federator/api/v1/dummy.php b/php/federator/api/v1/dummy.php index b70a5ac..97a9e0a 100644 --- a/php/federator/api/v1/dummy.php +++ b/php/federator/api/v1/dummy.php @@ -23,7 +23,7 @@ class Dummy implements \Federator\Api\APIInterface /** * internal message to output * - * @var Array $message + * @var array $message */ private $message = []; @@ -42,9 +42,10 @@ class Dummy implements \Federator\Api\APIInterface * * @param array $paths path array split by / * @param \Federator\Data\User|false $user user who is calling us + * @param \Federator\Connector\Connector $connector connector to use * @return bool true on success */ - public function exec($paths, $user) : bool + public function exec($paths, $user, $connector) : bool { // only for user with the 'publish' permission if ($user === false || $user->hasPermission('publish') === false) { diff --git a/php/federator/api/wellknown.php b/php/federator/api/wellknown.php index 6b1e76d..752f33b 100644 --- a/php/federator/api/wellknown.php +++ b/php/federator/api/wellknown.php @@ -57,7 +57,7 @@ class WellKnown implements APIInterface * @param \Federator\Data\User|false $user user who is calling us @unused-param * @return bool true on success */ - public function exec($paths, $user) + public function exec($paths, $user, $connector) { $method = $_SERVER["REQUEST_METHOD"]; switch ($method) { @@ -66,14 +66,14 @@ class WellKnown implements APIInterface case 2: if ($paths[0] === 'nodeinfo') { $ni = new WellKnown\NodeInfo($this, $this->main); - return $ni->exec($paths); + return $ni->exec($paths, $connector); } switch ($paths[1]) { case 'host-meta': return $this->hostMeta(); case 'nodeinfo': $ni = new WellKnown\NodeInfo($this, $this->main); - return $ni->exec($paths); + return $ni->exec($paths, $connector); case 'webfinger': $wf = new WellKnown\WebFinger($this, $this->main); return $wf->exec(); diff --git a/php/federator/api/wellknown/nodeinfo.php b/php/federator/api/wellknown/nodeinfo.php index 0d5b251..5bcef42 100644 --- a/php/federator/api/wellknown/nodeinfo.php +++ b/php/federator/api/wellknown/nodeinfo.php @@ -41,9 +41,10 @@ class NodeInfo * handle nodeinfo request * * @param string[] $paths path of us + * @param \Federator\Connector\Connector $connector connector to use * @return bool true on success */ - public function exec($paths) + public function exec($paths, $connector) { $data = [ 'fqdn' => $_SERVER['SERVER_NAME'] @@ -64,7 +65,7 @@ class NodeInfo default: $template = 'nodeinfo2.0.json'; } - $stats = \Federator\DIO\Stats::getStats($this->main); + $stats = \Federator\DIO\Stats::getStats($this->main, $connector); echo "fetch usercount via connector\n"; $data['usercount'] = $stats->userCount; $data['postcount'] = $stats->postCount; diff --git a/php/federator/api/wellknown/webfinger.php b/php/federator/api/wellknown/webfinger.php index 72555e3..b817401 100644 --- a/php/federator/api/wellknown/webfinger.php +++ b/php/federator/api/wellknown/webfinger.php @@ -46,18 +46,18 @@ class WebFinger { $_resource = $this->main->extractFromURI('resource'); $matches = []; - $config = $this->main->getConfig(); - $domain = $config['generic']['externaldomain']; - if (preg_match("/^acct:([^@]+)@(.*)$/", $_resource, $matches) != 1 || $matches[2] !== $domain) { + if (preg_match("/^acct:([^@]+)@(.*)$/", $_resource, $matches) != 1) { throw new \Federator\Exceptions\InvalidArgument(); - } + } + $domain = $matches[2]; $user = \Federator\DIO\User::getUserByName( $this->main->getDatabase(), $matches[1], - $this->main->getConnector(), + $this->main->getConnectorForHost($domain), $this->main->getCache() ); if ($user->id == 0) { + echo "not found"; throw new \Federator\Exceptions\FileNotFound(); } $data = [ diff --git a/php/federator/connector/connector.php b/php/federator/connector/connector.php index aab10e7..d1f41ac 100644 --- a/php/federator/connector/connector.php +++ b/php/federator/connector/connector.php @@ -13,6 +13,12 @@ namespace Federator\Connector; */ interface Connector { + /** + * get the host this connector is dedicated to + * + * @return string + */ + public function getHost(); /** * get posts by given user * diff --git a/php/federator/data/activitypub/common/apobject.php b/php/federator/data/activitypub/common/apobject.php index 735f3d6..62c2886 100644 --- a/php/federator/data/activitypub/common/apobject.php +++ b/php/federator/data/activitypub/common/apobject.php @@ -773,7 +773,7 @@ class APObject implements \JsonSerializable * {@inheritDoc} * @see JsonSerializable::jsonSerialize() */ - public function jsonSerialize() + public function jsonSerialize(): mixed { return $this->toObject(); } diff --git a/php/federator/data/user.php b/php/federator/data/user.php index efb829d..ef255e3 100644 --- a/php/federator/data/user.php +++ b/php/federator/data/user.php @@ -145,7 +145,7 @@ class User */ public function hasPermission(string $p) { - return in_array($p, $this->permissions, false); + return in_array(strtolower($p), array_map('strtolower', $this->permissions), true); } /** diff --git a/php/federator/dio/stats.php b/php/federator/dio/stats.php index ac905cf..9aa3fd4 100644 --- a/php/federator/dio/stats.php +++ b/php/federator/dio/stats.php @@ -17,11 +17,11 @@ class Stats /** * get remote stats * - * @param \Federator\Main $main - * main instance + * @param \Federator\Main $main main instance + * @param \Federator\Connector\Connector $connector connector to use * @return \Federator\Data\Stats */ - public static function getStats($main) + public static function getStats($main, $connector) { $cache = $main->getCache(); // ask cache @@ -31,7 +31,6 @@ class Stats return $stats; } } - $connector = $main->getConnector(); // ask connector for stats $stats = $connector->getRemoteStats(); if ($cache !== null && $stats !== false) { diff --git a/php/federator/dio/user.php b/php/federator/dio/user.php index 24ab009..9470198 100644 --- a/php/federator/dio/user.php +++ b/php/federator/dio/user.php @@ -145,6 +145,7 @@ class User public static function getUserByName($dbh, $_name, $connector, $cache) { $user = false; + // ask cache if ($cache !== null) { $user = $cache->getRemoteUserByName($_name); @@ -179,6 +180,7 @@ class User $stmt->fetch(); } $stmt->close(); + if ($user->id === null) { // ask connector for user-id $ruser = $connector->getRemoteUserByName($_name); @@ -190,7 +192,7 @@ class User if ($user->id === null && $user->externalid !== null) { self::addLocalUser($dbh, $user, $_name); } - $cache->saveRemoteUserByName($_name, $user); + $cache->saveRemoteUserByName($_name, user: $user); } return $user; } diff --git a/php/federator/main.php b/php/federator/main.php index 456c57f..6ec0637 100644 --- a/php/federator/main.php +++ b/php/federator/main.php @@ -28,11 +28,11 @@ class Main */ protected $config; /** - * remote connector + * remote connectors * - * @var Connector\Connector $connector + * @var array $connectors */ - protected $connector = null; + protected $connectors = []; /** * response content type * @@ -93,7 +93,7 @@ class Main public static function extractFromURI($param, $fallback = '') { $uri = $_SERVER['REQUEST_URI']; - $params = substr($uri, (int)(strpos($uri, '?') + 1)); + $params = substr($uri, (int) (strpos($uri, '?') + 1)); $params = explode('&', $params); foreach ($params as $p) { $tokens = explode('=', $p); @@ -141,18 +141,20 @@ class Main } /** - * get connector - * - * @return \Federator\Connector\Connector + * Get the connector for a given remote host + * + * @param string $remoteHost The host from the actor URL (e.g. mastodon.social) + * @return Connector\Connector|null */ - public function getConnector() + public function getConnectorForHost(string $remoteHost): ?Connector\Connector { - return $this->connector; + $host = strtolower(parse_url($remoteHost, PHP_URL_HOST) ?? $remoteHost); + return $this->connectors[$host] ?? null; } /** * get config - * @return Array + * @return array */ public function getConfig() { @@ -172,7 +174,7 @@ class Main /** * load plugins */ - public function loadPlugins() : void + public function loadPlugins(): void { if (array_key_exists('plugins', $this->config)) { $basepath = $_SERVER['DOCUMENT_ROOT'] . '../plugins/federator/'; @@ -199,8 +201,8 @@ class Main $dbconf = $this->config["database"]; $this->dbh = new \mysqli( $dbconf['host'], - $usernameOverride ?? (string)$dbconf['username'], - $passwordOverride ?? (string)$dbconf['password'], + $usernameOverride ?? (string) $dbconf['username'], + $passwordOverride ?? (string) $dbconf['password'], $dbconf['database'] ); if ($this->dbh->connect_error !== null) { @@ -221,7 +223,7 @@ class Main $smarty = new \Smarty\Smarty(); $root = $_SERVER['DOCUMENT_ROOT']; $smarty->setCompileDir($root . $this->config['templates']['compiledir']); - $smarty->setTemplateDir((string)realpath($root . $this->config['templates']['path'])); + $smarty->setTemplateDir((string) realpath($root . $this->config['templates']['path'])); $smarty->assign('database', $this->dbh); $smarty->assign('maininstance', $this); foreach ($data as $key => $value) { @@ -233,17 +235,22 @@ class Main /** * set cache */ - public function setCache(Cache\Cache $cache) : void + public function setCache(Cache\Cache $cache): void { $this->cache = $cache; } /** - * set connector + * Set a connector for a specific remote host + * + * @param string $remoteURL The remote host (like mastodon.social or contentnation.net) + * @param Connector\Connector $connector The connector instance */ - public function setConnector(Connector\Connector $connector) : void + public function addConnector(string $remoteURL, Connector\Connector $connector): void { - $this->connector = $connector; + // Normalize the host (no scheme, lowercase) + $host = strtolower(parse_url($remoteURL, PHP_URL_HOST) ?? $remoteURL); + $this->connectors[$host] = $connector; } /** @@ -252,7 +259,7 @@ class Main * @param int $code * new response code */ - public function setResponseCode(int $code) : void + public function setResponseCode(int $code): void { $this->responseCode = $code; } @@ -270,7 +277,7 @@ class Main * optional parameters * @return string translation */ - public static function translate(?string $lang, string $group, string $key, array $parameters = array()) : string + public static function translate(?string $lang, string $group, string $key, array $parameters = array()): string { $l = new Language($lang); return $l->printlang($group, $key, $parameters); @@ -281,7 +288,7 @@ class Main * * @param ?string $lang */ - public static function validLanguage(?string $lang) : bool + public static function validLanguage(?string $lang): bool { $language = new Language($lang); if ($language->getLang() === $lang) { diff --git a/plugins/federator/contentnation.php b/plugins/federator/contentnation.php index 3b871de..a9051b6 100644 --- a/plugins/federator/contentnation.php +++ b/plugins/federator/contentnation.php @@ -6,11 +6,11 @@ * @author Sascha Nitsch (grumpydeveloper) **/ - namespace Federator\Connector; +namespace Federator\Connector; - /** - * Connector to ContentNation.net - */ +/** + * Connector to ContentNation.net + */ class ContentNation implements Connector { /** @@ -49,6 +49,15 @@ class ContentNation implements Connector $this->main = $main; } + /** + * get the host this connector is dedicated to + * + * @return string + */ + public function getHost() { + return "contentnation.net"; + } + /** * get posts by given user * @@ -83,63 +92,66 @@ class ContentNation implements Connector $userdata = $this->config['userdata']['url']; foreach ($activities as $activity) { $create = new \Federator\Data\ActivityPub\Common\Create(); - $create->setAActor('https://' . $host .'/' . $userId); + $create->setAActor('https://' . $host . '/' . $userId); $create->setID($activity['id']) ->setPublished($activity['timestamp']) ->addTo("https://www.w3.org/ns/activitystreams#Public") ->addCC('https://' . $host . '/' . $userId . '/followers.json'); switch ($activity['type']) { case 'Article': - $create->setURL('https://'.$host . '/' . $activity['language'] . '/' . $userId . '/' - . $activity['name']); + $create->setURL('https://' . $host . '/' . $activity['language'] . '/' . $userId . '/' + . $activity['name']); $apArticle = new \Federator\Data\ActivityPub\Common\Article(); if (array_key_exists('tags', $activity)) { foreach ($activity['tags'] as $tag) { $href = 'https://' . $host . '/' . $activity['language'] - . '/search.htm?tagsearch=' . urlencode($tag); + . '/search.htm?tagsearch=' . urlencode($tag); $tagObj = new \Federator\Data\ActivityPub\Common\Tag(); $tagObj->setHref($href) - ->setName('#' . urlencode(str_replace(' ', '', $tag))) - ->setType('Hashtag'); + ->setName('#' . urlencode(str_replace(' ', '', $tag))) + ->setType('Hashtag'); $apArticle->addTag($tagObj); } } $apArticle->setPublished($activity['published']) - ->setName($activity['title']) - ->setAttributedTo('https://' . $host .'/' . $activity['profilename']) - ->setContent( - $activity['teaser'] ?? - $this->main->translate( - $activity['language'], - 'article', - 'newarticle' - ) - ) - ->addTo("https://www.w3.org/ns/activitystreams#Public") - ->addCC('https://' . $host . '/' . $userId . '/followers.json'); + ->setName($activity['title']) + ->setAttributedTo('https://' . $host . '/' . $activity['profilename']) + ->setContent( + $activity['teaser'] ?? + $this->main->translate( + $activity['language'], + 'article', + 'newarticle' + ) + ) + ->addTo("https://www.w3.org/ns/activitystreams#Public") + ->addCC('https://' . $host . '/' . $userId . '/followers.json'); $articleimage = $activity['imagealt'] ?? $this->main->translate($activity['language'], 'article', 'image'); $idurl = 'https://' . $host . '/' . $activity['language'] - . '/' . $userId . '/'. $activity['name']; + . '/' . $userId . '/' . $activity['name']; $apArticle->setID($idurl) - ->setURL($idurl); + ->setURL($idurl); $image = $activity['image'] ?? $activity['profileimg']; - $mediaType = @mime_content_type($imgpath . $activity['profile'] . '/' . $image) | 'text/plain'; + // $mediaType = @mime_content_type($imgpath . $activity['profile'] . '/' . $image) | 'text/plain'; // old approach, using local copy of images + $imgUrl = $userdata . '/' . $activity['profile'] . $image; + $mediaType = $this->getRemoteMimeType($imgUrl) ?? 'text/plain'; $img = new \Federator\Data\ActivityPub\Common\Image(); $img->setMediaType($mediaType) - ->setName($articleimage) - ->setURL($userdata . '/' . $activity['profile'] . $image); + ->setName($articleimage) + ->setURL($userdata . '/' . $activity['profile'] . $image); $apArticle->addImage($img); - $create->setObject($apArticle); + $create->setObject(object: $apArticle); $posts[] = $create; break; // Article case 'Comment': -// echo "comment\n"; -// print_r($activity); + $comment = new \Federator\Data\ActivityPub\Common\Activity('Comment'); + $create->setObject($comment); + $posts[] = $create; break; // Comment case 'Vote': - $url = 'https://'.$host . '/' . $activity['articlelang'] . $userId . '/' - . $activity['articlename']; + $url = 'https://' . $host . '/' . $activity['articlelang'] . $userId . '/' + . $activity['articlename']; $url .= '/vote/' . $activity['id']; $create->setURL($url); if ($activity['upvote'] === true) { @@ -167,7 +179,7 @@ class ContentNation implements Connector $actor->setName($activity['username']); $like->setActor($actor); $url = 'https://' . $host . '/' . $activity['articlelang'] - . '/' . $userId . '/'. $activity['articlename']; + . '/' . $userId . '/' . $activity['articlename']; if ($activity['comment'] !== '') { $url .= '/comment/' . $activity['comment']; } @@ -188,6 +200,18 @@ class ContentNation implements Connector } return $posts; } + + /** + * Get the MIME type of a remote file by its URL. + * + * @param string $_url The URL of the remote file. + * @return string|false The MIME type if found, or false on failure. + */ + public function getRemoteMimeType($url) + { + $headers = get_headers($url, 1); + return $headers['Content-Type'] ?? 'unknown'; + } /** * get statistics from remote system @@ -212,7 +236,7 @@ class ContentNation implements Connector return $stats; } - /** + /** * get remote user by given name * * @param string $_name user/profile name @@ -220,6 +244,9 @@ 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) { return false; @@ -271,7 +298,7 @@ class ContentNation implements Connector return false; } $r = json_decode($response, true); - if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) { + if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) { return false; } $user = $this->getRemoteUserByName($_user); @@ -299,5 +326,5 @@ namespace Federator; function contentnation_load($main) { $cn = new Connector\ContentNation($main); - $main->setConnector($cn); + $main->addConnector($cn->getHost(), $cn); } diff --git a/plugins/federator/dummyconnector.php b/plugins/federator/dummyconnector.php index ee0e6c3..d5acad4 100644 --- a/plugins/federator/dummyconnector.php +++ b/plugins/federator/dummyconnector.php @@ -18,6 +18,15 @@ class DummyConnector implements Connector public function __construct() { } + + /** + * get the host this connector is dedicated to + * + * @return string + */ + public function getHost() { + return "dummy"; + } /** * get posts by given user @@ -87,5 +96,5 @@ namespace Federator; function dummy_load($main) { $dummy = new Connector\DummyConnector(); - $main->setConnector($dummy); + $main->addConnector($dummy->getHost(), $dummy); } diff --git a/plugins/federator/mastodon.php b/plugins/federator/mastodon.php new file mode 100644 index 0000000..96bfdd1 --- /dev/null +++ b/plugins/federator/mastodon.php @@ -0,0 +1,302 @@ + $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; + } + + /** + * get the host this connector is dedicated to + * + * @return string + */ + public function getHost() { + return "mastodon.social"; + } + + /** + * 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) + { + $remoteURL = $this->service . '/users/' . $userId . '/outbox'; + + $items = []; + + do { + if ($min !== '') { + $remoteURL .= '&minTS=' . urlencode($min); + } + if ($max !== '') { + $remoteURL .= '&maxTS=' . urlencode($max); + } + // 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 "aborting"; + return false; + } + + $outbox = json_decode($outboxResponse, true); + + // Extract orderedItems from the current page + if (isset($outbox['orderedItems'])) { + $items = array_merge($items, $outbox['orderedItems']); + } + + // Step 4: Use 'last' URL to determine pagination + if (isset($outbox['last'])) { + // The 'last' URL will usually have a query string that includes min_id for the next set of results + $remoteURL = $outbox['last']; // Update to the last URL for the next page of items + } else { + break; // No more pages, exit pagination + } + + } while (!empty($outbox['last'])); // Continue fetching until no 'last' URL + + + $items = []; + + // 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 = []; + foreach ($items as $activity) { + if (!isset($activity['type']) || $activity['type'] !== 'Create' || !isset($activity['object'])) { + continue; // Skip non-Create activities + } + + $obj = $activity['object']; + $create = new \Federator\Data\ActivityPub\Common\Create(); + $create->setID($activity['id']) + ->setPublished(strtotime($activity['published'] ?? $obj['published'] ?? 'now')) + ->setAActor($activity['actor']) + ->addTo("https://www.w3.org/ns/activitystreams#Public"); + + // Handle main Note content + if ($obj['type'] === 'Note') { + $apNote = new \Federator\Data\ActivityPub\Common\Note(); + $apNote->setID($obj['id']) + ->setPublished(strtotime($obj['published'] ?? 'now')) + ->setContent($obj['content'] ?? '') + ->setAttributedTo($obj['attributedTo'] ?? $activity['actor']) + ->addTo("https://www.w3.org/ns/activitystreams#Public"); + + // 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); + } + } + + $create->setObject($apNote); + } + + $posts[] = $create; + } + + return $posts; + } + + /** + * Get the MIME type of a remote file by its URL. + * + * @param string $_url The URL of the remote file. + * @return string|false The MIME type if found, or false on failure. + */ + public function getRemoteMimeType($url) + { + $headers = get_headers($url, 1); + return $headers['Content-Type'] ?? 'unknown'; + } + + /** + * 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; + } + + // 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 = strtotime($r['created_at']); + + 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); + $main->addConnector($mast->getHost(), $mast); +} diff --git a/plugins/federator/rediscache.php b/plugins/federator/rediscache.php index 5c96eeb..78b16d4 100644 --- a/plugins/federator/rediscache.php +++ b/plugins/federator/rediscache.php @@ -53,6 +53,15 @@ class RedisCache implements Cache } } + /** + * get the host this connector is dedicated to + * + * @return string + */ + public function getHost() { + return "redis"; + } + /** * connect to redis * @return void