forked from grumpydevelop/federator
initial mastodon support and minor improvements
- reworked plugin handling. Main now registers and keeps all connectors and based on request we select the correct one and pass it (mainly for clean async-request-handling) - added outbox functionality for mastodon - changed image-mime-type approach to retrieve mime-type from the url, this way we don't need to store the images on our server/download each image - added host for connector for better debugging - minor bug-fixes
This commit is contained in:
parent
a21345c3c7
commit
530caa7ea6
25 changed files with 614 additions and 102 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -5,3 +5,4 @@ php-docs
|
||||||
.phpdoc
|
.phpdoc
|
||||||
phpdoc
|
phpdoc
|
||||||
html
|
html
|
||||||
|
/cache
|
||||||
|
|
|
@ -14,6 +14,8 @@ compiledir = '../cache'
|
||||||
[plugins]
|
[plugins]
|
||||||
rediscache = 'rediscache.php'
|
rediscache = 'rediscache.php'
|
||||||
dummy = 'dummyconnector.php'
|
dummy = 'dummyconnector.php'
|
||||||
|
contentnation = 'contentnation.php'
|
||||||
|
mastodon = 'mastodon.php'
|
||||||
|
|
||||||
[maintenance]
|
[maintenance]
|
||||||
username = 'federatoradmin'
|
username = 'federatoradmin'
|
||||||
|
|
6
contentnation.ini
Normal file
6
contentnation.ini
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[contentnation]
|
||||||
|
service-uri = https://contentnation.net
|
||||||
|
|
||||||
|
[userdata]
|
||||||
|
path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here
|
||||||
|
url = 'https://userdata.contentnation.net'
|
112
htdocs/index.html
Normal file
112
htdocs/index.html
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API Request UI</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="flex justify-center items-center min-h-screen bg-gray-100">
|
||||||
|
<div class="w-full max-w-3xl bg-white shadow-lg rounded-lg p-6 m-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 text-center">API Request UI</h2>
|
||||||
|
|
||||||
|
<div id="request-container">
|
||||||
|
<!-- Request Form Template -->
|
||||||
|
<div class="request-box border p-4 rounded-lg mb-4 bg-gray-50">
|
||||||
|
<label class="block font-medium">API target link</label>
|
||||||
|
<input type="text" class="target-link-input w-full p-2 border rounded-md mb-2"
|
||||||
|
placeholder="Enter target link" value="federator/fedusers/grumpydevelop/outbox?page=0">
|
||||||
|
|
||||||
|
<label class="block font-medium">Request type</label>
|
||||||
|
<input type="text" class="request-type-input w-full p-2 border rounded-md mb-2"
|
||||||
|
placeholder="POST or GET" value="GET">
|
||||||
|
|
||||||
|
<label class="block font-medium">X-Session:</label>
|
||||||
|
<input type="text" class="session-input w-full p-2 border rounded-md mb-2"
|
||||||
|
placeholder="Enter X-Session token" value="">
|
||||||
|
|
||||||
|
<label class="block font-medium">X-Profile:</label>
|
||||||
|
<input type="text" class="profile-input w-full p-2 border rounded-md mb-2" placeholder="Enter X-Profile"
|
||||||
|
value="">
|
||||||
|
|
||||||
|
<div class="buttonContainer">
|
||||||
|
<button class="send-btn bg-blue-500 text-white px-4 py-2 rounded-md w-full hover:bg-blue-600">
|
||||||
|
Send Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-gray-700">Response:</p>
|
||||||
|
<p class="response mt-2 text-sm text-gray-700 font-mono whitespace-pre">Waiting for response</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="add-request" class="mt-4 bg-green-500 text-white px-4 py-2 rounded-md w-full hover:bg-green-600">
|
||||||
|
Add Another Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function sendRequest(button) {
|
||||||
|
const container = button.parentElement.parentElement;
|
||||||
|
const targetLink = container.querySelector(".target-link-input").value;
|
||||||
|
const requestType = container.querySelector(".request-type-input").value;
|
||||||
|
const session = container.querySelector(".session-input").value;
|
||||||
|
const profile = container.querySelector(".profile-input").value;
|
||||||
|
const responseField = container.querySelector(".response");
|
||||||
|
button.parentElement.style.cursor = "not-allowed";
|
||||||
|
button.style.pointerEvents = "none";
|
||||||
|
button.textContent = "Sending...";
|
||||||
|
responseField.textContent = "Waiting for response";
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(session ? { "X-Session": session } : {}),
|
||||||
|
...(profile ? { "X-Profile": profile } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch("http://localhost/api/" + targetLink, {
|
||||||
|
method: requestType,
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(data => {
|
||||||
|
responseField.textContent = data;
|
||||||
|
button.parentElement.style.cursor = "";
|
||||||
|
button.style.pointerEvents = "";
|
||||||
|
button.textContent = "Send Request";
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
responseField.textContent = "Error: " + error;
|
||||||
|
button.parentElement.style.cursor = "";
|
||||||
|
button.style.pointerEvents = "";
|
||||||
|
button.textContent = "Send Request";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".send-btn").forEach(btn => {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
sendRequest(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("add-request").addEventListener("click", function () {
|
||||||
|
const container = document.getElementById("request-container");
|
||||||
|
const requestBox = container.firstElementChild.cloneNode(true);
|
||||||
|
|
||||||
|
requestBox.querySelector(".target-link-input").value = "federator/v1/dummy/moo";
|
||||||
|
requestBox.querySelector(".request-type-input").value = "GET";
|
||||||
|
requestBox.querySelector(".session-input").value = "somethingvalider";
|
||||||
|
requestBox.querySelector(".profile-input").value = "ihaveone";
|
||||||
|
requestBox.querySelector(".response").textContent = "";
|
||||||
|
|
||||||
|
requestBox.querySelector(".send-btn").addEventListener("click", function () {
|
||||||
|
sendRequest(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(requestBox);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
3
htdocs/info.php
Normal file
3
htdocs/info.php
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
echo phpinfo();
|
6
mastodon.ini
Normal file
6
mastodon.ini
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[mastodon]
|
||||||
|
service-uri = https://mastodon.social
|
||||||
|
|
||||||
|
[userdata]
|
||||||
|
path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here
|
||||||
|
url = 'https://files.mastodon.net'
|
|
@ -6,7 +6,7 @@
|
||||||
* @author Sascha Nitsch (grumpydeveloper)
|
* @author Sascha Nitsch (grumpydeveloper)
|
||||||
**/
|
**/
|
||||||
|
|
||||||
namespace Federator;
|
namespace Federator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* main API class
|
* main API class
|
||||||
|
@ -69,14 +69,26 @@ class Api extends Main
|
||||||
/**
|
/**
|
||||||
* main API function
|
* main API function
|
||||||
*/
|
*/
|
||||||
public function run() : void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->setPath((string)$_REQUEST['_call']);
|
$this->setPath((string) $_REQUEST['_call']);
|
||||||
$this->openDatabase();
|
$this->openDatabase();
|
||||||
$this->loadPlugins();
|
$this->loadPlugins();
|
||||||
|
|
||||||
|
$host = 'dummy'; // fallback
|
||||||
|
|
||||||
|
// Check if path matches something like fedusers/username@domain.tld
|
||||||
|
if (preg_match("#^fedusers/([^@]+)@([^/]+)$#", $this->path, $matches) === 1) {
|
||||||
|
$host = strtolower($matches[2]); // extract domain
|
||||||
|
} else {
|
||||||
|
$host = 'dummy';
|
||||||
|
echo "using dummy host in API::run\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$connector = $this->getConnectorForHost($host);
|
||||||
$retval = "";
|
$retval = "";
|
||||||
$handler = null;
|
$handler = null;
|
||||||
if ($this->connector === null) {
|
if ($connector === null) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -85,7 +97,7 @@ class Api extends Main
|
||||||
$this->dbh,
|
$this->dbh,
|
||||||
$_SERVER['HTTP_X_SESSION'],
|
$_SERVER['HTTP_X_SESSION'],
|
||||||
$_SERVER['HTTP_X_PROFILE'],
|
$_SERVER['HTTP_X_PROFILE'],
|
||||||
$this->connector,
|
$connector,
|
||||||
$this->cache
|
$this->cache
|
||||||
);
|
);
|
||||||
if ($this->user === false) {
|
if ($this->user === false) {
|
||||||
|
@ -112,7 +124,7 @@ class Api extends Main
|
||||||
$printresponse = true;
|
$printresponse = true;
|
||||||
if ($handler !== null) {
|
if ($handler !== null) {
|
||||||
try {
|
try {
|
||||||
$printresponse = $handler->exec($this->paths, $this->user);
|
$printresponse = $handler->exec($this->paths, $this->user, $connector);
|
||||||
if ($printresponse) {
|
if ($printresponse) {
|
||||||
$retval = $handler->toJson();
|
$retval = $handler->toJson();
|
||||||
}
|
}
|
||||||
|
@ -168,7 +180,7 @@ class Api extends Main
|
||||||
* @param string $message optional message
|
* @param string $message optional message
|
||||||
* @throws Exceptions\PermissionDenied
|
* @throws Exceptions\PermissionDenied
|
||||||
*/
|
*/
|
||||||
public function checkPermission($permission, $exception = "\Exceptions\PermissionDenied", $message = null) : void
|
public function checkPermission($permission, $exception = "\Exceptions\PermissionDenied", $message = null): void
|
||||||
{
|
{
|
||||||
// generic check first
|
// generic check first
|
||||||
if ($this->user === false) {
|
if ($this->user === false) {
|
||||||
|
@ -198,7 +210,7 @@ class Api extends Main
|
||||||
* input to strip
|
* input to strip
|
||||||
* @return string stripped input
|
* @return string stripped input
|
||||||
*/
|
*/
|
||||||
public static function stripHTML(string $_input) : string
|
public static function stripHTML(string $_input): string
|
||||||
{
|
{
|
||||||
$out = preg_replace('/<(script[^>]*)>/i', '<${1}>', $_input);
|
$out = preg_replace('/<(script[^>]*)>/i', '<${1}>', $_input);
|
||||||
$out = preg_replace('/<\/(script)>/i', '</${1};>', $out);
|
$out = preg_replace('/<\/(script)>/i', '</${1};>', $out);
|
||||||
|
@ -212,7 +224,7 @@ class Api extends Main
|
||||||
* parameter to check
|
* parameter to check
|
||||||
* @return bool true if in
|
* @return bool true if in
|
||||||
*/
|
*/
|
||||||
public static function hasPost(string $_key) : bool
|
public static function hasPost(string $_key): bool
|
||||||
{
|
{
|
||||||
return array_key_exists($_key, $_POST);
|
return array_key_exists($_key, $_POST);
|
||||||
}
|
}
|
||||||
|
@ -228,13 +240,13 @@ class Api extends Main
|
||||||
*/
|
*/
|
||||||
public function escapePost(string $key, $int = false)
|
public function escapePost(string $key, $int = false)
|
||||||
{
|
{
|
||||||
if (! array_key_exists($key, $_POST)) {
|
if (!array_key_exists($key, $_POST)) {
|
||||||
return $int ? 0 : "";
|
return $int ? 0 : "";
|
||||||
}
|
}
|
||||||
if ($int === true) {
|
if ($int === true) {
|
||||||
return intval($_POST[$key]);
|
return intval($_POST[$key]);
|
||||||
}
|
}
|
||||||
$ret = $this->dbh->escape_string($this->stripHTML((string)$_POST[$key]));
|
$ret = $this->dbh->escape_string($this->stripHTML((string) $_POST[$key]));
|
||||||
return $ret;
|
return $ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,9 +19,10 @@ interface APIInterface
|
||||||
* @param array<string> $paths path array split by /
|
* @param array<string> $paths path array split by /
|
||||||
*
|
*
|
||||||
* @param \Federator\Data\User|false $user user who is calling us
|
* @param \Federator\Data\User|false $user user who is calling us
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return bool true on success
|
* @return bool true on success
|
||||||
*/
|
*/
|
||||||
public function exec($paths, $user);
|
public function exec($paths, $user, $connector);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get internal represenation as json string
|
* get internal represenation as json string
|
||||||
|
|
|
@ -43,9 +43,10 @@ class FedUsers implements APIInterface
|
||||||
*
|
*
|
||||||
* @param array<string> $paths path array split by /
|
* @param array<string> $paths path array split by /
|
||||||
* @param \Federator\Data\User|false $user user who is calling us @unused-param
|
* @param \Federator\Data\User|false $user user who is calling us @unused-param
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return bool true on success
|
* @return bool true on success
|
||||||
*/
|
*/
|
||||||
public function exec($paths, $user)
|
public function exec($paths, $user, $connector)
|
||||||
{
|
{
|
||||||
$method = $_SERVER["REQUEST_METHOD"];
|
$method = $_SERVER["REQUEST_METHOD"];
|
||||||
$handler = null;
|
$handler = null;
|
||||||
|
@ -53,7 +54,7 @@ class FedUsers implements APIInterface
|
||||||
case 2:
|
case 2:
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
// /users/username or /@username
|
// /users/username or /@username
|
||||||
return $this->returnUserProfile($paths[1]);
|
return $this->returnUserProfile($paths[1], $connector);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
|
@ -82,10 +83,10 @@ class FedUsers implements APIInterface
|
||||||
$ret = false;
|
$ret = false;
|
||||||
switch ($method) {
|
switch ($method) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
$ret = $handler->get($paths[1]);
|
$ret = $handler->get($paths[1], $connector);
|
||||||
break;
|
break;
|
||||||
case 'POST':
|
case 'POST':
|
||||||
$ret = $handler->post($paths[1]);
|
$ret = $handler->post($paths[1], $connector);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if ($ret !== false) {
|
if ($ret !== false) {
|
||||||
|
@ -102,14 +103,15 @@ class FedUsers implements APIInterface
|
||||||
* return user profile
|
* return user profile
|
||||||
*
|
*
|
||||||
* @param string $_name
|
* @param string $_name
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return boolean true on success
|
* @return boolean true on success
|
||||||
*/
|
*/
|
||||||
private function returnUserProfile($_name)
|
private function returnUserProfile($_name, $connector)
|
||||||
{
|
{
|
||||||
$user = \Federator\DIO\User::getUserByName(
|
$user = \Federator\DIO\User::getUserByName(
|
||||||
$this->main->getDatabase(),
|
$this->main->getDatabase(),
|
||||||
$_name,
|
$_name,
|
||||||
$this->main->getConnector(),
|
$connector,
|
||||||
$this->main->getCache()
|
$this->main->getCache()
|
||||||
);
|
);
|
||||||
if ($user === false || $user->id === null) {
|
if ($user === false || $user->id === null) {
|
||||||
|
@ -123,7 +125,7 @@ class FedUsers implements APIInterface
|
||||||
'fqdn' => $_SERVER['SERVER_NAME'],
|
'fqdn' => $_SERVER['SERVER_NAME'],
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'username' => $user->id,
|
'username' => $user->id,
|
||||||
'publickey' => str_replace("\n", "\\n", $user->publicKey),
|
'publickey' => $user->publicKey,
|
||||||
'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z
|
'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z
|
||||||
'summary' => $user->summary,
|
'summary' => $user->summary,
|
||||||
'type' => $user->type
|
'type' => $user->type
|
||||||
|
|
|
@ -14,15 +14,17 @@ interface FedUsersInterface
|
||||||
* get call for user
|
* get call for user
|
||||||
*
|
*
|
||||||
* @param string $_user user to fetch data for
|
* @param string $_user user to fetch data for
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return string|false response or false in case of error
|
* @return string|false response or false in case of error
|
||||||
*/
|
*/
|
||||||
public function get($_user);
|
public function get($_user, $connector);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* post call for user
|
* post call for user
|
||||||
*
|
*
|
||||||
* @param string $_user user to add data to
|
* @param string $_user user to add data to
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return string|false response or false in case of error
|
* @return string|false response or false in case of error
|
||||||
*/
|
*/
|
||||||
public function post($_user);
|
public function post($_user, $connector);
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,13 +33,13 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
||||||
* handle get call
|
* handle get call
|
||||||
*
|
*
|
||||||
* @param string $_user user to fetch outbox for
|
* @param string $_user user to fetch outbox for
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return string|false response
|
* @return string|false response
|
||||||
*/
|
*/
|
||||||
public function get($_user)
|
public function get($_user, $connector)
|
||||||
{
|
{
|
||||||
$dbh = $this->main->getDatabase();
|
$dbh = $this->main->getDatabase();
|
||||||
$cache = $this->main->getCache();
|
$cache = $this->main->getCache();
|
||||||
$connector = $this->main->getConnector();
|
|
||||||
// get user
|
// get user
|
||||||
$user = \Federator\DIO\User::getUserByName(
|
$user = \Federator\DIO\User::getUserByName(
|
||||||
$dbh,
|
$dbh,
|
||||||
|
@ -50,6 +50,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
||||||
if ($user->id === null) {
|
if ($user->id === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get posts from user
|
// get posts from user
|
||||||
$outbox = new \Federator\Data\ActivityPub\Common\Outbox();
|
$outbox = new \Federator\Data\ActivityPub\Common\Outbox();
|
||||||
$min = $this->main->extractFromURI("min", "");
|
$min = $this->main->extractFromURI("min", "");
|
||||||
|
@ -79,16 +80,17 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
||||||
$outbox->setPrev($id . '&min=' . $oldestId);
|
$outbox->setPrev($id . '&min=' . $oldestId);
|
||||||
}
|
}
|
||||||
$obj = $outbox->toObject();
|
$obj = $outbox->toObject();
|
||||||
return json_encode($obj);
|
return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* handle post call
|
* handle post call
|
||||||
*
|
*
|
||||||
* @param string $_user user to add data to outbox @unused-param
|
* @param string $_user user to add data to outbox @unused-param
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return string|false response
|
* @return string|false response
|
||||||
*/
|
*/
|
||||||
public function post($_user)
|
public function post($_user, $connector)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ class Dummy implements \Federator\Api\APIInterface
|
||||||
/**
|
/**
|
||||||
* internal message to output
|
* internal message to output
|
||||||
*
|
*
|
||||||
* @var Array<string, mixed> $message
|
* @var array<string, mixed> $message
|
||||||
*/
|
*/
|
||||||
private $message = [];
|
private $message = [];
|
||||||
|
|
||||||
|
@ -42,9 +42,10 @@ class Dummy implements \Federator\Api\APIInterface
|
||||||
*
|
*
|
||||||
* @param array<string> $paths path array split by /
|
* @param array<string> $paths path array split by /
|
||||||
* @param \Federator\Data\User|false $user user who is calling us
|
* @param \Federator\Data\User|false $user user who is calling us
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return bool true on success
|
* @return bool true on success
|
||||||
*/
|
*/
|
||||||
public function exec($paths, $user) : bool
|
public function exec($paths, $user, $connector) : bool
|
||||||
{
|
{
|
||||||
// only for user with the 'publish' permission
|
// only for user with the 'publish' permission
|
||||||
if ($user === false || $user->hasPermission('publish') === false) {
|
if ($user === false || $user->hasPermission('publish') === false) {
|
||||||
|
|
|
@ -57,7 +57,7 @@ class WellKnown implements APIInterface
|
||||||
* @param \Federator\Data\User|false $user user who is calling us @unused-param
|
* @param \Federator\Data\User|false $user user who is calling us @unused-param
|
||||||
* @return bool true on success
|
* @return bool true on success
|
||||||
*/
|
*/
|
||||||
public function exec($paths, $user)
|
public function exec($paths, $user, $connector)
|
||||||
{
|
{
|
||||||
$method = $_SERVER["REQUEST_METHOD"];
|
$method = $_SERVER["REQUEST_METHOD"];
|
||||||
switch ($method) {
|
switch ($method) {
|
||||||
|
@ -66,14 +66,14 @@ class WellKnown implements APIInterface
|
||||||
case 2:
|
case 2:
|
||||||
if ($paths[0] === 'nodeinfo') {
|
if ($paths[0] === 'nodeinfo') {
|
||||||
$ni = new WellKnown\NodeInfo($this, $this->main);
|
$ni = new WellKnown\NodeInfo($this, $this->main);
|
||||||
return $ni->exec($paths);
|
return $ni->exec($paths, $connector);
|
||||||
}
|
}
|
||||||
switch ($paths[1]) {
|
switch ($paths[1]) {
|
||||||
case 'host-meta':
|
case 'host-meta':
|
||||||
return $this->hostMeta();
|
return $this->hostMeta();
|
||||||
case 'nodeinfo':
|
case 'nodeinfo':
|
||||||
$ni = new WellKnown\NodeInfo($this, $this->main);
|
$ni = new WellKnown\NodeInfo($this, $this->main);
|
||||||
return $ni->exec($paths);
|
return $ni->exec($paths, $connector);
|
||||||
case 'webfinger':
|
case 'webfinger':
|
||||||
$wf = new WellKnown\WebFinger($this, $this->main);
|
$wf = new WellKnown\WebFinger($this, $this->main);
|
||||||
return $wf->exec();
|
return $wf->exec();
|
||||||
|
|
|
@ -41,9 +41,10 @@ class NodeInfo
|
||||||
* handle nodeinfo request
|
* handle nodeinfo request
|
||||||
*
|
*
|
||||||
* @param string[] $paths path of us
|
* @param string[] $paths path of us
|
||||||
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return bool true on success
|
* @return bool true on success
|
||||||
*/
|
*/
|
||||||
public function exec($paths)
|
public function exec($paths, $connector)
|
||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'fqdn' => $_SERVER['SERVER_NAME']
|
'fqdn' => $_SERVER['SERVER_NAME']
|
||||||
|
@ -64,7 +65,7 @@ class NodeInfo
|
||||||
default:
|
default:
|
||||||
$template = 'nodeinfo2.0.json';
|
$template = 'nodeinfo2.0.json';
|
||||||
}
|
}
|
||||||
$stats = \Federator\DIO\Stats::getStats($this->main);
|
$stats = \Federator\DIO\Stats::getStats($this->main, $connector);
|
||||||
echo "fetch usercount via connector\n";
|
echo "fetch usercount via connector\n";
|
||||||
$data['usercount'] = $stats->userCount;
|
$data['usercount'] = $stats->userCount;
|
||||||
$data['postcount'] = $stats->postCount;
|
$data['postcount'] = $stats->postCount;
|
||||||
|
|
|
@ -46,18 +46,18 @@ class WebFinger
|
||||||
{
|
{
|
||||||
$_resource = $this->main->extractFromURI('resource');
|
$_resource = $this->main->extractFromURI('resource');
|
||||||
$matches = [];
|
$matches = [];
|
||||||
$config = $this->main->getConfig();
|
if (preg_match("/^acct:([^@]+)@(.*)$/", $_resource, $matches) != 1) {
|
||||||
$domain = $config['generic']['externaldomain'];
|
|
||||||
if (preg_match("/^acct:([^@]+)@(.*)$/", $_resource, $matches) != 1 || $matches[2] !== $domain) {
|
|
||||||
throw new \Federator\Exceptions\InvalidArgument();
|
throw new \Federator\Exceptions\InvalidArgument();
|
||||||
}
|
}
|
||||||
|
$domain = $matches[2];
|
||||||
$user = \Federator\DIO\User::getUserByName(
|
$user = \Federator\DIO\User::getUserByName(
|
||||||
$this->main->getDatabase(),
|
$this->main->getDatabase(),
|
||||||
$matches[1],
|
$matches[1],
|
||||||
$this->main->getConnector(),
|
$this->main->getConnectorForHost($domain),
|
||||||
$this->main->getCache()
|
$this->main->getCache()
|
||||||
);
|
);
|
||||||
if ($user->id == 0) {
|
if ($user->id == 0) {
|
||||||
|
echo "not found";
|
||||||
throw new \Federator\Exceptions\FileNotFound();
|
throw new \Federator\Exceptions\FileNotFound();
|
||||||
}
|
}
|
||||||
$data = [
|
$data = [
|
||||||
|
|
|
@ -13,6 +13,12 @@ namespace Federator\Connector;
|
||||||
*/
|
*/
|
||||||
interface Connector
|
interface Connector
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* get the host this connector is dedicated to
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getHost();
|
||||||
/**
|
/**
|
||||||
* get posts by given user
|
* get posts by given user
|
||||||
*
|
*
|
||||||
|
|
|
@ -773,7 +773,7 @@ class APObject implements \JsonSerializable
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
* @see JsonSerializable::jsonSerialize()
|
* @see JsonSerializable::jsonSerialize()
|
||||||
*/
|
*/
|
||||||
public function jsonSerialize()
|
public function jsonSerialize(): mixed
|
||||||
{
|
{
|
||||||
return $this->toObject();
|
return $this->toObject();
|
||||||
}
|
}
|
||||||
|
|
|
@ -145,7 +145,7 @@ class User
|
||||||
*/
|
*/
|
||||||
public function hasPermission(string $p)
|
public function hasPermission(string $p)
|
||||||
{
|
{
|
||||||
return in_array($p, $this->permissions, false);
|
return in_array(strtolower($p), array_map('strtolower', $this->permissions), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -17,11 +17,11 @@ class Stats
|
||||||
/**
|
/**
|
||||||
* get remote stats
|
* get remote stats
|
||||||
*
|
*
|
||||||
* @param \Federator\Main $main
|
* @param \Federator\Main $main main instance
|
||||||
* main instance
|
* @param \Federator\Connector\Connector $connector connector to use
|
||||||
* @return \Federator\Data\Stats
|
* @return \Federator\Data\Stats
|
||||||
*/
|
*/
|
||||||
public static function getStats($main)
|
public static function getStats($main, $connector)
|
||||||
{
|
{
|
||||||
$cache = $main->getCache();
|
$cache = $main->getCache();
|
||||||
// ask cache
|
// ask cache
|
||||||
|
@ -31,7 +31,6 @@ class Stats
|
||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$connector = $main->getConnector();
|
|
||||||
// ask connector for stats
|
// ask connector for stats
|
||||||
$stats = $connector->getRemoteStats();
|
$stats = $connector->getRemoteStats();
|
||||||
if ($cache !== null && $stats !== false) {
|
if ($cache !== null && $stats !== false) {
|
||||||
|
|
|
@ -145,6 +145,7 @@ class User
|
||||||
public static function getUserByName($dbh, $_name, $connector, $cache)
|
public static function getUserByName($dbh, $_name, $connector, $cache)
|
||||||
{
|
{
|
||||||
$user = false;
|
$user = false;
|
||||||
|
|
||||||
// ask cache
|
// ask cache
|
||||||
if ($cache !== null) {
|
if ($cache !== null) {
|
||||||
$user = $cache->getRemoteUserByName($_name);
|
$user = $cache->getRemoteUserByName($_name);
|
||||||
|
@ -179,6 +180,7 @@ class User
|
||||||
$stmt->fetch();
|
$stmt->fetch();
|
||||||
}
|
}
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
if ($user->id === null) {
|
if ($user->id === null) {
|
||||||
// ask connector for user-id
|
// ask connector for user-id
|
||||||
$ruser = $connector->getRemoteUserByName($_name);
|
$ruser = $connector->getRemoteUserByName($_name);
|
||||||
|
@ -190,7 +192,7 @@ class User
|
||||||
if ($user->id === null && $user->externalid !== null) {
|
if ($user->id === null && $user->externalid !== null) {
|
||||||
self::addLocalUser($dbh, $user, $_name);
|
self::addLocalUser($dbh, $user, $_name);
|
||||||
}
|
}
|
||||||
$cache->saveRemoteUserByName($_name, $user);
|
$cache->saveRemoteUserByName($_name, user: $user);
|
||||||
}
|
}
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,11 +28,11 @@ class Main
|
||||||
*/
|
*/
|
||||||
protected $config;
|
protected $config;
|
||||||
/**
|
/**
|
||||||
* remote connector
|
* remote connectors
|
||||||
*
|
*
|
||||||
* @var Connector\Connector $connector
|
* @var array<string, Connector\Connector> $connectors
|
||||||
*/
|
*/
|
||||||
protected $connector = null;
|
protected $connectors = [];
|
||||||
/**
|
/**
|
||||||
* response content type
|
* response content type
|
||||||
*
|
*
|
||||||
|
@ -93,7 +93,7 @@ class Main
|
||||||
public static function extractFromURI($param, $fallback = '')
|
public static function extractFromURI($param, $fallback = '')
|
||||||
{
|
{
|
||||||
$uri = $_SERVER['REQUEST_URI'];
|
$uri = $_SERVER['REQUEST_URI'];
|
||||||
$params = substr($uri, (int)(strpos($uri, '?') + 1));
|
$params = substr($uri, (int) (strpos($uri, '?') + 1));
|
||||||
$params = explode('&', $params);
|
$params = explode('&', $params);
|
||||||
foreach ($params as $p) {
|
foreach ($params as $p) {
|
||||||
$tokens = explode('=', $p);
|
$tokens = explode('=', $p);
|
||||||
|
@ -141,18 +141,20 @@ class Main
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get connector
|
* Get the connector for a given remote host
|
||||||
*
|
*
|
||||||
* @return \Federator\Connector\Connector
|
* @param string $remoteHost The host from the actor URL (e.g. mastodon.social)
|
||||||
|
* @return Connector\Connector|null
|
||||||
*/
|
*/
|
||||||
public function getConnector()
|
public function getConnectorForHost(string $remoteHost): ?Connector\Connector
|
||||||
{
|
{
|
||||||
return $this->connector;
|
$host = strtolower(parse_url($remoteHost, PHP_URL_HOST) ?? $remoteHost);
|
||||||
|
return $this->connectors[$host] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get config
|
* get config
|
||||||
* @return Array<String, Mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function getConfig()
|
public function getConfig()
|
||||||
{
|
{
|
||||||
|
@ -172,7 +174,7 @@ class Main
|
||||||
/**
|
/**
|
||||||
* load plugins
|
* load plugins
|
||||||
*/
|
*/
|
||||||
public function loadPlugins() : void
|
public function loadPlugins(): void
|
||||||
{
|
{
|
||||||
if (array_key_exists('plugins', $this->config)) {
|
if (array_key_exists('plugins', $this->config)) {
|
||||||
$basepath = $_SERVER['DOCUMENT_ROOT'] . '../plugins/federator/';
|
$basepath = $_SERVER['DOCUMENT_ROOT'] . '../plugins/federator/';
|
||||||
|
@ -199,8 +201,8 @@ class Main
|
||||||
$dbconf = $this->config["database"];
|
$dbconf = $this->config["database"];
|
||||||
$this->dbh = new \mysqli(
|
$this->dbh = new \mysqli(
|
||||||
$dbconf['host'],
|
$dbconf['host'],
|
||||||
$usernameOverride ?? (string)$dbconf['username'],
|
$usernameOverride ?? (string) $dbconf['username'],
|
||||||
$passwordOverride ?? (string)$dbconf['password'],
|
$passwordOverride ?? (string) $dbconf['password'],
|
||||||
$dbconf['database']
|
$dbconf['database']
|
||||||
);
|
);
|
||||||
if ($this->dbh->connect_error !== null) {
|
if ($this->dbh->connect_error !== null) {
|
||||||
|
@ -221,7 +223,7 @@ class Main
|
||||||
$smarty = new \Smarty\Smarty();
|
$smarty = new \Smarty\Smarty();
|
||||||
$root = $_SERVER['DOCUMENT_ROOT'];
|
$root = $_SERVER['DOCUMENT_ROOT'];
|
||||||
$smarty->setCompileDir($root . $this->config['templates']['compiledir']);
|
$smarty->setCompileDir($root . $this->config['templates']['compiledir']);
|
||||||
$smarty->setTemplateDir((string)realpath($root . $this->config['templates']['path']));
|
$smarty->setTemplateDir((string) realpath($root . $this->config['templates']['path']));
|
||||||
$smarty->assign('database', $this->dbh);
|
$smarty->assign('database', $this->dbh);
|
||||||
$smarty->assign('maininstance', $this);
|
$smarty->assign('maininstance', $this);
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
|
@ -233,17 +235,22 @@ class Main
|
||||||
/**
|
/**
|
||||||
* set cache
|
* set cache
|
||||||
*/
|
*/
|
||||||
public function setCache(Cache\Cache $cache) : void
|
public function setCache(Cache\Cache $cache): void
|
||||||
{
|
{
|
||||||
$this->cache = $cache;
|
$this->cache = $cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* set connector
|
* Set a connector for a specific remote host
|
||||||
|
*
|
||||||
|
* @param string $remoteURL The remote host (like mastodon.social or contentnation.net)
|
||||||
|
* @param Connector\Connector $connector The connector instance
|
||||||
*/
|
*/
|
||||||
public function setConnector(Connector\Connector $connector) : void
|
public function addConnector(string $remoteURL, Connector\Connector $connector): void
|
||||||
{
|
{
|
||||||
$this->connector = $connector;
|
// Normalize the host (no scheme, lowercase)
|
||||||
|
$host = strtolower(parse_url($remoteURL, PHP_URL_HOST) ?? $remoteURL);
|
||||||
|
$this->connectors[$host] = $connector;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -252,7 +259,7 @@ class Main
|
||||||
* @param int $code
|
* @param int $code
|
||||||
* new response code
|
* new response code
|
||||||
*/
|
*/
|
||||||
public function setResponseCode(int $code) : void
|
public function setResponseCode(int $code): void
|
||||||
{
|
{
|
||||||
$this->responseCode = $code;
|
$this->responseCode = $code;
|
||||||
}
|
}
|
||||||
|
@ -270,7 +277,7 @@ class Main
|
||||||
* optional parameters
|
* optional parameters
|
||||||
* @return string translation
|
* @return string translation
|
||||||
*/
|
*/
|
||||||
public static function translate(?string $lang, string $group, string $key, array $parameters = array()) : string
|
public static function translate(?string $lang, string $group, string $key, array $parameters = array()): string
|
||||||
{
|
{
|
||||||
$l = new Language($lang);
|
$l = new Language($lang);
|
||||||
return $l->printlang($group, $key, $parameters);
|
return $l->printlang($group, $key, $parameters);
|
||||||
|
@ -281,7 +288,7 @@ class Main
|
||||||
*
|
*
|
||||||
* @param ?string $lang
|
* @param ?string $lang
|
||||||
*/
|
*/
|
||||||
public static function validLanguage(?string $lang) : bool
|
public static function validLanguage(?string $lang): bool
|
||||||
{
|
{
|
||||||
$language = new Language($lang);
|
$language = new Language($lang);
|
||||||
if ($language->getLang() === $lang) {
|
if ($language->getLang() === $lang) {
|
||||||
|
|
|
@ -6,9 +6,9 @@
|
||||||
* @author Sascha Nitsch (grumpydeveloper)
|
* @author Sascha Nitsch (grumpydeveloper)
|
||||||
**/
|
**/
|
||||||
|
|
||||||
namespace Federator\Connector;
|
namespace Federator\Connector;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connector to ContentNation.net
|
* Connector to ContentNation.net
|
||||||
*/
|
*/
|
||||||
class ContentNation implements Connector
|
class ContentNation implements Connector
|
||||||
|
@ -49,6 +49,15 @@ class ContentNation implements Connector
|
||||||
$this->main = $main;
|
$this->main = $main;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the host this connector is dedicated to
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getHost() {
|
||||||
|
return "contentnation.net";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get posts by given user
|
* get posts by given user
|
||||||
*
|
*
|
||||||
|
@ -83,14 +92,14 @@ class ContentNation implements Connector
|
||||||
$userdata = $this->config['userdata']['url'];
|
$userdata = $this->config['userdata']['url'];
|
||||||
foreach ($activities as $activity) {
|
foreach ($activities as $activity) {
|
||||||
$create = new \Federator\Data\ActivityPub\Common\Create();
|
$create = new \Federator\Data\ActivityPub\Common\Create();
|
||||||
$create->setAActor('https://' . $host .'/' . $userId);
|
$create->setAActor('https://' . $host . '/' . $userId);
|
||||||
$create->setID($activity['id'])
|
$create->setID($activity['id'])
|
||||||
->setPublished($activity['timestamp'])
|
->setPublished($activity['timestamp'])
|
||||||
->addTo("https://www.w3.org/ns/activitystreams#Public")
|
->addTo("https://www.w3.org/ns/activitystreams#Public")
|
||||||
->addCC('https://' . $host . '/' . $userId . '/followers.json');
|
->addCC('https://' . $host . '/' . $userId . '/followers.json');
|
||||||
switch ($activity['type']) {
|
switch ($activity['type']) {
|
||||||
case 'Article':
|
case 'Article':
|
||||||
$create->setURL('https://'.$host . '/' . $activity['language'] . '/' . $userId . '/'
|
$create->setURL('https://' . $host . '/' . $activity['language'] . '/' . $userId . '/'
|
||||||
. $activity['name']);
|
. $activity['name']);
|
||||||
$apArticle = new \Federator\Data\ActivityPub\Common\Article();
|
$apArticle = new \Federator\Data\ActivityPub\Common\Article();
|
||||||
if (array_key_exists('tags', $activity)) {
|
if (array_key_exists('tags', $activity)) {
|
||||||
|
@ -106,7 +115,7 @@ class ContentNation implements Connector
|
||||||
}
|
}
|
||||||
$apArticle->setPublished($activity['published'])
|
$apArticle->setPublished($activity['published'])
|
||||||
->setName($activity['title'])
|
->setName($activity['title'])
|
||||||
->setAttributedTo('https://' . $host .'/' . $activity['profilename'])
|
->setAttributedTo('https://' . $host . '/' . $activity['profilename'])
|
||||||
->setContent(
|
->setContent(
|
||||||
$activity['teaser'] ??
|
$activity['teaser'] ??
|
||||||
$this->main->translate(
|
$this->main->translate(
|
||||||
|
@ -120,25 +129,28 @@ class ContentNation implements Connector
|
||||||
$articleimage = $activity['imagealt'] ??
|
$articleimage = $activity['imagealt'] ??
|
||||||
$this->main->translate($activity['language'], 'article', 'image');
|
$this->main->translate($activity['language'], 'article', 'image');
|
||||||
$idurl = 'https://' . $host . '/' . $activity['language']
|
$idurl = 'https://' . $host . '/' . $activity['language']
|
||||||
. '/' . $userId . '/'. $activity['name'];
|
. '/' . $userId . '/' . $activity['name'];
|
||||||
$apArticle->setID($idurl)
|
$apArticle->setID($idurl)
|
||||||
->setURL($idurl);
|
->setURL($idurl);
|
||||||
$image = $activity['image'] ?? $activity['profileimg'];
|
$image = $activity['image'] ?? $activity['profileimg'];
|
||||||
$mediaType = @mime_content_type($imgpath . $activity['profile'] . '/' . $image) | 'text/plain';
|
// $mediaType = @mime_content_type($imgpath . $activity['profile'] . '/' . $image) | 'text/plain'; // old approach, using local copy of images
|
||||||
|
$imgUrl = $userdata . '/' . $activity['profile'] . $image;
|
||||||
|
$mediaType = $this->getRemoteMimeType($imgUrl) ?? 'text/plain';
|
||||||
$img = new \Federator\Data\ActivityPub\Common\Image();
|
$img = new \Federator\Data\ActivityPub\Common\Image();
|
||||||
$img->setMediaType($mediaType)
|
$img->setMediaType($mediaType)
|
||||||
->setName($articleimage)
|
->setName($articleimage)
|
||||||
->setURL($userdata . '/' . $activity['profile'] . $image);
|
->setURL($userdata . '/' . $activity['profile'] . $image);
|
||||||
$apArticle->addImage($img);
|
$apArticle->addImage($img);
|
||||||
$create->setObject($apArticle);
|
$create->setObject(object: $apArticle);
|
||||||
$posts[] = $create;
|
$posts[] = $create;
|
||||||
break; // Article
|
break; // Article
|
||||||
case 'Comment':
|
case 'Comment':
|
||||||
// echo "comment\n";
|
$comment = new \Federator\Data\ActivityPub\Common\Activity('Comment');
|
||||||
// print_r($activity);
|
$create->setObject($comment);
|
||||||
|
$posts[] = $create;
|
||||||
break; // Comment
|
break; // Comment
|
||||||
case 'Vote':
|
case 'Vote':
|
||||||
$url = 'https://'.$host . '/' . $activity['articlelang'] . $userId . '/'
|
$url = 'https://' . $host . '/' . $activity['articlelang'] . $userId . '/'
|
||||||
. $activity['articlename'];
|
. $activity['articlename'];
|
||||||
$url .= '/vote/' . $activity['id'];
|
$url .= '/vote/' . $activity['id'];
|
||||||
$create->setURL($url);
|
$create->setURL($url);
|
||||||
|
@ -167,7 +179,7 @@ class ContentNation implements Connector
|
||||||
$actor->setName($activity['username']);
|
$actor->setName($activity['username']);
|
||||||
$like->setActor($actor);
|
$like->setActor($actor);
|
||||||
$url = 'https://' . $host . '/' . $activity['articlelang']
|
$url = 'https://' . $host . '/' . $activity['articlelang']
|
||||||
. '/' . $userId . '/'. $activity['articlename'];
|
. '/' . $userId . '/' . $activity['articlename'];
|
||||||
if ($activity['comment'] !== '') {
|
if ($activity['comment'] !== '') {
|
||||||
$url .= '/comment/' . $activity['comment'];
|
$url .= '/comment/' . $activity['comment'];
|
||||||
}
|
}
|
||||||
|
@ -189,6 +201,18 @@ class ContentNation implements Connector
|
||||||
return $posts;
|
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
|
* get statistics from remote system
|
||||||
*
|
*
|
||||||
|
@ -220,6 +244,9 @@ class ContentNation implements Connector
|
||||||
*/
|
*/
|
||||||
public function getRemoteUserByName(string $_name)
|
public function getRemoteUserByName(string $_name)
|
||||||
{
|
{
|
||||||
|
if (preg_match("#^([^@]+)@([^/]+)$#", $_name, $matches) === 1) {
|
||||||
|
$_name = $matches[1];
|
||||||
|
}
|
||||||
// validate name
|
// validate name
|
||||||
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_name) != 1) {
|
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_name) != 1) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -299,5 +326,5 @@ namespace Federator;
|
||||||
function contentnation_load($main)
|
function contentnation_load($main)
|
||||||
{
|
{
|
||||||
$cn = new Connector\ContentNation($main);
|
$cn = new Connector\ContentNation($main);
|
||||||
$main->setConnector($cn);
|
$main->addConnector($cn->getHost(), $cn);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,15 @@ class DummyConnector implements Connector
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the host this connector is dedicated to
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getHost() {
|
||||||
|
return "dummy";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get posts by given user
|
* get posts by given user
|
||||||
*
|
*
|
||||||
|
@ -87,5 +96,5 @@ namespace Federator;
|
||||||
function dummy_load($main)
|
function dummy_load($main)
|
||||||
{
|
{
|
||||||
$dummy = new Connector\DummyConnector();
|
$dummy = new Connector\DummyConnector();
|
||||||
$main->setConnector($dummy);
|
$main->addConnector($dummy->getHost(), $dummy);
|
||||||
}
|
}
|
||||||
|
|
302
plugins/federator/mastodon.php
Normal file
302
plugins/federator/mastodon.php
Normal file
|
@ -0,0 +1,302 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the host this connector is dedicated to
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getHost() {
|
||||||
|
return "mastodon.social";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
{
|
||||||
|
$remoteURL = $this->service . '/users/' . $userId . '/outbox';
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
do {
|
||||||
|
if ($min !== '') {
|
||||||
|
$remoteURL .= '&minTS=' . urlencode($min);
|
||||||
|
}
|
||||||
|
if ($max !== '') {
|
||||||
|
$remoteURL .= '&maxTS=' . urlencode($max);
|
||||||
|
}
|
||||||
|
// 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 "aborting";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outbox = json_decode($outboxResponse, true);
|
||||||
|
|
||||||
|
// Extract orderedItems from the current page
|
||||||
|
if (isset($outbox['orderedItems'])) {
|
||||||
|
$items = array_merge($items, $outbox['orderedItems']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Use 'last' URL to determine pagination
|
||||||
|
if (isset($outbox['last'])) {
|
||||||
|
// The 'last' URL will usually have a query string that includes min_id for the next set of results
|
||||||
|
$remoteURL = $outbox['last']; // Update to the last URL for the next page of items
|
||||||
|
} else {
|
||||||
|
break; // No more pages, exit pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
} while (!empty($outbox['last'])); // Continue fetching until no 'last' URL
|
||||||
|
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
// 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 = [];
|
||||||
|
foreach ($items as $activity) {
|
||||||
|
if (!isset($activity['type']) || $activity['type'] !== 'Create' || !isset($activity['object'])) {
|
||||||
|
continue; // Skip non-Create activities
|
||||||
|
}
|
||||||
|
|
||||||
|
$obj = $activity['object'];
|
||||||
|
$create = new \Federator\Data\ActivityPub\Common\Create();
|
||||||
|
$create->setID($activity['id'])
|
||||||
|
->setPublished(strtotime($activity['published'] ?? $obj['published'] ?? 'now'))
|
||||||
|
->setAActor($activity['actor'])
|
||||||
|
->addTo("https://www.w3.org/ns/activitystreams#Public");
|
||||||
|
|
||||||
|
// Handle main Note content
|
||||||
|
if ($obj['type'] === 'Note') {
|
||||||
|
$apNote = new \Federator\Data\ActivityPub\Common\Note();
|
||||||
|
$apNote->setID($obj['id'])
|
||||||
|
->setPublished(strtotime($obj['published'] ?? 'now'))
|
||||||
|
->setContent($obj['content'] ?? '')
|
||||||
|
->setAttributedTo($obj['attributedTo'] ?? $activity['actor'])
|
||||||
|
->addTo("https://www.w3.org/ns/activitystreams#Public");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$create->setObject($apNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts[] = $create;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
$main->addConnector($mast->getHost(), $mast);
|
||||||
|
}
|
|
@ -53,6 +53,15 @@ class RedisCache implements Cache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the host this connector is dedicated to
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getHost() {
|
||||||
|
return "redis";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* connect to redis
|
* connect to redis
|
||||||
* @return void
|
* @return void
|
||||||
|
|
Loading…
Add table
Reference in a new issue