
- integrated functionality to actually send new content to federated recipients and followers (IT WORKS!!) - changed the way we remove a follow to return the removed followId (used in order to build the undo follow activity)
484 lines
17 KiB
PHP
484 lines
17 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("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
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|