very rudimental follower-support

- fixed rewrites to properly support @username/outbox, username/outbox, users/username/outbox, ...
- initial support for sending follow to e.g. mastodon (code commented out in api.php)
- database migration for follows table and fedusers table
- save retrieved fedusers in cache and db
- depend more on configs externaldomain, less on server_name
- properly implemented following-logic in dio
- made postForuUser static in newContent and inbox
- provide rsaprivate for signing reuests -> actPub-Servers
- change contenttype depending on use-case
- changed user json-template to have loop-back between user-template and webfinger (mastodon f.e. needs this)
This commit is contained in:
Yannis Vogel 2025-05-07 07:57:04 +02:00
parent ce7aa5c72d
commit da18d37a79
No known key found for this signature in database
31 changed files with 1103 additions and 243 deletions

View file

@ -53,9 +53,10 @@ To configure an apache server, add the following rewrite rules:
RewriteRule ^@(.*)$ /federator.php?_call=fedusers/$1 [L,END] RewriteRule ^@(.*)$ /federator.php?_call=fedusers/$1 [L,END]
RewriteRule ^users/(.*)$ /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 ^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 ^(\.well-known/.*)$ /federator.php?_call=$1 [L,END]
RewriteRule ^(nodeinfo/2\.[01])$ /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]
</Directory> </Directory>
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. 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.

View file

@ -17,7 +17,7 @@
<div class="request-box border p-4 rounded-lg mb-4 bg-gray-50 overflow-y-auto"> <div class="request-box border p-4 rounded-lg mb-4 bg-gray-50 overflow-y-auto">
<label class="block font-medium">API target link</label> <label class="block font-medium">API target link</label>
<input type="text" class="target-link-input w-full p-2 border rounded-md mb-2" <input type="text" class="target-link-input w-full p-2 border rounded-md mb-2"
placeholder="Enter target link" value="federator/fedusers/grumpydevelop/outbox?page=0"> placeholder="Enter target link" value="users/grumpydevelop/outbox?page=0">
<label class="block font-medium">Request type</label> <label class="block font-medium">Request type</label>
<input type="text" class="request-type-input w-full p-2 border rounded-md mb-2" <input type="text" class="request-type-input w-full p-2 border rounded-md mb-2"
@ -65,7 +65,7 @@
...(profile ? { "X-Profile": profile } : {}) ...(profile ? { "X-Profile": profile } : {})
}; };
fetch("http://localhost/api/" + targetLink, { fetch("http://localhost/" + targetLink, {
method: requestType, method: requestType,
headers headers
}) })
@ -94,7 +94,7 @@
const container = document.getElementById("request-container"); const container = document.getElementById("request-container");
const requestBox = container.firstElementChild.cloneNode(true); const requestBox = container.firstElementChild.cloneNode(true);
requestBox.querySelector(".target-link-input").value = "federator/fedusers/grumpydevelop@contentnation.net/outbox?page=0"; requestBox.querySelector(".target-link-input").value = "users/grumpydevelop@contentnation.net/outbox?page=0";
requestBox.querySelector(".request-type-input").value = "GET"; requestBox.querySelector(".request-type-input").value = "GET";
requestBox.querySelector(".session-input").value = ""; requestBox.querySelector(".session-input").value = "";
requestBox.querySelector(".profile-input").value = ""; requestBox.querySelector(".profile-input").value = "";

View file

@ -101,6 +101,7 @@ class Api extends Main
break; break;
case 'fedusers': case 'fedusers':
$handler = new Api\FedUsers($this); $handler = new Api\FedUsers($this);
$this->setContentType("application/activity+json");
break; break;
case 'v1': case 'v1':
switch ($this->paths[1]) { switch ($this->paths[1]) {
@ -110,6 +111,39 @@ class Api extends Main
case 'newcontent': case 'newcontent':
$handler = new Api\V1\NewContent($this); $handler = new Api\V1\NewContent($this);
break; 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; break;
} }

View file

@ -50,21 +50,10 @@ class FedUsers implements APIInterface
$method = $_SERVER["REQUEST_METHOD"]; $method = $_SERVER["REQUEST_METHOD"];
$handler = null; $handler = null;
$_username = $paths[1]; $_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)) { switch (sizeof($paths)) {
case 2: case 2:
if ($method === 'GET') { if ($method === 'GET') {
// /fedusers/username or /@username // /users/username or /@username or /username
return $this->returnUserProfile($_username); return $this->returnUserProfile($_username);
} else { } else {
switch ($paths[1]) { switch ($paths[1]) {
@ -78,7 +67,7 @@ class FedUsers implements APIInterface
} }
break; break;
case 3: case 3:
// /fedusers/username/(inbox|outbox|following|followers) // /users/username/(inbox|outbox|following|followers)
switch ($paths[2]) { switch ($paths[2]) {
case 'following': case 'following':
// $handler = new FedUsers\Following(); // $handler = new FedUsers\Following();
@ -95,7 +84,7 @@ class FedUsers implements APIInterface
} }
break; break;
case 4: case 4:
// /fedusers/username/collections/(features|tags) // /users/username/collections/(features|tags)
// not yet implemented // not yet implemented
break; break;
} }
@ -153,7 +142,7 @@ class FedUsers implements APIInterface
'publickey' => trim($jsonKey, '"'), 'publickey' => trim($jsonKey, '"'),
'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z 'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z
'summary' => $user->summary, 'summary' => $user->summary,
'type' => $user->type 'type' => ucfirst($user->type) // capitalized user type
]; ];
$this->response = $this->main->renderTemplate('user.json', $data); $this->response = $this->main->renderTemplate('user.json', $data);
return true; return true;

View file

@ -60,7 +60,6 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
} }
$activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null; $activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
$host = $_SERVER['SERVER_NAME'];
if (!is_array($activity)) { if (!is_array($activity)) {
error_log("Inbox::post Input wasn't of type array"); 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); $inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity);
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
// Shared inbox // Shared inbox
if (!isset($_user)) { if (!isset($_user)) {
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
// Save the raw input and parsed JSON to a file for inspection // Save the raw input and parsed JSON to a file for inspection
file_put_contents( file_put_contents(
$rootDir . 'logs/inbox.log', $rootDir . 'logs/inbox.log',
@ -89,12 +88,15 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
$sendTo = $inboxActivity->getCC(); $sendTo = $inboxActivity->getCC();
if ($inboxActivity->getType() === 'Undo') { // for undo the object holds the proper cc if ($inboxActivity->getType() === 'Undo') { // for undo the object holds the proper cc
$object = $inboxActivity->getObject(); $object = $inboxActivity->getObject();
if ($object !== null) { if ($object !== null && is_object($object)) {
$sendTo = $object->getCC(); $sendTo = $object->getCC();
} }
} }
$users = []; $users = [];
$dbh = $this->main->getDatabase();
$cache = $this->main->getCache();
$connector = $this->main->getConnector();
foreach ($sendTo as $receiver) { foreach ($sendTo as $receiver) {
if ($receiver === '' || !is_string($receiver)) { if ($receiver === '' || !is_string($receiver)) {
@ -102,9 +104,23 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
} }
if (str_ends_with($receiver, '/followers')) { 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)) { 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; continue;
} }
$this->postForUser($user, $inboxActivity); $this->postForUser($dbh, $connector, $cache, $user, $inboxActivity);
} }
return json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); 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 * 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 string $_user user to add data to inbox
* @param \Federator\Data\ActivityPub\Common\Activity $inboxActivity the activity that we received * @param \Federator\Data\ActivityPub\Common\Activity $inboxActivity the activity that we received
* @return boolean response * @return boolean response
*/ */
private function postForUser($_user, $inboxActivity) private static function postForUser($dbh, $connector, $cache, $_user, $inboxActivity)
{ {
if (isset($_user)) { if (isset($_user)) {
$dbh = $this->main->getDatabase();
$cache = $this->main->getCache();
$connector = $this->main->getConnector();
// get user // get user
$user = \Federator\DIO\User::getUserByName( $user = \Federator\DIO\User::getUserByName(
$dbh, $dbh,
@ -142,7 +157,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
$connector, $connector,
$cache $cache
); );
if ($user->id === null) { if ($user === null || $user->id === null) {
error_log("Inbox::postForUser couldn't find user: $_user"); error_log("Inbox::postForUser couldn't find user: $_user");
return false; return false;
} }
@ -158,64 +173,4 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
return true; 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;
}
} }

View file

@ -66,15 +66,16 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
} else { } else {
$items = []; $items = [];
} }
$host = $_SERVER['SERVER_NAME']; $config = $this->main->getConfig();
$id = 'https://' . $host . '/users/' . $_user . '/outbox'; $domain = $config['generic']['externaldomain'];
$id = 'https://' . $domain . '/users/' . $_user . '/outbox';
$outbox->setPartOf($id); $outbox->setPartOf($id);
$outbox->setID($id); $outbox->setID($id);
if ($page !== '') { if ($page !== '') {
$id .= '?page=' . urlencode($page); $id .= '?page=' . urlencode($page);
} }
if ($page === '' || $outbox->count() == 0) { if ($page === '' || $outbox->count() == 0) {
$outbox->setFirst($id); $outbox->setFirst($id . '?page=0');
$outbox->setLast($id . '&min=0'); $outbox->setLast($id . '&min=0');
} }
if (sizeof($items) > 0) { if (sizeof($items) > 0) {
@ -84,7 +85,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
$outbox->setPrev($id . '&min=' . $oldestId); $outbox->setPrev($id . '&min=' . $oldestId);
} }
$obj = $outbox->toObject(); $obj = $outbox->toObject();
return json_encode($obj); return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
} }
/** /**

View file

@ -20,12 +20,8 @@ class Dummy implements \Federator\Api\APIInterface
*/ */
private $main; private $main;
/** /** @var array<string, string> $message internal message to output */
* internal message to output private $message = [];
*
* @var string $message
*/
private $message = '';
/** /**
* constructor * constructor
@ -47,9 +43,9 @@ class Dummy implements \Federator\Api\APIInterface
public function exec($paths, $user): bool public function exec($paths, $user): bool
{ {
// only for user with the 'publish' permission // only for user with the 'publish' permission
// if ($user === false || $user->hasPermission('publish') === false) { if ($user === false || $user->hasPermission('publish') === false) {
// throw new \Federator\Exceptions\PermissionDenied(); throw new \Federator\Exceptions\PermissionDenied();
// } }
$method = $_SERVER["REQUEST_METHOD"]; $method = $_SERVER["REQUEST_METHOD"];
switch ($method) { switch ($method) {
case 'GET': case 'GET':
@ -87,16 +83,13 @@ class Dummy implements \Federator\Api\APIInterface
*/ */
public function getDummy() public function getDummy()
{ {
$dummyResponse = json_encode([ $this->message = [
'r1' => ' (__) ', 'r1' => ' (__) ',
'r2' => ' `------(oo) ', 'r2' => ' `------(oo) ',
'r3' => ' || __ (__) ', 'r3' => ' || __ (__) ',
'r4' => ' ||w || ', 'r4' => ' ||w || ',
'r5' => ' ' 'r5' => ' '
], JSON_PRETTY_PRINT); ];
if ($dummyResponse !== false) {
$this->message = $dummyResponse;
}
return true; return true;
} }
@ -117,6 +110,6 @@ class Dummy implements \Federator\Api\APIInterface
*/ */
public function toJson() public function toJson()
{ {
return $this->message; return json_encode($this->message, JSON_PRETTY_PRINT) . "\n";
} }
} }

View file

@ -71,12 +71,11 @@ class NewContent implements \Federator\Api\APIInterface
/** /**
* handle post call * 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 * @return string|false response
*/ */
public function post($_user) public function post($_user)
{ {
error_log("NewContent::post called with user: $_user");
$_rawInput = file_get_contents('php://input'); $_rawInput = file_get_contents('php://input');
$allHeaders = getallheaders(); $allHeaders = getallheaders();
@ -89,7 +88,9 @@ class NewContent implements \Federator\Api\APIInterface
} }
$input = is_string($_rawInput) ? json_decode($_rawInput, true) : null; $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)) { if (!is_array($input)) {
error_log("NewContent::post Input wasn't of type array"); error_log("NewContent::post Input wasn't of type array");
return false; return false;
@ -98,7 +99,8 @@ class NewContent implements \Federator\Api\APIInterface
if (isset($allHeaders['X-Sender'])) { if (isset($allHeaders['X-Sender'])) {
$newActivity = $this->main->getConnector()->jsonToActivity($input); $newActivity = $this->main->getConnector()->jsonToActivity($input);
} else { } else {
$newActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($input); error_log("NewContent::post No X-Sender header found");
return false;
} }
if ($newActivity === false) { if ($newActivity === false) {
@ -106,27 +108,26 @@ class NewContent implements \Federator\Api\APIInterface
return false; return false;
} }
$sendTo = $newActivity->getCC(); $dbh = $this->main->getDatabase();
if ($newActivity->getType() === 'Undo') { $cache = $this->main->getCache();
$object = $newActivity->getObject(); $connector = $this->main->getConnector();
if ($object !== null) {
$sendTo = $object->getCC(); 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 = []; $users = [];
foreach ($sendTo as $receiver) { $followers = $this->fetchAllFollowers($dbh, $connector, $cache, $user);
if ($receiver === '' || !is_string($receiver)) { if (!empty($followers)) {
continue; $users = array_merge($users, $followers);
}
if (str_ends_with($receiver, '/followers')) {
$followers = $this->fetchAllFollowers($receiver, $host);
if (is_array($followers)) {
$users = array_merge($users, $followers);
}
}
} }
if (empty($users)) { // todo remove after proper implementation, debugging for now if (empty($users)) { // todo remove after proper implementation, debugging for now
@ -147,7 +148,7 @@ class NewContent implements \Federator\Api\APIInterface
continue; continue;
} }
$this->postForUser($user, $newActivity); $this->postForUser($dbh, $connector, $cache, $user, $newActivity);
} }
return json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); 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 * 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 string $_user user that triggered the post
* @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received * @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received
* @return boolean response * @return boolean response
*/ */
private function postForUser($_user, $newActivity) private static function postForUser($dbh, $connector, $cache, $_user, $newActivity)
{ {
if (isset($_user)) { if (!isset($_user)) {
$dbh = $this->main->getDatabase(); error_log("NewContent::postForUser no user given");
$cache = $this->main->getCache(); return false;
$connector = $this->main->getConnector(); }
// get user // get user
$user = \Federator\DIO\User::getUserByName( $user = \Federator\DIO\User::getUserByName(
$dbh, $dbh,
$_user, $_user,
$connector, $connector,
$cache $cache
); );
if ($user->id === null) { if ($user->id === null) {
error_log("NewContent::postForUser couldn't find user: $_user"); error_log("NewContent::postForUser couldn't find user: $_user");
return false; return false;
}
} }
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../'; $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 * 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 \mysqli $dbh
* @param string $host our current host-url * 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 * @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 = []; $users = [];
[$collectionResponse, $collectionInfo] = \Federator\Main::getFromRemote($collectionUrl, ['Accept: application/activity+json']); $apFollowers = \Federator\DIO\Followers::getFollowersByUser(
if ($collectionInfo['http_code'] != 200) { $dbh,
error_log("NewContent::fetchAllFollowers Failed to fetch follower collection metadata from $collectionUrl"); $userId,
return []; $connector,
} cache: $cache,
);
$collectionData = json_decode($collectionResponse, true); foreach ($apFollowers as $follower) {
$nextPage = $collectionData['first'] ?? $collectionData['current'] ?? null; $users[] = $follower->id;
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;
} }
return $users; return $users;

View file

@ -44,8 +44,10 @@ class WellKnown implements APIInterface
*/ */
private function hostMeta() private function hostMeta()
{ {
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$data = [ $data = [
'fqdn' => $_SERVER['SERVER_NAME'] 'fqdn' => $domain
]; ];
$this->response = $this->main->renderTemplate('host-meta.xml', $data); $this->response = $this->main->renderTemplate('host-meta.xml', $data);
return true; return true;

View file

@ -45,8 +45,10 @@ class NodeInfo
*/ */
public function exec($paths) public function exec($paths)
{ {
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$data = [ $data = [
'fqdn' => $_SERVER['SERVER_NAME'] 'fqdn' => $domain
]; ];
$template = null; $template = null;
if (sizeof($paths) == 2 && $paths[0] === '.well-known' && $paths[1] === 'nodeinfo') { if (sizeof($paths) == 2 && $paths[0] === '.well-known' && $paths[1] === 'nodeinfo') {

View file

@ -17,7 +17,7 @@ interface Cache extends \Federator\Connector\Connector
* save remote followers of user * save remote followers of user
* *
* @param string $user user name * @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 * @return void
*/ */
public function saveRemoteFollowersOfUser($user, $followers); public function saveRemoteFollowersOfUser($user, $followers);
@ -48,6 +48,15 @@ interface Cache extends \Federator\Connector\Connector
*/ */
public function saveRemoteUserByName($_name, $user); 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 * save remote user by given session
* *
@ -67,6 +76,14 @@ interface Cache extends \Federator\Connector\Connector
*/ */
public function savePublicKey(string $keyId, string $publicKeyPem); 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 * Retrieve the public key for a given keyId
* *

View file

@ -18,7 +18,7 @@ interface Connector
* *
* @param string $id user id * @param string $id user id
* @return \Federator\Data\ActivityPub\Common\APObject[]|false * @return \Federator\Data\FedUser[]|false
*/ */
public function getRemoteFollowersOfUser($id); public function getRemoteFollowersOfUser($id);

View file

@ -25,8 +25,6 @@ class Announce extends Activity
{ {
$return = parent::toObject(); $return = parent::toObject();
$return['type'] = 'Announce'; $return['type'] = 'Announce';
// overwrite id from url
$return['id'] = $this->getURL();
return $return; return $return;
} }

View file

@ -25,8 +25,6 @@ class Undo extends Activity
{ {
$return = parent::toObject(); $return = parent::toObject();
$return['type'] = 'Undo'; $return['type'] = 'Undo';
// overwrite id from url
$return['id'] = $this->getURL();
return $return; return $return;
} }

View file

@ -0,0 +1,18 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\Data\ActivityPub\Common;
class Accept extends Activity
{
public function __construct()
{
parent::__construct('Accept');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
}

View file

@ -74,6 +74,7 @@ class Activity extends APObject
{ {
if (array_key_exists('actor', $json)) { if (array_key_exists('actor', $json)) {
$this->actor = $json['actor']; $this->actor = $json['actor'];
$this->aactor = $json['actor'];
unset($json['actor']); unset($json['actor']);
} }
if (!parent::fromJson($json)) { if (!parent::fromJson($json)) {
@ -102,7 +103,7 @@ class Activity extends APObject
/** /**
* get Child Object * get Child Object
* *
* @return APObject|null * @return APObject|string|null
*/ */
public function getObject() public function getObject()
{ {

View file

@ -27,7 +27,7 @@ class APObject implements \JsonSerializable
/** /**
* child object * child object
* *
* @var APObject|null $object * @var APObject|string|null $object
*/ */
private $object = null; private $object = null;
@ -358,7 +358,7 @@ class APObject implements \JsonSerializable
/** /**
* get child object * get child object
* *
* @return APObject|null child object * @return APObject|string|null child object
*/ */
public function getObject() public function getObject()
{ {
@ -750,8 +750,8 @@ class APObject implements \JsonSerializable
if (array_key_exists('mediaType', $json)) { if (array_key_exists('mediaType', $json)) {
$this->mediaType = $json['mediaType']; $this->mediaType = $json['mediaType'];
} }
if (array_key_exists('object', $json)) { if (array_key_exists('object', $json)) { // some actPub servers send strings in the object field
$this->object = \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], ""); $this->object = is_array($json['object']) ? \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], "") : $json['object'];
} }
if (array_key_exists('sensitive', $json)) { if (array_key_exists('sensitive', $json)) {
$this->sensitive = $json['sensitive']; $this->sensitive = $json['sensitive'];
@ -890,7 +890,7 @@ class APObject implements \JsonSerializable
$return['mediaType'] = $this->mediaType; $return['mediaType'] = $this->mediaType;
} }
if ($this->object !== null) { if ($this->object !== null) {
$return['object'] = $this->object->toObject(); $return['object'] = is_string($this->object) ? $this->object : $this->object->toObject();
} }
if ($this->atomURI !== '') { if ($this->atomURI !== '') {
$return['atomUri'] = $this->atomURI; $return['atomUri'] = $this->atomURI;

View file

@ -0,0 +1,54 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\Data\ActivityPub\Common;
class Delete extends Activity
{
/**
* object overwrite
* @var string
*/
private $object = "";
public function setFObject(string $object): void
{
$this->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<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
if ($this->object !== "") {
$return['object'] = $this->object;
}
return $return;
}
}

View file

@ -0,0 +1,50 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\Data\ActivityPub\Common;
class Follow extends Activity
{
/**
* object overwrite
* @var string
*/
private $object = "";
public function setFObject(string $object): void
{
$this->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<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
if ($this->object !== "") {
$return['object'] = $this->object;
}
return $return;
}
}

View file

@ -0,0 +1,18 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\Data\ActivityPub\Common;
class Reject extends Activity
{
public function __construct()
{
parent::__construct('Reject');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
}

View file

@ -31,6 +31,9 @@ class Factory
} }
$return = null; $return = null;
switch ($json['type']) { switch ($json['type']) {
case 'Announce':
$return = new Common\Announce();
break;
case 'Article': case 'Article':
$return = new Common\Article(); $return = new Common\Article();
break; break;
@ -39,10 +42,10 @@ class Factory
break; break;
case 'Event': case 'Event':
$return = new Common\Event(); $return = new Common\Event();
break; break;*/
case 'Follow': case 'Follow':
$return = new Common\Follow(); $return = new Common\Follow();
break;*/ break;
case 'Image': case 'Image':
$return = new Common\Image(); $return = new Common\Image();
break; break;
@ -83,21 +86,24 @@ class Factory
} }
//$return = false; //$return = false;
switch ($json['type']) { switch ($json['type']) {
/* case 'Accept': case 'Accept':
$return = new Common\Accept(); $return = new Common\Accept();
break; */ break;
case 'Announce': case 'Announce':
$return = new Common\Announce(); $return = new Common\Announce();
break; break;
case 'Create': case 'Create':
$return = new Common\Create(); $return = new Common\Create();
break; break;
/*case 'Delete': case 'Delete':
$return = new Common\Delete(); $return = new Common\Delete();
break; break;
case 'Follow': case 'Follow':
$return = new Common\Follow(); $return = new Common\Follow();
break;*/ break;
case 'Reject':
$return = new Common\Reject();
break;
case 'Undo': case 'Undo':
$return = new Common\Undo(); $return = new Common\Undo();
break; break;

View file

@ -0,0 +1,151 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\Data;
/**
* storage class for user attributes
*/
class FedUser
{
/**
* user id
*
* @var string $id
*/
public $id;
/**
* user url
*
* @var string $actorURL
*/
public $actorURL;
/**
* user name
*
* @var string $name
*/
public $name = '';
/**
* user public key
*
* @var string $publicKey
*/
public $publicKey;
/**
* summary for user/profile
*
* @var string $summary
*/
public $summary = '';
/**
* type of user (person/group)
*
* @var string $type
*/
public $type = 'Person';
/**
* inbox URL
*
* @var string $inboxURL
*/
public $inboxURL;
/**
* shared inbox URL
*
* @var string $sharedInboxURL
*/
public $sharedInboxURL;
/**
* followers URL
*
* @var string $followersURL
*/
public $followersURL;
/**
* following URL
*
* @var string $followingURL
*/
public $followingURL;
/**
* public key ID
*
* @var string $publicKeyId
*/
public $publicKeyId;
/**
* outbox URL
*
* @var string $outboxURL
*/
public $outboxURL;
/**
* create new user object from json string
*
* @param string $input input string
* @return FedUser|false
*/
public static function createFromJson($input)
{
$data = json_decode($input, true);
if ($data === null) {
return false;
}
$user = new FedUser();
$user->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) ?: '';
}
}

View file

@ -0,0 +1,242 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\DIO;
/**
* IO functions related to fedUsers
*/
class FedUser
{
/**
* add local user based on given user object received from remote service
* @param \mysqli $dbh database handle
* @param \Federator\Data\FedUser $user user object to use
* @param string $_user user/profile name
* @return void
*/
protected static function addLocalUser($dbh, $user, $_user)
{
// check if it is timed out user
$sql = 'select 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($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;
}
}

View file

@ -13,11 +13,10 @@ namespace Federator\DIO;
*/ */
class Followers class Followers
{ {
/** /**
* get followers of user * get followers of user
* *
* @param \mysqli $dbh @unused-param * @param \mysqli $dbh
* database handle * database handle
* @param string $id * @param string $id
* user id * user id
@ -25,7 +24,7 @@ class Followers
* connector to fetch use with * connector to fetch use with
* @param \Federator\Cache\Cache|null $cache * @param \Federator\Cache\Cache|null $cache
* optional caching service * optional caching service
* @return \Federator\Data\ActivityPub\Common\APObject[] * @return \Federator\Data\FedUser[]
*/ */
public static function getFollowersByUser($dbh, $id, $connector, $cache) public static function getFollowersByUser($dbh, $id, $connector, $cache)
{ {
@ -37,7 +36,29 @@ class Followers
} }
} }
$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 === []) { if ($followers === []) {
// ask connector for user-id // ask connector for user-id
@ -52,4 +73,263 @@ class Followers
} }
return $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;
}
} }

View file

@ -42,6 +42,7 @@ class User
throw new \Federator\Exceptions\ServerError(); throw new \Federator\Exceptions\ServerError();
} }
$public = openssl_pkey_get_details($private_key)['key']; $public = openssl_pkey_get_details($private_key)['key'];
$user->publicKey = $public;
$private = ''; $private = '';
openssl_pkey_export($private_key, $private); openssl_pkey_export($private_key, $private);
$sql = 'insert into users (id, externalid, rsapublic, rsaprivate, validuntil,'; $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 * extend the given user with internal data
* @param \mysqli $dbh database handle * @param \mysqli $dbh database handle
* @param \Federator\Data\User $user user to extend * @param \Federator\Data\User $user user to extend
* @param string $_user user/profile name * @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=?'; $sql = 'select id,unix_timestamp(`validuntil`) from users where id=?';
$stmt = $dbh->prepare($sql); $stmt = $dbh->prepare($sql);

View file

@ -280,6 +280,16 @@ class Main
$this->host = $host; $this->host = $host;
} }
/**
* set content type
*
* @param string $_type content type
*/
public function setContentType($_type): void
{
$this->contentType = $_type;
}
/** /**
* set response code * set response code
* *

View file

@ -54,11 +54,11 @@ class ContentNation implements Connector
* get followers of given user * get followers of given user
* *
* @param string $userId user id * @param string $userId user id
* @return \Federator\Data\ActivityPub\Common\APObject[]|false * @return \Federator\Data\FedUser[]|false
*/ */
public function getRemoteFollowersOfUser($userId) public function getRemoteFollowersOfUser($userId)
{ {
// todo implement queue for this, move to DIO // todo implement queue for this
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) { if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
$userId = $matches[1]; $userId = $matches[1];
} }
@ -109,25 +109,25 @@ class ContentNation implements Connector
$posts = []; $posts = [];
if (array_key_exists('activities', $r)) { if (array_key_exists('activities', $r)) {
$activities = $r['activities']; $activities = $r['activities'];
$host = $_SERVER['SERVER_NAME']; $config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$imgpath = $this->config['userdata']['path']; $imgpath = $this->config['userdata']['path'];
$userdata = $this->config['userdata']['url']; $userdata = $this->config['userdata']['url'];
foreach ($activities as $activity) { foreach ($activities as $activity) {
$create = new \Federator\Data\ActivityPub\Common\Create(); $create = new \Federator\Data\ActivityPub\Common\Create();
$create->setAActor('https://' . $host . '/' . $userId); $create->setAActor('https://' . $domain . '/' . $userId);
$create->setID($activity['id']) $create->setID($activity['id'])
->setPublished($activity['timestamp']) ->setPublished($activity['published'] ?? $activity['timestamp'])
->addTo("https://www.w3.org/ns/activitystreams#Public") ->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $host . '/' . $userId . '/followers.json'); ->addCC('https://' . $domain . '/' . $userId . '/followers');
switch ($activity['type']) { switch ($activity['type']) {
case 'Article': case 'Article':
$create->setURL('https://' . $host . '/' . $activity['language'] . '/' . $userId . '/' $create->setURL('https://' . $domain . '/' . $activity['name']);
. $activity['name']);
$apArticle = new \Federator\Data\ActivityPub\Common\Article(); $apArticle = new \Federator\Data\ActivityPub\Common\Article();
if (array_key_exists('tags', $activity)) { if (array_key_exists('tags', $activity)) {
foreach ($activity['tags'] as $tag) { foreach ($activity['tags'] as $tag) {
$href = 'https://' . $host . '/' . $activity['language'] $href = 'https://' . $domain . '/search.htm?tagsearch=' . urlencode($tag);
. '/search.htm?tagsearch=' . urlencode($tag);
$tagObj = new \Federator\Data\ActivityPub\Common\Tag(); $tagObj = new \Federator\Data\ActivityPub\Common\Tag();
$tagObj->setHref($href) $tagObj->setHref($href)
->setName('#' . urlencode(str_replace(' ', '', $tag))) ->setName('#' . urlencode(str_replace(' ', '', $tag)))
@ -137,7 +137,7 @@ class ContentNation implements Connector
} }
$apArticle->setPublished($activity['published']) $apArticle->setPublished($activity['published'])
->setName($activity['title']) ->setName($activity['title'])
->setAttributedTo('https://' . $host . '/' . $activity['profilename']) ->setAttributedTo('https://' . $domain . '/' . $activity['profilename'])
->setContent( ->setContent(
$activity['teaser'] ?? $activity['teaser'] ??
$this->main->translate( $this->main->translate(
@ -147,11 +147,10 @@ class ContentNation implements Connector
) )
) )
->addTo("https://www.w3.org/ns/activitystreams#Public") ->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $host . '/' . $userId . '/followers.json'); ->addCC('https://' . $domain . '/' . $userId . '/followers.json');
$articleimage = $activity['imagealt'] ?? $articleimage = $activity['imagealt'] ??
$this->main->translate($activity['language'], 'article', 'image'); $this->main->translate($activity['language'], 'article', 'image');
$idurl = 'https://' . $host . '/' . $activity['language'] $idurl = 'https://' . $domain . '/' . $userId . '/' . $activity['name'];
. '/' . $userId . '/' . $activity['name'];
$apArticle->setID($idurl) $apArticle->setID($idurl)
->setURL($idurl); ->setURL($idurl);
$image = $activity['image'] ?? $activity['profileimg']; $image = $activity['image'] ?? $activity['profileimg'];
@ -176,7 +175,7 @@ class ContentNation implements Connector
$posts[] = $create; $posts[] = $create;
break; // Comment break; // Comment
case 'Vote': case 'Vote':
$url = 'https://' . $host . '/' . $activity['articlelang'] . $userId . '/' $url = 'https://' . $domain . '/' . $userId . '/'
. $activity['articlename']; . $activity['articlename'];
$url .= '/vote/' . $activity['id']; $url .= '/vote/' . $activity['id'];
$create->setURL($url); $create->setURL($url);
@ -204,8 +203,7 @@ class ContentNation implements Connector
$actor = new \Federator\Data\ActivityPub\Common\APObject('Person'); $actor = new \Federator\Data\ActivityPub\Common\APObject('Person');
$actor->setName($activity['username']); $actor->setName($activity['username']);
$like->setActor($actor); $like->setActor($actor);
$url = 'https://' . $host . '/' . $activity['articlelang'] $url = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename'];
. '/' . $userId . '/' . $activity['articlename'];
if ($activity['comment'] !== '') { if ($activity['comment'] !== '') {
$url .= '/comment/' . $activity['comment']; $url .= '/comment/' . $activity['comment'];
} }

View file

@ -23,7 +23,7 @@ class DummyConnector implements Connector
* get followers of given user * get followers of given user
* *
* @param string $userId user id @unused-param * @param string $userId user id @unused-param
* @return \Federator\Data\ActivityPub\Common\APObject[]|false * @return \Federator\Data\FedUser[]|false
*/ */
public function getRemoteFollowersOfUser($userId) public function getRemoteFollowersOfUser($userId)
{ {

View file

@ -90,7 +90,7 @@ class RedisCache implements Cache
* *
* @param string $id user id @unused-param * @param string $id user id @unused-param
* @return \Federator\Data\ActivityPub\Common\APObject[]|false * @return \Federator\Data\FedUser[]|false
*/ */
public function getRemoteFollowersOfUser($id) public function getRemoteFollowersOfUser($id)
{ {
@ -163,6 +163,26 @@ class RedisCache implements Cache
return $user; 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 * get remote user by given session
* *
@ -203,7 +223,7 @@ class RedisCache implements Cache
* save remote followers by user * save remote followers by user
* *
* @param string $user user name @unused-param * @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 * @return void
*/ */
public function saveRemoteFollowersOfUser($user, $followers) public function saveRemoteFollowersOfUser($user, $followers)
@ -252,6 +272,20 @@ class RedisCache implements Cache
$this->redis->setEx($key, $this->userTTL, $serialized); $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 * save remote user by given session
* *

3
sql/2025-05-06.sql Normal file
View file

@ -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";

View file

@ -58,7 +58,7 @@
{rdelim} {rdelim}
{rdelim} {rdelim}
], ],
"id":"https://{$fqdn}/users/{$username}", "id":"https://{$fqdn}/{$username}",
"type":"{$type}", "type":"{$type}",
"following":"https://{$fqdn}/users/{$username}/following", "following":"https://{$fqdn}/users/{$username}/following",
"followers":"https://{$fqdn}/users/{$username}/followers", "followers":"https://{$fqdn}/users/{$username}/followers",
@ -74,8 +74,8 @@
"discoverable":true, "discoverable":true,
"published":"{$registered}", "published":"{$registered}",
"publicKey":{ldelim} "publicKey":{ldelim}
"id":"https://{$fqdn}/users/{$username}#main-key", "id":"https://{$fqdn}/{$username}#main-key",
"owner":"https://{$fqdn}/users/{$username}", "owner":"https://{$fqdn}/{$username}",
"publicKeyPem":"{$publickey}" "publicKeyPem":"{$publickey}"
{rdelim}, {rdelim},
"tag":[], "tag":[],