aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorwinter2024-12-08 18:04:37 +0000
committerwinter2024-12-08 18:04:37 +0000
commitb00185ddbac9ac3de975a3954f2ede2f24458f6a (patch)
tree265ccaae497e2788ed7de8f6b08a0d5a77ea16dc
parent13647d55bd8085a2b3a686b8aad3b28b0faf693a (diff)
implement notes
-rw-r--r--WpfTest/Model.php46
-rw-r--r--WpfTest/Model/Actor.php25
-rw-r--r--WpfTest/Model/Note.php95
-rw-r--r--WpfTest/Model/NoteAttachment.php11
-rw-r--r--WpfTest/Model/NotePrivacy.php16
-rw-r--r--WpfTest/Model/NotePrivacyInteractors.php9
-rw-r--r--WpfTest/Model/NotePrivacyScope.php9
-rw-r--r--migrations/20241208_111936_create_note.php54
-rw-r--r--migrations/20241208_165411_create_note_mentions.php14
-rw-r--r--migrations/20241208_170639_add_note_attachment_type.php8
-rw-r--r--routes/actor.php41
-rw-r--r--routes/note.php26
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);
});