diff options
| author | winter | 2024-12-08 18:04:37 +0000 |
|---|---|---|
| committer | winter | 2024-12-08 18:04:37 +0000 |
| commit | b00185ddbac9ac3de975a3954f2ede2f24458f6a (patch) | |
| tree | 265ccaae497e2788ed7de8f6b08a0d5a77ea16dc | |
| parent | 13647d55bd8085a2b3a686b8aad3b28b0faf693a (diff) | |
implement notes
| -rw-r--r-- | WpfTest/Model.php | 46 | ||||
| -rw-r--r-- | WpfTest/Model/Actor.php | 25 | ||||
| -rw-r--r-- | WpfTest/Model/Note.php | 95 | ||||
| -rw-r--r-- | WpfTest/Model/NoteAttachment.php | 11 | ||||
| -rw-r--r-- | WpfTest/Model/NotePrivacy.php | 16 | ||||
| -rw-r--r-- | WpfTest/Model/NotePrivacyInteractors.php | 9 | ||||
| -rw-r--r-- | WpfTest/Model/NotePrivacyScope.php | 9 | ||||
| -rw-r--r-- | migrations/20241208_111936_create_note.php | 54 | ||||
| -rw-r--r-- | migrations/20241208_165411_create_note_mentions.php | 14 | ||||
| -rw-r--r-- | migrations/20241208_170639_add_note_attachment_type.php | 8 | ||||
| -rw-r--r-- | routes/actor.php | 41 | ||||
| -rw-r--r-- | routes/note.php | 26 |
12 files changed, 309 insertions, 45 deletions
diff --git a/WpfTest/Model.php b/WpfTest/Model.php index 26a7b49..c006ffd 100644 --- a/WpfTest/Model.php +++ b/WpfTest/Model.php @@ -3,8 +3,6 @@ namespace WpfTest; abstract class Model { private static function mangleName(string $name): string { - if ($name == 'self') - return 'uri'; $chars = str_split($name); $chars[0] = strtolower($chars[0]); for ($i = 0; $i < count($chars); $i++) { @@ -17,8 +15,6 @@ abstract class Model { } private static function unmangleName(string $name): string { - if ($name == 'uri') - return 'self'; $chars = str_split($name); for ($i = 0; $i < count($chars); $i++) { if ($chars[$i] == '_') { @@ -41,32 +37,68 @@ abstract class Model { 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); + $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; - $obj->{$name} = new ($type->getName())($value); + $typeClass = $type->getName(); + if (enum_exists($typeClass)) { + // if it's an enum we can find the enum case with the same name + $obj->{$name} = (new \ReflectionEnum($typeClass))->getCase($value)->getValue(); + } 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 extra-complicated fields if needed + $obj->hydrate($columns); return $obj; } + /** + * Initialises fields of the model that contain more complex objects + * @param array $row the row of the database + * @return void + */ + protected function hydrate(array $row) { + } + 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"); + $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]); } diff --git a/WpfTest/Model/Actor.php b/WpfTest/Model/Actor.php index cf8d7ca..eaef99c 100644 --- a/WpfTest/Model/Actor.php +++ b/WpfTest/Model/Actor.php @@ -3,9 +3,9 @@ namespace WpfTest\Model; use WpfTest\Model; -class Actor extends Model { - public int $id; - public string $self; +class Actor extends Model implements \JsonSerializable { + public ?int $id; + public string $uri; public bool $isLocal = false; public \DateTimeImmutable $created; public ?\DateTimeImmutable $modified; @@ -23,4 +23,23 @@ class Actor extends Model { public static function findLocalByHandle(string $handle): ?self { return self::findWhere('is_local = true AND handle = ?', [$handle]); } + + 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") + ] + ]; + } }
\ No newline at end of file diff --git a/WpfTest/Model/Note.php b/WpfTest/Model/Note.php new file mode 100644 index 0000000..85b3567 --- /dev/null +++ b/WpfTest/Model/Note.php @@ -0,0 +1,95 @@ +<?php +namespace WpfTest\Model; + +use WpfTest\Db; +use WpfTest\Model; + +class Note extends Model 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 hydrate(array $row) { + $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' => [ + 'scope' => $this->privacy->scope->name, + 'alsoVisibleTo' => $this->privacy->alsoVisibleTo, + 'indexable' => $this->privacy->indexable, + 'canReshare' => $this->privacy->canReshare->name, + 'canReply' => $this->privacy->canReply->name, + 'canOtherInteract' => $this->privacy->canOtherInteract->name + ], + '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/WpfTest/Model/NoteAttachment.php b/WpfTest/Model/NoteAttachment.php new file mode 100644 index 0000000..b07d595 --- /dev/null +++ b/WpfTest/Model/NoteAttachment.php @@ -0,0 +1,11 @@ +<?php +namespace WpfTest\Model; + +use WpfTest\Model; + +class NoteAttachment extends Model { + public int $index; + public string $type; + public string $href; + public ?string $description; +}
\ No newline at end of file diff --git a/WpfTest/Model/NotePrivacy.php b/WpfTest/Model/NotePrivacy.php new file mode 100644 index 0000000..b7ed58a --- /dev/null +++ b/WpfTest/Model/NotePrivacy.php @@ -0,0 +1,16 @@ +<?php +namespace WpfTest\Model; + +use WpfTest\Model; + +class NotePrivacy extends Model { + 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; +}
\ No newline at end of file diff --git a/WpfTest/Model/NotePrivacyInteractors.php b/WpfTest/Model/NotePrivacyInteractors.php new file mode 100644 index 0000000..ff4d7fa --- /dev/null +++ b/WpfTest/Model/NotePrivacyInteractors.php @@ -0,0 +1,9 @@ +<?php +namespace WpfTest\Model; + +enum NotePrivacyInteractors { + case all; + case followers; + case mutuals; + case none; +}
\ No newline at end of file diff --git a/WpfTest/Model/NotePrivacyScope.php b/WpfTest/Model/NotePrivacyScope.php new file mode 100644 index 0000000..3134c26 --- /dev/null +++ b/WpfTest/Model/NotePrivacyScope.php @@ -0,0 +1,9 @@ +<?php +namespace WpfTest\Model; + +enum NotePrivacyScope { + case public; + case followers; + case mutuals; + case none; +}
\ No newline at end of file diff --git a/migrations/20241208_111936_create_note.php b/migrations/20241208_111936_create_note.php new file mode 100644 index 0000000..a8471e7 --- /dev/null +++ b/migrations/20241208_111936_create_note.php @@ -0,0 +1,54 @@ +<?php + +use \WpfTest\Db\Migrator; + +Migrator::getInstance()->register(20241208_111936, function (PDO $db) { + // create notes and their related tables + $db->exec(<<<END + CREATE TABLE note ( + id bigserial primary key not null, + uri text unique not null, + created timestamp with time zone not null, + modified timestamp with time zone, + author bigint not null references actor(id), + summary text, + plain_content text not null, + language text not null, + in_reply_to text, + thread_apex bigint references note(id) + ); + CREATE TABLE note_formatted_content ( + note_id bigint not null references note(id), + mimetype text not null, + body text not null, + unique(note_id, mimetype) + ); + CREATE TYPE note_privacy_scope AS ENUM ( + 'public', 'followers', 'mutuals', 'none' + ); + CREATE TYPE note_privacy_interactors AS ENUM ( + 'all', 'followers', 'mutuals', 'none' + ); + CREATE TABLE note_privacy ( + note_id bigint unique not null references note(id), + scope note_privacy_scope not null, + indexable boolean not null, + can_reshare note_privacy_interactors not null default 'all', + can_reply note_privacy_interactors not null default 'all', + can_other_interact note_privacy_interactors not null default 'all' + ); + CREATE TABLE note_attachment ( + note_id bigint not null references note(id), + index int not null, + href text not null, + description text, + unique(note_id, index) + ); + CREATE TABLE note_extension ( + note_id bigint not null references note(id), + uri text not null, + data jsonb not null, + unique(note_id, uri) + ); + END); +}); diff --git a/migrations/20241208_165411_create_note_mentions.php b/migrations/20241208_165411_create_note_mentions.php new file mode 100644 index 0000000..f589161 --- /dev/null +++ b/migrations/20241208_165411_create_note_mentions.php @@ -0,0 +1,14 @@ +<?php + +use \WpfTest\Db\Migrator; + +Migrator::getInstance()->register(20241208_165411, function (PDO $db) { + // forgot this table in create_note :( + $db->exec(<<<END + CREATE TABLE note_mention ( + note_id bigint not null references note(id), + actor_id bigint not null references actor(id), + unique(note_id, actor_id) + ); + END); +}); diff --git a/migrations/20241208_170639_add_note_attachment_type.php b/migrations/20241208_170639_add_note_attachment_type.php new file mode 100644 index 0000000..66d2808 --- /dev/null +++ b/migrations/20241208_170639_add_note_attachment_type.php @@ -0,0 +1,8 @@ +<?php + +use \WpfTest\Db\Migrator; + +Migrator::getInstance()->register(20241208_170639, function (PDO $db) { + // forgot this one also + $db->exec('ALTER TABLE note_attachment ADD COLUMN type text not null'); +}); diff --git a/routes/actor.php b/routes/actor.php index 710adfa..5879538 100644 --- a/routes/actor.php +++ b/routes/actor.php @@ -2,6 +2,7 @@ use WpfTest\HttpResponseStatus\NotFound; use WpfTest\Model\Actor; +use WpfTest\Model\Note; use WpfTest\Router; Router::getInstance()->mount('/user/:handle', function (array $args) { @@ -9,35 +10,33 @@ Router::getInstance()->mount('/user/:handle', function (array $args) { if ($actor == null) { throw new NotFound("i don't know any local user called " . $args['handle']); } + json_response($actor); +}); + +Router::getInstance()->mount('/user/:handle/basicFeed', function (array $args) { + $actor = Actor::findLocalByHandle($args['handle']); + $notes = Note::findAllWithAuthor($actor); + // TODO: implement pagination json_response([ - 'type' => 'actor', - 'dbgIsLocal' => $actor->isLocal, - 'self' => path_to_uri("/user/$actor->handle"), - 'created' => $actor->created?->format('c'), - 'modified' => $actor->modified?->format('c'), - 'homepage' => path_to_uri("/$actor->handle"), - 'handle' => $actor->handle, - 'displayName' => $actor->displayName, - 'bio' => $actor->bio, - 'pronouns' => $actor->pronouns, - 'automated' => $actor->automated, - 'endpoints' => [ - 'basicFeed' => path_to_uri("/user/$actor->handle/basicFeed") - ] + 'page' => 1, + 'totalPages' => 1, + 'nextPage' => null, + 'previousPage' => null, + 'items' => array_map(function (Note $note) { + return $note->uri; + }, $notes) ]); }); -Router::getInstance()->mount('/user/:username/basicFeed', function (array $args) { - if ($args['username'] != 'winter') { - throw new NotFound('i only know about winter'); - } +Router::getInstance()->mount('/user/:handle/fullFeed', function (array $args) { + $actor = Actor::findLocalByHandle($args['handle']); + $notes = Note::findAllWithAuthor($actor); + // TODO: implement pagination here as well json_response([ 'page' => 1, 'totalPages' => 1, 'nextPage' => null, 'previousPage' => null, - 'items' => [ - path_to_uri('/post/1') - ] + 'items' => $notes ]); });
\ No newline at end of file diff --git a/routes/note.php b/routes/note.php index 9c5e507..e43c2a1 100644 --- a/routes/note.php +++ b/routes/note.php @@ -1,20 +1,18 @@ <?php +use WpfTest\HttpResponseStatus\NotFound; +use WpfTest\Model\Actor; +use WpfTest\Model\Note; +use WpfTest\Model\NoteAttachment; use WpfTest\Router; Router::getInstance()->mount('/post/:id', function (array $args) { - json_response([ - 'type' => 'note', - 'self' => path_to_uri('/post/' . $args['id']), - 'created' => '2024-12-06T20:14:00+00:00', - 'author' => path_to_uri('/user/winter'), - 'plainContent' => 'meow :3', - 'language' => 'eng', - 'inReplyTo' => null, - 'threadApex' => path_to_uri('/post/' . $args['id']), - 'privacy' => [ - 'scope' => 'public', - 'indexable' => true - ] - ]); + $note = Note::find($args['id']); + if ($note == null) { + throw new NotFound("i don't know that note"); + } + if (!$note->author->isLocal) { + throw new NotFound("i don't want to tell you about non local posts sorry"); + } + json_response($note); }); |
