diff --git a/php/federator/api.php b/php/federator/api.php index 8463319..2ecb654 100644 --- a/php/federator/api.php +++ b/php/federator/api.php @@ -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 * diff --git a/php/federator/api/fedusers/inbox.php b/php/federator/api/fedusers/inbox.php index de40196..801b88f 100644 --- a/php/federator/api/fedusers/inbox.php +++ b/php/federator/api/fedusers/inbox.php @@ -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,7 +134,12 @@ 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; default: @@ -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; } } diff --git a/php/federator/api/fedusers/outbox.php b/php/federator/api/fedusers/outbox.php index 9665fae..2fcca55 100644 --- a/php/federator/api/fedusers/outbox.php +++ b/php/federator/api/fedusers/outbox.php @@ -40,7 +40,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface $dbh = $this->main->getDatabase(); $cache = $this->main->getCache(); $connector = $this->main->getConnector(); - + // get user $user = \Federator\DIO\User::getUserByName( $dbh, @@ -64,7 +64,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface $items = []; } $host = $_SERVER['SERVER_NAME']; - $id = 'https://' . $host .'/' . $_user . '/outbox'; + $id = 'https://' . $host . '/' . $_user . '/outbox'; $outbox->setPartOf($id); $outbox->setID($id); if ($page !== '') { @@ -74,9 +74,9 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface $outbox->setFirst($id); $outbox->setLast($id . '&min=0'); } - if (sizeof($items)>0) { + if (sizeof($items) > 0) { $newestId = $items[0]->getPublished(); - $oldestId = $items[sizeof($items)-1]->getPublished(); + $oldestId = $items[sizeof($items) - 1]->getPublished(); $outbox->setNext($id . '&max=' . $newestId); $outbox->setPrev($id . '&min=' . $oldestId); } @@ -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) { - return false; + $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; } }