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
		
			
				
	
	
		
			416 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			416 lines
		
	
	
	
		
			16 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\Connector;
 | 
						|
 | 
						|
/**
 | 
						|
 * Connector to Mastodon.social
 | 
						|
 */
 | 
						|
class Mastodon implements Connector
 | 
						|
{
 | 
						|
    /**
 | 
						|
     * config parameter
 | 
						|
     *
 | 
						|
     * @var array<string, mixed> $config
 | 
						|
     */
 | 
						|
    private $config;
 | 
						|
 | 
						|
    /**
 | 
						|
     * main instance
 | 
						|
     *
 | 
						|
     * @var \Federator\Main $main
 | 
						|
     */
 | 
						|
    private $main;
 | 
						|
 | 
						|
    /**
 | 
						|
     * service-URL
 | 
						|
     *
 | 
						|
     * @var string $service
 | 
						|
     */
 | 
						|
    private $service;
 | 
						|
 | 
						|
    /**
 | 
						|
     * constructor
 | 
						|
     *
 | 
						|
     * @param \Federator\Main $main
 | 
						|
     */
 | 
						|
    public function __construct($main)
 | 
						|
    {
 | 
						|
        $config = parse_ini_file($_SERVER['DOCUMENT_ROOT'] . '../mastodon.ini', true);
 | 
						|
        if ($config !== false) {
 | 
						|
            $this->config = $config;
 | 
						|
        }
 | 
						|
        $this->service = $config['mastodon']['service-uri'];
 | 
						|
        $this->main = $main;
 | 
						|
        $this->main->setHost($this->service);
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * get posts by given user
 | 
						|
     *
 | 
						|
     * @param string $userId user id
 | 
						|
     * @param string $min min date
 | 
						|
     * @param string $max max date
 | 
						|
     * @return \Federator\Data\ActivityPub\Common\APObject[]|false
 | 
						|
     */
 | 
						|
    public function getRemotePostsByUser($userId, $min = null, $max = null)
 | 
						|
    {
 | 
						|
        if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) === 1) {
 | 
						|
            $name = $matches[1];
 | 
						|
        } else {
 | 
						|
            $name = $userId;
 | 
						|
        }
 | 
						|
 | 
						|
        $remoteURL = $this->service . '/users/' . $name . '/outbox';
 | 
						|
        if ($min !== '') {
 | 
						|
            $remoteURL .= '&minTS=' . urlencode($min);
 | 
						|
        }
 | 
						|
        if ($max !== '') {
 | 
						|
            $remoteURL .= '&maxTS=' . urlencode($max);
 | 
						|
        }
 | 
						|
 | 
						|
        $items = [];
 | 
						|
 | 
						|
        [$outboxResponse, $outboxInfo] = \Federator\Main::getFromRemote($remoteURL, ['Accept: application/activity+json']);
 | 
						|
 | 
						|
        if ($outboxInfo['http_code'] !== 200) {
 | 
						|
            echo "MastodonConnector::getRemotePostsByUser HTTP call failed for remoteURL $remoteURL\n";
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
 | 
						|
        $outbox = json_decode($outboxResponse, true);
 | 
						|
 | 
						|
        // retrieve ALL outbox items - disabled for now
 | 
						|
        /* do {
 | 
						|
            // Fetch the current page of items (first or subsequent pages)
 | 
						|
            [$outboxResponse, $outboxInfo] = \Federator\Main::getFromRemote($remoteURL, ['Accept: application/activity+json']);
 | 
						|
 | 
						|
            if ($outboxInfo['http_code'] !== 200) {
 | 
						|
                echo "MastodonConnector::getRemotePostsByUser HTTP call failed for remoteURL $remoteURL\n";
 | 
						|
                return false;
 | 
						|
            }
 | 
						|
 | 
						|
            $outbox = json_decode($outboxResponse, true);
 | 
						|
 | 
						|
            // Extract orderedItems from the current page
 | 
						|
            if (isset($outbox['orderedItems'])) {
 | 
						|
                $items = array_merge($items, $outbox['orderedItems']);
 | 
						|
            }
 | 
						|
 | 
						|
            // Use 'next' or 'last' URL to determine pagination
 | 
						|
            if (isset($outbox['next'])) {
 | 
						|
                $remoteURL = $outbox['next']; // Update target URL for the next page of items
 | 
						|
            } else if (isset($outbox['last'])) {
 | 
						|
                $remoteURL = $outbox['last']; // Update target URL for the next page of items
 | 
						|
            } else {
 | 
						|
                $remoteURL = "";
 | 
						|
                break; // No more pages, exit pagination
 | 
						|
            }
 | 
						|
            if ($remoteURL !== "") {
 | 
						|
                if ($min !== '') {
 | 
						|
                    $remoteURL .= '&minTS=' . urlencode($min);
 | 
						|
                }
 | 
						|
                if ($max !== '') {
 | 
						|
                    $remoteURL .= '&maxTS=' . urlencode($max);
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
        } while ($remoteURL !== ""); // Continue fetching until no 'last' URL */
 | 
						|
 | 
						|
        // Follow `first` page (or get orderedItems directly)
 | 
						|
        if (isset($outbox['orderedItems'])) {
 | 
						|
            $items = $outbox['orderedItems'];
 | 
						|
        } elseif (isset($outbox['first'])) {
 | 
						|
            $firstURL = is_array($outbox['first']) ? $outbox['first']['id'] : $outbox['first'];
 | 
						|
            [$pageResponse, $pageInfo] = \Federator\Main::getFromRemote($firstURL, ['Accept: application/activity+json']);
 | 
						|
            if ($pageInfo['http_code'] !== 200) {
 | 
						|
                return false;
 | 
						|
            }
 | 
						|
            $page = json_decode($pageResponse, true);
 | 
						|
            $items = $page['orderedItems'] ?? [];
 | 
						|
        }
 | 
						|
 | 
						|
        // Convert to internal representation
 | 
						|
        $posts = [];
 | 
						|
        $host = $_SERVER['SERVER_NAME'];
 | 
						|
        foreach ($items as $activity) {
 | 
						|
            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'])
 | 
						|
                        ->addCC($activity['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:
 | 
						|
                            echo "MastodonConnector::getRemotePostsByUser we currently don't support the obj type " . $obj['type'] . "\n";
 | 
						|
                            break;
 | 
						|
                    }
 | 
						|
 | 
						|
                    $posts[] = $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);
 | 
						|
                        echo "MastodonConnector::getRemotePostsByUser 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'])
 | 
						|
                        ->addTo("https://www.w3.org/ns/activitystreams#Public");
 | 
						|
 | 
						|
                    if (array_key_exists('to', $activity)) {
 | 
						|
                        foreach ($activity['to'] as $to) {
 | 
						|
                            $announce->addTo($to);
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
 | 
						|
                    // Optionally 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");
 | 
						|
 | 
						|
                            $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;
 | 
						|
                    }
 | 
						|
 | 
						|
                    $posts[] = $announce;
 | 
						|
                    break;
 | 
						|
                default:
 | 
						|
                    echo "MastodonConnector::getRemotePostsByUser we currently don't support the activity type " . $activity['type'] . "\n";
 | 
						|
                    break;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return $posts;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Get the MIME type of a remote file by its URL.
 | 
						|
     *
 | 
						|
     * @param string $_url The URL of the remote file.
 | 
						|
     * @return string|false The MIME type if found, or false on failure.
 | 
						|
     */
 | 
						|
    public function getRemoteMimeType($url)
 | 
						|
    {
 | 
						|
        $headers = get_headers($url, 1);
 | 
						|
        return $headers['Content-Type'] ?? 'unknown';
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * get statistics from remote system
 | 
						|
     *
 | 
						|
     * @return \Federator\Data\Stats|false
 | 
						|
     */
 | 
						|
    public function getRemoteStats()
 | 
						|
    {
 | 
						|
        $remoteURL = $this->service . '/api/stats';
 | 
						|
        [$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
 | 
						|
        if ($info['http_code'] != 200) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
        $r = json_decode($response, true);
 | 
						|
        if ($r === false || $r === null || !is_array($r)) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
        $stats = new \Federator\Data\Stats();
 | 
						|
        $stats->userCount = array_key_exists('userCount', $r) ? $r['userCount'] : 0;
 | 
						|
        $stats->postCount = array_key_exists('pageCount', $r) ? $r['pageCount'] : 0;
 | 
						|
        $stats->commentCount = array_key_exists('commentCount', $r) ? $r['commentCount'] : 0;
 | 
						|
        return $stats;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * get remote user by given name
 | 
						|
     *
 | 
						|
     * @param string $_name user/profile name
 | 
						|
     * @return \Federator\Data\User | false
 | 
						|
     */
 | 
						|
    public function getRemoteUserByName(string $_name)
 | 
						|
    {
 | 
						|
        // Validate username (Mastodon usernames can include @ and domain parts)
 | 
						|
        if (preg_match("/^[a-zA-Z0-9_\-@.]+$/", $_name) !== 1) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
 | 
						|
        // Mastodon lookup API endpoint
 | 
						|
        $remoteURL = $this->service . '/api/v1/accounts/lookup?acct=' . urlencode($_name);
 | 
						|
        // Set headers
 | 
						|
        $headers = ['Accept: application/json'];
 | 
						|
 | 
						|
        // Fetch data from Mastodon instance
 | 
						|
        [$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
 | 
						|
 | 
						|
        // Handle HTTP errors
 | 
						|
        if ($info['http_code'] !== 200) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
 | 
						|
        // Decode response
 | 
						|
        $r = json_decode($response, true);
 | 
						|
        if ($r === false || $r === null || !is_array($r)) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
 | 
						|
        // Map response to User object
 | 
						|
        $user = new \Federator\Data\User();
 | 
						|
        $user->externalid = (string) $r['id']; // Mastodon uses numeric IDs
 | 
						|
        $user->iconMediaType = 'image/png'; // Mastodon doesn't explicitly return this, assume PNG
 | 
						|
        $user->iconURL = $r['avatar'] ?? null;
 | 
						|
        $user->imageMediaType = 'image/png';
 | 
						|
        $user->imageURL = $r['header'] ?? null;
 | 
						|
        $user->name = $r['display_name'] ?: $r['username'];
 | 
						|
        $user->summary = $r['note'];
 | 
						|
        $user->type = 'Person'; // Mastodon profiles are ActivityPub "Person" objects
 | 
						|
        $user->registered = strtotime($r['created_at']);
 | 
						|
 | 
						|
        return $user;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * get remote user by given session
 | 
						|
     *
 | 
						|
     * @param string $_session session id
 | 
						|
     * @param string $_user user or profile name
 | 
						|
     * @return \Federator\Data\User | false
 | 
						|
     */
 | 
						|
    public function getRemoteUserBySession(string $_session, string $_user)
 | 
						|
    {
 | 
						|
        // validate $_session and $user
 | 
						|
        if (preg_match("/^[a-z0-9]{16}$/", $_session) != 1) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
        if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_user) != 1) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
        $remoteURL = $this->service . '/api/users/permissions?profile=' . urlencode($_user);
 | 
						|
        $headers = ['Cookie: session=' . $_session, 'Accept: application/json'];
 | 
						|
        [$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
 | 
						|
 | 
						|
        if ($info['http_code'] != 200) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
        $r = json_decode($response, true);
 | 
						|
        if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
        $user = $this->getRemoteUserByName($_user);
 | 
						|
        if ($user === false) {
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
        // extend with permissions
 | 
						|
        $user->permissions = [];
 | 
						|
        $user->session = $_session;
 | 
						|
        foreach ($r[$_user] as $p) {
 | 
						|
            $user->permissions[] = $p;
 | 
						|
        }
 | 
						|
        return $user;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
namespace Federator;
 | 
						|
 | 
						|
/**
 | 
						|
 * Function to initialize plugin
 | 
						|
 *
 | 
						|
 * @param  \Federator\Main $main main instance
 | 
						|
 * @return void
 | 
						|
 */
 | 
						|
function mastodon_load($main)
 | 
						|
{
 | 
						|
    $mast = new Connector\Mastodon($main);
 | 
						|
    # echo "mastodon::mastodon_load Loaded new connector, adding to main\n"; // TODO change to proper log
 | 
						|
    $main->setConnector($mast);
 | 
						|
}
 |