forked from grumpydevelop/federator

- also integrated better support for newContent types - Integrated saving posts to database (posts gotten via outbox request as well as posts received in the NewContent endpoint) - proper support for handling comments - support for likes/dislikes - support for requesting followers / following endpoints - better inbox support database needs changes, don't forget to run migration
397 lines
13 KiB
PHP
397 lines
13 KiB
PHP
<?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();
|
|
}
|
|
$stmt->bind_param("s", $id);
|
|
$stmt->execute();
|
|
$followerIds = [];
|
|
$stmt->bind_result($sourceUser);
|
|
while ($stmt->fetch()) {
|
|
$followerIds[] = $sourceUser;
|
|
}
|
|
$stmt->close();
|
|
foreach ($followerIds as $followerId) {
|
|
$user = \Federator\DIO\FedUser::getUserByName(
|
|
$dbh,
|
|
$followerId,
|
|
$cache,
|
|
);
|
|
if ($user !== false && $user->id !== null) {
|
|
$followers[] = $user;
|
|
}
|
|
}
|
|
|
|
if ($followers === []) {
|
|
// ask connector for user-id
|
|
$followers = $connector->getRemoteFollowersOfUser($id);
|
|
if ($followers === false) {
|
|
$followers = [];
|
|
}
|
|
}
|
|
// save posts to DB
|
|
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();
|
|
}
|
|
$stmt->bind_param("s", $id);
|
|
$stmt->execute();
|
|
$followingIds = [];
|
|
$stmt->bind_result($sourceUser);
|
|
while ($stmt->fetch()) {
|
|
$followingIds[] = $sourceUser;
|
|
}
|
|
$stmt->close();
|
|
foreach ($followingIds as $followingId) {
|
|
$user = \Federator\DIO\FedUser::getUserByName(
|
|
$dbh,
|
|
$followingId,
|
|
$cache,
|
|
);
|
|
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();
|
|
}
|
|
$stmt->bind_param("s", $id);
|
|
$stmt->execute();
|
|
$followerIds = [];
|
|
$stmt->bind_result($sourceUser);
|
|
while ($stmt->fetch()) {
|
|
$followerIds[] = $sourceUser;
|
|
}
|
|
foreach ($followerIds as $followerId) {
|
|
$user = \Federator\DIO\User::getUserByName(
|
|
$dbh,
|
|
$followerId,
|
|
$connector,
|
|
$cache
|
|
);
|
|
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();
|
|
}
|
|
$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();
|
|
}
|
|
$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();
|
|
}
|
|
$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();
|
|
}
|
|
$stmt->bind_param("sss", $idurl, $sourceUser, $targetUserId);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
return $idurl; // Return the generated follow ID
|
|
}
|
|
|
|
/**
|
|
* remove follow
|
|
*
|
|
* @param \mysqli $dbh database handle
|
|
* @param string $sourceUser source user id
|
|
* @param string $targetUserId target user id
|
|
* @return bool true on success
|
|
*/
|
|
public static function removeFollow($dbh, $sourceUser, $targetUserId)
|
|
{
|
|
$sql = 'delete from follows where source_user = ? and target_user = ?';
|
|
$stmt = $dbh->prepare($sql);
|
|
if ($stmt === false) {
|
|
throw new \Federator\Exceptions\ServerError();
|
|
}
|
|
$stmt->bind_param("ss", $sourceUser, $targetUserId);
|
|
$stmt->execute();
|
|
$affectedRows = $stmt->affected_rows;
|
|
$stmt->close();
|
|
return $affectedRows > 0;
|
|
}
|
|
}
|