federator/php/federator/api/fedusers/inbox.php
Yannis Vogel 4d36fc3c61
fix remaining phan errors
- also cleaned up dummy
- added new rewrite for apache for sharedInbox (your.fqdn/inbox)
- fixed json-formatting of publicKey when requesting user via api
2025-04-22 14:30:26 +02:00

440 lines
17 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Sascha Nitsch (grumpydeveloper)
**/
namespace Federator\Api\FedUsers;
/**
* handle activitypub outbox requests
*/
class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
{
/**
* main instance
*
* @var \Federator\Api $main
*/
private $main;
/**
* constructor
* @param \Federator\Api $main api main instance
*/
public function __construct($main)
{
$this->main = $main;
}
/**
* handle get call
*
* @param string|null $_user user to fetch inbox for @unused-param
* @return string|false response
*/
public function get($_user)
{
return false;
}
/**
* handle post call
*
* @param string|null $_user user to add data to inbox
* @return string|false response
*/
public function post($_user)
{
$inboxActivity = null;
$_rawInput = file_get_contents('php://input');
$allHeaders = getallheaders();
try {
$this->main->checkSignature($allHeaders);
} catch (\Federator\Exceptions\PermissionDenied $e) {
error_log("Inbox::post Signature check failed: " . $e->getMessage());
http_response_code(401);
exit("Access denied");
}
$activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
$host = $_SERVER['SERVER_NAME'];
if (!is_array($activity)) {
throw new \RuntimeException('Invalid activity format.');
}
switch ($activity['type']) {
case 'Create':
if (!isset($activity['object'])) {
break;
}
$obj = $activity['object'];
$published = strtotime($activity['published'] ?? $obj['published'] ?? 'now');
$create = new \Federator\Data\ActivityPub\Common\Create();
$create->setAActor($activity['actor'])
->setID($activity['id'])
->setURL($activity['id'])
->setPublished($published !== false ? $published : time());
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':
$published = strtotime($obj['published'] ?? 'now');
$apNote = new \Federator\Data\ActivityPub\Common\Note();
$apNote->setID($obj['id'])
->setPublished($published !== false ? $published : time())
->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("Inbox::post we currently don't support the obj type " . $obj['type'] . "\n");
break;
}
$inboxActivity = $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("Inbox::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;
}
$published = strtotime((string) $activity['published']);
$announce = new \Federator\Data\ActivityPub\Common\Announce();
$announce->setAActor((string) $activity['actor'])
->setPublished($published !== false ? $published : time())
->setID((string) $activity['id'])
->setURL((string) $activity['id'])
->addTo("https://www.w3.org/ns/activitystreams#Public");
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':
$published = strtotime($objData['published'] ?? 'now');
$note = new \Federator\Data\ActivityPub\Common\Note();
$note->setPublished($published !== false ? $published : time())
->setID($objData['id'])
->setSummary($objData['summary'])
->setContent($objData['content'] ?? '')
->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;
}
$inboxActivity = $announce;
break;
case 'Undo':
if (!isset($activity['object'])) {
break;
}
$undo = new \Federator\Data\ActivityPub\Common\Undo();
$undo->setActor($activity['actor'] ?? null)
->setID($activity['id'] ?? "test")
->setURL($activity['url'] ?? $activity['id']);
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':
$published = strtotime($undone['published'] ?? 'now');
$announce = new \Federator\Data\ActivityPub\Common\Announce();
$announce->setAActor($undone['actor'] ?? null)
->setPublished($published !== false ? $published : time())
->setID($undone['id'] ?? null)
->setURL($undone['url'] ?? $undone['id']);
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;
}
}
$inboxActivity = $undo;
break;
default:
error_log("Inbox::post we currently don't support the activity type " . $activity['type'] . "\n");
$apObject = new \Federator\Data\ActivityPub\Common\Activity($activity['type']);
$apObject->setID($activity['id'] ?? null);
$inboxActivity = $apObject;
break;
}
// Shared inbox
if (!isset($_user)) {
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
file_put_contents(
$rootDir . 'logs/inbox.log',
date('Y-m-d H:i:s') . ": ==== WILL TRY WORK WITH ACTIVITY ====\n" . json_encode($activity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
FILE_APPEND
);
// Save the raw input and parsed JSON to a file for inspection
file_put_contents(
$rootDir . 'logs/inbox.log',
date('Y-m-d H:i:s') . ": ==== POST Inbox Activity ====\n" . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
FILE_APPEND
);
}
if (!isset($inboxActivity)) {
error_log("Inbox::post couldn't create inboxActivity, aborting");
return false;
}
$sendTo = $inboxActivity->getCC();
if ($inboxActivity->getType() === 'Undo') {
$object = $inboxActivity->getObject();
if ($object !== null) {
$sendTo = $object->getCC();
}
}
$users = [];
foreach ($sendTo as $receiver) {
if ($receiver === '' || !is_string($receiver)) {
continue;
}
if (str_ends_with($receiver, '/followers')) {
$followers = $this->fetchAllFollowers($receiver, $host);
if (is_array($followers)) {
$users = array_merge($users, $followers);
}
}
}
if ($_user !== false && !in_array($_user, $users, true)) {
$users[] = $_user;
}
foreach ($users as $user) {
if (!isset($user)) {
continue;
}
$this->postForUser($user, $inboxActivity);
}
return json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}
/**
* handle post call for specific user
*
* @param string $_user user to add data to inbox
* @param \Federator\Data\ActivityPub\Common\Activity $inboxActivity the activity that we received
* @return boolean response
*/
private function postForUser($_user, $inboxActivity)
{
if (isset($_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("Inbox::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/inbox_' . $_user . '.log',
date('Y-m-d H:i:s') . ": ==== POST " . $_user . " Inbox Activity ====\n" . json_encode($inboxActivity, 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 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;
}
}