diff --git a/.gitignore b/.gitignore index d222587..60787f3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ php-docs .phpdoc phpdoc html +/cache +contentnation.ini +*.pem* +composer.phar diff --git a/.phan/config.php b/.phan/config.php index 8c7f84a..b79795c 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -359,6 +359,7 @@ return [ 'directory_list' => [ 'vendor/phan/phan/src/Phan', 'vendor/smarty/smarty/src', + 'vendor/resque/php-resque/lib', 'php/', 'plugins', 'htdocs', @@ -367,5 +368,7 @@ return [ // A list of individual files to include in analysis // with a path relative to the root directory of the // project. - 'file_list' => [], + 'file_list' => [ + 'phan-stubs.php', + ], ]; diff --git a/README.md b/README.md index 5d98edb..c6906df 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ If the include redis cache is enabled, - install redis - create a users.acl for redis with the content: - user federator on ~u_* +get +set ~s_* +get +setex ~m_* +get +setex >redis*change*password + user federator on ~u_* +get +set ~s_* +get +setex ~m_* +get +setex ~publickey_* +get +setex >redis*change*password - change password in the rediscache.ini to match your given password. - install the redis plugin from pecl if not provided via your distro @@ -52,9 +52,11 @@ 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 ^api/federator/(.+)$ federator.php?_call=$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] + RewriteRule ^([a-zA-Z0-9_-]+.*)$ /federator.php?_call=fedusers/$1 [L,END] change your document root for the domain you want to use (or default one if using localhost) to the directory you installed it, with the /htdocs at the end. A user should only be able to open that file, not the other data. diff --git a/composer.json b/composer.json index 5deb518..2a12bd1 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,8 @@ "description": "A federation service", "type": "project", "require": { - "smarty/smarty": "^5.3" + "smarty/smarty": "^5.3", + "resque/php-resque": "^1.3.6" }, "license": "GPL-3.0-or-later", "authors": [ @@ -14,5 +15,10 @@ ], "require-dev": { "phan/phan": "^5.4" + }, + "autoload": { + "psr-4": { + "Federator\\": "php/federator/" + } } } diff --git a/config.ini b/config.ini index dcf9ba1..9718fc2 100644 --- a/config.ini +++ b/config.ini @@ -1,4 +1,5 @@ [generic] +protocol = 'https' externaldomain = 'your.fqdn' [database] @@ -17,4 +18,8 @@ dummy = 'dummyconnector.php' [maintenance] username = 'federatoradmin' -password = '*change*me*as*well' \ No newline at end of file +password = '*change*me*as*well' + +[keys] +federatorPrivateKeyPath = 'federator.pem' +federatorPublicKeyPath = 'federator.pem.pub' diff --git a/formatsupport.json b/formatsupport.json new file mode 100644 index 0000000..3535021 --- /dev/null +++ b/formatsupport.json @@ -0,0 +1,8 @@ +{ + "activitypub": { + "article": [ + "localhost", + "writefreely.org" + ] + } +} \ No newline at end of file diff --git a/phan-stubs.php b/phan-stubs.php new file mode 100644 index 0000000..7428783 --- /dev/null +++ b/phan-stubs.php @@ -0,0 +1,10 @@ + + */ + function getallheaders(): array { + return []; + } +} \ No newline at end of file diff --git a/php/federator/api.php b/php/federator/api.php index 83ff2e2..1c85e5e 100644 --- a/php/federator/api.php +++ b/php/federator/api.php @@ -6,7 +6,7 @@ * @author Sascha Nitsch (grumpydeveloper) **/ - namespace Federator; +namespace Federator; /** * main API class @@ -46,8 +46,8 @@ class Api extends Main */ public function __construct() { - $this->contentType = "application/json"; - Main::__construct(); + $this->contentType = 'application/json'; + parent::__construct(); } /** @@ -63,18 +63,18 @@ class Api extends Main while ($this->path[0] === '/') { $this->path = substr($this->path, 1); } - $this->paths = explode("/", $this->path); + $this->paths = explode('/', $this->path); } /** * main API function */ - public function run() : void + public function run(): void { - $this->setPath((string)$_REQUEST['_call']); + $this->setPath((string) $_REQUEST['_call']); $this->openDatabase(); $this->loadPlugins(); - $retval = ""; + $retval = ''; $handler = null; if ($this->connector === null) { http_response_code(500); @@ -100,12 +100,16 @@ class Api extends Main break; case 'fedusers': $handler = new Api\FedUsers($this); + $this->setContentType('application/activity+json'); break; case 'v1': switch ($this->paths[1]) { case 'dummy': $handler = new Api\V1\Dummy($this); break; + case 'newcontent': + $handler = new Api\V1\NewContent($this); + break; } break; } @@ -119,7 +123,7 @@ class Api extends Main } catch (Exceptions\Exception $e) { $this->setResponseCode($e->getRetCode()); $retval = json_encode(array( - "error" => $e->getMessage() + 'error' => $e->getMessage() )); } } else { @@ -135,26 +139,26 @@ class Api extends Main } if ($printresponse) { if ($this->redirect !== null) { - header("Location: $this->redirect"); + header('Location: ' . $this->redirect); } if ($this->responseCode != 404) { - header("Content-type: " . $this->contentType); - header("Access-Control-Allow-Origin: *"); + header('Content-type: ' . $this->contentType); + header('Access-Control-Allow-Origin: *'); } if ($this->cacheTime == 0) { - header("Cache-Control: no-cache, no-store, must-revalidate"); - header("Pragma: no-cache"); - header("Expires: 0"); + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); } else { - $ts = gmdate("D, d M Y H:i:s", time() + $this->cacheTime) . " GMT"; - header("Expires: $ts"); - header("Pragma: cache"); - header("Cache-Control: max-age=" . $this->cacheTime); + $ts = gmdate('D, d M Y H:i:s', time() + $this->cacheTime) . ' GMT'; + header('Expires: ' . $ts); + header('Pragma: cache'); + header('Cache-Control: max-age=' . $this->cacheTime); } echo $retval; } else { if (!headers_sent()) { - header("Content-type: " . $this->contentType); + header('Content-type: ' . $this->contentType); } } } @@ -168,7 +172,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) { @@ -191,6 +195,96 @@ class Api extends Main throw new $exception($message); } + /** + * check if the headers include a valid signature + * + * @param string[] $headers the headers + * @throws Exceptions\PermissionDenied + * @return string|Exceptions\PermissionDenied + */ + public function checkSignature($headers) + { + if (isset($headers['X-Sender'])) { + try { + return $this->connector->checkSignature($headers); + } catch (Exceptions\PermissionDenied $e) { + http_response_code(500); + throw $e; + } + } + + $signatureHeader = $headers['Signature'] ?? null; + + if (!isset($signatureHeader)) { + throw new Exceptions\PermissionDenied('Missing Signature header'); + } + + // 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']); + $keyId = $signatureParts['keyId']; + + $publicKeyPem = $this->cache->getPublicKey($keyId); + + if (!isset($publicKeyPem) || $publicKeyPem === false) { + // Fetch public key from `keyId` (usually actor URL + #main-key) + [$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']); + + if ($info['http_code'] != 200) { + throw new Exceptions\PermissionDenied('Failed to fetch public key from keyId: ' . $keyId); + } + + $actor = json_decode($publicKeyData, true); + + if (!is_array($actor) || !isset($actor['id'])) { + throw new Exceptions\PermissionDenied('Invalid actor data'); + } + + $publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null; + + if (!isset($publicKeyPem) || $publicKeyPem === false) { + http_response_code(500); + throw new Exceptions\PermissionDenied('Public key couldn\'t be determined'); + } + + // Cache the public key for 1 hour + $this->cache->savePublicKey($keyId, $publicKeyPem); + } + + // 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 Exceptions\PermissionDenied('Signature verification failed'); + } + + // Signature is valid! + return 'Signature verified.'; + } + /** * remove unwanted elements from html input * @@ -198,7 +292,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 +306,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 +322,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/fedusers.php b/php/federator/api/fedusers.php index 77958e7..bb7b38d 100644 --- a/php/federator/api/fedusers.php +++ b/php/federator/api/fedusers.php @@ -16,7 +16,7 @@ class FedUsers implements APIInterface /** * main instance * - * @var \Federator\Main $main + * @var \Federator\Api $main */ private $main; @@ -47,26 +47,36 @@ class FedUsers implements APIInterface */ public function exec($paths, $user) { - $method = $_SERVER["REQUEST_METHOD"]; + $method = $_SERVER['REQUEST_METHOD']; $handler = null; + $_username = $paths[1]; switch (sizeof($paths)) { case 2: if ($method === 'GET') { - // /users/username or /@username - return $this->returnUserProfile($paths[1]); + // /users/username or /@username or /username + return $this->returnUserProfile($_username); + } else { + switch ($paths[1]) { + case 'inbox': + $_username = null; + $handler = new FedUsers\Inbox($this->main); + break; + default: + break; + } } break; case 3: // /users/username/(inbox|outbox|following|followers) switch ($paths[2]) { case 'following': - // $handler = new FedUsers\Following(); + $handler = new FedUsers\Following($this->main); break; case 'followers': - // $handler = new FedUsers\Followers(); + $handler = new FedUsers\Followers($this->main); break; case 'inbox': - // $handler = new FedUsers\Inbox(); + $handler = new FedUsers\Inbox($this->main); break; case 'outbox': $handler = new FedUsers\Outbox($this->main); @@ -82,10 +92,10 @@ class FedUsers implements APIInterface $ret = false; switch ($method) { case 'GET': - $ret = $handler->get($paths[1]); + $ret = $handler->get($_username); break; case 'POST': - $ret = $handler->post($paths[1]); + $ret = $handler->post($_username); break; } if ($ret !== false) { @@ -115,22 +125,29 @@ class FedUsers implements APIInterface if ($user === false || $user->id === null) { throw new \Federator\Exceptions\FileNotFound(); } + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; + $jsonKey = json_encode($user->publicKey); + if (!is_string($jsonKey)) { + throw new \Federator\Exceptions\FileNotFound(); + } $data = [ 'iconMediaType' => $user->iconMediaType, 'iconURL' => $user->iconURL, 'imageMediaType' => $user->imageMediaType, 'imageURL' => $user->imageURL, - 'fqdn' => $_SERVER['SERVER_NAME'], + 'fqdn' => $domain, 'name' => $user->name, 'username' => $user->id, - 'publickey' => str_replace("\n", "\\n", $user->publicKey), + 'publickey' => trim($jsonKey, '"'), 'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z 'summary' => $user->summary, - 'type' => $user->type + 'type' => ucfirst($user->type) // capitalized user type ]; $this->response = $this->main->renderTemplate('user.json', $data); return true; } + /** * set response * diff --git a/php/federator/api/fedusers/followers.php b/php/federator/api/fedusers/followers.php new file mode 100644 index 0000000..f447e0e --- /dev/null +++ b/php/federator/api/fedusers/followers.php @@ -0,0 +1,123 @@ +main = $main; + } + + /** + * handle get call + * + * @param string|null $_user user to fetch followers for + * @return string|false response + */ + public function get($_user) + { + if (!isset($_user)) { + return false; + } + $dbh = $this->main->getDatabase(); + $cache = $this->main->getCache(); + $connector = $this->main->getConnector(); + + // get user + $user = \Federator\DIO\User::getUserByName( + $dbh, + $_user, + $connector, + $cache + ); + if ($user->id === null) { + return false; + } + + $followers = new \Federator\Data\ActivityPub\Common\Followers(); + $followerItems = \Federator\DIO\Followers::getFollowersByUser($dbh, $user->id, $connector, $cache); + + $config = $this->main->getConfig(); + $protocol = $config['generic']['protocol']; + $domain = $config['generic']['externaldomain']; + $baseUrl = $protocol . '://' . $domain . '/' . $_user . '/followers'; + + $pageSize = 10; + $page = $this->main->extractFromURI('page', ''); + $id = $baseUrl; + $items = []; + $totalItems = count($followerItems); + + if ($page !== '') { + $pageNum = max(0, (int) $page); + $offset = (int)($pageNum * $pageSize); + $pagedItems = array_slice($followerItems, $offset, $pageSize); + + foreach ($pagedItems as $follower) { + $items[] = $follower->actorURL; + } + $followers->setItems($items); + $id .= '?page=' . urlencode($page); + } + + $followers->setID($id); + $followers->setPartOf($baseUrl); + $followers->setTotalItems($totalItems); + + // Pagination navigation + $lastPage = max(0, ceil($totalItems / $pageSize) - 1); + + if ($page === '' || $followers->getCount() == 0) { + $followers->setFirst($baseUrl . '?page=0'); + $followers->setLast($baseUrl . '?page=' . $lastPage); + } + if ($page !== '') { + $pageNum = max(0, (int) $page); + if ($pageNum < $lastPage) { + $followers->setNext($baseUrl . '?page=' . ($pageNum + 1)); + } + if ($pageNum > 0) { + $followers->setPrev($baseUrl . '?page=' . ($pageNum - 1)); + } + $followers->setFirst($baseUrl . '?page=0'); + $followers->setLast($baseUrl . '?page=' . $lastPage); + } else { + $followers->setType('OrderedCollection'); + } + $obj = $followers->toObject(); + + return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + } + + /** + * handle post call + * + * @param string|null $_user user to add data to outbox @unused-param + * @return string|false response + */ + public function post($_user) + { + return false; + } +} diff --git a/php/federator/api/fedusers/following.php b/php/federator/api/fedusers/following.php new file mode 100644 index 0000000..d6c6823 --- /dev/null +++ b/php/federator/api/fedusers/following.php @@ -0,0 +1,123 @@ +main = $main; + } + + /** + * handle get call + * + * @param string|null $_user user to fetch followers for + * @return string|false response + */ + public function get($_user) + { + if (!isset($_user)) { + return false; + } + $dbh = $this->main->getDatabase(); + $cache = $this->main->getCache(); + $connector = $this->main->getConnector(); + + // get user + $user = \Federator\DIO\User::getUserByName( + $dbh, + $_user, + $connector, + $cache + ); + if ($user->id === null) { + return false; + } + + $following = new \Federator\Data\ActivityPub\Common\Following(); + $followingItems = \Federator\DIO\Followers::getFollowingForUser($dbh, $user->id, $connector, $cache); + + $config = $this->main->getConfig(); + $protocol = $config['generic']['protocol']; + $domain = $config['generic']['externaldomain']; + $baseUrl = $protocol . '://' . $domain . '/users/' . $_user . '/following'; + + $pageSize = 10; + $page = $this->main->extractFromURI('page', ''); + $id = $baseUrl; + $items = []; + $totalItems = count($followingItems); + + if ($page !== '') { + $pageNum = max(0, (int) $page); + $offset = (int) ($pageNum * $pageSize); + $pagedItems = array_slice($followingItems, $offset, $pageSize); + + foreach ($pagedItems as $followed) { + $items[] = $followed->actorURL; + } + $following->setItems($items); + $id .= '?page=' . urlencode($page); + } + + $following->setID($id); + $following->setPartOf($baseUrl); + $following->setTotalItems($totalItems); + + // Pagination navigation + $lastPage = max(0, ceil($totalItems / $pageSize) - 1); + + if ($page === '' || $following->getCount() == 0) { + $following->setFirst($baseUrl . '?page=0'); + $following->setLast($baseUrl . '?page=' . $lastPage); + } + if ($page !== '') { + $pageNum = max(0, (int) $page); + if ($pageNum < $lastPage) { + $following->setNext($baseUrl . '?page=' . ($pageNum + 1)); + } + if ($pageNum > 0) { + $following->setPrev($baseUrl . '?page=' . ($pageNum - 1)); + } + $following->setFirst($baseUrl . '?page=0'); + $following->setLast($baseUrl . '?page=' . $lastPage); + } else { + $following->setType('OrderedCollection'); + } + $obj = $following->toObject(); + + return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + } + + /** + * handle post call + * + * @param string|null $_user user to add data to outbox @unused-param + * @return string|false response + */ + public function post($_user) + { + return false; + } +} diff --git a/php/federator/api/fedusers/inbox.php b/php/federator/api/fedusers/inbox.php new file mode 100644 index 0000000..e63782c --- /dev/null +++ b/php/federator/api/fedusers/inbox.php @@ -0,0 +1,446 @@ +main = $main; + } + + /** + * handle get call + * + * @param string|null $_user user to fetch inbox for @unused-param + * @return string|false response + */ + public function get($_user) + { + return false; + } + + /** + * handle post call + * + * @param string|null $_user user to add data to inbox + * @return string|false response + */ + public function post($_user) + { + $_rawInput = file_get_contents('php://input'); + + $allHeaders = getallheaders(); + try { + $this->main->checkSignature($allHeaders); + } catch (\Federator\Exceptions\PermissionDenied $e) { + throw new \Federator\Exceptions\Unauthorized('Inbox::post Signature check failed: ' . $e->getMessage()); + } + + $activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null; + + $dbh = $this->main->getDatabase(); + $cache = $this->main->getCache(); + $connector = $this->main->getConnector(); + + $config = $this->main->getConfig(); + + if (!is_array($activity)) { + throw new \Federator\Exceptions\ServerError('Inbox::post Input wasn\'t of type array'); + } + + $inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity); + + if ($inboxActivity === false) { + throw new \Federator\Exceptions\ServerError('Inbox::post couldn\'t create inboxActivity'); + } + $user = $inboxActivity->getAActor(); // url of the sender https://contentnation.net/username + $username = basename((string) (parse_url($user, PHP_URL_PATH) ?? '')); + $domain = parse_url($user, PHP_URL_HOST); + $userId = $username . '@' . $domain; + $user = \Federator\DIO\FedUser::getUserByName( + $dbh, + $userId, + $cache + ); + if ($user === null || $user->id === null) { + error_log('Inbox::post couldn\'t find user: ' . $userId); + throw new \Federator\Exceptions\ServerError('Inbox::post couldn\'t find user: ' . $userId); + } + + $users = []; + + $receivers = array_merge($inboxActivity->getTo(), $inboxActivity->getCC()); + + // For Undo, the object may hold the proper to/cc + if ($inboxActivity->getType() === 'Undo') { + $object = $inboxActivity->getObject(); + if ($object !== null && is_object($object)) { + $receivers = array_merge($object->getTo(), $object->getCC()); + } + } + + // Filter out the public address and keep only actual URLs + $receivers = array_filter($receivers, static function (mixed $receiver): bool { + return is_string($receiver) + && $receiver !== 'https://www.w3.org/ns/activitystreams#Public' + && (filter_var($receiver, FILTER_VALIDATE_URL) !== false); + }); + + if (isset($_user)) { + $receivers[] = $dbh->real_escape_string($_user); // Add the target user to the receivers list + } + + // Special handling for Follow and Undo follow activities + if (strtolower($inboxActivity->getType()) === 'follow') { + // For Follow, the object should hold the target + $object = $inboxActivity->getObject(); + if ($object !== null && is_string($object)) { + $receivers[] = $object; + } + } elseif (strtolower($inboxActivity->getType()) === 'undo') { + $object = $inboxActivity->getObject(); + if ($object !== null && is_object($object)) { + // For Undo, the objects object should hold the target + if (strtolower($object->getType()) === 'follow') { + $objObject = $object->getObject(); + if ($objObject !== null && is_string($objObject)) { + $receivers[] = $objObject; + } + } + } + } + $ourDomain = $config['generic']['externaldomain']; + + foreach ($receivers as $receiver) { + if ($receiver === '' || !is_string($receiver)) { + continue; + } + + if (str_ends_with($receiver, '/followers')) { + $actor = $inboxActivity->getAActor(); + if ($actor === null || !is_string($actor)) { + error_log('Inbox::post no actor found'); + continue; + } + + // Extract username from the actor URL + $username = basename((string) (parse_url($actor, PHP_URL_PATH) ?? '')); + $domain = parse_url($actor, PHP_URL_HOST); + if ($username === null || $domain === null) { + error_log('Inbox::post no username or domain found for recipient: ' . $receiver); + continue; + } + try { + $followers = \Federator\DIO\Followers::getFollowersByFedUser( + $dbh, + $connector, + $cache, + $username . '@' . $domain + ); + } catch (\Throwable $e) { + error_log('Inbox::post get followers for user: ' . $username . '@' . $domain . '. Exception: ' + . $e->getMessage()); + continue; + } + + if (is_array($followers)) { + $users = array_merge($users, array_column($followers, 'id')); + } + } else { + // check if receiver is an actor url from our domain + if (!str_contains($receiver, $ourDomain) && $receiver !== $_user) { + continue; + } + if ($receiver !== $_user) { + $receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? '')); + $ourDomain = parse_url($receiver, PHP_URL_HOST); + if ($receiverName === null || $ourDomain === null) { + error_log('Inbox::post no receiverName or domain found for receiver: ' . $receiver); + continue; + } + $receiver = $receiverName; + } + try { + $localUser = \Federator\DIO\User::getUserByName( + $dbh, + $receiver, + $connector, + $cache + ); + } catch (\Throwable $e) { + error_log('Inbox::post get user by name: ' . $receiver . '. Exception: ' . $e->getMessage()); + continue; + } + if ($localUser === null || $localUser->id === null) { + error_log('Inbox::post couldn\'t find user: ' . $receiver); + continue; + } + $users[] = $localUser->id; + } + } + + $users = array_unique($users); // remove duplicates + + if (empty($users)) { // todo remove after proper implementation, debugging for now + $rootDir = PROJECT_ROOT . '/'; + // Save the raw input and parsed JSON to a file for inspection + file_put_contents( + $rootDir . 'logs/inbox.log', + date('Y-m-d H:i:s') . ": ==== POST Inbox Activity ====\n" + . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n", + FILE_APPEND + ); + } + + foreach ($users as $receiver) { + if (!isset($receiver)) { + continue; + } + $token = \Resque::enqueue('inbox', 'Federator\\Jobs\\InboxJob', [ + 'user' => $user->id, + 'recipientId' => $receiver, + 'activity' => $inboxActivity->toObject(), + ]); + error_log('Inbox::post enqueued job for user: ' . $user->id . ' with token: ' . $token); + } + if (empty($users)) { + $type = strtolower($inboxActivity->getType()); + if ($type === 'undo' || $type === 'delete') { + $token = \Resque::enqueue('inbox', 'Federator\\Jobs\\InboxJob', [ + 'user' => $user->id, + 'recipientId' => "", + 'activity' => $inboxActivity->toObject(), + ]); + error_log('Inbox::post enqueued job for user: ' . $user->id . ' with token: ' . $token); + } else { + error_log('Inbox::post no users found for activity, doing nothing: ' + . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + } + + $connector->sendActivity($user, $inboxActivity); + + return 'success'; + } + + /** + * handle post call for specific user + * + * @param \mysqli $dbh database handle + * @param \Federator\Connector\Connector $connector connector to use + * @param \Federator\Cache\Cache|null $cache optional caching service + * @param string $_user user that triggered the post + * @param string $_recipientId recipient of the post + * @param \Federator\Data\ActivityPub\Common\Activity $inboxActivity the activity that we received + * @return boolean response + */ + public static function postForUser($dbh, $connector, $cache, $_user, $_recipientId, $inboxActivity) + { + if (!isset($_user)) { + error_log('Inbox::postForUser no user given'); + return false; + } + + // get sender + $user = \Federator\DIO\FedUser::getUserByName( + $dbh, + $_user, + $cache + ); + if ($user === null || $user->id === null) { + error_log('Inbox::postForUser couldn\'t find user: ' . $_user); + return false; + } + + $type = strtolower($inboxActivity->getType()); + + if ($_recipientId === '') { + if ($type === 'undo' || $type === 'delete') { + switch ($type) { + case 'delete': + // Delete Note/Post + $object = $inboxActivity->getObject(); + if (is_string($object)) { + \Federator\DIO\Posts::deletePost($dbh, $object); + } elseif (is_object($object)) { + $objectId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $objectId); + } else { + error_log('Inbox::postForUser Error in Delete Post for user ' . $user->id + . ', object is not a string or object'); + error_log(' object of type ' . gettype($object)); + return false; + } + break; + + case 'undo': + $object = $inboxActivity->getObject(); + if (is_object($object)) { + switch (strtolower($object->getType())) { + case 'like': + case 'dislike': + // Undo Like/Dislike (remove like/dislike) + $targetId = $object->getID(); + // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike'); + \Federator\DIO\Posts::deletePost($dbh, $targetId); + break; + case 'note': + case 'article': + // Undo Note (remove note) + $noteId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $noteId); + break; + } + } + break; + + default: + error_log('Inbox::postForUser Unhandled activity type ' . $type . ' for user ' . $user->id); + break; + } + + return true; + } + } + + $atPos = strpos($_recipientId, '@'); + if ($atPos !== false) { + $_recipientId = substr($_recipientId, 0, $atPos); + } + + // get recipient + $recipient = \Federator\DIO\User::getUserByName( + $dbh, + $_recipientId, + $connector, + $cache + ); + if ($recipient === null || $recipient->id === null) { + error_log('Inbox::postForUser couldn\'t find recipient: ' . $_recipientId); + return false; + } + + $rootDir = PROJECT_ROOT . '/'; + // Save the raw input and parsed JSON to a file for inspection + file_put_contents( + $rootDir . 'logs/inbox_' . $recipient->id . '.log', + date('Y-m-d H:i:s') . ": ==== POST " . $recipient->id . " Inbox Activity ====\n" + . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n", + FILE_APPEND + ); + + switch ($type) { + case 'follow': + $success = \Federator\DIO\Followers::addExternalFollow( + $dbh, + $inboxActivity->getID(), + $user->id, + $recipient->id + ); + + if ($success === false) { + error_log('Inbox::postForUser Failed to add follower for user ' . $user->id); + } + break; + + case 'delete': + // Delete Note/Post + $object = $inboxActivity->getObject(); + if (is_string($object)) { + \Federator\DIO\Posts::deletePost($dbh, $object); + } elseif (is_object($object)) { + $objectId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $objectId); + } + break; + + case 'undo': + $object = $inboxActivity->getObject(); + if (is_object($object)) { + switch (strtolower($object->getType())) { + case 'follow': + $success = false; + if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) { + $actor = $object->getAActor(); + if ($actor !== '') { + $success = \Federator\DIO\Followers::removeFollow($dbh, $user->id, $recipient->id); + } + } + if ($success === false) { + error_log('Inbox::postForUser Failed to remove follower for user ' . $user->id); + } + break; + case 'like': + case 'dislike': + // Undo Like/Dislike (remove like/dislike) + $targetId = $object->getID(); + \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId); + // \Federator\DIO\Posts::deletePost($dbh, $targetId); + break; + case 'note': + // Undo Note (remove note) + $noteId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $noteId); + break; + } + } + break; + + case 'like': + case 'dislike': + // Add Like/Dislike + $targetId = $inboxActivity->getObject(); + if (is_string($targetId)) { + \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, $type); + } else { + error_log('Inbox::postForUser Error in Add Like/Dislike for user ' . $user->id + . ', targetId is not a string'); + return false; + } + break; + + case 'create': + case 'update': + $object = $inboxActivity->getObject(); + if (is_object($object)) { + switch (strtolower($object->getType())) { + case 'note': + \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + break; + case 'article': + \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + break; + default: + \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + break; + } + } + break; + default: + error_log('Inbox::postForUser Unhandled activity type $type for user ' . $user->id); + break; + } + return true; + } +} diff --git a/php/federator/api/fedusers/outbox.php b/php/federator/api/fedusers/outbox.php index a18fae9..f7b3457 100644 --- a/php/federator/api/fedusers/outbox.php +++ b/php/federator/api/fedusers/outbox.php @@ -16,13 +16,13 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface /** * main instance * - * @var \Federator\Main $main + * @var \Federator\Api $main */ private $main; /** * constructor - * @param \Federator\Main $main main instance + * @param \Federator\Api $main main instance */ public function __construct($main) { @@ -32,11 +32,14 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface /** * handle get call * - * @param string $_user user to fetch outbox for + * @param string|null $_user user to fetch outbox for * @return string|false response */ public function get($_user) { + if (!isset($_user)) { + return false; + } $dbh = $this->main->getDatabase(); $cache = $this->main->getCache(); $connector = $this->main->getConnector(); @@ -50,42 +53,46 @@ 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", ""); - $max = $this->main->extractFromURI("max", ""); - $page = $this->main->extractFromURI("page", ""); + $min = intval($this->main->extractFromURI('min', '0'), 10); + $max = intval($this->main->extractFromURI('max', '0'), 10); + $page = $this->main->extractFromURI('page', ''); if ($page !== "") { - $items = \Federator\DIO\Posts::getPostsByUser($dbh, $user->id, $connector, $cache, $min, $max); + $items = \Federator\DIO\Posts::getPostsByUser($dbh, $user->id, $connector, $cache, $min, $max, 20); $outbox->setItems($items); } else { + $tmpitems = \Federator\DIO\Posts::getPostsByUser($dbh, $user->id, $connector, $cache, $min, $max, 99999); + $outbox->setTotalItems(sizeof($tmpitems)); $items = []; } - $host = $_SERVER['SERVER_NAME']; - $id = 'https://' . $host .'/' . $_user . '/outbox'; + $config = $this->main->getConfig(); + $protocol = $config['generic']['protocol']; + $domain = $config['generic']['externaldomain']; + $id = $protocol . '://' . $domain . '/' . $_user . '/outbox'; $outbox->setPartOf($id); $outbox->setID($id); - if ($page !== '') { - $id .= '?page=' . urlencode($page); + if ($page === '') { + $outbox->setType('OrderedCollection'); } - if ($page === '' || $outbox->count() == 0) { - $outbox->setFirst($id); - $outbox->setLast($id . '&min=0'); + if ($page === '' || $outbox->getCount() == 0) { + $outbox->setFirst($id . '?page=true'); } - if (sizeof($items)>0) { - $newestId = $items[0]->getPublished(); - $oldestId = $items[sizeof($items)-1]->getPublished(); - $outbox->setNext($id . '&max=' . $newestId); - $outbox->setPrev($id . '&min=' . $oldestId); + if (sizeof($items) > 0) { + $oldestTS = $items[0]->getPublished(); + $newestTS = $items[sizeof($items) - 1]->getPublished(); + $outbox->setNext($id . '?page=true&max=' . $newestTS); + $outbox->setPrev($id . '?page=true&min=' . $oldestTS); } $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 string|null $_user user to add data to outbox @unused-param * @return string|false response */ public function post($_user) diff --git a/php/federator/api/v1/dummy.php b/php/federator/api/v1/dummy.php index b70a5ac..caed78b 100644 --- a/php/federator/api/v1/dummy.php +++ b/php/federator/api/v1/dummy.php @@ -41,10 +41,10 @@ class Dummy implements \Federator\Api\APIInterface * run given url path * * @param array $paths path array split by / - * @param \Federator\Data\User|false $user user who is calling us + * @param \Federator\Data\User|false $user user who is calling us @unused-param * @return bool true on success */ - public function exec($paths, $user) : bool + public function exec($paths, $user): bool { // only for user with the 'publish' permission if ($user === false || $user->hasPermission('publish') === false) { @@ -58,6 +58,7 @@ class Dummy implements \Federator\Api\APIInterface if ($paths[2] === 'moo') { return $this->getDummy(); } + break; } break; case 'POST': @@ -68,6 +69,7 @@ class Dummy implements \Federator\Api\APIInterface } break; } + break; } $this->main->setResponseCode(404); return false; diff --git a/php/federator/api/v1/newcontent.php b/php/federator/api/v1/newcontent.php new file mode 100644 index 0000000..9a82f25 --- /dev/null +++ b/php/federator/api/v1/newcontent.php @@ -0,0 +1,573 @@ +main = $main; + } + + /** + * run given url path + * + * @param array $paths path array split by / + * @param \Federator\Data\User|false $user user who is calling us @unused-param + * @return bool true on success + */ + public function exec($paths, $user) + { + $method = $_SERVER['REQUEST_METHOD']; + $_username = $paths[2]; + if ($method === 'GET') { // unsupported + /// TODO: throw unsupported method exception + throw new \Federator\Exceptions\InvalidArgument('GET not supported'); + } + switch (sizeof($paths)) { + case 3: + $ret = $this->post($_username); + break; + } + + if (isset($ret) && $ret !== false) { + $this->response = $ret; + return true; + } + + $this->main->setResponseCode(404); + return false; + } + + /** + * handle post call + * + * @param string|null $_user optional user that triggered the post + * @return string|false response + */ + public function post($_user) + { + $_rawInput = file_get_contents('php://input'); + $allHeaders = getallheaders(); + try { + $this->main->checkSignature($allHeaders); + } catch (\Federator\Exceptions\PermissionDenied $e) { + error_log('NewContent::post Signature check failed: ' . $e->getMessage()); + http_response_code(401); + return false; + } + + $input = is_string($_rawInput) ? json_decode($_rawInput, true) : null; + + + $dbh = $this->main->getDatabase(); + $cache = $this->main->getCache(); + $connector = $this->main->getConnector(); + + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; + if (!is_array($input)) { + error_log('NewContent::post Input wasn\'t of type array'); + return false; + } + + $articleId = ''; + if (isset($allHeaders['X-Sender'])) { + $newActivity = $connector->jsonToActivity($input, $articleId); + } else { + error_log('NewContent::post No X-Sender header found'); + return false; + } + if ($newActivity === false) { + error_log('NewContent::post couldn\'t create newActivity'); + return false; + } + if (!isset($_user)) { + $user = $newActivity->getAActor(); // url of the sender https://contentnation.net/username + $posterName = str_replace( + $domain, + '', + $user + ); // retrieve only the last part of the url + } else { + $posterName = $dbh->real_escape_string($_user); + } + + $users = []; + + $receivers = array_merge($newActivity->getTo(), $newActivity->getCC()); + + // For Undo, the object may hold the proper to/cc + if ($newActivity->getType() === 'Undo') { + $object = $newActivity->getObject(); + if ($object !== null && is_object($object)) { + $receivers = array_merge($object->getTo(), $object->getCC()); + } + } + + // Filter out the public address and keep only actual URLs + $receivers = array_filter($receivers, static function (mixed $receiver): bool { + return is_string($receiver) + && $receiver !== 'https://www.w3.org/ns/activitystreams#Public' + && (filter_var($receiver, FILTER_VALIDATE_URL) !== false); + }); + + foreach ($receivers as $receiver) { + if ($receiver === '' || !is_string($receiver)) { + continue; + } + + if (str_ends_with($receiver, '/followers')) { + if ($posterName === null) { + error_log('NewContent::post no username found'); + continue; + } + try { + $followers = \Federator\DIO\Followers::getFollowersByUser($dbh, $posterName, $connector, $cache); + } catch (\Throwable $e) { + error_log('NewContent::post get followers for user: ' . $posterName . '. Exception: ' + . $e->getMessage()); + continue; + } + + if (is_array($followers)) { + $users = array_merge($users, array_column($followers, 'id')); + } + } else { + // check if receiver is an actor url and not from our domain + if (str_contains($receiver, $domain)) { + continue; + } + $receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? '')); + $domain = parse_url($receiver, PHP_URL_HOST); + if ($receiverName === null || $domain === null) { + if ($receiver === $posterName) { + continue; + } + error_log('NewContent::post no receiverName or domain found for receiver: ' . $receiver); + continue; + } + $receiver = $receiverName . '@' . $domain; + try { + $user = \Federator\DIO\FedUser::getUserByName( + $dbh, + $receiver, + $cache + ); + } catch (\Throwable $e) { + error_log('NewContent::post get user by name: ' . $receiver . '. Exception: ' . $e->getMessage()); + continue; + } + if ($user === null || $user->id === null) { + error_log('NewContent::post couldn\'t find user: ' . $receiver); + continue; + } + $users[] = $user->id; + } + } + + $users = array_unique($users); // remove duplicates + + if (empty($users)) { // todo remove after proper implementation, debugging for now + $rootDir = PROJECT_ROOT . '/'; + // Save the raw input and parsed JSON to a file for inspection + file_put_contents( + $rootDir . 'logs/newContent.log', + date('Y-m-d H:i:s') . ": ==== POST NewContent Activity ====\n" + . json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n", + FILE_APPEND + ); + } + + foreach ($users as $receiver) { + if (!isset($receiver)) { + continue; + } + + $token = \Resque::enqueue('inbox', 'Federator\\Jobs\\NewContentJob', [ + 'user' => $posterName, + 'recipientId' => $receiver, + 'activity' => $newActivity->toObject(), + 'articleId' => $articleId, + ]); + error_log('Inbox::post enqueued job for receiver: ' . $receiver . ' with token: ' . $token); + } + + return json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + } + + /** + * handle post call for specific user + * + * @param \mysqli $dbh @unused-param + * database handle + * @param \Federator\Connector\Connector $connector + * connector to fetch use with + * @param \Federator\Cache\Cache|null $cache + * optional caching service + * @param string $host host url of our server (e.g. https://federator.com) + * @param string $_user user that triggered the post + * @param string $_recipientId recipient of the post + * @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) + * @return boolean response + */ + public static function postForUser($dbh, $connector, $cache, $host, $_user, $_recipientId, $newActivity, $articleId) + { + if (!isset($_user)) { + error_log('NewContent::postForUser no user given'); + return false; + } + + // get sender + $user = \Federator\DIO\User::getUserByName( + $dbh, + $_user, + $connector, + $cache + ); + if ($user === null || $user->id === null) { + error_log('NewContent::postForUser couldn\'t find user: ' . $_user); + return false; + } + + // get recipient + $recipient = \Federator\DIO\FedUser::getUserByName( + $dbh, + $_recipientId, + $cache + ); + if ($recipient === null || $recipient->id === null) { + error_log('NewContent::postForUser couldn\'t find user: ' . $_recipientId); + return false; + } + + $rootDir = PROJECT_ROOT . '/'; + // Save the raw input and parsed JSON to a file for inspection + file_put_contents( + $rootDir . 'logs/newcontent_' . $recipient->id . '.log', + date('Y-m-d H:i:s') . ": ==== POST " . $recipient->id . " NewContent Activity ====\n" + . json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n", + FILE_APPEND + ); + + $type = strtolower($newActivity->getType()); + + switch ($type) { + case 'follow': + // $success = false; + $actor = $newActivity->getAActor(); + if ($actor !== '') { + $newIdUrl = \Federator\DIO\Followers::generateNewFollowId($dbh, $host); + $newActivity->setID($newIdUrl); + /*if (is_string($followerDomain)) { + $followerId = "{$followerUsername}@{$followerDomain}"; + $success = \Federator\DIO\Followers::sendFollowRequest($dbh, $connector, $cache, $user->id, + $followerId, $followerDomain); + }*/ + } + /* if ($success === false) { + error_log("NewContent::postForUser Failed to add follower for user $user->id"); + } */ + break; + + case 'delete': + // Delete Note/Post + $object = $newActivity->getObject(); + if (is_string($object)) { + \Federator\DIO\Posts::deletePost($dbh, $object); + } elseif (is_object($object)) { + $objectId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $objectId); + } + break; + + case 'undo': + $object = $newActivity->getObject(); + if (is_object($object)) { + switch (strtolower($object->getType())) { + case 'follow': + $success = false; + if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) { + $actor = $object->getAActor(); + if ($actor !== '') { + $followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? '')); + $followerDomain = parse_url($actor, PHP_URL_HOST); + if (is_string($followerDomain)) { + $followerId = $followerUsername . '@' . $followerDomain; + $removedId = \Federator\DIO\Followers::removeFollow( + $dbh, + $followerId, + $user->id + ); + if ($removedId !== false) { + $object->setID($removedId); + $newActivity->setObject($object); + $success = true; + } else { + error_log('NewContent::postForUser Failed to remove follow for user ' + . $user->id); + } + } + } + } + if ($success === false) { + error_log('NewContent::postForUser Failed to remove follower for user ' . $user->id); + } + break; + case 'like': + case 'dislike': + if (method_exists($object, 'getObject')) { + $targetId = $object->getObject(); + if (is_string($targetId)) { + \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId); + } else { + error_log('NewContent::postForUser Error in Undo Like/Dislike for user ' . $user->id + . ', targetId is not a string'); + } + } + break; + case 'note': + // Undo Note (remove note) + $noteId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $noteId); + break; + case 'article': + $articleId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $articleId); + // also remove latest saved article-update + \Federator\DIO\Posts::deletePost($dbh, $articleId . '#update'); + + // Undo Article (remove article) + $idPart = strrchr($recipient->id, '@'); + if ($idPart === false) { + error_log('NewContent::postForUser Error in Undo Article. ' . $recipient->id + . ', recipient ID is not valid'); + return false; + } else { + $targetUrl = ltrim($idPart, '@'); + + if ($object instanceof \Federator\Data\ActivityPub\Common\Article) { + $object = \Federator\DIO\Article::conditionalConvertToNote($object, $targetUrl); + $newActivity->setObject($object); + } else { + error_log('NewContent::postForUser Error in Undo Article for recipient ' + . $recipient->id . ', object is not an Article'); + } + } + + break; + } + } elseif (is_string($object)) { + \Federator\DIO\Posts::deletePost($dbh, $object); + } else { + error_log('NewContent::postForUser Error in Undo for recipient ' . $recipient->id + . ', object is not a string or object'); + } + break; + + case 'like': + case 'dislike': + // Add Like/Dislike + $targetId = $newActivity->getObject(); + if (is_string($targetId)) { + \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, $type); + // \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId); + } else { + error_log('NewContent::postForUser Error in Add Like/Dislike for recipient ' . $recipient->id + . ', targetId is not a string'); + return false; + } + break; + + case 'create': + case 'update': + $object = $newActivity->getObject(); + if (is_object($object)) { + switch (strtolower($object->getType())) { + case 'note': + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId); + + break; + case 'article': + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId); + + $idPart = strrchr($recipient->id, '@'); + if ($idPart === false) { + error_log('NewContent::postForUser Error in Create/Update Article. ' . $recipient->id + . ', recipient ID is not valid'); + return false; + } else { + $targetUrl = ltrim($idPart, '@'); + + if ($object instanceof \Federator\Data\ActivityPub\Common\Article) { + $object = \Federator\DIO\Article::conditionalConvertToNote($object, $targetUrl); + $newActivity->setObject($object); + } else { + error_log('NewContent::postForUser Error in Create/Update Article for recipient ' + . $recipient->id . ', object is not an Article'); + } + } + + break; + default: + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId); + break; + } + } + // Post Note + break; + + default: + error_log('NewContent::postForUser Unhandled activity type $type for user ' . $user->id); + break; + } + + try { + $response = self::sendActivity($dbh, $host, $user, $recipient, $newActivity); + } catch (\Exception $e) { + error_log('NewContent::postForUser Failed to send activity: ' . $e->getMessage()); + return false; + } + if (empty($response)) { + error_log('NewContent::postForUser Sent activity to ' . $recipient->id); + } else { + error_log('NewContent::postForUser Sent activity to ' . $recipient->id . ' with response: ' + . json_encode($response, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + + return true; + } + + /** + * send activity to federated server + * + * @param \mysqli $dbh database handle + * @param string $host host url of our server (e.g. federator) + * @param \Federator\Data\User $sender source user + * @param \Federator\Data\FedUser $target federated target user + * @param \Federator\Data\ActivityPub\Common\Activity $activity activity to send + * @return string|true the generated follow ID on success, false on failure + */ + public static function sendActivity($dbh, $host, $sender, $target, $activity) + { + if ($dbh === false) { + throw new \Federator\Exceptions\ServerError('NewContent::sendActivity Failed to get database handle'); + } + + $inboxUrl = $target->inboxURL; + + $json = json_encode($activity, JSON_UNESCAPED_SLASHES); + + if ($json === false) { + throw new \Exception('Failed to encode JSON: ' . json_last_error_msg()); + } + $digest = 'SHA-256=' . base64_encode(hash('sha256', $json, true)); + $date = gmdate('D, d M Y H:i:s') . ' GMT'; + $parsed = parse_url($inboxUrl); + if ($parsed === false) { + throw new \Exception('Failed to parse URL: ' . $inboxUrl); + } + + if (!isset($parsed['host']) || !isset($parsed['path'])) { + throw new \Exception('Invalid inbox URL: missing host or path'); + } + $extHost = $parsed['host']; + $path = $parsed['path']; + + // Build the signature string + $signatureString = "(request-target): post {$path}\n" . + "host: {$extHost}\n" . + "date: {$date}\n" . + "digest: {$digest}"; + + // Get rsa private key + $privateKey = \Federator\DIO\User::getrsaprivate($dbh, $sender->id); // OR from DB + if ($privateKey === false) { + throw new \Exception('Failed to get private key'); + } + $pkeyId = openssl_pkey_get_private($privateKey); + + if ($pkeyId === false) { + throw new \Exception('Invalid private key'); + } + + openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256); + $signature_b64 = base64_encode($signature); + + // Build keyId (public key ID from your actor object) + $keyId = $host . '/' . $sender->id . '#main-key'; + + $signatureHeader = 'keyId="' . $keyId + . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; + + $ch = curl_init($inboxUrl); + if ($ch === false) { + throw new \Exception('Failed to initialize cURL'); + } + $headers = [ + 'Host: ' . $extHost, + 'Date: ' . $date, + 'Digest: ' . $digest, + 'Content-Type: application/activity+json', + 'Signature: ' . $signatureHeader, + 'Accept: application/activity+json', + ]; + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + $response = curl_exec($ch); + curl_close($ch); + + if ($response === false) { + throw new \Exception('Failed to send activity: ' . curl_error($ch)); + } else { + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($httpcode != 200 && $httpcode != 202) { + throw new \Exception('Unexpected HTTP code ' . $httpcode . ':' . $response); + } + } + return $response; + } + + /** + * get internal represenation as json string + * @return string json string or html + */ + public function toJson() + { + return $this->response; + } +} diff --git a/php/federator/api/wellknown.php b/php/federator/api/wellknown.php index 6b1e76d..74e968b 100644 --- a/php/federator/api/wellknown.php +++ b/php/federator/api/wellknown.php @@ -44,8 +44,10 @@ class WellKnown implements APIInterface */ private function hostMeta() { + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; $data = [ - 'fqdn' => $_SERVER['SERVER_NAME'] + 'fqdn' => $domain ]; $this->response = $this->main->renderTemplate('host-meta.xml', $data); return true; @@ -59,7 +61,7 @@ class WellKnown implements APIInterface */ public function exec($paths, $user) { - $method = $_SERVER["REQUEST_METHOD"]; + $method = $_SERVER['REQUEST_METHOD']; switch ($method) { case 'GET': switch (sizeof($paths)) { diff --git a/php/federator/api/wellknown/nodeinfo.php b/php/federator/api/wellknown/nodeinfo.php index 0d5b251..37919a6 100644 --- a/php/federator/api/wellknown/nodeinfo.php +++ b/php/federator/api/wellknown/nodeinfo.php @@ -45,8 +45,10 @@ class NodeInfo */ public function exec($paths) { + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; $data = [ - 'fqdn' => $_SERVER['SERVER_NAME'] + 'fqdn' => $domain ]; $template = null; if (sizeof($paths) == 2 && $paths[0] === '.well-known' && $paths[1] === 'nodeinfo') { @@ -65,7 +67,6 @@ class NodeInfo $template = 'nodeinfo2.0.json'; } $stats = \Federator\DIO\Stats::getStats($this->main); - echo "fetch usercount via connector\n"; $data['usercount'] = $stats->userCount; $data['postcount'] = $stats->postCount; $data['commentcount'] = $stats->commentCount; diff --git a/php/federator/cache/cache.php b/php/federator/cache/cache.php index 3e692ef..f9ba480 100644 --- a/php/federator/cache/cache.php +++ b/php/federator/cache/cache.php @@ -13,14 +13,35 @@ namespace Federator\Cache; */ interface Cache extends \Federator\Connector\Connector { + /** + * save remote followers of user + * + * @param string $user user name + * @param \Federator\Data\FedUser[]|false $followers user followers + * @return void + */ + public function saveRemoteFollowersOfUser($user, $followers); + + /** + * save remote following for user + * + * @param string $user user name + * @param \Federator\Data\FedUser[]|false $following user following + * @return void + */ + public function saveRemoteFollowingForUser($user, $following); + /** * save remote posts by user * * @param string $user user name + * @param int $min min timestamp + * @param int $max max timestamp + * @param int $limit limit results * @param \Federator\Data\ActivityPub\Common\APObject[]|false $posts user posts * @return void */ - public function saveRemotePostsByUser($user, $posts); + public function saveRemotePostsByUser($user, $min, $max, $limit, $posts); /** * save remote stats @@ -39,6 +60,15 @@ interface Cache extends \Federator\Connector\Connector */ public function saveRemoteUserByName($_name, $user); + /** + * save remote federation user by given name + * + * @param string $_name user/profile name + * @param \Federator\Data\FedUser $user user data + * @return void + */ + public function saveRemoteFedUserByName(string $_name, \Federator\Data\FedUser $user); + /** * save remote user by given session * @@ -48,4 +78,29 @@ interface Cache extends \Federator\Connector\Connector * @return void */ public function saveRemoteUserBySession($_session, $_user, $user); + + /** + * Save the public key for a given keyId + * + * @param string $keyId The keyId (e.g., actor URL + #main-key) + * @param string $publicKeyPem The public key PEM to cache + * @return void + */ + public function savePublicKey(string $keyId, string $publicKeyPem); + + /** + * get remote federation user by given name + * + * @param string $_name user/profile name + * @return \Federator\Data\FedUser | false + */ + public function getRemoteFedUserByName(string $_name); + + /** + * Retrieve the public key for a given keyId + * + * @param string $keyId The keyId (e.g., actor URL + #main-key) + * @return string|false The cached public key PEM or false if not found + */ + public function getPublicKey(string $keyId); } diff --git a/php/federator/connector/connector.php b/php/federator/connector/connector.php index aab10e7..fc69bb6 100644 --- a/php/federator/connector/connector.php +++ b/php/federator/connector/connector.php @@ -13,16 +13,35 @@ namespace Federator\Connector; */ interface Connector { + /** + * get followers of given user + * + * @param string $id user id + + * @return \Federator\Data\FedUser[]|false + */ + public function getRemoteFollowersOfUser($id); + + /** + * get following of given user + * + * @param string $id user id + + * @return \Federator\Data\FedUser[]|false + */ + public function getRemoteFollowingForUser($id); + /** * get posts by given user * * @param string $id user id - * @param string $minId min ID - * @param string $maxId max ID + * @param int $min min value + * @param int $max max value + * @param int $limit maximum number of results - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @return \Federator\Data\ActivityPub\Common\Activity[]|false */ - public function getRemotePostsByUser($id, $minId, $maxId); + public function getRemotePostsByUser($id, $min, $max, $limit); /** * get remote user by given name @@ -47,4 +66,32 @@ interface Connector * @return \Federator\Data\Stats|false */ public function getRemoteStats(); + + /** + * Convert jsonData to Activity format + * + * @param array $jsonData the json data from our platfrom + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) + * @return \Federator\Data\ActivityPub\Common\Activity|false + */ + public function jsonToActivity(array $jsonData, &$articleId); + + /** + * send target-friendly json from ActivityPub activity + * + * @param \Federator\Data\FedUser $sender the user of the sender + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity + * @return boolean did we successfully send the activity? + */ + public function sendActivity($sender, $activity); + + /** + * 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); } diff --git a/php/federator/data/activitypub/common/Undo.php b/php/federator/data/activitypub/common/Undo.php new file mode 100644 index 0000000..5a42c49 --- /dev/null +++ b/php/federator/data/activitypub/common/Undo.php @@ -0,0 +1,41 @@ + + */ + public function toObject() + { + $return = parent::toObject(); + $return['type'] = 'Undo'; + 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/common/accept.php b/php/federator/data/activitypub/common/accept.php new file mode 100644 index 0000000..ec395ff --- /dev/null +++ b/php/federator/data/activitypub/common/accept.php @@ -0,0 +1,18 @@ +aactor; } @@ -74,6 +74,7 @@ class Activity extends APObject { if (array_key_exists('actor', $json)) { $this->actor = $json['actor']; + $this->aactor = $json['actor']; unset($json['actor']); } if (!parent::fromJson($json)) { @@ -102,7 +103,7 @@ class Activity extends APObject /** * get Child Object * - * @return APObject|null + * @return APObject|string|null */ public function getObject() { diff --git a/php/federator/data/activitypub/common/announce.php b/php/federator/data/activitypub/common/announce.php new file mode 100644 index 0000000..f3b5035 --- /dev/null +++ b/php/federator/data/activitypub/common/announce.php @@ -0,0 +1,41 @@ + + */ + public function toObject() + { + $return = parent::toObject(); + $return['type'] = 'Announce'; + 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/common/apobject.php b/php/federator/data/activitypub/common/apobject.php index 735f3d6..d04f3e7 100644 --- a/php/federator/data/activitypub/common/apobject.php +++ b/php/federator/data/activitypub/common/apobject.php @@ -27,7 +27,7 @@ class APObject implements \JsonSerializable /** * child object * - * @var APObject|null $object + * @var APObject|string|null $object */ private $object = null; @@ -347,7 +347,7 @@ class APObject implements \JsonSerializable /** * set child object * - * @param APObject $object + * @param APObject|string $object * @return void */ public function setObject($object) @@ -358,7 +358,7 @@ class APObject implements \JsonSerializable /** * get child object * - * @return APObject|null child object + * @return APObject|string|null child object */ public function getObject() { @@ -376,6 +376,17 @@ class APObject implements \JsonSerializable $this->summary = $summary; return $this; } + + /** + * get summary + * + * @return string summary + */ + public function getSummary() + { + return $this->summary; + } + /** * set type * @@ -459,6 +470,16 @@ class APObject implements \JsonSerializable return $this; } + /** + * get name + * + * @return string name + */ + public function getName() : string + { + return $this->name; + } + /** * add Image * @@ -647,7 +668,7 @@ class APObject implements \JsonSerializable if (array_key_exists('duration', $json)) { try { $this->duration = new \DateInterval($json['duration']); - } catch (\Exception $unused_e) { + } catch (\Throwable $unused_e) { error_log("error parsing duration ". $json['duration']); } } @@ -750,8 +771,8 @@ class APObject implements \JsonSerializable if (array_key_exists('mediaType', $json)) { $this->mediaType = $json['mediaType']; } - if (array_key_exists('object', $json)) { - $this->object = \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], ""); + if (array_key_exists('object', $json)) { // some actPub servers send strings in the object field + $this->object = is_array($json['object']) ? \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], "") : $json['object']; } if (array_key_exists('sensitive', $json)) { $this->sensitive = $json['sensitive']; @@ -773,7 +794,7 @@ class APObject implements \JsonSerializable * {@inheritDoc} * @see JsonSerializable::jsonSerialize() */ - public function jsonSerialize() + public function jsonSerialize(): mixed { return $this->toObject(); } @@ -875,7 +896,7 @@ class APObject implements \JsonSerializable $return['tag'] = $tags; } if ($this->updated > 0) { - $return['updated'] = gmdate("Y-m-d\TH:i:S\Z", $this->updated); + $return['updated'] = gmdate("Y-m-d\TH:i:s\Z", $this->updated); } if ($this->url !== '') { $return['url'] = $this->url; @@ -890,7 +911,7 @@ class APObject implements \JsonSerializable $return['mediaType'] = $this->mediaType; } if ($this->object !== null) { - $return['object'] = $this->object->toObject(); + $return['object'] = is_string($this->object) ? $this->object : $this->object->toObject(); } if ($this->atomURI !== '') { $return['atomUri'] = $this->atomURI; diff --git a/php/federator/data/activitypub/common/collection.php b/php/federator/data/activitypub/common/collection.php index 644b52e..14b3a7c 100644 --- a/php/federator/data/activitypub/common/collection.php +++ b/php/federator/data/activitypub/common/collection.php @@ -48,20 +48,48 @@ class Collection extends APObject */ public function fromJson($json) { - return parent::fromJson($json); + $success = parent::fromJson($json); + if (!$success) { + return false; + } + if (array_key_exists('totalItems', $json)) { + $this->totalItems = $json['totalItems']; + } + if (array_key_exists('first', $json)) { + $this->first = $json['first']; + } + if (array_key_exists('last', $json)) { + $this->last = $json['last']; + } + return true; } - public function count() : int + /** + * set total items + * + * @param int $totalItems total items + */ + public function setTotalItems(int $totalItems): void + { + $this->totalItems = $totalItems; + } + + public function getTotalItems(): int { return $this->totalItems; } - public function setFirst(string $url) : void + public function getFirst(): string + { + return $this->first; + } + + public function setFirst(string $url): void { $this->first = $url; } - public function setLast(string $url) : void + public function setLast(string $url): void { $this->last = $url; } 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/delete.php b/php/federator/data/activitypub/common/delete.php new file mode 100644 index 0000000..aa23167 --- /dev/null +++ b/php/federator/data/activitypub/common/delete.php @@ -0,0 +1,36 @@ + + */ + public function toObject() + { + $return = parent::toObject(); + return $return; + } +} diff --git a/php/federator/data/activitypub/common/dislike.php b/php/federator/data/activitypub/common/dislike.php new file mode 100644 index 0000000..2b185e9 --- /dev/null +++ b/php/federator/data/activitypub/common/dislike.php @@ -0,0 +1,18 @@ +object = $object; + } + + public function getObject(): string + { + return $this->object; + } + + public function __construct() + { + parent::__construct("Follow"); + parent::addContext('https://www.w3.org/ns/activitystreams'); + } + + public function fromJson($json): bool + { + if (array_key_exists('object', $json)) { + $this->object = $json['object']; + unset($json['object']); + } + return parent::fromJson($json); + } + /** + * convert internal state to php array + * @return array + */ + public function toObject() + { + $return = parent::toObject(); + if ($this->object !== "") { + $return['object'] = $this->object; + } + return $return; + } +} diff --git a/php/federator/data/activitypub/common/followers.php b/php/federator/data/activitypub/common/followers.php new file mode 100644 index 0000000..a2f3116 --- /dev/null +++ b/php/federator/data/activitypub/common/followers.php @@ -0,0 +1,53 @@ +orderedItems = $items; + $this->totalItems = sizeof($items); + } + + /** + * 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/common/following.php b/php/federator/data/activitypub/common/following.php new file mode 100644 index 0000000..ea57e9c --- /dev/null +++ b/php/federator/data/activitypub/common/following.php @@ -0,0 +1,53 @@ +orderedItems = $items; + $this->totalItems = sizeof($items); + } + + /** + * 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/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/common/like.php b/php/federator/data/activitypub/common/like.php new file mode 100644 index 0000000..a62b366 --- /dev/null +++ b/php/federator/data/activitypub/common/like.php @@ -0,0 +1,18 @@ +totalItems > 0) { - foreach ($this->items as $item) { - $return['OrderedItems'][] = $item->toObject(); + if (sizeof($this->orderedItems) > 0) { + foreach ($this->orderedItems as $item) { + if (is_string($item)) { + $return['orderedItems'][] = $item; + } elseif (is_object($item)) { + $return['orderedItems'][] = $item->toObject(); + } } } return $return; @@ -48,44 +52,63 @@ class OrderedCollection extends Collection */ public function fromJson($json) { - return parent::fromJson($json); + $success = parent::fromJson($json); + if (!$success) { + return false; + } + if (array_key_exists('orderedItems', $json)) { + foreach ($json['orderedItems'] as $item) { + $obj = \Federator\Data\ActivityPub\Factory::newActivityFromJson($item); + if ($obj !== false) { + $this->orderedItems[] = $obj; + } + } + } + + return true; } - public function append(APObject &$item) : void + /** + * add item to collection + * @param APObject|string $item + */ + public function append(&$item): void { - $this->items[] = $item; - $this->totalItems = sizeof($this->items); + $this->orderedItems[] = $item; } /** * get item with given index * - * @return APObject|false + * @return APObject|string|false */ public function get(int $index) { if ($index >= 0) { - if ($index >= $this->totalItems) { + if ($index >= sizeof($this->orderedItems)) { return false; } - return $this->items[$index]; + return $this->orderedItems[$index]; } else { - if ($this->totalItems+ $index < 0) { + if (sizeof($this->orderedItems) + $index < 0) { return false; } - return $this->items[$this->totalItems + $index]; + return $this->orderedItems[sizeof($this->orderedItems) + $index]; } } + public function getCount(): int + { + return sizeof($this->orderedItems); + } /** * set items * - * @param APObject[] $items + * @param APObject[]|string[] $items * @return void */ public function setItems(&$items) { - $this->items = $items; - $this->totalItems = sizeof($items); + $this->orderedItems = $items; } } diff --git a/php/federator/data/activitypub/common/orderedcollectionpage.php b/php/federator/data/activitypub/common/orderedcollectionpage.php index 3d928ff..3bfbe86 100644 --- a/php/federator/data/activitypub/common/orderedcollectionpage.php +++ b/php/federator/data/activitypub/common/orderedcollectionpage.php @@ -19,6 +19,7 @@ class OrderedCollectionPage extends OrderedCollection { parent::__construct(); parent::addContext('https://www.w3.org/ns/activitystreams'); + $this->setType('OrderedCollectionPage'); } /** @@ -38,7 +39,7 @@ class OrderedCollectionPage extends OrderedCollection if ($this->partOf !== '') { $return['partOf'] = $this->partOf; } - $return['type'] = 'OrderedCollectionPage'; + $return['type'] = $this->getType(); return $return; } @@ -50,7 +51,29 @@ class OrderedCollectionPage extends OrderedCollection */ public function fromJson($json) { - return parent::fromJson($json); + $success = parent::fromJson($json); + if (!$success) { + return false; + } + if (array_key_exists('next', $json)) { + $this->next = $json['next']; + } + if (array_key_exists('prev', $json)) { + $this->prev = $json['prev']; + } + if (array_key_exists('partOf', $json)) { + $this->partOf = $json['partOf']; + } + return true; + } + + /** + * get next url + * @return string next URL + */ + public function getNext() + { + return $this->next; } /** diff --git a/php/federator/data/activitypub/common/outbox.php b/php/federator/data/activitypub/common/outbox.php index 4439ebe..6c51622 100644 --- a/php/federator/data/activitypub/common/outbox.php +++ b/php/federator/data/activitypub/common/outbox.php @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * @author Sascha Nitsch (grumpydeveloper) + * @author Yannis Vogel (vogeldevelopment) **/ namespace Federator\Data\ActivityPub\Common; @@ -26,6 +27,17 @@ class Outbox extends OrderedCollectionPage ]); } + /** + * set items + * + * @param \Federator\Data\ActivityPub\Common\APObject[] $items the items in the collection + */ + public function setItems(&$items) + { + // Optionally: type check that all $items are Activity objects + $this->orderedItems = $items; + } + /** * convert internal state to php array * diff --git a/php/federator/data/activitypub/common/reject.php b/php/federator/data/activitypub/common/reject.php new file mode 100644 index 0000000..fb7b1ab --- /dev/null +++ b/php/federator/data/activitypub/common/reject.php @@ -0,0 +1,18 @@ + Document + [mediaType] => image/jpeg + [url] => https://noc.social/system/media_attachments/files/112/350/286/131/419/396/original/26ab9c8a4ab13f16.jpg + [name] => Screenshot Zeitleiste aus kdenlive. + [blurhash] => UC8|^tSwI-bu-taeRiaeu5e.aJjGsBWnR*jH + [focalPoint] => Array + ( + [0] => -0.01 + [1] => -0.79 + ) + + [width] => 1333 + [height] => 651 +) diff --git a/php/federator/data/activitypub/common/update.php b/php/federator/data/activitypub/common/update.php new file mode 100644 index 0000000..5585357 --- /dev/null +++ b/php/federator/data/activitypub/common/update.php @@ -0,0 +1,45 @@ + + */ + public function toObject() + { + $return = parent::toObject(); + $return['type'] = 'Update'; + // overwrite id from url + if ($this->getURL() !== '') { + $return['id'] = $this->getURL(); + } + 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/common/vote.php b/php/federator/data/activitypub/common/vote.php new file mode 100644 index 0000000..f29ef1c --- /dev/null +++ b/php/federator/data/activitypub/common/vote.php @@ -0,0 +1,27 @@ + $json input json + * @param array|mixed $json input json * @return Common\APObject|null object or false on error */ public static function newFromJson($json, string $jsonstring) @@ -31,6 +31,9 @@ class Factory } $return = null; switch ($json['type']) { + case 'Announce': + $return = new Common\Announce(); + break; case 'Article': $return = new Common\Article(); break; @@ -39,21 +42,45 @@ class Factory break; case 'Event': $return = new Common\Event(); - break; + break;*/ case 'Follow': $return = new Common\Follow(); - break;*/ + break; 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 'Vote': + $return = new Common\Vote(); + break; + case 'Like': + $return = new Common\Like(); + break; + case 'Dislike': + $return = new Common\Dislike(); + break; + case 'Inbox': + $return = new Common\Inbox(); + break; + case 'OrderedCollection': + $return = new Common\OrderedCollection(); + break; + case 'OrderedCollectionPage': + $return = new Common\OrderedCollectionPage(); + break; + case 'Tombstone': + $return = new Common\APObject("Tombstone"); + 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,9 +104,7 @@ class Factory } //$return = false; switch ($json['type']) { - case 'MakePhanHappy': - break; -/* case 'Accept': + case 'Accept': $return = new Common\Accept(); break; case 'Announce': @@ -91,18 +116,30 @@ class Factory case 'Delete': $return = new Common\Delete(); break; + case 'Like': + $return = new Common\Like(); + break; + case 'Dislike': + $return = new Common\Dislike(); + break; case 'Follow': $return = new Common\Follow(); break; + case 'Reject': + $return = new Common\Reject(); + break; case 'Undo': - $return = new \Common\Undo(); - break;*/ + $return = new Common\Undo(); + break; + case 'Update': + $return = new Common\Update(); + break; default: - error_log("newActivityFromJson " . print_r($json, true)); + error_log("newActivityFromJson unsupported type: " . 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/data/feduser.php b/php/federator/data/feduser.php new file mode 100644 index 0000000..48c119a --- /dev/null +++ b/php/federator/data/feduser.php @@ -0,0 +1,151 @@ +id = $data['id'] ?? ''; + $user->actorURL = $data['actorURL'] ?? ''; + $user->name = $data['name'] ?? ''; + $user->publicKey = $data['publicKey'] ?? ''; + $user->summary = $data['summary'] ?? ''; + $user->type = $data['type'] ?? 'Person'; + $user->inboxURL = $data['inbox'] ?? ''; + $user->sharedInboxURL = $data['sharedInbox'] ?? ''; + $user->followersURL = $data['followers'] ?? ''; + $user->followingURL = $data['following'] ?? ''; + $user->publicKeyId = $data['publicKeyId'] ?? ''; + $user->outboxURL = $data['outbox'] ?? ''; + return $user; + } + + /** + * convert internal data to json string + * + * @return string + */ + public function toJson() + { + $data = [ + 'id' => $this->id, + 'actorURL' => $this->actorURL, + 'name' => $this->name, + 'publicKey' => $this->publicKey, + 'summary' => $this->summary, + 'type' => $this->type, + 'inbox' => $this->inboxURL, + 'sharedInbox' => $this->sharedInboxURL, + 'followers' => $this->followersURL, + 'following' => $this->followingURL, + 'publicKeyId' => $this->publicKeyId, + 'outbox' => $this->outboxURL, + ]; + return json_encode($data) ?: ''; + } +} diff --git a/php/federator/dio/article.php b/php/federator/dio/article.php new file mode 100644 index 0000000..e1f408c --- /dev/null +++ b/php/federator/dio/article.php @@ -0,0 +1,67 @@ +setId($article->getId()) + ->setURL($article->getURL()); + $note->setContent($article->getContent()); + $note->setSummary($article->getSummary()); + $note->setPublished($article->getPublished()); + $note->setName($article->getName()); + $note->setAttributedTo($article->getAttributedTo()); + foreach ($article->getTo() as $to) { + $note->addTo($to); + } + foreach ($article->getCc() as $cc) { + $note->addCc($cc); + } + return $note; + } + + /** Conditionally convert article to a note + * + * @param \Federator\Data\ActivityPub\Common\Article $article + * @param string $targetUrl + * The target URL for the activity (e.g. mastodon.social) + * @return \Federator\Data\ActivityPub\Common\Note|\Federator\Data\ActivityPub\Common\Article + * The generated note on success, false on failure + */ + public static function conditionalConvertToNote($article, $targetUrl) + { + $supportFile = file_get_contents(PROJECT_ROOT . '/formatsupport.json'); + if ($supportFile === false) { + error_log("Article::conditionalConvertToNote Failed to read support file for article conversion."); + return $article; // Fallback to original article if file read fails + } + $supportlist = json_decode($supportFile, true); + + if (!isset($supportlist['activitypub']['article']) || + !is_array($supportlist['activitypub']['article']) || + !in_array($targetUrl, $supportlist['activitypub']['article'], true) + ) { + return self::convertToNote($article); // Articles are not supported for this target + } + return $article; // Articles are supported, return as is + } +} diff --git a/php/federator/dio/feduser.php b/php/federator/dio/feduser.php new file mode 100644 index 0000000..3058cbc --- /dev/null +++ b/php/federator/dio/feduser.php @@ -0,0 +1,248 @@ +prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('FedUser::addLocalUser Failed to prepare statement'); + } + $stmt->bind_param("s", $_user); + $validuntil = 0; + $ret = $stmt->bind_result($validuntil); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + if ($validuntil == 0) { + $sql = 'insert into fedusers (id, url, name, publickey, summary, type, inboxurl, sharedinboxurl,'; + $sql .= ' followersurl, followingurl, publickeyid, outboxurl, validuntil)'; + $sql .= ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now() + interval 1 day)'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('FedUser::addLocalUser Failed to prepare create statement'); + } + $stmt->bind_param( + 'ssssssssssss', + $_user, + $user->actorURL, + $user->name, + $user->publicKey, + $user->summary, + $user->type, + $user->inboxURL, + $user->sharedInboxURL, + $user->followersURL, + $user->followingURL, + $user->publicKeyId, + $user->outboxURL + ); + } else { + // update to existing user + $sql = 'update fedusers set validuntil=now() + interval 1 day, url=?, name=?, publickey=?, summary=?,'; + $sql .= ' type=?, inboxurl=?, sharedinboxurl=?, followersurl=?, followingurl=?, publickeyid=?, outboxurl=?'; + $sql .= ' where id=?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('FedUser::extendUser Failed to prepare update statement'); + } + $stmt->bind_param( + 'ssssssssssss', + $user->actorURL, + $user->name, + $user->publicKey, + $user->summary, + $user->type, + $user->inboxURL, + $user->sharedInboxURL, + $user->followersURL, + $user->followingURL, + $user->publicKeyId, + $user->outboxURL, + $_user + ); + } + try { + $stmt->execute(); + $stmt->close(); + $user->id = $_user; + } catch (\mysqli_sql_exception $e) { + error_log($sql); + error_log(print_r($user, true)); + error_log($e->getMessage()); + } + } + + /** + * extend the given user with internal data + * @param \mysqli $dbh database handle + * @param \Federator\Data\FedUser $user user to extend + * @param string $_user user/profile name + */ + protected static function extendUser(\mysqli $dbh, \Federator\Data\FedUser $user, $_user): void + { + $sql = 'select id,unix_timestamp(`validuntil`) from fedusers where id=?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('FedUser::extendUser Failed to prepare statement'); + } + $stmt->bind_param('s', $_user); + $validuntil = 0; + $ret = $stmt->bind_result($user->id, $validuntil); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + // if a new user, create own database entry with additionally needed info + if ($user->id === null || $validuntil < time()) { + self::addLocalUser($dbh, $user, $_user); + } + + // no further processing for now + } + + /** + * get user by name + * + * @param \mysqli $dbh + * database handle + * @param string $_name + * user name + * @param \Federator\Cache\Cache|null $cache + * optional caching service + * @return \Federator\Data\FedUser + */ + public static function getUserByName($dbh, $_name, $cache) + { + $user = false; + + // ask cache + if ($cache !== null) { + $user = $cache->getRemoteFedUserByName($_name); + } + if ($user !== false) { + return $user; + } + // check our db + $sql = 'select `id`, `url`, `name`, `publickey`, `summary`, `type`, `inboxurl`, `sharedinboxurl`, '; + $sql .= '`followersurl`, `followingurl`, `publickeyid`, `outboxurl`'; + $sql .= ' from fedusers where `id`=? and `validuntil`>=now()'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('FedUser::getUserByName Failed to prepare statement'); + } + $stmt->bind_param('s', $_name); + $user = new \Federator\Data\FedUser(); + $ret = $stmt->bind_result( + $user->id, + $user->actorURL, + $user->name, + $user->publicKey, + $user->summary, + $user->type, + $user->inboxURL, + $user->sharedInboxURL, + $user->followersURL, + $user->followingURL, + $user->publicKeyId, + $user->outboxURL + ); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + + if ($user->id === null) { + // check if its a federated user with username@domain.ending + if (preg_match("/^([^@]+)@(.*)$/", $_name, $matches) == 1) { + // make webfinger request + $remoteURL = 'https://' . $matches[2] . '/.well-known/webfinger?resource=acct:' . urlencode($_name); + $headers = ['Accept: application/activity+json']; + [$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers); + if ($info['http_code'] != 200) { + throw new \Federator\Exceptions\ServerError('FedUser::getUserByName Failed to fetch webfinger for ' + . $_name); + } + $r = json_decode($response, true); + if ($r === false || $r === null || !is_array($r)) { + throw new \Federator\Exceptions\ServerError('FedUser::getUserByName Failed to decode webfinger for ' + . $_name); + } + // get the webwinger user url and fetch the user + if (isset($r['links'])) { + foreach ($r['links'] as $link) { + if (isset($link['rel']) && $link['rel'] === 'self') { + $remoteURL = $link['href']; + break; + } + } + } + if (!isset($remoteURL)) { + throw new \Federator\Exceptions\ServerError('FedUser::getUserByName Failed to find self link ' + . 'in webfinger for ' . $_name); + } + // fetch the user + $headers = ['Accept: application/activity+json']; + [$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers); + if ($info['http_code'] != 200) { + throw new \Federator\Exceptions\ServerError('FedUser::getUserByName Failed to fetch user from ' + . 'remoteUrl for ' . $_name); + } + $r = json_decode($response, true); + if ($r === false || $r === null || !is_array($r)) { + throw new \Federator\Exceptions\ServerError('FedUser::getUserByName Failed to decode user for ' + . $_name); + } + $r['publicKeyId'] = $r['publicKey']['id']; + $r['publicKey'] = $r['publicKey']['publicKeyPem']; + if (isset($r['endpoints'])) { + if (isset($r['endpoints']['sharedInbox'])) { + $r['sharedInbox'] = $r['endpoints']['sharedInbox']; + } + } + $r['actorURL'] = $remoteURL; + $data = json_encode($r); + if ($data === false) { + throw new \Federator\Exceptions\ServerError('FedUser::getUserByName Failed to encode userdata ' + . $_name); + } + $user = \Federator\Data\FedUser::createFromJson($data); + } + } + + if ($cache !== null && $user !== false) { + if ($user->id !== null && $user->actorURL !== null) { + self::addLocalUser($dbh, $user, $_name); + } + $cache->saveRemoteFedUserByName($_name, $user); + } + if ($user === false) { + throw new \Federator\Exceptions\ServerError('FedUser::getUserByName User not found'); + } + return $user; + } +} diff --git a/php/federator/dio/followers.php b/php/federator/dio/followers.php new file mode 100644 index 0000000..8925bb7 --- /dev/null +++ b/php/federator/dio/followers.php @@ -0,0 +1,531 @@ +getRemoteFollowersOfUser($id); + if ($followers !== false) { + return $followers; + } + } + $followers = []; + $sql = 'select source_user from follows where target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::getFollowersByUser Failed to prepare statement'); + } + $stmt->bind_param("s", $id); + $stmt->execute(); + $followerIds = []; + $stmt->bind_result($sourceUser); + while ($stmt->fetch()) { + $followerIds[] = $sourceUser; + } + $stmt->close(); + foreach ($followerIds as $followerId) { + try { + $user = \Federator\DIO\FedUser::getUserByName( + $dbh, + $followerId, + $cache, + ); + } catch (\Throwable $e) { + error_log('Followers::getFollowersByUser Exception: ' . $e->getMessage()); + continue; // Skip this user if an exception occurs + } + if ($user !== false && $user->id !== null) { + $followers[] = $user; + } + } + + if ($followers === []) { + // ask connector for user-id + $followers = $connector->getRemoteFollowersOfUser($id); + if ($followers === false) { + $followers = []; + } + } + // save followers to cache + if ($cache !== null) { + $cache->saveRemoteFollowersOfUser($id, $followers); + } + return $followers; + } + /** + * get following for user - who does the user follow + * + * @param \mysqli $dbh + * database handle + * @param string $id + * user id + * @param \Federator\Connector\Connector $connector + * connector to fetch use with + * @param \Federator\Cache\Cache|null $cache + * optional caching service + * @return \Federator\Data\FedUser[] + */ + + public static function getFollowingForUser($dbh, $id, $connector, $cache) + { + // ask cache + if ($cache !== null) { + $following = $cache->getRemoteFollowingForUser($id); + if ($following !== false) { + return $following; + } + } + $following = []; + $sql = 'select target_user from follows where source_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::getFollowingForUser Failed to prepare statement'); + } + $stmt->bind_param("s", $id); + $stmt->execute(); + $followingIds = []; + $stmt->bind_result($sourceUser); + while ($stmt->fetch()) { + $followingIds[] = $sourceUser; + } + $stmt->close(); + foreach ($followingIds as $followingId) { + try { + $user = \Federator\DIO\FedUser::getUserByName( + $dbh, + $followingId, + $cache, + ); + } catch (\Throwable $e) { + error_log('Followers::getFollowingForUser Exception: ' . $e->getMessage()); + continue; // Skip this user if an exception occurs + } + if ($user !== false && $user->id !== null) { + $following[] = $user; + } + } + + if ($following === []) { + // ask connector for user-id + $following = $connector->getRemoteFollowingForUser($id); + if ($following === false) { + $following = []; + } + } + // save posts to DB + if ($cache !== null) { + $cache->saveRemoteFollowingForUser($id, $following); + } + return $following; + } + + /** + * get followers of federated external user (e.g. mastodon) + * + * @param \mysqli $dbh + * database handle + * @param \Federator\Connector\Connector $connector + * connector to fetch use with + * @param \Federator\Cache\Cache|null $cache + * optional caching service + * @param string $id + * user id + * @return \Federator\Data\User[] + */ + + public static function getFollowersByFedUser($dbh, $connector, $cache, $id) + { + $followers = []; + + $sql = 'select source_user from follows where target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::getFollowersByFedUser Failed to prepare statement'); + } + $stmt->bind_param("s", $id); + $stmt->execute(); + $followerIds = []; + $stmt->bind_result($sourceUser); + while ($stmt->fetch()) { + $followerIds[] = $sourceUser; + } + foreach ($followerIds as $followerId) { + try { + $user = \Federator\DIO\User::getUserByName( + $dbh, + $followerId, + $connector, + $cache + ); + } catch (\Throwable $e) { + error_log('Followers::getFollowersByFedUser Exception: ' . $e->getMessage()); + continue; // Skip this user if an exception occurs + } + if ($user !== false && $user->id !== null) { + $followers[] = $user; + } + } + return $followers; + } + + /** + * send follow request + * + * @param \mysqli $dbh database handle + * @param \Federator\Connector\Connector $connector connector to use + * @param \Federator\Cache\Cache|null $cache optional caching service + * @param string $_user source user + * @param string $_targetUser target user id + * @param string $host the host for generating the follow ID + * @return string|false the generated follow ID on success, false on failure + */ + public static function sendFollowRequest($dbh, $connector, $cache, $_user, $_targetUser, $host) + { + if ($dbh === false) { + throw new \Federator\Exceptions\ServerError('Followers::sendFollowRequest Failed to get database handle'); + } + $user = \Federator\DIO\User::getUserByName( + $dbh, + $_user, + $connector, + $cache + ); + if ($user === false || $user->id === null) { + throw new \Federator\Exceptions\FileNotFound(); + } + + $fedUser = \Federator\DIO\FedUser::getUserByName( + $dbh, + $_targetUser, + $cache + ); + if ($fedUser === false || $fedUser->actorURL === null) { + throw new \Federator\Exceptions\FileNotFound(); + } + + $sourceUser = $user->id; + $idUrl = self::addFollow($dbh, $sourceUser, $fedUser->id, $host); + if ($idUrl === false) { + return false; // Failed to add follow + } + $followObj = new \Federator\Data\ActivityPub\Common\Follow(); + $sourceUserUrl = 'https://' . $host . '/' . $sourceUser; + $followObj->setFObject($fedUser->actorURL); + $followObj->setAActor($sourceUserUrl); + $followObj->setID($idUrl); + + // Send the Follow activity + $inboxUrl = $fedUser->inboxURL; + + $json = json_encode($followObj, JSON_UNESCAPED_SLASHES); + + if ($json === false) { + self::removeFollow($dbh, $sourceUser, $fedUser->id); + throw new \Exception('Failed to encode JSON: ' . json_last_error_msg()); + } + $digest = 'SHA-256=' . base64_encode(hash('sha256', $json, true)); + $date = gmdate('D, d M Y H:i:s') . ' GMT'; + $parsed = parse_url($inboxUrl); + if ($parsed === false) { + self::removeFollow($dbh, $sourceUser, $fedUser->id); + throw new \Exception('Failed to parse URL: ' . $inboxUrl); + } + + if (!isset($parsed['host']) || !isset($parsed['path'])) { + self::removeFollow($dbh, $sourceUser, $fedUser->id); + throw new \Exception('Invalid inbox URL: missing host or path'); + } + $extHost = $parsed['host']; + $path = $parsed['path']; + + // Build the signature string + $signatureString = "(request-target): post {$path}\n" . + "host: {$extHost}\n" . + "date: {$date}\n" . + "digest: {$digest}"; + + // Get rsa private key + $privateKey = \Federator\DIO\User::getrsaprivate($dbh, $user->id); // OR from DB + if ($privateKey === false) { + self::removeFollow($dbh, $sourceUser, $fedUser->id); + throw new \Exception('Failed to get private key'); + } + $pkeyId = openssl_pkey_get_private($privateKey); + + if ($pkeyId === false) { + self::removeFollow($dbh, $sourceUser, $fedUser->id); + throw new \Exception('Invalid private key'); + } + + openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256); + $signature_b64 = base64_encode($signature); + + // Build keyId (public key ID from your actor object) + $keyId = 'https://' . $host . '/' . $user->id . '#main-key'; + + $signatureHeader = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest"' + . ',signature="' . $signature_b64 . '"'; + + $ch = curl_init($inboxUrl); + if ($ch === false) { + self::removeFollow($dbh, $sourceUser, $fedUser->id); + throw new \Exception('Failed to initialize cURL'); + } + $headers = [ + 'Host: ' . $extHost, + 'Date: ' . $date, + 'Digest: ' . $digest, + 'Content-Type: application/activity+json', + 'Signature: ' . $signatureHeader, + 'Accept: application/activity+json', + ]; + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + $response = curl_exec($ch); + curl_close($ch); + + // Log the response for debugging if needed + if ($response === false) { + self::removeFollow($dbh, $sourceUser, $fedUser->id); + throw new \Exception('Failed to send Follow activity: ' . curl_error($ch)); + } else { + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($httpcode != 200 && $httpcode != 202) { + self::removeFollow($dbh, $sourceUser, $fedUser->id); + throw new \Exception('Unexpected HTTP code ' . $httpcode . ':' . $response); + } + } + return $idUrl; + } + + /** + * add follow + * + * @param \mysqli $dbh database handle + * @param string $sourceUser source user id + * @param string $targetUserId target user id + * @param string $host the host for generating the follow ID + * @return string|false the generated follow ID on success, false on failure + */ + public static function addFollow($dbh, $sourceUser, $targetUserId, $host) + { + // Check if we already follow this user + $sql = 'select id from follows where source_user = ? and target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::addFollow Failed to prepare statement'); + } + $stmt->bind_param('ss', $sourceUser, $targetUserId); + $foundId = 0; + $ret = $stmt->bind_result($foundId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + if ($foundId != 0) { + return false; // Already following this user + } + + // Generate a unique ID for the follow relationship + do { + $id = bin2hex(openssl_random_pseudo_bytes(16)); + $idurl = 'https://' . $host . '/' . $sourceUser . '/' . $id; + + // Check if the generated ID is unique + $sql = 'select id from follows where id = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::addFollow Failed to prepare id-check' + . 'statement'); + } + $stmt->bind_param('s', $idurl); + $foundId = 0; + $ret = $stmt->bind_result($foundId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + } while ($foundId > 0); + + // Add follow with created_at timestamp + $sql = 'insert into follows (id, source_user, target_user, created_at) values (?, ?, ?, NOW())'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::addFollow Failed to prepare insert statement'); + } + $stmt->bind_param('sss', $idurl, $sourceUser, $targetUserId); + $stmt->execute(); + $stmt->close(); + return $idurl; // Return the generated follow ID + } + + /** + * add follow + * + * @param \mysqli $dbh database handle + * @param string $followId the follow ID to use (should be an external url) + * @param string $sourceUserId source user id + * @param string $targetUserId target user id + * @return boolean true on success, false on failure + */ + public static function addExternalFollow($dbh, $followId, $sourceUserId, $targetUserId) + { + // Check if we already follow this user + $sql = 'select id from follows where source_user = ? and target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::addExternalFollow Failed to prepare statement'); + } + $stmt->bind_param('ss', $sourceUserId, $targetUserId); + $foundId = 0; + $ret = $stmt->bind_result($foundId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + if ($foundId != 0) { + return false; // Already following this user + } + + // Add follow with created_at timestamp + $sql = 'insert into follows (id, source_user, target_user, created_at) values (?, ?, ?, NOW())'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::addExternalFollow Failed to prepare insert ' + . 'statement'); + } + $stmt->bind_param('sss', $followId, $sourceUserId, $targetUserId); + $stmt->execute(); + $stmt->close(); + return true; + } + + /** + * generate new follow id + * + * @param \mysqli $dbh database handle + * @param string $hostUrl the host URL (e.g. federator URL) + * @return string the new follow id + */ + public static function generateNewFollowId($dbh, $hostUrl) + { + // Generate a new unique follow ID + do { + $newId = bin2hex(openssl_random_pseudo_bytes(16)); + $newIdUrl = $hostUrl . '/' . $newId; + + // Check if the generated ID is unique + $sql = 'select id from follows where id = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::generateNewFollowId Failed to prepare id-check' + . ' statement'); + } + $stmt->bind_param('s', $newIdUrl); + $foundId = 0; + $ret = $stmt->bind_result($foundId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + } while ($foundId > 0); + + return $newIdUrl; + } + + /** + * remove follow + * + * @param \mysqli $dbh database handle + * @param string $sourceUser source user id + * @param string $targetUserId target user id + * @return string|false removed followId on success, false on failure + */ + public static function removeFollow($dbh, $sourceUser, $targetUserId) + { + // Combine retrieval and removal in one query using MySQL's RETURNING (if supported) + $sql = 'delete from follows where source_user = ? and target_user = ? RETURNING id'; + $stmt = $dbh->prepare($sql); + if ($stmt !== false) { + $stmt->bind_param('ss', $sourceUser, $targetUserId); + if ($stmt->execute()) { + $stmt->bind_result($followId); + if ($stmt->fetch() === true) { + $stmt->close(); + if (!empty($followId)) { + return $followId; + } else { + return false; + } + } + } + $stmt->close(); + } else { + // Fallback for MySQL versions that do not support RETURNING + // First, fetch the id of the follow to be removed + $sql = 'select id from follows where source_user = ? and target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::removeFollow Failed to prepare select ' + . 'statement'); + } + $stmt->bind_param('ss', $sourceUser, $targetUserId); + $stmt->execute(); + $stmt->bind_result($followId); + $found = $stmt->fetch(); + $stmt->close(); + + if ($found === false || empty($followId)) { + return false; // No such follow found + } + + // Now, delete the row + $sql = 'delete from follows where source_user = ? and target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('Followers::removeFollow Failed to prepare delete ' + . 'statement'); + } + $stmt->bind_param('ss', $sourceUser, $targetUserId); + $stmt->execute(); + $affectedRows = $stmt->affected_rows; + $stmt->close(); + + return $affectedRows > 0 ? $followId : false; + } + return false; + } +} diff --git a/php/federator/dio/posts.php b/php/federator/dio/posts.php index 7be8c0e..ec95c8a 100644 --- a/php/federator/dio/posts.php +++ b/php/federator/dio/posts.php @@ -13,47 +13,331 @@ namespace Federator\DIO; */ class Posts { - /** * get posts by user * * @param \mysqli $dbh @unused-param * database handle - * @param string $id + * @param string $userid * user id * @param \Federator\Connector\Connector $connector * connector to fetch use with * @param \Federator\Cache\Cache|null $cache * optional caching service - * @param string $minId - * minimum ID - * @param string $maxId - * maximum ID - * @return \Federator\Data\ActivityPub\Common\APObject[] + * @param int $min + * minimum timestamp + * @param int $max + * maximum timestamp + * @param int $limit + * maximum number of results + * @return \Federator\Data\ActivityPub\Common\Activity[] */ - public static function getPostsByUser($dbh, $id, $connector, $cache, $minId, $maxId) + public static function getPostsByUser($dbh, $userid, $connector, $cache, $min, $max, $limit) { // ask cache if ($cache !== null) { - $posts = $cache->getRemotePostsByUser($id, $minId, $maxId); + $posts = $cache->getRemotePostsByUser($userid, $min, $max, $limit); if ($posts !== false) { return $posts; } } - $posts = []; - // TODO: check our db + $posts = self::getPostsFromDb($dbh, $userid, $min, $max, $limit); + if ($posts === false) { + $posts = []; + } - if ($posts === []) { - // ask connector for user-id - $posts = $connector->getRemotePostsByUser($id, $minId, $maxId); - if ($posts === false) { - $posts = []; + // Only override $min if we found posts in our DB + $remoteMin = $min; + if (!empty($posts)) { + // Find the latest published date in the DB posts + $latestPublished = null; + foreach ($posts as $post) { + $published = $post->getPublished(); + if ($published != null) { + if ($latestPublished === null || $published > $latestPublished) { + $latestPublished = $published; + } + } + } + if ($latestPublished !== null) { + $remoteMin = $latestPublished; } } - // save posts to DB + + // Fetch newer posts from connector (if any) if max is not set and limit not reached + if ($max == 0 && sizeof($posts) < $limit) { + $newPosts = $connector->getRemotePostsByUser($userid, $remoteMin, $max, $limit); + if ($newPosts !== false && is_array($newPosts)) { + // Merge new posts with DB posts, avoiding duplicates by ID + $existingIds = []; + foreach ($posts as $post) { + $existingIds[$post->getID()] = true; + } + foreach ($newPosts as $newPost) { + if (!isset($existingIds[$newPost->getID()])) { + if ($newPost->getID() !== '') { + self::savePost($dbh, $userid, $newPost); + } + if (sizeof($posts) < $limit) { + $posts[] = $newPost; + } + } + } + } + } + + $originUrl = 'localhost'; + if (isset($_SERVER['HTTP_HOST'])) { + $originUrl = $_SERVER['HTTP_HOST']; // origin of our request - e.g. mastodon + } elseif (isset($_SERVER['HTTP_ORIGIN'])) { + $origin = $_SERVER['HTTP_ORIGIN']; + $parsed = parse_url($origin); + if (isset($parsed) && isset($parsed['host'])) { + $parsedHost = $parsed['host']; + if (is_string($parsedHost) && $parsedHost !== '') { + $originUrl = $parsedHost; + } + } + } + if (!isset($originUrl) || $originUrl === '') { + $originUrl = 'localhost'; // Fallback to localhost if no origin is set + } + + // optionally convert from article to note + foreach ($posts as $post) { + switch (strtolower($post->getType())) { + case 'undo': + $object = $post->getObject(); + if (is_object($object)) { + if (strtolower($object->getType()) === 'article') { + if ($object instanceof \Federator\Data\ActivityPub\Common\Article) { + $object = \Federator\DIO\Article::conditionalConvertToNote($object, $originUrl); + $post->setObject($object); + } + } + } + + break; + + case 'create': + case 'update': + $object = $post->getObject(); + if (is_object($object)) { + if (strtolower($object->getType()) === 'article') { + if ($object instanceof \Federator\Data\ActivityPub\Common\Article) { + $object = \Federator\DIO\Article::conditionalConvertToNote($object, $originUrl); + $post->setObject($object); + } + } + } + + break; + + default: + break; + } + } + if ($cache !== null) { - $cache->saveRemotePostsByUser($id, $posts); + $cache->saveRemotePostsByUser($userid, $min, $max, $limit, $posts); } return $posts; } + + /** + * Get posts for a user from the DB (optionally by date) + * + * @param \mysqli $dbh + * @param string $userId + * @param int $min min timestamp + * @param int $max max timestamp + * @param int $limit + * @return \Federator\Data\ActivityPub\Common\Activity[]|false + */ + public static function getPostsFromDb($dbh, $userId, $min, $max, $limit = 20) + { + $sql = 'SELECT `id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, unix_timestamp(`published`) as ' + . 'published FROM posts WHERE user_id = ?'; + $params = [$userId]; + $types = 's'; + if ($min > 0) { + $sql .= ' AND published >= from_unixtime(?)'; + $params[] = $min; + $types .= 's'; + } + if ($max > 0) { + $sql .= ' AND published <= from_unixtime(?)'; + $params[] = $max; + $types .= 's'; + } + $sql .= ' ORDER BY published DESC LIMIT ' . $limit; + + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $result = $stmt->get_result(); + if (!($result instanceof \mysqli_result)) { + $stmt->close(); + return false; + } + $posts = []; + while ($row = $result->fetch_assoc()) { + if (isset($row['to']) && $row['to'] !== null) { + $row['to'] = json_decode($row['to'], true); + } + if (isset($row['cc']) && $row['cc'] !== null) { + $row['cc'] = json_decode($row['cc'], true); + } + if (isset($row['object']) && $row['object'] !== null) { + $decoded = json_decode($row['object'], true); + // Only use decoded value if it's an array/object + if (is_array($decoded)) { + $row['object'] = $decoded; + } + } + if (isset($row['published']) && $row['published'] !== null) { + // If it's numeric, keep as int. If it's a string, try to parse as ISO 8601. + if (!is_numeric($row['published'])) { + // Try to parse as datetime string + $timestamp = strtotime($row['published']); + $row['published'] = $timestamp !== false ? $timestamp : null; + } + } + $activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($row); + if ($activity !== false) { + $posts[] = $activity; + } + } + $stmt->close(); + return $posts; + } + + /** + * Save a post (insert or update) + * + * @param \mysqli $dbh + * @param string $userId + * @param \Federator\Data\ActivityPub\Common\Activity $post + * @param string|null $articleId the original id of the article + * (used to identify the source article in the remote system) + * @return bool + */ + public static function savePost($dbh, $userId, $post, $articleId = null) + { + $sql = 'INSERT INTO posts ( + `id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published`, `article_id` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + `url` = VALUES(`url`), + `user_id` = VALUES(`user_id`), + `actor` = VALUES(`actor`), + `type` = VALUES(`type`), + `object` = VALUES(`object`), + `to` = VALUES(`to`), + `cc` = VALUES(`cc`), + `published` = VALUES(`published`), + `article_id` = VALUES(`article_id`)'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + + $id = $post->getID(); + $url = $post->getUrl(); + $actor = $post->getAActor(); + $type = $post->getType(); + $object = $post->getObject(); + $objectJson = ($object instanceof \Federator\Data\ActivityPub\Common\APObject) + ? json_encode($object) + : $object; + if ($objectJson === false) { + $objectJson = null; + } + if (is_object($object)) { + $id = $object->getID(); + } + $to = $post->getTo(); + $cc = $post->getCC(); + $toJson = is_array($to) ? json_encode($to) : (is_string($to) ? json_encode([$to]) : null); + $ccJson = is_array($cc) ? json_encode($cc) : (is_string($cc) ? json_encode([$cc]) : null); + $published = $post->getPublished(); + $publishedStr = $published ? gmdate('Y-m-d H:i:s', $published) : gmdate('Y-m-d H:i:s'); + + $stmt->bind_param( + 'ssssssssss', + $id, + $url, + $userId, + $actor, + $type, + $objectJson, + $toJson, + $ccJson, + $publishedStr, + $articleId, + ); + $result = $stmt->execute(); + $stmt->close(); + return $result; + } + + /** + * Delete a post + * + * @param \mysqli $dbh + * @param string $id The post ID + * @return bool + */ + public static function deletePost($dbh, $id) + { + $sql = 'delete from posts where id = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $stmt->bind_param('s', $id); + $stmt->execute(); + $affectedRows = $stmt->affected_rows; + $stmt->close(); + return $affectedRows > 0; + } + + /** retrieve original article id of post + * + * @param \mysqli $dbh + * @param \Federator\Data\ActivityPub\Common\Activity $post + * @return string|null + */ + public static function getOriginalArticleId($dbh, $post) + { + $sql = 'SELECT `article_id` FROM posts WHERE id = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $id = $post->getID(); + $object = $post->getObject(); + if (is_object($object)) { + $inReplyTo = $object->getInReplyTo(); + if ($inReplyTo !== '') { + $id = $inReplyTo; // Use inReplyTo as ID if it's a string + } else { + $id = $object->getObject(); + } + } elseif (is_string($object)) { + $id = $object; // If object is a string, use it directly + } + $stmt->bind_param('s', $id); + $articleId = null; + $ret = $stmt->bind_result($articleId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + return $articleId; + } } diff --git a/php/federator/dio/user.php b/php/federator/dio/user.php index 24ab009..38d1cfe 100644 --- a/php/federator/dio/user.php +++ b/php/federator/dio/user.php @@ -26,9 +26,9 @@ class User $sql = 'select unix_timestamp(`validuntil`) from users where id=?'; $stmt = $dbh->prepare($sql); if ($stmt === false) { - throw new \Federator\Exceptions\ServerError(); + throw new \Federator\Exceptions\ServerError('User::addLocalUser Failed to prepare statement'); } - $stmt->bind_param("s", $_user); + $stmt->bind_param('s', $_user); $validuntil = 0; $ret = $stmt->bind_result($validuntil); $stmt->execute(); @@ -42,6 +42,7 @@ class User throw new \Federator\Exceptions\ServerError(); } $public = openssl_pkey_get_details($private_key)['key']; + $user->publicKey = $public; $private = ''; openssl_pkey_export($private_key, $private); $sql = 'insert into users (id, externalid, rsapublic, rsaprivate, validuntil,'; @@ -49,11 +50,11 @@ class User $sql .= ' values (?, ?, ?, ?, now() + interval 1 day, ?, ?, ?, ?, ?, ?, ?, ?)'; $stmt = $dbh->prepare($sql); if ($stmt === false) { - throw new \Federator\Exceptions\ServerError(); + throw new \Federator\Exceptions\ServerError('User::addLocalUser Failed to prepare create statement'); } $registered = gmdate('Y-m-d H:i:s', $user->registered); $stmt->bind_param( - "ssssssssssss", + 'ssssssssssss', $_user, $user->externalid, $public, @@ -73,11 +74,11 @@ class User $sql .= ' iconmediatype=?, iconurl=?, imagemediatype=?, imageurl=? where id=?'; $stmt = $dbh->prepare($sql); if ($stmt === false) { - throw new \Federator\Exceptions\ServerError(); + throw new \Federator\Exceptions\ServerError('User::addLocalUser Failed to prepare update statement'); } $registered = gmdate('Y-m-d H:i:s', $user->registered); $stmt->bind_param( - "sssssssss", + 'sssssssss', $user->type, $user->name, $user->summary, @@ -100,18 +101,42 @@ class User } } + /** + * get private rsa key + * @return string|false key or false + */ + public static function getrsaprivate(\mysqli $dbh, string $_user) + { + $sql = 'select rsaprivate from users where id=?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError('User::getrsaprivate Failed to prepare statement'); + } + $stmt->bind_param('s', $_user); + $ret = $stmt->bind_result($rsaPrivateKey); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + if ($rsaPrivateKey !== null) { + return $rsaPrivateKey; + } + return false; + } + /** * extend the given user with internal data * @param \mysqli $dbh database handle * @param \Federator\Data\User $user user to extend * @param string $_user user/profile name */ - protected static function extendUser(\mysqli $dbh, $user, $_user) : void + protected static function extendUser(\mysqli $dbh, $user, $_user): void { $sql = 'select id,unix_timestamp(`validuntil`) from users where id=?'; $stmt = $dbh->prepare($sql); if ($stmt === false) { - throw new \Federator\Exceptions\ServerError(); + throw new \Federator\Exceptions\ServerError('User::extendUser Failed to prepare statement'); } $stmt->bind_param("s", $_user); $validuntil = 0; @@ -157,9 +182,9 @@ class User $sql .= 'iconmediatype,iconurl,imagemediatype,imageurl from users where id=? and validuntil>=now()'; $stmt = $dbh->prepare($sql); if ($stmt === false) { - throw new \Federator\Exceptions\ServerError(); + throw new \Federator\Exceptions\ServerError('User::getUserByName Failed to prepare statement'); } - $stmt->bind_param("s", $_name); + $stmt->bind_param('s', $_name); $user = new \Federator\Data\User(); $ret = $stmt->bind_result( $user->id, diff --git a/php/federator/dio/votes.php b/php/federator/dio/votes.php new file mode 100644 index 0000000..ff446ea --- /dev/null +++ b/php/federator/dio/votes.php @@ -0,0 +1,97 @@ +prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $stmt->bind_param('sss', $userId, $targetId, $type); + $foundId = 0; + $ret = $stmt->bind_result($foundId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + if ($foundId != 0) { + return false; // Already voted + } + + // Generate a unique ID for the vote + do { + $id = bin2hex(openssl_random_pseudo_bytes(16)); + // Check if the generated ID is unique + $sql = 'SELECT id FROM votes WHERE id = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $stmt->bind_param('s', $id); + $foundId = 0; + $ret = $stmt->bind_result($foundId); + $stmt->execute(); + if ($ret) { + $stmt->fetch(); + } + $stmt->close(); + } while ($foundId > 0); + + // Add vote with created_at timestamp + $sql = 'INSERT INTO votes (id, user_id, target_id, type, created_at) VALUES (?, ?, ?, ?, NOW())'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $stmt->bind_param('ssss', $id, $userId, $targetId, $type); + $stmt->execute(); + $stmt->close(); + return $id; // Return the generated vote ID + } + + /** + * Remove a vote (like/dislike) + * + * @param \mysqli $dbh + * @param string $userId + * @param string $targetId + * @return bool true on success + */ + public static function removeVote($dbh, $userId, $targetId) + { + $sql = 'DELETE FROM votes WHERE user_id = ? AND target_id = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $stmt->bind_param('ss', $userId, $targetId); + $stmt->execute(); + $affectedRows = $stmt->affected_rows; + $stmt->close(); + return $affectedRows > 0; + } +} diff --git a/php/federator/jobs/inboxJob.php b/php/federator/jobs/inboxJob.php new file mode 100644 index 0000000..b4a4301 --- /dev/null +++ b/php/federator/jobs/inboxJob.php @@ -0,0 +1,77 @@ + $args Arguments for the job */ + public $args = []; + + /** + * cache instance + * + * @var \Federator\Cache\Cache $cache + */ + protected $cache; + + /** + * remote connector + * + * @var \Federator\Connector\Connector $connector + */ + protected $connector = null; + + /** + * database instance + * + * @var \Mysqli $dbh + */ + protected $dbh; + + /** + * constructor + */ + public function __construct() + { + parent::__construct(); + } + + /** + * Set up environment for this job + */ + public function setUp(): void + { + $this->openDatabase(); + $this->loadPlugins(); + } + + /** + * Perform the inbox job. + * + * @return bool true on success, false on failure + */ + public function perform(): bool + { + error_log('InboxJob: Starting job'); + $user = $this->args['user']; + $recipientId = $this->args['recipientId']; + $activity = $this->args['activity']; + + $inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity); + + if ($inboxActivity === false) { + error_log('InboxJob: Failed to create inboxActivity from JSON'); + return false; + } + + \Federator\Api\FedUsers\Inbox::postForUser( + $this->dbh, + $this->connector, + $this->cache, + $user, + $recipientId, + $inboxActivity + ); + return true; + } +} diff --git a/php/federator/jobs/newContentJob.php b/php/federator/jobs/newContentJob.php new file mode 100644 index 0000000..2ab3b11 --- /dev/null +++ b/php/federator/jobs/newContentJob.php @@ -0,0 +1,83 @@ + $args Arguments for the job */ + public $args = []; + + /** + * cache instance + * + * @var \Federator\Cache\Cache $cache + */ + protected $cache; + + /** + * remote connector + * + * @var \Federator\Connector\Connector $connector + */ + protected $connector = null; + + /** + * database instance + * + * @var \Mysqli $dbh + */ + protected $dbh; + + /** + * constructor + */ + public function __construct() + { + parent::__construct(); + } + + /** + * Set up environment for this job + */ + public function setUp(): void + { + $this->openDatabase(); + $this->loadPlugins(); + } + + /** + * Perform the inbox job. + * + * @return bool true on success, false on failure + */ + public function perform(): bool + { + error_log('NewContentJob: Starting job'); + $user = $this->args['user']; + $recipientId = $this->args['recipientId']; + $activity = $this->args['activity']; + $articleId = $this->args['articleId'] ?? null; + + $activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity); + + if ($activity === false) { + error_log('NewContentJob: Failed to create activity from JSON'); + return false; + } + $domain = $this->config['generic']['externaldomain']; + + $ourUrl = 'https://' . $domain; + + \Federator\Api\V1\NewContent::postForUser( + $this->dbh, + $this->connector, + $this->cache, + $ourUrl, + $user, + $recipientId, + $activity, + $articleId + ); + return true; + } +} diff --git a/php/federator/language.php b/php/federator/language.php index 2e58133..fa1af53 100644 --- a/php/federator/language.php +++ b/php/federator/language.php @@ -20,9 +20,9 @@ class Language * @var array $validLanguages */ private $validLanguages = array( - "de" => true, - "en" => true, - "xy" => true + 'de' => true, + 'en' => true, + 'xy' => true ); /** @@ -96,8 +96,8 @@ class Language if ($root === '') { $root = '.'; } - if (@file_exists($root . '../lang/federator/' . $this->uselang . "/$group.inc")) { - require($root . '../lang/federator/' . $this->uselang . "/$group.inc"); + if (@file_exists($root . '../lang/federator/' . $this->uselang . '/' . $group . '.inc')) { + require($root . '../lang/federator/' . $this->uselang . '/' . $group . '.inc'); $this->lang[$group] = $l; } } @@ -107,15 +107,15 @@ class Language if (isset($values[$i])) { $string = str_replace("\$$i", $values[$i], $string); } else { - $string = str_replace("\$$i", "", $string); + $string = str_replace("\$$i", '', $string); } } return $string; } $basedir = $_SERVER['DOCUMENT_ROOT'] . '/../'; - $fh = @fopen("$basedir/logs/missingtrans.txt", 'a'); + $fh = @fopen($basedir . '/logs/missingtrans.txt', 'a'); if ($fh !== false) { - fwrite($fh, $this->uselang.":$group:$key\n"); + fwrite($fh, $this->uselang . ':' . $group . ':' . "$key\n"); fclose($fh); } return ">>$group:$key<<"; @@ -132,7 +132,7 @@ class Language { if (! isset($this->lang[$group])) { $l = []; - require_once($_SERVER['DOCUMENT_ROOT'] . '/../lang/' . $this->uselang . "/$group.inc"); + require_once($_SERVER['DOCUMENT_ROOT'] . '/../lang/' . $this->uselang . '/' . $group . '.inc'); $this->lang[$group] = $l; } // @phan-suppress-next-line PhanPartialTypeMismatchReturn @@ -288,7 +288,7 @@ function smarty_function_printlang($params, $template) : string */ function smarty_function_printjslang($params, $template) : string { - $lang = $template->getTemplateVars("language"); + $lang = $template->getTemplateVars('language'); $prefix = 'window.translations.' . $params['group'] . '.' . $params['key'] . ' = \''; $postfix = '\';'; if (isset($params['var'])) { diff --git a/php/federator/main.php b/php/federator/main.php index 456c57f..0683151 100644 --- a/php/federator/main.php +++ b/php/federator/main.php @@ -21,30 +21,42 @@ class Main * @var Cache\Cache $cache */ protected $cache; + /** * current config * * @var array $config */ protected $config; + /** * remote connector * * @var Connector\Connector $connector */ protected $connector = null; + + /** + * remote host (f.e. https://contentnation.net) + * + * @var string $host + */ + protected $host = null; + /** * response content type * * @var string $contentType */ - protected $contentType = "text/html"; + protected $contentType = 'text/html'; + /** * database instance * * @var \Mysqli $dbh */ protected $dbh; + /** * extra headers * @@ -150,9 +162,19 @@ class Main return $this->connector; } + /** + * get host (f.e. https://contentnation.net) + * + * @return string + */ + public function getHost() + { + return $this->host; + } + /** * get config - * @return Array + * @return array */ public function getConfig() { @@ -172,7 +194,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/'; @@ -196,11 +218,11 @@ class Main */ public function openDatabase($usernameOverride = null, $passwordOverride = null) { - $dbconf = $this->config["database"]; + $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) { @@ -232,27 +254,51 @@ class Main /** * set cache + * + * @param \Federator\Cache\Cache $cache the new cache */ - public function setCache(Cache\Cache $cache) : void + public function setCache(Cache\Cache $cache): void { $this->cache = $cache; } /** * set connector + * + * @param \Federator\Connector\Connector $connector the new connector */ public function setConnector(Connector\Connector $connector) : void { $this->connector = $connector; } + /** + * set host + * + * @param string $host the new host url + */ + public function setHost(string $host) : void + { + $this->host = $host; + } + + /** + * set content type + * + * @param string $_type content type + */ + public function setContentType($_type): void + { + $this->contentType = $_type; + } + /** * set response code * * @param int $code * new response code */ - public function setResponseCode(int $code) : void + public function setResponseCode(int $code): void { $this->responseCode = $code; } @@ -270,7 +316,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 +327,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/php/federator/maintenance.php b/php/federator/maintenance.php index 4177498..9cbeaeb 100644 --- a/php/federator/maintenance.php +++ b/php/federator/maintenance.php @@ -22,7 +22,7 @@ class Maintenance */ public static function run($argc, $argv) { - date_default_timezone_set("Europe/Berlin"); + date_default_timezone_set('Europe/Berlin'); spl_autoload_register(static function (string $className) { $root = $_SERVER['DOCUMENT_ROOT']; include $root . '../php/' . str_replace("\\", "/", strtolower($className)) . '.php'; diff --git a/php/federator/test.php b/php/federator/test.php new file mode 100644 index 0000000..0b0b131 --- /dev/null +++ b/php/federator/test.php @@ -0,0 +1,199 @@ +getTotalItems(); + echo "total items: " . $total; + echo " first: " . $ap->getFirst() . "\n"; + + $page = $ap->getFirst(); + $count = 0; + + while ($count < $total) { + echo "query $page\n"; + // query pages + [$response, $info] = \Federator\Main::getFromRemote($page, $headers); + if ($info['http_code'] != 200) { + return; + } + // echo "'$response'\n"; + $json = json_decode($response, true); + $ap = \Federator\Data\ActivityPub\Factory::newFromJson($json, $response); + if (!($ap instanceof \Federator\Data\ActivityPub\Common\OrderedCollectionPage)) { + echo "unsupport reply from $page\n"; + return; + } + $thisCount = $ap->getCount(); + echo "count: " . $thisCount . "\n"; + for ($i = 0; $i < $thisCount; ++$i) { + $entry = $ap->get($i); + if ($entry instanceof \Federator\Data\ActivityPub\Common\APObject) { + echo $entry->getID() . " " . $entry->getPublished() . "\n"; + } + } + $count += $thisCount; + $page = $ap->getNext(); + } + //print_r($ap); + } + /** + * run test + * + * @param int $argc number of arguments + * @param string[] $argv arguments + * @return void + */ + public static function run($argc, $argv) + { + date_default_timezone_set('Europe/Berlin'); + spl_autoload_register(static function (string $className) { + include PROJECT_ROOT . '/php/' . str_replace("\\", "/", strtolower($className)) . '.php'; + }); + if ($argc < 2) { + self::printUsage(); + } + // pretend that we are running from web directory + define('PROJECT_ROOT', dirname(__DIR__, 2)); + $api = new \Federator\Api(); + $api->loadPlugins(); + $api->openDatabase(); + + for ($i = 1; $i < $argc; ++$i) { + switch ($argv[$i]) { + case 'fetchoutbox': + self::fetchOutbox($argv[$i + 1]); + ++$i; + break; + case 'upvote': + self::upvote($api, $argv[$i + 1]); + ++$i; + break; + default: + self::printUsage(); + } + } + } + + + /** + * print usage of test tool + * + * @return void + */ + public static function printUsage() + { + echo "usage php test.php [ ...]\n"; + echo "command can be one of:\n"; + echo " fetchoutbox - fetch users outbox. parameter: username\n"; + echo " upvote - upvote. parameter: URL to upvote\n"; + echo " downvote - downvote. parameter: URL to downvote\n"; + echo " comment - comment. parameter: URL to comment, text to comment\n"; + echo " Run this after you updated the program files\n"; + exit(); + } + + /** + * upvote given URL + * + * @param \Federator\Api $api api instance + * @param string $_url URL to upvote + * @note uses hardcoded source + * @return void + */ + public static function upvote($api, $_url) + { + $dbh = $api->getDatabase(); + $inboxActivity = new \Federator\Data\ActivityPub\Common\Like(); + $inboxActivity->setAActor('https://mastodon.local/users/admin'); + $inboxActivity->setObject($_url); + $inboxActivity->setID("https://mastodon.local/users/admin#like/" . md5($_url)); + \Federator\Api\FedUsers\Inbox::postForUser( + $dbh, + $api->getConnector(), + null, + 'admin@mastodon.local', + "grumpydevelop@192.168.178.143", + $inboxActivity + ); + } + + /** + * do a webfinger request + * @param string $_name name to query + */ + private static function webfinger($_name): mixed + { + // make webfinger request + if (preg_match("/^([^@]+)@(.*)$/", $_name, $matches) != 1) { + echo "username is malformed"; + return false; + } + $remoteURL = 'https://' . $matches[2] . '/.well-known/webfinger?resource=acct:' . urlencode($_name); + $headers = ['Accept: application/activity+json']; + [$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers); + if ($info['http_code'] != 200) { + return false; + } + $r = json_decode($response, true); + if (isset($r['links'])) { + foreach ($r['links'] as $link) { + if (isset($link['rel']) && $link['rel'] === 'self') { + $remoteURL = $link['href']; + break; + } + } + } + if (!isset($remoteURL)) { + echo "FedUser::getUserByName Failed to find self link in webfinger for " . $_name . "\n"; + return false; + } + // fetch the user + $headers = ['Accept: application/activity+json']; + [$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers); + if ($info['http_code'] != 200) { + echo "FedUser::getUserByName Failed to fetch user from remoteUrl for " . $_name . "\n"; + return false; + } + $r = json_decode($response, true); + return $r; + } +} + + +Test::run($argc, $argv); diff --git a/php/federator/workers/worker_inbox.php b/php/federator/workers/worker_inbox.php new file mode 100644 index 0000000..64be128 --- /dev/null +++ b/php/federator/workers/worker_inbox.php @@ -0,0 +1,23 @@ +work(10); // 10 seconds interval diff --git a/plugins/federator/contentnation.php b/plugins/federator/contentnation.php index 3b871de..da5408f 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 { /** @@ -47,28 +47,92 @@ class ContentNation implements Connector } $this->service = $config['contentnation']['service-uri']; $this->main = $main; + $this->main->setHost($this->service); + } + + /** + * get followers of given user + * + * @param string $userId user id + * @return \Federator\Data\FedUser[]|false + */ + public function getRemoteFollowersOfUser($userId) + { + // todo implement queue for this + if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) { + $userId = $matches[1]; + } + $remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/followers'; + + [$response, $info] = \Federator\Main::getFromRemote($remoteURL, []); + if ($info['http_code'] != 200) { + error_log("ContentNation::getRemoteFollowersOfUser error retrieving followers for userId: $userId . Error: " + . json_encode($info)); + return false; + } + $r = json_decode($response, true); + if ($r === false || $r === null || !is_array($r)) { + return false; + } + $followers = []; + return $followers; + } + + /** + * get following of given user + * + * @param string $userId user id + + * @return \Federator\Data\FedUser[]|false + */ + public function getRemoteFollowingForUser($userId) + { + // todo implement queue for this + if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) { + $userId = $matches[1]; + } + $remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/following'; + + [$response, $info] = \Federator\Main::getFromRemote($remoteURL, []); + if ($info['http_code'] != 200) { + error_log('ContentNation::getRemoteFollowingForUser error retrieving following for userId: ' . $userId + . '. Error: ' . json_encode($info)); + return false; + } + $r = json_decode($response, true); + if ($r === false || $r === null || !is_array($r)) { + return false; + } + $followers = []; + return $followers; } /** * get posts by given user * * @param string $userId user id - * @param string $min min date - * @param string $max max date - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @param int $min min date + * @param int $max max date + * @param int $limit limit results + * @unused-param $limit + * @return \Federator\Data\ActivityPub\Common\Activity[]|false */ - public function getRemotePostsByUser($userId, $min, $max) + public function getRemotePostsByUser($userId, $min, $max, $limit) { - $remoteURL = $this->service . '/api/profile/' . $userId . '/activities'; - if ($min !== '') { - $remoteURL .= '&minTS=' . urlencode($min); + if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) { + $userId = $matches[1]; } - if ($max !== '') { - $remoteURL .= '&maxTS=' . urlencode($max); + $remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/activities'; + if ($min > 0) { + $remoteURL .= '&minTS=' . intval($min, 10); + } + if ($max > 0) { + $remoteURL .= '&maxTS=' . intval($max, 10); } [$response, $info] = \Federator\Main::getFromRemote($remoteURL, []); if ($info['http_code'] != 200) { - print_r($info); + error_log('ContentNation::getRemotePostsByUser error retrieving activities for userId: ' . $userId + . '. Error: ' . json_encode($info)); return false; } $r = json_decode($response, true); @@ -78,110 +142,125 @@ class ContentNation implements Connector $posts = []; if (array_key_exists('activities', $r)) { $activities = $r['activities']; - $host = $_SERVER['SERVER_NAME']; + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; + $ourUrl = 'https://' . $domain; + $imgpath = $this->config['userdata']['path']; $userdata = $this->config['userdata']['url']; foreach ($activities as $activity) { - $create = new \Federator\Data\ActivityPub\Common\Create(); - $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 = new \Federator\Data\ActivityPub\Common\Create(); + $create->setAActor($ourUrl . '/' . $userId); + $create->setPublished($activity['published'] ?? $activity['timestamp']) + ->addTo($ourUrl . '/' . $userId . '/followers') + ->addCC("https://www.w3.org/ns/activitystreams#Public"); + $create->setURL($ourUrl . '/' . $activity['profilename'] . '/' . $activity['name']); + $create->setID($ourUrl . '/' . $activity['profilename'] . '/' . $activity['name']); $apArticle = new \Federator\Data\ActivityPub\Common\Article(); if (array_key_exists('tags', $activity)) { foreach ($activity['tags'] as $tag) { - $href = 'https://' . $host . '/' . $activity['language'] - . '/search.htm?tagsearch=' . urlencode($tag); + $href = $ourUrl . '/search.htm?tagsearch=' . urlencode($tag); $tagObj = new \Federator\Data\ActivityPub\Common\Tag(); $tagObj->setHref($href) - ->setName('#' . urlencode(str_replace(' ', '', $tag))) - ->setType('Hashtag'); + ->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($ourUrl . '/' . $activity['profilename']) + ->setContent( + $activity['teaser'] ?? + $this->main->translate( + $activity['language'], + 'article', + 'newarticle' + ) + ) + ->addTo("https://www.w3.org/ns/activitystreams#Public") + ->addCC($ourUrl . '/' . $userId . '/followers.json'); $articleimage = $activity['imagealt'] ?? $this->main->translate($activity['language'], 'article', 'image'); - $idurl = 'https://' . $host . '/' . $activity['language'] - . '/' . $userId . '/'. $activity['name']; + $idurl = $ourUrl . '/' . $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'; + $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); + ->setName($articleimage) + ->setURL($userdata . '/' . $activity['profile'] . $image); $apArticle->addImage($img); $create->setObject($apArticle); $posts[] = $create; break; // Article + case 'Comment': -// echo "comment\n"; -// print_r($activity); - break; // Comment - case 'Vote': - $url = 'https://'.$host . '/' . $activity['articlelang'] . $userId . '/' - . $activity['articlename']; - $url .= '/vote/' . $activity['id']; - $create->setURL($url); - if ($activity['upvote'] === true) { - $like = new \Federator\Data\ActivityPub\Common\Activity('Like'); - $like->setSummary( - $this->main->translate( - $activity['articlelang'], - 'vote', - 'like', - [$activity['username']] - ) - ); + $create = new \Federator\Data\ActivityPub\Common\Create(); + $create->setAActor($ourUrl . '/' . $userId); + $create->setID($activity['id']) + ->setPublished($activity['published'] ?? $activity['timestamp']) + ->addTo($ourUrl . '/' . $userId . '/followers') + ->addCC("https://www.w3.org/ns/activitystreams#Public"); + $commentJson = $activity; + $commentJson['type'] = 'Note'; + $commentJson['summary'] = $activity['subject']; + $commentJson['id'] = $ourUrl . '/' . $activity['articleOwnerName'] . '/' + . $activity['articleName'] . '#' . $activity['id']; + $note = \Federator\Data\ActivityPub\Factory::newFromJson($commentJson, ""); + if ($note === null) { + error_log("ContentNation::getRemotePostsByUser couldn't create comment"); + $note = new \Federator\Data\ActivityPub\Common\Activity('Comment'); + $create->setObject($note); + break; + } + $note->setID($commentJson['id']); + if (!isset($commentJson['parent']) || $commentJson['parent'] === null) { + $note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' + . $activity['articleName']); } else { - $like = new \Federator\Data\ActivityPub\Common\Activity('Dislike'); - $like->setSummary( - $this->main->translate( - $activity['articlelang'], - 'vote', - 'dislike', - [$activity['username']] - ) - ); + $note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' + . $activity['articleName'] . "#" . $commentJson['parent']); } - $actor = new \Federator\Data\ActivityPub\Common\APObject('Person'); - $actor->setName($activity['username']); - $like->setActor($actor); - $url = 'https://' . $host . '/' . $activity['articlelang'] - . '/' . $userId . '/'. $activity['articlename']; - if ($activity['comment'] !== '') { - $url .= '/comment/' . $activity['comment']; - } - $type = 'Article'; - switch ($activity['votetype']) { - case 'comment': - $type = 'Comment'; - break; - } - $object = new \Federator\Data\ActivityPub\Common\APObject($type); - $object->setHref($url); - $like->setObject($object); - $create->setObject($like); + $url = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] + . '#' . $activity['id']; + $create->setURL($url); + $create->setID($url); + $create->setObject($note); $posts[] = $create; + break; // Comment + + case 'Vote': + // Build Like/Dislike as top-level activity + $likeType = $activity['upvote'] === true ? 'Like' : 'Dislike'; + $like = new \Federator\Data\ActivityPub\Common\Activity($likeType); + $like->setAActor($ourUrl . '/' . $userId); + $like->setID($activity['id']) + ->setPublished($activity['published'] ?? $activity['timestamp']); + // $like->addTo("https://www.w3.org/ns/activitystreams#Public") + // ->addCC('https://' . $domain . '/' . $userId . '/followers'); + $like->setSummary( + $this->main->translate( + $activity['articlelang'], + 'vote', + $likeType === 'Like' ? 'like' : 'dislike', + [$activity['username']] + ) + ); + $objectUrl = $ourUrl . '/' . $userId . '/' . $activity['articlename']; + $like->setURL($objectUrl . '#' . $activity['id']); + $like->setID($objectUrl . '#' . $activity['id']); + $like->setObject($objectUrl); + $posts[] = $like; break; // Vote } } @@ -212,7 +291,7 @@ class ContentNation implements Connector return $stats; } - /** + /** * get remote user by given name * * @param string $_name user/profile name @@ -221,7 +300,7 @@ class ContentNation implements Connector public function getRemoteUserByName(string $_name) { // validate name - if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_name) != 1) { + if (preg_match("/^[a-zA-Z0-9\._\-]+$/", $_name) != 1) { return false; } $remoteURL = $this->service . '/api/users/info?user=' . urlencode($_name); @@ -236,6 +315,7 @@ class ContentNation implements Connector } $user = new \Federator\Data\User(); $user->externalid = $_name; + $user->id = $_name; $user->iconMediaType = $r['iconMediaType']; $user->iconURL = $r['iconURL']; $user->imageMediaType = $r['imageMediaType']; @@ -271,7 +351,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); @@ -286,6 +366,745 @@ class ContentNation implements Connector } return $user; } + + /** + * Convert jsonData to Activity format + * + * @param array $jsonData the json data from our platfrom + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) + * @return \Federator\Data\ActivityPub\Common\Activity|false + */ + public function jsonToActivity($jsonData, &$articleId) + { + $returnActivity = false; + // Common fields for all activity types + $ap = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Create', // Default to 'Create' + 'id' => $jsonData['id'] ?? null, + 'actor' => $jsonData['actor'] ?? null, + ]; + + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; + + $ourUrl = 'https://' . $domain; + + // Extract actorName as the last segment of the actor URL (after the last '/') + $actorData = $jsonData['actor'] ?? null; + $actorName = $actorData['name'] ?? null; + + $ap['actor'] = $ourUrl . '/' . $actorName; + + if (isset($jsonData['type'])) { + switch ($jsonData['type']) { + case 'undo': + $ap['type'] = 'Undo'; + $ap['actor'] = $ourUrl . '/' . $actorName; + $objectType = $jsonData['object']['type'] ?? null; + if ($objectType === "article") { + $articleName = $jsonData['object']['name'] ?? null; + $ownerName = $jsonData['object']['ownerName'] ?? null; + $ap['id'] = $ourUrl . '/' . $ownerName . '/' . $articleName . '/undo'; + $ap['object'] = self::generateObjectJson($ourUrl, $jsonData); + } elseif ($objectType === "comment") { + $articleName = $jsonData['object']['articleName'] ?? null; + $ownerName = $jsonData['object']['articleOwnerName'] ?? null; + $commentId = $jsonData['object']['id'] ?? null; + $ap['id'] = $ourUrl . '/' . $ownerName . '/' . $articleName . '#' . $commentId . '/undo'; + $ap['object'] = self::generateObjectJson($ourUrl, $jsonData); + } elseif ($objectType === "vote") { + $id = $jsonData['object']['id'] ?? null; + $articleName = $jsonData['object']['articleName'] ?? null; + $articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null; + $ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id . '/undo'; + $ap['published'] = $jsonData['object']['published'] ?? null; + $ap['actor'] = $ourUrl . '/' . $actorName; + $ap['object']['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id; + $ap['object']['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id; + $ap['object']['actor'] = $ourUrl . '/' . $actorName; + if ($jsonData['object']['vote']['value'] == 1) { + $ap['object']['type'] = 'Like'; + } elseif ($jsonData['object']['vote']['value'] == 0) { + $ap['object']['type'] = 'Dislike'; + } else { + error_log('ContentNation::jsonToActivity unknown vote value: ' + . $jsonData['object']['vote']['value']); + break; + } + $ap['object']['object'] = self::generateObjectJson($ourUrl, $jsonData); + } else { + error_log("ContentNation::jsonToActivity unknown undo type: {$objectType}"); + break; + } + $returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap); + if ($returnActivity === false) { + error_log('ContentNation::jsonToActivity couldn\'t create undo'); + $returnActivity = new \Federator\Data\ActivityPub\Common\Undo(); + } else { + $returnActivity->setID($ap['id']); + $returnActivity->setURL($ap['id']); + } + break; + default: + // Handle unsupported types or fallback to default behavior + throw new \InvalidArgumentException('ContentNation::jsonToActivity Unsupported type: ' + . $jsonData['type']); + } + } else { + // Handle specific fields based on the type + switch ($jsonData['object']['type']) { + case 'article': + $articleName = $jsonData['object']['name'] ?? null; + $articleOwnerName = $jsonData['object']['ownerName'] ?? null; + // Set Create-level fields + $updatedOn = $jsonData['object']['modified'] ?? null; + $originalPublished = $jsonData['object']['published'] ?? null; + $update = $updatedOn !== $originalPublished; + $ap['published'] = $updatedOn ?? $originalPublished; + $ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName; + $ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName; + $ap['type'] = $update ? 'Update' : 'Create'; + $ap['actor'] = $ourUrl . '/' . $actorName; + if ($update) { + $ap['id'] .= '#update'; + $ap['url'] .= '#update'; + } + $ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public']; + + if (isset($jsonData['options'])) { + if (isset($jsonData['options']['informFollowers'])) { + if ($jsonData['options']['informFollowers'] === true) { + $ap['to'][] = $ourUrl . '/' . $actorName . '/followers'; + } + } + } + $ap['object'] = self::generateObjectJson($ourUrl, $jsonData); + $returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap); + if ($returnActivity === false) { + error_log('ContentNation::jsonToActivity couldn\'t create article'); + $returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create'); + } else { + $returnActivity->setID($ap['id']); + $returnActivity->setURL($ap['url']); + } + $articleId = $jsonData['object']['id']; // Set the article ID for the activity + break; + + case 'comment': + $commentId = $jsonData['object']['id'] ?? null; + $articleName = $jsonData['object']['articleName'] ?? null; + $articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null; + // Set Create-level fields + $ap['published'] = $jsonData['object']['published'] ?? null; + $ap['actor'] = $ourUrl . '/' . $actorName; + $ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId; + $ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId; + $ap['type'] = 'Create'; + $ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public']; + if (isset($jsonData['options'])) { + if (isset($jsonData['options']['informFollowers'])) { + if ($jsonData['options']['informFollowers'] === true) { + if ($actorName !== $articleOwnerName) { + $ap['to'][] = $ourUrl . '/' . $articleOwnerName; + } + $ap['to'][] = $ourUrl . '/' . $actorName . '/followers'; + } + } + } + $ap['object'] = self::generateObjectJson($ourUrl, $jsonData); + $returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap); + if ($returnActivity === false) { + error_log('ContentNation::jsonToActivity couldn\'t create comment'); + $returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create'); + } else { + $returnActivity->setID($ap['id']); + $returnActivity->setURL($ap['url']); + } + $articleId = $jsonData['object']['articleId']; // Set the article ID for the activity + break; + + case 'vote': + $articleName = $jsonData['object']['articleName'] ?? null; + $articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null; + $voteId = $jsonData['object']['id'] ?? null; + $ap['published'] = $jsonData['object']['published'] ?? null; + $ap['actor'] = $ourUrl . '/' . $actorName; + $ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId; + $ap['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId; + if ($jsonData['object']['vote']['value'] == 1) { + $ap['type'] = 'Like'; + } elseif ($jsonData['object']['vote']['value'] == 0) { + $ap['type'] = 'Dislike'; + } else { + error_log('ContentNation::jsonToActivity unknown vote value: ' + . $jsonData['object']['vote']['value']); + break; + } + + $ap['object'] = self::generateObjectJson($ourUrl, $jsonData); + $returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap); + if ($returnActivity === false) { + error_log('ContentNation::jsonToActivity couldn\'t create vote'); + if ($ap['type'] === 'Like') { + $returnActivity = new \Federator\Data\ActivityPub\Common\Like(); + } elseif ($ap['type'] === 'Dislike') { + $returnActivity = new \Federator\Data\ActivityPub\Common\Dislike(); + } else { + $returnActivity = new \Federator\Data\ActivityPub\Common\Undo(); + } + } else { + $returnActivity->setID($ap['id']); + $returnActivity->setURL($ap['url']); + } + $articleId = $jsonData['object']['articleId']; // Set the article ID for the activity + break; + + default: + // Handle unsupported types or fallback to default behavior + throw new \InvalidArgumentException('ContentNation::jsonToActivity Unsupported object type: ' + . $jsonData['type']); + } + } + + return $returnActivity; + } + + /** + * Convert jsonData to Activity format + * + * @param string $ourUrl the url of our instance + * @param array $jsonData the json data from our platfrom + * @return array|string|false the json object data or false + */ + private static function generateObjectJson($ourUrl, $jsonData) + { + $objectType = $jsonData['object']['type'] ?? null; + $actorData = $jsonData['actor'] ?? null; + $actorName = $actorData['name'] ?? null; + + $actorUrl = $ourUrl . '/' . $actorName; + + if ($objectType === 'article') { + $articleName = $jsonData['object']['name'] ?? null; + $articleOwnerName = $jsonData['object']['ownerName'] ?? null; + $updatedOn = $jsonData['object']['modified'] ?? null; + $originalPublished = $jsonData['object']['published'] ?? null; + $update = $updatedOn !== $originalPublished; + $returnJson = [ + 'type' => 'Article', + 'id' => $ourUrl . '/' . $articleOwnerName . '/' . $articleName, + 'name' => $jsonData['object']['title'] ?? null, + 'published' => $originalPublished, + 'summary' => $jsonData['object']['summary'] ?? null, + 'content' => $jsonData['object']['content'] ?? null, + 'attributedTo' => $actorUrl, + 'url' => $ourUrl . '/' . $articleOwnerName . '/' . $articleName, + 'cc' => ['https://www.w3.org/ns/activitystreams#Public'], + ]; + if ($update) { + $returnJson['updated'] = $updatedOn; + } + if (isset($jsonData['object']['tags'])) { + if (is_array($jsonData['object']['tags'])) { + foreach ($jsonData['object']['tags'] as $tag) { + $returnJson['tags'][] = $tag; + } + } elseif (is_string($jsonData['object']['tags']) && $jsonData['object']['tags'] !== '') { + // If it's a single tag as a string, add it as a one-element array + $returnJson['tags'][] = $jsonData['object']['tags']; + } + } + + if (isset($jsonData['options'])) { + if (isset($jsonData['options']['informFollowers'])) { + if ($jsonData['options']['informFollowers'] === true) { + $returnJson['to'][] = $ourUrl . '/' . $actorName . '/followers'; + } + } + } + } elseif ($objectType === 'comment') { + $commentId = $jsonData['object']['id'] ?? null; + $articleName = $jsonData['object']['articleName'] ?? null; + $articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null; + $returnJson = [ + 'type' => 'Note', + 'id' => $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $commentId, + 'url' => $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $commentId, + 'attributedTo' => $actorUrl, + 'content' => $jsonData['object']['content'] ?? null, + 'summary' => $jsonData['object']['summary'] ?? null, + 'published' => $jsonData['object']['published'] ?? null, + 'cc' => ['https://www.w3.org/ns/activitystreams#Public'], + ]; + if (isset($jsonData['options'])) { + if (isset($jsonData['options']['informFollowers'])) { + if ($jsonData['options']['informFollowers'] === true) { + $returnJson['to'][] = $ourUrl . '/' . $actorName . '/followers'; + } + } + } + $replyType = $jsonData['object']['inReplyTo']['type'] ?? null; + if ($replyType === "article") { + $returnJson['inReplyTo'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName; + } elseif ($replyType === "comment") { + $returnJson['inReplyTo'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName + . '#' . $jsonData['object']['inReplyTo']['id']; + } else { + error_log('ContentNation::generateObjectJson for comment - unknown inReplyTo type: ' + . $replyType); + } + } elseif ($objectType === 'vote') { + $votedOn = $jsonData['object']['type'] ?? null; + $articleName = $jsonData['object']['articleName'] ?? null; + $articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null; + $objectId = $ourUrl . '/' . $articleOwnerName . '/' . $articleName; + if ($votedOn === 'comment') { + $objectId .= '#' . $jsonData['object']['commentId']; + } + + $returnJson = $objectId; + } else { + error_log('ContentNation::generateObjectJson unknown object type: ' . $objectType); + return false; + } + + return $returnJson; + } + + /** + * send CN-friendly json from ActivityPub activity + * + * @param \Federator\Data\FedUser $sender the user of the sender + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity + * @return boolean did we successfully send the activity? + */ + public function sendActivity($sender, $activity) + { + $targetUrl = $this->service; + $targetRequestType = 'post'; // Default request type + // Convert ActivityPub activity to ContentNation JSON format and retrieve target url + $jsonData = self::activityToJson( + $this->main->getDatabase(), + $this->service, + $activity, + $targetUrl, + $targetRequestType + ); + + if ($jsonData === false) { + error_log('ContentNation::sendActivity failed to convert activity to JSON'); + return false; + } + + $json = json_encode($jsonData, JSON_UNESCAPED_SLASHES); + + if ($json === false) { + throw new \Exception('Failed to encode JSON: ' . json_last_error_msg()); + } + $digest = 'SHA-256=' . base64_encode(hash('sha256', $json, true)); + $date = gmdate('D, d M Y H:i:s') . ' GMT'; + $parsed = parse_url($targetUrl); + if ($parsed === false) { + throw new \Exception('Failed to parse URL: ' . $targetUrl); + } + + if (!isset($parsed['host']) || !isset($parsed['path'])) { + throw new \Exception('Invalid target URL: missing host or path'); + } + $extHost = $parsed['host']; + $path = $parsed['path']; + + // Build the signature string + $signatureString = "(request-target): $targetRequestType {$path}\n" . + "host: {$extHost}\n" . + "date: {$date}\n" . + "digest: {$digest}"; + + $pKeyPath = PROJECT_ROOT . '/' . $this->main->getConfig()['keys']['federatorPrivateKeyPath']; + $privateKeyPem = file_get_contents($pKeyPath); + if ($privateKeyPem === false) { + http_response_code(500); + throw new \Federator\Exceptions\PermissionDenied("Private key couldn't be determined"); + } + + $pkeyId = openssl_pkey_get_private($privateKeyPem); + + if ($pkeyId === false) { + throw new \Exception('Invalid private key'); + } + + openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256); + $signature_b64 = base64_encode($signature); + + $signatureHeader = 'algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' + . $signature_b64 . '"'; + + $ch = curl_init($targetUrl); + if ($ch === false) { + throw new \Exception('Failed to initialize cURL'); + } + $headers = [ + 'Host: ' . $extHost, + 'Date: ' . $date, + 'Digest: ' . $digest, + 'Content-Type: application/json', + 'Signature: ' . $signatureHeader, + 'Accept: application/json', + 'Username: ' . 'ap:' . $sender->id, + ]; + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + switch ($targetRequestType) { + case 'post': + curl_setopt($ch, CURLOPT_POST, true); + break; + case 'delete': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + break; + default: + throw new \Exception('ContentNation::sendActivity Unsupported target request type: ' + . $targetRequestType); + } + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + $response = curl_exec($ch); + curl_close($ch); + + if ($response === false) { + throw new \Exception('Failed to send activity: ' . curl_error($ch)); + } else { + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($httpcode != 200 && $httpcode != 202) { + throw new \Exception('Unexpected HTTP code ' . $httpcode .':' . $response); + } + } + + return true; + } + + /** + * Convert ActivityPub activity to ContentNation JSON format + * + * @param \mysqli $dbh database handle + * @param string $serviceUrl the service URL + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity + * @param string $targetUrl the target URL for the activity + * @param string $targetRequestType the target request type (e.g., 'post', 'delete', etc.) + * @return array|false the json data or false on failure + */ + private function activityToJson($dbh, $serviceUrl, $activity, &$targetUrl, &$targetRequestType) + { + $type = strtolower($activity->getType()); + $targetRequestType = 'post'; // Default request type + switch ($type) { + case 'create': + case 'update': + $object = $activity->getObject(); + if (is_object($object)) { + $objType = strtolower($object->getType()); + $articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity); + if ($articleId === null) { + error_log('ContentNation::activityToJson Failed to get original article ID' + .' for create/update activity'); + } + switch ($objType) { + case 'article': + // We don't support article create/update at this point in time + error_log('ContentNation::activityToJson Unsupported create/update object type: ' + .$objType); + break; + case 'note': + $targetUrl = $serviceUrl . '/api/article/' . $articleId . '/comment'; + $type = 'comment'; + $inReplyTo = $object->getInReplyTo(); + if ($inReplyTo !== '') { + $target = $inReplyTo; + } else { + $target = $object->getObject(); + } + $comment = null; + if (is_string($target)) { + if (strpos($target, '#') !== false) { + $parts = explode('#', $target); + if (count($parts) > 0) { + $comment = $parts[count($parts) - 1]; + } + } + } else { + error_log('ContentNation::activityToJson Unsupported target type for comment with id: ' + . $activity->getID() . ' Type: ' . gettype($target)); + return false; + } + return [ + 'type' => $type, + 'id' => $activity->getID(), + 'parent' => $comment, + 'subject' => $object->getSummary(), + 'comment' => $object->getContent(), + ]; + default: + error_log('ContentNation::activityToJson Unsupported create/update object type: ' + . $objType); + return false; + } + } + break; + + case 'follow': + $profileUrl = $activity->getObject(); + if (!is_string($profileUrl)) { + error_log('ContentNation::activityToJson Invalid profile URL: ' . json_encode($profileUrl)); + return false; + } + $receiverName = basename((string) (parse_url($profileUrl, PHP_URL_PATH) ?? '')); + $ourDomain = parse_url($profileUrl, PHP_URL_HOST); + if ($receiverName === '' || $ourDomain === '') { + error_log('ContentNation::activityToJson no profileName or domain found for object url: ' + . $profileUrl); + return false; + } + $receiver = $receiverName; + try { + $localUser = \Federator\DIO\User::getUserByName( + $dbh, + $receiver, + $this, + null + ); + } catch (\Throwable $e) { + error_log('ContentNation::activityToJson get user by name: ' . $receiver . '. Exception: ' + . $e->getMessage()); + return false; + } + if ($localUser === null || $localUser->id === null) { + error_log('ContentNation::activityToJson couldn\'t find user: ' . $receiver); + return false; + } + $targetUrl = $serviceUrl . '/api/profile/' . $localUser->id . '/fedfollow'; + $type = 'follow'; + $actor = $activity->getAActor(); + $fedUser = \Federator\DIO\FedUser::getUserByName( + $dbh, + $actor, + null + ); + $from = $fedUser->id; + return [ + 'type' => $type, + 'id' => $activity->getID(), + 'from' => $from, + 'to' => $localUser->id, + ]; + + case 'like': + case 'dislike': + $articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity); + if ($articleId === null) { + error_log('ContentNation::activityToJson Failed to get original article ID for vote activity'); + } + $voteValue = $type === 'like' ? true : false; + $activityType = 'vote'; + $inReplyTo = $activity->getInReplyTo(); + if ($inReplyTo !== '') { + $target = $inReplyTo; + } else { + $target = $activity->getObject(); + } + $comment = null; + if (is_string($target)) { + if (strpos($target, '#') !== false) { + $parts = explode('#', $target); + if (count($parts) > 0) { + $comment = $parts[count($parts) - 1]; + } + } + } else { + error_log('ContentNation::activityToJson Unsupported target type for vote with id: ' + . $activity->getID() . ' Type: ' . gettype($target)); + return false; + } + $targetUrl = $serviceUrl . '/api/article/' . $articleId . '/vote'; + return [ + 'vote' => $voteValue, + 'type' => $activityType, + 'id' => $activity->getID(), + 'comment' => $comment, + ]; + + case 'undo': + $object = $activity->getObject(); + if (is_object($object)) { + $objType = strtolower($object->getType()); + switch ($objType) { + case 'follow': + $profileUrl = $object->getObject(); + if (!is_string($profileUrl)) { + error_log('ContentNation::activityToJson Invalid profile URL: ' + . json_encode($profileUrl)); + return false; + } + $receiverName = basename((string) (parse_url($profileUrl, PHP_URL_PATH) ?? '')); + $ourDomain = parse_url($profileUrl, PHP_URL_HOST); + if ($receiverName === '' || $ourDomain === '') { + error_log('ContentNation::activityToJson no profileName or domain found for object' + . ' url: ' . $profileUrl); + return false; + } + $receiver = $receiverName; + try { + $localUser = \Federator\DIO\User::getUserByName( + $dbh, + $receiver, + $this, + null + ); + } catch (\Throwable $e) { + error_log('ContentNation::activityToJson get user by name: ' . $receiver + . '. Exception: ' . $e->getMessage()); + return false; + } + if ($localUser === null || $localUser->id === null) { + error_log('ContentNation::activityToJson couldn\'t find user: ' . $receiver); + return false; + } + $targetUrl = $serviceUrl . '/api/profile/' . $localUser->id . '/fedfollow'; + $type = 'follow'; + if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) { + $actor = $object->getAActor(); + if ($actor !== '') { + $fedUser = \Federator\DIO\FedUser::getUserByName( + $dbh, + $actor, + null + ); + $from = $fedUser->id; + $targetRequestType = 'delete'; + return [ + 'type' => $type, + 'id' => $object->getID(), + 'from' => $from, + 'to' => $localUser->id, + ]; + } + } + return false; + + case 'like': + case 'dislike': + $articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity); + if ($articleId === null) { + error_log('ContentNation::activityToJson Failed to get original article ID ' + . 'for undo vote activity'); + } + $activityType = 'vote'; + $inReplyTo = $object->getInReplyTo(); + if ($inReplyTo !== '') { + $target = $inReplyTo; + } else { + $target = $object->getObject(); + } + $comment = null; + if (is_string($target)) { + if (strpos($target, '#') !== false) { + $parts = explode('#', $target); + if (count($parts) > 0) { + $comment = $parts[count($parts) - 1]; + } + } + } else { + error_log('ContentNation::activityToJson Unsupported target type for undo ' + . 'vote with id: ' . $activity->getID() . " Type: " . gettype($target)); + return false; + } + $targetUrl = $serviceUrl . '/api/article/' . $articleId . '/vote'; + return [ + 'vote' => null, + 'type' => $activityType, + 'id' => $object->getID(), + 'comment' => $comment, + ]; + case 'note': + // We don't support comment deletions at this point in time + error_log('ContentNation::activityToJson Unsupported undo object type: ' . $objType); + break; + default: + error_log('ContentNation::activityToJson Unsupported create/update object type: ' + . $objType); + return false; + } + } + break; + default: + error_log('ContentNation::activityToJson Unsupported activity type: ' . $type); + return false; + } + + return false; + } + + /** + * check if the headers include a valid signature + * + * @param string[] $headers the headers + * @throws \Federator\Exceptions\PermissionDenied + * @return string|\Federator\Exceptions\PermissionDenied + */ + public function checkSignature($headers) + { + $signatureHeader = $headers['Signature'] ?? null; + + if (!isset($signatureHeader)) { + throw new \Federator\Exceptions\PermissionDenied('Missing Signature header'); + } + + if (!isset($headers['X-Sender']) || $headers['X-Sender'] !== $this->config['keys']['headerSenderName']) { + throw new \Federator\Exceptions\PermissionDenied('Invalid sender name'); + } + + // Parse Signature header + preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches); + $signatureParts = array_combine($matches[1], $matches[2]); + + $signature = base64_decode($signatureParts['signature']); + $signedHeaders = explode(' ', $signatureParts['headers']); + + $pKeyPath = PROJECT_ROOT . '/' . $this->config['keys']['publicKeyPath']; + $publicKeyPem = file_get_contents($pKeyPath); + if ($publicKeyPem === false) { + http_response_code(500); + throw new \Federator\Exceptions\PermissionDenied('Public key couldn\'t be determined'); + } + + // Reconstruct the signed string + $signedString = ''; + foreach ($signedHeaders as $header) { + if ($header === '(request-target)') { + $method = strtolower($_SERVER['REQUEST_METHOD']); + $path = $_SERVER['REQUEST_URI']; + $headerValue = "$method $path"; + } else { + $headerValue = $headers[ucwords($header, '-')] ?? ''; + } + + $signedString .= strtolower($header) . ': ' . $headerValue . "\n"; + } + + $signedString = rtrim($signedString); + + // Verify the signature + $pubkeyRes = openssl_pkey_get_public($publicKeyPem); + $verified = false; + if ($pubkeyRes instanceof \OpenSSLAsymmetricKey && is_string($signature)) { + $verified = openssl_verify($signedString, $signature, $pubkeyRes, OPENSSL_ALGO_SHA256); + } + if ($verified != 1) { + http_response_code(500); + throw new \Federator\Exceptions\PermissionDenied('Signature verification failed'); + } + return 'Signature verified.'; + } } namespace Federator; diff --git a/plugins/federator/dummyconnector.php b/plugins/federator/dummyconnector.php index ee0e6c3..eba203f 100644 --- a/plugins/federator/dummyconnector.php +++ b/plugins/federator/dummyconnector.php @@ -19,15 +19,39 @@ class DummyConnector implements Connector { } + /** + * get followers of given user + * + * @param string $userId user id @unused-param + * @return \Federator\Data\FedUser[]|false + */ + public function getRemoteFollowersOfUser($userId) + { + return false; + } + + /** + * get following of given user + * + * @param string $id user id @unused-param + + * @return \Federator\Data\FedUser[]|false + */ + public function getRemoteFollowingForUser($id) + { + 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 - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @param int $min min timestamp @unused-param + * @param int $max max timestamp @unused-param + * @param int $limit limit number of results @unused-param + * @return \Federator\Data\ActivityPub\Common\Activity[]|false */ - public function getRemotePostsByUser($id, $minId, $maxId) + public function getRemotePostsByUser($id, $min, $max, $limit) { return false; } @@ -46,6 +70,19 @@ class DummyConnector implements Connector return $stats; } + /** + * Convert jsonData to Activity format + * + * @param array $jsonData the json data from our platfrom @unused-param + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) @unused-param + * @return \Federator\Data\ActivityPub\Common\Activity|false + */ + public function jsonToActivity(array $jsonData, &$articleId) + { + return false; + } + /** * get remote user by name * @param string $_name user or profile name @@ -70,10 +107,34 @@ class DummyConnector implements Connector // validate $_session and $user $user = new \Federator\Data\User(); $user->externalid = $_user; - $user->permissions = ['PUBLISH']; + $user->permissions = ['publish']; $user->session = $_session; return $user; } + + /** + * send target-friendly json from ActivityPub activity + * + * @param \Federator\Data\FedUser $sender the user of the sender @unused-param + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity @unused-param + * @return boolean did we successfully send the activity? + */ + public function sendActivity($sender, $activity) + { + return false; + } + + /** + * check if the headers include a valid signature + * + * @param string[] $headers the headers @unused-param + * @throws \Federator\Exceptions\PermissionDenied + * @return string|\Federator\Exceptions\PermissionDenied + */ + public function checkSignature($headers) + { + return new \Federator\Exceptions\PermissionDenied("Dummy connector: no signature check"); + } } namespace Federator; diff --git a/plugins/federator/rediscache.php b/plugins/federator/rediscache.php index 5c96eeb..73c8cb5 100644 --- a/plugins/federator/rediscache.php +++ b/plugins/federator/rediscache.php @@ -41,6 +41,13 @@ class RedisCache implements Cache */ private $userTTL; + /** + * public key cache time to live in secods + * + * @var int $publicKeyPemTTL + */ + private $publicKeyPemTTL; + /** * constructor */ @@ -50,6 +57,8 @@ class RedisCache implements Cache if ($config !== false) { $this->config = $config; $this->userTTL = array_key_exists('userttl', $config) ? intval($config['userttl'], 10) : 60; + $this->publicKeyPemTTL = array_key_exists('publickeypemttl', $config) + ? intval($config['publickeypemttl'], 10) : 3600; } } @@ -59,10 +68,20 @@ class RedisCache implements Cache */ private function connect() { - $this->redis = new \Redis(); - $this->redis->pconnect($this->config['host'], intval($this->config['port'], 10)); - // @phan-suppress-next-line PhanTypeMismatchArgumentInternalProbablyReal - $this->redis->auth([$this->config['username'], $this->config['password']]); + $this->redis = new \Redis(); + $this->redis->pconnect($this->config['host'], intval($this->config['port'], 10)); + // @phan-suppress-next-line PhanTypeMismatchArgumentInternalProbablyReal + $this->redis->auth([$this->config['username'], $this->config['password']]); + + // Set the Redis backend for Resque + $redisUrl = sprintf( + 'redis://%s:%s@%s:%d', + urlencode($this->config['username']), + urlencode($this->config['password']), + $this->config['host'], + intval($this->config['port'], 10) + ); + \Resque::setBackend($redisUrl); } /** @@ -77,16 +96,57 @@ class RedisCache implements Cache return $prefix . '_' . md5($input); } + /** + * get followers of given user + * + * @param string $id user id @unused-param + + * @return \Federator\Data\FedUser[]|false + */ + public function getRemoteFollowersOfUser($id) + { + error_log("rediscache::getRemoteFollowersOfUser not implemented"); + return false; + } + + /** + * get following of given user + * + * @param string $id user id @unused-param + + * @return \Federator\Data\FedUser[]|false + */ + public function getRemoteFollowingForUser($id) + { + error_log("rediscache::getRemoteFollowingForUser not implemented"); + return false; + } + + /** + * Convert jsonData to Activity format + * + * @param array $jsonData the json data from our platfrom @unused-param + * @param string $articleId the original id of the article (if applicable) + * (used to identify the article in the remote system) @unused-param + * @return \Federator\Data\ActivityPub\Common\Activity|false + */ + public function jsonToActivity(array $jsonData, &$articleId) + { + error_log("rediscache::jsonToActivity not implemented"); + 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 int $min min timestamp @unused-param + * @param int $max max timestamp @unused-param + * @param int $limit limit results @unused-param - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @return \Federator\Data\ActivityPub\Common\Activity[]|false */ - public function getRemotePostsByUser($id, $minId, $maxId) + public function getRemotePostsByUser($id, $min, $max, $limit) { error_log("rediscache::getRemotePostsByUser not implemented"); return false; @@ -131,6 +191,26 @@ class RedisCache implements Cache return $user; } + /** + * get remote federation user by given name + * + * @param string $_name user/profile name + * @return \Federator\Data\FedUser | false + */ + public function getRemoteFedUserByName(string $_name) + { + if (!$this->connected) { + $this->connect(); + } + $key = self::createKey('u', $_name); + $data = $this->redis->get($key); + if ($data === false) { + return false; + } + $user = \Federator\Data\FedUser::createFromJson($data); + return $user; + } + /** * get remote user by given session * @@ -152,14 +232,56 @@ class RedisCache implements Cache return $user; } + /** + * Retrieve the public key for a given keyId + * + * @param string $keyId The keyId (e.g., actor URL + #main-key) + * @return string|false The cached public key PEM or false if not found + */ + public function getPublicKey(string $keyId) + { + if (!$this->connected) { + $this->connect(); + } + $key = self::createKey('publickey', $keyId); + return $this->redis->get($key); + } + + /** + * save remote followers by user + * + * @param string $user user name @unused-param + * @param \Federator\Data\FedUser[]|false $followers user followers @unused-param + * @return void + */ + public function saveRemoteFollowersOfUser($user, $followers) + { + error_log("rediscache::saveRemoteFollowersOfUser not implemented"); + } + + /** + * save remote following for user + * + * @param string $user user name @unused-param + * @param \Federator\Data\FedUser[]|false $following user following @unused-param + * @return void + */ + public function saveRemoteFollowingForUser($user, $following) + { + error_log("rediscache::saveRemoteFollowingForUser not implemented"); + } + /** * save remote posts by user * * @param string $user user name @unused-param + * @param int $min min timestamp @unused-param + * @param int $max max timestamp @unused-param + * @param int $limit limit results @unused-param * @param \Federator\Data\ActivityPub\Common\APObject[]|false $posts user posts @unused-param * @return void */ - public function saveRemotePostsByUser($user, $posts) + public function saveRemotePostsByUser($user, $min, $max, $limit, $posts) { error_log("rediscache::saveRemotePostsByUser not implemented"); } @@ -179,6 +301,7 @@ class RedisCache implements Cache $serialized = $stats->toJson(); $this->redis->setEx($key, $this->config['statsttl'], $serialized); } + /** * save remote user by name * @@ -187,6 +310,23 @@ class RedisCache implements Cache * @return void */ public function saveRemoteUserByName($_name, $user) + { + if (!$this->connected) { + $this->connect(); + } + $key = self::createKey('u', $_name); + $serialized = $user->toJson(); + $this->redis->setEx($key, $this->userTTL, $serialized); + } + + /** + * save remote federation user by given name + * + * @param string $_name user/profile name + * @param \Federator\Data\FedUser $user user data + * @return void + */ + public function saveRemoteFedUserByName(string $_name, \Federator\Data\FedUser $user) { $key = self::createKey('u', $_name); $serialized = $user->toJson(); @@ -205,7 +345,47 @@ class RedisCache implements Cache { $key = self::createKey('s', $_session . $_user); $serialized = $user->toJson(); - $this->redis->setEx($key, $this->userTTL, $serialized,); + $this->redis->setEx($key, $this->userTTL, $serialized); + } + + /** + * Save the public key for a given keyId + * + * @param string $keyId The keyId (e.g., actor URL + #main-key) + * @param string $publicKeyPem The public key PEM to cache + * @return void + */ + public function savePublicKey(string $keyId, string $publicKeyPem) + { + if (!$this->connected) { + $this->connect(); + } + $key = self::createKey('publickey', $keyId); + $this->redis->setEx($key, $this->publicKeyPemTTL, $publicKeyPem); + } + + /** + * send target-friendly json from ActivityPub activity + * + * @param \Federator\Data\FedUser $sender the user of the sender @unused-param + * @param \Federator\Data\ActivityPub\Common\Activity $activity the activity @unused-param + * @return boolean did we successfully send the activity? + */ + public function sendActivity($sender, $activity) + { + return false; + } + + /** + * check if the headers include a valid signature + * + * @param string[] $headers the headers @unused-param + * @throws \Federator\Exceptions\PermissionDenied + * @return string|\Federator\Exceptions\PermissionDenied + */ + public function checkSignature($headers) + { + return new \Federator\Exceptions\PermissionDenied("RedisCache: no signature check"); } } diff --git a/rediscache.ini b/rediscache.ini index 5d55955..c12d1ef 100644 --- a/rediscache.ini +++ b/rediscache.ini @@ -4,4 +4,5 @@ port = 6379 username = federator password = redis*change*password userttl = 10 +publickeypemttl = 3600 statsttl = 60 diff --git a/sql/2024-07-24.sql b/sql/2024-07-24.sql new file mode 100644 index 0000000..3459a47 --- /dev/null +++ b/sql/2024-07-24.sql @@ -0,0 +1,2 @@ +create table outbox (`id` char(32) unique primary key, `user` varchar(255), index(user), `timestamp` timestamp, `type` enum ("article", "note"), index(type), `externalid` varchar(255), index(externalid), `apjson` text); +update settings set `value`="2024-07-24" where `key`="database_version"; \ No newline at end of file diff --git a/sql/2024-07-30.sql b/sql/2024-07-30.sql new file mode 100644 index 0000000..0e46a4a --- /dev/null +++ b/sql/2024-07-30.sql @@ -0,0 +1,2 @@ +create table webfinger (`subject` varchar(255) unique primary key, `timestamp` timestamp, index(`timestamp`), `aliases` text, `links` text); +update settings set `value`="2024-07-22" where `key`="database_version"; \ No newline at end of file diff --git a/sql/2024-08-19.sql b/sql/2024-08-19.sql new file mode 100644 index 0000000..90e2760 --- /dev/null +++ b/sql/2024-08-19.sql @@ -0,0 +1,3 @@ +create table settings(`key` varchar(255) unique primary key, `value` text); +create table users(`id` varchar(255) unique primary key, `externalid` varchar(255), index(`externalid`), `rsapublic` text, `rsaprivate` text); +insert into settings (`key`, `value`) value ("database_version", "2024-07-19"); diff --git a/sql/2025-05-06.sql b/sql/2025-05-06.sql new file mode 100644 index 0000000..6432661 --- /dev/null +++ b/sql/2025-05-06.sql @@ -0,0 +1,3 @@ +create table follows(`id` varchar(255) unique primary key,`source_user` varchar(255) not null,`target_user` varchar(255) not null,`created_at` timestamp default current_timestamp,unique key `unique_follow` (`source_user`, `target_user`)); +create table fedusers(`id` varchar(255) unique primary key,`url` varchar(255) not null,`name` varchar(255) default '',`publickey` text default '',`summary` text default '',`validuntil` timestamp null default null,`type` enum('person', 'group') default 'person',`inboxurl` varchar(255) default null,`sharedinboxurl` varchar(255) default null,`followersurl` varchar(255) default null,`followingurl` varchar(255) default null,`publickeyid` varchar(255) default null,`outboxurl` varchar(255) default null,unique key `unique_feduser` (`url`),unique key `unique_feduser_id` (`url`)); +update settings set `value`="2025-05-06" where `key`="database_version"; \ No newline at end of file diff --git a/sql/2025-05-19.sql b/sql/2025-05-19.sql new file mode 100644 index 0000000..f0e0bb0 --- /dev/null +++ b/sql/2025-05-19.sql @@ -0,0 +1,2 @@ +create table posts(`id` varchar(255) primary key,`url` varchar(255) not null,`user_id` varchar(255) not null,`actor` varchar(255) not null,`type` varchar(255) not null default 'note',`object` text default null,`to` text default null,`cc` text default null,`published` timestamp not null default current_timestamp); +update settings set `value`="2025-05-19" where `key`="database_version"; \ No newline at end of file diff --git a/sql/2025-05-27.sql b/sql/2025-05-27.sql new file mode 100644 index 0000000..0cc7654 --- /dev/null +++ b/sql/2025-05-27.sql @@ -0,0 +1,2 @@ +alter table posts add `article_id` varchar(255) null default null comment 'The optional original article id (of non-federated system, e.g. CN)'; +update settings set `value`="2025-05-27" where `key`="database_version"; diff --git a/templates/federator/host-meta.xml b/templates/federator/host-meta.xml index 0b2b948..aa34ea7 100644 --- a/templates/federator/host-meta.xml +++ b/templates/federator/host-meta.xml @@ -1,4 +1,4 @@ -version="1.0" encoding="UTF-8"?> + diff --git a/templates/federator/user.json b/templates/federator/user.json index 752eb80..13a2aaf 100644 --- a/templates/federator/user.json +++ b/templates/federator/user.json @@ -58,14 +58,14 @@ {rdelim} {rdelim} ], - "id":"https://{$fqdn}/users/{$username}", + "id":"https://{$fqdn}/{$username}", "type":"{$type}", - "following":"https://{$fqdn}/users/{$username}/following", - "followers":"https://{$fqdn}/users/{$username}/followers", - "inbox":"https://{$fqdn}/users/{$username}/inbox", - "outbox":"https://{$fqdn}/users/{$username}/outbox", - "featured":"https://{$fqdn}/users/{$username}/collections/featured", - "featuredTags":"https://{$fqdn}/users/{$username}/collections/tags", + "following":"https://{$fqdn}/{$username}/following", + "followers":"https://{$fqdn}/{$username}/followers", + "inbox":"https://{$fqdn}/{$username}/inbox", + "outbox":"https://{$fqdn}/{$username}/outbox", + "featured":"https://{$fqdn}/{$username}/collections/featured", + "featuredTags":"https://{$fqdn}/{$username}/collections/tags", "preferredUsername":"{$username}", "name":"{$name}", "summary":"{$summary}", @@ -74,8 +74,8 @@ "discoverable":true, "published":"{$registered}", "publicKey":{ldelim} - "id":"https://{$fqdn}/users/{$username}#main-key", - "owner":"https://{$fqdn}/users/{$username}", + "id":"https://{$fqdn}/{$username}#main-key", + "owner":"https://{$fqdn}/{$username}", "publicKeyPem":"{$publickey}" {rdelim}, "tag":[], diff --git a/templates/federator/webfinger_acct.json b/templates/federator/webfinger_acct.json index 39978f4..9d043d7 100644 --- a/templates/federator/webfinger_acct.json +++ b/templates/federator/webfinger_acct.json @@ -1,8 +1,7 @@ {ldelim} "subject": "acct:{$username}@{$domain}", "aliases": [ - "https://{$domain}/@{$username}", - "https://{$domain}/users/{$username}" + "https://{$domain}/@{$username}" ], "links": [ {ldelim}"rel": "self", "type": "application/activity+json", "href": "https://{$domain}/{$username}"{rdelim},