aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Digitigrade/Job/ExpireInboundAuthToken.php2
-rw-r--r--Digitigrade/Job/RefreshOutboundAuthToken.php2
-rw-r--r--Digitigrade/Job/RequestOutboundAuthToken.php2
-rw-r--r--Digitigrade/Job/UpdateInstanceInfo.php2
-rw-r--r--Digitigrade/Model.php8
-rw-r--r--Digitigrade/Model/Actor.php4
-rw-r--r--Digitigrade/Model/FetchableModel.php22
-rw-r--r--Digitigrade/Model/Instance.php75
-rw-r--r--Digitigrade/Model/InstanceAuth.php2
-rw-r--r--Digitigrade/Model/Note.php7
-rw-r--r--Digitigrade/Model/PushableModel.php8
-rwxr-xr-xbin/devserver3
-rw-r--r--migrations/20241217_165550_add_instance_auth_push_eligibility.php11
-rw-r--r--misc/get_remote_object_authenticated.php11
-rw-r--r--misc/hostname_from_uri.php19
-rw-r--r--routes/auth.php12
-rw-r--r--routes/nodeinfo.php5
-rw-r--r--routes/push.php50
-rw-r--r--routes/subscribe.php11
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