aboutsummaryrefslogtreecommitdiffhomepage
path: root/Digitigrade
diff options
context:
space:
mode:
authorwinter2024-12-09 21:31:54 +0000
committerwinter2024-12-09 21:31:54 +0000
commit4c7bde1400820f36caf8b2a5374007384c3018f3 (patch)
tree83ed26ff368117b8e392f7bc28736482077fc2f4 /Digitigrade
parent50edbd3907c26a2d4d616421de9bb51a4d083c1b (diff)
rename to Digitigrade / PawPub
:3
Diffstat (limited to 'Digitigrade')
-rw-r--r--Digitigrade/Db.php42
-rw-r--r--Digitigrade/Db/Migrator.php52
-rw-r--r--Digitigrade/GlobalConfig.php44
-rw-r--r--Digitigrade/HttpResponseStatus.php7
-rw-r--r--Digitigrade/HttpResponseStatus/BadRequest.php18
-rw-r--r--Digitigrade/HttpResponseStatus/InternalServerError.php23
-rw-r--r--Digitigrade/HttpResponseStatus/NoHandlerFound.php18
-rw-r--r--Digitigrade/HttpResponseStatus/NotFound.php18
-rw-r--r--Digitigrade/Model.php219
-rw-r--r--Digitigrade/Model/Actor.php90
-rw-r--r--Digitigrade/Model/ActorEndpoints.php27
-rw-r--r--Digitigrade/Model/FetchableModel.php78
-rw-r--r--Digitigrade/Model/Note.php95
-rw-r--r--Digitigrade/Model/NoteAttachment.php23
-rw-r--r--Digitigrade/Model/NotePrivacy.php39
-rw-r--r--Digitigrade/Model/NotePrivacyInteractors.php9
-rw-r--r--Digitigrade/Model/NotePrivacyScope.php9
-rw-r--r--Digitigrade/RemoteFetchable.php21
-rw-r--r--Digitigrade/Router.php98
-rw-r--r--Digitigrade/Singleton.php18
20 files changed, 948 insertions, 0 deletions
diff --git a/Digitigrade/Db.php b/Digitigrade/Db.php
new file mode 100644
index 0000000..c9f9df9
--- /dev/null
+++ b/Digitigrade/Db.php
@@ -0,0 +1,42 @@
+<?php
+namespace Digitigrade;
+
+use Digitigrade\Model\Actor;
+
+class Db extends Singleton {
+ private const INIT_SCHEMA_PATH = __DIR__ . '/../schema.sql';
+
+ private \PDO $conn;
+
+ protected function __construct() {
+ $conf = GlobalConfig::getInstance();
+ $this->conn = new \PDO($conf->getDbConnection(), $conf->getDbUser(), $conf->getDbPassword(), [
+ \PDO::ATTR_PERSISTENT => true,
+ ]);
+ }
+
+ public function runInitialSchema() {
+ $this->conn->exec(file_get_contents(self::INIT_SCHEMA_PATH));
+ }
+
+ public function getDbVersion(): int {
+ $result = $this->conn->query('SELECT db_version FROM db_version');
+ return $result->fetch()['db_version'];
+ }
+
+ /**
+ * Changes the database schema version
+ *
+ * CAREFUL! this shouldn't be used unless you absolutely know why
+ * @param int $newVersion the new version number
+ * @return void
+ */
+ public function setDbVersion(int $newVersion) {
+ $stmt = $this->conn->prepare('UPDATE db_version SET db_version = ?');
+ $stmt->execute([$newVersion]);
+ }
+
+ public function getPdo(): \PDO {
+ return $this->conn;
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Db/Migrator.php b/Digitigrade/Db/Migrator.php
new file mode 100644
index 0000000..a1b4e8b
--- /dev/null
+++ b/Digitigrade/Db/Migrator.php
@@ -0,0 +1,52 @@
+<?php
+namespace Digitigrade\Db;
+
+use Digitigrade\Db;
+use Digitigrade\Singleton;
+
+class Migrator extends Singleton {
+ private array $migrations = [];
+
+ public function register(int $newVersion, callable $fn) {
+ $this->migrations[$newVersion] = $fn;
+ }
+
+ /**
+ * Runs all available migrations
+ * @return void
+ */
+ public function migrate() {
+ $db = Db::getInstance();
+ $currentVersion = null;
+ try {
+ $currentVersion = $db->getDbVersion();
+ error_log('starting on version ' . $currentVersion);
+ } catch (\PDOException $e) {
+ if ($e->getCode() == '42P01') { // undefined table
+ error_log("initialising database because db version table doesn't exist");
+ $db->runInitialSchema();
+ $currentVersion = $db->getDbVersion();
+ error_log('starting on version ' . $currentVersion);
+ } else
+ throw $e;
+ }
+
+ // find migrations that haven't been run yet
+ $available = array_filter($this->migrations, function (int $key) use ($currentVersion) {
+ return $key > $currentVersion;
+ }, ARRAY_FILTER_USE_KEY);
+
+ try {
+ // run them in order (at least they should be in order?)
+ foreach (array_keys($available) as $version) {
+ $fn = $available[$version];
+ $filename = basename((new \ReflectionFunction($fn))->getFileName());
+ error_log("running migration $filename");
+ $available[$version]($db->getPdo());
+ $db->setDbVersion($version);
+ }
+ } finally {
+ error_log('now on version ' . $db->getDbVersion());
+ }
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/GlobalConfig.php b/Digitigrade/GlobalConfig.php
new file mode 100644
index 0000000..f17b758
--- /dev/null
+++ b/Digitigrade/GlobalConfig.php
@@ -0,0 +1,44 @@
+<?php
+namespace Digitigrade;
+
+class GlobalConfig extends Singleton {
+ private const CONFIG_INI_PATH = __DIR__ . '/../config.ini';
+ private array $confData;
+
+ protected function __construct() {
+ $this->confData = parse_ini_file(self::CONFIG_INI_PATH, process_sections: true);
+ }
+
+ public function getCanonicalHost(): string {
+ $host = $this->confData['instance']['hostname'];
+ if (isset($this->confData['instance']['port']) && $this->confData['instance']['port'] != ($this->isHttps() ? 443 : 80))
+ $host .= ':' . $this->confData['instance']['port'];
+ return $host;
+ }
+
+ public function isHttps(): bool {
+ return $this->confData['debug']['https'] ?? true;
+ }
+
+ public function getBasePath(): string {
+ return ($this->isHttps() ? 'https://' : 'http://') . $this->getCanonicalHost();
+ }
+
+ public function shouldReportTraces(): bool {
+ return $this->confData['debug']['send_tracebacks'] ?? false;
+ }
+
+ public function getDbConnection(): string {
+ return 'pgsql:host=' . $this->confData['database']['host'] .
+ ';port=' . $this->confData['database']['port'] .
+ ';dbname=' . $this->confData['database']['database'];
+ }
+
+ public function getDbUser(): string {
+ return $this->confData['database']['username'];
+ }
+
+ public function getDbPassword(): string {
+ return $this->confData['database']['password'];
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/HttpResponseStatus.php b/Digitigrade/HttpResponseStatus.php
new file mode 100644
index 0000000..e169833
--- /dev/null
+++ b/Digitigrade/HttpResponseStatus.php
@@ -0,0 +1,7 @@
+<?php
+namespace Digitigrade;
+
+interface HttpResponseStatus extends \Throwable {
+ public function httpCode(): int;
+ public function httpReason(): string;
+} \ No newline at end of file
diff --git a/Digitigrade/HttpResponseStatus/BadRequest.php b/Digitigrade/HttpResponseStatus/BadRequest.php
new file mode 100644
index 0000000..ba7ce65
--- /dev/null
+++ b/Digitigrade/HttpResponseStatus/BadRequest.php
@@ -0,0 +1,18 @@
+<?php
+namespace Digitigrade\HttpResponseStatus;
+
+use Digitigrade\HttpResponseStatus;
+
+class BadRequest extends \Exception implements HttpResponseStatus {
+ public function __construct(?string $reason) {
+ $this->message = $reason ?? 'Request does not conform to expectations';
+ }
+
+ public function httpCode(): int {
+ return 400;
+ }
+
+ public function httpReason(): string {
+ return "Bad Request";
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/HttpResponseStatus/InternalServerError.php b/Digitigrade/HttpResponseStatus/InternalServerError.php
new file mode 100644
index 0000000..237c526
--- /dev/null
+++ b/Digitigrade/HttpResponseStatus/InternalServerError.php
@@ -0,0 +1,23 @@
+<?php
+namespace Digitigrade\HttpResponseStatus;
+
+use Digitigrade\GlobalConfig;
+use Digitigrade\HttpResponseStatus;
+
+class InternalServerError extends \Exception implements HttpResponseStatus {
+ public function __construct(\Throwable $reason, ?string $context = null) {
+ $this->message = $reason::class . ': ' . $reason->getMessage();
+ if (isset($context)) {
+ $this->message .= " [$context]";
+ }
+ if (GlobalConfig::getInstance()->shouldReportTraces()) {
+ $this->message .= "\n" . $reason->getTraceAsString();
+ }
+ }
+ public function httpCode(): int {
+ return 500;
+ }
+ public function httpReason(): string {
+ return "Internal Server Error";
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/HttpResponseStatus/NoHandlerFound.php b/Digitigrade/HttpResponseStatus/NoHandlerFound.php
new file mode 100644
index 0000000..871b873
--- /dev/null
+++ b/Digitigrade/HttpResponseStatus/NoHandlerFound.php
@@ -0,0 +1,18 @@
+<?php
+namespace Digitigrade\HttpResponseStatus;
+
+use Digitigrade\HttpResponseStatus;
+
+class NoHandlerFound extends \Exception implements HttpResponseStatus {
+ public function __construct(string $path) {
+ $this->message = "No handler found for path $path";
+ }
+
+ public function httpCode(): int {
+ return 404;
+ }
+
+ public function httpReason(): string {
+ return "Not Found";
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/HttpResponseStatus/NotFound.php b/Digitigrade/HttpResponseStatus/NotFound.php
new file mode 100644
index 0000000..643976c
--- /dev/null
+++ b/Digitigrade/HttpResponseStatus/NotFound.php
@@ -0,0 +1,18 @@
+<?php
+namespace Digitigrade\HttpResponseStatus;
+
+use Digitigrade\HttpResponseStatus;
+
+class NotFound extends \Exception implements HttpResponseStatus {
+ public function __construct(?string $reason) {
+ $this->message = $reason ?? 'Request was handled but no appropriate resource found';
+ }
+
+ public function httpCode(): int {
+ return 404;
+ }
+
+ public function httpReason(): string {
+ return "Not Found";
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model.php b/Digitigrade/Model.php
new file mode 100644
index 0000000..8a593eb
--- /dev/null
+++ b/Digitigrade/Model.php
@@ -0,0 +1,219 @@
+<?php
+namespace Digitigrade;
+
+use Digitigrade\Model\FetchableModel;
+
+abstract class Model {
+ private bool $saved = false;
+
+ protected function setOwnerId(int $id) {
+ }
+
+ /**
+ * @return ?string a condition for a WHERE clause if this object already
+ * exists in the database, or null otherwise
+ */
+ protected function getUpdateWhereClause(\PDO $db): ?string {
+ if (isset($this->id) && static::find($this->{'id'}) != null) {
+ return 'id = ' . $this->{'id'};
+ }
+ return null;
+ }
+
+ /**
+ * Converts a field or type name into the version used in the database
+ */
+ private static function mangleName(string $name): string {
+ $chars = str_split($name);
+ $chars[0] = strtolower($chars[0]);
+ for ($i = 0; $i < count($chars); $i++) {
+ $c = $chars[$i];
+ if (ctype_upper($c)) {
+ array_splice($chars, $i, 1, ['_', strtolower($c)]);
+ }
+ }
+ return implode($chars);
+ }
+
+ /**
+ * Converts a database column name into the corresponding field name
+ */
+ private static function unmangleName(string $name): string {
+ $chars = str_split($name);
+ for ($i = 0; $i < count($chars); $i++) {
+ if ($chars[$i] == '_') {
+ array_splice($chars, $i, 2, [strtoupper($chars[$i + 1])]);
+ }
+ }
+ return implode($chars);
+ }
+
+ public function toDbRow(): array {
+ $refl = new \ReflectionObject($this);
+ $props = $refl->getProperties();
+ $result = [];
+ foreach ($props as $p) {
+ $pName = $p->getName();
+ if (!isset($this->{$pName}))
+ continue;
+ // we need to determine the plain value to insert ...
+ $value = $this->{$pName};
+ $discarded = false;
+ if ($value instanceof \DateTimeInterface) {
+ // datetime objects should be turned into iso 8601 timestamps
+ $value = $value->format('c');
+ } elseif ($value instanceof Model) {
+ // models should generally be turned into their id if there is one or removed if not
+ if (property_exists($value, 'id') && isset($value->id))
+ $value = $value->{'id'}; // doing it like this to shut up the warning (lol)
+ else
+ $discarded = true;
+ } elseif (is_array($value)) {
+ // arrays ought to be handled separately when saving (different db table)
+ $discarded = true;
+ }
+ if (!$discarded)
+ $result[self::mangleName($pName)] = $value;
+ }
+ return $result;
+ }
+
+ public static function fromDbRow(array|false $columns): ?static {
+ if ($columns === false)
+ return null;
+ // create the model
+ $obj = new static();
+ $refl = new \ReflectionObject($obj);
+ // for each field of the db row ...
+ foreach (array_keys($columns) as $name) {
+ $value = $columns[$name];
+ $name = self::unmangleName($name); // guess the appropriate field in the model
+ try {
+ // try to assign it directly (works for simple values)
+ $obj->{$name} = $value;
+ } catch (\TypeError $e) {
+ // if it's not the right type we have to try to wrangle it into the right one
+ // there's a few strategies for this
+ $type = $refl->getProperty($name)->getType();
+ if ($type == null || !is_a($type, \ReflectionNamedType::class))
+ throw $e;
+ $typeClass = $type->getName();
+ if (enum_exists($typeClass)) {
+ // if it's a backed enum we can find the enum case with the same value
+ $obj->{$name} = $typeClass::from($value);
+ } elseif (is_subclass_of($typeClass, Model::class) && is_int($value)) {
+ // if it's another model we can try to look it up by id automatically
+ $obj->{$name} = $typeClass::find($value);
+ } else {
+ // otherwise try to instantiate the correct class and pass it the simple value
+ $obj->{$name} = new $typeClass($value);
+ }
+ }
+ }
+ // set up any referenced fields if needed
+ $obj->hydrate();
+ $obj->saved = true;
+ return $obj;
+ }
+
+ /**
+ * Initialises fields of the model that contain referenced / foreign keyed objects
+ * which couldn't be initialised automatically
+ * @return void
+ */
+ protected function hydrate() {
+ }
+
+ public static function findWhere(string $whereClause, array $parameters): ?static {
+ $classNameParts = explode('\\', static::class);
+ $className = $classNameParts[count($classNameParts) - 1];
+ $tableName = self::mangleName($className);
+
+ $stmt = Db::getInstance()->getPdo()->prepare("SELECT * FROM $tableName WHERE $whereClause LIMIT 1");
+ $stmt->execute($parameters);
+ return static::fromDbRow($stmt->fetch(\PDO::FETCH_ASSOC));
+ }
+
+ public static function findAllWhere(string $whereClause, array $parameters): array {
+ $classNameParts = explode('\\', static::class);
+ $className = $classNameParts[count($classNameParts) - 1];
+ $tableName = self::mangleName($className);
+
+ $stmt = Db::getInstance()->getPdo()->prepare("SELECT * FROM $tableName WHERE $whereClause");
+ $stmt->execute($parameters);
+ return array_map(function ($row) {
+ return static::fromDbRow($row);
+ }, $stmt->fetchAll(\PDO::FETCH_ASSOC));
+ }
+
+ public static function find(int $id): ?static {
+ return static::findWhere('id = ?', [$id]);
+ }
+
+ /**
+ * Inserts or updates the model's corresponding database record.
+ * Also sets the model's id field if it has one.
+ */
+ public function save() {
+ // figure out all the parameters and such for the query
+ $paramData = $this->toDbRow();
+ $columnNames = array_keys($paramData);
+ $parameterNames = array_map(function (string $column) {
+ return ":$column";
+ }, $columnNames);
+ $columns = implode(', ', $columnNames);
+ $parameters = implode(', ', $parameterNames);
+
+ $classNameParts = explode('\\', static::class);
+ $className = $classNameParts[count($classNameParts) - 1];
+ $tableName = self::mangleName($className);
+
+ $db = Db::getInstance()->getPdo();
+
+ $whereClause = $this->getUpdateWhereClause($db);
+ $shouldUpdate = $whereClause != null;
+ $stmt = $db->prepare(
+ $shouldUpdate
+ ? "UPDATE $tableName SET ($columns) = ($parameters) WHERE $whereClause"
+ : "INSERT INTO $tableName ($columns) VALUES ($parameters)"
+ );
+
+ // pdo is really stupid and casts `false`/0 to an empty string, which breaks the query
+ // so i have to cast `false`s and `0`s manually. hooray
+ // i also have to convert backed enums to their value because they dont implement Stringable
+ $paramData = array_map(function ($item) {
+ if ($item === false || $item === 0)
+ return '0';
+ if (is_a($item, \BackedEnum::class))
+ return $item->value;
+ return $item;
+ }, $paramData);
+ // actually save it !!!! yay
+ $stmt->execute($paramData);
+
+ if (property_exists($this, 'id')) {
+ $this->id = $shouldUpdate
+ ? static::findWhere($whereClause, [])->id
+ : (int) $db->lastInsertId();
+ }
+
+ // recursively save inner models
+ // but not fetchable ones because they should really be saved separately
+ $reflProps = (new \ReflectionObject($this))->getProperties();
+ foreach ($reflProps as $reflProp) {
+ assert($reflProp instanceof \ReflectionProperty);
+ $reflType = $reflProp->getType();
+ $propName = $reflProp->getName();
+ if (!($reflType instanceof \ReflectionNamedType))
+ continue;
+ $type = $reflType->getName();
+ if (is_subclass_of($type, Model::class) && !is_subclass_of($type, FetchableModel::class)) {
+ if (property_exists($this, 'id'))
+ $this->{$propName}->setOwnerId($this->id);
+ $this->{$propName}->save();
+ }
+ }
+
+ $this->saved = true;
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/Actor.php b/Digitigrade/Model/Actor.php
new file mode 100644
index 0000000..946265e
--- /dev/null
+++ b/Digitigrade/Model/Actor.php
@@ -0,0 +1,90 @@
+<?php
+namespace Digitigrade\Model;
+
+use Digitigrade\RemoteFetchable;
+
+class Actor extends FetchableModel implements \JsonSerializable {
+ public const REL_URI = "https://pawpub.entities.org.uk/rel/actor.html";
+
+ public ?int $id;
+ public string $uri;
+ public bool $isLocal = false;
+ public \DateTimeImmutable $created;
+ public ?\DateTimeImmutable $modified;
+ public ?string $homepage;
+ public string $handle;
+ public string $displayName;
+ public ?string $avatar;
+ public ?string $bio;
+ public ?string $pronouns;
+ public bool $automated = false;
+ public bool $requestToFollow = false;
+ public ActorEndpoints $endpoints;
+ public array $extensions = [];
+
+ protected function getUpdateWhereClause(\PDO $db): ?string {
+ if (self::findWhere('uri = ?', [$this->uri]) != null)
+ return 'uri = ' . $db->quote($this->uri);
+ if (self::findWhere('id = ?', [$this->id]) != null)
+ return "id = $this->id";
+ return null;
+ }
+
+ public static function findLocalByHandle(string $handle): ?self {
+ return self::findWhere('is_local = true AND handle = ?', [$handle]);
+ }
+
+ public static function findByWebfinger(string $acct, bool $autoSave = true, bool $forceRefetch = false): ?self {
+ // normalise uri
+ if (!str_starts_with($acct, 'acct:')) {
+ if (str_starts_with($acct, '@')) {
+ $acct = substr($acct, 1);
+ }
+ $acct = "acct:$acct";
+ }
+ // fetch it
+ $domain = explode('@', $acct, 2)[1];
+ $data = @file_get_contents("https://$domain/.well-known/webfinger?resource=$acct",
+ context: stream_context_create([
+ 'http' => ['header' => 'Accept: application/jrd+json, application/json']
+ ])
+ );
+ if ($data === false)
+ return null;
+ $data = json_decode($data);
+ if ($data === null)
+ return null;
+ // find the right link
+ if (!isset($data->links))
+ return null;
+ $matching = array_filter($data->links, function (\stdClass $linkObj) {
+ return $linkObj->rel == self::REL_URI;
+ });
+ if (count($matching) == 0)
+ return null;
+ return self::findByUri($matching[0]->href, $autoSave, $forceRefetch);
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'type' => 'actor',
+ 'self' => path_to_uri("/user/$this->handle"),
+ 'created' => $this->created?->format('c'),
+ 'modified' => $this->modified?->format('c'),
+ 'homepage' => path_to_uri("/$this->handle"),
+ 'handle' => $this->handle,
+ 'displayName' => $this->displayName,
+ 'bio' => $this->bio,
+ 'pronouns' => $this->pronouns,
+ 'automated' => $this->automated,
+ 'endpoints' => [
+ 'basicFeed' => path_to_uri("/user/$this->handle/basicFeed"),
+ 'fullFeed' => path_to_uri("/user/$this->handle/fullFeed")
+ ]
+ ];
+ }
+
+ public function hydrate() {
+ $this->endpoints = ActorEndpoints::findWhere('actor_id = ?', [$this->id]);
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/ActorEndpoints.php b/Digitigrade/Model/ActorEndpoints.php
new file mode 100644
index 0000000..8821d1c
--- /dev/null
+++ b/Digitigrade/Model/ActorEndpoints.php
@@ -0,0 +1,27 @@
+<?php
+namespace Digitigrade\Model;
+
+use Digitigrade\Model;
+
+class ActorEndpoints extends Model {
+ public ?int $actorId;
+ public string $basicFeed;
+ public ?string $fullFeed;
+ public ?string $follow;
+ public ?string $unfollow;
+ public ?string $block;
+ public ?string $unblock;
+ public ?string $subscribe;
+ public ?string $unsubscribe;
+
+ protected function setOwnerId(int $id) {
+ $this->actorId = $id;
+ }
+
+ protected function getUpdateWhereClause($db): ?string {
+ if (self::findWhere('actor_id = ?', [$this->actorId]) != null) {
+ return "actor_id = $this->actorId";
+ }
+ return null;
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/FetchableModel.php b/Digitigrade/Model/FetchableModel.php
new file mode 100644
index 0000000..30b68e7
--- /dev/null
+++ b/Digitigrade/Model/FetchableModel.php
@@ -0,0 +1,78 @@
+<?php
+namespace Digitigrade\Model;
+
+use Digitigrade\Model;
+use Digitigrade\RemoteFetchable;
+
+abstract class FetchableModel extends Model implements RemoteFetchable {
+ private static \JsonMapper $mapper;
+
+ public static function __initStatic() {
+ self::$mapper = new \JsonMapper();
+ // this is unfortunately necessary to parse DateTime(Immutable)s properly
+ self::$mapper->bStrictObjectTypeChecking = false;
+ }
+
+ private static function fetchFromRemote(string $uri, bool $autoSave): ?static {
+ // fetch the object
+ $data = @file_get_contents($uri, context: stream_context_create(['http' => [
+ 'header' => 'Accept: application/json'
+ ]]));
+ if ($data === false)
+ return null;
+ $data = json_decode($data);
+ if ($data === null)
+ return null;
+ // default everything nullable to null
+ $obj = new static();
+ $reflProps = (new \ReflectionClass(static::class))->getProperties();
+ foreach ($reflProps as $reflProp) {
+ assert($reflProp instanceof \ReflectionProperty); // for intellisense lmao
+ if ($reflProp->getType()->allowsNull()) {
+ $obj->{$reflProp->getName()} = null;
+ }
+ }
+ // rename 'self' to 'uri' because of reasons
+ if (property_exists($data, 'self')) {
+ $data->uri = $data->self;
+ unset($data->self);
+ }
+ // map all present fields
+ self::$mapper->map($data, $obj);
+ // (hopefully) recursively fetch any other fetchable models contained in this one
+ foreach ($reflProps as $reflProp) {
+ assert($reflProp instanceof \ReflectionProperty);
+ $reflType = $reflProp->getType();
+ $propName = $reflProp->getName();
+ if (!($reflType instanceof \ReflectionNamedType))
+ continue;
+ $type = $reflType->getName();
+ if (is_subclass_of($type, FetchableModel::class) && is_string($data->{$propName})) {
+ // try to avoid recursion
+ // TODO: make a better effort at avoiding recursion (e.g. when it's more than one step nested)
+ if ($data->{$propName} == $uri) {
+ $obj->{$propName} = $obj;
+ } else {
+ $obj->{$propName} = $type::findByUri($data->{$propName}, $autoSave);
+ }
+ }
+ }
+
+ if ($autoSave) {
+ $obj->save();
+ }
+
+ return $obj;
+ }
+
+ public static function findByUri(string $uri, bool $autoSave = true, bool $forceRefetch = false): ?static {
+ if (!$forceRefetch) {
+ $obj = static::findWhere('uri = ?', [$uri]);
+ if ($obj != null)
+ return $obj;
+ }
+ return static::fetchFromRemote($uri, $autoSave);
+ }
+}
+
+FetchableModel::__initStatic(); \ No newline at end of file
diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php
new file mode 100644
index 0000000..3b7c362
--- /dev/null
+++ b/Digitigrade/Model/Note.php
@@ -0,0 +1,95 @@
+<?php
+namespace Digitigrade\Model;
+
+use Digitigrade\Db;
+
+class Note extends FetchableModel implements \JsonSerializable {
+ public ?int $id;
+ public string $uri;
+ public \DateTimeImmutable $created;
+ public ?\DateTimeImmutable $modified;
+ public Actor $author;
+ public ?string $summary;
+ public string $plainContent;
+ /**
+ * @var array<string, string>
+ */
+ public array $formattedContent = [];
+ public string $language;
+ /**
+ * @var Actor[]
+ */
+ public array $mentions = [];
+ public ?Note $inReplyTo;
+ public ?Note $threadApex;
+ public NotePrivacy $privacy;
+ /**
+ * @var NoteAttachment[]
+ */
+ public array $attachments = [];
+ public array $extensions = [];
+
+ protected function getUpdateWhereClause(\PDO $db): ?string {
+ if (self::findWhere('uri = ?', [$this->uri]) != null)
+ return 'uri = ' . $db->quote($this->uri);
+ if (self::findWhere('id = ?', [$this->id]) != null)
+ return "id = $this->id";
+ return null;
+ }
+
+ protected function hydrate() {
+ $this->privacy = NotePrivacy::findWhere('note_id = ?', [$this->id]);
+ $this->formattedContent = $this->findFormattedContents();
+ $this->mentions = $this->findMentions();
+ $this->attachments = NoteAttachment::findAllWhere('note_id = ?', [$this->id]);
+ }
+
+ private function findFormattedContents(): array {
+ $pdo = Db::getInstance()->getPdo();
+ $stmt = $pdo->prepare('SELECT mimetype, body FROM note_formatted_content WHERE note_id = ?');
+ $stmt->execute([$this->id]);
+ return $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
+ private function findMentions(): array {
+ $pdo = Db::getInstance()->getPdo();
+ $stmt = $pdo->prepare('SELECT actor_id FROM note_mention WHERE note_id = ?');
+ $stmt->execute([$this->id]);
+ $actors = array_map(function ($actorId) {
+ return Actor::find($actorId);
+ }, $stmt->fetchAll(\PDO::FETCH_COLUMN, 0));
+ return $actors;
+ }
+
+ public static function findAllWithAuthor(Actor $author): array {
+ return self::findAllWhere('author = ?', [$author->id]);
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'type' => 'note',
+ 'self' => path_to_uri("/post/$this->id"),
+ 'created' => $this->created->format('c'),
+ 'modified' => $this->modified?->format('c'),
+ 'author' => $this->author->uri,
+ 'summary' => $this->summary,
+ 'plainContent' => $this->plainContent,
+ 'formattedContent' => $this->formattedContent,
+ 'language' => $this->language,
+ 'mentions' => array_map(function (Actor $actor) {
+ return $actor->uri;
+ }, $this->mentions),
+ 'inReplyTo' => $this->inReplyTo?->uri,
+ 'threadApex' => $this->threadApex?->uri,
+ 'privacy' => $this->privacy,
+ 'attachments' => array_map(function (NoteAttachment $attachment) {
+ return [
+ 'type' => $attachment->type,
+ 'href' => $attachment->href,
+ 'description' => $attachment->description
+ ];
+ }, $this->attachments),
+ //'extensions' => []
+ ];
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/NoteAttachment.php b/Digitigrade/Model/NoteAttachment.php
new file mode 100644
index 0000000..c47ec64
--- /dev/null
+++ b/Digitigrade/Model/NoteAttachment.php
@@ -0,0 +1,23 @@
+<?php
+namespace Digitigrade\Model;
+
+use Digitigrade\Model;
+
+class NoteAttachment extends Model {
+ public ?int $noteId;
+ public int $index;
+ public string $type;
+ public string $href;
+ public ?string $description;
+
+ protected function setOwnerId(int $id) {
+ $this->noteId = $id;
+ }
+
+ protected function getUpdateWhereClause($db): ?string {
+ if (self::findWhere('note_id = ?', [$this->noteId]) != null) {
+ return "note_id = $this->noteId";
+ }
+ return null;
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/NotePrivacy.php b/Digitigrade/Model/NotePrivacy.php
new file mode 100644
index 0000000..faaacf6
--- /dev/null
+++ b/Digitigrade/Model/NotePrivacy.php
@@ -0,0 +1,39 @@
+<?php
+namespace Digitigrade\Model;
+
+use Digitigrade\Model;
+
+class NotePrivacy extends Model implements \JsonSerializable {
+ public ?int $noteId;
+ public NotePrivacyScope $scope;
+ /**
+ * @var Actor[]
+ */
+ public array $alsoVisibleTo = [];
+ public bool $indexable;
+ public NotePrivacyInteractors $canReshare = NotePrivacyInteractors::ALL;
+ public NotePrivacyInteractors $canReply = NotePrivacyInteractors::ALL;
+ public NotePrivacyInteractors $canOtherInteract = NotePrivacyInteractors::ALL;
+
+ protected function setOwnerId(int $id) {
+ $this->noteId = $id;
+ }
+
+ protected function getUpdateWhereClause($db): ?string {
+ if (self::findWhere('note_id = ?', [$this->noteId]) != null) {
+ return "note_id = $this->noteId";
+ }
+ return null;
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'scope' => $this->scope->value,
+ 'alsoVisibleTo' => $this->alsoVisibleTo,
+ 'indexable' => $this->indexable,
+ 'canReshare' => $this->canReshare->value,
+ 'canReply' => $this->canReply->value,
+ 'canOtherInteract' => $this->canOtherInteract->value
+ ];
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/NotePrivacyInteractors.php b/Digitigrade/Model/NotePrivacyInteractors.php
new file mode 100644
index 0000000..8313e90
--- /dev/null
+++ b/Digitigrade/Model/NotePrivacyInteractors.php
@@ -0,0 +1,9 @@
+<?php
+namespace Digitigrade\Model;
+
+enum NotePrivacyInteractors: string {
+ case ALL = 'all';
+ case FOLLOWERS = 'followers';
+ case MUTUALS = 'mutuals';
+ case NONE = 'none';
+} \ No newline at end of file
diff --git a/Digitigrade/Model/NotePrivacyScope.php b/Digitigrade/Model/NotePrivacyScope.php
new file mode 100644
index 0000000..84d84f2
--- /dev/null
+++ b/Digitigrade/Model/NotePrivacyScope.php
@@ -0,0 +1,9 @@
+<?php
+namespace Digitigrade\Model;
+
+enum NotePrivacyScope: string {
+ case PUBLIC = 'public';
+ case FOLLOWERS = 'followers';
+ case MUTUALS = 'mutuals';
+ case NONE = 'none';
+} \ No newline at end of file
diff --git a/Digitigrade/RemoteFetchable.php b/Digitigrade/RemoteFetchable.php
new file mode 100644
index 0000000..4b4262d
--- /dev/null
+++ b/Digitigrade/RemoteFetchable.php
@@ -0,0 +1,21 @@
+<?php
+namespace Digitigrade;
+
+interface RemoteFetchable {
+ /**
+ * Fetches the object by its URI from the remote server.
+ * @param string $uri the URI of the remote object
+ * @param bool $autoSave whether to automatically save the fetched object to the database
+ * @return static
+ */
+ //public static function fetchFromRemote(string $uri, bool $autoSave);
+
+ /**
+ * Finds the object locally by its URI or fetches it remotely if not found.
+ * @param string $uri the URI identifier of the object
+ * @param bool $autoSave whether to automatically save the object (if fetched) to the database
+ * @param bool $forceRefetch whether to forcibly refetch the object even if it's in the database
+ * @return static
+ */
+ public static function findByUri(string $uri, bool $autoSave = true, bool $forceRefetch = false);
+} \ No newline at end of file
diff --git a/Digitigrade/Router.php b/Digitigrade/Router.php
new file mode 100644
index 0000000..fb984d5
--- /dev/null
+++ b/Digitigrade/Router.php
@@ -0,0 +1,98 @@
+<?php
+namespace Digitigrade;
+
+use Digitigrade\HttpResponseStatus\InternalServerError;
+use Digitigrade\HttpResponseStatus\NoHandlerFound;
+
+class Router extends Singleton {
+
+ private readonly string $requestPath;
+ private array $routes;
+
+ protected function __construct() {
+ $this->requestPath = $_GET['requestPath'] ?? $_SERVER['PATH_INFO'] ?? explode('?', $_SERVER['REQUEST_URI'], 2)[0] ?? '/';
+ $this->routes = [];
+ }
+
+ /**
+ * Processes and sends a response to the current request
+ * @return void
+ */
+ public function processRequest() {
+ try {
+ $this->processRequestInner();
+ } catch (HttpResponseStatus $e) {
+ http_response_code($e->httpCode());
+ echo '<h1>' . $e->httpReason() . '</h1><pre>' . $e->getMessage() . '</pre>';
+ }
+ }
+
+ private function processRequestInner() {
+ $context = null;
+ try {
+ $route = $this->matchPath($this->requestPath);
+ if ($route == null) {
+ throw new NoHandlerFound($this->requestPath);
+ }
+ $context = 'handler for /' . implode('/', $route[0]);
+ $route[1]($route[2]); // call handler with args
+ } catch (\Exception $e) {
+ if (is_a($e, HttpResponseStatus::class)) {
+ throw $e;
+ }
+ throw new InternalServerError($e, $context);
+ } catch (\Error $e) {
+ throw new InternalServerError($e, $context);
+ }
+ }
+
+ public function mount(string $pathSpec, callable $handler) {
+ $this->routes[] = [$this->explodePathPieces($pathSpec), $handler];
+ }
+
+ private function explodePathPieces(string $pathSpec): array {
+ $pieces = explode('/', $pathSpec);
+ if ($pieces[0] == '')
+ array_shift($pieces);
+ return $pieces;
+ }
+
+ private function matchPath(string $requestPath): ?array {
+ // find matching candidates
+ $pieces = $this->explodePathPieces($requestPath);
+ $candidates = [];
+ foreach ($this->routes as $route) {
+ $routePieces = $route[0];
+ if (count($routePieces) != count($pieces))
+ continue;
+ $args = [];
+ $matches = true;
+ for ($i = 0; $i < count($pieces); $i++) {
+ if (str_starts_with($routePieces[$i], ':')) { // this is a parameter
+ $args[substr($routePieces[$i], 1)] = $pieces[$i];
+ } elseif ($routePieces[$i] != $pieces[$i]) {
+ $matches = false;
+ break;
+ }
+ }
+ if ($matches) {
+ $candidates[] = [$route[0], $route[1], $args];
+ }
+ }
+
+ // select the best matching one (has the longest path, for now)
+ $bestScore = 0;
+ $bestItem = null;
+ foreach ($candidates as $c) {
+ $score = count($c[0]) - count(array_filter($c[0], function ($item) {
+ return str_starts_with($item, ':');
+ })) * 0.1;
+ if ($score > $bestScore) {
+ $bestItem = $c;
+ $bestScore = $score;
+ }
+ }
+ return $bestItem;
+ }
+
+}
diff --git a/Digitigrade/Singleton.php b/Digitigrade/Singleton.php
new file mode 100644
index 0000000..23b2e78
--- /dev/null
+++ b/Digitigrade/Singleton.php
@@ -0,0 +1,18 @@
+<?php
+namespace Digitigrade;
+
+abstract class Singleton {
+ private static array $instances;
+ protected function __construct() {
+ }
+ /**
+ * @return static the singleton instance
+ */
+ public static function getInstance() {
+ if (!isset(self::$instances[static::class])) {
+ //error_log('making a new ' . static::class);
+ self::$instances[static::class] = new static();
+ }
+ return self::$instances[static::class];
+ }
+} \ No newline at end of file