forked from grumpydevelop/federator
support outbox post
- we can now receive content into outbox (f.e. from contentnation) and post it into the outbox-user-logfile for now - we now verify post-requests according to ActivityPub standard - minor bugfix where user specified by passed user-parameter is possibly not included in postForUser-call
This commit is contained in:
parent
305ded4986
commit
20c2db4b35
3 changed files with 449 additions and 9 deletions
|
@ -192,6 +192,81 @@ class Api extends Main
|
|||
throw new $exception($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the headers include a valid signature
|
||||
*
|
||||
* @param string[] $headers
|
||||
* permission(s) to check for
|
||||
* @throws Exceptions\PermissionDenied
|
||||
*/
|
||||
public function checkSignature($headers)
|
||||
{
|
||||
$signatureHeader = $headers['Signature'] ?? null;
|
||||
|
||||
if (!$signatureHeader) {
|
||||
http_response_code(400);
|
||||
throw new Exceptions\PermissionDenied("Missing Signature header");
|
||||
}
|
||||
|
||||
// Parse Signature header
|
||||
preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches);
|
||||
$signatureParts = array_combine($matches[1], $matches[2]);
|
||||
|
||||
$signature = base64_decode($signatureParts['signature']);
|
||||
$keyId = $signatureParts['keyId'];
|
||||
$signedHeaders = explode(' ', $signatureParts['headers']);
|
||||
|
||||
// Fetch public key from `keyId` (usually actor URL + #main-key)
|
||||
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
|
||||
|
||||
if ($info['http_code'] !== 200) {
|
||||
http_response_code(500);
|
||||
throw new Exceptions\PermissionDenied("Failed to fetch public key from keyId: $keyId");
|
||||
}
|
||||
|
||||
$actor = json_decode($publicKeyData, true);
|
||||
error_log("actor: " . $publicKeyData);
|
||||
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
|
||||
|
||||
error_log($publicKeyPem);
|
||||
error_log(json_encode($headers));
|
||||
if (!$publicKeyPem) {
|
||||
http_response_code(500);
|
||||
throw new Exceptions\PermissionDenied("Invalid public key format from actor with keyId: $keyId");
|
||||
}
|
||||
|
||||
// Reconstruct the signed string
|
||||
$signedString = '';
|
||||
foreach ($signedHeaders as $header) {
|
||||
$headerValue = '';
|
||||
|
||||
if ($header === '(request-target)') {
|
||||
$method = strtolower($_SERVER['REQUEST_METHOD']);
|
||||
$path = $_SERVER['REQUEST_URI'];
|
||||
$headerValue = "$method $path";
|
||||
} else {
|
||||
$headerValue = $headers[ucwords($header, '-')] ?? '';
|
||||
}
|
||||
|
||||
$signedString .= strtolower($header) . ": " . $headerValue . "\n";
|
||||
}
|
||||
$signedString = rtrim($signedString);
|
||||
|
||||
// Verify the signature
|
||||
$pubkeyRes = openssl_pkey_get_public($publicKeyPem);
|
||||
$verified = false;
|
||||
if ($pubkeyRes) {
|
||||
$verified = openssl_verify($signedString, $signature, $pubkeyRes, OPENSSL_ALGO_SHA256);
|
||||
}
|
||||
if ($verified !== 1) {
|
||||
http_response_code(500);
|
||||
throw new Exceptions\PermissionDenied("Signature verification failed for publicKey with keyId: $keyId");
|
||||
}
|
||||
|
||||
// Signature is valid!
|
||||
return "Signature verified from actor: " . $actor['id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* remove unwanted elements from html input
|
||||
*
|
||||
|
|
|
@ -22,7 +22,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
|
||||
/**
|
||||
* constructor
|
||||
* @param \Federator\Main $main main instance
|
||||
* @param \Federator\Api $main api main instance
|
||||
*/
|
||||
public function __construct($main)
|
||||
{
|
||||
|
@ -51,6 +51,16 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
$inboxActivity = null;
|
||||
$_rawInput = file_get_contents('php://input');
|
||||
|
||||
$allHeaders = getallheaders();
|
||||
try {
|
||||
$result = $this->main->checkSignature($allHeaders);
|
||||
error_log($result); // Signature verified
|
||||
} catch (\Federator\Exceptions\PermissionDenied $e) {
|
||||
error_log("Inbox::post Signature check failed: " . $e->getMessage());
|
||||
http_response_code(403); // Or 401
|
||||
exit("Access denied");
|
||||
}
|
||||
|
||||
$activity = json_decode($_rawInput, true);
|
||||
$host = $_SERVER['SERVER_NAME'];
|
||||
|
||||
|
@ -124,6 +134,11 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
$apNote->addTag($tagObj);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('cc', $obj)) {
|
||||
foreach ($obj['cc'] as $cc) {
|
||||
$apNote->addCC($cc);
|
||||
}
|
||||
}
|
||||
|
||||
$create->setObject($apNote);
|
||||
break;
|
||||
|
@ -176,6 +191,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
case 'Note':
|
||||
$note = new \Federator\Data\ActivityPub\Common\Note();
|
||||
$note->setID($objData['id'])
|
||||
->setSummary($objData['summary'])
|
||||
->setContent($objData['content'] ?? '')
|
||||
->setPublished(strtotime($objData['published'] ?? 'now'))
|
||||
->setURL($objData['url'] ?? $objData['id'])
|
||||
|
@ -290,7 +306,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
$users = array_merge($users, $this->fetchAllFollowers($receiver, $host));
|
||||
}
|
||||
}
|
||||
if ($_user !== false && in_array($_user, $users)) {
|
||||
if ($_user !== false && !in_array($_user, $users)) {
|
||||
$users[] = $_user;
|
||||
}
|
||||
foreach ($users as $user) {
|
||||
|
@ -324,6 +340,7 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
$cache
|
||||
);
|
||||
if ($user->id === null) {
|
||||
error_log("Inbox::postForUser couldn't find user: $_user");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,11 +87,359 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
/**
|
||||
* handle post call
|
||||
*
|
||||
* @param string $_user user to add data to outbox @unused-param
|
||||
* @param string $_user user to add data to outbox
|
||||
* @return string|false response
|
||||
*/
|
||||
public function post($_user)
|
||||
{
|
||||
$outboxActivity = null;
|
||||
$_rawInput = file_get_contents('php://input');
|
||||
|
||||
$allHeaders = getallheaders();
|
||||
try {
|
||||
$result = $this->main->checkSignature($allHeaders);
|
||||
error_log($result); // Signature verified
|
||||
} catch (\Federator\Exceptions\PermissionDenied $e) {
|
||||
error_log("Outbox::post Signature check failed: " . $e->getMessage());
|
||||
http_response_code(403); // Or 401
|
||||
exit("Access denied");
|
||||
}
|
||||
|
||||
$activity = json_decode($_rawInput, true);
|
||||
$host = $_SERVER['SERVER_NAME'];
|
||||
|
||||
$sendTo = [];
|
||||
|
||||
switch ($activity['type']) {
|
||||
case 'Create':
|
||||
if (!isset($activity['object'])) {
|
||||
break;
|
||||
}
|
||||
|
||||
$obj = $activity['object'];
|
||||
$create = new \Federator\Data\ActivityPub\Common\Create();
|
||||
$create->setID($activity['id'])
|
||||
->setURL($activity['id'])
|
||||
->setPublished(published: strtotime($activity['published'] ?? $obj['published'] ?? 'now'))
|
||||
->setAActor($activity['actor']);
|
||||
|
||||
if (array_key_exists('cc', $activity)) {
|
||||
foreach ($activity['cc'] as $cc) {
|
||||
$create->addCC($cc);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('to', $activity)) {
|
||||
foreach ($activity['to'] as $to) {
|
||||
$create->addTo($to);
|
||||
}
|
||||
}
|
||||
|
||||
switch ($obj['type']) {
|
||||
case 'Note':
|
||||
$apNote = new \Federator\Data\ActivityPub\Common\Note();
|
||||
$apNote->setID($obj['id'])
|
||||
->setPublished(strtotime($obj['published'] ?? 'now'))
|
||||
->setContent($obj['content'] ?? '')
|
||||
->setSummary($obj['summary'])
|
||||
->setURL($obj['url'])
|
||||
->setAttributedTo($obj['attributedTo'] ?? $activity['actor'])
|
||||
->addTo("https://www.w3.org/ns/activitystreams#Public");
|
||||
|
||||
if (!empty($obj['sensitive'])) {
|
||||
$apNote->setSensitive($obj['sensitive']);
|
||||
}
|
||||
if (!empty($obj['conversation'])) {
|
||||
$apNote->setConversation($obj['conversation']);
|
||||
}
|
||||
if (!empty($obj['inReplyTo'])) {
|
||||
$apNote->setInReplyTo($obj['inReplyTo']);
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
if (!empty($obj['attachment']) && is_array($obj['attachment'])) {
|
||||
foreach ($obj['attachment'] as $media) {
|
||||
if (!isset($media['type'], $media['url']))
|
||||
continue;
|
||||
$mediaObj = new \Federator\Data\ActivityPub\Common\APObject($media['type']);
|
||||
$mediaObj->setURL($media['url']);
|
||||
$apNote->addAttachment($mediaObj);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('tag', $obj)) {
|
||||
foreach ($obj['tag'] as $tag) {
|
||||
$tagName = is_array($tag) && isset($tag['name']) ? $tag['name'] : (string) $tag;
|
||||
$cleanName = preg_replace('/\s+/', '', ltrim($tagName, '#')); // Remove space and leading #
|
||||
$tagObj = new \Federator\Data\ActivityPub\Common\Tag();
|
||||
$tagObj->setName('#' . $cleanName)
|
||||
->setHref("https://$host/tags/" . urlencode($cleanName))
|
||||
->setType('Hashtag');
|
||||
$apNote->addTag($tagObj);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('cc', $obj)) {
|
||||
foreach ($obj['cc'] as $cc) {
|
||||
$apNote->addCC($cc);
|
||||
}
|
||||
}
|
||||
|
||||
$create->setObject($apNote);
|
||||
break;
|
||||
default:
|
||||
error_log("Outbox::post we currently don't support the obj type " . $obj['type'] . "\n");
|
||||
break;
|
||||
}
|
||||
|
||||
$outboxActivity = $create;
|
||||
|
||||
break;
|
||||
case 'Announce':
|
||||
if (!isset($activity['object'])) {
|
||||
break;
|
||||
}
|
||||
|
||||
$objectURL = is_array($activity['object']) ? $activity['object']['id'] : $activity['object'];
|
||||
|
||||
// Fetch the original object (e.g. Note)
|
||||
[$response, $info] = \Federator\Main::getFromRemote($objectURL, ['Accept: application/activity+json']);
|
||||
if ($info['http_code'] != 200) {
|
||||
print_r($info);
|
||||
error_log("Outbox::post Failed to fetch original object for Announce: $objectURL\n");
|
||||
break;
|
||||
}
|
||||
$objData = json_decode($response, true);
|
||||
if ($objData === false || $objData === null || !is_array($objData)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$announce = new \Federator\Data\ActivityPub\Common\Announce();
|
||||
$announce->setID($activity['id'])
|
||||
->setURL($activity['id'])
|
||||
->setPublished(strtotime($activity['published'] ?? 'now'))
|
||||
->setAActor($activity['actor']);
|
||||
|
||||
if (array_key_exists('cc', $activity)) {
|
||||
foreach ($activity['cc'] as $cc) {
|
||||
$announce->addCC($cc);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('to', $activity)) {
|
||||
foreach ($activity['to'] as $to) {
|
||||
$announce->addTo($to);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the shared object as a Note or something else
|
||||
switch ($objData['type']) {
|
||||
case 'Note':
|
||||
$note = new \Federator\Data\ActivityPub\Common\Note();
|
||||
$note->setID($objData['id'])
|
||||
->setSummary($objData['summary'])
|
||||
->setContent($objData['content'] ?? '')
|
||||
->setPublished(strtotime($objData['published'] ?? 'now'))
|
||||
->setURL($objData['url'] ?? $objData['id'])
|
||||
->setAttributedTo($objData['attributedTo'] ?? null)
|
||||
->addTo("https://www.w3.org/ns/activitystreams#Public");
|
||||
|
||||
if (array_key_exists('cc', $objData)) {
|
||||
foreach ($objData['cc'] as $cc) {
|
||||
$note->addCC($cc);
|
||||
}
|
||||
}
|
||||
$announce->setObject($note);
|
||||
break;
|
||||
default:
|
||||
// fallback object
|
||||
$fallback = new \Federator\Data\ActivityPub\Common\APObject($objData['type']);
|
||||
$fallback->setID($objData['id'] ?? $objectURL);
|
||||
$announce->setObject($fallback);
|
||||
break;
|
||||
}
|
||||
|
||||
$outboxActivity = $announce;
|
||||
break;
|
||||
case 'Undo':
|
||||
if (!isset($activity['object'])) {
|
||||
break;
|
||||
}
|
||||
|
||||
$undo = new \Federator\Data\ActivityPub\Common\Undo();
|
||||
$undo->setID($activity['id'] ?? "test")
|
||||
->setURL($activity['url'] ?? $activity['id'])
|
||||
->setActor($activity['actor'] ?? null);
|
||||
|
||||
if (array_key_exists('cc', $activity)) {
|
||||
foreach ($activity['cc'] as $cc) {
|
||||
$undo->addCC($cc);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('to', $activity)) {
|
||||
foreach ($activity['to'] as $to) {
|
||||
$undo->addTo($to);
|
||||
}
|
||||
}
|
||||
|
||||
// what was undone
|
||||
$undone = $activity['object'];
|
||||
if (is_array($undone) && isset($undone['type'])) {
|
||||
switch ($undone['type']) {
|
||||
case 'Announce':
|
||||
$announce = new \Federator\Data\ActivityPub\Common\Announce();
|
||||
$announce->setID($undone['id'] ?? null)
|
||||
->setAActor($undone['actor'] ?? null)
|
||||
->setURL($undone['url'] ?? $undone['id'])
|
||||
->setPublished(strtotime($undone['published'] ?? 'now'));
|
||||
|
||||
if (array_key_exists('cc', $undone)) {
|
||||
foreach ($undone['cc'] as $cc) {
|
||||
$announce->addCC($cc);
|
||||
}
|
||||
}
|
||||
$undo->setObject($announce);
|
||||
break;
|
||||
case 'Follow':
|
||||
// Implement if needed
|
||||
break;
|
||||
default:
|
||||
// Fallback for unknown types
|
||||
$apObject = new \Federator\Data\ActivityPub\Common\APObject($undone['type']);
|
||||
$apObject->setID($undone['id'] ?? null);
|
||||
$undo->setObject($apObject);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$outboxActivity = $undo;
|
||||
break;
|
||||
default:
|
||||
error_log("Outbox::post we currently don't support the activity type " . $activity['type'] . "\n");
|
||||
break;
|
||||
}
|
||||
|
||||
$sendTo = $outboxActivity->getCC();
|
||||
if ($outboxActivity->getType() === 'Undo') {
|
||||
$sendTo = $outboxActivity->getObject()->getCC();
|
||||
}
|
||||
|
||||
$users = [];
|
||||
|
||||
foreach ($sendTo as $receiver) {
|
||||
if (!$receiver || !is_string($receiver)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_ends_with($receiver, '/followers')) {
|
||||
$users = array_merge($users, $this->fetchAllFollowers($receiver, $host));
|
||||
}
|
||||
}
|
||||
if ($_user !== false && !in_array($_user, $users)) {
|
||||
$users[] = $_user;
|
||||
}
|
||||
foreach ($users as $user) {
|
||||
if (!$user)
|
||||
continue;
|
||||
|
||||
$this->postForUser($user, $outboxActivity);
|
||||
}
|
||||
return json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* handle post call for specific user
|
||||
*
|
||||
* @param string $_user user to add data to outbox
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $outboxActivity the activity that we received
|
||||
* @return string|false response
|
||||
*/
|
||||
private function postForUser($_user, $outboxActivity)
|
||||
{
|
||||
if ($_user) {
|
||||
$dbh = $this->main->getDatabase();
|
||||
$cache = $this->main->getCache();
|
||||
$connector = $this->main->getConnector();
|
||||
|
||||
// get user
|
||||
$user = \Federator\DIO\User::getUserByName(
|
||||
$dbh,
|
||||
$_user,
|
||||
$connector,
|
||||
$cache
|
||||
);
|
||||
if ($user->id === null) {
|
||||
error_log("Outbox::postForUser couldn't find user: $_user");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
|
||||
// Save the raw input and parsed JSON to a file for inspection
|
||||
file_put_contents(
|
||||
$rootDir . 'logs/outbox_' . $_user . '.log',
|
||||
date('Y-m-d H:i:s') . ": ==== POST " . $_user . " Outbox Activity ====\n" . json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
|
||||
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 array|false the names of the followers that are hosted on our server
|
||||
*/
|
||||
private 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("Outbox::fetchAllFollowers Failed to fetch follower collection metadata from $collectionUrl");
|
||||
return [];
|
||||
}
|
||||
|
||||
$collectionData = json_decode($collectionResponse, true);
|
||||
$nextPage = $collectionData['first'] ?? $collectionData['current'] ?? null;
|
||||
|
||||
if (!$nextPage) {
|
||||
error_log("Outbox::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("Outbox::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("Outbox::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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue