forked from grumpydevelop/federator
		
	- also cleaned up dummy - added new rewrite for apache for sharedInbox (your.fqdn/inbox) - fixed json-formatting of publicKey when requesting user via api
		
			
				
	
	
		
			440 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			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;
 | 
						|
    }
 | 
						|
}
 |