aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorwinter2024-12-19 20:59:18 +0000
committerwinter2024-12-19 20:59:18 +0000
commit72b07245f44c73ed41bfcca2465a9ee34426a922 (patch)
tree57372703100ad792c95c665a568aabe8a763bfdf
parent1e070a7a0097c74f8ac30678d39267f19c76f9f6 (diff)
implement follow relationships (untested)
does NOT implement the actual behaviour behind follows (receiving posts and such)
-rw-r--r--Digitigrade/Model.php21
-rw-r--r--Digitigrade/Model/Actor.php67
-rw-r--r--Digitigrade/Model/FetchableModel.php6
-rw-r--r--Digitigrade/Model/FollowRelation.php37
-rw-r--r--Digitigrade/Model/FollowRelationStatus.php7
-rw-r--r--Digitigrade/RpcException.php6
-rw-r--r--Digitigrade/RpcReceiver.php22
-rw-r--r--migrations/20241219_181401_create_follow_relation.php19
-rw-r--r--misc/send_request_authenticated.php (renamed from misc/get_remote_object_authenticated.php)36
-rw-r--r--routes/actor.php25
10 files changed, 229 insertions, 17 deletions
diff --git a/Digitigrade/Model.php b/Digitigrade/Model.php
index 80a6d91..0db27ef 100644
--- a/Digitigrade/Model.php
+++ b/Digitigrade/Model.php
@@ -10,8 +10,8 @@ abstract class Model {
}
/**
- * @return ?string a condition for a WHERE clause if this object already
- * exists in the database, or null otherwise
+ * @return ?string a condition for a WHERE clause that uniquely identifies
+ * this object if it already exists in the database, or null if not
*/
protected function getUpdateWhereClause(\PDO $db): ?string {
if (isset($this->id) && static::find($this->{'id'}) != null) {
@@ -241,4 +241,21 @@ abstract class Model {
$this->saved = true;
}
+
+ /**
+ * Removes this record from the database.
+ * @return void
+ */
+ public function remove() {
+ $db = Db::getInstance()->getPdo();
+ $where = $this->getUpdateWhereClause($db);
+ if ($where == null) {
+ throw new \RuntimeException("object doesn't seem to exist in the database so i can't delete it");
+ }
+ $classNameParts = explode('\\', static::class);
+ $className = $classNameParts[count($classNameParts) - 1];
+ $tableName = self::mangleName($className);
+
+ $db->exec("DELETE FROM $tableName WHERE $where");
+ }
} \ No newline at end of file
diff --git a/Digitigrade/Model/Actor.php b/Digitigrade/Model/Actor.php
index 4f3aab2..fa08463 100644
--- a/Digitigrade/Model/Actor.php
+++ b/Digitigrade/Model/Actor.php
@@ -1,7 +1,10 @@
<?php
namespace Digitigrade\Model;
-class Actor extends PushableModel {
+use Digitigrade\RpcException;
+use Digitigrade\RpcReceiver;
+
+class Actor extends PushableModel implements RpcReceiver {
public ?int $id;
public string $uri;
public bool $isLocal = false;
@@ -83,6 +86,64 @@ class Actor extends PushableModel {
return ActorWebfinger::findByAcct($acct, $autoSave, $forceRefetch)->actor;
}
+ public static function findByRequestHeaders(): ?self {
+ $instance = Instance::findByRequestHeaders();
+ if ($instance == null || !isset($_SERVER['HTTP_X_PAWPUB_ACTOR']))
+ return null;
+ $uri = $_SERVER['HTTP_X_PAWPUB_ACTOR'];
+ if (hostname_from_uri($uri) != $instance->domain)
+ return null;
+ return self::findByUri($uri);
+ }
+
+ protected function rpcCall(string $method, array $args) {
+ switch ($method) {
+ case 'follow': // $this is the target, $args[0] is the initiator
+ $status = $this->requestToFollow ? FollowRelationStatus::PENDING : FollowRelationStatus::ACTIVE;
+ if (!$this->isLocal) {
+ $endpoint = $this->endpoints->follow ?? null;
+ if (!isset($endpoint, $this->endpoints->unfollow)) {
+ throw new RpcException("Actor `$this->uri` has no (un)follow endpoint");
+ }
+ $result = send_request_authenticated($endpoint, method: 'POST', actingAs: $args[0]);
+ if (!str_contains($result->headers[0], ' 20')) {
+ throw new RpcException('remote call did not indicate success: ' . $result->headers[0]);
+ }
+ }
+ FollowRelation::create($args[0], $this, $status);
+ return $status;
+
+ case 'unfollow':
+ if (!$this->isLocal) {
+ $endpoint = $this->endpoints->unfollow ?? null;
+ if (!isset($endpoint, $this->endpoints->follow)) {
+ throw new RpcException("Actor `$this->uri` has no (un)follow endpoint");
+ }
+ $result = send_request_authenticated($endpoint, method: 'POST', actingAs: $args[0]);
+ if (!str_contains($result->headers[0], ' 20')) {
+ throw new RpcException('remote call did not indicate success: ' . $result->headers[0]);
+ }
+ }
+ FollowRelation::findByActors($args[0], $this)->remove();
+
+ default:
+ throw new \RuntimeException("Actor rpc method $method not (yet?) implemented");
+ }
+ }
+
+ /**
+ * Tries to follow (or send a follow request to) another actor
+ * @param Actor $target the actor to follow
+ * @return FollowRelationStatus PENDING if a request was sent or ACTIVE if the follow went through immediately
+ */
+ public function follow(self $target): FollowRelationStatus {
+ return $target->rpcCall('follow', [$this]);
+ }
+
+ public function unfollow(self $target) {
+ $target->rpcCall('unfollow', [$this]);
+ }
+
public function jsonSerialize(): array {
return [
'type' => 'actor',
@@ -97,7 +158,9 @@ class Actor extends PushableModel {
'automated' => $this->automated,
'endpoints' => [
'basicFeed' => path_to_uri("/user/$this->handle/basicFeed"),
- 'fullFeed' => path_to_uri("/user/$this->handle/fullFeed")
+ 'fullFeed' => path_to_uri("/user/$this->handle/fullFeed"),
+ 'follow' => path_to_uri("/user/$this->handle/follow"),
+ 'unfollow' => path_to_uri("/user/$this->handle/unfollow"),
]
];
}
diff --git a/Digitigrade/Model/FetchableModel.php b/Digitigrade/Model/FetchableModel.php
index 1120823..5e4d1e8 100644
--- a/Digitigrade/Model/FetchableModel.php
+++ b/Digitigrade/Model/FetchableModel.php
@@ -55,10 +55,10 @@ abstract class FetchableModel extends Model implements RemoteFetchable {
protected static function fetchFromRemote(string $uri, bool $autoSave): ?static {
// fetch the object
// janky hack: if this is an Instance then avoid recursively fetching itself
- $data = @get_remote_object_authenticated($uri, static::class == Instance::class);
- if ($data === false)
+ $result = @send_request_authenticated($uri, static::class == Instance::class);
+ if (!str_contains($result->headers[0], '200'))
return null;
- $data = json_decode($data);
+ $data = json_decode($result->body);
if ($data === null)
return null;
diff --git a/Digitigrade/Model/FollowRelation.php b/Digitigrade/Model/FollowRelation.php
new file mode 100644
index 0000000..fc43a6f
--- /dev/null
+++ b/Digitigrade/Model/FollowRelation.php
@@ -0,0 +1,37 @@
+<?php
+namespace Digitigrade\Model;
+
+use Digitigrade\Model;
+
+class FollowRelation extends Model {
+ public Actor $subject;
+ public Actor $object;
+ public FollowRelationStatus $status;
+
+ /**
+ * Creates and saves a new follower/following relationship
+ * @param Actor $subject the follower
+ * @param Actor $object the followee
+ * @param FollowRelationStatus $status whether the relationship is pending or active
+ * @return FollowRelation
+ */
+ public static function create(Actor $subject, Actor $object, FollowRelationStatus $status): self {
+ $rel = new self();
+ $rel->subject = $subject;
+ $rel->object = $object;
+ $rel->status = $status;
+ $rel->save();
+ return $rel;
+ }
+
+ protected function getUpdateWhereClause(\PDO $db): ?string {
+ if (self::findWhere('subject = ? and object = ?', [$this->subject->id, $this->object->id]) != null) {
+ return 'subject = ' . $this->subject->id . ' and object = ' . $this->object->id;
+ }
+ return null;
+ }
+
+ public static function findByActors(Actor $subject, Actor $object): ?self {
+ return self::findWhere('subject = ? and object = ?', [$subject->id, $object->id]);
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/FollowRelationStatus.php b/Digitigrade/Model/FollowRelationStatus.php
new file mode 100644
index 0000000..24ba34b
--- /dev/null
+++ b/Digitigrade/Model/FollowRelationStatus.php
@@ -0,0 +1,7 @@
+<?php
+namespace Digitigrade\Model;
+
+enum FollowRelationStatus: string {
+ case PENDING = 'pending';
+ case ACTIVE = 'active';
+} \ No newline at end of file
diff --git a/Digitigrade/RpcException.php b/Digitigrade/RpcException.php
new file mode 100644
index 0000000..f2d4775
--- /dev/null
+++ b/Digitigrade/RpcException.php
@@ -0,0 +1,6 @@
+<?php
+namespace Digitigrade;
+
+class RpcException extends \Exception {
+
+} \ No newline at end of file
diff --git a/Digitigrade/RpcReceiver.php b/Digitigrade/RpcReceiver.php
new file mode 100644
index 0000000..f776bf2
--- /dev/null
+++ b/Digitigrade/RpcReceiver.php
@@ -0,0 +1,22 @@
+<?php
+namespace Digitigrade;
+
+/**
+ * Describes an object that can be acted on via an abstract RPC interface.
+ *
+ * If the object comes from a remote server, it will be contacted to complete the call,
+ * whereas if it is a local object, the call may be completed using regular function calls.
+ */
+interface RpcReceiver {
+ /**
+ * Makes a remote or local method call on this object.
+ *
+ * Implementors should not expose this method publicly, but should instead create wrapper
+ * methods with a nicer interface.
+ * @param string $method the name of the remote method to call
+ * @param array $args any arguments
+ * @return mixed results of the call
+ * @throws RpcException if there was an error contacting the other server
+ */
+ protected function rpcCall(string $method, array $args);
+} \ No newline at end of file
diff --git a/migrations/20241219_181401_create_follow_relation.php b/migrations/20241219_181401_create_follow_relation.php
new file mode 100644
index 0000000..e5e5e73
--- /dev/null
+++ b/migrations/20241219_181401_create_follow_relation.php
@@ -0,0 +1,19 @@
+<?php
+
+use \Digitigrade\Db\Migrator;
+
+Migrator::getInstance()->register(20241219_181401, function (PDO $db) {
+ $db->beginTransaction();
+ $db->exec(<<<END
+ CREATE TYPE follow_relation_status AS ENUM (
+ 'pending', 'active'
+ );
+ CREATE TABLE follow_relation (
+ subject bigint not null references actor,
+ object bigint not null references actor,
+ status follow_relation_status not null,
+ unique(subject, object)
+ );
+ END);
+ $db->commit();
+});
diff --git a/misc/get_remote_object_authenticated.php b/misc/send_request_authenticated.php
index 9f2db43..9019da8 100644
--- a/misc/get_remote_object_authenticated.php
+++ b/misc/send_request_authenticated.php
@@ -1,6 +1,7 @@
<?php
use Digitigrade\GlobalConfig;
+use Digitigrade\Model\Actor;
use Digitigrade\Model\Instance;
/**
@@ -8,16 +9,22 @@ use Digitigrade\Model\Instance;
* @param string $uri
* @param bool $dontLookUpInstance if true, will skip looking up the Instance, and consequently the auth token
* @param string $method HTTP verb
- * @return false|string the response data, or false if it failed
+ * @param ?Actor $actingAs if specified, the local actor to send the request on behalf of
+ * @return stdClass object with fields `body` and `headers`
*/
-function get_remote_object_authenticated(string $uri, bool $dontLookUpInstance = false, string $method = 'GET'): false|string {
- if (!str_starts_with($uri, 'https://'))
- return false;
+function send_request_authenticated(
+ string $uri,
+ bool $dontLookUpInstance = false,
+ string $method = 'GET',
+ ?Actor $actingAs = null
+) {
+ if (!str_starts_with($uri, 'https://')) {
+ throw new RuntimeException('refusing to fetch a non-https uri');
+ }
$remoteHost = hostname_from_uri($uri);
if ($remoteHost == GlobalConfig::getInstance()->getCanonicalHost()) {
- // refuse to fetch objects from ourself. that's silly
- return false;
+ throw new RuntimeException('refusing to fetch content from myself');
}
$instance = null;
@@ -37,14 +44,23 @@ function get_remote_object_authenticated(string $uri, bool $dontLookUpInstance =
// so i'm choosing to begin the auth process now and return false in the hopes that
// this request will be retried later
$instance?->beginOutboundAuth();
- return false;
}
- return $resp;
+ $result = new stdClass();
+ $result->body = $resp;
+ $result->headers = $http_response_header;
+ return $result;
}
+ $header = 'Authorization: Bearer ' . $instance->auth->outboundToken;
+ if ($actingAs != null) {
+ $header .= "\nX-PawPub-Actor: $actingAs->uri";
+ }
$context = stream_context_create(['http' => [
'method' => $method,
- 'header' => 'Authorization: Bearer ' . $instance->auth->outboundToken
+ 'header' => $header
]]);
- return file_get_contents($uri, context: $context);
+ $result = new stdClass();
+ $result->body = file_get_contents($uri, context: $context);
+ $result->headers = $http_response_header;
+ return $result;
} \ No newline at end of file
diff --git a/routes/actor.php b/routes/actor.php
index 51c0e1a..30b28c5 100644
--- a/routes/actor.php
+++ b/routes/actor.php
@@ -2,6 +2,7 @@
use Digitigrade\HttpResponseStatus\NotFound;
use Digitigrade\HttpResponseStatus\TemporaryRedirect;
+use Digitigrade\HttpResponseStatus\Unauthorized;
use Digitigrade\Model\Actor;
use Digitigrade\Model\Note;
use Digitigrade\Router;
@@ -43,4 +44,28 @@ Router::getInstance()->mount('/user/:handle/fullFeed', function (array $args) {
'previousPage' => null,
'items' => $notes
]);
+});
+
+Router::getInstance()->mount('/user/:handle/follow', function (array $args) {
+ $target = Actor::findLocalByHandle($args['handle']);
+ if ($target == null) {
+ throw new NotFound();
+ }
+ $initiator = Actor::findByRequestHeaders();
+ if ($initiator == null) {
+ throw new Unauthorized('please authenticate yourself on behalf of the initiating actor');
+ }
+ $initiator->follow($target);
+});
+
+Router::getInstance()->mount('/user/:handle/unfollow', function (array $args) {
+ $target = Actor::findLocalByHandle($args['handle']);
+ if ($target == null) {
+ throw new NotFound();
+ }
+ $initiator = Actor::findByRequestHeaders();
+ if ($initiator == null) {
+ throw new Unauthorized('please authenticate yourself on behalf of the initiating actor');
+ }
+ $initiator->unfollow($target);
}); \ No newline at end of file