forked from grumpydevelop/federator
		
	- includes hacky following-mechanic in order to simulate a follow on mastodon (not properly working, need to also inject the user this creates into the followers db for the target mastodon-user) - created endpoint for inbox. SharedInbox is used when no user is provided (/api/federator/fedusers/inbox'), the regular inbox link now works (/users/username/inbox). - Retrieve all followers of sender and, if they're part of our system, send the activity into their personal inbox - Support Announce and Undo Activity-Types - Inbox currently converts to proper ActPub-objects and saves data to log-files
		
			
				
	
	
		
			401 lines
		
	
	
	
		
			15 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			401 lines
		
	
	
	
		
			15 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\Main $main
 | 
						|
     */
 | 
						|
    private $main;
 | 
						|
 | 
						|
    /**
 | 
						|
     * constructor
 | 
						|
     * @param \Federator\Main $main main instance
 | 
						|
     */
 | 
						|
    public function __construct($main)
 | 
						|
    {
 | 
						|
        $this->main = $main;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * handle get call
 | 
						|
     *
 | 
						|
     * @param string $_user user to fetch inbox for @unused-param
 | 
						|
     * @return string|false response
 | 
						|
     */
 | 
						|
    public function get($_user)
 | 
						|
    {
 | 
						|
        return false;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * handle post call
 | 
						|
     *
 | 
						|
     * @param string $_user user to add data to inbox
 | 
						|
     * @return string|false response
 | 
						|
     */
 | 
						|
    public function post($_user)
 | 
						|
    {
 | 
						|
        $inboxActivity = null;
 | 
						|
        $_rawInput = file_get_contents('php://input');
 | 
						|
 | 
						|
        $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(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);
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
 | 
						|
                        $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;
 | 
						|
                }
 | 
						|
 | 
						|
                $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'])
 | 
						|
                            ->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;
 | 
						|
                }
 | 
						|
 | 
						|
                $inboxActivity = $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;
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                $inboxActivity = $undo;
 | 
						|
                break;
 | 
						|
            default:
 | 
						|
                error_log("Inbox::post we currently don't support the activity type " . $activity['type'] . "\n");
 | 
						|
                break;
 | 
						|
        }
 | 
						|
 | 
						|
        // Shared inbox
 | 
						|
        if (!$_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
 | 
						|
            );
 | 
						|
        }
 | 
						|
 | 
						|
        $sendTo = $inboxActivity->getCC();
 | 
						|
        if ($inboxActivity->getType() === 'Undo') {
 | 
						|
            $sendTo = $inboxActivity->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, $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 string|false response
 | 
						|
     */
 | 
						|
    private function postForUser($_user, $inboxActivity)
 | 
						|
    {
 | 
						|
        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) {
 | 
						|
                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 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("Inbox::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("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;
 | 
						|
    }
 | 
						|
}
 |