federator/plugins/federator/mastodon.php
Yannis Vogel 305ded4986
WIP inbox-support
- 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
2025-04-15 16:42:46 +02:00

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);
}