diff options
| author | winter | 2024-12-17 18:39:18 +0000 |
|---|---|---|
| committer | winter | 2024-12-17 18:39:18 +0000 |
| commit | 085762ec174eae1c519ec14db632f5ba197ed3c7 (patch) | |
| tree | 9ad49bc839b5f72ded47c119ff01a09dfa0b934a | |
| parent | bc2cd99b108b8755a648c3915714e7c0e0771548 (diff) | |
hopefully implement push!
again i can't really test this yet because i don't have two https instances that can talk to each other.
soon i will set that up and test all these recent commits
| -rw-r--r-- | Digitigrade/Job/ExpireInboundAuthToken.php | 2 | ||||
| -rw-r--r-- | Digitigrade/Job/RefreshOutboundAuthToken.php | 2 | ||||
| -rw-r--r-- | Digitigrade/Job/RequestOutboundAuthToken.php | 2 | ||||
| -rw-r--r-- | Digitigrade/Job/UpdateInstanceInfo.php | 2 | ||||
| -rw-r--r-- | Digitigrade/Model.php | 8 | ||||
| -rw-r--r-- | Digitigrade/Model/Actor.php | 4 | ||||
| -rw-r--r-- | Digitigrade/Model/FetchableModel.php | 22 | ||||
| -rw-r--r-- | Digitigrade/Model/Instance.php | 75 | ||||
| -rw-r--r-- | Digitigrade/Model/InstanceAuth.php | 2 | ||||
| -rw-r--r-- | Digitigrade/Model/Note.php | 7 | ||||
| -rw-r--r-- | Digitigrade/Model/PushableModel.php | 8 | ||||
| -rwxr-xr-x | bin/devserver | 3 | ||||
| -rw-r--r-- | migrations/20241217_165550_add_instance_auth_push_eligibility.php | 11 | ||||
| -rw-r--r-- | misc/get_remote_object_authenticated.php | 11 | ||||
| -rw-r--r-- | misc/hostname_from_uri.php | 19 | ||||
| -rw-r--r-- | routes/auth.php | 12 | ||||
| -rw-r--r-- | routes/nodeinfo.php | 5 | ||||
| -rw-r--r-- | routes/push.php | 50 | ||||
| -rw-r--r-- | routes/subscribe.php | 11 |
19 files changed, 226 insertions, 30 deletions
diff --git a/Digitigrade/Job/ExpireInboundAuthToken.php b/Digitigrade/Job/ExpireInboundAuthToken.php index dfb5ae6..1ec3585 100644 --- a/Digitigrade/Job/ExpireInboundAuthToken.php +++ b/Digitigrade/Job/ExpireInboundAuthToken.php @@ -20,7 +20,7 @@ class ExpireInboundAuthToken extends Job { public function run() { Logger::getInstance()->info("expiring token for $this->domain"); - $instance = Instance::findByDomain($this->domain); + $instance = Instance::findByHostname($this->domain); if ($instance->auth->inboundToken != $this->token) { Logger::getInstance()->info("actually not expiring the token because it's been refreshed"); return; diff --git a/Digitigrade/Job/RefreshOutboundAuthToken.php b/Digitigrade/Job/RefreshOutboundAuthToken.php index 72dd466..6ba1f57 100644 --- a/Digitigrade/Job/RefreshOutboundAuthToken.php +++ b/Digitigrade/Job/RefreshOutboundAuthToken.php @@ -18,7 +18,7 @@ class RefreshOutboundAuthToken extends Job { public function run() { Logger::getInstance()->info("refreshing outbound token for $this->domain"); - $instance = Instance::findByDomain($this->domain); + $instance = Instance::findByHostname($this->domain); $instance->refreshOutboundAuthToken(); } diff --git a/Digitigrade/Job/RequestOutboundAuthToken.php b/Digitigrade/Job/RequestOutboundAuthToken.php index 1786e32..ef4de03 100644 --- a/Digitigrade/Job/RequestOutboundAuthToken.php +++ b/Digitigrade/Job/RequestOutboundAuthToken.php @@ -18,7 +18,7 @@ class RequestOutboundAuthToken extends Job { public function run() { Logger::getInstance()->info("requesting token from $this->domain in response to their dialback challenge"); - $instance = Instance::findByDomain($this->domain); + $instance = Instance::findByHostname($this->domain); $instance->requestOutboundAuthToken($this->secret); } diff --git a/Digitigrade/Job/UpdateInstanceInfo.php b/Digitigrade/Job/UpdateInstanceInfo.php index abcd182..4206aac 100644 --- a/Digitigrade/Job/UpdateInstanceInfo.php +++ b/Digitigrade/Job/UpdateInstanceInfo.php @@ -16,7 +16,7 @@ class UpdateInstanceInfo extends Job { public function run() { Logger::getInstance()->info("updating info for instance $this->domain"); - if (Instance::findByDomain($this->domain, forceRefetch: true) == null) { + if (Instance::findByHostname($this->domain, forceRefetch: true) == null) { throw new \RuntimeException("remote object doesn't seem to exist"); } } diff --git a/Digitigrade/Model.php b/Digitigrade/Model.php index a065154..40e85f4 100644 --- a/Digitigrade/Model.php +++ b/Digitigrade/Model.php @@ -128,6 +128,14 @@ abstract class Model { protected function hydrate() { } + /** + * Checks that all fields of the model are valid and allowed + * @return bool true if the model is valid, false otherwise + */ + protected function validate(): bool { + return true; + } + public static function findWhere(string $whereClause, array $parameters): ?static { $classNameParts = explode('\\', static::class); $className = $classNameParts[count($classNameParts) - 1]; diff --git a/Digitigrade/Model/Actor.php b/Digitigrade/Model/Actor.php index 3c416ee..8e8799d 100644 --- a/Digitigrade/Model/Actor.php +++ b/Digitigrade/Model/Actor.php @@ -1,9 +1,7 @@ <?php namespace Digitigrade\Model; -use Digitigrade\RemoteFetchable; - -class Actor extends FetchableModel implements \JsonSerializable { +class Actor extends PushableModel { public ?int $id; public string $uri; public bool $isLocal = false; diff --git a/Digitigrade/Model/FetchableModel.php b/Digitigrade/Model/FetchableModel.php index bd25d39..964ea15 100644 --- a/Digitigrade/Model/FetchableModel.php +++ b/Digitigrade/Model/FetchableModel.php @@ -13,15 +13,7 @@ abstract class FetchableModel extends Model implements RemoteFetchable { self::$mapper->bStrictObjectTypeChecking = false; } - 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) - return null; - $data = json_decode($data); - if ($data === null) - return null; + protected static function createFromJson(\stdClass $data, bool $autoSave): static { // default everything nullable to null $obj = new static(); $reflProps = (new \ReflectionClass(static::class))->getProperties(); @@ -67,6 +59,18 @@ abstract class FetchableModel extends Model implements RemoteFetchable { return $obj; } + 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) + return null; + $data = json_decode($data); + if ($data === null) + return null; + return static::createFromJson($data, $autoSave); + } + protected function finaliseAfterFetch(string $uri) { } diff --git a/Digitigrade/Model/Instance.php b/Digitigrade/Model/Instance.php index 7176fcd..9e54a5f 100644 --- a/Digitigrade/Model/Instance.php +++ b/Digitigrade/Model/Instance.php @@ -25,8 +25,8 @@ class Instance extends FetchableModel { return null; } - public static function findByDomain(string $domain, bool $autoSave = true, bool $forceRefetch = false): ?self { - return self::findByUri("https://$domain/.well-known/pawpub-instance", $autoSave, $forceRefetch); + public static function findByHostname(string $host, bool $autoSave = true, bool $forceRefetch = false): ?self { + return self::findByUri("https://$host/.well-known/pawpub-instance", $autoSave, $forceRefetch); } protected function hydrate() { @@ -40,7 +40,7 @@ class Instance extends FetchableModel { } protected function finaliseAfterFetch(string $uri) { - $this->domain = explode('/', $uri)[2]; + $this->domain = hostname_from_uri($uri); } protected function finaliseAfterSave() { @@ -50,7 +50,7 @@ class Instance extends FetchableModel { public static function findByUri(string $uri, bool $autoSave = true, bool $forceRefetch = false): ?static { if (!$forceRefetch) { - $domain = explode('/', $uri)[2]; + $domain = hostname_from_uri($uri); $obj = self::findWhere('domain = ?', [$domain]); if ($obj != null) return $obj; @@ -65,6 +65,14 @@ class Instance extends FetchableModel { return self::find($authRecord->instanceId); } + public static function findByRequestHeaders(): ?self { + if (!isset($_SERVER['HTTP_AUTHORIZATION']) || !str_starts_with($_SERVER['HTTP_AUTHORIZATION'], 'Bearer ')) + return null; + $header = $_SERVER['HTTP_AUTHORIZATION']; + $token = substr($header, 7); + return self::findByInboundAuthToken($token); + } + public static function findByDialbackSecret(string $secret): ?self { $authRecord = InstanceAuth::findWhere('secret = ?', [$secret]); if ($authRecord == null) @@ -126,4 +134,63 @@ class Instance extends FetchableModel { $refreshAt = (new \DateTimeImmutable($data->expires))->sub(new \DateInterval('PT1H')); (new RefreshOutboundAuthToken($this->domain, $refreshAt))->submit(); } + + private function subscribeOrUnsubscribe(bool $isUnsubscribe): bool { + if (!isset($this->endpoints->subscribe, $this->endpoints->unsubscribe)) { + throw new \RuntimeException("can't (un)subscribe to $this->domain because it doesn't have the right endpoints"); + } + if (!isset($this->auth->outboundToken)) { + throw new \RuntimeException("can't (un)subscribe to $this->domain because i don't have an outbound token for it"); + } + $context = stream_context_create(['http' => [ + 'header' => 'Authorization: Bearer ' . $this->auth->outboundToken + ]]); + file_get_contents($isUnsubscribe ? $this->endpoints->unsubscribe : $this->endpoints->subscribe, context: $context); + return str_contains($http_response_header[0], '200'); + } + + /** + * Tries to subscribe to the instance for receiving objects by push + * @return bool whether subscribing was successful + */ + public function subscribe(): bool { + if ($this->subscribeOrUnsubscribe(isUnsubscribe: false)) { + $this->auth->inboundPushEnabled = true; + $this->auth->save(); + return true; + } + return false; + } + + /** + * Tries to unsubscribe from the instance + * @return bool whether unsubscribing was successful + */ + public function unsubscribe() { + if ($this->subscribeOrUnsubscribe(isUnsubscribe: true)) { + $this->auth->inboundPushEnabled = false; + $this->auth->save(); + return true; + } + return false; + } + + public function pushObject(\JsonSerializable $obj) { + if (!isset($this->endpoints->push)) { + throw new \RuntimeException("can't push to $this->domain because it doesn't have a push endpoint"); + } + if (!isset($this->auth->outboundToken)) { + throw new \RuntimeException("can't push to $this->domain because i don't have an outbound token for it"); + } + if (!$this->auth->outboundPushEnabled) { + throw new \RuntimeException("won't push to $this->domain because it hasn't subscribed to us"); + } + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\nAuthorization: Bearer " . $this->auth->outboundToken, + 'content' => json_encode($obj) + ]]); + file_get_contents($this->endpoints->push, context: $context); + return str_contains($http_response_header[0], '200'); + } }
\ No newline at end of file diff --git a/Digitigrade/Model/InstanceAuth.php b/Digitigrade/Model/InstanceAuth.php index 6f58298..6ebccb8 100644 --- a/Digitigrade/Model/InstanceAuth.php +++ b/Digitigrade/Model/InstanceAuth.php @@ -8,6 +8,8 @@ class InstanceAuth extends Model { public ?string $outboundToken; public ?string $inboundToken; public ?string $secret; + public bool $outboundPushEnabled = false; + public bool $inboundPushEnabled = false; protected function setOwnerId(int $id) { $this->instanceId = $id; diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php index 3b7c362..1e867fa 100644 --- a/Digitigrade/Model/Note.php +++ b/Digitigrade/Model/Note.php @@ -3,7 +3,7 @@ namespace Digitigrade\Model; use Digitigrade\Db; -class Note extends FetchableModel implements \JsonSerializable { +class Note extends PushableModel { public ?int $id; public string $uri; public \DateTimeImmutable $created; @@ -44,6 +44,11 @@ class Note extends FetchableModel implements \JsonSerializable { $this->attachments = NoteAttachment::findAllWhere('note_id = ?', [$this->id]); } + protected function validate(): bool { + // author has to be from the same instance as the note itself + return hostname_from_uri($this->author->uri) == hostname_from_uri($this->uri); + } + private function findFormattedContents(): array { $pdo = Db::getInstance()->getPdo(); $stmt = $pdo->prepare('SELECT mimetype, body FROM note_formatted_content WHERE note_id = ?'); diff --git a/Digitigrade/Model/PushableModel.php b/Digitigrade/Model/PushableModel.php new file mode 100644 index 0000000..cfa68d3 --- /dev/null +++ b/Digitigrade/Model/PushableModel.php @@ -0,0 +1,8 @@ +<?php +namespace Digitigrade\Model; + +abstract class PushableModel extends FetchableModel implements \JsonSerializable { + public static function importFromReceivedObject(\stdClass $obj, bool $autoSave = true): static { + return static::createFromJson($obj, $autoSave); + } +}
\ No newline at end of file diff --git a/bin/devserver b/bin/devserver new file mode 100755 index 0000000..57bf881 --- /dev/null +++ b/bin/devserver @@ -0,0 +1,3 @@ +#!/bin/sh +cd "$(dirname "$0")"/.. || exit 1 +php -S 0.0.0.0:8080 index.php
\ No newline at end of file diff --git a/migrations/20241217_165550_add_instance_auth_push_eligibility.php b/migrations/20241217_165550_add_instance_auth_push_eligibility.php new file mode 100644 index 0000000..ebf2a47 --- /dev/null +++ b/migrations/20241217_165550_add_instance_auth_push_eligibility.php @@ -0,0 +1,11 @@ +<?php + +use \Digitigrade\Db\Migrator; + +Migrator::getInstance()->register(20241217_165550, function (PDO $db) { + // whether we are allowed to push / receive pushes for a given instance + $db->exec(<<<END + ALTER TABLE instance_auth ADD COLUMN outbound_push_enabled boolean not null default false; + ALTER TABLE instance_auth ADD COLUMN inbound_push_enabled boolean not null default false; + END); +}); diff --git a/misc/get_remote_object_authenticated.php b/misc/get_remote_object_authenticated.php index ba67473..49aaa59 100644 --- a/misc/get_remote_object_authenticated.php +++ b/misc/get_remote_object_authenticated.php @@ -1,5 +1,6 @@ <?php +use Digitigrade\GlobalConfig; use Digitigrade\Model\Instance; /** @@ -8,13 +9,19 @@ use Digitigrade\Model\Instance; * @param bool $dontLookUpInstance if true, will skip looking up the Instance, and consequently the auth token * @return false|string the response data, or false if it failed */ -function get_remote_object_authenticated(string $uri, bool $dontLookUpInstance = false) { +function get_remote_object_authenticated(string $uri, bool $dontLookUpInstance = false): false|string { if (!str_starts_with($uri, 'https://')) return false; + $remoteHost = hostname_from_uri($uri); + if ($remoteHost == GlobalConfig::getInstance()->getCanonicalHost()) { + // refuse to fetch objects from ourself. that's silly + return false; + } + $instance = null; if (!$dontLookUpInstance) { - $instance = Instance::findByDomain(explode('/', $uri)[2]); + $instance = Instance::findByHostname($remoteHost); } if ($instance?->auth?->outboundToken == null) { diff --git a/misc/hostname_from_uri.php b/misc/hostname_from_uri.php new file mode 100644 index 0000000..0ee6b05 --- /dev/null +++ b/misc/hostname_from_uri.php @@ -0,0 +1,19 @@ +<?php + +/** + * Extracts the hostname (and port) from the given URI, assuming it is of a known scheme and can have one + * @param string $uri + * @return ?string the host:port pair if there is one or null otherwise + */ +function hostname_from_uri(string $uri): ?string { + if (!( + str_starts_with($uri, 'https://') || + str_starts_with($uri, 'http://') || + str_starts_with($uri, 'gopher://') || + str_starts_with($uri, 'gemini://') || + str_starts_with($uri, 'mumble://') + )) { + return null; + } + return explode('/', $uri, 4)[2]; +}
\ No newline at end of file diff --git a/routes/auth.php b/routes/auth.php index 2127a46..d64331e 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -2,7 +2,7 @@ use Digitigrade\GlobalConfig; use Digitigrade\HttpResponseStatus\BadRequest; -use Digitigrade\HttpResponseStatus\Forbidden; +use Digitigrade\HttpResponseStatus\Unauthorized; use Digitigrade\Job\ExpireInboundAuthToken; use Digitigrade\Job\RequestOutboundAuthToken; use Digitigrade\Model\Instance; @@ -19,8 +19,8 @@ Router::getInstance()->mount('/auth/main', function (array $args) { if (!isset($_GET['target'])) { throw new BadRequest('you need to specify the `target` GET parameter'); } - $domain = explode('/', $_GET['target'])[2]; - $instance = Instance::findByDomain($domain); + $domain = hostname_from_uri($_GET['target']); + $instance = Instance::findByHostname($domain); if ($instance == null) { throw new RuntimeException("i can't find your instance information, sorry"); } @@ -51,7 +51,7 @@ Router::getInstance()->mount('/auth/main', function (array $args) { } $instance = Instance::findByDialbackSecret($_GET['secret']); if ($instance == null) { - throw new Forbidden('the provided secret is invalid!'); + throw new Unauthorized('the provided secret is invalid!'); } $instance->auth->secret = null; // it's been used now @@ -79,7 +79,7 @@ Router::getInstance()->mount('/auth/main', function (array $args) { } $instance = Instance::findByInboundAuthToken($_GET['token']); if ($instance == null) { - throw new Forbidden('the provided token is invalid!'); + throw new Unauthorized('the provided token is invalid!'); } $instance->auth->inboundToken = random_printable_bytes(); @@ -109,7 +109,7 @@ Router::getInstance()->mount('/auth/dialback', function (array $args) { if (!isset($_POST['origin'], $_POST['secret'])) { throw new BadRequest('send the `origin` and `secret` parameters as POST data please :3'); } - $instance = Instance::findByDomain($_POST['origin']); + $instance = Instance::findByHostname($_POST['origin']); if ($instance == null) { throw new BadRequest("i can't seem to find your instance information!"); } diff --git a/routes/nodeinfo.php b/routes/nodeinfo.php index 93f103c..92f1f7b 100644 --- a/routes/nodeinfo.php +++ b/routes/nodeinfo.php @@ -51,7 +51,10 @@ Router::getInstance()->mount('/.well-known/pawpub-instance', function (array $ar 'softwareVersion' => '0.1.0', 'softwareDescription' => 'An experimental PawPub server', 'endpoints' => [ - + 'auth' => path_to_uri('/auth/main'), + 'subscribe' => path_to_uri('/subscribe'), + 'unsubscribe' => path_to_uri('/unsubscribe'), + 'push' => path_to_uri('/push') ] ]); });
\ No newline at end of file diff --git a/routes/push.php b/routes/push.php new file mode 100644 index 0000000..6712fb4 --- /dev/null +++ b/routes/push.php @@ -0,0 +1,50 @@ +<?php + +use Digitigrade\HttpResponseStatus\BadRequest; +use Digitigrade\HttpResponseStatus\Forbidden; +use Digitigrade\HttpResponseStatus\Unauthorized; +use Digitigrade\Model\Actor; +use Digitigrade\Model\Instance; +use Digitigrade\Model\Note; +use Digitigrade\Router; + +Router::getInstance()->mount('/push', function (array $args) { + // receive incoming pushes + $instance = Instance::findByRequestHeaders(); + if ($instance == null) { + throw new Unauthorized('please identify yourself with a valid Authorization header!'); + } + if (!$instance->auth->inboundPushEnabled) { + throw new Forbidden('you are not permitted to push as i have not subscribed to you'); + } + + $body = file_get_contents('php://input'); + $obj = json_decode($body); + if ($obj === false) { + throw new BadRequest("request body doesn't look like valid json"); + } + if (!isset($obj->type, $obj->self)) { + throw new BadRequest('the object needs to have `type` and `self` properties'); + } + if (!str_starts_with($obj->self, 'https://')) { + throw new BadRequest('dodgy looking `self` uri!'); + } + if (hostname_from_uri($obj->self) != $instance->domain) { + throw new Forbidden('you may not push objects belonging to a different instance'); + } + + switch ($obj->type) { + case 'actor': + Actor::importFromReceivedObject($obj); + break; + case 'note': + Note::importFromReceivedObject($obj); + break; + case 'interaction': + case 'extension': + case 'tombstone': + throw new \RuntimeException('object type not yet implemented :('); + default: + throw new BadRequest('invalid object type'); + } +});
\ No newline at end of file diff --git a/routes/subscribe.php b/routes/subscribe.php new file mode 100644 index 0000000..c18d484 --- /dev/null +++ b/routes/subscribe.php @@ -0,0 +1,11 @@ +<?php + +use Digitigrade\Router; + +Router::getInstance()->mount('/subscribe', function (array $args) { + +}); + +Router::getInstance()->mount('/unsubscribe', function (array $args) { + +});
\ No newline at end of file |
