diff --git a/README.md b/README.md index 37a2927..51df547 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,10 @@ To configure an apache server, add the following rewrite rules: RewriteRule ^@(.*)$ /federator.php?_call=fedusers/$1 [L,END] RewriteRule ^users/(.*)$ /federator.php?_call=fedusers/$1 [L,END] RewriteRule ^inbox[/]?$ /federator.php?_call=fedusers/inbox [L,END] - RewriteRule ^api/federator/(.+)$ federator.php?_call=$1 [L,END] + RewriteRule ^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/htdocs/index.html b/htdocs/index.html index 7347200..61ab824 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -17,7 +17,7 @@
+ placeholder="Enter target link" value="users/grumpydevelop/outbox?page=0"> setContentType("application/activity+json"); break; case 'v1': switch ($this->paths[1]) { @@ -110,6 +111,39 @@ class Api extends Main case 'newcontent': $handler = new Api\V1\NewContent($this); break; + /* case 'sendFollow': { // hacky implementation for testing purposes + $username = $this->paths[2]; + $domain = $this->config['generic']['externaldomain']; + $response = \Federator\DIO\Followers::sendFollowRequest( + $this->dbh, + $this->connector, + $this->cache, + $username, + "admin@mastodon.local", + $domain + ); + header("Content-type: " . $this->contentType); + header("Access-Control-Allow-Origin: *"); + header("Cache-Control: no-cache, no-store, must-revalidate"); + header("Pragma: no-cache"); + header("Expires: 0"); + if (is_string($response)) { + $this->setResponseCode(200); + $retval = json_encode(array( + "status" => "ok", + "message" => $response + )); + } else { + $this->setResponseCode(500); + $retval = json_encode(array( + "status" => "error", + "message" => "Failed to send follow request" + )); + } + http_response_code($this->responseCode); + echo $retval; + return; + } */ } break; } diff --git a/php/federator/api/fedusers.php b/php/federator/api/fedusers.php index 7e530dd..a6da348 100644 --- a/php/federator/api/fedusers.php +++ b/php/federator/api/fedusers.php @@ -50,21 +50,10 @@ class FedUsers implements APIInterface $method = $_SERVER["REQUEST_METHOD"]; $handler = null; $_username = $paths[1]; - if (preg_match("#^([^@]+)@([^/]+)#", $_username, $matches) != 1) { - $hostUrl = $this->main->getHost(); - if ($hostUrl !== false) { - $host = parse_url($hostUrl, PHP_URL_HOST); - $port = parse_url($hostUrl, PHP_URL_PORT); - if ($port !== null) { - $host .= ":$port"; - } - $_username = "$_username@$host"; - } - } switch (sizeof($paths)) { case 2: if ($method === 'GET') { - // /fedusers/username or /@username + // /users/username or /@username or /username return $this->returnUserProfile($_username); } else { switch ($paths[1]) { @@ -78,7 +67,7 @@ class FedUsers implements APIInterface } break; case 3: - // /fedusers/username/(inbox|outbox|following|followers) + // /users/username/(inbox|outbox|following|followers) switch ($paths[2]) { case 'following': // $handler = new FedUsers\Following(); @@ -95,7 +84,7 @@ class FedUsers implements APIInterface } break; case 4: - // /fedusers/username/collections/(features|tags) + // /users/username/collections/(features|tags) // not yet implemented break; } @@ -153,7 +142,7 @@ class FedUsers implements APIInterface '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; diff --git a/php/federator/api/fedusers/inbox.php b/php/federator/api/fedusers/inbox.php index 627cf30..970ad5e 100644 --- a/php/federator/api/fedusers/inbox.php +++ b/php/federator/api/fedusers/inbox.php @@ -60,7 +60,6 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface } $activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null; - $host = $_SERVER['SERVER_NAME']; if (!is_array($activity)) { error_log("Inbox::post Input wasn't of type array"); @@ -69,10 +68,10 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface $inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity); + $rootDir = $_SERVER['DOCUMENT_ROOT'] . '../'; + // Shared inbox if (!isset($_user)) { - $rootDir = $_SERVER['DOCUMENT_ROOT'] . '../'; - // Save the raw input and parsed JSON to a file for inspection file_put_contents( $rootDir . 'logs/inbox.log', @@ -89,12 +88,15 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface $sendTo = $inboxActivity->getCC(); if ($inboxActivity->getType() === 'Undo') { // for undo the object holds the proper cc $object = $inboxActivity->getObject(); - if ($object !== null) { + if ($object !== null && is_object($object)) { $sendTo = $object->getCC(); } } $users = []; + $dbh = $this->main->getDatabase(); + $cache = $this->main->getCache(); + $connector = $this->main->getConnector(); foreach ($sendTo as $receiver) { if ($receiver === '' || !is_string($receiver)) { @@ -102,9 +104,23 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface } if (str_ends_with($receiver, '/followers')) { - $followers = $this->fetchAllFollowers($receiver, $host); + $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"); + continue; + } + $followers = \Federator\DIO\Followers::getFollowersByFedUser($dbh, $connector, $cache, $username . '@' . $domain); + if (is_array($followers)) { - $users = array_merge($users, $followers); + $users = array_merge($users, array_column($followers, 'id')); } } } @@ -116,7 +132,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface continue; } - $this->postForUser($user, $inboxActivity); + $this->postForUser($dbh, $connector, $cache, $user, $inboxActivity); } return json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); } @@ -124,17 +140,16 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface /** * 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 to add data to inbox * @param \Federator\Data\ActivityPub\Common\Activity $inboxActivity the activity that we received * @return boolean response */ - private function postForUser($_user, $inboxActivity) + private static function postForUser($dbh, $connector, $cache, $_user, $inboxActivity) { if (isset($_user)) { - $dbh = $this->main->getDatabase(); - $cache = $this->main->getCache(); - $connector = $this->main->getConnector(); - // get user $user = \Federator\DIO\User::getUserByName( $dbh, @@ -142,7 +157,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface $connector, $cache ); - if ($user->id === null) { + if ($user === null || $user->id === null) { error_log("Inbox::postForUser couldn't find user: $_user"); return false; } @@ -158,64 +173,4 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface return true; } - - /** - * fetch all followers from url and return the ones that belong to our server - * - * @param string $collectionUrl The url of f.e. the posters followers - * @param string $host our current host-url - * @return string[] the names of the followers that are hosted on our server - */ - private static function fetchAllFollowers(string $collectionUrl, string $host): array - { - $users = []; - - [$collectionResponse, $collectionInfo] = \Federator\Main::getFromRemote($collectionUrl, ['Accept: application/activity+json']); - if ($collectionInfo['http_code'] != 200) { - error_log("Inbox::fetchAllFollowers Failed to fetch follower collection metadata from $collectionUrl"); - return []; - } - - $collectionData = json_decode($collectionResponse, true); - $nextPage = $collectionData['first'] ?? $collectionData['current'] ?? null; - - if (!isset($nextPage)) { - error_log("Inbox::fetchAllFollowers No 'first' or 'current' page in collection at $collectionUrl"); - return []; - } - - // Loop through all pages - while ($nextPage) { - [$pageResponse, $pageInfo] = \Federator\Main::getFromRemote($nextPage, ['Accept: application/activity+json']); - if ($pageInfo['http_code'] != 200) { - error_log("Inbox::fetchAllFollowers Failed to fetch follower page at $nextPage"); - break; - } - - $pageData = json_decode($pageResponse, true); - $items = $pageData['orderedItems'] ?? $pageData['items'] ?? []; - - foreach ($items as $followerUrl) { - $parts = parse_url($followerUrl); - if (!isset($parts['host']) || !str_ends_with($parts['host'], $host)) { - continue; - } - - [$actorResponse, $actorInfo] = \Federator\Main::getFromRemote($followerUrl, ['Accept: application/activity+json']); - if ($actorInfo['http_code'] != 200) { - error_log("Inbox::fetchAllFollowers Failed to fetch actor data for follower: $followerUrl"); - continue; - } - - $actorData = json_decode($actorResponse, true); - if (isset($actorData['preferredUsername'])) { - $users[] = $actorData['preferredUsername']; - } - } - - $nextPage = $pageData['next'] ?? null; - } - - return $users; - } } diff --git a/php/federator/api/fedusers/outbox.php b/php/federator/api/fedusers/outbox.php index e943dbb..92b1170 100644 --- a/php/federator/api/fedusers/outbox.php +++ b/php/federator/api/fedusers/outbox.php @@ -66,15 +66,16 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface } else { $items = []; } - $host = $_SERVER['SERVER_NAME']; - $id = 'https://' . $host . '/users/' . $_user . '/outbox'; + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; + $id = 'https://' . $domain . '/users/' . $_user . '/outbox'; $outbox->setPartOf($id); $outbox->setID($id); if ($page !== '') { $id .= '?page=' . urlencode($page); } if ($page === '' || $outbox->count() == 0) { - $outbox->setFirst($id); + $outbox->setFirst($id . '?page=0'); $outbox->setLast($id . '&min=0'); } if (sizeof($items) > 0) { @@ -84,7 +85,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface $outbox->setPrev($id . '&min=' . $oldestId); } $obj = $outbox->toObject(); - return json_encode($obj); + return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); } /** diff --git a/php/federator/api/v1/dummy.php b/php/federator/api/v1/dummy.php index 934de18..2769bd6 100644 --- a/php/federator/api/v1/dummy.php +++ b/php/federator/api/v1/dummy.php @@ -20,12 +20,8 @@ class Dummy implements \Federator\Api\APIInterface */ private $main; - /** - * internal message to output - * - * @var string $message - */ - private $message = ''; + /** @var array $message internal message to output */ + private $message = []; /** * constructor @@ -47,9 +43,9 @@ class Dummy implements \Federator\Api\APIInterface public function exec($paths, $user): bool { // only for user with the 'publish' permission - // if ($user === false || $user->hasPermission('publish') === false) { - // throw new \Federator\Exceptions\PermissionDenied(); - // } + if ($user === false || $user->hasPermission('publish') === false) { + throw new \Federator\Exceptions\PermissionDenied(); + } $method = $_SERVER["REQUEST_METHOD"]; switch ($method) { case 'GET': @@ -87,16 +83,13 @@ class Dummy implements \Federator\Api\APIInterface */ public function getDummy() { - $dummyResponse = json_encode([ + $this->message = [ 'r1' => ' (__) ', 'r2' => ' `------(oo) ', 'r3' => ' || __ (__) ', 'r4' => ' ||w || ', 'r5' => ' ' - ], JSON_PRETTY_PRINT); - if ($dummyResponse !== false) { - $this->message = $dummyResponse; - } + ]; return true; } @@ -117,6 +110,6 @@ class Dummy implements \Federator\Api\APIInterface */ public function toJson() { - return $this->message; + return json_encode($this->message, JSON_PRETTY_PRINT) . "\n"; } } diff --git a/php/federator/api/v1/newcontent.php b/php/federator/api/v1/newcontent.php index 450ef18..3a6b01b 100644 --- a/php/federator/api/v1/newcontent.php +++ b/php/federator/api/v1/newcontent.php @@ -71,12 +71,11 @@ class NewContent implements \Federator\Api\APIInterface /** * handle post call * - * @param string|null $_user user that triggered the post + * @param string|null $_user optional user that triggered the post * @return string|false response */ public function post($_user) { - error_log("NewContent::post called with user: $_user"); $_rawInput = file_get_contents('php://input'); $allHeaders = getallheaders(); @@ -89,7 +88,9 @@ class NewContent implements \Federator\Api\APIInterface } $input = is_string($_rawInput) ? json_decode($_rawInput, true) : null; - $host = $_SERVER['SERVER_NAME']; + + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; if (!is_array($input)) { error_log("NewContent::post Input wasn't of type array"); return false; @@ -98,7 +99,8 @@ class NewContent implements \Federator\Api\APIInterface if (isset($allHeaders['X-Sender'])) { $newActivity = $this->main->getConnector()->jsonToActivity($input); } else { - $newActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($input); + error_log("NewContent::post No X-Sender header found"); + return false; } if ($newActivity === false) { @@ -106,27 +108,26 @@ class NewContent implements \Federator\Api\APIInterface return false; } - $sendTo = $newActivity->getCC(); - if ($newActivity->getType() === 'Undo') { - $object = $newActivity->getObject(); - if ($object !== null) { - $sendTo = $object->getCC(); - } + $dbh = $this->main->getDatabase(); + $cache = $this->main->getCache(); + $connector = $this->main->getConnector(); + + if (!isset($_user)) { + $user = $newActivity->getAActor(); // url of the sender https://contentnation.net/username + $user = str_replace( + $domain, + '', + $user + ); // retrieve only the last part of the url + } else { + $user = $dbh->real_escape_string($_user); } $users = []; - foreach ($sendTo as $receiver) { - if ($receiver === '' || !is_string($receiver)) { - continue; - } - - if (str_ends_with($receiver, '/followers')) { - $followers = $this->fetchAllFollowers($receiver, $host); - if (is_array($followers)) { - $users = array_merge($users, $followers); - } - } + $followers = $this->fetchAllFollowers($dbh, $connector, $cache, $user); + if (!empty($followers)) { + $users = array_merge($users, $followers); } if (empty($users)) { // todo remove after proper implementation, debugging for now @@ -147,7 +148,7 @@ class NewContent implements \Federator\Api\APIInterface continue; } - $this->postForUser($user, $newActivity); + $this->postForUser($dbh, $connector, $cache, $user, $newActivity); } return json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); @@ -156,28 +157,33 @@ class NewContent implements \Federator\Api\APIInterface /** * 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 $_user user that triggered the post * @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received * @return boolean response */ - private function postForUser($_user, $newActivity) + private static function postForUser($dbh, $connector, $cache, $_user, $newActivity) { - if (isset($_user)) { - $dbh = $this->main->getDatabase(); - $cache = $this->main->getCache(); - $connector = $this->main->getConnector(); + if (!isset($_user)) { + error_log("NewContent::postForUser no user given"); + return false; + } - // get user - $user = \Federator\DIO\User::getUserByName( - $dbh, - $_user, - $connector, - $cache - ); - if ($user->id === null) { - error_log("NewContent::postForUser couldn't find user: $_user"); - return false; - } + // get user + $user = \Federator\DIO\User::getUserByName( + $dbh, + $_user, + $connector, + $cache + ); + if ($user->id === null) { + error_log("NewContent::postForUser couldn't find user: $_user"); + return false; } $rootDir = $_SERVER['DOCUMENT_ROOT'] . '../'; @@ -194,58 +200,32 @@ class NewContent implements \Federator\Api\APIInterface /** * fetch all followers from url and return the ones that belong to our server * - * @param string $collectionUrl The url of f.e. the posters followers - * @param string $host our current host-url + * @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 $userId The id of the user * @return string[] the names of the followers that are hosted on our server */ - private static function fetchAllFollowers(string $collectionUrl, string $host): array + private static function fetchAllFollowers($dbh, $connector, $cache, string $userId): array { + if (empty($userId)) { + return []; + } + $users = []; - [$collectionResponse, $collectionInfo] = \Federator\Main::getFromRemote($collectionUrl, ['Accept: application/activity+json']); - if ($collectionInfo['http_code'] != 200) { - error_log("NewContent::fetchAllFollowers Failed to fetch follower collection metadata from $collectionUrl"); - return []; - } + $apFollowers = \Federator\DIO\Followers::getFollowersByUser( + $dbh, + $userId, + $connector, + cache: $cache, + ); - $collectionData = json_decode($collectionResponse, true); - $nextPage = $collectionData['first'] ?? $collectionData['current'] ?? null; - - if (!isset($nextPage)) { - error_log("NewContent::fetchAllFollowers No 'first' or 'current' page in collection at $collectionUrl"); - return []; - } - - // Loop through all pages - while ($nextPage) { - [$pageResponse, $pageInfo] = \Federator\Main::getFromRemote($nextPage, ['Accept: application/activity+json']); - if ($pageInfo['http_code'] != 200) { - error_log("NewContent::fetchAllFollowers Failed to fetch follower page at $nextPage"); - break; - } - - $pageData = json_decode($pageResponse, true); - $items = $pageData['orderedItems'] ?? $pageData['items'] ?? []; - - foreach ($items as $followerUrl) { - $parts = parse_url($followerUrl); - if (!isset($parts['host']) || !str_ends_with($parts['host'], $host)) { - continue; - } - - [$actorResponse, $actorInfo] = \Federator\Main::getFromRemote($followerUrl, ['Accept: application/activity+json']); - if ($actorInfo['http_code'] != 200) { - error_log("NewContent::fetchAllFollowers Failed to fetch actor data for follower: $followerUrl"); - continue; - } - - $actorData = json_decode($actorResponse, true); - if (isset($actorData['preferredUsername'])) { - $users[] = $actorData['preferredUsername']; - } - } - - $nextPage = $pageData['next'] ?? null; + foreach ($apFollowers as $follower) { + $users[] = $follower->id; } return $users; diff --git a/php/federator/api/wellknown.php b/php/federator/api/wellknown.php index 6b1e76d..28e3746 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; diff --git a/php/federator/api/wellknown/nodeinfo.php b/php/federator/api/wellknown/nodeinfo.php index 0d5b251..250d0a3 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') { diff --git a/php/federator/cache/cache.php b/php/federator/cache/cache.php index 5f3878d..3742b8c 100644 --- a/php/federator/cache/cache.php +++ b/php/federator/cache/cache.php @@ -17,7 +17,7 @@ interface Cache extends \Federator\Connector\Connector * save remote followers of user * * @param string $user user name - * @param \Federator\Data\ActivityPub\Common\APObject[]|false $followers user followers + * @param \Federator\Data\FedUser[]|false $followers user followers * @return void */ public function saveRemoteFollowersOfUser($user, $followers); @@ -48,6 +48,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 * @@ -67,6 +76,14 @@ interface Cache extends \Federator\Connector\Connector */ 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 * diff --git a/php/federator/connector/connector.php b/php/federator/connector/connector.php index 3a49c5a..02ba83d 100644 --- a/php/federator/connector/connector.php +++ b/php/federator/connector/connector.php @@ -18,7 +18,7 @@ interface Connector * * @param string $id user id - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @return \Federator\Data\FedUser[]|false */ public function getRemoteFollowersOfUser($id); diff --git a/php/federator/data/activitypub/common/Announce.php b/php/federator/data/activitypub/common/Announce.php index d50a932..f3b5035 100644 --- a/php/federator/data/activitypub/common/Announce.php +++ b/php/federator/data/activitypub/common/Announce.php @@ -25,8 +25,6 @@ class Announce extends Activity { $return = parent::toObject(); $return['type'] = 'Announce'; - // overwrite id from url - $return['id'] = $this->getURL(); return $return; } diff --git a/php/federator/data/activitypub/common/Undo.php b/php/federator/data/activitypub/common/Undo.php index 28ec50b..5a42c49 100644 --- a/php/federator/data/activitypub/common/Undo.php +++ b/php/federator/data/activitypub/common/Undo.php @@ -25,8 +25,6 @@ class Undo extends Activity { $return = parent::toObject(); $return['type'] = 'Undo'; - // overwrite id from url - $return['id'] = $this->getURL(); return $return; } diff --git a/php/federator/data/activitypub/common/accept.php b/php/federator/data/activitypub/common/accept.php new file mode 100644 index 0000000..c0eef65 --- /dev/null +++ b/php/federator/data/activitypub/common/accept.php @@ -0,0 +1,18 @@ +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/apobject.php b/php/federator/data/activitypub/common/apobject.php index 62c2886..6d29cac 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; @@ -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() { @@ -750,8 +750,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']; @@ -890,7 +890,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/delete.php b/php/federator/data/activitypub/common/delete.php new file mode 100644 index 0000000..cdceb00 --- /dev/null +++ b/php/federator/data/activitypub/common/delete.php @@ -0,0 +1,54 @@ +object = $object; + } + + public function __construct() + { + parent::__construct('Delete'); + parent::addContext('https://www.w3.org/ns/activitystreams'); + } + + /** + * create from json/array + * @param mixed $json + */ + 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/follow.php b/php/federator/data/activitypub/common/follow.php new file mode 100644 index 0000000..cafcfcf --- /dev/null +++ b/php/federator/data/activitypub/common/follow.php @@ -0,0 +1,50 @@ +object = $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/reject.php b/php/federator/data/activitypub/common/reject.php new file mode 100644 index 0000000..66424f9 --- /dev/null +++ b/php/federator/data/activitypub/common/reject.php @@ -0,0 +1,18 @@ +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/feduser.php b/php/federator/dio/feduser.php new file mode 100644 index 0000000..65cd46b --- /dev/null +++ b/php/federator/dio/feduser.php @@ -0,0 +1,242 @@ +prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $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(); + } + $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(); + } + $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(); + } + $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, followersurl,'; + $sql .= ' followingurl,publickeyid,outboxurl'; + $sql .= ' from fedusers where id=? and validuntil>=now()'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $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(); + } + $r = json_decode($response, true); + if ($r === false || $r === null || !is_array($r)) { + throw new \Federator\Exceptions\ServerError(); + } + // 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(); + } + // 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(); + } + $r = json_decode($response, true); + if ($r === false || $r === null || !is_array($r)) { + throw new \Federator\Exceptions\ServerError(); + } + $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(); + } + $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(); + } + return $user; + } +} diff --git a/php/federator/dio/followers.php b/php/federator/dio/followers.php index 5972b1b..4d8a814 100644 --- a/php/federator/dio/followers.php +++ b/php/federator/dio/followers.php @@ -13,11 +13,10 @@ namespace Federator\DIO; */ class Followers { - /** * get followers of user * - * @param \mysqli $dbh @unused-param + * @param \mysqli $dbh * database handle * @param string $id * user id @@ -25,7 +24,7 @@ class Followers * connector to fetch use with * @param \Federator\Cache\Cache|null $cache * optional caching service - * @return \Federator\Data\ActivityPub\Common\APObject[] + * @return \Federator\Data\FedUser[] */ public static function getFollowersByUser($dbh, $id, $connector, $cache) { @@ -37,7 +36,29 @@ class Followers } } $followers = []; - // TODO: check our db + $sql = 'select source_user from follows where target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $stmt->bind_param("s", $id); + $stmt->execute(); + $followerIds = []; + $stmt->bind_result($sourceUser); + while ($stmt->fetch()) { + $followerIds[] = $sourceUser; + } + $stmt->close(); + foreach ($followerIds as $followerId) { + $user = \Federator\DIO\FedUser::getUserByName( + $dbh, + $followerId, + $cache, + ); + if ($user !== false && $user->id !== null) { + $followers[] = $user; + } + } if ($followers === []) { // ask connector for user-id @@ -52,4 +73,263 @@ class Followers } return $followers; } + /** + * get followers of user + * + * @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(); + } + $stmt->bind_param("s", $id); + $stmt->execute(); + $followerIds = []; + $stmt->bind_result($sourceUser); + while ($stmt->fetch()) { + $followerIds[] = $sourceUser; + } + foreach ($followerIds as $followerId) { + $user = \Federator\DIO\User::getUserByName( + $dbh, + $followerId, + $connector, + $cache + ); + 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(); + } + $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(); + } + $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(); + } + $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(); + } + $stmt->bind_param("sss", $idurl, $sourceUser, $targetUserId); + $stmt->execute(); + $stmt->close(); + return $idurl; // Return the generated follow ID + } + + /** + * remove follow + * + * @param \mysqli $dbh database handle + * @param string $sourceUser source user id + * @param string $targetUserId target user id + * @return bool true on success + */ + public static function removeFollow($dbh, $sourceUser, $targetUserId) + { + $sql = 'delete from follows where source_user = ? and target_user = ?'; + $stmt = $dbh->prepare($sql); + if ($stmt === false) { + throw new \Federator\Exceptions\ServerError(); + } + $stmt->bind_param("ss", $sourceUser, $targetUserId); + $stmt->execute(); + $affectedRows = $stmt->affected_rows; + $stmt->close(); + return $affectedRows > 0; + } } diff --git a/php/federator/dio/user.php b/php/federator/dio/user.php index ac936e7..845e8bd 100644 --- a/php/federator/dio/user.php +++ b/php/federator/dio/user.php @@ -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,'; @@ -100,13 +101,37 @@ 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(); + } + $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); diff --git a/php/federator/main.php b/php/federator/main.php index d588c30..f63f8ee 100644 --- a/php/federator/main.php +++ b/php/federator/main.php @@ -280,6 +280,16 @@ class Main $this->host = $host; } + /** + * set content type + * + * @param string $_type content type + */ + public function setContentType($_type): void + { + $this->contentType = $_type; + } + /** * set response code * diff --git a/plugins/federator/contentnation.php b/plugins/federator/contentnation.php index f0704af..fd9bf43 100644 --- a/plugins/federator/contentnation.php +++ b/plugins/federator/contentnation.php @@ -54,11 +54,11 @@ class ContentNation implements Connector * get followers of given user * * @param string $userId user id - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @return \Federator\Data\FedUser[]|false */ public function getRemoteFollowersOfUser($userId) { - // todo implement queue for this, move to DIO + // todo implement queue for this if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) { $userId = $matches[1]; } @@ -109,25 +109,25 @@ 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']; $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->setAActor('https://' . $domain . '/' . $userId); $create->setID($activity['id']) - ->setPublished($activity['timestamp']) + ->setPublished($activity['published'] ?? $activity['timestamp']) ->addTo("https://www.w3.org/ns/activitystreams#Public") - ->addCC('https://' . $host . '/' . $userId . '/followers.json'); + ->addCC('https://' . $domain . '/' . $userId . '/followers'); + switch ($activity['type']) { case 'Article': - $create->setURL('https://' . $host . '/' . $activity['language'] . '/' . $userId . '/' - . $activity['name']); + $create->setURL('https://' . $domain . '/' . $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 = 'https://' . $domain . '/search.htm?tagsearch=' . urlencode($tag); $tagObj = new \Federator\Data\ActivityPub\Common\Tag(); $tagObj->setHref($href) ->setName('#' . urlencode(str_replace(' ', '', $tag))) @@ -137,7 +137,7 @@ class ContentNation implements Connector } $apArticle->setPublished($activity['published']) ->setName($activity['title']) - ->setAttributedTo('https://' . $host . '/' . $activity['profilename']) + ->setAttributedTo('https://' . $domain . '/' . $activity['profilename']) ->setContent( $activity['teaser'] ?? $this->main->translate( @@ -147,11 +147,10 @@ class ContentNation implements Connector ) ) ->addTo("https://www.w3.org/ns/activitystreams#Public") - ->addCC('https://' . $host . '/' . $userId . '/followers.json'); + ->addCC('https://' . $domain . '/' . $userId . '/followers.json'); $articleimage = $activity['imagealt'] ?? $this->main->translate($activity['language'], 'article', 'image'); - $idurl = 'https://' . $host . '/' . $activity['language'] - . '/' . $userId . '/' . $activity['name']; + $idurl = 'https://' . $domain . '/' . $userId . '/' . $activity['name']; $apArticle->setID($idurl) ->setURL($idurl); $image = $activity['image'] ?? $activity['profileimg']; @@ -176,7 +175,7 @@ class ContentNation implements Connector $posts[] = $create; break; // Comment case 'Vote': - $url = 'https://' . $host . '/' . $activity['articlelang'] . $userId . '/' + $url = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename']; $url .= '/vote/' . $activity['id']; $create->setURL($url); @@ -204,8 +203,7 @@ class ContentNation implements Connector $actor = new \Federator\Data\ActivityPub\Common\APObject('Person'); $actor->setName($activity['username']); $like->setActor($actor); - $url = 'https://' . $host . '/' . $activity['articlelang'] - . '/' . $userId . '/' . $activity['articlename']; + $url = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename']; if ($activity['comment'] !== '') { $url .= '/comment/' . $activity['comment']; } diff --git a/plugins/federator/dummyconnector.php b/plugins/federator/dummyconnector.php index 2871f70..ba4b373 100644 --- a/plugins/federator/dummyconnector.php +++ b/plugins/federator/dummyconnector.php @@ -23,7 +23,7 @@ class DummyConnector implements Connector * get followers of given user * * @param string $userId user id @unused-param - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @return \Federator\Data\FedUser[]|false */ public function getRemoteFollowersOfUser($userId) { diff --git a/plugins/federator/rediscache.php b/plugins/federator/rediscache.php index 6768e2a..dc466a1 100644 --- a/plugins/federator/rediscache.php +++ b/plugins/federator/rediscache.php @@ -90,7 +90,7 @@ class RedisCache implements Cache * * @param string $id user id @unused-param - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @return \Federator\Data\FedUser[]|false */ public function getRemoteFollowersOfUser($id) { @@ -163,6 +163,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 * @@ -203,7 +223,7 @@ class RedisCache implements Cache * save remote followers by user * * @param string $user user name @unused-param - * @param \Federator\Data\ActivityPub\Common\APObject[]|false $followers user followers @unused-param + * @param \Federator\Data\FedUser[]|false $followers user followers @unused-param * @return void */ public function saveRemoteFollowersOfUser($user, $followers) @@ -252,6 +272,20 @@ class RedisCache implements Cache $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(); + $this->redis->setEx($key, $this->userTTL, $serialized); + } + /** * save remote user by given session * 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/templates/federator/user.json b/templates/federator/user.json index 752eb80..23d225e 100644 --- a/templates/federator/user.json +++ b/templates/federator/user.json @@ -58,7 +58,7 @@ {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", @@ -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":[],