diff --git a/php/federator/api/fedusers.php b/php/federator/api/fedusers.php index a6da348..75f6120 100644 --- a/php/federator/api/fedusers.php +++ b/php/federator/api/fedusers.php @@ -70,10 +70,10 @@ class FedUsers implements APIInterface // /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($this->main); diff --git a/php/federator/api/fedusers/followers.php b/php/federator/api/fedusers/followers.php new file mode 100644 index 0000000..cab059c --- /dev/null +++ b/php/federator/api/fedusers/followers.php @@ -0,0 +1,120 @@ +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; + } +} diff --git a/php/federator/api/fedusers/following.php b/php/federator/api/fedusers/following.php new file mode 100644 index 0000000..1d25f5d --- /dev/null +++ b/php/federator/api/fedusers/following.php @@ -0,0 +1,120 @@ +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; + } +} diff --git a/php/federator/api/fedusers/inbox.php b/php/federator/api/fedusers/inbox.php index 3c1ff50..869dcf3 100644 --- a/php/federator/api/fedusers/inbox.php +++ b/php/federator/api/fedusers/inbox.php @@ -148,22 +148,21 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface */ public static function postForUser($dbh, $connector, $cache, $_user, $inboxActivity) { - if (isset($_user)) { - // get user - $user = \Federator\DIO\User::getUserByName( - $dbh, - $_user, - $connector, - $cache - ); - if ($user === null || $user->id === null) { - throw new \Federator\Exceptions\ServerError("Inbox::postForUser couldn't find user: $_user"); - } - } else { - // Not a local user, nothing to do + if (!isset($_user)) { + error_log("Inbox::postForUser no user given"); return false; } + $user = \Federator\DIO\User::getUserByName( + $dbh, + $_user, + $connector, + $cache + ); + if ($user === null || $user->id === null) { + throw new \Federator\Exceptions\ServerError("Inbox::postForUser couldn't find user: $_user"); + } + $rootDir = PROJECT_ROOT . '/'; // Save the raw input and parsed JSON to a file for inspection file_put_contents( @@ -174,40 +173,119 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface $type = $inboxActivity->getType(); - if ($type === 'Follow') { - // Someone wants to follow our user - $actor = $inboxActivity->getAActor(); // The follower's actor URI - if ($actor !== '') { - // Extract follower username (you may need to adjust this logic) - $followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? '')); - $followerDomain = parse_url($actor, PHP_URL_HOST); - if (is_string($followerDomain)) { - $followerId = "{$followerUsername}@{$followerDomain}"; - - // Save the follow relationship - \Federator\DIO\Followers::addFollow($dbh, $followerId, $user->id, $followerDomain); - error_log("Inbox::postForUser: Added follower $followerId for user $user->id"); - } - } - } elseif ($type === 'Undo') { - // Check if this is an Undo of a Follow (i.e., Unfollow) - $object = $inboxActivity->getObject(); - if (is_object($object) && method_exists($object, 'getType') && $object->getType() === 'Follow') { - 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}"; - - // Remove the follow relationship - \Federator\DIO\Followers::removeFollow($dbh, $followerId, $user->id); - error_log("Inbox::postForUser: Removed follower $followerId for user $user->id"); - } + switch ($type) { + case 'Follow': + $success = false; + $actor = $inboxActivity->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}"; + $success = \Federator\DIO\Followers::addFollow($dbh, $followerId, $user->id, $followerDomain); } } - } + 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); + } + break; + + case 'Undo': + $object = $inboxActivity->getObject(); + if (is_object($object) && method_exists($object, 'getType')) { + switch ($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}"; + $success = \Federator\DIO\Followers::removeFollow($dbh, $followerId, $user->id); + } + } + } + if ($success === false) { + error_log("Inbox::postForUser: Failed to remove follower for user $user->id"); + } + break; + case 'Like': + // Undo Like (remove like) + if (method_exists($object, 'getObject')) { + $targetId = $object->getObject(); + if (is_string($targetId)) { + // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'like'); + \Federator\DIO\Posts::deletePost($dbh, $targetId); + } else { + error_log("Inbox::postForUser: Error in Undo Like for user $user->id, targetId is not a string"); + } + } + break; + case 'Dislike': + // Undo Dislike (remove dislike) + if (method_exists($object, 'getObject')) { + $targetId = $object->getObject(); + if (is_string($targetId)) { + // \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike'); + \Federator\DIO\Posts::deletePost($dbh, $targetId); + } else { + error_log("Inbox::postForUser: Error in Undo Dislike for user $user->id, targetId is not a string"); + } + } + break; + case 'Note': + // Undo Note (remove note) + if (method_exists($object, 'getID')) { + $noteId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $noteId); + } + break; + } + } + break; + + case 'Like': + // Add Like + $targetId = $inboxActivity->getObject(); + if (is_string($targetId)) { + // \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like'); + \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + } else { + error_log("Inbox::postForUser: Error in Add Like for user $user->id, targetId is not a string"); + return false; + } + break; + + case 'Dislike': + // Add 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 Dislike for user $user->id, targetId is not a string"); + return false; + } + break; + + case 'Note': + // Post Note + \Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity); + break; + + default: + error_log("Inbox::postForUser: Unhandled activity type $type for user $user->id"); + break; } return true; diff --git a/php/federator/api/v1/newcontent.php b/php/federator/api/v1/newcontent.php index 307e337..c3164fc 100644 --- a/php/federator/api/v1/newcontent.php +++ b/php/federator/api/v1/newcontent.php @@ -125,7 +125,9 @@ class NewContent implements \Federator\Api\APIInterface $users = []; - $followers = $this->fetchAllFollowers($dbh, $connector, $cache, $user); + if ($newActivity->getType() === 'Create') { + $followers = $this->fetchAllFollowers($dbh, $connector, $cache, $user); + } if (!empty($followers)) { $users = array_merge($users, $followers); } @@ -148,7 +150,11 @@ class NewContent implements \Federator\Api\APIInterface continue; } - $this->postForUser($dbh, $connector, $cache, $user, $newActivity); + $token = \Resque::enqueue('inbox', 'Federator\\Jobs\\NewContentJob', [ + 'user' => $user, + 'activity' => $newActivity->toObject(), + ]); + error_log("Inbox::post enqueued job for user: $user with token: $token"); } return json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); @@ -167,7 +173,7 @@ class NewContent implements \Federator\Api\APIInterface * @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received * @return boolean response */ - private static function postForUser($dbh, $connector, $cache, $_user, $newActivity) + public static function postForUser($dbh, $connector, $cache, $_user, $newActivity) { if (!isset($_user)) { error_log("NewContent::postForUser no user given"); @@ -181,7 +187,7 @@ class NewContent implements \Federator\Api\APIInterface $connector, $cache ); - if ($user->id === null) { + if ($user === null || $user->id === null) { error_log("NewContent::postForUser couldn't find user: $_user"); return false; } @@ -194,6 +200,127 @@ class NewContent implements \Federator\Api\APIInterface FILE_APPEND ); + $type = $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); + if (is_string($followerDomain)) { + $followerId = "{$followerUsername}@{$followerDomain}"; + $success = \Federator\DIO\Followers::addFollow($dbh, $followerId, $user->id, $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 ($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}"; + $success = \Federator\DIO\Followers::removeFollow($dbh, $followerId, $user->id); + } + } + } + if ($success === false) { + error_log("NewContent::postForUser: Failed to remove follower for user $user->id"); + } + break; + case 'Vote': + // Undo Vote (remove vote) + 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 Vote for user $user->id, targetId is not a string"); + } + } + break; + case 'Note': + // Undo Note (remove note) + if (method_exists($object, 'getID')) { + $noteId = $object->getID(); + \Federator\DIO\Posts::deletePost($dbh, $noteId); + } + break; + } + } else if (is_string($object)) { + \Federator\DIO\Posts::deletePost($dbh, $object); + } else { + error_log("NewContent::postForUser: Error in Undo for user $user->id, object is not a string or object"); + } + break; + + case 'Like': + // Add Like + $targetId = $newActivity->getObject(); + if (is_string($targetId)) { + // \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like'); + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); + } else { + error_log("NewContent::postForUser: Error in Add Like for user $user->id, targetId is not a string"); + return false; + } + break; + + case 'Dislike': + // Add Dislike + $targetId = $newActivity->getObject(); + if (is_string($targetId)) { + // \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'dislike'); + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); + } else { + error_log("NewContent::postForUser: Error in Add Dislike for user $user->id, targetId is not a string"); + return false; + } + break; + + case 'Create': + $object = $newActivity->getObject(); + if (is_object($object)) { + switch ($object->getType()) { + case 'Note': + case 'Article': + default: + \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); + break; + } + } + // Post Note + break; + + default: + error_log("NewContent::postForUser: Unhandled activity type $type for user $user->id"); + break; + } + return true; } diff --git a/php/federator/cache/cache.php b/php/federator/cache/cache.php index 3742b8c..4a979bf 100644 --- a/php/federator/cache/cache.php +++ b/php/federator/cache/cache.php @@ -22,6 +22,15 @@ interface Cache extends \Federator\Connector\Connector */ 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 * diff --git a/php/federator/connector/connector.php b/php/federator/connector/connector.php index 02ba83d..f02c02e 100644 --- a/php/federator/connector/connector.php +++ b/php/federator/connector/connector.php @@ -22,6 +22,15 @@ interface Connector */ 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 * @@ -29,7 +38,7 @@ interface Connector * @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, $min, $max); diff --git a/php/federator/data/activitypub/common/apobject.php b/php/federator/data/activitypub/common/apobject.php index 6d29cac..2ed32b3 100644 --- a/php/federator/data/activitypub/common/apobject.php +++ b/php/federator/data/activitypub/common/apobject.php @@ -347,7 +347,7 @@ class APObject implements \JsonSerializable /** * set child object * - * @param APObject $object + * @param APObject|string $object * @return void */ public function setObject($object) diff --git a/php/federator/data/activitypub/common/collection.php b/php/federator/data/activitypub/common/collection.php index 644b52e..d925f05 100644 --- a/php/federator/data/activitypub/common/collection.php +++ b/php/federator/data/activitypub/common/collection.php @@ -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; } diff --git a/php/federator/data/activitypub/common/dislike.php b/php/federator/data/activitypub/common/dislike.php new file mode 100644 index 0000000..2b185e9 --- /dev/null +++ b/php/federator/data/activitypub/common/dislike.php @@ -0,0 +1,18 @@ +items = $items; + $this->totalItems = sizeof($items); + } + + /** + * convert internal state to php array + * + * @return array + */ + public function toObject() + { + $return = parent::toObject(); + return $return; + } + + /** + * create object from json + * + * @param array $json input json + * @return bool true on success + */ + public function fromJson($json) + { + return parent::fromJson($json); + } +} diff --git a/php/federator/data/activitypub/common/following.php b/php/federator/data/activitypub/common/following.php new file mode 100644 index 0000000..6358ed8 --- /dev/null +++ b/php/federator/data/activitypub/common/following.php @@ -0,0 +1,53 @@ +items = $items; + $this->totalItems = sizeof($items); + } + + /** + * convert internal state to php array + * + * @return array + */ + public function toObject() + { + $return = parent::toObject(); + return $return; + } + + /** + * create object from json + * + * @param array $json input json + * @return bool true on success + */ + public function fromJson($json) + { + return parent::fromJson($json); + } +} diff --git a/php/federator/data/activitypub/common/like.php b/php/federator/data/activitypub/common/like.php new file mode 100644 index 0000000..a62b366 --- /dev/null +++ b/php/federator/data/activitypub/common/like.php @@ -0,0 +1,18 @@ +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) diff --git a/php/federator/data/activitypub/common/outbox.php b/php/federator/data/activitypub/common/outbox.php index 4519a8a..9c668c0 100644 --- a/php/federator/data/activitypub/common/outbox.php +++ b/php/federator/data/activitypub/common/outbox.php @@ -27,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 * diff --git a/php/federator/data/activitypub/common/vote.php b/php/federator/data/activitypub/common/vote.php new file mode 100644 index 0000000..f29ef1c --- /dev/null +++ b/php/federator/data/activitypub/common/vote.php @@ -0,0 +1,27 @@ +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 diff --git a/php/federator/dio/posts.php b/php/federator/dio/posts.php index 6e6486c..6b261aa 100644 --- a/php/federator/dio/posts.php +++ b/php/federator/dio/posts.php @@ -13,13 +13,12 @@ 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 @@ -29,31 +28,203 @@ class Posts * minimum date * @param string $max * maximum date - * @return \Federator\Data\ActivityPub\Common\APObject[] + * @return \Federator\Data\ActivityPub\Common\Activity[] */ - public static function getPostsByUser($dbh, $id, $connector, $cache, $min, $max) + public static function getPostsByUser($dbh, $userid, $connector, $cache, $min, $max) { // ask cache if ($cache !== null) { - $posts = $cache->getRemotePostsByUser($id, $min, $max); + $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, $min, $max); - 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; + } + } + } + // save posts to DB + foreach ($posts as $post) { + if ($post->getID() !== "") { + self::savePost($dbh, $userid, $post); + } + } + 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, user_id, type, object, published FROM posts WHERE user_id = ?'; + $params = [$userId]; + $types = 's'; + if ($min !== null) { + $sql .= ' AND published >= ?'; + $params[] = $min; + $types .= 's'; + } + if ($max !== null) { + $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 (!empty($row['object'])) { + $objectData = json_decode($row['object'], true); + if (is_array($objectData)) { + // Use the ActivityPub factory to create the APObject + $object = \Federator\Data\ActivityPub\Factory::newActivityFromJson($objectData); + if ($object !== false) { + $posts[] = $object; + } + } + } + } + $stmt->close(); + return $posts; + } + + /** + * Save a post (insert or update) + * + * @param \mysqli $dbh + * @param string $userId + * @param \Federator\Data\ActivityPub\Common\Activity $post + * @return bool + */ + public static function savePost($dbh, $userId, $post) + { + $sql = 'INSERT INTO posts ( + `id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published` + ) 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`)'; + $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; + } + $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( + "sssssssss", + $id, + $url, + $userId, + $actor, + $type, + $objectJson, + $toJson, + $ccJson, + $publishedStr + ); + $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; + } } diff --git a/php/federator/dio/votes.php b/php/federator/dio/votes.php new file mode 100644 index 0000000..b3d2663 --- /dev/null +++ b/php/federator/dio/votes.php @@ -0,0 +1,97 @@ +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; + } +} diff --git a/php/federator/jobs/newContentJob.php b/php/federator/jobs/newContentJob.php new file mode 100644 index 0000000..4f7b16a --- /dev/null +++ b/php/federator/jobs/newContentJob.php @@ -0,0 +1,69 @@ + $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 inbox job"); + $user = $this->args['user']; + $activity = $this->args['activity']; + + $activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity); + + if ($activity === false) { + error_log("NewContentJob: Failed to create activity from JSON"); + return false; + } + + \Federator\Api\V1\NewContent::postForUser($this->dbh, $this->connector, $this->cache, $user, $activity); + return true; + } +} \ No newline at end of file diff --git a/plugins/federator/contentnation.php b/plugins/federator/contentnation.php index 11a9a92..ecf7d9f 100644 --- a/plugins/federator/contentnation.php +++ b/plugins/federator/contentnation.php @@ -77,13 +77,41 @@ class ContentNation implements Connector 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; + } + /** * get posts by given user * * @param string $userId user id * @param string $min min date * @param string $max max date - * @return \Federator\Data\ActivityPub\Common\APObject[]|false + * @return \Federator\Data\ActivityPub\Common\Activity[]|false */ public function getRemotePostsByUser($userId, $min, $max) { @@ -114,16 +142,15 @@ class ContentNation implements Connector $imgpath = $this->config['userdata']['path']; $userdata = $this->config['userdata']['url']; foreach ($activities as $activity) { - $create = new \Federator\Data\ActivityPub\Common\Create(); - $create->setAActor('https://' . $domain . '/' . $userId); - $create->setID($activity['id']) - ->setPublished($activity['published'] ?? $activity['timestamp']) - ->addTo("https://www.w3.org/ns/activitystreams#Public") - ->addCC('https://' . $domain . '/' . $userId . '/followers'); - switch ($activity['type']) { case 'Article': - $create->setURL('https://' . $domain . '/' . $activity['name']); + $create = new \Federator\Data\ActivityPub\Common\Create(); + $create->setAActor('https://' . $domain . '/' . $userId); + $create->setID($activity['id']) + ->setPublished($activity['published'] ?? $activity['timestamp']) + ->addTo("https://www.w3.org/ns/activitystreams#Public") + ->addCC('https://' . $domain . '/' . $userId . '/followers'); + $create->setURL('https://' . $domain . '/' . $activity['profilename'] . '/' . $activity['name']); $apArticle = new \Federator\Data\ActivityPub\Common\Article(); if (array_key_exists('tags', $activity)) { foreach ($activity['tags'] as $tag) { @@ -169,10 +196,18 @@ class ContentNation implements Connector $create->setObject($apArticle); $posts[] = $create; break; // Article + case 'Comment': + $create = new \Federator\Data\ActivityPub\Common\Create(); + $create->setAActor('https://' . $domain . '/' . $userId); + $create->setID($activity['id']) + ->setPublished($activity['published'] ?? $activity['timestamp']) + ->addTo("https://www.w3.org/ns/activitystreams#Public") + ->addCC('https://' . $domain . '/' . $userId . '/followers'); $commentJson = $activity; $commentJson['type'] = 'Note'; $commentJson['summary'] = $activity['subject']; + $commentJson['id'] = $activity['id']; $note = \Federator\Data\ActivityPub\Factory::newFromJson($commentJson, ""); if ($note === null) { error_log("ContentNation::getRemotePostsByUser couldn't create comment"); @@ -180,53 +215,36 @@ class ContentNation implements Connector $create->setObject($comment); break; } + $url = 'https://' . $domain . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $note->getID(); + $create->setURL($url); $create->setObject($note); $posts[] = $create; break; // Comment + case 'Vote': - $url = 'https://' . $domain . '/' . $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']] - ) - ); - } else { - $like = new \Federator\Data\ActivityPub\Common\Activity('Dislike'); - $like->setSummary( - $this->main->translate( - $activity['articlelang'], - 'vote', - 'dislike', - [$activity['username']] - ) - ); - } - $actor = new \Federator\Data\ActivityPub\Common\APObject('Person'); - $actor->setName($activity['username']); - $like->setActor($actor); - $url = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename']; + // Build Like/Dislike as top-level activity + $likeType = $activity['upvote'] === true ? 'Like' : 'Dislike'; + $like = new \Federator\Data\ActivityPub\Common\Activity($likeType); + $like->setAActor('https://' . $domain . '/' . $userId); + $like->setID($activity['id']) + ->setPublished($activity['published'] ?? $activity['timestamp']) + ->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 = 'https://' . $domain . '/' . $userId . '/' . $activity['articlename']; if ($activity['comment'] !== '') { - $url .= '/comment/' . $activity['comment']; + $objectUrl .= "#" . $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); - $posts[] = $create; + $like->setURL($objectUrl . '#' . $activity['id']); + $like->setObject($objectUrl); + $posts[] = $like; break; // Vote } } @@ -352,50 +370,98 @@ class ContentNation implements Connector 'type' => 'Create', // Default to 'Create' 'id' => $jsonData['id'] ?? null, 'actor' => $jsonData['actor'] ?? null, - 'published' => $jsonData['object']['published'] ?? null, - 'to' => ['https://www.w3.org/ns/activitystreams#Public'], - 'cc' => [$jsonData['related']['cc']['followers'] ?? null], ]; + // Extract actorName as the last segment of the actor URL (after the last '/') + $actorUrl = $jsonData['actor'] ?? null; + $actorName = null; + $replaceUrl = null; + if (is_array($actorUrl)) { + $actorUrl = $actorUrl['id']; + $replaceUrl = $actorUrl->url ?? null; + } + if ($actorUrl !== null) { + $actorUrlParts = parse_url($actorUrl); + if (isset($actorUrlParts['path'])) { + $pathSegments = array_values(array_filter(explode('/', $actorUrlParts['path']))); + $actorName = end($pathSegments); + // Build replaceUrl as scheme://host if both are set + if (isset($actorUrlParts['scheme'], $actorUrlParts['host'])) { + $replaceUrl = $actorUrlParts['scheme'] . '://' . $actorUrlParts['host']; + } else { + $replaceUrl = $actorUrl; + } + } else { + $actorName = $actorUrl; + $replaceUrl = $actorUrl; + } + } + $ap['actor'] = $actorUrl; + // Handle specific fields based on the type switch ($jsonData['type']) { case 'comment': + // Set Create-level fields + $ap['published'] = $jsonData['object']['published'] ?? null; + $ap['url'] = $jsonData['object']['url'] ?? null; + $ap['to'] = ['https://www.w3.org/ns/activitystreams#Public']; + $ap['cc'] = [$jsonData['related']['cc']['followers'] ?? null]; + + // Set object as Note with only required fields $ap['object'] = [ - 'type' => 'Note', 'id' => $jsonData['object']['id'] ?? null, - 'summary' => $jsonData['object']['summary'] ?? '', + 'type' => 'Note', 'content' => $jsonData['object']['content'] ?? '', - 'published' => $jsonData['object']['published'] ?? null, - 'attributedTo' => $jsonData['actor']['id'] ?? null, - 'to' => ['https://www.w3.org/ns/activitystreams#Public'], - 'cc' => [$jsonData['related']['cc']['followers'] ?? null], - 'url' => $jsonData['object']['url'] ?? null, - 'inReplyTo' => $jsonData['related']['article']['id'] ?? null, + 'summary' => $jsonData['object']['summary'] ?? '', ]; break; - // todo fix this to properly handle votes, data is mocked for now case 'vote': - // Handle voting on a comment or an article - if ($jsonData['object']['type'] === 'Comment') { - $jsonData['object']['type'] = 'Note'; - } - $ap['object'] = [ - 'id' => $jsonData['object']['id'] ?? null, - 'type' => $jsonData['object']['type'] ?? 'Article', - ]; - - // Include additional fields if voting on an article - if ($ap['object']['type'] === 'Article') { - $ap['object']['name'] = $jsonData['object']['name'] ?? null; - $ap['object']['author'] = $jsonData['object']['author'] ?? null; + $ap['id'] .= "_$actorName"; + if ( + isset($jsonData['vote']['type']) && + strtolower($jsonData['vote']['type']) === 'undo' + ) { + $ap['type'] = 'Undo'; + } elseif ($jsonData['vote']['value'] == 1) { + $ap['type'] = 'Like'; + } elseif ($jsonData['vote']['value'] == 0) { + $ap['type'] = 'Dislike'; + } else { + error_log("ContentNation::jsonToActivity unknown vote type: {$jsonData['vote']['type']} and value: {$jsonData['vote']['value']}"); + break; } - // Add vote-specific fields - $ap['vote'] = [ - 'value' => $jsonData['vote']['value'] ?? null, - 'type' => $jsonData['vote']['type'] ?? null, - ]; + $objectId = $jsonData['object']['id'] ?? null; + + $ap['object'] = $objectId; + + if ($ap['type'] === "Undo") { + $ap['object'] = $ap['id']; + } + + /* if ($ap['type'] === 'Undo') { + $ap['object'] = [ + 'id' => $objectId, + 'type' => 'Vote', + ]; + } else if ( + isset($jsonData['object']['type']) && + $jsonData['object']['type'] === 'Article' + ) { + $ap['object'] = [ + 'id' => $objectId, + 'type' => $jsonData['object']['type'], + 'name' => $jsonData['object']['name'] ?? null, + 'author' => $jsonData['object']['author'] ?? null, + ]; + } else if ($jsonData['object']['type'] === 'Comment') { + $ap['object'] = [ + 'id' => $objectId, + 'type' => 'Note', + 'author' => $jsonData['object']['author'] ?? null, + ]; + } */ break; default: @@ -403,6 +469,35 @@ class ContentNation implements Connector throw new \InvalidArgumentException("Unsupported activity type: {$jsonData['type']}"); } + $config = $this->main->getConfig(); + $domain = $config['generic']['externaldomain']; + /** + * Recursively replace strings in an array. + * + * @param string $search + * @param string $replace + * @param array $array + * @return array + */ + function array_str_replace_recursive(string $search, string $replace, array $array): array + { + foreach ($array as $key => $value) { + if (is_array($value)) { + $array[$key] = array_str_replace_recursive($search, $replace, $value); + } elseif (is_string($value)) { + $array[$key] = str_replace($search, $replace, $value); + } + } + return $array; + } + if (is_string($replaceUrl)) { + $ap = array_str_replace_recursive( + $replaceUrl, + 'https://' . $domain, + $ap + ); + } + return \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap); } @@ -421,8 +516,7 @@ class ContentNation implements Connector throw new \Federator\Exceptions\PermissionDenied("Missing Signature header"); } - if (!isset($headers['X-Sender']) || $headers['X-Sender'] !== $this->config['keys']['headerSenderName']) - { + if (!isset($headers['X-Sender']) || $headers['X-Sender'] !== $this->config['keys']['headerSenderName']) { throw new \Federator\Exceptions\PermissionDenied("Invalid sender name"); } diff --git a/plugins/federator/dummyconnector.php b/plugins/federator/dummyconnector.php index ba4b373..4b9b62d 100644 --- a/plugins/federator/dummyconnector.php +++ b/plugins/federator/dummyconnector.php @@ -30,13 +30,25 @@ class DummyConnector implements Connector 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 $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, $min, $max) { diff --git a/plugins/federator/rediscache.php b/plugins/federator/rediscache.php index 0c74936..6b1019c 100644 --- a/plugins/federator/rediscache.php +++ b/plugins/federator/rediscache.php @@ -108,6 +108,19 @@ class RedisCache implements Cache 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 * @@ -127,7 +140,7 @@ class RedisCache implements Cache * @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, $min, $max) { @@ -242,6 +255,18 @@ class RedisCache implements Cache 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 * diff --git a/sql/2025-05-19.sql b/sql/2025-05-19.sql new file mode 100644 index 0000000..f0e0bb0 --- /dev/null +++ b/sql/2025-05-19.sql @@ -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"; \ No newline at end of file