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