diff --git a/database/2025_04_21_005231_kaomoji_table.php b/database/2025_04_21_005231_kaomoji_table.php new file mode 100644 index 00000000..97aad775 --- /dev/null +++ b/database/2025_04_21_005231_kaomoji_table.php @@ -0,0 +1,15 @@ +<?php +use Index\Db\DbConnection; +use Index\Db\Migration\DbMigration; + +final class KaomojiTable_20250421_005231 implements DbMigration { + public function migrate(DbConnection $conn): void { + $conn->execute(<<<SQL + CREATE TABLE msz_kaomoji ( + kao_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + kao_string VARCHAR(255) NOT NULL COLLATE 'utf8mb4_bin', + PRIMARY KEY (kao_id) + ) COLLATE='utf8mb4_bin' ENGINE=InnoDB + SQL); + } +} diff --git a/src/Kaomoji/KaomojiApiRoutes.php b/src/Kaomoji/KaomojiApiRoutes.php new file mode 100644 index 00000000..1b4a60fc --- /dev/null +++ b/src/Kaomoji/KaomojiApiRoutes.php @@ -0,0 +1,76 @@ +<?php +namespace Misuzu\Kaomoji; + +use RuntimeException; +use Index\XArray; +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\AccessControl\AccessControl; +use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; +use Misuzu\FieldTransformer; +use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon}; + +#[HandlerRoles('main')] +final class KaomojiApiRoutes implements RouteHandler { + use RouteHandlerCommon; + + public function __construct( + private KaomojiContext $kaomojiCtx + ) {} + + /** @return FieldTransformer<KaomojiInfo> */ + private function createKaomojiTransformer(): FieldTransformer { + return new FieldTransformer([ + 'id' => [ + 'transform' => fn($kaomoji) => $kaomoji->id, + ], + 'string' => [ + 'default' => true, + 'transform' => fn($kaomoji) => $kaomoji->string, + ], + ]); + } + + /** @return int|mixed[] */ + #[AccessControl] + #[ExactRoute('GET', '/api/v1/kaomoji')] + public function getEmotes(HttpResponseBuilder $response, HttpRequest $request): array|int { + $kaomoji = $this->kaomojiCtx->kaomoji->getAllKaomoji(); + + if($request->hasParam('as')) { + if($request->getParam('as') === 'array') { + $response->setCacheControl('max-age=3600', 'stale-if-error=86400', 'public', 'immutable'); + return XArray::select($kaomoji, fn($kaomoji) => (string)$kaomoji); + } else return 400; + } + + $transformer = $this->createKaomojiTransformer(); + if(!$transformer->filter($request)) + return 400; + + $response->setCacheControl('max-age=3600', 'stale-if-error=86400', 'public', 'immutable'); + + return XArray::select($kaomoji, fn($kaomoji) => $transformer->convert($kaomoji)); + } + + /** @return int|mixed[] */ + #[AccessControl] + #[PatternRoute('GET', '/api/v1/kaomoji/([0-9]+)')] + public function getEmote(HttpResponseBuilder $response, HttpRequest $request, string $id): array|int { + if(empty($id)) + return 404; + + $transformer = $this->createKaomojiTransformer(); + if(!$transformer->filter($request)) + return 400; + + try { + $kaomoji = $this->kaomojiCtx->kaomoji->getKaomoji($id); + } catch(RuntimeException $ex) { + return 404; + } + + $response->setCacheControl('max-age=3600', 'stale-if-error=86400', 'public', 'immutable'); + + return $transformer->convert($kaomoji); + } +} diff --git a/src/Kaomoji/KaomojiContext.php b/src/Kaomoji/KaomojiContext.php new file mode 100644 index 00000000..df7937d9 --- /dev/null +++ b/src/Kaomoji/KaomojiContext.php @@ -0,0 +1,12 @@ +<?php +namespace Misuzu\Kaomoji; + +use Index\Db\DbConnection; + +class KaomojiContext { + public private(set) KaomojiData $kaomoji; + + public function __construct(DbConnection $dbConn) { + $this->kaomoji = new KaomojiData($dbConn); + } +} diff --git a/src/Kaomoji/KaomojiData.php b/src/Kaomoji/KaomojiData.php new file mode 100644 index 00000000..27fa5da4 --- /dev/null +++ b/src/Kaomoji/KaomojiData.php @@ -0,0 +1,92 @@ +<?php +namespace Misuzu\Kaomoji; + +use RuntimeException; +use Index\Db\{DbConnection,DbStatementCache}; + +class KaomojiData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + public function getKaomoji(string $kaoId): KaomojiInfo { + $stmt = $this->cache->get(<<<SQL + SELECT kao_id, kao_string + FROM msz_kaomoji + WHERE kao_id = ? + SQL); + $stmt->nextParameter($kaoId); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('No kaomoji with that ID exists.'); + + return KaomojiInfo::fromResult($result); + } + + /** + * @todo pagination + * @return \Iterator<int, KaomojiInfo> + */ + public function getAllKaomoji(): iterable { + $stmt = $this->cache->get(<<<SQL + SELECT kao_id, kao_string + FROM msz_kaomoji + SQL); + $stmt->execute(); + + return $stmt->getResultIterator(KaomojiInfo::fromResult(...)); + } + + public function createKaomoji(string $string): KaomojiInfo { + $stmt = $this->cache->get(<<<SQL + INSERT INTO msz_kaomoji (kao_string) VALUES (?) + SQL); + $stmt->nextParameter($string); + $stmt->execute(); + + return $this->getKaomoji((string)$stmt->lastInsertId); + } + + public function deleteKaomoji(KaomojiInfo|string $infoOrId): void { + if($infoOrId instanceof KaomojiInfo) + $infoOrId = $infoOrId->id; + + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_kaomoji + WHERE kao_id = ? + SQL); + $stmt->nextParameter($infoOrId); + $stmt->execute(); + } + + public function updateKaomoji( + KaomojiInfo|string $infoOrId, + ?string $string = null + ): void { + $fields = []; + $values = []; + + if($string !== null) { + $fields[] = 'kao_string = ?'; + $values[] = $string; + } + + if(empty($fields)) + return; + + $fields = implode(', ', $fields); + $stmt = $this->cache->get(<<<SQL + UPDATE msz_kaomoji + SET {$fields} + WHERE kao_id = ? + SQL); + foreach($values as $value) + $stmt->nextParameter($value); + $stmt->nextParameter($infoOrId instanceof KaomojiInfo ? $infoOrId->id : $infoOrId); + $stmt->execute(); + } +} diff --git a/src/Kaomoji/KaomojiInfo.php b/src/Kaomoji/KaomojiInfo.php new file mode 100644 index 00000000..b078f982 --- /dev/null +++ b/src/Kaomoji/KaomojiInfo.php @@ -0,0 +1,23 @@ +<?php +namespace Misuzu\Kaomoji; + +use Stringable; +use Index\Db\DbResult; + +class KaomojiInfo implements Stringable { + public function __construct( + public private(set) string $id, + public private(set) string $string, + ) {} + + public static function fromResult(DbResult $result): KaomojiInfo { + return new KaomojiInfo( + id: $result->getString(0), + string: $result->getString(1), + ); + } + + public function __toString(): string { + return $this->string; + } +} diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index c01538cf..fc778240 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -42,6 +42,7 @@ class MisuzuContext implements RouteHandler { public private(set) Comments\CommentsContext $commentsCtx; public private(set) Emoticons\EmotesContext $emotesCtx; public private(set) Forum\ForumContext $forumCtx; + public private(set) Kaomoji\KaomojiContext $kaomojiCtx; public private(set) Logs\LogsContext $logsCtx; public private(set) Messages\MessagesContext $messagesCtx; public private(set) OAuth2\OAuth2Context $oauth2Ctx; @@ -105,6 +106,7 @@ class MisuzuContext implements RouteHandler { Forum\ForumContext::class, config: $this->config->scopeTo('forum'), )); + $this->deps->register($this->kaomojiCtx = $this->deps->constructLazy(Kaomoji\KaomojiContext::class)); $this->deps->register($this->logsCtx = $this->deps->constructLazy(Logs\LogsContext::class)); $this->deps->register($this->messagesCtx = $this->deps->constructLazy( Messages\MessagesContext::class, @@ -185,6 +187,7 @@ class MisuzuContext implements RouteHandler { $this->routingCtx->register($this->deps->constructLazy(Colours\ColoursApiRoutes::class), $roles); $this->routingCtx->register($this->deps->constructLazy(Emoticons\EmotesApiRoutes::class), $roles); + $this->routingCtx->register($this->deps->constructLazy(Kaomoji\KaomojiApiRoutes::class), $roles); $this->routingCtx->register($this->deps->constructLazy(Users\UsersApiRoutes::class), $roles); $this->routingCtx->register($this->deps->constructLazy(Home\HomeRoutes::class), $roles);