diff options
| author | winter | 2024-12-19 20:59:18 +0000 |
|---|---|---|
| committer | winter | 2024-12-19 20:59:18 +0000 |
| commit | 72b07245f44c73ed41bfcca2465a9ee34426a922 (patch) | |
| tree | 57372703100ad792c95c665a568aabe8a763bfdf | |
| parent | 1e070a7a0097c74f8ac30678d39267f19c76f9f6 (diff) | |
implement follow relationships (untested)
does NOT implement the actual behaviour behind follows (receiving posts and such)
| -rw-r--r-- | Digitigrade/Model.php | 21 | ||||
| -rw-r--r-- | Digitigrade/Model/Actor.php | 67 | ||||
| -rw-r--r-- | Digitigrade/Model/FetchableModel.php | 6 | ||||
| -rw-r--r-- | Digitigrade/Model/FollowRelation.php | 37 | ||||
| -rw-r--r-- | Digitigrade/Model/FollowRelationStatus.php | 7 | ||||
| -rw-r--r-- | Digitigrade/RpcException.php | 6 | ||||
| -rw-r--r-- | Digitigrade/RpcReceiver.php | 22 | ||||
| -rw-r--r-- | migrations/20241219_181401_create_follow_relation.php | 19 | ||||
| -rw-r--r-- | misc/send_request_authenticated.php (renamed from misc/get_remote_object_authenticated.php) | 36 | ||||
| -rw-r--r-- | routes/actor.php | 25 |
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 |
