forked from grumpydevelop/federator
Compare commits
34 commits
develop
...
mastodon-s
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9b3ae63c7e | ||
![]() |
96bb1efe16 | ||
![]() |
8891234617 | ||
![]() |
90fe8fab5c | ||
![]() |
f8539e479e | ||
![]() |
10dec5ebd3 | ||
![]() |
30c577c82f | ||
![]() |
5c90b4cfc9 | ||
![]() |
7a5870de95 | ||
![]() |
572bb376c1 | ||
![]() |
62cfd6ef0d | ||
![]() |
ba88adcebd | ||
![]() |
d355b5a7cd | ||
![]() |
767f51cc5b | ||
![]() |
6cf9a030a4 | ||
![]() |
49a4bee76a | ||
![]() |
da18d37a79 | ||
![]() |
ce7aa5c72d | ||
![]() |
8ea9bdcf9a | ||
![]() |
10a3b1e0f9 | ||
![]() |
352377887c | ||
![]() |
0edc4c0db6 | ||
![]() |
21f73fb56f | ||
![]() |
4d36fc3c61 | ||
![]() |
d9b02bd95b | ||
![]() |
957b4f5266 | ||
![]() |
64ee4c518f | ||
![]() |
f6d6c74bc6 | ||
![]() |
20c2db4b35 | ||
![]() |
305ded4986 | ||
![]() |
823283183e | ||
![]() |
721e37882d | ||
![]() |
1a7b8264a1 | ||
![]() |
530caa7ea6 |
67 changed files with 5007 additions and 261 deletions
16
.gitattributes
vendored
Normal file
16
.gitattributes
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text=auto
|
||||
|
||||
# Explicitly declare text files you want to always be normalized and converted
|
||||
# to native line endings on checkout.
|
||||
*.c text
|
||||
*.h text
|
||||
*.php text
|
||||
|
||||
# Declare files that will always have LF line endings on checkout.
|
||||
*.sln text eol=lf
|
||||
*.php text eol.lf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -5,3 +5,4 @@ php-docs
|
|||
.phpdoc
|
||||
phpdoc
|
||||
html
|
||||
/cache
|
||||
|
|
|
@ -359,6 +359,7 @@ return [
|
|||
'directory_list' => [
|
||||
'vendor/phan/phan/src/Phan',
|
||||
'vendor/smarty/smarty/src',
|
||||
'vendor/resque/php-resque/lib',
|
||||
'php/',
|
||||
'plugins',
|
||||
'htdocs',
|
||||
|
@ -367,5 +368,7 @@ return [
|
|||
// A list of individual files to include in analysis
|
||||
// with a path relative to the root directory of the
|
||||
// project.
|
||||
'file_list' => [],
|
||||
'file_list' => [
|
||||
'phan-stubs.php',
|
||||
],
|
||||
];
|
||||
|
|
|
@ -35,7 +35,7 @@ If the include redis cache is enabled,
|
|||
- install redis
|
||||
- create a users.acl for redis with the content:
|
||||
|
||||
user federator on ~u_* +get +set ~s_* +get +setex ~m_* +get +setex >redis*change*password
|
||||
user federator on ~u_* +get +set ~s_* +get +setex ~m_* +get +setex ~publickey_* +get +setex >redis*change*password
|
||||
|
||||
- change password in the rediscache.ini to match your given password.
|
||||
- install the redis plugin from pecl if not provided via your distro
|
||||
|
@ -52,9 +52,11 @@ To configure an apache server, add the following rewrite rules:
|
|||
RewriteCond expr "%{HTTP:content-type} -strcmatch '*application/activity+json*'"
|
||||
RewriteRule ^@(.*)$ /federator.php?_call=fedusers/$1 [L,END]
|
||||
RewriteRule ^users/(.*)$ /federator.php?_call=fedusers/$1 [L,END]
|
||||
RewriteRule ^api/federator/(.+)$ federator.php?_call=$1 [L,END]
|
||||
RewriteRule ^inbox[/]?$ /federator.php?_call=fedusers/inbox [L,END]
|
||||
RewriteRule ^api/federator/(.+)$ /federator.php?_call=$1 [L,END]
|
||||
RewriteRule ^(\.well-known/.*)$ /federator.php?_call=$1 [L,END]
|
||||
RewriteRule ^(nodeinfo/2\.[01])$ /federator.php?_call=$1 [L,END]
|
||||
RewriteRule ^([a-zA-Z0-9_-]+.*)$ /federator.php?_call=fedusers/$1 [L,END]
|
||||
</Directory>
|
||||
|
||||
change your document root for the domain you want to use (or default one if using localhost) to the directory you installed it, with the /htdocs at the end. A user should only be able to open that file, not the other data.
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
"description": "A federation service",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"smarty/smarty": "^5.3"
|
||||
"smarty/smarty": "^5.3",
|
||||
"resque/php-resque": "^1.3.6"
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"authors": [
|
||||
|
@ -14,5 +15,10 @@
|
|||
],
|
||||
"require-dev": {
|
||||
"phan/phan": "^5.4"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Federator\\": "php/federator/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
15
config.ini
15
config.ini
|
@ -1,5 +1,5 @@
|
|||
[generic]
|
||||
externaldomain = 'your.fqdn'
|
||||
externaldomain = 'contentnation.net'
|
||||
|
||||
[database]
|
||||
host = '127.0.0.1'
|
||||
|
@ -8,13 +8,18 @@ password = '*change*me*'
|
|||
database = 'federator'
|
||||
|
||||
[templates]
|
||||
path = '../templates/federator/'
|
||||
compiledir = '../cache'
|
||||
path = 'templates/federator/'
|
||||
compiledir = 'cache'
|
||||
|
||||
[plugins]
|
||||
rediscache = 'rediscache.php'
|
||||
dummy = 'dummyconnector.php'
|
||||
# dummy = 'dummyconnector.php'
|
||||
contentnation = 'contentnation.php'
|
||||
|
||||
[maintenance]
|
||||
username = 'federatoradmin'
|
||||
password = '*change*me*as*well'
|
||||
password = '*change*me*as*well'
|
||||
|
||||
[keys]
|
||||
federatorPrivateKeyPath = 'federator.key'
|
||||
federatorPublicKeyPath = 'federator.pub'
|
10
contentnation.ini
Normal file
10
contentnation.ini
Normal file
|
@ -0,0 +1,10 @@
|
|||
[contentnation]
|
||||
service-uri = http://local.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'
|
||||
|
||||
[keys]
|
||||
headerSenderName = 'contentnation'
|
||||
publicKeyPath = 'contentnation.pub'
|
8
formatsupport.json
Normal file
8
formatsupport.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"activitypub": {
|
||||
"article": [
|
||||
"localhost",
|
||||
"writefreely.org"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@ date_default_timezone_set("Europe/Berlin");
|
|||
spl_autoload_register(static function (string $className) {
|
||||
include '../php/' . str_replace("\\", "/", strtolower($className)) . '.php';
|
||||
});
|
||||
define('PROJECT_ROOT', dirname(__DIR__, 1));
|
||||
|
||||
/// main instance
|
||||
$contentnation = new \Federator\Api();
|
||||
|
|
113
htdocs/index.html
Normal file
113
htdocs/index.html
Normal file
|
@ -0,0 +1,113 @@
|
|||
<!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 overflow-y-auto">
|
||||
<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="users/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-wrap">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 } : {}),
|
||||
"HTTP_HOST": "localhost",
|
||||
};
|
||||
|
||||
fetch("http://localhost/" + 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 = "users/grumpydevelop@contentnation.net/outbox?page=0";
|
||||
requestBox.querySelector(".request-type-input").value = "GET";
|
||||
requestBox.querySelector(".session-input").value = "";
|
||||
requestBox.querySelector(".profile-input").value = "";
|
||||
requestBox.querySelector(".response").textContent = "Waiting for response";
|
||||
|
||||
requestBox.querySelector(".send-btn").addEventListener("click", function () {
|
||||
sendRequest(this);
|
||||
});
|
||||
|
||||
container.appendChild(requestBox);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
10
phan-stubs.php
Normal file
10
phan-stubs.php
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
if (!function_exists('getallheaders')) {
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function getallheaders(): array {
|
||||
return [];
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@
|
|||
* @author Sascha Nitsch (grumpydeveloper)
|
||||
**/
|
||||
|
||||
namespace Federator;
|
||||
namespace Federator;
|
||||
|
||||
/**
|
||||
* main API class
|
||||
|
@ -47,7 +47,7 @@ class Api extends Main
|
|||
public function __construct()
|
||||
{
|
||||
$this->contentType = "application/json";
|
||||
Main::__construct();
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,11 +69,12 @@ class Api extends Main
|
|||
/**
|
||||
* main API function
|
||||
*/
|
||||
public function run() : void
|
||||
public function run(): void
|
||||
{
|
||||
$this->setPath((string)$_REQUEST['_call']);
|
||||
$this->setPath((string) $_REQUEST['_call']);
|
||||
$this->openDatabase();
|
||||
$this->loadPlugins();
|
||||
|
||||
$retval = "";
|
||||
$handler = null;
|
||||
if ($this->connector === null) {
|
||||
|
@ -100,12 +101,49 @@ class Api extends Main
|
|||
break;
|
||||
case 'fedusers':
|
||||
$handler = new Api\FedUsers($this);
|
||||
$this->setContentType("application/activity+json");
|
||||
break;
|
||||
case 'v1':
|
||||
switch ($this->paths[1]) {
|
||||
case 'dummy':
|
||||
$handler = new Api\V1\Dummy($this);
|
||||
break;
|
||||
case 'newcontent':
|
||||
$handler = new Api\V1\NewContent($this);
|
||||
break;
|
||||
/* case 'sendFollow': { // hacky implementation for testing purposes
|
||||
$username = $this->paths[2];
|
||||
$domain = $this->config['generic']['externaldomain'];
|
||||
$response = \Federator\DIO\Followers::sendFollowRequest(
|
||||
$this->dbh,
|
||||
$this->connector,
|
||||
$this->cache,
|
||||
$username,
|
||||
"admin@mastodon.local",
|
||||
$domain
|
||||
);
|
||||
header("Content-type: " . $this->contentType);
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Cache-Control: no-cache, no-store, must-revalidate");
|
||||
header("Pragma: no-cache");
|
||||
header("Expires: 0");
|
||||
if (is_string($response)) {
|
||||
$this->setResponseCode(200);
|
||||
$retval = json_encode(array(
|
||||
"status" => "ok",
|
||||
"message" => $response
|
||||
));
|
||||
} else {
|
||||
$this->setResponseCode(500);
|
||||
$retval = json_encode(array(
|
||||
"status" => "error",
|
||||
"message" => "Failed to send follow request"
|
||||
));
|
||||
}
|
||||
http_response_code($this->responseCode);
|
||||
echo $retval;
|
||||
return;
|
||||
} */
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -168,7 +206,7 @@ class Api extends Main
|
|||
* @param string $message optional message
|
||||
* @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
|
||||
if ($this->user === false) {
|
||||
|
@ -191,6 +229,96 @@ class Api extends Main
|
|||
throw new $exception($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the headers include a valid signature
|
||||
*
|
||||
* @param string[] $headers the headers
|
||||
* @throws Exceptions\PermissionDenied
|
||||
* @return string|Exceptions\PermissionDenied
|
||||
*/
|
||||
public function checkSignature($headers)
|
||||
{
|
||||
if (isset($headers['X-Sender'])) {
|
||||
try {
|
||||
return $this->connector->checkSignature($headers);
|
||||
} catch (Exceptions\PermissionDenied $e) {
|
||||
http_response_code(500);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$signatureHeader = $headers['Signature'] ?? null;
|
||||
|
||||
if (!isset($signatureHeader)) {
|
||||
throw new Exceptions\PermissionDenied("Missing Signature header");
|
||||
}
|
||||
|
||||
// Parse Signature header
|
||||
preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches);
|
||||
$signatureParts = array_combine($matches[1], $matches[2]);
|
||||
|
||||
$signature = base64_decode($signatureParts['signature']);
|
||||
$signedHeaders = explode(' ', $signatureParts['headers']);
|
||||
$keyId = $signatureParts['keyId'];
|
||||
|
||||
$publicKeyPem = $this->cache->getPublicKey($keyId);
|
||||
|
||||
if (!isset($publicKeyPem) || $publicKeyPem === false) {
|
||||
// Fetch public key from `keyId` (usually actor URL + #main-key)
|
||||
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
|
||||
|
||||
if ($info['http_code'] != 200) {
|
||||
throw new Exceptions\PermissionDenied("Failed to fetch public key from keyId: $keyId");
|
||||
}
|
||||
|
||||
$actor = json_decode($publicKeyData, true);
|
||||
|
||||
if (!is_array($actor) || !isset($actor['id'])) {
|
||||
throw new Exceptions\PermissionDenied("Invalid actor data");
|
||||
}
|
||||
|
||||
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
|
||||
|
||||
if (!isset($publicKeyPem) || $publicKeyPem === false) {
|
||||
http_response_code(500);
|
||||
throw new Exceptions\PermissionDenied("Public key couldn't be determined");
|
||||
}
|
||||
|
||||
// Cache the public key for 1 hour
|
||||
$this->cache->savePublicKey($keyId, $publicKeyPem);
|
||||
}
|
||||
|
||||
// Reconstruct the signed string
|
||||
$signedString = '';
|
||||
foreach ($signedHeaders as $header) {
|
||||
if ($header === '(request-target)') {
|
||||
$method = strtolower($_SERVER['REQUEST_METHOD']);
|
||||
$path = $_SERVER['REQUEST_URI'];
|
||||
$headerValue = "$method $path";
|
||||
} else {
|
||||
$headerValue = $headers[ucwords($header, '-')] ?? '';
|
||||
}
|
||||
|
||||
$signedString .= strtolower($header) . ": " . $headerValue . "\n";
|
||||
}
|
||||
|
||||
$signedString = rtrim($signedString);
|
||||
|
||||
// Verify the signature
|
||||
$pubkeyRes = openssl_pkey_get_public($publicKeyPem);
|
||||
$verified = false;
|
||||
if ($pubkeyRes instanceof \OpenSSLAsymmetricKey && is_string($signature)) {
|
||||
$verified = openssl_verify($signedString, $signature, $pubkeyRes, OPENSSL_ALGO_SHA256);
|
||||
}
|
||||
if ($verified != 1) {
|
||||
http_response_code(500);
|
||||
throw new Exceptions\PermissionDenied("Signature verification failed");
|
||||
}
|
||||
|
||||
// Signature is valid!
|
||||
return "Signature verified.";
|
||||
}
|
||||
|
||||
/**
|
||||
* remove unwanted elements from html input
|
||||
*
|
||||
|
@ -198,7 +326,7 @@ class Api extends Main
|
|||
* input to strip
|
||||
* @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};>', $out);
|
||||
|
@ -212,7 +340,7 @@ class Api extends Main
|
|||
* parameter to check
|
||||
* @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);
|
||||
}
|
||||
|
@ -228,13 +356,13 @@ class Api extends Main
|
|||
*/
|
||||
public function escapePost(string $key, $int = false)
|
||||
{
|
||||
if (! array_key_exists($key, $_POST)) {
|
||||
if (!array_key_exists($key, $_POST)) {
|
||||
return $int ? 0 : "";
|
||||
}
|
||||
if ($int === true) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ class FedUsers implements APIInterface
|
|||
/**
|
||||
* main instance
|
||||
*
|
||||
* @var \Federator\Main $main
|
||||
* @var \Federator\Api $main
|
||||
*/
|
||||
private $main;
|
||||
|
||||
|
@ -49,24 +49,34 @@ class FedUsers implements APIInterface
|
|||
{
|
||||
$method = $_SERVER["REQUEST_METHOD"];
|
||||
$handler = null;
|
||||
$_username = $paths[1];
|
||||
switch (sizeof($paths)) {
|
||||
case 2:
|
||||
if ($method === 'GET') {
|
||||
// /users/username or /@username
|
||||
return $this->returnUserProfile($paths[1]);
|
||||
// /users/username or /@username or /username
|
||||
return $this->returnUserProfile($_username);
|
||||
} else {
|
||||
switch ($paths[1]) {
|
||||
case 'inbox':
|
||||
$_username = NULL;
|
||||
$handler = new FedUsers\Inbox($this->main);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
// /users/username/(inbox|outbox|following|followers)
|
||||
switch ($paths[2]) {
|
||||
case 'following':
|
||||
// $handler = new FedUsers\Following();
|
||||
$handler = new FedUsers\Following($this->main);
|
||||
break;
|
||||
case 'followers':
|
||||
// $handler = new FedUsers\Followers();
|
||||
$handler = new FedUsers\Followers($this->main);
|
||||
break;
|
||||
case 'inbox':
|
||||
// $handler = new FedUsers\Inbox();
|
||||
$handler = new FedUsers\Inbox($this->main);
|
||||
break;
|
||||
case 'outbox':
|
||||
$handler = new FedUsers\Outbox($this->main);
|
||||
|
@ -82,10 +92,10 @@ class FedUsers implements APIInterface
|
|||
$ret = false;
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
$ret = $handler->get($paths[1]);
|
||||
$ret = $handler->get($_username);
|
||||
break;
|
||||
case 'POST':
|
||||
$ret = $handler->post($paths[1]);
|
||||
$ret = $handler->post($_username);
|
||||
break;
|
||||
}
|
||||
if ($ret !== false) {
|
||||
|
@ -115,18 +125,24 @@ class FedUsers implements APIInterface
|
|||
if ($user === false || $user->id === null) {
|
||||
throw new \Federator\Exceptions\FileNotFound();
|
||||
}
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
$jsonKey = json_encode($user->publicKey);
|
||||
if (!is_string($jsonKey)) {
|
||||
throw new \Federator\Exceptions\FileNotFound();
|
||||
}
|
||||
$data = [
|
||||
'iconMediaType' => $user->iconMediaType,
|
||||
'iconURL' => $user->iconURL,
|
||||
'imageMediaType' => $user->imageMediaType,
|
||||
'imageURL' => $user->imageURL,
|
||||
'fqdn' => $_SERVER['SERVER_NAME'],
|
||||
'fqdn' => $domain,
|
||||
'name' => $user->name,
|
||||
'username' => $user->id,
|
||||
'publickey' => str_replace("\n", "\\n", $user->publicKey),
|
||||
'publickey' => trim($jsonKey, '"'),
|
||||
'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z
|
||||
'summary' => $user->summary,
|
||||
'type' => $user->type
|
||||
'type' => ucfirst($user->type) // capitalized user type
|
||||
];
|
||||
$this->response = $this->main->renderTemplate('user.json', $data);
|
||||
return true;
|
||||
|
|
120
php/federator/api/fedusers/followers.php
Normal file
120
php/federator/api/fedusers/followers.php
Normal file
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Api\FedUsers;
|
||||
|
||||
/**
|
||||
* handle activitypub followers requests
|
||||
*/
|
||||
class Followers implements \Federator\Api\FedUsers\FedUsersInterface
|
||||
{
|
||||
/**
|
||||
* main instance
|
||||
*
|
||||
* @var \Federator\Api $main
|
||||
*/
|
||||
private $main;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
* @param \Federator\Api $main main instance
|
||||
*/
|
||||
public function __construct($main)
|
||||
{
|
||||
$this->main = $main;
|
||||
}
|
||||
|
||||
/**
|
||||
* handle get call
|
||||
*
|
||||
* @param string|null $_user user to fetch followers for
|
||||
* @return string|false response
|
||||
*/
|
||||
public function get($_user)
|
||||
{
|
||||
if (!isset($_user)) {
|
||||
return false;
|
||||
}
|
||||
$dbh = $this->main->getDatabase();
|
||||
$cache = $this->main->getCache();
|
||||
$connector = $this->main->getConnector();
|
||||
|
||||
// get user
|
||||
$user = \Federator\DIO\User::getUserByName(
|
||||
$dbh,
|
||||
$_user,
|
||||
$connector,
|
||||
$cache
|
||||
);
|
||||
if ($user->id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$followers = new \Federator\Data\ActivityPub\Common\Followers();
|
||||
$followerItems = \Federator\DIO\Followers::getFollowersByUser($dbh, $user->id, $connector, $cache);
|
||||
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
$baseUrl = 'https://' . $domain . '/' . $_user . '/followers';
|
||||
|
||||
$pageSize = 10;
|
||||
$page = $this->main->extractFromURI("page", "");
|
||||
$id = $baseUrl;
|
||||
$items = [];
|
||||
$totalItems = count($followerItems);
|
||||
|
||||
if ($page !== "") {
|
||||
$pageNum = max(0, (int) $page);
|
||||
$offset = (int)($pageNum * $pageSize);
|
||||
$pagedItems = array_slice($followerItems, $offset, $pageSize);
|
||||
|
||||
foreach ($pagedItems as $follower) {
|
||||
$items[] = $follower->actorURL;
|
||||
}
|
||||
$followers->setItems($items);
|
||||
$id .= '?page=' . urlencode($page);
|
||||
}
|
||||
|
||||
$followers->setID($id);
|
||||
$followers->setPartOf($baseUrl);
|
||||
$followers->setTotalItems($totalItems);
|
||||
|
||||
// Pagination navigation
|
||||
$lastPage = max(0, ceil($totalItems / $pageSize) - 1);
|
||||
|
||||
if ($page === "" || $followers->count() == 0) {
|
||||
$followers->setFirst($baseUrl . '?page=0');
|
||||
$followers->setLast($baseUrl . '?page=' . $lastPage);
|
||||
}
|
||||
if ($page !== "") {
|
||||
$pageNum = max(0, (int) $page);
|
||||
if ($pageNum < $lastPage) {
|
||||
$followers->setNext($baseUrl . '?page=' . ($pageNum + 1));
|
||||
}
|
||||
if ($pageNum > 0) {
|
||||
$followers->setPrev($baseUrl . '?page=' . ($pageNum - 1));
|
||||
}
|
||||
$followers->setFirst($baseUrl . '?page=0');
|
||||
$followers->setLast($baseUrl . '?page=' . $lastPage);
|
||||
}
|
||||
$obj = $followers->toObject();
|
||||
|
||||
return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* handle post call
|
||||
*
|
||||
* @param string|null $_user user to add data to outbox @unused-param
|
||||
* @return string|false response
|
||||
*/
|
||||
public function post($_user)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
120
php/federator/api/fedusers/following.php
Normal file
120
php/federator/api/fedusers/following.php
Normal file
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Api\FedUsers;
|
||||
|
||||
/**
|
||||
* handle activitypub following requests
|
||||
*/
|
||||
class Following implements \Federator\Api\FedUsers\FedUsersInterface
|
||||
{
|
||||
/**
|
||||
* main instance
|
||||
*
|
||||
* @var \Federator\Api $main
|
||||
*/
|
||||
private $main;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
* @param \Federator\Api $main main instance
|
||||
*/
|
||||
public function __construct($main)
|
||||
{
|
||||
$this->main = $main;
|
||||
}
|
||||
|
||||
/**
|
||||
* handle get call
|
||||
*
|
||||
* @param string|null $_user user to fetch followers for
|
||||
* @return string|false response
|
||||
*/
|
||||
public function get($_user)
|
||||
{
|
||||
if (!isset($_user)) {
|
||||
return false;
|
||||
}
|
||||
$dbh = $this->main->getDatabase();
|
||||
$cache = $this->main->getCache();
|
||||
$connector = $this->main->getConnector();
|
||||
|
||||
// get user
|
||||
$user = \Federator\DIO\User::getUserByName(
|
||||
$dbh,
|
||||
$_user,
|
||||
$connector,
|
||||
$cache
|
||||
);
|
||||
if ($user->id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$following = new \Federator\Data\ActivityPub\Common\Following();
|
||||
$followingItems = \Federator\DIO\Followers::getFollowingForUser($dbh, $user->id, $connector, $cache);
|
||||
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
$baseUrl = 'https://' . $domain . '/users/' . $_user . '/following';
|
||||
|
||||
$pageSize = 10;
|
||||
$page = $this->main->extractFromURI("page", "");
|
||||
$id = $baseUrl;
|
||||
$items = [];
|
||||
$totalItems = count($followingItems);
|
||||
|
||||
if ($page !== "") {
|
||||
$pageNum = max(0, (int) $page);
|
||||
$offset = (int) ($pageNum * $pageSize);
|
||||
$pagedItems = array_slice($followingItems, $offset, $pageSize);
|
||||
|
||||
foreach ($pagedItems as $followed) {
|
||||
$items[] = $followed->actorURL;
|
||||
}
|
||||
$following->setItems($items);
|
||||
$id .= '?page=' . urlencode($page);
|
||||
}
|
||||
|
||||
$following->setID($id);
|
||||
$following->setPartOf($baseUrl);
|
||||
$following->setTotalItems($totalItems);
|
||||
|
||||
// Pagination navigation
|
||||
$lastPage = max(0, ceil($totalItems / $pageSize) - 1);
|
||||
|
||||
if ($page === "" || $following->count() == 0) {
|
||||
$following->setFirst($baseUrl . '?page=0');
|
||||
$following->setLast($baseUrl . '?page=' . $lastPage);
|
||||
}
|
||||
if ($page !== "") {
|
||||
$pageNum = max(0, (int) $page);
|
||||
if ($pageNum < $lastPage) {
|
||||
$following->setNext($baseUrl . '?page=' . ($pageNum + 1));
|
||||
}
|
||||
if ($pageNum > 0) {
|
||||
$following->setPrev($baseUrl . '?page=' . ($pageNum - 1));
|
||||
}
|
||||
$following->setFirst($baseUrl . '?page=0');
|
||||
$following->setLast($baseUrl . '?page=' . $lastPage);
|
||||
}
|
||||
$obj = $following->toObject();
|
||||
|
||||
return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* handle post call
|
||||
*
|
||||
* @param string|null $_user user to add data to outbox @unused-param
|
||||
* @return string|false response
|
||||
*/
|
||||
public function post($_user)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
441
php/federator/api/fedusers/inbox.php
Normal file
441
php/federator/api/fedusers/inbox.php
Normal file
|
@ -0,0 +1,441 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Api\FedUsers;
|
||||
|
||||
/**
|
||||
* handle activitypub inbox requests
|
||||
*/
|
||||
class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
|
||||
{
|
||||
/**
|
||||
* main instance
|
||||
*
|
||||
* @var \Federator\Api $main
|
||||
*/
|
||||
private $main;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
* @param \Federator\Api $main api main instance
|
||||
*/
|
||||
public function __construct($main)
|
||||
{
|
||||
$this->main = $main;
|
||||
}
|
||||
|
||||
/**
|
||||
* handle get call
|
||||
*
|
||||
* @param string|null $_user user to fetch inbox for @unused-param
|
||||
* @return string|false response
|
||||
*/
|
||||
public function get($_user)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* handle post call
|
||||
*
|
||||
* @param string|null $_user user to add data to inbox
|
||||
* @return string|false response
|
||||
*/
|
||||
public function post($_user)
|
||||
{
|
||||
$_rawInput = file_get_contents('php://input');
|
||||
|
||||
$allHeaders = getallheaders();
|
||||
try {
|
||||
$this->main->checkSignature($allHeaders);
|
||||
} catch (\Federator\Exceptions\PermissionDenied $e) {
|
||||
throw new \Federator\Exceptions\Unauthorized("Inbox::post Signature check failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
|
||||
|
||||
$dbh = $this->main->getDatabase();
|
||||
$cache = $this->main->getCache();
|
||||
$connector = $this->main->getConnector();
|
||||
|
||||
$config = $this->main->getConfig();
|
||||
|
||||
if (!is_array($activity)) {
|
||||
throw new \Federator\Exceptions\ServerError("Inbox::post Input wasn't of type array");
|
||||
}
|
||||
|
||||
$inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity);
|
||||
|
||||
if ($inboxActivity === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Inbox::post couldn't create inboxActivity");
|
||||
}
|
||||
$user = $inboxActivity->getAActor(); // url of the sender https://contentnation.net/username
|
||||
$username = basename((string) (parse_url($user, PHP_URL_PATH) ?? ''));
|
||||
$domain = parse_url($user, PHP_URL_HOST);
|
||||
$userId = $username . '@' . $domain;
|
||||
$user = \Federator\DIO\FedUser::getUserByName(
|
||||
$dbh,
|
||||
$userId,
|
||||
$cache
|
||||
);
|
||||
if ($user === null || $user->id === null) {
|
||||
error_log("Inbox::post couldn't find user: $userId");
|
||||
throw new \Federator\Exceptions\ServerError("Inbox::post couldn't find user: $userId");
|
||||
}
|
||||
|
||||
$users = [];
|
||||
|
||||
$receivers = array_merge($inboxActivity->getTo(), $inboxActivity->getCC());
|
||||
|
||||
// For Undo, the object may hold the proper to/cc
|
||||
if ($inboxActivity->getType() === 'Undo') {
|
||||
$object = $inboxActivity->getObject();
|
||||
if ($object !== null && is_object($object)) {
|
||||
$receivers = array_merge($object->getTo(), $object->getCC());
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out the public address and keep only actual URLs
|
||||
$receivers = array_filter($receivers, static function (mixed $receiver): bool {
|
||||
return is_string($receiver)
|
||||
&& $receiver !== 'https://www.w3.org/ns/activitystreams#Public'
|
||||
&& (filter_var($receiver, FILTER_VALIDATE_URL) !== false);
|
||||
});
|
||||
|
||||
if (isset($_user)) {
|
||||
$receivers[] = $dbh->real_escape_string($_user); // Add the target user to the receivers list
|
||||
}
|
||||
|
||||
// Special handling for Follow and Undo follow activities
|
||||
if (strtolower($inboxActivity->getType()) === 'follow') {
|
||||
// For Follow, the object should hold the target
|
||||
$object = $inboxActivity->getObject();
|
||||
if ($object !== null && is_string($object)) {
|
||||
$receivers[] = $object;
|
||||
}
|
||||
} elseif (strtolower($inboxActivity->getType()) === 'undo') {
|
||||
$object = $inboxActivity->getObject();
|
||||
if ($object !== null && is_object($object)) {
|
||||
// For Undo, the objects object should hold the target
|
||||
if (strtolower($object->getType()) === 'follow') {
|
||||
$objObject = $object->getObject();
|
||||
if ($objObject !== null && is_string($objObject)) {
|
||||
$receivers[] = $objObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$ourDomain = $config['generic']['externaldomain'];
|
||||
|
||||
foreach ($receivers as $receiver) {
|
||||
if ($receiver === '' || !is_string($receiver)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_ends_with($receiver, '/followers')) {
|
||||
$actor = $inboxActivity->getAActor();
|
||||
if ($actor === null || !is_string($actor)) {
|
||||
error_log("Inbox::post no actor found");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract username from the actor URL
|
||||
$username = basename((string) (parse_url($actor, PHP_URL_PATH) ?? ''));
|
||||
$domain = parse_url($actor, PHP_URL_HOST);
|
||||
if ($username === null || $domain === null) {
|
||||
error_log("Inbox::post no username or domain found for recipient: $receiver");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$followers = \Federator\DIO\Followers::getFollowersByFedUser($dbh, $connector, $cache, $username . '@' . $domain);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Inbox::post get followers for user: " . $username . '@' . $domain . ". Exception: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($followers)) {
|
||||
$users = array_merge($users, array_column($followers, 'id'));
|
||||
}
|
||||
} else {
|
||||
// check if receiver is an actor url from our domain
|
||||
if (!str_contains($receiver, $ourDomain) && $receiver !== $_user) {
|
||||
continue;
|
||||
}
|
||||
if ($receiver !== $_user) {
|
||||
$receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? ''));
|
||||
$ourDomain = parse_url($receiver, PHP_URL_HOST);
|
||||
if ($receiverName === null || $ourDomain === null) {
|
||||
error_log("Inbox::post no receiverName or domain found for receiver: " . $receiver);
|
||||
continue;
|
||||
}
|
||||
$receiver = $receiverName;
|
||||
}
|
||||
try {
|
||||
$localUser = \Federator\DIO\User::getUserByName(
|
||||
$dbh,
|
||||
$receiver,
|
||||
$connector,
|
||||
$cache
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Inbox::post get user by name: " . $receiver . ". Exception: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
if ($localUser === null || $localUser->id === null) {
|
||||
error_log("Inbox::post couldn't find user: $receiver");
|
||||
continue;
|
||||
}
|
||||
$users[] = $localUser->id;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($users)) { // todo remove after proper implementation, debugging for now
|
||||
$rootDir = PROJECT_ROOT . '/';
|
||||
// Save the raw input and parsed JSON to a file for inspection
|
||||
file_put_contents(
|
||||
$rootDir . 'logs/inbox.log',
|
||||
date('Y-m-d H:i:s') . ": ==== POST Inbox Activity ====\n" . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($users as $receiver) {
|
||||
if (!isset($receiver)) {
|
||||
continue;
|
||||
}
|
||||
$token = \Resque::enqueue('inbox', 'Federator\\Jobs\\InboxJob', [
|
||||
'user' => $user->id,
|
||||
'recipientId' => $receiver,
|
||||
'activity' => $inboxActivity->toObject(),
|
||||
]);
|
||||
error_log("Inbox::post enqueued job for user: $user->id with token: $token");
|
||||
}
|
||||
if (empty($users)) {
|
||||
$type = strtolower($inboxActivity->getType());
|
||||
if ($type === 'undo' || $type === 'delete') {
|
||||
$token = \Resque::enqueue('inbox', 'Federator\\Jobs\\InboxJob', [
|
||||
'user' => $user->id,
|
||||
'recipientId' => "",
|
||||
'activity' => $inboxActivity->toObject(),
|
||||
]);
|
||||
error_log("Inbox::post enqueued job for user: $user->id with token: $token");
|
||||
} else {
|
||||
error_log("Inbox::post no users found for activity, doing nothing: " . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $inboxActivity);
|
||||
if ($articleId !== null) {
|
||||
$connector->sendActivity($user, $inboxActivity);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Inbox::postForUser Error sending activity to connector. Exception: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
return "success";
|
||||
}
|
||||
|
||||
/**
|
||||
* handle post call for specific user
|
||||
*
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param \Federator\Connector\Connector $connector connector to use
|
||||
* @param \Federator\Cache\Cache|null $cache optional caching service
|
||||
* @param string $_user user that triggered the post
|
||||
* @param string $_recipientId recipient of the post
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $inboxActivity the activity that we received
|
||||
* @return boolean response
|
||||
*/
|
||||
public static function postForUser($dbh, $connector, $cache, $_user, $_recipientId, $inboxActivity)
|
||||
{
|
||||
if (!isset($_user)) {
|
||||
error_log("Inbox::postForUser no user given");
|
||||
return false;
|
||||
}
|
||||
|
||||
// get sender
|
||||
$user = \Federator\DIO\FedUser::getUserByName(
|
||||
$dbh,
|
||||
$_user,
|
||||
$cache
|
||||
);
|
||||
if ($user === null || $user->id === null) {
|
||||
error_log("Inbox::postForUser couldn't find user: $_user");
|
||||
return false;
|
||||
}
|
||||
|
||||
$type = strtolower($inboxActivity->getType());
|
||||
|
||||
if ($_recipientId === '') {
|
||||
if ($type === 'undo' || $type === 'delete') {
|
||||
switch ($type) {
|
||||
case 'delete':
|
||||
// Delete Note/Post
|
||||
$object = $inboxActivity->getObject();
|
||||
if (is_string($object)) {
|
||||
\Federator\DIO\Posts::deletePost($dbh, $object);
|
||||
} elseif (is_object($object)) {
|
||||
$objectId = $object->getID();
|
||||
\Federator\DIO\Posts::deletePost($dbh, $objectId);
|
||||
} else {
|
||||
error_log("Inbox::postForUser Error in Delete Post for user $user->id, object is not a string or object");
|
||||
error_log(" object of type " . gettype($object));
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'undo':
|
||||
$object = $inboxActivity->getObject();
|
||||
if (is_object($object)) {
|
||||
switch (strtolower($object->getType())) {
|
||||
case 'like':
|
||||
case 'dislike':
|
||||
// Undo Like/Dislike (remove like/dislike)
|
||||
$targetId = $object->getID();
|
||||
// \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike');
|
||||
\Federator\DIO\Posts::deletePost($dbh, $targetId);
|
||||
break;
|
||||
case 'note':
|
||||
case 'article':
|
||||
// Undo Note (remove note)
|
||||
$noteId = $object->getID();
|
||||
\Federator\DIO\Posts::deletePost($dbh, $noteId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
error_log("Inbox::postForUser Unhandled activity type $type for user $user->id");
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$atPos = strpos($_recipientId, '@');
|
||||
if ($atPos !== false) {
|
||||
$_recipientId = substr($_recipientId, 0, $atPos);
|
||||
}
|
||||
|
||||
// get recipient
|
||||
$recipient = \Federator\DIO\User::getUserByName(
|
||||
$dbh,
|
||||
$_recipientId,
|
||||
$connector,
|
||||
$cache
|
||||
);
|
||||
if ($recipient === null || $recipient->id === null) {
|
||||
error_log("Inbox::postForUser couldn't find user: $_recipientId");
|
||||
return false;
|
||||
}
|
||||
|
||||
$rootDir = PROJECT_ROOT . '/';
|
||||
// Save the raw input and parsed JSON to a file for inspection
|
||||
file_put_contents(
|
||||
$rootDir . 'logs/inbox_' . $recipient->id . '.log',
|
||||
date('Y-m-d H:i:s') . ": ==== POST " . $recipient->id . " Inbox Activity ====\n" . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
|
||||
switch ($type) {
|
||||
case 'follow':
|
||||
$success = \Federator\DIO\Followers::addExternalFollow($dbh, $inboxActivity->getID(), $user->id, $recipient->id);
|
||||
|
||||
if ($success === false) {
|
||||
error_log("Inbox::postForUser Failed to add follower for user $user->id");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
// Delete Note/Post
|
||||
$object = $inboxActivity->getObject();
|
||||
if (is_string($object)) {
|
||||
\Federator\DIO\Posts::deletePost($dbh, $object);
|
||||
} elseif (is_object($object)) {
|
||||
$objectId = $object->getID();
|
||||
\Federator\DIO\Posts::deletePost($dbh, $objectId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'undo':
|
||||
$object = $inboxActivity->getObject();
|
||||
if (is_object($object)) {
|
||||
switch (strtolower($object->getType())) {
|
||||
case 'follow':
|
||||
$success = false;
|
||||
if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) {
|
||||
$actor = $object->getAActor();
|
||||
if ($actor !== '') {
|
||||
$success = \Federator\DIO\Followers::removeFollow($dbh, $user->id, $recipient->id);
|
||||
}
|
||||
}
|
||||
if ($success === false) {
|
||||
error_log("Inbox::postForUser Failed to remove follower for user $user->id");
|
||||
}
|
||||
break;
|
||||
case 'like':
|
||||
case 'dislike':
|
||||
// Undo Like/Dislike (remove like/dislike)
|
||||
$targetId = $object->getID();
|
||||
// \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike');
|
||||
\Federator\DIO\Posts::deletePost($dbh, $targetId);
|
||||
break;
|
||||
case 'note':
|
||||
// Undo Note (remove note)
|
||||
$noteId = $object->getID();
|
||||
\Federator\DIO\Posts::deletePost($dbh, $noteId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'like':
|
||||
case 'dislike':
|
||||
// Add Like/Dislike
|
||||
$targetId = $inboxActivity->getObject();
|
||||
if (is_string($targetId)) {
|
||||
// \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'dislike');
|
||||
\Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity);
|
||||
} else {
|
||||
error_log("Inbox::postForUser Error in Add Like/Dislike for user $user->id, targetId is not a string");
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
case 'update':
|
||||
$object = $inboxActivity->getObject();
|
||||
if (is_object($object)) {
|
||||
switch (strtolower($object->getType())) {
|
||||
case 'note':
|
||||
\Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity);
|
||||
|
||||
break;
|
||||
case 'article':
|
||||
\Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity);
|
||||
|
||||
break;
|
||||
default:
|
||||
\Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
error_log("Inbox::postForUser Unhandled activity type $type for user $user->id");
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -16,13 +16,13 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
/**
|
||||
* main instance
|
||||
*
|
||||
* @var \Federator\Main $main
|
||||
* @var \Federator\Api $main
|
||||
*/
|
||||
private $main;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
* @param \Federator\Main $main main instance
|
||||
* @param \Federator\Api $main main instance
|
||||
*/
|
||||
public function __construct($main)
|
||||
{
|
||||
|
@ -32,11 +32,14 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
/**
|
||||
* handle get call
|
||||
*
|
||||
* @param string $_user user to fetch outbox for
|
||||
* @param string|null $_user user to fetch outbox for
|
||||
* @return string|false response
|
||||
*/
|
||||
public function get($_user)
|
||||
{
|
||||
if (!isset($_user)) {
|
||||
return false;
|
||||
}
|
||||
$dbh = $this->main->getDatabase();
|
||||
$cache = $this->main->getCache();
|
||||
$connector = $this->main->getConnector();
|
||||
|
@ -50,6 +53,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
if ($user->id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// get posts from user
|
||||
$outbox = new \Federator\Data\ActivityPub\Common\Outbox();
|
||||
$min = $this->main->extractFromURI("min", "");
|
||||
|
@ -61,31 +65,32 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|||
} else {
|
||||
$items = [];
|
||||
}
|
||||
$host = $_SERVER['SERVER_NAME'];
|
||||
$id = 'https://' . $host .'/' . $_user . '/outbox';
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
$id = 'https://' . $domain . '/users/' . $_user . '/outbox';
|
||||
$outbox->setPartOf($id);
|
||||
$outbox->setID($id);
|
||||
if ($page !== '') {
|
||||
$id .= '?page=' . urlencode($page);
|
||||
}
|
||||
if ($page === '' || $outbox->count() == 0) {
|
||||
$outbox->setFirst($id);
|
||||
$outbox->setFirst($id . '?page=0');
|
||||
$outbox->setLast($id . '&min=0');
|
||||
}
|
||||
if (sizeof($items)>0) {
|
||||
if (sizeof($items) > 0) {
|
||||
$newestId = $items[0]->getPublished();
|
||||
$oldestId = $items[sizeof($items)-1]->getPublished();
|
||||
$oldestId = $items[sizeof($items) - 1]->getPublished();
|
||||
$outbox->setNext($id . '&max=' . $newestId);
|
||||
$outbox->setPrev($id . '&min=' . $oldestId);
|
||||
}
|
||||
$obj = $outbox->toObject();
|
||||
return json_encode($obj);
|
||||
return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* handle post call
|
||||
*
|
||||
* @param string $_user user to add data to outbox @unused-param
|
||||
* @param string|null $_user user to add data to outbox @unused-param
|
||||
* @return string|false response
|
||||
*/
|
||||
public function post($_user)
|
||||
|
|
|
@ -20,11 +20,7 @@ class Dummy implements \Federator\Api\APIInterface
|
|||
*/
|
||||
private $main;
|
||||
|
||||
/**
|
||||
* internal message to output
|
||||
*
|
||||
* @var Array<string, mixed> $message
|
||||
*/
|
||||
/** @var array<string, string> $message internal message to output */
|
||||
private $message = [];
|
||||
|
||||
/**
|
||||
|
@ -41,10 +37,10 @@ class Dummy implements \Federator\Api\APIInterface
|
|||
* run given url path
|
||||
*
|
||||
* @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 @unused-param
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function exec($paths, $user) : bool
|
||||
public function exec($paths, $user): bool
|
||||
{
|
||||
// only for user with the 'publish' permission
|
||||
if ($user === false || $user->hasPermission('publish') === false) {
|
||||
|
@ -55,16 +51,23 @@ class Dummy implements \Federator\Api\APIInterface
|
|||
case 'GET':
|
||||
switch (sizeof($paths)) {
|
||||
case 3:
|
||||
if ($paths[2] === 'moo') {
|
||||
return $this->getDummy();
|
||||
switch ($paths[2]) {
|
||||
case 'moo':
|
||||
return $this->getDummy();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'POST':
|
||||
switch (sizeof($paths)) {
|
||||
case 3:
|
||||
if ($paths[2] === 'moo') {
|
||||
return $this->postDummy();
|
||||
switch ($paths[2]) {
|
||||
case 'moo':
|
||||
return $this->postDummy();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
557
php/federator/api/v1/newcontent.php
Normal file
557
php/federator/api/v1/newcontent.php
Normal file
|
@ -0,0 +1,557 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Api\V1;
|
||||
|
||||
/**
|
||||
* Called from our application to inform us about new content (f.e. new posts on contentnation.net)
|
||||
*/
|
||||
class NewContent implements \Federator\Api\APIInterface
|
||||
{
|
||||
/**
|
||||
* main instance
|
||||
*
|
||||
* @var \Federator\Api $main
|
||||
*/
|
||||
private $main;
|
||||
|
||||
/**
|
||||
* response from sub-calls
|
||||
*
|
||||
* @var string $response
|
||||
*/
|
||||
private $response;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param \Federator\Main $main main instance
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($main)
|
||||
{
|
||||
$this->main = $main;
|
||||
}
|
||||
|
||||
/**
|
||||
* run given url path
|
||||
*
|
||||
* @param array<string> $paths path array split by /
|
||||
* @param \Federator\Data\User|false $user user who is calling us @unused-param
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function exec($paths, $user)
|
||||
{
|
||||
$method = $_SERVER["REQUEST_METHOD"];
|
||||
$_username = $paths[2];
|
||||
if ($method === 'GET') { // unsupported
|
||||
throw new \Federator\Exceptions\InvalidArgument("GET not supported");
|
||||
}
|
||||
switch (sizeof($paths)) {
|
||||
case 3:
|
||||
$ret = $this->post($_username);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isset($ret) && $ret !== false) {
|
||||
$this->response = $ret;
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->main->setResponseCode(404);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* handle post call
|
||||
*
|
||||
* @param string|null $_user optional user that triggered the post
|
||||
* @return string|false response
|
||||
*/
|
||||
public function post($_user)
|
||||
{
|
||||
$_rawInput = file_get_contents('php://input');
|
||||
$allHeaders = getallheaders();
|
||||
try {
|
||||
$this->main->checkSignature($allHeaders);
|
||||
} catch (\Federator\Exceptions\PermissionDenied $e) {
|
||||
error_log("NewContent::post Signature check failed: " . $e->getMessage());
|
||||
http_response_code(401);
|
||||
return false;
|
||||
}
|
||||
|
||||
$input = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
|
||||
|
||||
|
||||
$dbh = $this->main->getDatabase();
|
||||
$cache = $this->main->getCache();
|
||||
$connector = $this->main->getConnector();
|
||||
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
if (!is_array($input)) {
|
||||
error_log("NewContent::post Input wasn't of type array");
|
||||
return false;
|
||||
}
|
||||
|
||||
$articleId = "";
|
||||
if (isset($allHeaders['X-Sender'])) {
|
||||
$newActivity = $connector->jsonToActivity($input, $articleId);
|
||||
} else {
|
||||
error_log("NewContent::post No X-Sender header found");
|
||||
return false;
|
||||
}
|
||||
if ($newActivity === false) {
|
||||
error_log("NewContent::post couldn't create newActivity");
|
||||
return false;
|
||||
}
|
||||
if (!isset($_user)) {
|
||||
$user = $newActivity->getAActor(); // url of the sender https://contentnation.net/username
|
||||
$posterName = str_replace(
|
||||
$domain,
|
||||
'',
|
||||
$user
|
||||
); // retrieve only the last part of the url
|
||||
} else {
|
||||
$posterName = $dbh->real_escape_string($_user);
|
||||
}
|
||||
|
||||
$users = [];
|
||||
|
||||
$receivers = array_merge($newActivity->getTo(), $newActivity->getCC());
|
||||
|
||||
// For Undo, the object may hold the proper to/cc
|
||||
if ($newActivity->getType() === 'Undo') {
|
||||
$object = $newActivity->getObject();
|
||||
if ($object !== null && is_object($object)) {
|
||||
$receivers = array_merge($object->getTo(), $object->getCC());
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out the public address and keep only actual URLs
|
||||
$receivers = array_filter($receivers, static function (mixed $receiver): bool {
|
||||
return is_string($receiver)
|
||||
&& $receiver !== 'https://www.w3.org/ns/activitystreams#Public'
|
||||
&& (filter_var($receiver, FILTER_VALIDATE_URL) !== false);
|
||||
});
|
||||
|
||||
foreach ($receivers as $receiver) {
|
||||
if ($receiver === '' || !is_string($receiver)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_ends_with($receiver, '/followers')) {
|
||||
|
||||
if ($posterName === null) {
|
||||
error_log("NewContent::post no username found");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$followers = \Federator\DIO\Followers::getFollowersByUser($dbh, $posterName, $connector, $cache);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("NewContent::post get followers for user: " . $posterName . ". Exception: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($followers)) {
|
||||
$users = array_merge($users, array_column($followers, 'id'));
|
||||
}
|
||||
} else {
|
||||
// check if receiver is an actor url and not from our domain
|
||||
if (str_contains($receiver, $domain)) {
|
||||
continue;
|
||||
}
|
||||
$receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? ''));
|
||||
$domain = parse_url($receiver, PHP_URL_HOST);
|
||||
if ($receiverName === null || $domain === null) {
|
||||
if ($receiver === $posterName) {
|
||||
continue;
|
||||
}
|
||||
error_log("NewContent::post no receiverName or domain found for receiver: " . $receiver);
|
||||
continue;
|
||||
}
|
||||
$receiver = $receiverName . '@' . $domain;
|
||||
try {
|
||||
$user = \Federator\DIO\FedUser::getUserByName(
|
||||
$dbh,
|
||||
$receiver,
|
||||
$cache
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("NewContent::post get user by name: " . $receiver . ". Exception: " . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
if ($user === null || $user->id === null) {
|
||||
error_log("NewContent::post couldn't find user: $receiver");
|
||||
continue;
|
||||
}
|
||||
$users[] = $user->id;
|
||||
}
|
||||
}
|
||||
if (empty($users)) { // todo remove after proper implementation, debugging for now
|
||||
$rootDir = PROJECT_ROOT . '/';
|
||||
// Save the raw input and parsed JSON to a file for inspection
|
||||
file_put_contents(
|
||||
$rootDir . 'logs/newContent.log',
|
||||
date('Y-m-d H:i:s') . ": ==== POST NewContent Activity ====\n" . json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($users as $receiver) {
|
||||
if (!isset($receiver)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token = \Resque::enqueue('inbox', 'Federator\\Jobs\\NewContentJob', [
|
||||
'user' => $posterName,
|
||||
'recipientId' => $receiver,
|
||||
'activity' => $newActivity->toObject(),
|
||||
'articleId' => $articleId,
|
||||
]);
|
||||
error_log("Inbox::post enqueued job for receiver: $receiver with token: $token");
|
||||
}
|
||||
|
||||
return json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* handle post call for specific user
|
||||
*
|
||||
* @param \mysqli $dbh @unused-param
|
||||
* database handle
|
||||
* @param \Federator\Connector\Connector $connector
|
||||
* connector to fetch use with
|
||||
* @param \Federator\Cache\Cache|null $cache
|
||||
* optional caching service
|
||||
* @param string $host host url of our server (e.g. https://federator.com)
|
||||
* @param string $_user user that triggered the post
|
||||
* @param string $_recipientId recipient of the post
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received
|
||||
* @param string $articleId the original id of the article (if applicable)
|
||||
* (used to identify the article in the remote system)
|
||||
* @return boolean response
|
||||
*/
|
||||
public static function postForUser($dbh, $connector, $cache, $host, $_user, $_recipientId, $newActivity, $articleId)
|
||||
{
|
||||
if (!isset($_user)) {
|
||||
error_log("NewContent::postForUser no user given");
|
||||
return false;
|
||||
}
|
||||
|
||||
// get sender
|
||||
$user = \Federator\DIO\User::getUserByName(
|
||||
$dbh,
|
||||
$_user,
|
||||
$connector,
|
||||
$cache
|
||||
);
|
||||
if ($user === null || $user->id === null) {
|
||||
error_log("NewContent::postForUser couldn't find user: $_user");
|
||||
return false;
|
||||
}
|
||||
|
||||
// get recipient
|
||||
$recipient = \Federator\DIO\FedUser::getUserByName(
|
||||
$dbh,
|
||||
$_recipientId,
|
||||
$cache
|
||||
);
|
||||
if ($recipient === null || $recipient->id === null) {
|
||||
error_log("NewContent::postForUser couldn't find user: $_recipientId");
|
||||
return false;
|
||||
}
|
||||
|
||||
$rootDir = PROJECT_ROOT . '/';
|
||||
// Save the raw input and parsed JSON to a file for inspection
|
||||
file_put_contents(
|
||||
$rootDir . 'logs/newcontent_' . $recipient->id . '.log',
|
||||
date('Y-m-d H:i:s') . ": ==== POST " . $recipient->id . " NewContent Activity ====\n" . json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
|
||||
$type = strtolower($newActivity->getType());
|
||||
|
||||
switch ($type) {
|
||||
case 'follow':
|
||||
// $success = false;
|
||||
$actor = $newActivity->getAActor();
|
||||
if ($actor !== '') {
|
||||
// $followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? ''));
|
||||
// $followerDomain = parse_url($actor, PHP_URL_HOST);
|
||||
$newIdUrl = \Federator\DIO\Followers::generateNewFollowId($dbh, $host);
|
||||
$newActivity->setID($newIdUrl);
|
||||
/* if (is_string($followerDomain)) {
|
||||
$followerId = "{$followerUsername}@{$followerDomain}";
|
||||
$success = \Federator\DIO\Followers::sendFollowRequest($dbh, $connector, $cache, $user->id, $followerId, $followerDomain);
|
||||
} */
|
||||
}
|
||||
/* if ($success === false) {
|
||||
error_log("NewContent::postForUser Failed to add follower for user $user->id");
|
||||
} */
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
// Delete Note/Post
|
||||
$object = $newActivity->getObject();
|
||||
if (is_string($object)) {
|
||||
\Federator\DIO\Posts::deletePost($dbh, $object);
|
||||
} elseif (is_object($object)) {
|
||||
$objectId = $object->getID();
|
||||
\Federator\DIO\Posts::deletePost($dbh, $objectId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'undo':
|
||||
$object = $newActivity->getObject();
|
||||
if (is_object($object)) {
|
||||
switch (strtolower($object->getType())) {
|
||||
case 'follow':
|
||||
$success = false;
|
||||
if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) {
|
||||
$actor = $object->getAActor();
|
||||
if ($actor !== '') {
|
||||
$followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? ''));
|
||||
$followerDomain = parse_url($actor, PHP_URL_HOST);
|
||||
if (is_string($followerDomain)) {
|
||||
$followerId = "{$followerUsername}@{$followerDomain}";
|
||||
$removedId = \Federator\DIO\Followers::removeFollow($dbh, $followerId, $user->id);
|
||||
if ($removedId !== false) {
|
||||
$object->setID($removedId);
|
||||
$newActivity->setObject($object);
|
||||
$success = true;
|
||||
} else {
|
||||
error_log("NewContent::postForUser Failed to remove follow for user $user->id");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($success === false) {
|
||||
error_log("NewContent::postForUser Failed to remove follower for user $user->id");
|
||||
}
|
||||
break;
|
||||
case 'like':
|
||||
case 'dislike':
|
||||
if (method_exists($object, 'getObject')) {
|
||||
$targetId = $object->getObject();
|
||||
if (is_string($targetId)) {
|
||||
// \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId);
|
||||
\Federator\DIO\Posts::deletePost($dbh, $targetId);
|
||||
} else {
|
||||
error_log("NewContent::postForUser Error in Undo Like/Dislike for user $user->id, targetId is not a string");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'note':
|
||||
// Undo Note (remove note)
|
||||
$noteId = $object->getID();
|
||||
\Federator\DIO\Posts::deletePost($dbh, $noteId);
|
||||
|
||||
break;
|
||||
case 'article':
|
||||
$articleId = $object->getID();
|
||||
\Federator\DIO\Posts::deletePost($dbh, $articleId);
|
||||
// also remove latest saved article-update
|
||||
\Federator\DIO\Posts::deletePost($dbh, $articleId . '#update');
|
||||
|
||||
// Undo Article (remove article)
|
||||
$idPart = strrchr($recipient->id, '@');
|
||||
if ($idPart === false) {
|
||||
error_log("NewContent::postForUser Error in Undo Article. $recipient->id, recipient ID is not valid");
|
||||
return false;
|
||||
} else {
|
||||
$targetUrl = ltrim($idPart, '@');
|
||||
|
||||
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
|
||||
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $targetUrl);
|
||||
$newActivity->setObject($object);
|
||||
} else {
|
||||
error_log("NewContent::postForUser Error in Undo Article for recipient $recipient->id, object is not an Article");
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
} else if (is_string($object)) {
|
||||
\Federator\DIO\Posts::deletePost($dbh, $object);
|
||||
} else {
|
||||
error_log("NewContent::postForUser Error in Undo for recipient $recipient->id, object is not a string or object");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'like':
|
||||
case 'dislike':
|
||||
// Add Like/Dislike
|
||||
$targetId = $newActivity->getObject();
|
||||
if (is_string($targetId)) {
|
||||
// \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like');
|
||||
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId);
|
||||
} else {
|
||||
error_log("NewContent::postForUser Error in Add Like/Dislike for recipient $recipient->id, targetId is not a string");
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
case 'update':
|
||||
$object = $newActivity->getObject();
|
||||
if (is_object($object)) {
|
||||
switch (strtolower($object->getType())) {
|
||||
case 'note':
|
||||
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId);
|
||||
|
||||
break;
|
||||
case 'article':
|
||||
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId);
|
||||
|
||||
$idPart = strrchr($recipient->id, '@');
|
||||
if ($idPart === false) {
|
||||
error_log("NewContent::postForUser Error in Create/Update Article. $recipient->id, recipient ID is not valid");
|
||||
return false;
|
||||
} else {
|
||||
$targetUrl = ltrim($idPart, '@');
|
||||
|
||||
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
|
||||
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $targetUrl);
|
||||
$newActivity->setObject($object);
|
||||
} else {
|
||||
error_log("NewContent::postForUser Error in Create/Update Article for recipient $recipient->id, object is not an Article");
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Post Note
|
||||
break;
|
||||
|
||||
default:
|
||||
error_log("NewContent::postForUser Unhandled activity type $type for user $user->id");
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = self::sendActivity($dbh, $host, $user, $recipient, $newActivity);
|
||||
} catch (\Exception $e) {
|
||||
error_log("NewContent::postForUser Failed to send activity: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
if (empty($response)) {
|
||||
error_log("NewContent::postForUser Sent activity to $recipient->id");
|
||||
} else {
|
||||
error_log("NewContent::postForUser Sent activity to $recipient->id with response: " . json_encode($response, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* send activity to federated server
|
||||
*
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param string $host host url of our server (e.g. federator)
|
||||
* @param \Federator\Data\User $sender source user
|
||||
* @param \Federator\Data\FedUser $target federated target user
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $activity activity to send
|
||||
* @return string|true the generated follow ID on success, false on failure
|
||||
*/
|
||||
public static function sendActivity($dbh, $host, $sender, $target, $activity)
|
||||
{
|
||||
if ($dbh === false) {
|
||||
throw new \Federator\Exceptions\ServerError("NewContent::sendActivity Failed to get database handle");
|
||||
}
|
||||
|
||||
$inboxUrl = $target->inboxURL;
|
||||
|
||||
$json = json_encode($activity, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
throw new \Exception('Failed to encode JSON: ' . json_last_error_msg());
|
||||
}
|
||||
$digest = 'SHA-256=' . base64_encode(hash('sha256', $json, true));
|
||||
$date = gmdate('D, d M Y H:i:s') . ' GMT';
|
||||
$parsed = parse_url($inboxUrl);
|
||||
if ($parsed === false) {
|
||||
throw new \Exception('Failed to parse URL: ' . $inboxUrl);
|
||||
}
|
||||
|
||||
if (!isset($parsed['host']) || !isset($parsed['path'])) {
|
||||
throw new \Exception('Invalid inbox URL: missing host or path');
|
||||
}
|
||||
$extHost = $parsed['host'];
|
||||
$path = $parsed['path'];
|
||||
|
||||
// Build the signature string
|
||||
$signatureString = "(request-target): post {$path}\n" .
|
||||
"host: {$extHost}\n" .
|
||||
"date: {$date}\n" .
|
||||
"digest: {$digest}";
|
||||
|
||||
// Get rsa private key
|
||||
$privateKey = \Federator\DIO\User::getrsaprivate($dbh, $sender->id); // OR from DB
|
||||
if ($privateKey === false) {
|
||||
throw new \Exception('Failed to get private key');
|
||||
}
|
||||
$pkeyId = openssl_pkey_get_private($privateKey);
|
||||
|
||||
if ($pkeyId === false) {
|
||||
throw new \Exception('Invalid private key');
|
||||
}
|
||||
|
||||
openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256);
|
||||
$signature_b64 = base64_encode($signature);
|
||||
|
||||
// Build keyId (public key ID from your actor object)
|
||||
$keyId = $host . '/' . $sender->id . '#main-key';
|
||||
|
||||
$signatureHeader = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"';
|
||||
|
||||
$ch = curl_init($inboxUrl);
|
||||
if ($ch === false) {
|
||||
throw new \Exception('Failed to initialize cURL');
|
||||
}
|
||||
$headers = [
|
||||
'Host: ' . $extHost,
|
||||
'Date: ' . $date,
|
||||
'Digest: ' . $digest,
|
||||
'Content-Type: application/activity+json',
|
||||
'Signature: ' . $signatureHeader,
|
||||
'Accept: application/activity+json',
|
||||
];
|
||||
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new \Exception("Failed to send activity: " . curl_error($ch));
|
||||
} else {
|
||||
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($httpcode != 200 && $httpcode != 202) {
|
||||
throw new \Exception("Unexpected HTTP code $httpcode: $response");
|
||||
}
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* get internal represenation as json string
|
||||
* @return string json string or html
|
||||
*/
|
||||
public function toJson()
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
}
|
|
@ -44,8 +44,10 @@ class WellKnown implements APIInterface
|
|||
*/
|
||||
private function hostMeta()
|
||||
{
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
$data = [
|
||||
'fqdn' => $_SERVER['SERVER_NAME']
|
||||
'fqdn' => $domain
|
||||
];
|
||||
$this->response = $this->main->renderTemplate('host-meta.xml', $data);
|
||||
return true;
|
||||
|
|
|
@ -45,8 +45,10 @@ class NodeInfo
|
|||
*/
|
||||
public function exec($paths)
|
||||
{
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
$data = [
|
||||
'fqdn' => $_SERVER['SERVER_NAME']
|
||||
'fqdn' => $domain
|
||||
];
|
||||
$template = null;
|
||||
if (sizeof($paths) == 2 && $paths[0] === '.well-known' && $paths[1] === 'nodeinfo') {
|
||||
|
|
|
@ -51,6 +51,7 @@ class WebFinger
|
|||
if (preg_match("/^acct:([^@]+)@(.*)$/", $_resource, $matches) != 1 || $matches[2] !== $domain) {
|
||||
throw new \Federator\Exceptions\InvalidArgument();
|
||||
}
|
||||
$domain = $matches[2];
|
||||
$user = \Federator\DIO\User::getUserByName(
|
||||
$this->main->getDatabase(),
|
||||
$matches[1],
|
||||
|
@ -58,6 +59,7 @@ class WebFinger
|
|||
$this->main->getCache()
|
||||
);
|
||||
if ($user->id == 0) {
|
||||
echo "not found";
|
||||
throw new \Federator\Exceptions\FileNotFound();
|
||||
}
|
||||
$data = [
|
||||
|
|
52
php/federator/cache/cache.php
vendored
52
php/federator/cache/cache.php
vendored
|
@ -13,6 +13,24 @@ namespace Federator\Cache;
|
|||
*/
|
||||
interface Cache extends \Federator\Connector\Connector
|
||||
{
|
||||
/**
|
||||
* save remote followers of user
|
||||
*
|
||||
* @param string $user user name
|
||||
* @param \Federator\Data\FedUser[]|false $followers user followers
|
||||
* @return void
|
||||
*/
|
||||
public function saveRemoteFollowersOfUser($user, $followers);
|
||||
|
||||
/**
|
||||
* save remote following for user
|
||||
*
|
||||
* @param string $user user name
|
||||
* @param \Federator\Data\FedUser[]|false $following user following
|
||||
* @return void
|
||||
*/
|
||||
public function saveRemoteFollowingForUser($user, $following);
|
||||
|
||||
/**
|
||||
* save remote posts by user
|
||||
*
|
||||
|
@ -39,6 +57,15 @@ interface Cache extends \Federator\Connector\Connector
|
|||
*/
|
||||
public function saveRemoteUserByName($_name, $user);
|
||||
|
||||
/**
|
||||
* save remote federation user by given name
|
||||
*
|
||||
* @param string $_name user/profile name
|
||||
* @param \Federator\Data\FedUser $user user data
|
||||
* @return void
|
||||
*/
|
||||
public function saveRemoteFedUserByName(string $_name, \Federator\Data\FedUser $user);
|
||||
|
||||
/**
|
||||
* save remote user by given session
|
||||
*
|
||||
|
@ -48,4 +75,29 @@ interface Cache extends \Federator\Connector\Connector
|
|||
* @return void
|
||||
*/
|
||||
public function saveRemoteUserBySession($_session, $_user, $user);
|
||||
|
||||
/**
|
||||
* Save the public key for a given keyId
|
||||
*
|
||||
* @param string $keyId The keyId (e.g., actor URL + #main-key)
|
||||
* @param string $publicKeyPem The public key PEM to cache
|
||||
* @return void
|
||||
*/
|
||||
public function savePublicKey(string $keyId, string $publicKeyPem);
|
||||
|
||||
/**
|
||||
* get remote federation user by given name
|
||||
*
|
||||
* @param string $_name user/profile name
|
||||
* @return \Federator\Data\FedUser | false
|
||||
*/
|
||||
public function getRemoteFedUserByName(string $_name);
|
||||
|
||||
/**
|
||||
* Retrieve the public key for a given keyId
|
||||
*
|
||||
* @param string $keyId The keyId (e.g., actor URL + #main-key)
|
||||
* @return string|false The cached public key PEM or false if not found
|
||||
*/
|
||||
public function getPublicKey(string $keyId);
|
||||
}
|
||||
|
|
|
@ -13,16 +13,34 @@ namespace Federator\Connector;
|
|||
*/
|
||||
interface Connector
|
||||
{
|
||||
/**
|
||||
* get followers of given user
|
||||
*
|
||||
* @param string $id user id
|
||||
|
||||
* @return \Federator\Data\FedUser[]|false
|
||||
*/
|
||||
public function getRemoteFollowersOfUser($id);
|
||||
|
||||
/**
|
||||
* get following of given user
|
||||
*
|
||||
* @param string $id user id
|
||||
|
||||
* @return \Federator\Data\FedUser[]|false
|
||||
*/
|
||||
public function getRemoteFollowingForUser($id);
|
||||
|
||||
/**
|
||||
* get posts by given user
|
||||
*
|
||||
* @param string $id user id
|
||||
* @param string $minId min ID
|
||||
* @param string $maxId max ID
|
||||
* @param string $min min date
|
||||
* @param string $max max date
|
||||
|
||||
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
|
||||
*/
|
||||
public function getRemotePostsByUser($id, $minId, $maxId);
|
||||
public function getRemotePostsByUser($id, $min, $max);
|
||||
|
||||
/**
|
||||
* get remote user by given name
|
||||
|
@ -47,4 +65,32 @@ interface Connector
|
|||
* @return \Federator\Data\Stats|false
|
||||
*/
|
||||
public function getRemoteStats();
|
||||
|
||||
/**
|
||||
* Convert jsonData to Activity format
|
||||
*
|
||||
* @param array<string, mixed> $jsonData the json data from our platfrom
|
||||
* @param string $articleId the original id of the article (if applicable)
|
||||
* (used to identify the article in the remote system)
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity|false
|
||||
*/
|
||||
public function jsonToActivity(array $jsonData, &$articleId);
|
||||
|
||||
/**
|
||||
* send target-friendly json from ActivityPub activity
|
||||
*
|
||||
* @param \Federator\Data\FedUser $sender the user of the sender
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity
|
||||
* @return boolean did we successfully send the activity?
|
||||
*/
|
||||
public function sendActivity($sender, $activity);
|
||||
|
||||
/**
|
||||
* check if the headers include a valid signature
|
||||
*
|
||||
* @param string[] $headers the headers
|
||||
* @throws \Federator\Exceptions\PermissionDenied
|
||||
* @return string|\Federator\Exceptions\PermissionDenied
|
||||
*/
|
||||
public function checkSignature($headers);
|
||||
}
|
||||
|
|
41
php/federator/data/activitypub/common/Undo.php
Normal file
41
php/federator/data/activitypub/common/Undo.php
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Undo extends Activity
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Undo');
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
|
||||
/**
|
||||
* convert internal state to php array
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toObject()
|
||||
{
|
||||
$return = parent::toObject();
|
||||
$return['type'] = 'Undo';
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* create object from json
|
||||
*
|
||||
* @param array<string,mixed> $json input json
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function fromJson($json)
|
||||
{
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
}
|
18
php/federator/data/activitypub/common/accept.php
Normal file
18
php/federator/data/activitypub/common/accept.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Accept extends Activity
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Accept');
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ class Activity extends APObject
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function getAActor() : string
|
||||
public function getAActor(): string
|
||||
{
|
||||
return $this->aactor;
|
||||
}
|
||||
|
@ -74,6 +74,7 @@ class Activity extends APObject
|
|||
{
|
||||
if (array_key_exists('actor', $json)) {
|
||||
$this->actor = $json['actor'];
|
||||
$this->aactor = $json['actor'];
|
||||
unset($json['actor']);
|
||||
}
|
||||
if (!parent::fromJson($json)) {
|
||||
|
@ -102,7 +103,7 @@ class Activity extends APObject
|
|||
/**
|
||||
* get Child Object
|
||||
*
|
||||
* @return APObject|null
|
||||
* @return APObject|string|null
|
||||
*/
|
||||
public function getObject()
|
||||
{
|
||||
|
|
41
php/federator/data/activitypub/common/announce.php
Normal file
41
php/federator/data/activitypub/common/announce.php
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Announce extends Activity
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Announce');
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
|
||||
/**
|
||||
* convert internal state to php array
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toObject()
|
||||
{
|
||||
$return = parent::toObject();
|
||||
$return['type'] = 'Announce';
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* create object from json
|
||||
*
|
||||
* @param array<string,mixed> $json input json
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function fromJson($json)
|
||||
{
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
}
|
|
@ -27,7 +27,7 @@ class APObject implements \JsonSerializable
|
|||
/**
|
||||
* child object
|
||||
*
|
||||
* @var APObject|null $object
|
||||
* @var APObject|string|null $object
|
||||
*/
|
||||
private $object = null;
|
||||
|
||||
|
@ -347,7 +347,7 @@ class APObject implements \JsonSerializable
|
|||
/**
|
||||
* set child object
|
||||
*
|
||||
* @param APObject $object
|
||||
* @param APObject|string $object
|
||||
* @return void
|
||||
*/
|
||||
public function setObject($object)
|
||||
|
@ -358,7 +358,7 @@ class APObject implements \JsonSerializable
|
|||
/**
|
||||
* get child object
|
||||
*
|
||||
* @return APObject|null child object
|
||||
* @return APObject|string|null child object
|
||||
*/
|
||||
public function getObject()
|
||||
{
|
||||
|
@ -376,6 +376,17 @@ class APObject implements \JsonSerializable
|
|||
$this->summary = $summary;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* get summary
|
||||
*
|
||||
* @return string summary
|
||||
*/
|
||||
public function getSummary()
|
||||
{
|
||||
return $this->summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* set type
|
||||
*
|
||||
|
@ -459,6 +470,16 @@ class APObject implements \JsonSerializable
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* get name
|
||||
*
|
||||
* @return string name
|
||||
*/
|
||||
public function getName() : string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* add Image
|
||||
*
|
||||
|
@ -647,7 +668,7 @@ class APObject implements \JsonSerializable
|
|||
if (array_key_exists('duration', $json)) {
|
||||
try {
|
||||
$this->duration = new \DateInterval($json['duration']);
|
||||
} catch (\Exception $unused_e) {
|
||||
} catch (\Throwable $unused_e) {
|
||||
error_log("error parsing duration ". $json['duration']);
|
||||
}
|
||||
}
|
||||
|
@ -750,8 +771,8 @@ class APObject implements \JsonSerializable
|
|||
if (array_key_exists('mediaType', $json)) {
|
||||
$this->mediaType = $json['mediaType'];
|
||||
}
|
||||
if (array_key_exists('object', $json)) {
|
||||
$this->object = \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], "");
|
||||
if (array_key_exists('object', $json)) { // some actPub servers send strings in the object field
|
||||
$this->object = is_array($json['object']) ? \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], "") : $json['object'];
|
||||
}
|
||||
if (array_key_exists('sensitive', $json)) {
|
||||
$this->sensitive = $json['sensitive'];
|
||||
|
@ -773,7 +794,7 @@ class APObject implements \JsonSerializable
|
|||
* {@inheritDoc}
|
||||
* @see JsonSerializable::jsonSerialize()
|
||||
*/
|
||||
public function jsonSerialize()
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
return $this->toObject();
|
||||
}
|
||||
|
@ -875,7 +896,7 @@ class APObject implements \JsonSerializable
|
|||
$return['tag'] = $tags;
|
||||
}
|
||||
if ($this->updated > 0) {
|
||||
$return['updated'] = gmdate("Y-m-d\TH:i:S\Z", $this->updated);
|
||||
$return['updated'] = gmdate("Y-m-d\TH:i:s\Z", $this->updated);
|
||||
}
|
||||
if ($this->url !== '') {
|
||||
$return['url'] = $this->url;
|
||||
|
@ -890,7 +911,7 @@ class APObject implements \JsonSerializable
|
|||
$return['mediaType'] = $this->mediaType;
|
||||
}
|
||||
if ($this->object !== null) {
|
||||
$return['object'] = $this->object->toObject();
|
||||
$return['object'] = is_string($this->object) ? $this->object : $this->object->toObject();
|
||||
}
|
||||
if ($this->atomURI !== '') {
|
||||
$return['atomUri'] = $this->atomURI;
|
||||
|
|
|
@ -51,17 +51,27 @@ class Collection extends APObject
|
|||
return parent::fromJson($json);
|
||||
}
|
||||
|
||||
public function count() : int
|
||||
/**
|
||||
* set total items
|
||||
*
|
||||
* @param int $totalItems total items
|
||||
*/
|
||||
public function setTotalItems(int $totalItems): void
|
||||
{
|
||||
$this->totalItems = $totalItems;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->totalItems;
|
||||
}
|
||||
|
||||
public function setFirst(string $url) : void
|
||||
public function setFirst(string $url): void
|
||||
{
|
||||
$this->first = $url;
|
||||
}
|
||||
|
||||
public function setLast(string $url) : void
|
||||
public function setLast(string $url): void
|
||||
{
|
||||
$this->last = $url;
|
||||
}
|
||||
|
|
|
@ -26,7 +26,9 @@ class Create extends Activity
|
|||
$return = parent::toObject();
|
||||
$return['type'] = 'Create';
|
||||
// overwrite id from url
|
||||
$return['id'] = $this->getURL();
|
||||
if ($this->getURL() !== '') {
|
||||
$return['id'] = $this->getURL();
|
||||
}
|
||||
return $return;
|
||||
}
|
||||
|
||||
|
|
36
php/federator/data/activitypub/common/delete.php
Normal file
36
php/federator/data/activitypub/common/delete.php
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Delete extends Activity
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Delete');
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
|
||||
/**
|
||||
* create from json/array
|
||||
* @param mixed $json
|
||||
*/
|
||||
public function fromJson($json): bool
|
||||
{
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
/**
|
||||
* convert internal state to php array
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toObject()
|
||||
{
|
||||
$return = parent::toObject();
|
||||
return $return;
|
||||
}
|
||||
}
|
18
php/federator/data/activitypub/common/dislike.php
Normal file
18
php/federator/data/activitypub/common/dislike.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Dislike extends Activity
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Dislike');
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
}
|
55
php/federator/data/activitypub/common/follow.php
Normal file
55
php/federator/data/activitypub/common/follow.php
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Follow extends Activity
|
||||
{
|
||||
/**
|
||||
* object overwrite
|
||||
* @var string
|
||||
*/
|
||||
private $object = "";
|
||||
|
||||
public function setFObject(string $object): void
|
||||
{
|
||||
$this->object = $object;
|
||||
}
|
||||
|
||||
public function getObject(): string
|
||||
{
|
||||
return $this->object;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct("Follow");
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
|
||||
public function fromJson($json): bool
|
||||
{
|
||||
if (array_key_exists('object', $json)) {
|
||||
$this->object = $json['object'];
|
||||
unset($json['object']);
|
||||
}
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
/**
|
||||
* convert internal state to php array
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toObject()
|
||||
{
|
||||
$return = parent::toObject();
|
||||
if ($this->object !== "") {
|
||||
$return['object'] = $this->object;
|
||||
}
|
||||
return $return;
|
||||
}
|
||||
}
|
53
php/federator/data/activitypub/common/followers.php
Normal file
53
php/federator/data/activitypub/common/followers.php
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Followers extends OrderedCollectionPage
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
|
||||
/**
|
||||
* set items
|
||||
*
|
||||
* @param string[] $items the items in the collection
|
||||
* @return void
|
||||
*/
|
||||
public function setItems(&$items)
|
||||
{
|
||||
// Optionally: type check that all $items are Activity objects
|
||||
$this->items = $items;
|
||||
$this->totalItems = sizeof($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* convert internal state to php array
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toObject()
|
||||
{
|
||||
$return = parent::toObject();
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* create object from json
|
||||
*
|
||||
* @param array<string,mixed> $json input json
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function fromJson($json)
|
||||
{
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
}
|
53
php/federator/data/activitypub/common/following.php
Normal file
53
php/federator/data/activitypub/common/following.php
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Following extends OrderedCollectionPage
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
|
||||
/**
|
||||
* set items
|
||||
*
|
||||
* @param string[] $items the items in the collection
|
||||
* @return void
|
||||
*/
|
||||
public function setItems(&$items)
|
||||
{
|
||||
// Optionally: type check that all $items are Activity objects
|
||||
$this->items = $items;
|
||||
$this->totalItems = sizeof($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* convert internal state to php array
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toObject()
|
||||
{
|
||||
$return = parent::toObject();
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* create object from json
|
||||
*
|
||||
* @param array<string,mixed> $json input json
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function fromJson($json)
|
||||
{
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
}
|
50
php/federator/data/activitypub/common/inbox.php
Normal file
50
php/federator/data/activitypub/common/inbox.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Inbox extends OrderedCollectionPage
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
parent::addContexts([
|
||||
"ostatus" => "http://ostatus.org#",
|
||||
"atomUri" => "ostatus:atomUri",
|
||||
"inReplyToAtomUri" => "ostatus:inReplyToAtomUri",
|
||||
"conversation" => "ostatus:conversation",
|
||||
"sensitive" => "as:sensitive",
|
||||
"toot" => "http://joinmastodon.org/ns#",
|
||||
"votersCount" => "toot:votersCount",
|
||||
"Hashtag" => "as:Hashtag"
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* convert internal state to php array
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toObject()
|
||||
{
|
||||
$return = parent::toObject();
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* create object from json
|
||||
*
|
||||
* @param array<string,mixed> $json input json
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function fromJson($json)
|
||||
{
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
}
|
18
php/federator/data/activitypub/common/like.php
Normal file
18
php/federator/data/activitypub/common/like.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Like extends Activity
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Like');
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ class OrderedCollection extends Collection
|
|||
/**
|
||||
* nested items
|
||||
*
|
||||
* @var APObject[]
|
||||
* @var APObject[]|string[]
|
||||
*/
|
||||
protected $items = [];
|
||||
|
||||
|
@ -34,7 +34,11 @@ class OrderedCollection extends Collection
|
|||
$return['type'] = 'OrderedCollection';
|
||||
if ($this->totalItems > 0) {
|
||||
foreach ($this->items as $item) {
|
||||
$return['OrderedItems'][] = $item->toObject();
|
||||
if (is_string($item)) {
|
||||
$return['orderedItems'][] = $item;
|
||||
} elseif (is_object($item)) {
|
||||
$return['orderedItems'][] = $item->toObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
return $return;
|
||||
|
@ -51,7 +55,11 @@ class OrderedCollection extends Collection
|
|||
return parent::fromJson($json);
|
||||
}
|
||||
|
||||
public function append(APObject &$item) : void
|
||||
/**
|
||||
* add item to collection
|
||||
* @param APObject|string $item
|
||||
*/
|
||||
public function append(&$item): void
|
||||
{
|
||||
$this->items[] = $item;
|
||||
$this->totalItems = sizeof($this->items);
|
||||
|
@ -60,7 +68,7 @@ class OrderedCollection extends Collection
|
|||
/**
|
||||
* get item with given index
|
||||
*
|
||||
* @return APObject|false
|
||||
* @return APObject|string|false
|
||||
*/
|
||||
public function get(int $index)
|
||||
{
|
||||
|
@ -70,7 +78,7 @@ class OrderedCollection extends Collection
|
|||
}
|
||||
return $this->items[$index];
|
||||
} else {
|
||||
if ($this->totalItems+ $index < 0) {
|
||||
if ($this->totalItems + $index < 0) {
|
||||
return false;
|
||||
}
|
||||
return $this->items[$this->totalItems + $index];
|
||||
|
@ -80,7 +88,7 @@ class OrderedCollection extends Collection
|
|||
/**
|
||||
* set items
|
||||
*
|
||||
* @param APObject[] $items
|
||||
* @param APObject[]|string[] $items
|
||||
* @return void
|
||||
*/
|
||||
public function setItems(&$items)
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Sascha Nitsch (grumpydeveloper)
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
@ -26,6 +27,18 @@ class Outbox extends OrderedCollectionPage
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* set items
|
||||
*
|
||||
* @param \Federator\Data\ActivityPub\Common\APObject[] $items the items in the collection
|
||||
*/
|
||||
public function setItems(&$items)
|
||||
{
|
||||
// Optionally: type check that all $items are Activity objects
|
||||
$this->items = $items;
|
||||
$this->totalItems = sizeof($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* convert internal state to php array
|
||||
*
|
||||
|
|
18
php/federator/data/activitypub/common/reject.php
Normal file
18
php/federator/data/activitypub/common/reject.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Reject extends Activity
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Reject');
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
}
|
45
php/federator/data/activitypub/common/update.php
Normal file
45
php/federator/data/activitypub/common/update.php
Normal file
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Update extends Activity
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Update');
|
||||
parent::addContext('https://www.w3.org/ns/activitystreams');
|
||||
}
|
||||
|
||||
/**
|
||||
* convert internal state to php array
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toObject()
|
||||
{
|
||||
$return = parent::toObject();
|
||||
$return['type'] = 'Update';
|
||||
// overwrite id from url
|
||||
if ($this->getURL() !== '') {
|
||||
$return['id'] = $this->getURL();
|
||||
}
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* create object from json
|
||||
*
|
||||
* @param array<string,mixed> $json input json
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function fromJson($json)
|
||||
{
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
}
|
27
php/federator/data/activitypub/common/vote.php
Normal file
27
php/federator/data/activitypub/common/vote.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data\ActivityPub\Common;
|
||||
|
||||
class Vote extends APObject
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Vote');
|
||||
}
|
||||
|
||||
/**
|
||||
* create object from json
|
||||
* @param mixed $json input
|
||||
* @return bool true on success
|
||||
*/
|
||||
public function fromJson($json)
|
||||
{
|
||||
return parent::fromJson($json);
|
||||
}
|
||||
}
|
|
@ -31,6 +31,9 @@ class Factory
|
|||
}
|
||||
$return = null;
|
||||
switch ($json['type']) {
|
||||
case 'Announce':
|
||||
$return = new Common\Announce();
|
||||
break;
|
||||
case 'Article':
|
||||
$return = new Common\Article();
|
||||
break;
|
||||
|
@ -39,21 +42,39 @@ class Factory
|
|||
break;
|
||||
case 'Event':
|
||||
$return = new Common\Event();
|
||||
break;
|
||||
break;*/
|
||||
case 'Follow':
|
||||
$return = new Common\Follow();
|
||||
break;*/
|
||||
break;
|
||||
case 'Image':
|
||||
$return = new Common\Image();
|
||||
break;
|
||||
/*case 'Note':
|
||||
case 'Note':
|
||||
$return = new Common\Note();
|
||||
break;
|
||||
case 'Question':
|
||||
$return = new \Common\Question();
|
||||
case 'Outbox':
|
||||
$return = new Common\Outbox();
|
||||
break;
|
||||
case 'Vote':
|
||||
$return = new Common\Vote();
|
||||
break;
|
||||
case 'Like':
|
||||
$return = new Common\Like();
|
||||
break;
|
||||
case 'Dislike':
|
||||
$return = new Common\Dislike();
|
||||
break;
|
||||
case 'Inbox':
|
||||
$return = new Common\Inbox();
|
||||
break;
|
||||
case 'Tombstone':
|
||||
$return = new Common\APObject("Tombstone");
|
||||
break;
|
||||
/*case 'Question':
|
||||
$return = new Common\Question();
|
||||
break;
|
||||
case 'Video':
|
||||
$return = new \Common\Video();
|
||||
$return = new Common\Video();
|
||||
break;*/
|
||||
default:
|
||||
error_log("newFromJson: unknown type: '" . $json['type'] . "' " . $jsonstring);
|
||||
|
@ -77,9 +98,7 @@ class Factory
|
|||
}
|
||||
//$return = false;
|
||||
switch ($json['type']) {
|
||||
case 'MakePhanHappy':
|
||||
break;
|
||||
/* case 'Accept':
|
||||
case 'Accept':
|
||||
$return = new Common\Accept();
|
||||
break;
|
||||
case 'Announce':
|
||||
|
@ -91,18 +110,30 @@ class Factory
|
|||
case 'Delete':
|
||||
$return = new Common\Delete();
|
||||
break;
|
||||
case 'Like':
|
||||
$return = new Common\Like();
|
||||
break;
|
||||
case 'Dislike':
|
||||
$return = new Common\Dislike();
|
||||
break;
|
||||
case 'Follow':
|
||||
$return = new Common\Follow();
|
||||
break;
|
||||
case 'Reject':
|
||||
$return = new Common\Reject();
|
||||
break;
|
||||
case 'Undo':
|
||||
$return = new \Common\Undo();
|
||||
break;*/
|
||||
$return = new Common\Undo();
|
||||
break;
|
||||
case 'Update':
|
||||
$return = new Common\Update();
|
||||
break;
|
||||
default:
|
||||
error_log("newActivityFromJson " . print_r($json, true));
|
||||
error_log("newActivityFromJson unsupported type: " . print_r($json, true));
|
||||
}
|
||||
/*if ($return !== false && $return->fromJson($json) !== null) {
|
||||
if (isset($return) && $return->fromJson($json) !== null) {
|
||||
return $return;
|
||||
}*/
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
151
php/federator/data/feduser.php
Normal file
151
php/federator/data/feduser.php
Normal file
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\Data;
|
||||
|
||||
/**
|
||||
* storage class for user attributes
|
||||
*/
|
||||
class FedUser
|
||||
{
|
||||
/**
|
||||
* user id
|
||||
*
|
||||
* @var string $id
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* user url
|
||||
*
|
||||
* @var string $actorURL
|
||||
*/
|
||||
public $actorURL;
|
||||
|
||||
/**
|
||||
* user name
|
||||
*
|
||||
* @var string $name
|
||||
*/
|
||||
public $name = '';
|
||||
|
||||
/**
|
||||
* user public key
|
||||
*
|
||||
* @var string $publicKey
|
||||
*/
|
||||
public $publicKey;
|
||||
|
||||
/**
|
||||
* summary for user/profile
|
||||
*
|
||||
* @var string $summary
|
||||
*/
|
||||
public $summary = '';
|
||||
|
||||
/**
|
||||
* type of user (person/group)
|
||||
*
|
||||
* @var string $type
|
||||
*/
|
||||
public $type = 'Person';
|
||||
|
||||
/**
|
||||
* inbox URL
|
||||
*
|
||||
* @var string $inboxURL
|
||||
*/
|
||||
public $inboxURL;
|
||||
|
||||
/**
|
||||
* shared inbox URL
|
||||
*
|
||||
* @var string $sharedInboxURL
|
||||
*/
|
||||
public $sharedInboxURL;
|
||||
|
||||
/**
|
||||
* followers URL
|
||||
*
|
||||
* @var string $followersURL
|
||||
*/
|
||||
public $followersURL;
|
||||
|
||||
/**
|
||||
* following URL
|
||||
*
|
||||
* @var string $followingURL
|
||||
*/
|
||||
public $followingURL;
|
||||
|
||||
/**
|
||||
* public key ID
|
||||
*
|
||||
* @var string $publicKeyId
|
||||
*/
|
||||
public $publicKeyId;
|
||||
|
||||
/**
|
||||
* outbox URL
|
||||
*
|
||||
* @var string $outboxURL
|
||||
*/
|
||||
public $outboxURL;
|
||||
|
||||
/**
|
||||
* create new user object from json string
|
||||
*
|
||||
* @param string $input input string
|
||||
* @return FedUser|false
|
||||
*/
|
||||
public static function createFromJson($input)
|
||||
{
|
||||
$data = json_decode($input, true);
|
||||
if ($data === null) {
|
||||
return false;
|
||||
}
|
||||
$user = new FedUser();
|
||||
$user->id = $data['id'] ?? '';
|
||||
$user->actorURL = $data['actorURL'] ?? '';
|
||||
$user->name = $data['name'] ?? '';
|
||||
$user->publicKey = $data['publicKey'] ?? '';
|
||||
$user->summary = $data['summary'] ?? '';
|
||||
$user->type = $data['type'] ?? 'Person';
|
||||
$user->inboxURL = $data['inbox'] ?? '';
|
||||
$user->sharedInboxURL = $data['sharedInbox'] ?? '';
|
||||
$user->followersURL = $data['followers'] ?? '';
|
||||
$user->followingURL = $data['following'] ?? '';
|
||||
$user->publicKeyId = $data['publicKeyId'] ?? '';
|
||||
$user->outboxURL = $data['outbox'] ?? '';
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* convert internal data to json string
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function toJson()
|
||||
{
|
||||
$data = [
|
||||
'id' => $this->id,
|
||||
'actorURL' => $this->actorURL,
|
||||
'name' => $this->name,
|
||||
'publicKey' => $this->publicKey,
|
||||
'summary' => $this->summary,
|
||||
'type' => $this->type,
|
||||
'inbox' => $this->inboxURL,
|
||||
'sharedInbox' => $this->sharedInboxURL,
|
||||
'followers' => $this->followersURL,
|
||||
'following' => $this->followingURL,
|
||||
'publicKeyId' => $this->publicKeyId,
|
||||
'outbox' => $this->outboxURL,
|
||||
];
|
||||
return json_encode($data) ?: '';
|
||||
}
|
||||
}
|
68
php/federator/dio/article.php
Normal file
68
php/federator/dio/article.php
Normal file
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\DIO;
|
||||
|
||||
/**
|
||||
* IO functions related to articles
|
||||
*/
|
||||
class Article
|
||||
{
|
||||
/**
|
||||
* Convert an Article to a Note
|
||||
*
|
||||
* @param \Federator\Data\ActivityPub\Common\Article $article
|
||||
* @return \Federator\Data\ActivityPub\Common\Note
|
||||
* The generated note
|
||||
*/
|
||||
public static function convertToNote($article)
|
||||
{
|
||||
$note = new \Federator\Data\ActivityPub\Common\Note();
|
||||
$note->setId($article->getId())
|
||||
->setURL($article->getURL());
|
||||
$note->setContent($article->getContent());
|
||||
$note->setSummary($article->getSummary());
|
||||
$note->setPublished($article->getPublished());
|
||||
$note->setName($article->getName());
|
||||
$note->setAttributedTo($article->getAttributedTo());
|
||||
foreach ($article->getTo() as $to) {
|
||||
$note->addTo($to);
|
||||
}
|
||||
foreach ($article->getCc() as $cc) {
|
||||
$note->addCc($cc);
|
||||
}
|
||||
return $note;
|
||||
}
|
||||
|
||||
/** Conditionally convert article to a note
|
||||
*
|
||||
* @param \Federator\Data\ActivityPub\Common\Article $article
|
||||
* @param string $targetUrl
|
||||
* The target URL for the activity (e.g. mastodon.social)
|
||||
* @return \Federator\Data\ActivityPub\Common\Note|\Federator\Data\ActivityPub\Common\Article
|
||||
* The generated note on success, false on failure
|
||||
*/
|
||||
public static function conditionalConvertToNote($article, $targetUrl)
|
||||
{
|
||||
$supportFile = file_get_contents(PROJECT_ROOT . '/formatsupport.json');
|
||||
if ($supportFile === false) {
|
||||
error_log("Article::conditionalConvertToNote Failed to read support file for article conversion.");
|
||||
return $article; // Fallback to original article if file read fails
|
||||
}
|
||||
$supportlist = json_decode($supportFile, true);
|
||||
|
||||
if (
|
||||
!isset($supportlist['activitypub']['article']) ||
|
||||
!is_array($supportlist['activitypub']['article']) ||
|
||||
!in_array($targetUrl, $supportlist['activitypub']['article'], true)
|
||||
) {
|
||||
return self::convertToNote($article); // Articles are not supported for this target
|
||||
}
|
||||
return $article; // Articles are supported, return as is
|
||||
}
|
||||
}
|
242
php/federator/dio/feduser.php
Normal file
242
php/federator/dio/feduser.php
Normal file
|
@ -0,0 +1,242 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\DIO;
|
||||
|
||||
/**
|
||||
* IO functions related to fedUsers
|
||||
*/
|
||||
class FedUser
|
||||
{
|
||||
/**
|
||||
* add local user based on given user object received from remote service
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param \Federator\Data\FedUser $user user object to use
|
||||
* @param string $_user user/profile name
|
||||
* @return void
|
||||
*/
|
||||
protected static function addLocalUser($dbh, $user, $_user)
|
||||
{
|
||||
// check if it is timed out user
|
||||
$sql = 'select unix_timestamp(`validuntil`) from fedusers where id=?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::addLocalUser Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $_user);
|
||||
$validuntil = 0;
|
||||
$ret = $stmt->bind_result($validuntil);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
if ($validuntil == 0) {
|
||||
$sql = 'insert into fedusers (id, url, name, publickey, summary, type, inboxurl, sharedinboxurl,';
|
||||
$sql .= ' followersurl, followingurl, publickeyid, outboxurl, validuntil)';
|
||||
$sql .= ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now() + interval 1 day)';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::addLocalUser Failed to prepare create statement");
|
||||
}
|
||||
$stmt->bind_param(
|
||||
"ssssssssssss",
|
||||
$_user,
|
||||
$user->actorURL,
|
||||
$user->name,
|
||||
$user->publicKey,
|
||||
$user->summary,
|
||||
$user->type,
|
||||
$user->inboxURL,
|
||||
$user->sharedInboxURL,
|
||||
$user->followersURL,
|
||||
$user->followingURL,
|
||||
$user->publicKeyId,
|
||||
$user->outboxURL
|
||||
);
|
||||
} else {
|
||||
// update to existing user
|
||||
$sql = 'update fedusers set validuntil=now() + interval 1 day, url=?, name=?, publickey=?, summary=?,';
|
||||
$sql .= ' type=?, inboxurl=?, sharedinboxurl=?, followersurl=?, followingurl=?, publickeyid=?, outboxurl=?';
|
||||
$sql .= ' where id=?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::extendUser Failed to prepare update statement");
|
||||
}
|
||||
$stmt->bind_param(
|
||||
"ssssssssssss",
|
||||
$user->actorURL,
|
||||
$user->name,
|
||||
$user->publicKey,
|
||||
$user->summary,
|
||||
$user->type,
|
||||
$user->inboxURL,
|
||||
$user->sharedInboxURL,
|
||||
$user->followersURL,
|
||||
$user->followingURL,
|
||||
$user->publicKeyId,
|
||||
$user->outboxURL,
|
||||
$_user
|
||||
);
|
||||
}
|
||||
try {
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
$user->id = $_user;
|
||||
} catch (\mysqli_sql_exception $e) {
|
||||
error_log($sql);
|
||||
error_log(print_r($user, true));
|
||||
error_log($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* extend the given user with internal data
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param \Federator\Data\FedUser $user user to extend
|
||||
* @param string $_user user/profile name
|
||||
*/
|
||||
protected static function extendUser(\mysqli $dbh, \Federator\Data\FedUser $user, $_user): void
|
||||
{
|
||||
$sql = 'select id,unix_timestamp(`validuntil`) from fedusers where id=?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::extendUser Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $_user);
|
||||
$validuntil = 0;
|
||||
$ret = $stmt->bind_result($user->id, $validuntil);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
// if a new user, create own database entry with additionally needed info
|
||||
if ($user->id === null || $validuntil < time()) {
|
||||
self::addLocalUser($dbh, $user, $_user);
|
||||
}
|
||||
|
||||
// no further processing for now
|
||||
}
|
||||
|
||||
/**
|
||||
* get user by name
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* database handle
|
||||
* @param string $_name
|
||||
* user name
|
||||
* @param \Federator\Cache\Cache|null $cache
|
||||
* optional caching service
|
||||
* @return \Federator\Data\FedUser
|
||||
*/
|
||||
public static function getUserByName($dbh, $_name, $cache)
|
||||
{
|
||||
$user = false;
|
||||
|
||||
// ask cache
|
||||
if ($cache !== null) {
|
||||
$user = $cache->getRemoteFedUserByName($_name);
|
||||
}
|
||||
if ($user !== false) {
|
||||
return $user;
|
||||
}
|
||||
// check our db
|
||||
$sql = 'select `id`, `url`, `name`, `publickey`, `summary`, `type`, `inboxurl`, `sharedinboxurl`, `followersurl`,';
|
||||
$sql .= ' `followingurl`, `publickeyid`, `outboxurl`';
|
||||
$sql .= ' from fedusers where `id`=? and `validuntil`>=now()';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $_name);
|
||||
$user = new \Federator\Data\FedUser();
|
||||
$ret = $stmt->bind_result(
|
||||
$user->id,
|
||||
$user->actorURL,
|
||||
$user->name,
|
||||
$user->publicKey,
|
||||
$user->summary,
|
||||
$user->type,
|
||||
$user->inboxURL,
|
||||
$user->sharedInboxURL,
|
||||
$user->followersURL,
|
||||
$user->followingURL,
|
||||
$user->publicKeyId,
|
||||
$user->outboxURL
|
||||
);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
if ($user->id === null) {
|
||||
// check if its a federated user with username@domain.ending
|
||||
if (preg_match("/^([^@]+)@(.*)$/", $_name, $matches) == 1) {
|
||||
// make webfinger request
|
||||
$remoteURL = 'https://' . $matches[2] . '/.well-known/webfinger?resource=acct:' . urlencode($_name);
|
||||
$headers = ['Accept: application/activity+json'];
|
||||
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
|
||||
if ($info['http_code'] != 200) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to fetch webfinger for " . $_name);
|
||||
}
|
||||
$r = json_decode($response, true);
|
||||
if ($r === false || $r === null || !is_array($r)) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to decode webfinger for " . $_name);
|
||||
}
|
||||
// get the webwinger user url and fetch the user
|
||||
if (isset($r['links'])) {
|
||||
foreach ($r['links'] as $link) {
|
||||
if (isset($link['rel']) && $link['rel'] === 'self') {
|
||||
$remoteURL = $link['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isset($remoteURL)) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to find self link in webfinger for " . $_name);
|
||||
}
|
||||
// fetch the user
|
||||
$headers = ['Accept: application/activity+json'];
|
||||
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
|
||||
if ($info['http_code'] != 200) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to fetch user from remoteUrl for " . $_name);
|
||||
}
|
||||
$r = json_decode($response, true);
|
||||
if ($r === false || $r === null || !is_array($r)) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to decode user for " . $_name);
|
||||
}
|
||||
$r['publicKeyId'] = $r['publicKey']['id'];
|
||||
$r['publicKey'] = $r['publicKey']['publicKeyPem'];
|
||||
if (isset($r['endpoints'])) {
|
||||
if (isset($r['endpoints']['sharedInbox'])) {
|
||||
$r['sharedInbox'] = $r['endpoints']['sharedInbox'];
|
||||
}
|
||||
}
|
||||
$r['actorURL'] = $remoteURL;
|
||||
$data = json_encode($r);
|
||||
if ($data === false) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to encode userdata " . $_name);
|
||||
}
|
||||
$user = \Federator\Data\FedUser::createFromJson($data);
|
||||
}
|
||||
}
|
||||
|
||||
if ($cache !== null && $user !== false) {
|
||||
if ($user->id !== null && $user->actorURL !== null) {
|
||||
self::addLocalUser($dbh, $user, $_name);
|
||||
}
|
||||
$cache->saveRemoteFedUserByName($_name, $user);
|
||||
}
|
||||
if ($user === false) {
|
||||
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName User not found");
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
}
|
525
php/federator/dio/followers.php
Normal file
525
php/federator/dio/followers.php
Normal file
|
@ -0,0 +1,525 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\DIO;
|
||||
|
||||
/**
|
||||
* IO functions related to followers
|
||||
*/
|
||||
class Followers
|
||||
{
|
||||
/**
|
||||
* get followers of user
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* database handle
|
||||
* @param string $id
|
||||
* user id
|
||||
* @param \Federator\Connector\Connector $connector
|
||||
* connector to fetch use with
|
||||
* @param \Federator\Cache\Cache|null $cache
|
||||
* optional caching service
|
||||
* @return \Federator\Data\FedUser[]
|
||||
*/
|
||||
public static function getFollowersByUser($dbh, $id, $connector, $cache)
|
||||
{
|
||||
// ask cache
|
||||
if ($cache !== null) {
|
||||
$followers = $cache->getRemoteFollowersOfUser($id);
|
||||
if ($followers !== false) {
|
||||
return $followers;
|
||||
}
|
||||
}
|
||||
$followers = [];
|
||||
$sql = 'select source_user from follows where target_user = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::getFollowersByUser Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $id);
|
||||
$stmt->execute();
|
||||
$followerIds = [];
|
||||
$stmt->bind_result($sourceUser);
|
||||
while ($stmt->fetch()) {
|
||||
$followerIds[] = $sourceUser;
|
||||
}
|
||||
$stmt->close();
|
||||
foreach ($followerIds as $followerId) {
|
||||
try {
|
||||
$user = \Federator\DIO\FedUser::getUserByName(
|
||||
$dbh,
|
||||
$followerId,
|
||||
$cache,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Followers::getFollowersByUser Exception: " . $e->getMessage());
|
||||
continue; // Skip this user if an exception occurs
|
||||
}
|
||||
if ($user !== false && $user->id !== null) {
|
||||
$followers[] = $user;
|
||||
}
|
||||
}
|
||||
|
||||
if ($followers === []) {
|
||||
// ask connector for user-id
|
||||
$followers = $connector->getRemoteFollowersOfUser($id);
|
||||
if ($followers === false) {
|
||||
$followers = [];
|
||||
}
|
||||
}
|
||||
// save followers to cache
|
||||
if ($cache !== null) {
|
||||
$cache->saveRemoteFollowersOfUser($id, $followers);
|
||||
}
|
||||
return $followers;
|
||||
}
|
||||
/**
|
||||
* get following for user - who does the user follow
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* database handle
|
||||
* @param string $id
|
||||
* user id
|
||||
* @param \Federator\Connector\Connector $connector
|
||||
* connector to fetch use with
|
||||
* @param \Federator\Cache\Cache|null $cache
|
||||
* optional caching service
|
||||
* @return \Federator\Data\FedUser[]
|
||||
*/
|
||||
|
||||
public static function getFollowingForUser($dbh, $id, $connector, $cache)
|
||||
{
|
||||
// ask cache
|
||||
if ($cache !== null) {
|
||||
$following = $cache->getRemoteFollowingForUser($id);
|
||||
if ($following !== false) {
|
||||
return $following;
|
||||
}
|
||||
}
|
||||
$following = [];
|
||||
$sql = 'select target_user from follows where source_user = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::getFollowingForUser Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $id);
|
||||
$stmt->execute();
|
||||
$followingIds = [];
|
||||
$stmt->bind_result($sourceUser);
|
||||
while ($stmt->fetch()) {
|
||||
$followingIds[] = $sourceUser;
|
||||
}
|
||||
$stmt->close();
|
||||
foreach ($followingIds as $followingId) {
|
||||
try {
|
||||
$user = \Federator\DIO\FedUser::getUserByName(
|
||||
$dbh,
|
||||
$followingId,
|
||||
$cache,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Followers::getFollowingForUser Exception: " . $e->getMessage());
|
||||
continue; // Skip this user if an exception occurs
|
||||
}
|
||||
if ($user !== false && $user->id !== null) {
|
||||
$following[] = $user;
|
||||
}
|
||||
}
|
||||
|
||||
if ($following === []) {
|
||||
// ask connector for user-id
|
||||
$following = $connector->getRemoteFollowingForUser($id);
|
||||
if ($following === false) {
|
||||
$following = [];
|
||||
}
|
||||
}
|
||||
// save posts to DB
|
||||
if ($cache !== null) {
|
||||
$cache->saveRemoteFollowingForUser($id, $following);
|
||||
}
|
||||
return $following;
|
||||
}
|
||||
|
||||
/**
|
||||
* get followers of federated external user (e.g. mastodon)
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* database handle
|
||||
* @param \Federator\Connector\Connector $connector
|
||||
* connector to fetch use with
|
||||
* @param \Federator\Cache\Cache|null $cache
|
||||
* optional caching service
|
||||
* @param string $id
|
||||
* user id
|
||||
* @return \Federator\Data\User[]
|
||||
*/
|
||||
|
||||
public static function getFollowersByFedUser($dbh, $connector, $cache, $id)
|
||||
{
|
||||
$followers = [];
|
||||
|
||||
$sql = 'select source_user from follows where target_user = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::getFollowersByFedUser Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $id);
|
||||
$stmt->execute();
|
||||
$followerIds = [];
|
||||
$stmt->bind_result($sourceUser);
|
||||
while ($stmt->fetch()) {
|
||||
$followerIds[] = $sourceUser;
|
||||
}
|
||||
foreach ($followerIds as $followerId) {
|
||||
try {
|
||||
$user = \Federator\DIO\User::getUserByName(
|
||||
$dbh,
|
||||
$followerId,
|
||||
$connector,
|
||||
$cache
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Followers::getFollowersByFedUser Exception: " . $e->getMessage());
|
||||
continue; // Skip this user if an exception occurs
|
||||
}
|
||||
if ($user !== false && $user->id !== null) {
|
||||
$followers[] = $user;
|
||||
}
|
||||
}
|
||||
return $followers;
|
||||
}
|
||||
|
||||
/**
|
||||
* send follow request
|
||||
*
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param \Federator\Connector\Connector $connector connector to use
|
||||
* @param \Federator\Cache\Cache|null $cache optional caching service
|
||||
* @param string $_user source user
|
||||
* @param string $_targetUser target user id
|
||||
* @param string $host the host for generating the follow ID
|
||||
* @return string|false the generated follow ID on success, false on failure
|
||||
*/
|
||||
public static function sendFollowRequest($dbh, $connector, $cache, $_user, $_targetUser, $host)
|
||||
{
|
||||
if ($dbh === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::sendFollowRequest Failed to get database handle");
|
||||
}
|
||||
$user = \Federator\DIO\User::getUserByName(
|
||||
$dbh,
|
||||
$_user,
|
||||
$connector,
|
||||
$cache
|
||||
);
|
||||
if ($user === false || $user->id === null) {
|
||||
throw new \Federator\Exceptions\FileNotFound();
|
||||
}
|
||||
|
||||
$fedUser = \Federator\DIO\FedUser::getUserByName(
|
||||
$dbh,
|
||||
$_targetUser,
|
||||
$cache
|
||||
);
|
||||
if ($fedUser === false || $fedUser->actorURL === null) {
|
||||
throw new \Federator\Exceptions\FileNotFound();
|
||||
}
|
||||
|
||||
$sourceUser = $user->id;
|
||||
$idUrl = self::addFollow($dbh, $sourceUser, $fedUser->id, $host);
|
||||
if ($idUrl === false) {
|
||||
return false; // Failed to add follow
|
||||
}
|
||||
$followObj = new \Federator\Data\ActivityPub\Common\Follow();
|
||||
$sourceUserUrl = 'https://' . $host . '/' . $sourceUser;
|
||||
$followObj->setFObject($fedUser->actorURL);
|
||||
$followObj->setAActor($sourceUserUrl);
|
||||
$followObj->setID($idUrl);
|
||||
|
||||
// Send the Follow activity
|
||||
$inboxUrl = $fedUser->inboxURL;
|
||||
|
||||
$json = json_encode($followObj, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
self::removeFollow($dbh, $sourceUser, $fedUser->id);
|
||||
throw new \Exception('Failed to encode JSON: ' . json_last_error_msg());
|
||||
}
|
||||
$digest = 'SHA-256=' . base64_encode(hash('sha256', $json, true));
|
||||
$date = gmdate('D, d M Y H:i:s') . ' GMT';
|
||||
$parsed = parse_url($inboxUrl);
|
||||
if ($parsed === false) {
|
||||
self::removeFollow($dbh, $sourceUser, $fedUser->id);
|
||||
throw new \Exception('Failed to parse URL: ' . $inboxUrl);
|
||||
}
|
||||
|
||||
if (!isset($parsed['host']) || !isset($parsed['path'])) {
|
||||
self::removeFollow($dbh, $sourceUser, $fedUser->id);
|
||||
throw new \Exception('Invalid inbox URL: missing host or path');
|
||||
}
|
||||
$extHost = $parsed['host'];
|
||||
$path = $parsed['path'];
|
||||
|
||||
// Build the signature string
|
||||
$signatureString = "(request-target): post {$path}\n" .
|
||||
"host: {$extHost}\n" .
|
||||
"date: {$date}\n" .
|
||||
"digest: {$digest}";
|
||||
|
||||
// Get rsa private key
|
||||
$privateKey = \Federator\DIO\User::getrsaprivate($dbh, $user->id); // OR from DB
|
||||
if ($privateKey === false) {
|
||||
self::removeFollow($dbh, $sourceUser, $fedUser->id);
|
||||
throw new \Exception('Failed to get private key');
|
||||
}
|
||||
$pkeyId = openssl_pkey_get_private($privateKey);
|
||||
|
||||
if ($pkeyId === false) {
|
||||
self::removeFollow($dbh, $sourceUser, $fedUser->id);
|
||||
throw new \Exception('Invalid private key');
|
||||
}
|
||||
|
||||
openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256);
|
||||
$signature_b64 = base64_encode($signature);
|
||||
|
||||
// Build keyId (public key ID from your actor object)
|
||||
$keyId = 'https://' . $host . '/' . $user->id . '#main-key';
|
||||
|
||||
$signatureHeader = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"';
|
||||
|
||||
$ch = curl_init($inboxUrl);
|
||||
if ($ch === false) {
|
||||
self::removeFollow($dbh, $sourceUser, $fedUser->id);
|
||||
throw new \Exception('Failed to initialize cURL');
|
||||
}
|
||||
$headers = [
|
||||
'Host: ' . $extHost,
|
||||
'Date: ' . $date,
|
||||
'Digest: ' . $digest,
|
||||
'Content-Type: application/activity+json',
|
||||
'Signature: ' . $signatureHeader,
|
||||
'Accept: application/activity+json',
|
||||
];
|
||||
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Log the response for debugging if needed
|
||||
if ($response === false) {
|
||||
self::removeFollow($dbh, $sourceUser, $fedUser->id);
|
||||
throw new \Exception("Failed to send Follow activity: " . curl_error($ch));
|
||||
} else {
|
||||
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($httpcode != 200 && $httpcode != 202) {
|
||||
self::removeFollow($dbh, $sourceUser, $fedUser->id);
|
||||
throw new \Exception("Unexpected HTTP code $httpcode: $response");
|
||||
}
|
||||
}
|
||||
return $idUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* add follow
|
||||
*
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param string $sourceUser source user id
|
||||
* @param string $targetUserId target user id
|
||||
* @param string $host the host for generating the follow ID
|
||||
* @return string|false the generated follow ID on success, false on failure
|
||||
*/
|
||||
public static function addFollow($dbh, $sourceUser, $targetUserId, $host)
|
||||
{
|
||||
// Check if we already follow this user
|
||||
$sql = 'select id from follows where source_user = ? and target_user = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::addFollow Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("ss", $sourceUser, $targetUserId);
|
||||
$foundId = 0;
|
||||
$ret = $stmt->bind_result($foundId);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
if ($foundId != 0) {
|
||||
return false; // Already following this user
|
||||
}
|
||||
|
||||
// Generate a unique ID for the follow relationship
|
||||
do {
|
||||
$id = bin2hex(openssl_random_pseudo_bytes(16));
|
||||
$idurl = 'https://' . $host . '/' . $sourceUser . '/' . $id;
|
||||
|
||||
// Check if the generated ID is unique
|
||||
$sql = 'select id from follows where id = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::addFollow Failed to prepare id-check statement");
|
||||
}
|
||||
$stmt->bind_param("s", $idurl);
|
||||
$foundId = 0;
|
||||
$ret = $stmt->bind_result($foundId);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
} while ($foundId > 0);
|
||||
|
||||
// Add follow with created_at timestamp
|
||||
$sql = 'insert into follows (id, source_user, target_user, created_at) values (?, ?, ?, NOW())';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::addFollow Failed to prepare insert statement");
|
||||
}
|
||||
$stmt->bind_param("sss", $idurl, $sourceUser, $targetUserId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
return $idurl; // Return the generated follow ID
|
||||
}
|
||||
|
||||
/**
|
||||
* add follow
|
||||
*
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param string $followId the follow ID to use (should be an external url)
|
||||
* @param string $sourceUserId source user id
|
||||
* @param string $targetUserId target user id
|
||||
* @return boolean true on success, false on failure
|
||||
*/
|
||||
public static function addExternalFollow($dbh, $followId, $sourceUserId, $targetUserId)
|
||||
{
|
||||
// Check if we already follow this user
|
||||
$sql = 'select id from follows where source_user = ? and target_user = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::addExternalFollow Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("ss", $sourceUserId, $targetUserId);
|
||||
$foundId = 0;
|
||||
$ret = $stmt->bind_result($foundId);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
if ($foundId != 0) {
|
||||
return false; // Already following this user
|
||||
}
|
||||
|
||||
// Add follow with created_at timestamp
|
||||
$sql = 'insert into follows (id, source_user, target_user, created_at) values (?, ?, ?, NOW())';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::addExternalFollow Failed to prepare insert statement");
|
||||
}
|
||||
$stmt->bind_param("sss", $followId, $sourceUserId, $targetUserId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* generate new follow id
|
||||
*
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param string $hostUrl the host URL (e.g. federator URL)
|
||||
* @return string the new follow id
|
||||
*/
|
||||
public static function generateNewFollowId($dbh, $hostUrl)
|
||||
{
|
||||
// Generate a new unique follow ID
|
||||
do {
|
||||
$newId = bin2hex(openssl_random_pseudo_bytes(16));
|
||||
$newIdUrl = $hostUrl . '/' . $newId;
|
||||
|
||||
// Check if the generated ID is unique
|
||||
$sql = 'select id from follows where id = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::generateNewFollowId Failed to prepare id-check statement");
|
||||
}
|
||||
$stmt->bind_param("s", $newIdUrl);
|
||||
$foundId = 0;
|
||||
$ret = $stmt->bind_result($foundId);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
} while ($foundId > 0);
|
||||
|
||||
return $newIdUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* remove follow
|
||||
*
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param string $sourceUser source user id
|
||||
* @param string $targetUserId target user id
|
||||
* @return string|false removed followId on success, false on failure
|
||||
*/
|
||||
public static function removeFollow($dbh, $sourceUser, $targetUserId)
|
||||
{
|
||||
// Combine retrieval and removal in one query using MySQL's RETURNING (if supported)
|
||||
$sql = 'delete from follows where source_user = ? and target_user = ? RETURNING id';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt !== false) {
|
||||
$stmt->bind_param("ss", $sourceUser, $targetUserId);
|
||||
if ($stmt->execute()) {
|
||||
$stmt->bind_result($followId);
|
||||
if ($stmt->fetch() === true) {
|
||||
$stmt->close();
|
||||
if (!empty($followId)) {
|
||||
return $followId;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
$stmt->close();
|
||||
} else {
|
||||
// Fallback for MySQL versions that do not support RETURNING
|
||||
// First, fetch the id of the follow to be removed
|
||||
$sql = 'select id from follows where source_user = ? and target_user = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::removeFollow Failed to prepare select statement");
|
||||
}
|
||||
$stmt->bind_param("ss", $sourceUser, $targetUserId);
|
||||
$stmt->execute();
|
||||
$stmt->bind_result($followId);
|
||||
$found = $stmt->fetch();
|
||||
$stmt->close();
|
||||
|
||||
if ($found === false || empty($followId)) {
|
||||
return false; // No such follow found
|
||||
}
|
||||
|
||||
// Now, delete the row
|
||||
$sql = 'delete from follows where source_user = ? and target_user = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("Followers::removeFollow Failed to prepare delete statement");
|
||||
}
|
||||
$stmt->bind_param("ss", $sourceUser, $targetUserId);
|
||||
$stmt->execute();
|
||||
$affectedRows = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
|
||||
return $affectedRows > 0 ? $followId : false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -13,47 +13,326 @@ namespace Federator\DIO;
|
|||
*/
|
||||
class Posts
|
||||
{
|
||||
|
||||
/**
|
||||
* get posts by user
|
||||
*
|
||||
* @param \mysqli $dbh @unused-param
|
||||
* database handle
|
||||
* @param string $id
|
||||
* @param string $userid
|
||||
* user id
|
||||
* @param \Federator\Connector\Connector $connector
|
||||
* connector to fetch use with
|
||||
* @param \Federator\Cache\Cache|null $cache
|
||||
* optional caching service
|
||||
* @param string $minId
|
||||
* minimum ID
|
||||
* @param string $maxId
|
||||
* maximum ID
|
||||
* @return \Federator\Data\ActivityPub\Common\APObject[]
|
||||
* @param string $min
|
||||
* minimum date
|
||||
* @param string $max
|
||||
* maximum date
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity[]
|
||||
*/
|
||||
public static function getPostsByUser($dbh, $id, $connector, $cache, $minId, $maxId)
|
||||
public static function getPostsByUser($dbh, $userid, $connector, $cache, $min, $max)
|
||||
{
|
||||
// ask cache
|
||||
if ($cache !== null) {
|
||||
$posts = $cache->getRemotePostsByUser($id, $minId, $maxId);
|
||||
$posts = $cache->getRemotePostsByUser($userid, $min, $max);
|
||||
if ($posts !== false) {
|
||||
return $posts;
|
||||
}
|
||||
}
|
||||
$posts = [];
|
||||
// TODO: check our db
|
||||
$posts = self::getPostsFromDb($dbh, $userid, $min, $max);
|
||||
if ($posts === false) {
|
||||
$posts = [];
|
||||
}
|
||||
|
||||
if ($posts === []) {
|
||||
// ask connector for user-id
|
||||
$posts = $connector->getRemotePostsByUser($id, $minId, $maxId);
|
||||
if ($posts === false) {
|
||||
$posts = [];
|
||||
// Only override $min if we found posts in our DB
|
||||
$remoteMin = $min;
|
||||
if (!empty($posts)) {
|
||||
// Find the latest published date in the DB posts
|
||||
$latestPublished = null;
|
||||
foreach ($posts as $post) {
|
||||
$published = $post->getPublished();
|
||||
if ($published != null) {
|
||||
$publishedStr = gmdate('Y-m-d H:i:s', $published);
|
||||
if ($latestPublished === null || $publishedStr > $latestPublished) {
|
||||
$latestPublished = $publishedStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($latestPublished !== null) {
|
||||
$remoteMin = $latestPublished;
|
||||
}
|
||||
}
|
||||
|
||||
// Always fetch newer posts from connector (if any)
|
||||
$newPosts = $connector->getRemotePostsByUser($userid, $remoteMin, $max);
|
||||
if ($newPosts !== false && is_array($newPosts)) {
|
||||
// Merge new posts with DB posts, avoiding duplicates by ID
|
||||
$existingIds = [];
|
||||
foreach ($posts as $post) {
|
||||
$existingIds[$post->getID()] = true;
|
||||
}
|
||||
foreach ($newPosts as $newPost) {
|
||||
if (!isset($existingIds[$newPost->getID()])) {
|
||||
$posts[] = $newPost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$originUrl = 'localhost';
|
||||
if (isset($_SERVER['HTTP_HOST'])) {
|
||||
$originUrl = $_SERVER['HTTP_HOST']; // origin of our request - e.g. mastodon
|
||||
} elseif (isset($_SERVER['HTTP_ORIGIN'])) {
|
||||
$origin = $_SERVER['HTTP_ORIGIN'];
|
||||
$parsed = parse_url($origin);
|
||||
if (isset($parsed) && isset($parsed['host'])) {
|
||||
$parsedHost = $parsed['host'];
|
||||
if (is_string($parsedHost) && $parsedHost !== "") {
|
||||
$originUrl = $parsedHost;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isset($originUrl) || $originUrl === "") {
|
||||
$originUrl = 'localhost'; // Fallback to localhost if no origin is set
|
||||
}
|
||||
|
||||
// save posts to DB
|
||||
foreach ($posts as $post) {
|
||||
if ($post->getID() !== "") {
|
||||
self::savePost($dbh, $userid, $post);
|
||||
}
|
||||
switch (strtolower($post->getType())) {
|
||||
case 'undo':
|
||||
$object = $post->getObject();
|
||||
if (is_object($object)) {
|
||||
if (strtolower($object->getType()) === 'article') {
|
||||
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
|
||||
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $originUrl);
|
||||
$post->setObject($object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
case 'update':
|
||||
$object = $post->getObject();
|
||||
if (is_object($object)) {
|
||||
if (strtolower($object->getType()) === 'article') {
|
||||
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
|
||||
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $originUrl);
|
||||
$post->setObject($object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cache !== null) {
|
||||
$cache->saveRemotePostsByUser($id, $posts);
|
||||
$cache->saveRemotePostsByUser($userid, $posts);
|
||||
}
|
||||
return $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posts for a user from the DB (optionally by date)
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* @param string $userId
|
||||
* @param string|null $min
|
||||
* @param string|null $max
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
|
||||
*/
|
||||
public static function getPostsFromDb($dbh, $userId, $min = null, $max = null)
|
||||
{
|
||||
$sql = 'SELECT `id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published` FROM posts WHERE user_id = ?';
|
||||
$params = [$userId];
|
||||
$types = 's';
|
||||
if ($min !== null && $min !== "") {
|
||||
$sql .= ' AND published >= ?';
|
||||
$params[] = $min;
|
||||
$types .= 's';
|
||||
}
|
||||
if ($max !== null && $max !== "") {
|
||||
$sql .= ' AND published <= ?';
|
||||
$params[] = $max;
|
||||
$types .= 's';
|
||||
}
|
||||
$sql .= ' ORDER BY published DESC';
|
||||
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
if (!($result instanceof \mysqli_result)) {
|
||||
$stmt->close();
|
||||
return false;
|
||||
}
|
||||
$posts = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if (isset($row['to']) && $row['to'] !== null) {
|
||||
$row['to'] = json_decode($row['to'], true);
|
||||
}
|
||||
if (isset($row['cc']) && $row['cc'] !== null) {
|
||||
$row['cc'] = json_decode($row['cc'], true);
|
||||
}
|
||||
if (isset($row['object']) && $row['object'] !== null) {
|
||||
$decoded = json_decode($row['object'], true);
|
||||
// Only use decoded value if it's an array/object
|
||||
if (is_array($decoded)) {
|
||||
$row['object'] = $decoded;
|
||||
}
|
||||
}
|
||||
if (isset($row['published']) && $row['published'] !== null) {
|
||||
// If it's numeric, keep as int. If it's a string, try to parse as ISO 8601.
|
||||
if (is_numeric($row['published'])) {
|
||||
$row['published'] = intval($row['published'], 10);
|
||||
} else {
|
||||
// Try to parse as datetime string
|
||||
$timestamp = strtotime($row['published']);
|
||||
$row['published'] = $timestamp !== false ? $timestamp : null;
|
||||
}
|
||||
}
|
||||
$activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($row);
|
||||
if ($activity !== false) {
|
||||
$posts[] = $activity;
|
||||
}
|
||||
}
|
||||
$stmt->close();
|
||||
return $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a post (insert or update)
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* @param string $userId
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $post
|
||||
* @param string|null $articleId the original id of the article
|
||||
* (used to identify the source article in the remote system)
|
||||
* @return bool
|
||||
*/
|
||||
public static function savePost($dbh, $userId, $post, $articleId = null)
|
||||
{
|
||||
$sql = 'INSERT INTO posts (
|
||||
`id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published`, `article_id`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`url` = VALUES(`url`),
|
||||
`user_id` = VALUES(`user_id`),
|
||||
`actor` = VALUES(`actor`),
|
||||
`type` = VALUES(`type`),
|
||||
`object` = VALUES(`object`),
|
||||
`to` = VALUES(`to`),
|
||||
`cc` = VALUES(`cc`),
|
||||
`published` = VALUES(`published`),
|
||||
`article_id` = VALUES(`article_id`)';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
|
||||
$id = $post->getID();
|
||||
$url = $post->getUrl();
|
||||
$actor = $post->getAActor();
|
||||
$type = $post->getType();
|
||||
$object = $post->getObject();
|
||||
$objectJson = ($object instanceof \Federator\Data\ActivityPub\Common\APObject)
|
||||
? json_encode($object)
|
||||
: $object;
|
||||
if ($objectJson === false) {
|
||||
$objectJson = null;
|
||||
}
|
||||
if (is_object($object)) {
|
||||
$id = $object->getID();
|
||||
}
|
||||
$to = $post->getTo();
|
||||
$cc = $post->getCC();
|
||||
$toJson = is_array($to) ? json_encode($to) : (is_string($to) ? json_encode([$to]) : null);
|
||||
$ccJson = is_array($cc) ? json_encode($cc) : (is_string($cc) ? json_encode([$cc]) : null);
|
||||
$published = $post->getPublished();
|
||||
$publishedStr = $published ? gmdate('Y-m-d H:i:s', $published) : gmdate('Y-m-d H:i:s');
|
||||
|
||||
$stmt->bind_param(
|
||||
"ssssssssss",
|
||||
$id,
|
||||
$url,
|
||||
$userId,
|
||||
$actor,
|
||||
$type,
|
||||
$objectJson,
|
||||
$toJson,
|
||||
$ccJson,
|
||||
$publishedStr,
|
||||
$articleId,
|
||||
);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a post
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* @param string $id The post ID
|
||||
* @return bool
|
||||
*/
|
||||
public static function deletePost($dbh, $id)
|
||||
{
|
||||
$sql = 'delete from posts where id = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
$stmt->bind_param("s", $id);
|
||||
$stmt->execute();
|
||||
$affectedRows = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $affectedRows > 0;
|
||||
}
|
||||
|
||||
/** retrieve original article id of post
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $post
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getOriginalArticleId($dbh, $post)
|
||||
{
|
||||
$sql = 'SELECT `article_id` FROM posts WHERE id = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
$id = $post->getID();
|
||||
$object = $post->getObject();
|
||||
if (is_object($object)) {
|
||||
$inReplyTo = $object->getInReplyTo();
|
||||
if ($inReplyTo !== "") {
|
||||
$id = $inReplyTo; // Use inReplyTo as ID if it's a string
|
||||
} else {
|
||||
$id = $object->getObject();
|
||||
}
|
||||
} elseif (is_string($object)) {
|
||||
$id = $object; // If object is a string, use it directly
|
||||
}
|
||||
$stmt->bind_param("s", $id);
|
||||
$articleId = null;
|
||||
$ret = $stmt->bind_result($articleId);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
return $articleId;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,8 +17,7 @@ class Stats
|
|||
/**
|
||||
* get remote stats
|
||||
*
|
||||
* @param \Federator\Main $main
|
||||
* main instance
|
||||
* @param \Federator\Main $main main instance
|
||||
* @return \Federator\Data\Stats
|
||||
*/
|
||||
public static function getStats($main)
|
||||
|
|
|
@ -26,7 +26,7 @@ class User
|
|||
$sql = 'select unix_timestamp(`validuntil`) from users where id=?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $_user);
|
||||
$validuntil = 0;
|
||||
|
@ -42,6 +42,7 @@ class User
|
|||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
$public = openssl_pkey_get_details($private_key)['key'];
|
||||
$user->publicKey = $public;
|
||||
$private = '';
|
||||
openssl_pkey_export($private_key, $private);
|
||||
$sql = 'insert into users (id, externalid, rsapublic, rsaprivate, validuntil,';
|
||||
|
@ -49,7 +50,7 @@ class User
|
|||
$sql .= ' values (?, ?, ?, ?, now() + interval 1 day, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare create statement");
|
||||
}
|
||||
$registered = gmdate('Y-m-d H:i:s', $user->registered);
|
||||
$stmt->bind_param(
|
||||
|
@ -73,7 +74,7 @@ class User
|
|||
$sql .= ' iconmediatype=?, iconurl=?, imagemediatype=?, imageurl=? where id=?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare update statement");
|
||||
}
|
||||
$registered = gmdate('Y-m-d H:i:s', $user->registered);
|
||||
$stmt->bind_param(
|
||||
|
@ -100,18 +101,42 @@ class User
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get private rsa key
|
||||
* @return string|false key or false
|
||||
*/
|
||||
public static function getrsaprivate(\mysqli $dbh, string $_user)
|
||||
{
|
||||
$sql = "select rsaprivate from users where id=?";
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError("User::getrsaprivate Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $_user);
|
||||
$ret = $stmt->bind_result($rsaPrivateKey);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
if ($rsaPrivateKey !== null) {
|
||||
return $rsaPrivateKey;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* extend the given user with internal data
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param \Federator\Data\User $user user to extend
|
||||
* @param string $_user user/profile name
|
||||
*/
|
||||
protected static function extendUser(\mysqli $dbh, $user, $_user) : void
|
||||
protected static function extendUser(\mysqli $dbh, $user, $_user): void
|
||||
{
|
||||
$sql = 'select id,unix_timestamp(`validuntil`) from users where id=?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
throw new \Federator\Exceptions\ServerError("User::extendUser Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $_user);
|
||||
$validuntil = 0;
|
||||
|
@ -145,6 +170,7 @@ class User
|
|||
public static function getUserByName($dbh, $_name, $connector, $cache)
|
||||
{
|
||||
$user = false;
|
||||
|
||||
// ask cache
|
||||
if ($cache !== null) {
|
||||
$user = $cache->getRemoteUserByName($_name);
|
||||
|
@ -157,7 +183,7 @@ class User
|
|||
$sql .= 'iconmediatype,iconurl,imagemediatype,imageurl from users where id=? and validuntil>=now()';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
throw new \Federator\Exceptions\ServerError("User::getUserByName Failed to prepare statement");
|
||||
}
|
||||
$stmt->bind_param("s", $_name);
|
||||
$user = new \Federator\Data\User();
|
||||
|
@ -179,6 +205,7 @@ class User
|
|||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
if ($user->id === null) {
|
||||
// ask connector for user-id
|
||||
$ruser = $connector->getRemoteUserByName($_name);
|
||||
|
|
97
php/federator/dio/votes.php
Normal file
97
php/federator/dio/votes.php
Normal file
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* @author Yannis Vogel (vogeldevelopment)
|
||||
**/
|
||||
|
||||
namespace Federator\DIO;
|
||||
|
||||
/**
|
||||
* IO functions related to votes
|
||||
*/
|
||||
class Votes
|
||||
{
|
||||
/**
|
||||
* Add a vote (like/dislike)
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* @param string $userId The user who votes
|
||||
* @param string $targetId The object being voted on (e.g., post id)
|
||||
* @param string $type 'like' or 'dislike'
|
||||
* @return string|false The generated vote ID on success, false on failure
|
||||
*/
|
||||
public static function addVote($dbh, $userId, $targetId, $type)
|
||||
{
|
||||
// Check if already voted
|
||||
$sql = 'SELECT id FROM votes WHERE user_id = ? AND target_id = ? AND type = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
$stmt->bind_param("sss", $userId, $targetId, $type);
|
||||
$foundId = 0;
|
||||
$ret = $stmt->bind_result($foundId);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
if ($foundId != 0) {
|
||||
return false; // Already voted
|
||||
}
|
||||
|
||||
// Generate a unique ID for the vote
|
||||
do {
|
||||
$id = bin2hex(openssl_random_pseudo_bytes(16));
|
||||
// Check if the generated ID is unique
|
||||
$sql = 'SELECT id FROM votes WHERE id = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
$stmt->bind_param("s", $id);
|
||||
$foundId = 0;
|
||||
$ret = $stmt->bind_result($foundId);
|
||||
$stmt->execute();
|
||||
if ($ret) {
|
||||
$stmt->fetch();
|
||||
}
|
||||
$stmt->close();
|
||||
} while ($foundId > 0);
|
||||
|
||||
// Add vote with created_at timestamp
|
||||
$sql = 'INSERT INTO votes (id, user_id, target_id, type, created_at) VALUES (?, ?, ?, ?, NOW())';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
$stmt->bind_param("ssss", $id, $userId, $targetId, $type);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
return $id; // Return the generated vote ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a vote (like/dislike)
|
||||
*
|
||||
* @param \mysqli $dbh
|
||||
* @param string $userId
|
||||
* @param string $targetId
|
||||
* @return bool true on success
|
||||
*/
|
||||
public static function removeVote($dbh, $userId, $targetId)
|
||||
{
|
||||
$sql = 'DELETE FROM votes WHERE user_id = ? AND target_id = ?';
|
||||
$stmt = $dbh->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new \Federator\Exceptions\ServerError();
|
||||
}
|
||||
$stmt->bind_param("ss", $userId, $targetId);
|
||||
$stmt->execute();
|
||||
$affectedRows = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $affectedRows > 0;
|
||||
}
|
||||
}
|
70
php/federator/jobs/inboxJob.php
Normal file
70
php/federator/jobs/inboxJob.php
Normal file
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Federator\Jobs;
|
||||
|
||||
class InboxJob extends \Federator\Api
|
||||
{
|
||||
/** @var array<string, mixed> $args Arguments for the job */
|
||||
public $args = [];
|
||||
|
||||
/**
|
||||
* cache instance
|
||||
*
|
||||
* @var \Federator\Cache\Cache $cache
|
||||
*/
|
||||
protected $cache;
|
||||
|
||||
/**
|
||||
* remote connector
|
||||
*
|
||||
* @var \Federator\Connector\Connector $connector
|
||||
*/
|
||||
protected $connector = null;
|
||||
|
||||
/**
|
||||
* database instance
|
||||
*
|
||||
* @var \Mysqli $dbh
|
||||
*/
|
||||
protected $dbh;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up environment for this job
|
||||
*/
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->openDatabase();
|
||||
$this->loadPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the inbox job.
|
||||
*
|
||||
* @return bool true on success, false on failure
|
||||
*/
|
||||
public function perform(): bool
|
||||
{
|
||||
error_log("InboxJob: Starting job");
|
||||
$user = $this->args['user'];
|
||||
$recipientId = $this->args['recipientId'];
|
||||
$activity = $this->args['activity'];
|
||||
|
||||
$inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity);
|
||||
|
||||
if ($inboxActivity === false) {
|
||||
error_log("InboxJob: Failed to create inboxActivity from JSON");
|
||||
return false;
|
||||
}
|
||||
|
||||
\Federator\Api\FedUsers\Inbox::postForUser($this->dbh, $this->connector, $this->cache, $user, $recipientId, $inboxActivity);
|
||||
return true;
|
||||
}
|
||||
}
|
74
php/federator/jobs/newContentJob.php
Normal file
74
php/federator/jobs/newContentJob.php
Normal file
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace Federator\Jobs;
|
||||
|
||||
class NewContentJob extends \Federator\Api
|
||||
{
|
||||
/** @var array<string, mixed> $args Arguments for the job */
|
||||
public $args = [];
|
||||
|
||||
/**
|
||||
* cache instance
|
||||
*
|
||||
* @var \Federator\Cache\Cache $cache
|
||||
*/
|
||||
protected $cache;
|
||||
|
||||
/**
|
||||
* remote connector
|
||||
*
|
||||
* @var \Federator\Connector\Connector $connector
|
||||
*/
|
||||
protected $connector = null;
|
||||
|
||||
/**
|
||||
* database instance
|
||||
*
|
||||
* @var \Mysqli $dbh
|
||||
*/
|
||||
protected $dbh;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up environment for this job
|
||||
*/
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->openDatabase();
|
||||
$this->loadPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the inbox job.
|
||||
*
|
||||
* @return bool true on success, false on failure
|
||||
*/
|
||||
public function perform(): bool
|
||||
{
|
||||
error_log("NewContentJob: Starting job");
|
||||
$user = $this->args['user'];
|
||||
$recipientId = $this->args['recipientId'];
|
||||
$activity = $this->args['activity'];
|
||||
$articleId = $this->args['articleId'] ?? null;
|
||||
|
||||
$activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity);
|
||||
|
||||
if ($activity === false) {
|
||||
error_log("NewContentJob: Failed to create activity from JSON");
|
||||
return false;
|
||||
}
|
||||
$domain = $this->config['generic']['externaldomain'];
|
||||
|
||||
$ourUrl = 'https://' . $domain;
|
||||
|
||||
\Federator\Api\V1\NewContent::postForUser($this->dbh, $this->connector, $this->cache, $ourUrl, $user, $recipientId, $activity, $articleId);
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -92,12 +92,9 @@ class Language
|
|||
}
|
||||
if (! isset($this->lang[$group])) {
|
||||
$l = [];
|
||||
$root = $_SERVER['DOCUMENT_ROOT'];
|
||||
if ($root === '') {
|
||||
$root = '.';
|
||||
}
|
||||
if (@file_exists($root . '../lang/federator/' . $this->uselang . "/$group.inc")) {
|
||||
require($root . '../lang/federator/' . $this->uselang . "/$group.inc");
|
||||
$root = PROJECT_ROOT;
|
||||
if (@file_exists($root . '/lang/federator/' . $this->uselang . "/$group.inc")) {
|
||||
require($root . '/lang/federator/' . $this->uselang . "/$group.inc");
|
||||
$this->lang[$group] = $l;
|
||||
}
|
||||
}
|
||||
|
@ -112,7 +109,7 @@ class Language
|
|||
}
|
||||
return $string;
|
||||
}
|
||||
$basedir = $_SERVER['DOCUMENT_ROOT'] . '/../';
|
||||
$basedir = PROJECT_ROOT;
|
||||
$fh = @fopen("$basedir/logs/missingtrans.txt", 'a');
|
||||
if ($fh !== false) {
|
||||
fwrite($fh, $this->uselang.":$group:$key\n");
|
||||
|
@ -132,7 +129,7 @@ class Language
|
|||
{
|
||||
if (! isset($this->lang[$group])) {
|
||||
$l = [];
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/../lang/' . $this->uselang . "/$group.inc");
|
||||
require_once(PROJECT_ROOT . '/lang/' . $this->uselang . "/$group.inc");
|
||||
$this->lang[$group] = $l;
|
||||
}
|
||||
// @phan-suppress-next-line PhanPartialTypeMismatchReturn
|
||||
|
|
|
@ -33,6 +33,12 @@ class Main
|
|||
* @var Connector\Connector $connector
|
||||
*/
|
||||
protected $connector = null;
|
||||
/**
|
||||
* remote host (f.e. https://contentnation.net)
|
||||
*
|
||||
* @var string $host
|
||||
*/
|
||||
protected $host = null;
|
||||
/**
|
||||
* response content type
|
||||
*
|
||||
|
@ -72,9 +78,9 @@ class Main
|
|||
*/
|
||||
public function __construct()
|
||||
{
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '../vendor/autoload.php');
|
||||
require_once(PROJECT_ROOT . '/vendor/autoload.php');
|
||||
$this->responseCode = 200;
|
||||
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
|
||||
$rootDir = PROJECT_ROOT . '/';
|
||||
$config = parse_ini_file($rootDir . 'config.ini', true);
|
||||
if ($config !== false) {
|
||||
$this->config = $config;
|
||||
|
@ -93,7 +99,7 @@ class Main
|
|||
public static function extractFromURI($param, $fallback = '')
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'];
|
||||
$params = substr($uri, (int)(strpos($uri, '?') + 1));
|
||||
$params = substr($uri, (int) (strpos($uri, '?') + 1));
|
||||
$params = explode('&', $params);
|
||||
foreach ($params as $p) {
|
||||
$tokens = explode('=', $p);
|
||||
|
@ -139,20 +145,28 @@ class Main
|
|||
{
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* get connector
|
||||
*
|
||||
* @return \Federator\Connector\Connector
|
||||
*/
|
||||
* get connector
|
||||
*
|
||||
* @return \Federator\Connector\Connector
|
||||
*/
|
||||
public function getConnector()
|
||||
{
|
||||
return $this->connector;
|
||||
}
|
||||
/**
|
||||
* get host (f.e. https://contentnation.net)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getHost()
|
||||
{
|
||||
return $this->host;
|
||||
}
|
||||
|
||||
/**
|
||||
* get config
|
||||
* @return Array<String, Mixed>
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getConfig()
|
||||
{
|
||||
|
@ -172,10 +186,10 @@ class Main
|
|||
/**
|
||||
* load plugins
|
||||
*/
|
||||
public function loadPlugins() : void
|
||||
public function loadPlugins(): void
|
||||
{
|
||||
if (array_key_exists('plugins', $this->config)) {
|
||||
$basepath = $_SERVER['DOCUMENT_ROOT'] . '../plugins/federator/';
|
||||
$basepath = PROJECT_ROOT . '/plugins/federator/';
|
||||
$plugins = $this->config['plugins'];
|
||||
foreach ($plugins as $name => $file) {
|
||||
require_once($basepath . $file);
|
||||
|
@ -199,8 +213,8 @@ class Main
|
|||
$dbconf = $this->config["database"];
|
||||
$this->dbh = new \mysqli(
|
||||
$dbconf['host'],
|
||||
$usernameOverride ?? (string)$dbconf['username'],
|
||||
$passwordOverride ?? (string)$dbconf['password'],
|
||||
$usernameOverride ?? (string) $dbconf['username'],
|
||||
$passwordOverride ?? (string) $dbconf['password'],
|
||||
$dbconf['database']
|
||||
);
|
||||
if ($this->dbh->connect_error !== null) {
|
||||
|
@ -218,10 +232,10 @@ class Main
|
|||
*/
|
||||
public function renderTemplate($template, $data)
|
||||
{
|
||||
$rootDir = PROJECT_ROOT . '/';
|
||||
$smarty = new \Smarty\Smarty();
|
||||
$root = $_SERVER['DOCUMENT_ROOT'];
|
||||
$smarty->setCompileDir($root . $this->config['templates']['compiledir']);
|
||||
$smarty->setTemplateDir((string)realpath($root . $this->config['templates']['path']));
|
||||
$smarty->setCompileDir($rootDir . $this->config['templates']['compiledir']);
|
||||
$smarty->setTemplateDir((string) realpath($rootDir . $this->config['templates']['path']));
|
||||
$smarty->assign('database', $this->dbh);
|
||||
$smarty->assign('maininstance', $this);
|
||||
foreach ($data as $key => $value) {
|
||||
|
@ -232,27 +246,57 @@ class Main
|
|||
|
||||
/**
|
||||
* set cache
|
||||
*
|
||||
* @param \Federator\Cache\Cache $cache the new cache
|
||||
*/
|
||||
public function setCache(Cache\Cache $cache) : void
|
||||
public function setCache(Cache\Cache $cache): void
|
||||
{
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* set connector
|
||||
*
|
||||
* @param \Federator\Connector\Connector $connector the new connector
|
||||
*/
|
||||
public function setConnector(Connector\Connector $connector) : void
|
||||
{
|
||||
if (isset($this->connector)) {
|
||||
# echo "main::setConnector Setting new connector will override old one.\n"; // TODO CHANGE TO LOG WARNING
|
||||
}
|
||||
$this->connector = $connector;
|
||||
}
|
||||
|
||||
/**
|
||||
* set host
|
||||
*
|
||||
* @param string $host the new host url
|
||||
*/
|
||||
public function setHost(string $host) : void
|
||||
{
|
||||
if (isset($this->host)) {
|
||||
# echo "main::setHost Setting new host will override old one.\n"; // TODO CHANGE TO LOG WARNING
|
||||
}
|
||||
$this->host = $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* set content type
|
||||
*
|
||||
* @param string $_type content type
|
||||
*/
|
||||
public function setContentType($_type): void
|
||||
{
|
||||
$this->contentType = $_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* set response code
|
||||
*
|
||||
* @param int $code
|
||||
* new response code
|
||||
*/
|
||||
public function setResponseCode(int $code) : void
|
||||
public function setResponseCode(int $code): void
|
||||
{
|
||||
$this->responseCode = $code;
|
||||
}
|
||||
|
@ -270,7 +314,7 @@ class Main
|
|||
* optional parameters
|
||||
* @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);
|
||||
return $l->printlang($group, $key, $parameters);
|
||||
|
@ -281,7 +325,7 @@ class Main
|
|||
*
|
||||
* @param ?string $lang
|
||||
*/
|
||||
public static function validLanguage(?string $lang) : bool
|
||||
public static function validLanguage(?string $lang): bool
|
||||
{
|
||||
$language = new Language($lang);
|
||||
if ($language->getLang() === $lang) {
|
||||
|
|
|
@ -24,14 +24,13 @@ class Maintenance
|
|||
{
|
||||
date_default_timezone_set("Europe/Berlin");
|
||||
spl_autoload_register(static function (string $className) {
|
||||
$root = $_SERVER['DOCUMENT_ROOT'];
|
||||
include $root . '../php/' . str_replace("\\", "/", strtolower($className)) . '.php';
|
||||
include PROJECT_ROOT . '/php/' . str_replace("\\", "/", strtolower($className)) . '.php';
|
||||
});
|
||||
if ($argc < 2) {
|
||||
self::printUsage();
|
||||
}
|
||||
// pretend that we are running from web directory
|
||||
$_SERVER['DOCUMENT_ROOT'] = realpath('../../htdocs') . '/';
|
||||
define('PROJECT_ROOT', dirname(__DIR__, 2));
|
||||
$main = new \Federator\Main();
|
||||
switch ($argv[1]) {
|
||||
case 'dbupgrade':
|
||||
|
@ -71,7 +70,7 @@ class Maintenance
|
|||
}
|
||||
}
|
||||
echo "current version: $version\n";
|
||||
$root = $_SERVER['DOCUMENT_ROOT'] . '../';
|
||||
$root = PROJECT_ROOT . '/';
|
||||
$updateFolder = opendir($root . 'sql');
|
||||
if ($updateFolder === false) {
|
||||
die();
|
||||
|
|
23
php/federator/workers/worker_inbox.php
Normal file
23
php/federator/workers/worker_inbox.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
define('PROJECT_ROOT', dirname(__DIR__, 3));
|
||||
|
||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||
|
||||
$config = parse_ini_file(PROJECT_ROOT . '/rediscache.ini');
|
||||
|
||||
// Set the Redis backend for Resque
|
||||
$redisUrl = sprintf(
|
||||
'redis://%s:%s@%s:%d',
|
||||
urlencode($config['username']),
|
||||
urlencode($config['password']),
|
||||
$config['host'],
|
||||
intval($config['port'], 10)
|
||||
);
|
||||
\Resque::setBackend($redisUrl);
|
||||
|
||||
// Start the worker
|
||||
$worker = new \Resque_Worker(['inbox']);
|
||||
|
||||
fwrite(STDOUT, "*** Starting worker for inbox queue\n");
|
||||
$worker->work(10); // 10 seconds interval
|
|
@ -6,11 +6,11 @@
|
|||
* @author Sascha Nitsch (grumpydeveloper)
|
||||
**/
|
||||
|
||||
namespace Federator\Connector;
|
||||
namespace Federator\Connector;
|
||||
|
||||
/**
|
||||
* Connector to ContentNation.net
|
||||
*/
|
||||
/**
|
||||
* Connector to ContentNation.net
|
||||
*/
|
||||
class ContentNation implements Connector
|
||||
{
|
||||
/**
|
||||
|
@ -41,12 +41,68 @@ class ContentNation implements Connector
|
|||
*/
|
||||
public function __construct($main)
|
||||
{
|
||||
$config = parse_ini_file($_SERVER['DOCUMENT_ROOT'] . '../contentnation.ini', true);
|
||||
$config = parse_ini_file(PROJECT_ROOT . '/contentnation.ini', true);
|
||||
if ($config !== false) {
|
||||
$this->config = $config;
|
||||
}
|
||||
$this->service = $config['contentnation']['service-uri'];
|
||||
$this->main = $main;
|
||||
$this->main->setHost($this->service);
|
||||
}
|
||||
|
||||
/**
|
||||
* get followers of given user
|
||||
*
|
||||
* @param string $userId user id
|
||||
* @return \Federator\Data\FedUser[]|false
|
||||
*/
|
||||
public function getRemoteFollowersOfUser($userId)
|
||||
{
|
||||
// todo implement queue for this
|
||||
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
|
||||
$userId = $matches[1];
|
||||
}
|
||||
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/followers';
|
||||
|
||||
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
|
||||
if ($info['http_code'] != 200) {
|
||||
print_r($info);
|
||||
return false;
|
||||
}
|
||||
$r = json_decode($response, true);
|
||||
if ($r === false || $r === null || !is_array($r)) {
|
||||
return false;
|
||||
}
|
||||
$followers = [];
|
||||
return $followers;
|
||||
}
|
||||
|
||||
/**
|
||||
* get following of given user
|
||||
*
|
||||
* @param string $userId user id
|
||||
|
||||
* @return \Federator\Data\FedUser[]|false
|
||||
*/
|
||||
public function getRemoteFollowingForUser($userId)
|
||||
{
|
||||
// todo implement queue for this
|
||||
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
|
||||
$userId = $matches[1];
|
||||
}
|
||||
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/following';
|
||||
|
||||
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
|
||||
if ($info['http_code'] != 200) {
|
||||
print_r($info);
|
||||
return false;
|
||||
}
|
||||
$r = json_decode($response, true);
|
||||
if ($r === false || $r === null || !is_array($r)) {
|
||||
return false;
|
||||
}
|
||||
$followers = [];
|
||||
return $followers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -55,11 +111,14 @@ class ContentNation implements Connector
|
|||
* @param string $userId user id
|
||||
* @param string $min min date
|
||||
* @param string $max max date
|
||||
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
|
||||
*/
|
||||
public function getRemotePostsByUser($userId, $min, $max)
|
||||
{
|
||||
$remoteURL = $this->service . '/api/profile/' . $userId . '/activities';
|
||||
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
|
||||
$userId = $matches[1];
|
||||
}
|
||||
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/activities';
|
||||
if ($min !== '') {
|
||||
$remoteURL .= '&minTS=' . urlencode($min);
|
||||
}
|
||||
|
@ -78,110 +137,122 @@ class ContentNation implements Connector
|
|||
$posts = [];
|
||||
if (array_key_exists('activities', $r)) {
|
||||
$activities = $r['activities'];
|
||||
$host = $_SERVER['SERVER_NAME'];
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
$ourUrl = 'https://' . $domain;
|
||||
|
||||
$imgpath = $this->config['userdata']['path'];
|
||||
$userdata = $this->config['userdata']['url'];
|
||||
foreach ($activities as $activity) {
|
||||
$create = new \Federator\Data\ActivityPub\Common\Create();
|
||||
$create->setAActor('https://' . $host .'/' . $userId);
|
||||
$create->setID($activity['id'])
|
||||
->setPublished($activity['timestamp'])
|
||||
->addTo("https://www.w3.org/ns/activitystreams#Public")
|
||||
->addCC('https://' . $host . '/' . $userId . '/followers.json');
|
||||
switch ($activity['type']) {
|
||||
case 'Article':
|
||||
$create->setURL('https://'.$host . '/' . $activity['language'] . '/' . $userId . '/'
|
||||
. $activity['name']);
|
||||
$create = new \Federator\Data\ActivityPub\Common\Create();
|
||||
$create->setAActor($ourUrl . '/' . $userId);
|
||||
$create->setID($activity['id'])
|
||||
->setPublished($activity['published'] ?? $activity['timestamp'])
|
||||
->addTo($ourUrl . '/' . $userId . '/followers')
|
||||
->addCC("https://www.w3.org/ns/activitystreams#Public");
|
||||
$create->setURL($ourUrl . '/' . $activity['profilename'] . '/' . $activity['name']);
|
||||
$create->setID($ourUrl . '/' . $activity['profilename'] . '/' . $activity['id']);
|
||||
$apArticle = new \Federator\Data\ActivityPub\Common\Article();
|
||||
if (array_key_exists('tags', $activity)) {
|
||||
foreach ($activity['tags'] as $tag) {
|
||||
$href = 'https://' . $host . '/' . $activity['language']
|
||||
. '/search.htm?tagsearch=' . urlencode($tag);
|
||||
$href = $ourUrl . '/search.htm?tagsearch=' . urlencode($tag);
|
||||
$tagObj = new \Federator\Data\ActivityPub\Common\Tag();
|
||||
$tagObj->setHref($href)
|
||||
->setName('#' . urlencode(str_replace(' ', '', $tag)))
|
||||
->setType('Hashtag');
|
||||
->setName('#' . urlencode(str_replace(' ', '', $tag)))
|
||||
->setType('Hashtag');
|
||||
$apArticle->addTag($tagObj);
|
||||
}
|
||||
}
|
||||
$apArticle->setPublished($activity['published'])
|
||||
->setName($activity['title'])
|
||||
->setAttributedTo('https://' . $host .'/' . $activity['profilename'])
|
||||
->setContent(
|
||||
$activity['teaser'] ??
|
||||
$this->main->translate(
|
||||
$activity['language'],
|
||||
'article',
|
||||
'newarticle'
|
||||
)
|
||||
)
|
||||
->addTo("https://www.w3.org/ns/activitystreams#Public")
|
||||
->addCC('https://' . $host . '/' . $userId . '/followers.json');
|
||||
->setName($activity['title'])
|
||||
->setAttributedTo($ourUrl . '/' . $activity['profilename'])
|
||||
->setContent(
|
||||
$activity['teaser'] ??
|
||||
$this->main->translate(
|
||||
$activity['language'],
|
||||
'article',
|
||||
'newarticle'
|
||||
)
|
||||
)
|
||||
->addTo("https://www.w3.org/ns/activitystreams#Public")
|
||||
->addCC($ourUrl . '/' . $userId . '/followers.json');
|
||||
$articleimage = $activity['imagealt'] ??
|
||||
$this->main->translate($activity['language'], 'article', 'image');
|
||||
$idurl = 'https://' . $host . '/' . $activity['language']
|
||||
. '/' . $userId . '/'. $activity['name'];
|
||||
$idurl = $ourUrl . '/' . $userId . '/' . $activity['name'];
|
||||
$apArticle->setID($idurl)
|
||||
->setURL($idurl);
|
||||
->setURL($idurl);
|
||||
$image = $activity['image'] ?? $activity['profileimg'];
|
||||
$mediaType = @mime_content_type($imgpath . $activity['profile'] . '/' . $image) | 'text/plain';
|
||||
$path = $imgpath . $activity['profile'] . '/' . $image;
|
||||
|
||||
$type = file_exists($path) ? mime_content_type($path) : false;
|
||||
$mediaType = ($type !== false && !str_starts_with($type, 'text/'))
|
||||
? $type
|
||||
: 'image/jpeg';
|
||||
|
||||
$img = new \Federator\Data\ActivityPub\Common\Image();
|
||||
$img->setMediaType($mediaType)
|
||||
->setName($articleimage)
|
||||
->setURL($userdata . '/' . $activity['profile'] . $image);
|
||||
->setName($articleimage)
|
||||
->setURL($userdata . '/' . $activity['profile'] . $image);
|
||||
$apArticle->addImage($img);
|
||||
$create->setObject($apArticle);
|
||||
$posts[] = $create;
|
||||
break; // Article
|
||||
|
||||
case 'Comment':
|
||||
// echo "comment\n";
|
||||
// print_r($activity);
|
||||
break; // Comment
|
||||
case 'Vote':
|
||||
$url = 'https://'.$host . '/' . $activity['articlelang'] . $userId . '/'
|
||||
. $activity['articlename'];
|
||||
$url .= '/vote/' . $activity['id'];
|
||||
$create->setURL($url);
|
||||
if ($activity['upvote'] === true) {
|
||||
$like = new \Federator\Data\ActivityPub\Common\Activity('Like');
|
||||
$like->setSummary(
|
||||
$this->main->translate(
|
||||
$activity['articlelang'],
|
||||
'vote',
|
||||
'like',
|
||||
[$activity['username']]
|
||||
)
|
||||
);
|
||||
$create = new \Federator\Data\ActivityPub\Common\Create();
|
||||
$create->setAActor($ourUrl . '/' . $userId);
|
||||
$create->setID($activity['id'])
|
||||
->setPublished($activity['published'] ?? $activity['timestamp'])
|
||||
->addTo($ourUrl . '/' . $userId . '/followers')
|
||||
->addCC("https://www.w3.org/ns/activitystreams#Public");
|
||||
$commentJson = $activity;
|
||||
$commentJson['type'] = 'Note';
|
||||
$commentJson['summary'] = $activity['subject'];
|
||||
$commentJson['id'] = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id'];
|
||||
$note = \Federator\Data\ActivityPub\Factory::newFromJson($commentJson, "");
|
||||
if ($note === null) {
|
||||
error_log("ContentNation::getRemotePostsByUser couldn't create comment");
|
||||
$note = new \Federator\Data\ActivityPub\Common\Activity('Comment');
|
||||
$create->setObject($note);
|
||||
break;
|
||||
}
|
||||
$note->setID($commentJson['id']);
|
||||
if (!isset($commentJson['parent']) || $commentJson['parent'] === null) {
|
||||
$note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName']);
|
||||
} else {
|
||||
$like = new \Federator\Data\ActivityPub\Common\Activity('Dislike');
|
||||
$like->setSummary(
|
||||
$this->main->translate(
|
||||
$activity['articlelang'],
|
||||
'vote',
|
||||
'dislike',
|
||||
[$activity['username']]
|
||||
)
|
||||
);
|
||||
$note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . "#" . $commentJson['parent']);
|
||||
}
|
||||
$actor = new \Federator\Data\ActivityPub\Common\APObject('Person');
|
||||
$actor->setName($activity['username']);
|
||||
$like->setActor($actor);
|
||||
$url = 'https://' . $host . '/' . $activity['articlelang']
|
||||
. '/' . $userId . '/'. $activity['articlename'];
|
||||
if ($activity['comment'] !== '') {
|
||||
$url .= '/comment/' . $activity['comment'];
|
||||
}
|
||||
$type = 'Article';
|
||||
switch ($activity['votetype']) {
|
||||
case 'comment':
|
||||
$type = 'Comment';
|
||||
break;
|
||||
}
|
||||
$object = new \Federator\Data\ActivityPub\Common\APObject($type);
|
||||
$object->setHref($url);
|
||||
$like->setObject($object);
|
||||
$create->setObject($like);
|
||||
$url = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id'];
|
||||
$create->setURL($url);
|
||||
$create->setID($url);
|
||||
$create->setObject($note);
|
||||
$posts[] = $create;
|
||||
break; // Comment
|
||||
|
||||
case 'Vote':
|
||||
// Build Like/Dislike as top-level activity
|
||||
$likeType = $activity['upvote'] === true ? 'Like' : 'Dislike';
|
||||
$like = new \Federator\Data\ActivityPub\Common\Activity($likeType);
|
||||
$like->setAActor($ourUrl . '/' . $userId);
|
||||
$like->setID($activity['id'])
|
||||
->setPublished($activity['published'] ?? $activity['timestamp']);
|
||||
// $like->addTo("https://www.w3.org/ns/activitystreams#Public")
|
||||
// ->addCC('https://' . $domain . '/' . $userId . '/followers');
|
||||
$like->setSummary(
|
||||
$this->main->translate(
|
||||
$activity['articlelang'],
|
||||
'vote',
|
||||
$likeType === 'Like' ? 'like' : 'dislike',
|
||||
[$activity['username']]
|
||||
)
|
||||
);
|
||||
$objectUrl = $ourUrl . '/' . $userId . '/' . $activity['articlename'];
|
||||
$like->setURL($objectUrl . '#' . $activity['id']);
|
||||
$like->setID($objectUrl . '#' . $activity['id']);
|
||||
$like->setObject($objectUrl);
|
||||
$posts[] = $like;
|
||||
break; // Vote
|
||||
}
|
||||
}
|
||||
|
@ -212,7 +283,7 @@ class ContentNation implements Connector
|
|||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* get remote user by given name
|
||||
*
|
||||
* @param string $_name user/profile name
|
||||
|
@ -221,10 +292,16 @@ class ContentNation implements Connector
|
|||
public function getRemoteUserByName(string $_name)
|
||||
{
|
||||
// validate name
|
||||
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_name) != 1) {
|
||||
if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_name) != 1) {
|
||||
return false;
|
||||
}
|
||||
$remoteURL = $this->service . '/api/users/info?user=' . urlencode($_name);
|
||||
// make sure we only get name part, without domain
|
||||
if (preg_match("#^([^@]+)@([^/]+)#", $_name, $matches) == 1) {
|
||||
$name = $matches[1];
|
||||
} else {
|
||||
$name = $_name;
|
||||
}
|
||||
$remoteURL = $this->service . '/api/users/info?user=' . urlencode($name);
|
||||
$headers = ['Accept: application/json'];
|
||||
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
|
||||
if ($info['http_code'] != 200) {
|
||||
|
@ -260,7 +337,7 @@ class ContentNation implements Connector
|
|||
if (preg_match("/^[a-z0-9]{16}$/", $_session) != 1) {
|
||||
return false;
|
||||
}
|
||||
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_user) != 1) {
|
||||
if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_user) != 1) {
|
||||
return false;
|
||||
}
|
||||
$remoteURL = $this->service . '/api/users/permissions?profile=' . urlencode($_user);
|
||||
|
@ -271,7 +348,7 @@ class ContentNation implements Connector
|
|||
return false;
|
||||
}
|
||||
$r = json_decode($response, true);
|
||||
if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) {
|
||||
if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) {
|
||||
return false;
|
||||
}
|
||||
$user = $this->getRemoteUserByName($_user);
|
||||
|
@ -286,6 +363,612 @@ class ContentNation implements Connector
|
|||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert jsonData to Activity format
|
||||
*
|
||||
* @param array<string, mixed> $jsonData the json data from our platfrom
|
||||
* @param string $articleId the original id of the article (if applicable)
|
||||
* (used to identify the article in the remote system)
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity|false
|
||||
*/
|
||||
public function jsonToActivity($jsonData, &$articleId)
|
||||
{
|
||||
$returnActivity = false;
|
||||
// Common fields for all activity types
|
||||
$ap = [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'type' => 'Create', // Default to 'Create'
|
||||
'id' => $jsonData['id'] ?? null,
|
||||
'actor' => $jsonData['actor'] ?? null,
|
||||
];
|
||||
|
||||
$config = $this->main->getConfig();
|
||||
$domain = $config['generic']['externaldomain'];
|
||||
|
||||
$ourUrl = 'https://' . $domain;
|
||||
|
||||
// Extract actorName as the last segment of the actor URL (after the last '/')
|
||||
$actorData = $jsonData['actor'] ?? null;
|
||||
$actorName = $actorData['name'] ?? null;
|
||||
|
||||
$ap['actor'] = $ourUrl . '/' . $actorName;
|
||||
|
||||
if (isset($jsonData['type'])) {
|
||||
switch ($jsonData['type']) {
|
||||
case 'undo':
|
||||
$ap['type'] = 'Undo';
|
||||
$ap['actor'] = $ourUrl . '/' . $actorName;
|
||||
$objectType = $jsonData['object']['type'] ?? null;
|
||||
if ($objectType === "article") {
|
||||
$articleName = $jsonData['object']['name'] ?? null;
|
||||
$ownerName = $jsonData['object']['ownerName'] ?? null;
|
||||
$ap['id'] = $ourUrl . '/' . $ownerName . '/' . $articleName . '/undo';
|
||||
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
|
||||
} elseif ($objectType === "comment") {
|
||||
$articleName = $jsonData['object']['articleName'] ?? null;
|
||||
$ownerName = $jsonData['object']['articleOwnerName'] ?? null;
|
||||
$commentId = $jsonData['object']['id'] ?? null;
|
||||
$ap['id'] = $ourUrl . '/' . $ownerName . '/' . $articleName . '#' . $commentId . '/undo';
|
||||
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
|
||||
} elseif ($objectType === "vote") {
|
||||
$id = $jsonData['object']['id'] ?? null;
|
||||
$articleName = $jsonData['object']['articleName'] ?? null;
|
||||
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
|
||||
$ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id . '/undo';
|
||||
$ap['published'] = $jsonData['object']['published'] ?? null;
|
||||
$ap['actor'] = $ourUrl . '/' . $actorName;
|
||||
$ap['object']['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id;
|
||||
$ap['object']['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id;
|
||||
$ap['object']['actor'] = $ourUrl . '/' . $actorName;
|
||||
if ($jsonData['object']['vote']['value'] == 1) {
|
||||
$ap['object']['type'] = 'Like';
|
||||
} elseif ($jsonData['object']['vote']['value'] == 0) {
|
||||
$ap['object']['type'] = 'Dislike';
|
||||
} else {
|
||||
error_log("ContentNation::jsonToActivity unknown vote value: {$jsonData['object']['vote']['value']}");
|
||||
break;
|
||||
}
|
||||
$ap['object']['object'] = self::generateObjectJson($ourUrl, $jsonData);
|
||||
} else {
|
||||
error_log("ContentNation::jsonToActivity unknown undo type: {$objectType}");
|
||||
break;
|
||||
}
|
||||
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
|
||||
if ($returnActivity === false) {
|
||||
error_log("ContentNation::jsonToActivity couldn't create undo");
|
||||
$returnActivity = new \Federator\Data\ActivityPub\Common\Undo();
|
||||
} else {
|
||||
$returnActivity->setID($ap['id']);
|
||||
$returnActivity->setURL($ap['id']);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Handle unsupported types or fallback to default behavior
|
||||
throw new \InvalidArgumentException("ContentNation::jsonToActivity Unsupported type: {$jsonData['type']}");
|
||||
}
|
||||
} else {
|
||||
// Handle specific fields based on the type
|
||||
switch ($jsonData['object']['type']) {
|
||||
case 'article':
|
||||
$articleName = $jsonData['object']['name'] ?? null;
|
||||
$articleOwnerName = $jsonData['object']['ownerName'] ?? null;
|
||||
// Set Create-level fields
|
||||
$updatedOn = $jsonData['object']['modified'] ?? null;
|
||||
$originalPublished = $jsonData['object']['published'] ?? null;
|
||||
$update = $updatedOn !== $originalPublished;
|
||||
$ap['published'] = $updatedOn ?? $originalPublished;
|
||||
$ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
|
||||
$ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
|
||||
$ap['type'] = $update ? 'Update' : 'Create';
|
||||
$ap['actor'] = $ourUrl . '/' . $actorName;
|
||||
if ($update) {
|
||||
$ap['id'] .= '#update';
|
||||
$ap['url'] .= '#update';
|
||||
}
|
||||
$ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
|
||||
|
||||
if (isset($jsonData['options'])) {
|
||||
if (isset($jsonData['options']['informFollowers'])) {
|
||||
if ($jsonData['options']['informFollowers'] === true) {
|
||||
$ap['to'][] = $ourUrl . '/' . $actorName . '/followers';
|
||||
}
|
||||
}
|
||||
}
|
||||
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
|
||||
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
|
||||
if ($returnActivity === false) {
|
||||
error_log("ContentNation::jsonToActivity couldn't create article");
|
||||
$returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create');
|
||||
} else {
|
||||
$returnActivity->setID($ap['id']);
|
||||
$returnActivity->setURL($ap['url']);
|
||||
}
|
||||
$articleId = $jsonData['object']['id']; // Set the article ID for the activity
|
||||
break;
|
||||
|
||||
case 'comment':
|
||||
$commentId = $jsonData['object']['id'] ?? null;
|
||||
$articleName = $jsonData['object']['articleName'] ?? null;
|
||||
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
|
||||
// Set Create-level fields
|
||||
$ap['published'] = $jsonData['object']['published'] ?? null;
|
||||
$ap['actor'] = $ourUrl . '/' . $actorName;
|
||||
$ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
|
||||
$ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
|
||||
$ap['type'] = 'Create';
|
||||
$ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
|
||||
if (isset($jsonData['options'])) {
|
||||
if (isset($jsonData['options']['informFollowers'])) {
|
||||
if ($jsonData['options']['informFollowers'] === true) {
|
||||
if ($actorName !== $articleOwnerName) {
|
||||
$ap['to'][] = $ourUrl . '/' . $articleOwnerName;
|
||||
}
|
||||
$ap['to'][] = $ourUrl . '/' . $actorName . '/followers';
|
||||
}
|
||||
}
|
||||
}
|
||||
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
|
||||
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
|
||||
if ($returnActivity === false) {
|
||||
error_log("ContentNation::jsonToActivity couldn't create comment");
|
||||
$returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create');
|
||||
} else {
|
||||
$returnActivity->setID($ap['id']);
|
||||
$returnActivity->setURL($ap['url']);
|
||||
}
|
||||
$articleId = $jsonData['object']['articleId']; // Set the article ID for the activity
|
||||
break;
|
||||
|
||||
case 'vote':
|
||||
$articleName = $jsonData['object']['articleName'] ?? null;
|
||||
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
|
||||
$voteId = $jsonData['object']['id'] ?? null;
|
||||
$ap['published'] = $jsonData['object']['published'] ?? null;
|
||||
$ap['actor'] = $ourUrl . '/' . $actorName;
|
||||
$ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId;
|
||||
$ap['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId;
|
||||
if ($jsonData['object']['vote']['value'] == 1) {
|
||||
$ap['type'] = 'Like';
|
||||
} elseif ($jsonData['object']['vote']['value'] == 0) {
|
||||
$ap['type'] = 'Dislike';
|
||||
} else {
|
||||
error_log("ContentNation::jsonToActivity unknown vote value: {$jsonData['object']['vote']['value']}");
|
||||
break;
|
||||
}
|
||||
|
||||
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
|
||||
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
|
||||
if ($returnActivity === false) {
|
||||
error_log("ContentNation::jsonToActivity couldn't create vote");
|
||||
if ($ap['type'] === "Like") {
|
||||
$returnActivity = new \Federator\Data\ActivityPub\Common\Like();
|
||||
} elseif ($ap['type'] === "Dislike") {
|
||||
$returnActivity = new \Federator\Data\ActivityPub\Common\Dislike();
|
||||
} else {
|
||||
$returnActivity = new \Federator\Data\ActivityPub\Common\Undo();
|
||||
}
|
||||
} else {
|
||||
$returnActivity->setID($ap['id']);
|
||||
$returnActivity->setURL($ap['url']);
|
||||
}
|
||||
$articleId = $jsonData['object']['articleId']; // Set the article ID for the activity
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle unsupported types or fallback to default behavior
|
||||
throw new \InvalidArgumentException("ContentNation::jsonToActivity Unsupported object type: {$jsonData['type']}");
|
||||
}
|
||||
}
|
||||
|
||||
return $returnActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert jsonData to Activity format
|
||||
*
|
||||
* @param string $ourUrl the url of our instance
|
||||
* @param array<string, mixed> $jsonData the json data from our platfrom
|
||||
* @return array|string|false the json object data or false
|
||||
*/
|
||||
private static function generateObjectJson($ourUrl, $jsonData)
|
||||
{
|
||||
$objectType = $jsonData['object']['type'] ?? null;
|
||||
$actorData = $jsonData['actor'] ?? null;
|
||||
$actorName = $actorData['name'] ?? null;
|
||||
|
||||
$actorUrl = $ourUrl . '/' . $actorName;
|
||||
|
||||
if ($objectType === "article") {
|
||||
$articleName = $jsonData['object']['name'] ?? null;
|
||||
$articleOwnerName = $jsonData['object']['ownerName'] ?? null;
|
||||
$updatedOn = $jsonData['object']['modified'] ?? null;
|
||||
$originalPublished = $jsonData['object']['published'] ?? null;
|
||||
$update = $updatedOn !== $originalPublished;
|
||||
$returnJson = [
|
||||
'type' => 'Article',
|
||||
'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName,
|
||||
'name' => $jsonData['object']['title'] ?? null,
|
||||
'published' => $originalPublished,
|
||||
'summary' => $jsonData['object']['summary'] ?? null,
|
||||
'content' => $jsonData['object']['content'] ?? null,
|
||||
'attributedTo' => $actorUrl,
|
||||
'url' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName,
|
||||
'cc' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
];
|
||||
if ($update) {
|
||||
$returnJson['updated'] = $updatedOn;
|
||||
}
|
||||
if (isset($jsonData['object']['tags'])) {
|
||||
if (is_array($jsonData['object']['tags'])) {
|
||||
foreach ($jsonData['object']['tags'] as $tag) {
|
||||
$returnJson['tags'][] = $tag;
|
||||
}
|
||||
} elseif (is_string($jsonData['object']['tags']) && $jsonData['object']['tags'] !== '') {
|
||||
// If it's a single tag as a string, add it as a one-element array
|
||||
$returnJson['tags'][] = $jsonData['object']['tags'];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($jsonData['options'])) {
|
||||
if (isset($jsonData['options']['informFollowers'])) {
|
||||
if ($jsonData['options']['informFollowers'] === true) {
|
||||
$returnJson['to'][] = $ourUrl . '/' . $actorName . '/followers';
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($objectType === "comment") {
|
||||
$commentId = $jsonData['object']['id'] ?? null;
|
||||
$articleName = $jsonData['object']['articleName'] ?? null;
|
||||
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
|
||||
$returnJson = [
|
||||
'type' => 'Note',
|
||||
'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId,
|
||||
'url' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId,
|
||||
'attributedTo' => $actorUrl,
|
||||
'content' => $jsonData['object']['content'] ?? null,
|
||||
'summary' => $jsonData['object']['summary'] ?? null,
|
||||
'published' => $jsonData['object']['published'] ?? null,
|
||||
'cc' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
];
|
||||
if (isset($jsonData['options'])) {
|
||||
if (isset($jsonData['options']['informFollowers'])) {
|
||||
if ($jsonData['options']['informFollowers'] === true) {
|
||||
$returnJson['to'][] = $ourUrl . '/' . $actorName . '/followers';
|
||||
}
|
||||
}
|
||||
}
|
||||
$replyType = $jsonData['object']['inReplyTo']['type'] ?? null;
|
||||
if ($replyType === "article") {
|
||||
$returnJson['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
|
||||
} elseif ($replyType === "comment") {
|
||||
$returnJson['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $jsonData['object']['inReplyTo']['id'];
|
||||
} else {
|
||||
error_log("ContentNation::generateObjectJson for comment - unknown inReplyTo type: {$replyType}");
|
||||
}
|
||||
} elseif ($objectType === "vote") {
|
||||
$votedOn = $jsonData['object']['type'] ?? null;
|
||||
$articleName = $jsonData['object']['articleName'] ?? null;
|
||||
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
|
||||
$objectId = $ourUrl . '/' . $articleOwnerName . '/' . $articleName;
|
||||
if ($votedOn === "comment") {
|
||||
$objectId .= '#' . $jsonData['object']['commentId'];
|
||||
}
|
||||
|
||||
$returnJson = $objectId;
|
||||
} else {
|
||||
error_log("ContentNation::generateObjectJson unknown object type: {$objectType}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return $returnJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* send CN-friendly json from ActivityPub activity
|
||||
*
|
||||
* @param \Federator\Data\FedUser $sender the user of the sender
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity
|
||||
* @return boolean did we successfully send the activity?
|
||||
*/
|
||||
public function sendActivity($sender, $activity)
|
||||
{
|
||||
$targetUrl = $this->service;
|
||||
// Convert ActivityPub activity to ContentNation JSON format and retrieve target url
|
||||
$jsonData = self::activityToJson($this->main->getDatabase(), $this->service, $activity, $targetUrl);
|
||||
|
||||
if ($jsonData === false) {
|
||||
error_log("ContentNation::sendActivity failed to convert activity to JSON");
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = json_encode($jsonData, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
throw new \Exception('Failed to encode JSON: ' . json_last_error_msg());
|
||||
}
|
||||
$digest = 'SHA-256=' . base64_encode(hash('sha256', $json, true));
|
||||
$date = gmdate('D, d M Y H:i:s') . ' GMT';
|
||||
$parsed = parse_url($targetUrl);
|
||||
if ($parsed === false) {
|
||||
throw new \Exception('Failed to parse URL: ' . $targetUrl);
|
||||
}
|
||||
|
||||
if (!isset($parsed['host']) || !isset($parsed['path'])) {
|
||||
throw new \Exception('Invalid target URL: missing host or path');
|
||||
}
|
||||
$extHost = $parsed['host'];
|
||||
$path = $parsed['path'];
|
||||
|
||||
// Build the signature string
|
||||
$signatureString = "(request-target): post {$path}\n" .
|
||||
"host: {$extHost}\n" .
|
||||
"date: {$date}\n" .
|
||||
"digest: {$digest}";
|
||||
|
||||
$pKeyPath = PROJECT_ROOT . '/' . $this->main->getConfig()['keys']['federatorPrivateKeyPath'];
|
||||
$privateKeyPem = file_get_contents($pKeyPath);
|
||||
if ($privateKeyPem === false) {
|
||||
http_response_code(500);
|
||||
throw new \Federator\Exceptions\PermissionDenied("Private key couldn't be determined");
|
||||
}
|
||||
|
||||
$pkeyId = openssl_pkey_get_private($privateKeyPem);
|
||||
|
||||
if ($pkeyId === false) {
|
||||
throw new \Exception('Invalid private key');
|
||||
}
|
||||
|
||||
openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256);
|
||||
$signature_b64 = base64_encode($signature);
|
||||
|
||||
$signatureHeader = 'algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"';
|
||||
|
||||
$ch = curl_init($targetUrl);
|
||||
if ($ch === false) {
|
||||
throw new \Exception('Failed to initialize cURL');
|
||||
}
|
||||
$headers = [
|
||||
'Host: ' . $extHost,
|
||||
'Date: ' . $date,
|
||||
'Digest: ' . $digest,
|
||||
'Content-Type: application/json',
|
||||
'Signature: ' . $signatureHeader,
|
||||
'Accept: application/json',
|
||||
'Username: ' . 'ap:' . $sender->id,
|
||||
];
|
||||
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new \Exception("Failed to send activity: " . curl_error($ch));
|
||||
} else {
|
||||
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($httpcode != 200 && $httpcode != 202) {
|
||||
throw new \Exception("Unexpected HTTP code $httpcode: $response");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ActivityPub activity to ContentNation JSON format
|
||||
*
|
||||
* @param \mysqli $dbh database handle
|
||||
* @param string $serviceUrl the service URL
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity
|
||||
* @param string $targetUrl the target URL for the activity
|
||||
* @return array<string, mixed>|false the json data or false on failure
|
||||
*/
|
||||
private static function activityToJson($dbh, $serviceUrl, \Federator\Data\ActivityPub\Common\Activity $activity, string &$targetUrl)
|
||||
{
|
||||
$type = strtolower($activity->getType());
|
||||
switch ($type) {
|
||||
case 'create':
|
||||
case 'update':
|
||||
$object = $activity->getObject();
|
||||
if (is_object($object)) {
|
||||
$objType = strtolower($object->getType());
|
||||
$articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity);
|
||||
if ($articleId === null) {
|
||||
error_log("ContentNation::activityToJson Failed to get original article ID for create/update activity");
|
||||
}
|
||||
switch ($objType) {
|
||||
case 'article':
|
||||
// We don't support article create/update at this point in time
|
||||
error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}");
|
||||
break;
|
||||
case 'note':
|
||||
$targetUrl = $serviceUrl . '/api/article/' . $articleId . '/comment';
|
||||
$type = 'comment';
|
||||
$inReplyTo = $object->getInReplyTo();
|
||||
if ($inReplyTo !== '') {
|
||||
$target = $inReplyTo;
|
||||
} else {
|
||||
$target = $object->getObject();
|
||||
}
|
||||
$comment = null;
|
||||
if (is_string($target)) {
|
||||
if (strpos($target, '#') !== false) {
|
||||
$parts = explode('#', $target);
|
||||
if (count($parts) > 0) {
|
||||
$comment = $parts[count($parts) - 1];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error_log("ContentNation::activityToJson Unsupported target type for comment with id: " . $activity->getID() . " Type: " . gettype($target));
|
||||
return false;
|
||||
}
|
||||
return [
|
||||
'type' => $type,
|
||||
'id' => $activity->getID(),
|
||||
'parent' => $comment,
|
||||
'subject' => $object->getSummary(),
|
||||
'comment' => $object->getContent(),
|
||||
];
|
||||
default:
|
||||
error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'like':
|
||||
case 'dislike':
|
||||
$articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity);
|
||||
if ($articleId === null) {
|
||||
error_log("ContentNation::activityToJson Failed to get original article ID for vote activity");
|
||||
}
|
||||
$voteValue = $type === 'like' ? true : false;
|
||||
$activityType = 'vote';
|
||||
$inReplyTo = $activity->getInReplyTo();
|
||||
if ($inReplyTo !== '') {
|
||||
$target = $inReplyTo;
|
||||
} else {
|
||||
$target = $activity->getObject();
|
||||
}
|
||||
$comment = null;
|
||||
if (is_string($target)) {
|
||||
if (strpos($target, '#') !== false) {
|
||||
$parts = explode('#', $target);
|
||||
if (count($parts) > 0) {
|
||||
$comment = $parts[count($parts) - 1];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error_log("ContentNation::activityToJson Unsupported target type for vote with id: " . $activity->getID() . " Type: " . gettype($target));
|
||||
return false;
|
||||
}
|
||||
$targetUrl = $serviceUrl . '/api/article/' . $articleId . '/vote';
|
||||
return [
|
||||
'vote' => $voteValue,
|
||||
'type' => $activityType,
|
||||
'id' => $activity->getID(),
|
||||
'comment' => $comment,
|
||||
];
|
||||
|
||||
case 'undo':
|
||||
$object = $activity->getObject();
|
||||
if (is_object($object)) {
|
||||
$objType = strtolower($object->getType());
|
||||
switch ($objType) {
|
||||
case 'like':
|
||||
case 'dislike':
|
||||
$articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity);
|
||||
if ($articleId === null) {
|
||||
error_log("ContentNation::activityToJson Failed to get original article ID for undo vote activity");
|
||||
}
|
||||
$activityType = 'vote';
|
||||
$inReplyTo = $object->getInReplyTo();
|
||||
if ($inReplyTo !== '') {
|
||||
$target = $inReplyTo;
|
||||
} else {
|
||||
$target = $object->getObject();
|
||||
}
|
||||
$comment = null;
|
||||
if (is_string($target)) {
|
||||
if (strpos($target, '#') !== false) {
|
||||
$parts = explode('#', $target);
|
||||
if (count($parts) > 0) {
|
||||
$comment = $parts[count($parts) - 1];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error_log("ContentNation::activityToJson Unsupported target type for undo vote with id: " . $activity->getID() . " Type: " . gettype($target));
|
||||
return false;
|
||||
}
|
||||
$targetUrl = $serviceUrl . '/api/article/' . $articleId . '/vote';
|
||||
return [
|
||||
'vote' => null,
|
||||
'type' => $activityType,
|
||||
'id' => $object->getID(),
|
||||
'comment' => $comment,
|
||||
];
|
||||
case 'note':
|
||||
// We don't support comment deletions at this point in time
|
||||
error_log("ContentNation::activityToJson Unsupported undo object type: {$objType}");
|
||||
break;
|
||||
default:
|
||||
error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
error_log("ContentNation::activityToJson Unsupported activity type: {$type}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the headers include a valid signature
|
||||
*
|
||||
* @param string[] $headers the headers
|
||||
* @throws \Federator\Exceptions\PermissionDenied
|
||||
* @return string|\Federator\Exceptions\PermissionDenied
|
||||
*/
|
||||
public function checkSignature($headers)
|
||||
{
|
||||
$signatureHeader = $headers['Signature'] ?? null;
|
||||
|
||||
if (!isset($signatureHeader)) {
|
||||
throw new \Federator\Exceptions\PermissionDenied("Missing Signature header");
|
||||
}
|
||||
|
||||
if (!isset($headers['X-Sender']) || $headers['X-Sender'] !== $this->config['keys']['headerSenderName']) {
|
||||
throw new \Federator\Exceptions\PermissionDenied("Invalid sender name");
|
||||
}
|
||||
|
||||
// Parse Signature header
|
||||
preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches);
|
||||
$signatureParts = array_combine($matches[1], $matches[2]);
|
||||
|
||||
$signature = base64_decode($signatureParts['signature']);
|
||||
$signedHeaders = explode(' ', $signatureParts['headers']);
|
||||
|
||||
$pKeyPath = PROJECT_ROOT . '/' . $this->config['keys']['publicKeyPath'];
|
||||
$publicKeyPem = file_get_contents($pKeyPath);
|
||||
if ($publicKeyPem === false) {
|
||||
http_response_code(500);
|
||||
throw new \Federator\Exceptions\PermissionDenied("Public key couldn't be determined");
|
||||
}
|
||||
|
||||
// Reconstruct the signed string
|
||||
$signedString = '';
|
||||
foreach ($signedHeaders as $header) {
|
||||
if ($header === '(request-target)') {
|
||||
$method = strtolower($_SERVER['REQUEST_METHOD']);
|
||||
$path = $_SERVER['REQUEST_URI'];
|
||||
$headerValue = "$method $path";
|
||||
} else {
|
||||
$headerValue = $headers[ucwords($header, '-')] ?? '';
|
||||
}
|
||||
|
||||
$signedString .= strtolower($header) . ": " . $headerValue . "\n";
|
||||
}
|
||||
|
||||
$signedString = rtrim($signedString);
|
||||
|
||||
// Verify the signature
|
||||
$pubkeyRes = openssl_pkey_get_public($publicKeyPem);
|
||||
$verified = false;
|
||||
if ($pubkeyRes instanceof \OpenSSLAsymmetricKey && is_string($signature)) {
|
||||
$verified = openssl_verify($signedString, $signature, $pubkeyRes, OPENSSL_ALGO_SHA256);
|
||||
}
|
||||
if ($verified != 1) {
|
||||
http_response_code(500);
|
||||
throw new \Federator\Exceptions\PermissionDenied("Signature verification failed");
|
||||
}
|
||||
return "Signature verified.";
|
||||
}
|
||||
}
|
||||
|
||||
namespace Federator;
|
||||
|
@ -299,5 +982,6 @@ namespace Federator;
|
|||
function contentnation_load($main)
|
||||
{
|
||||
$cn = new Connector\ContentNation($main);
|
||||
# echo "contentnation::contentnation_load Loaded new connector, adding to main\n"; // TODO change to proper log
|
||||
$main->setConnector($cn);
|
||||
}
|
||||
|
|
|
@ -19,15 +19,38 @@ class DummyConnector implements Connector
|
|||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* get followers of given user
|
||||
*
|
||||
* @param string $userId user id @unused-param
|
||||
* @return \Federator\Data\FedUser[]|false
|
||||
*/
|
||||
public function getRemoteFollowersOfUser($userId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* get following of given user
|
||||
*
|
||||
* @param string $id user id @unused-param
|
||||
|
||||
* @return \Federator\Data\FedUser[]|false
|
||||
*/
|
||||
public function getRemoteFollowingForUser($id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* get posts by given user
|
||||
*
|
||||
* @param string $id user id @unused-param
|
||||
* @param string $minId min ID @unused-param
|
||||
* @param string $maxId max ID @unused-param
|
||||
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
|
||||
* @param string $min min date @unused-param
|
||||
* @param string $max max date @unused-param
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
|
||||
*/
|
||||
public function getRemotePostsByUser($id, $minId, $maxId)
|
||||
public function getRemotePostsByUser($id, $min, $max)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
@ -46,6 +69,18 @@ class DummyConnector implements Connector
|
|||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert jsonData to Activity format
|
||||
*
|
||||
* @param array<string, mixed> $jsonData the json data from our platfrom @unused-param
|
||||
* @param string $articleId the original id of the article (if applicable)
|
||||
* (used to identify the article in the remote system) @unused-param
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity|false
|
||||
*/
|
||||
public function jsonToActivity(array $jsonData, &$articleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* get remote user by name
|
||||
* @param string $_name user or profile name
|
||||
|
@ -70,10 +105,34 @@ class DummyConnector implements Connector
|
|||
// validate $_session and $user
|
||||
$user = new \Federator\Data\User();
|
||||
$user->externalid = $_user;
|
||||
$user->permissions = ['PUBLISH'];
|
||||
$user->permissions = ['publish'];
|
||||
$user->session = $_session;
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* send target-friendly json from ActivityPub activity
|
||||
*
|
||||
* @param \Federator\Data\FedUser $sender the user of the sender @unused-param
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity @unused-param
|
||||
* @return boolean did we successfully send the activity?
|
||||
*/
|
||||
public function sendActivity($sender, $activity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the headers include a valid signature
|
||||
*
|
||||
* @param string[] $headers the headers @unused-param
|
||||
* @throws \Federator\Exceptions\PermissionDenied
|
||||
* @return string|\Federator\Exceptions\PermissionDenied
|
||||
*/
|
||||
public function checkSignature($headers)
|
||||
{
|
||||
return new \Federator\Exceptions\PermissionDenied("Dummy connector: no signature check");
|
||||
}
|
||||
}
|
||||
|
||||
namespace Federator;
|
||||
|
@ -87,5 +146,6 @@ namespace Federator;
|
|||
function dummy_load($main)
|
||||
{
|
||||
$dummy = new Connector\DummyConnector();
|
||||
# echo "dummyconnector::dummy_load Loaded new connector, adding to main\n"; // TODO change to proper log
|
||||
$main->setConnector($dummy);
|
||||
}
|
||||
|
|
|
@ -41,15 +41,23 @@ class RedisCache implements Cache
|
|||
*/
|
||||
private $userTTL;
|
||||
|
||||
/**
|
||||
* public key cache time to live in secods
|
||||
*
|
||||
* @var int $publicKeyPemTTL
|
||||
*/
|
||||
private $publicKeyPemTTL;
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$config = parse_ini_file('../rediscache.ini');
|
||||
$config = parse_ini_file(PROJECT_ROOT . '/rediscache.ini');
|
||||
if ($config !== false) {
|
||||
$this->config = $config;
|
||||
$this->userTTL = array_key_exists('userttl', $config) ? intval($config['userttl'], 10) : 60;
|
||||
$this->publicKeyPemTTL = array_key_exists('publickeypemttl', $config) ? intval($config['publickeypemttl'], 10) : 3600;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -59,10 +67,20 @@ class RedisCache implements Cache
|
|||
*/
|
||||
private function connect()
|
||||
{
|
||||
$this->redis = new \Redis();
|
||||
$this->redis->pconnect($this->config['host'], intval($this->config['port'], 10));
|
||||
// @phan-suppress-next-line PhanTypeMismatchArgumentInternalProbablyReal
|
||||
$this->redis->auth([$this->config['username'], $this->config['password']]);
|
||||
$this->redis = new \Redis();
|
||||
$this->redis->pconnect($this->config['host'], intval($this->config['port'], 10));
|
||||
// @phan-suppress-next-line PhanTypeMismatchArgumentInternalProbablyReal
|
||||
$this->redis->auth([$this->config['username'], $this->config['password']]);
|
||||
|
||||
// Set the Redis backend for Resque
|
||||
$redisUrl = sprintf(
|
||||
'redis://%s:%s@%s:%d',
|
||||
urlencode($this->config['username']),
|
||||
urlencode($this->config['password']),
|
||||
$this->config['host'],
|
||||
intval($this->config['port'], 10)
|
||||
);
|
||||
\Resque::setBackend($redisUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -77,16 +95,56 @@ class RedisCache implements Cache
|
|||
return $prefix . '_' . md5($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* get followers of given user
|
||||
*
|
||||
* @param string $id user id @unused-param
|
||||
|
||||
* @return \Federator\Data\FedUser[]|false
|
||||
*/
|
||||
public function getRemoteFollowersOfUser($id)
|
||||
{
|
||||
error_log("rediscache::getRemoteFollowersOfUser not implemented");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* get following of given user
|
||||
*
|
||||
* @param string $id user id @unused-param
|
||||
|
||||
* @return \Federator\Data\FedUser[]|false
|
||||
*/
|
||||
public function getRemoteFollowingForUser($id)
|
||||
{
|
||||
error_log("rediscache::getRemoteFollowingForUser not implemented");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert jsonData to Activity format
|
||||
*
|
||||
* @param array<string, mixed> $jsonData the json data from our platfrom @unused-param
|
||||
* @param string $articleId the original id of the article (if applicable)
|
||||
* (used to identify the article in the remote system) @unused-param
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity|false
|
||||
*/
|
||||
public function jsonToActivity(array $jsonData, &$articleId)
|
||||
{
|
||||
error_log("rediscache::jsonToActivity not implemented");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* get posts by given user
|
||||
*
|
||||
* @param string $id user id @unused-param
|
||||
* @param string $minId min ID @unused-param
|
||||
* @param string $maxId max ID @unused-param
|
||||
* @param string $min min date @unused-param
|
||||
* @param string $max max date @unused-param
|
||||
|
||||
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
|
||||
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
|
||||
*/
|
||||
public function getRemotePostsByUser($id, $minId, $maxId)
|
||||
public function getRemotePostsByUser($id, $min, $max)
|
||||
{
|
||||
error_log("rediscache::getRemotePostsByUser not implemented");
|
||||
return false;
|
||||
|
@ -131,6 +189,26 @@ class RedisCache implements Cache
|
|||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* get remote federation user by given name
|
||||
*
|
||||
* @param string $_name user/profile name
|
||||
* @return \Federator\Data\FedUser | false
|
||||
*/
|
||||
public function getRemoteFedUserByName(string $_name)
|
||||
{
|
||||
if (!$this->connected) {
|
||||
$this->connect();
|
||||
}
|
||||
$key = self::createKey('u', $_name);
|
||||
$data = $this->redis->get($key);
|
||||
if ($data === false) {
|
||||
return false;
|
||||
}
|
||||
$user = \Federator\Data\FedUser::createFromJson($data);
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* get remote user by given session
|
||||
*
|
||||
|
@ -152,6 +230,45 @@ class RedisCache implements Cache
|
|||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the public key for a given keyId
|
||||
*
|
||||
* @param string $keyId The keyId (e.g., actor URL + #main-key)
|
||||
* @return string|false The cached public key PEM or false if not found
|
||||
*/
|
||||
public function getPublicKey(string $keyId)
|
||||
{
|
||||
if (!$this->connected) {
|
||||
$this->connect();
|
||||
}
|
||||
$key = self::createKey('publickey', $keyId);
|
||||
return $this->redis->get($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* save remote followers by user
|
||||
*
|
||||
* @param string $user user name @unused-param
|
||||
* @param \Federator\Data\FedUser[]|false $followers user followers @unused-param
|
||||
* @return void
|
||||
*/
|
||||
public function saveRemoteFollowersOfUser($user, $followers)
|
||||
{
|
||||
error_log("rediscache::saveRemoteFollowersOfUser not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* save remote following for user
|
||||
*
|
||||
* @param string $user user name @unused-param
|
||||
* @param \Federator\Data\FedUser[]|false $following user following @unused-param
|
||||
* @return void
|
||||
*/
|
||||
public function saveRemoteFollowingForUser($user, $following)
|
||||
{
|
||||
error_log("rediscache::saveRemoteFollowingForUser not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* save remote posts by user
|
||||
*
|
||||
|
@ -193,6 +310,20 @@ class RedisCache implements Cache
|
|||
$this->redis->setEx($key, $this->userTTL, $serialized);
|
||||
}
|
||||
|
||||
/**
|
||||
* save remote federation user by given name
|
||||
*
|
||||
* @param string $_name user/profile name
|
||||
* @param \Federator\Data\FedUser $user user data
|
||||
* @return void
|
||||
*/
|
||||
public function saveRemoteFedUserByName(string $_name, \Federator\Data\FedUser $user)
|
||||
{
|
||||
$key = self::createKey('u', $_name);
|
||||
$serialized = $user->toJson();
|
||||
$this->redis->setEx($key, $this->userTTL, $serialized);
|
||||
}
|
||||
|
||||
/**
|
||||
* save remote user by given session
|
||||
*
|
||||
|
@ -205,7 +336,47 @@ class RedisCache implements Cache
|
|||
{
|
||||
$key = self::createKey('s', $_session . $_user);
|
||||
$serialized = $user->toJson();
|
||||
$this->redis->setEx($key, $this->userTTL, $serialized,);
|
||||
$this->redis->setEx($key, $this->userTTL, $serialized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the public key for a given keyId
|
||||
*
|
||||
* @param string $keyId The keyId (e.g., actor URL + #main-key)
|
||||
* @param string $publicKeyPem The public key PEM to cache
|
||||
* @return void
|
||||
*/
|
||||
public function savePublicKey(string $keyId, string $publicKeyPem)
|
||||
{
|
||||
if (!$this->connected) {
|
||||
$this->connect();
|
||||
}
|
||||
$key = self::createKey('publickey', $keyId);
|
||||
$this->redis->setEx($key, $this->publicKeyPemTTL, $publicKeyPem);
|
||||
}
|
||||
|
||||
/**
|
||||
* send target-friendly json from ActivityPub activity
|
||||
*
|
||||
* @param \Federator\Data\FedUser $sender the user of the sender @unused-param
|
||||
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity @unused-param
|
||||
* @return boolean did we successfully send the activity?
|
||||
*/
|
||||
public function sendActivity($sender, $activity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the headers include a valid signature
|
||||
*
|
||||
* @param string[] $headers the headers @unused-param
|
||||
* @throws \Federator\Exceptions\PermissionDenied
|
||||
* @return string|\Federator\Exceptions\PermissionDenied
|
||||
*/
|
||||
public function checkSignature($headers)
|
||||
{
|
||||
return new \Federator\Exceptions\PermissionDenied("RedisCache: no signature check");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,4 +4,5 @@ port = 6379
|
|||
username = federator
|
||||
password = redis*change*password
|
||||
userttl = 10
|
||||
publickeypemttl = 3600
|
||||
statsttl = 60
|
||||
|
|
3
sql/2025-05-06.sql
Normal file
3
sql/2025-05-06.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
create table follows(`id` varchar(255) unique primary key,`source_user` varchar(255) not null,`target_user` varchar(255) not null,`created_at` timestamp default current_timestamp,unique key `unique_follow` (`source_user`, `target_user`));
|
||||
create table fedusers(`id` varchar(255) unique primary key,`url` varchar(255) not null,`name` varchar(255) default '',`publickey` text default '',`summary` text default '',`validuntil` timestamp null default null,`type` enum('person', 'group') default 'person',`inboxurl` varchar(255) default null,`sharedinboxurl` varchar(255) default null,`followersurl` varchar(255) default null,`followingurl` varchar(255) default null,`publickeyid` varchar(255) default null,`outboxurl` varchar(255) default null,unique key `unique_feduser` (`url`),unique key `unique_feduser_id` (`url`));
|
||||
update settings set `value`="2025-05-06" where `key`="database_version";
|
2
sql/2025-05-19.sql
Normal file
2
sql/2025-05-19.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
create table posts(`id` varchar(255) primary key,`url` varchar(255) not null,`user_id` varchar(255) not null,`actor` varchar(255) not null,`type` varchar(255) not null default 'note',`object` text default null,`to` text default null,`cc` text default null,`published` timestamp not null default current_timestamp);
|
||||
update settings set `value`="2025-05-19" where `key`="database_version";
|
2
sql/2025-05-27.sql
Normal file
2
sql/2025-05-27.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
alter table posts add `article_id` varchar(255) null default null comment 'The optional original article id (of non-federated system, e.g. CN)';
|
||||
update settings set `value`="2025-05-27" where `key`="database_version";
|
|
@ -1,4 +1,4 @@
|
|||
version="1.0" encoding="UTF-8"?>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
||||
<Link rel="lrdd" template="https://{$fqdn}/.well-known/webfinger?resource={ldelim}uri{rdelim}"/>
|
||||
</XRD>
|
||||
|
|
|
@ -58,14 +58,14 @@
|
|||
{rdelim}
|
||||
{rdelim}
|
||||
],
|
||||
"id":"https://{$fqdn}/users/{$username}",
|
||||
"id":"https://{$fqdn}/{$username}",
|
||||
"type":"{$type}",
|
||||
"following":"https://{$fqdn}/users/{$username}/following",
|
||||
"followers":"https://{$fqdn}/users/{$username}/followers",
|
||||
"inbox":"https://{$fqdn}/users/{$username}/inbox",
|
||||
"outbox":"https://{$fqdn}/users/{$username}/outbox",
|
||||
"featured":"https://{$fqdn}/users/{$username}/collections/featured",
|
||||
"featuredTags":"https://{$fqdn}/users/{$username}/collections/tags",
|
||||
"following":"https://{$fqdn}/{$username}/following",
|
||||
"followers":"https://{$fqdn}/{$username}/followers",
|
||||
"inbox":"https://{$fqdn}/{$username}/inbox",
|
||||
"outbox":"https://{$fqdn}/{$username}/outbox",
|
||||
"featured":"https://{$fqdn}/{$username}/collections/featured",
|
||||
"featuredTags":"https://{$fqdn}/{$username}/collections/tags",
|
||||
"preferredUsername":"{$username}",
|
||||
"name":"{$name}",
|
||||
"summary":"{$summary}",
|
||||
|
@ -74,8 +74,8 @@
|
|||
"discoverable":true,
|
||||
"published":"{$registered}",
|
||||
"publicKey":{ldelim}
|
||||
"id":"https://{$fqdn}/users/{$username}#main-key",
|
||||
"owner":"https://{$fqdn}/users/{$username}",
|
||||
"id":"https://{$fqdn}/{$username}#main-key",
|
||||
"owner":"https://{$fqdn}/{$username}",
|
||||
"publicKeyPem":"{$publickey}"
|
||||
{rdelim},
|
||||
"tag":[],
|
||||
|
|
Loading…
Add table
Reference in a new issue