diff --git a/VERSION b/VERSION index f904f62e..0e0aca03 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20250326.1 +20250327 diff --git a/database/2025_03_27_213508_colours_presets.php b/database/2025_03_27_213508_colours_presets.php new file mode 100644 index 00000000..95016345 --- /dev/null +++ b/database/2025_03_27_213508_colours_presets.php @@ -0,0 +1,20 @@ +<?php +use Index\Db\DbConnection; +use Index\Db\Migration\DbMigration; + +final class ColoursPresets_20250327_213508 implements DbMigration { + public function migrate(DbConnection $conn): void { + $conn->execute(<<<SQL + CREATE TABLE msz_colours_presets ( + preset_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + preset_order INT(11) NOT NULL DEFAULT '0', + preset_name VARCHAR(255) NOT NULL COLLATE 'ascii_general_ci', + preset_title VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8mb4_bin', + preset_colour INT(11) UNSIGNED NOT NULL, + PRIMARY KEY (preset_id), + UNIQUE KEY msz_colours_presets_name_unique (preset_name), + KEY msz_colours_presets_order_index (preset_order) + ) COLLATE='utf8mb4_bin' ENGINE=InnoDB; + SQL); + } +} diff --git a/public-legacy/manage/general/emoticon.php b/public-legacy/manage/general/emoticon.php index 2993c0fe..479e3f57 100644 --- a/public-legacy/manage/general/emoticon.php +++ b/public-legacy/manage/general/emoticon.php @@ -19,8 +19,8 @@ if(empty($emoteId)) else try { $isNew = false; - $emoteInfo = $msz->emotes->getEmote($emoteId); - $emoteStrings = iterator_to_array($msz->emotes->getEmoteStrings($emoteInfo)); + $emoteInfo = $msz->emotesCtx->emotes->getEmote($emoteId); + $emoteStrings = iterator_to_array($msz->emotesCtx->emotes->getEmoteStrings($emoteInfo)); } catch(RuntimeException $ex) { Template::throwError(404); } @@ -33,7 +33,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { $strings = explode(' ', !empty($_POST['em_strings']) && is_scalar($_POST['em_strings']) ? trim((string)$_POST['em_strings']) : ''); if($isNew || $url !== $emoteInfo->url) { - $checkUrl = $msz->emotes->checkEmoteUrl($url); + $checkUrl = $msz->emotesCtx->emotes->checkEmoteUrl($url); if($checkUrl !== '') { echo match($checkUrl) { 'empty' => 'URL may not be empty.', @@ -49,7 +49,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { $order = null; if($isNew) { - $emoteInfo = $msz->emotes->createEmote($url, $minRank, $order); + $emoteInfo = $msz->emotesCtx->emotes->createEmote($url, $minRank, $order); } else { if($order === $emoteInfo->order) $order = null; @@ -59,7 +59,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { $url = null; if($order !== null || $minRank !== null || $url !== null) - $msz->emotes->updateEmote($emoteInfo, $order, $minRank, $url); + $msz->emotesCtx->emotes->updateEmote($emoteInfo, $order, $minRank, $url); } $sCurrent = XArray::select($emoteStrings, fn($stringInfo) => $stringInfo->string); @@ -69,16 +69,16 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { foreach($sCurrent as $string) if(!in_array($string, $sApply)) { $sRemove[] = $string; - $msz->emotes->removeEmoteString($string); + $msz->emotesCtx->emotes->removeEmoteString($string); } $sCurrent = array_diff($sCurrent, $sRemove); foreach($sApply as $string) if(!in_array($string, $sCurrent)) { - $checkString = $msz->emotes->checkEmoteString($string); + $checkString = $msz->emotesCtx->emotes->checkEmoteString($string); if($checkString === '') { - $msz->emotes->addEmoteString($emoteInfo, $string); + $msz->emotesCtx->emotes->addEmoteString($emoteInfo, $string); } else { echo match($checkString) { 'empty' => 'String may not be empty.', diff --git a/public-legacy/manage/general/emoticons.php b/public-legacy/manage/general/emoticons.php index 5b1e60f2..548a94a0 100644 --- a/public-legacy/manage/general/emoticons.php +++ b/public-legacy/manage/general/emoticons.php @@ -13,26 +13,26 @@ if(CSRF::validateRequest() && !empty($_GET['emote'])) { $emoteId = !empty($_GET['emote']) && is_scalar($_GET['emote']) ? (string)$_GET['emote'] : ''; try { - $emoteInfo = $msz->emotes->getEmote($emoteId); + $emoteInfo = $msz->emotesCtx->emotes->getEmote($emoteId); } catch(RuntimeException $ex) { Template::throwError(404); } if(!empty($_GET['delete'])) { - $msz->emotes->deleteEmote($emoteInfo); + $msz->emotesCtx->emotes->deleteEmote($emoteInfo); $msz->createAuditLog('EMOTICON_DELETE', [$emoteInfo->id]); } else { if(isset($_GET['order'])) { $order = !empty($_GET['order']) && is_scalar($_GET['order']) ? (string)$_GET['order'] : ''; $offset = $order === 'i' ? 10 : ($order === 'd' ? -10 : 0); - $msz->emotes->updateEmoteOrderOffset($emoteInfo, $offset); + $msz->emotesCtx->emotes->updateEmoteOrderOffset($emoteInfo, $offset); $msz->createAuditLog('EMOTICON_ORDER', [$emoteInfo->id]); } if(isset($_GET['alias'])) { $alias = !empty($_GET['alias']) && is_scalar($_GET['alias']) ? (string)$_GET['alias'] : ''; - if($msz->emotes->checkEmoteString($alias) === '') { - $msz->emotes->addEmoteString($emoteInfo, $alias); + if($msz->emotesCtx->emotes->checkEmoteString($alias) === '') { + $msz->emotesCtx->emotes->addEmoteString($emoteInfo, $alias); $msz->createAuditLog('EMOTICON_ALIAS', [$emoteInfo->id, $alias]); } } @@ -43,5 +43,5 @@ if(CSRF::validateRequest() && !empty($_GET['emote'])) { } Template::render('manage.general.emoticons', [ - 'emotes' => $msz->emotes->getEmotes(), + 'emotes' => $msz->emotesCtx->emotes->getEmotes(), ]); diff --git a/src/Colours/ColourPresetGetField.php b/src/Colours/ColourPresetGetField.php new file mode 100644 index 00000000..a956b314 --- /dev/null +++ b/src/Colours/ColourPresetGetField.php @@ -0,0 +1,8 @@ +<?php +namespace Misuzu\Colours; + +enum ColourPresetGetField { + case Id; + case Name; + case IdOrName; +} diff --git a/src/Colours/ColourPresetInfo.php b/src/Colours/ColourPresetInfo.php new file mode 100644 index 00000000..31c580b9 --- /dev/null +++ b/src/Colours/ColourPresetInfo.php @@ -0,0 +1,29 @@ +<?php +namespace Misuzu\Colours; + +use Index\Colour\ColourRgb; +use Index\Db\DbResult; + +class ColourPresetInfo { + public function __construct( + public private(set) string $id, + public private(set) int $order, + public private(set) string $name, + public private(set) ?string $title, + public private(set) int $colour, + ) {} + + public static function fromResult(DbResult $result): ColourPresetInfo { + return new ColourPresetInfo( + id: $result->getString(0), + order: $result->getInteger(1), + name: $result->getString(2), + title: $result->getStringOrNull(3), + colour: $result->getInteger(4), + ); + } + + public function toColour(): ColourRgb { + return ColourRgb::fromRawArgb($this->colour); + } +} diff --git a/src/Colours/ColourPresetsData.php b/src/Colours/ColourPresetsData.php new file mode 100644 index 00000000..784cc04f --- /dev/null +++ b/src/Colours/ColourPresetsData.php @@ -0,0 +1,145 @@ +<?php +namespace Misuzu\Colours; + +use InvalidArgumentException; +use RuntimeException; +use Index\Colour\Colour; +use Index\Db\{DbConnection,DbStatementCache}; + +class ColourPresetsData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + /** @return iterable<ColourPresetInfo> */ + public function getPresets(): iterable { + $query = <<<SQL + SELECT preset_id, preset_order, preset_name, preset_title, preset_colour + FROM msz_colours_presets + ORDER BY preset_order + SQL; + + $stmt = $this->cache->get($query); + $stmt->execute(); + + return $stmt->getResultIterator(ColourPresetInfo::fromResult(...)); + } + + public function getPreset( + string $value, + ColourPresetGetField $field = ColourPresetGetField::Id + ): ColourPresetInfo { + $field = match($field) { + ColourPresetGetField::Id => 'preset_id = ?', + ColourPresetGetField::Name => 'preset_name = ?', + ColourPresetGetField::IdOrName => sprintf('preset_%s = ?', ctype_digit($value) ? 'id' : 'name'), + }; + + $stmt = $this->cache->get(<<<SQL + SELECT preset_id, preset_order, preset_name, preset_title, preset_colour + FROM msz_colours_presets + WHERE {$field} + SQL); + $stmt->nextParameter($value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('could not find that preset'); + + return ColourPresetInfo::fromResult($result); + } + + public function deletePreset(ColourPresetInfo|string $presetInfo): void { + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_colours_presets + WHERE preset_id = ? + SQL); + $stmt->nextParameter($presetInfo instanceof ColourPresetInfo ? $presetInfo->id : $presetInfo); + $stmt->execute(); + } + + public function createPreset( + string $name, + Colour|int $colour, + ?string $title = null, + ?int $order = null, + ): ColourPresetInfo { + if(empty($name) || ctype_digit($name[0])) + throw new InvalidArgumentException('$name may not be empty and may not start with a number'); + if($colour instanceof Colour) + $colour = Colour::toRawArgb($colour); + if($colour < 0 || $colour > 0xFFFFFFFF) + throw new InvalidArgumentException('$colour must be a positive integer between 0 and 0xFFFFFFFF'); + + $stmt = $this->cache->get(<<<SQL + INSERT INTO msz_colours_presets (preset_name, preset_colour, preset_title, preset_order) + SELECT ?, ?, ?, COALESCE(?, ( + SELECT FLOOR(MAX(preset_order) / 10) * 10 + 10 + FROM msz_colours_presets + ), 10) + SQL); + $stmt->nextParameter($name); + $stmt->nextParameter($colour); + $stmt->nextParameter($title); + $stmt->nextParameter($order); + $stmt->execute(); + + return $this->getPreset((string)$stmt->lastInsertId); + } + + public function updatePreset( + ColourPresetInfo|string $presetInfo, + ?string $name = null, + Colour|int|null $colour = null, + string|null|false $title = false, + ?int $order = null, + ): void { + $fields = []; + $values = []; + + if($name !== null) { + if(empty($name) || ctype_digit($name[0])) + throw new InvalidArgumentException('$name may not be empty and may not start with a number'); + + $fields[] = 'preset_name = ?'; + $values[] = $name; + } + + if($colour !== null) { + if($colour instanceof Colour) + $colour = Colour::toRawArgb($colour); + if($colour < 0 || $colour > 0xFFFFFFFF) + throw new InvalidArgumentException('$colour must be a positive integer between 0 and 0xFFFFFFFF'); + + $fields[] = 'preset_colour = ?'; + $values[] = $colour; + } + + if($title !== false) { + $fields[] = 'preset_title'; + $values[] = $title; + } + + if($order !== null) { + $fields[] = 'preset_order'; + $values[] = $order; + } + + if(empty($fields)) + return; + + $fields = implode(', ', $fields); + $stmt = $this->cache->get(<<<SQL + UPDATE msz_colours_presets + SET {$fields} + WHERE preset_id = ? + SQL); + foreach($values as $value) + $stmt->nextParameter($value); + $stmt->nextParameter($presetInfo instanceof ColourPresetInfo ? $presetInfo->id : $presetInfo); + $stmt->execute(); + } +} diff --git a/src/Colours/ColoursApiRoutes.php b/src/Colours/ColoursApiRoutes.php new file mode 100644 index 00000000..1710b177 --- /dev/null +++ b/src/Colours/ColoursApiRoutes.php @@ -0,0 +1,83 @@ +<?php +namespace Misuzu\Colours; + +use RuntimeException; +use Index\XArray; +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; +use Index\Http\Routing\AccessControl\AccessControl; +use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; +use Misuzu\FieldTransformer; + +final class ColoursApiRoutes implements RouteHandler { + use RouteHandlerCommon; + + public function __construct( + private ColoursContext $coloursCtx + ) {} + + /** @return FieldTransformer<ColourPresetInfo> */ + private function createPresetTransformer(): FieldTransformer { + return new FieldTransformer([ + 'id' => [ + 'transform' => fn($preset) => $preset->id, + ], + 'order' => [ + 'transform' => fn($preset) => $preset->order, + ], + 'name' => [ + 'default' => true, + 'transform' => fn($preset) => $preset->name, + ], + 'title' => [ + 'default' => true, + 'transform' => fn($preset) => $preset->title ?? $preset->name, + ], + 'argb' => [ + 'default' => true, + 'transform' => fn($preset) => $preset->colour, + ], + 'css' => [ + 'default' => true, + 'transform' => fn($preset) => (string)$preset->toColour(), + ], + ]); + } + + /** @return int|mixed[] */ + #[AccessControl] + #[ExactRoute('GET', '/api/v1/colours/presets')] + public function getPresets(HttpRequest $request): array|int { + $transformer = $this->createPresetTransformer(); + if(!$transformer->filter($request)) + return 400; + + return XArray::select( + $this->coloursCtx->presets->getPresets(), + fn($preset) => $transformer->convert($preset), + ); + } + + /** @return int|mixed[] */ + #[AccessControl] + #[PatternRoute('GET', '/api/v1/colours/presets/([A-Za-z0-9\-_]+)')] + public function getPreset(HttpRequest $request, string $name): array|int { + if(empty($name)) + return 404; + + $transformer = $this->createPresetTransformer(); + if(!$transformer->filter($request)) + return 400; + + try { + $preset = $this->coloursCtx->presets->getPreset( + $name, + ColourPresetGetField::IdOrName, + ); + } catch(RuntimeException $ex) { + return 404; + } + + return $transformer->convert($preset); + } +} diff --git a/src/Colours/ColoursContext.php b/src/Colours/ColoursContext.php new file mode 100644 index 00000000..2701468b --- /dev/null +++ b/src/Colours/ColoursContext.php @@ -0,0 +1,12 @@ +<?php +namespace Misuzu\Colours; + +use Index\Db\DbConnection; + +class ColoursContext { + public private(set) ColourPresetsData $presets; + + public function __construct(DbConnection $dbConn) { + $this->presets = new ColourPresetsData($dbConn); + } +} diff --git a/src/Emoticons/EmotesApiRoutes.php b/src/Emoticons/EmotesApiRoutes.php index a713ae54..7c318d7a 100644 --- a/src/Emoticons/EmotesApiRoutes.php +++ b/src/Emoticons/EmotesApiRoutes.php @@ -1,48 +1,116 @@ <?php namespace Misuzu\Emoticons; +use RuntimeException; use Index\XArray; use Index\Http\{HttpRequest,HttpResponseBuilder}; use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; use Index\Http\Routing\AccessControl\AccessControl; -use Index\Http\Routing\Routes\ExactRoute; +use Index\Http\Routing\Routes\{ExactRoute,PatternRoute}; +use Misuzu\FieldTransformer; final class EmotesApiRoutes implements RouteHandler { use RouteHandlerCommon; public function __construct( - private EmotesData $emotes + private EmotesContext $emotesCtx ) {} - /** @return mixed[] */ + /** @return FieldTransformer<EmoteInfo> */ + private function createEmoteTransformer(): FieldTransformer { + return new FieldTransformer([ + 'id' => [ + 'transform' => fn($emote) => $emote->id, + ], + 'order' => [ + 'transform' => fn($emote) => $emote->order, + ], + 'url' => [ + 'default' => true, + 'transform' => fn($emote) => $emote->url, + ], + 'strings' => [ + 'default' => true, + 'transform' => fn($emote) => XArray::select( + $this->emotesCtx->emotes->getEmoteStrings($emote), + fn($string) => $string->string + ), + ], + 'min_rank' => [ + 'default' => true, + 'transform' => fn($emote) => $emote->minRank, + 'include' => fn($value) => $value !== 0, + ], + ]); + } + + /** @return int|mixed[] */ #[AccessControl] #[ExactRoute('GET', '/api/v1/emotes')] - public function getEmotes(HttpResponseBuilder $response, HttpRequest $request): array { - $includeId = !empty($request->getParam('include_id')); - $includeOrder = !empty($request->getParam('include_order')); + public function getEmotes(HttpRequest $request): array|int { + $transformer = $this->createEmoteTransformer(); + if(!$transformer->filter($request)) + return 400; return XArray::select( - $this->emotes->getEmotes(orderBy: 'order'), - function($emote) use ($includeId, $includeOrder) { - $info = [ - 'url' => $emote->url, - 'strings' => XArray::select( - $this->emotes->getEmoteStrings($emote), - fn($string) => $string->string - ), - ]; + $this->emotesCtx->emotes->getEmotes(orderBy: 'order'), + fn($emote) => $transformer->convert($emote), + ); + } - if($includeId) - $info['id'] = $emote->id; - if($includeOrder) - $info['order'] = $emote->order; + /** @return int|mixed[] */ + #[AccessControl] + #[PatternRoute('GET', '/api/v1/emotes/([0-9]+)')] + public function getEmote(HttpRequest $request, string $id): array|int { + if(empty($id)) + return 404; - $rank = $emote->minRank; - if($rank !== 0) - $info['min_rank'] = $rank; + $transformer = $this->createEmoteTransformer(); + if(!$transformer->filter($request)) + return 400; - return $info; - } + try { + $emote = $this->emotesCtx->emotes->getEmote($id); + } catch(RuntimeException $ex) { + return 404; + } + + return $transformer->convert($emote); + } + + /** @return FieldTransformer<EmoteStringInfo> */ + private function createEmoteStringTransformer(): FieldTransformer { + return new FieldTransformer([ + 'order' => [ + 'transform' => fn($string) => $string->order, + ], + 'string' => [ + 'default' => true, + 'transform' => fn($string) => $string->string, + ], + ]); + } + + /** @return int|mixed[] */ + #[AccessControl] + #[PatternRoute('GET', '/api/v1/emotes/([0-9]+)/strings')] + public function getEmoteStrings(HttpRequest $request, string $id): array|int { + if(empty($id)) + return 404; + + $transformer = $this->createEmoteStringTransformer(); + if(!$transformer->filter($request)) + return 400; + + try { + $emote = $this->emotesCtx->emotes->getEmote($id); + } catch(RuntimeException $ex) { + return 404; + } + + return XArray::select( + $this->emotesCtx->emotes->getEmoteStrings($emote), + fn($emote) => $transformer->convert($emote), ); } } diff --git a/src/Emoticons/EmotesContext.php b/src/Emoticons/EmotesContext.php new file mode 100644 index 00000000..5e261354 --- /dev/null +++ b/src/Emoticons/EmotesContext.php @@ -0,0 +1,12 @@ +<?php +namespace Misuzu\Emoticons; + +use Index\Db\DbConnection; + +class EmotesContext { + public private(set) EmotesData $emotes; + + public function __construct(DbConnection $dbConn) { + $this->emotes = new EmotesData($dbConn); + } +} diff --git a/src/Emoticons/EmotesData.php b/src/Emoticons/EmotesData.php index 276f38eb..52011d87 100644 --- a/src/Emoticons/EmotesData.php +++ b/src/Emoticons/EmotesData.php @@ -19,7 +19,11 @@ class EmotesData { } public function getEmote(string $emoteId): EmoteInfo { - $stmt = $this->cache->get('SELECT emote_id, emote_order, emote_rank, emote_url FROM msz_emoticons WHERE emote_id = ?'); + $stmt = $this->cache->get(<<<SQL + SELECT emote_id, emote_order, emote_rank, emote_url + FROM msz_emoticons + WHERE emote_id = ? + SQL); $stmt->nextParameter($emoteId); $stmt->execute(); @@ -49,7 +53,10 @@ class EmotesData { $hasOrderBy = $orderBy !== null; $hasReverse = $reverse !== null; - $query = 'SELECT emote_id, emote_order, emote_rank, emote_url FROM msz_emoticons'; + $query = <<<SQL + SELECT emote_id, emote_order, emote_rank, emote_url + FROM msz_emoticons + SQL; if($hasMinRank) $query .= ' WHERE emote_rank <= ?'; if($hasOrderBy) { @@ -83,7 +90,11 @@ class EmotesData { if($check !== '') return $check; - $stmt = $this->cache->get('SELECT COUNT(*) FROM msz_emoticons WHERE emote_url = ?'); + $stmt = $this->cache->get(<<<SQL + SELECT COUNT(*) + FROM msz_emoticons + WHERE emote_url = ? + SQL); $stmt->nextParameter($url); $stmt->execute(); $result = $stmt->getResult(); @@ -99,7 +110,13 @@ class EmotesData { if($check !== '') throw new InvalidArgumentException('$url is not correctly formatted: ' . $check); - $stmt = $this->cache->get('INSERT INTO msz_emoticons (emote_url, emote_rank, emote_order) SELECT ?, ?, COALESCE(?, (SELECT FLOOR(MAX(emote_order) / 10) * 10 + 10 FROM msz_emoticons), 10)'); + $stmt = $this->cache->get(<<<SQL + INSERT INTO msz_emoticons (emote_url, emote_rank, emote_order) + SELECT ?, ?, COALESCE(?, ( + SELECT FLOOR(MAX(emote_order) / 10) * 10 + 10 + FROM msz_emoticons + ), 10) + SQL); $stmt->nextParameter($url); $stmt->nextParameter($minRank); $stmt->nextParameter($order); @@ -112,7 +129,10 @@ class EmotesData { if($infoOrId instanceof EmoteInfo) $infoOrId = $infoOrId->id; - $stmt = $this->cache->get('DELETE FROM msz_emoticons WHERE emote_id = ?'); + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_emoticons + WHERE emote_id = ? + SQL); $stmt->nextParameter($infoOrId); $stmt->execute(); } @@ -132,7 +152,13 @@ class EmotesData { if($infoOrId instanceof EmoteInfo) $infoOrId = $infoOrId->id; - $stmt = $this->cache->get('UPDATE msz_emoticons SET emote_order = COALESCE(?, emote_order), emote_rank = COALESCE(?, emote_rank), emote_url = COALESCE(?, emote_url) WHERE emote_id = ?'); + $stmt = $this->cache->get(<<<SQL + UPDATE msz_emoticons + SET emote_order = COALESCE(?, emote_order), + emote_rank = COALESCE(?, emote_rank), + emote_url = COALESCE(?, emote_url) + WHERE emote_id = ? + SQL); $stmt->nextParameter($order); $stmt->nextParameter($minRank); $stmt->nextParameter($url); @@ -145,7 +171,11 @@ class EmotesData { if($infoOrId instanceof EmoteInfo) $infoOrId = $infoOrId->id; - $stmt = $this->cache->get('UPDATE msz_emoticons SET emote_order = emote_order + ? WHERE emote_id = ?'); + $stmt = $this->cache->get(<<<SQL + UPDATE msz_emoticons + SET emote_order = emote_order + ? + WHERE emote_id = ? + SQL); $stmt->nextParameter($offset); $stmt->nextParameter($infoOrId); $stmt->execute(); @@ -156,7 +186,12 @@ class EmotesData { if($infoOrId instanceof EmoteInfo) $infoOrId = $infoOrId->id; - $stmt = $this->cache->get('SELECT emote_id, emote_string_order, emote_string FROM msz_emoticons_strings WHERE emote_id = ? ORDER BY emote_string_order'); + $stmt = $this->cache->get(<<<SQL + SELECT emote_id, emote_string_order, emote_string + FROM msz_emoticons_strings + WHERE emote_id = ? + ORDER BY emote_string_order + SQL); $stmt->nextParameter($infoOrId); $stmt->execute(); @@ -181,7 +216,11 @@ class EmotesData { if($check !== '') return $check; - $stmt = $this->cache->get('SELECT COUNT(*) FROM msz_emoticons_strings WHERE emote_string = ?'); + $stmt = $this->cache->get(<<<SQL + SELECT COUNT(*) + FROM msz_emoticons_strings + WHERE emote_string = ? + SQL); $stmt->nextParameter($string); $stmt->execute(); $result = $stmt->getResult(); @@ -200,7 +239,14 @@ class EmotesData { if($infoOrId instanceof EmoteInfo) $infoOrId = $infoOrId->id; - $stmt = $this->cache->get('INSERT INTO msz_emoticons_strings (emote_id, emote_string, emote_string_order) SELECT ? AS target_emote_id, ?, COALESCE(?, (SELECT MAX(emote_string_order) + 1 FROM msz_emoticons_strings WHERE emote_id = target_emote_id), 1)'); + $stmt = $this->cache->get(<<<SQL + INSERT INTO msz_emoticons_strings (emote_id, emote_string, emote_string_order) + SELECT ? AS target_emote_id, ?, COALESCE(?, ( + SELECT MAX(emote_string_order) + 1 + FROM msz_emoticons_strings + WHERE emote_id = target_emote_id + ), 1) + SQL); $stmt->nextParameter($infoOrId); $stmt->nextParameter($string); $stmt->nextParameter($order); diff --git a/src/FieldTransformer.php b/src/FieldTransformer.php new file mode 100644 index 00000000..97174f7d --- /dev/null +++ b/src/FieldTransformer.php @@ -0,0 +1,139 @@ +<?php +namespace Misuzu; + +use RuntimeException; +use InvalidArgumentException; +use Index\Http\HttpRequest; + +/** + * @template T + */ +class FieldTransformer { + /** @var string[] */ + public private(set) array $available = []; + + /** @var string[] */ + public private(set) array $defaults = []; + + /** @var array<string, callable(T): mixed> */ + public private(set) array $transformers = []; + + /** @var array<string, callable(mixed): bool> */ + public private(set) array $include = []; + + /** @var string[] */ + public private(set) array $fields = []; + + /** + * @param array<string, array{ + * default?: bool, + * transform: (callable(T): mixed), + * include?: (callable(mixed): bool), + * }> $fields + */ + public function __construct(array $fields) { + foreach($fields as $name => $field) { + $this->available[] = $name; + $this->transformers[$name] = $field['transform']; + + if(isset($field['default']) && is_bool($field['default']) && $field['default']) + $this->defaults[] = $name; + if(isset($field['include']) && is_callable($field['include'])) + $this->include[$name] = $field['include']; + } + } + + /** + * @param HttpRequest|string|string[] $fields + */ + public function filter(HttpRequest|string|array $fields, string $param = 'fields'): bool { + if($fields instanceof HttpRequest) { + if(!$fields->hasParam($param)) { + $this->fields = $this->defaults; + return true; + } + + $fields = trim($fields->getParam($param)); + } + + if(is_string($fields)) { + if($fields === '*') { + $this->fields = $this->available; + return true; + } + + $fields = explode(',', $fields); + } + + $unfiltered = array_map(fn($value) => trim($value), $fields); + $fields = []; + + foreach($unfiltered as $field) { + // this is a little bit crusty + if(str_ends_with($field, '.*')) { + $prefix = substr($field, 0, -1); + $any = false; + foreach($this->available as $name) + if(str_starts_with($name, $prefix)) { + if(in_array($name, $fields)) + return false; + + $any = true; + $fields[] = $name; + } + + if(!$any) return false; + continue; + } + + if(!in_array($field, $this->available)) + return false; + if(in_array($field, $fields)) + return false; + + $fields[] = $field; + } + + if(empty($fields)) { + $this->fields = $this->defaults; + return true; + } + + $this->fields = $fields; + return true; + } + + /** + * @param T $value + * @return mixed[] + */ + public function convert(mixed $value): array { + $output = []; + + foreach($this->fields as $field) { + $result = $this->transformers[$field]($value); + if(array_key_exists($field, $this->include) ? !$this->include[$field]($result) : $result === null) + continue; + + $target = &$output; + $steps = explode('.', $field); + while(count($steps) > 1) { + $step = array_shift($steps); + + // PHPStan does not know how to deal with recursion and references + if(!array_key_exists($step, $target)) // @phpstan-ignore function.impossibleType + $target[$step] = []; + if(!is_array($target[$step])) // @phpstan-ignore function.alreadyNarrowedType + throw new RuntimeException(sprintf('"%s" is not an array', $field)); + if(!empty($target[$step]) && array_is_list($target[$step])) // @phpstan-ignore empty.offset + throw new RuntimeException(sprintf('"%s" conflicts with a list', $field)); + + $target = &$target[$step]; + } + + $target[$steps[0]] = $result; + } + + return $output; + } +} diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 36b40a8c..35fa6e49 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -27,14 +27,15 @@ class MisuzuContext { public private(set) AuditLog\AuditLogData $auditLog; public private(set) Counters\CountersData $counters; - public private(set) Emoticons\EmotesData $emotes; public private(set) Changelog\ChangelogData $changelog; public private(set) News\NewsData $news; public private(set) DatabaseContext $dbCtx; public private(set) Apps\AppsContext $appsCtx; public private(set) Auth\AuthContext $authCtx; + public private(set) Colours\ColoursContext $coloursCtx; public private(set) Comments\CommentsContext $commentsCtx; + public private(set) Emoticons\EmotesContext $emotesCtx; public private(set) Forum\ForumContext $forumCtx; public private(set) Messages\MessagesContext $messagesCtx; public private(set) OAuth2\OAuth2Context $oauth2Ctx; @@ -93,7 +94,9 @@ class MisuzuContext { Auth\AuthContext::class, config: $this->config->scopeTo('auth'), )); + $this->deps->register($this->coloursCtx = $this->deps->constructLazy(Colours\ColoursContext::class)); $this->deps->register($this->commentsCtx = $this->deps->constructLazy(Comments\CommentsContext::class)); + $this->deps->register($this->emotesCtx = $this->deps->constructLazy(Emoticons\EmotesContext::class)); $this->deps->register($this->forumCtx = $this->deps->constructLazy( Forum\ForumContext::class, config: $this->config->scopeTo('forum'), @@ -116,7 +119,6 @@ class MisuzuContext { $this->deps->register($this->auditLog = $this->deps->constructLazy(AuditLog\AuditLogData::class)); $this->deps->register($this->changelog = $this->deps->constructLazy(Changelog\ChangelogData::class)); $this->deps->register($this->counters = $this->deps->constructLazy(Counters\CountersData::class)); - $this->deps->register($this->emotes = $this->deps->constructLazy(Emoticons\EmotesData::class)); $this->deps->register($this->news = $this->deps->constructLazy(News\NewsData::class)); $this->deps->register($this->storageCtx = $this->deps->construct( @@ -215,6 +217,7 @@ class MisuzuContext { Auth\AuthApiRoutes::class, impersonateConfig: $this->config->scopeTo('impersonate') )); + $routingCtx->register($this->deps->constructLazy(Colours\ColoursApiRoutes::class)); $routingCtx->register($this->deps->constructLazy(Emoticons\EmotesApiRoutes::class)); $routingCtx->register($this->deps->constructLazy(Users\UsersApiRoutes::class)); diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php index c9516fe3..128f13ce 100644 --- a/src/SharpChat/SharpChatRoutes.php +++ b/src/SharpChat/SharpChatRoutes.php @@ -13,7 +13,7 @@ use Index\Http\Routing\Routes\ExactRoute; use Index\Urls\UrlRegistry; use Misuzu\Auth\{AuthContext,AuthInfo,Sessions}; use Misuzu\Counters\CountersData; -use Misuzu\Emoticons\EmotesData; +use Misuzu\Emoticons\EmotesContext; use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context}; use Misuzu\Perms\PermissionsData; use Misuzu\Users\{BansData,UsersContext,UserInfo}; @@ -30,7 +30,7 @@ final class SharpChatRoutes implements RouteHandler { private UsersContext $usersCtx, private AuthContext $authCtx, private OAuth2Context $oauth2Ctx, - private EmotesData $emotes, + private EmotesContext $emotesCtx, private PermissionsData $perms, private AuthInfo $authInfo, private CountersData $counters @@ -44,13 +44,13 @@ final class SharpChatRoutes implements RouteHandler { public function getEmotes(): array { $this->counters->increment('dev:legacy_emotes_loads'); - $emotes = $this->emotes->getEmotes(orderBy: 'order'); + $emotes = $this->emotesCtx->emotes->getEmotes(orderBy: 'order'); $out = []; foreach($emotes as $emoteInfo) { $strings = []; - foreach($this->emotes->getEmoteStrings($emoteInfo) as $stringInfo) + foreach($this->emotesCtx->emotes->getEmoteStrings($emoteInfo) as $stringInfo) $strings[] = sprintf(':%s:', $stringInfo->string); $out[] = [ diff --git a/src/Users/UsersApiRoutes.php b/src/Users/UsersApiRoutes.php index 74ed30e0..c219e48c 100644 --- a/src/Users/UsersApiRoutes.php +++ b/src/Users/UsersApiRoutes.php @@ -2,9 +2,6 @@ namespace Misuzu\Users; use RuntimeException; -use Misuzu\SiteInfo; -use Misuzu\Auth\AuthInfo; -use Misuzu\Users\Assets\UserAvatarAsset; use Index\XArray; use Index\Colour\{Colour,ColourRgb}; use Index\Http\{HttpRequest,HttpResponseBuilder}; @@ -12,6 +9,10 @@ use Index\Http\Routing\{RouteHandler,RouteHandlerCommon}; use Index\Http\Routing\AccessControl\AccessControl; use Index\Http\Routing\Routes\ExactRoute; use Index\Urls\UrlRegistry; +use Misuzu\{FieldTransformer,SiteInfo}; +use Misuzu\Auth\AuthInfo; +use Misuzu\Users\UserInfo; +use Misuzu\Users\Assets\UserAvatarAsset; final class UsersApiRoutes implements RouteHandler { use RouteHandlerCommon; @@ -23,6 +24,105 @@ final class UsersApiRoutes implements RouteHandler { private AuthInfo $authInfo, ) {} + /** @return FieldTransformer<UserInfo> */ + public function createUserTransformer(): FieldTransformer { + $fields = [ + 'id' => [ + 'default' => true, + 'transform' => fn($user) => $user->id, + ], + ]; + + $openid = $this->authInfo->hasScope('openid'); + + if(!$openid || $this->authInfo->hasScope('profile')) { + $fields['name'] = [ + 'default' => true, + 'transform' => fn($user) => $user->name, + ]; + $fields['colour_raw'] = [ + 'default' => true, + 'transform' => function($user) { + $colour = $this->usersCtx->getUserColour($user); + return $colour->inherits ? null : Colour::toRawRgb($colour); + }, + 'include' => fn() => true, + ]; + $fields['colour_css'] = [ + 'default' => true, + 'transform' => function($user) { + $colour = $this->usersCtx->getUserColour($user); + return $colour->inherits ? (string)$colour : (string)ColourRgb::convert($colour); + }, + ]; + $fields['country_code'] = [ + 'default' => true, + 'transform' => fn($user) => $user->countryCode, + ]; + $fields['rank'] = [ + 'default' => true, + 'transform' => fn($user) => ( + $this->usersCtx->hasActiveBan($user) + ? 0 + : $this->usersCtx->getUserRank($user) + ), + ]; + $fields['roles'] = [ + 'default' => true, + 'transform' => fn($user) => ( + $this->usersCtx->hasActiveBan($user) + ? ['x-banned'] + : XArray::select( + $this->usersCtx->roles->getRoles( + userInfo: $user, + hasString: true, + orderByRank: true + ), + fn($roleInfo) => $roleInfo->string, + ) + ), + ]; + $fields['is_super'] = [ + 'default' => true, + 'transform' => fn($user) => $user->super, + 'include' => fn($value) => $value === true, + ]; + $fields['title'] = [ + 'default' => true, + 'transform' => fn($user) => empty($user->title) ? null : $user->title, + ]; + $fields['created_at'] = [ + 'default' => true, + 'transform' => fn($user) => $user->createdAt->toIso8601ZuluString(), + ]; + $fields['last_active_at'] = [ + 'default' => true, + 'transform' => fn($user) => $user->lastActiveAt?->toIso8601ZuluString(), + ]; + $fields['profile_url'] = [ + 'default' => true, + 'transform' => fn($user) => ($this->siteInfo->url . $this->urls->format('user-profile', ['user' => $user->id])), + ]; + $fields['avatar_url'] = [ + 'default' => true, + 'transform' => fn($user) => ($this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $user->id])), + ]; + $fields['is_deleted'] = [ + 'default' => true, + 'transform' => fn($user) => $user->deleted, + 'include' => fn($value) => $value === true, + ]; + } + + if($this->authInfo->hasScope($openid ? 'email' : 'identify:email')) + $fields['email'] = [ + 'default' => true, + 'transform' => fn($user) => $user->emailAddress, + ]; + + return new FieldTransformer($fields); + } + /** @return int|mixed[] */ #[AccessControl] #[ExactRoute('GET', '/api/v1/me')] @@ -35,10 +135,9 @@ final class UsersApiRoutes implements RouteHandler { && !$this->authInfo->hasScope('beans')) return 403; - $includeProfile = !$openid || $this->authInfo->hasScope('profile'); - $includeEMail = $openid - ? $this->authInfo->hasScope('email') - : $this->authInfo->hasScope('identify:email'); + $transformer = $this->createUserTransformer(); + if(!$transformer->filter($request)) + return 400; try { $userInfo = $this->usersCtx->getUserInfo($this->authInfo->userId, UsersData::GET_USER_ID); @@ -46,60 +145,6 @@ final class UsersApiRoutes implements RouteHandler { return 404; } - // TODO: there should be some kinda privacy controls for users - - $output = ['id' => $userInfo->id]; - - if($includeProfile) { - $output['name'] = $userInfo->name; - - $colour = $this->usersCtx->getUserColour($userInfo); - if($colour->inherits) { - $colourRaw = null; - $colourCSS = (string)$colour; - } else { - $colourRaw = Colour::toRawRgb($colour); - $colourCSS = (string)ColourRgb::convert($colour); - } - - $output['colour_raw'] = $colourRaw; - $output['colour_css'] = $colourCSS; - $output['country_code'] = $userInfo->countryCode; - - if($this->usersCtx->hasActiveBan($userInfo)) { - $output['rank'] = 0; - $output['roles'] = ['x-banned']; - } else { - $roles = XArray::select( - $this->usersCtx->roles->getRoles(userInfo: $userInfo, hasString: true, orderByRank: true), - fn($roleInfo) => $roleInfo->string, - ); - - $output['rank'] = $this->usersCtx->getUserRank($userInfo); - if(!empty($roles)) - $output['roles'] = $roles; - if($userInfo->super) - $output['is_super'] = true; - } - - if(!empty($userInfo->title)) - $output['title'] = $userInfo->title; - - $output['created_at'] = $userInfo->createdAt->toIso8601ZuluString(); - if($userInfo->lastActiveTime !== null) - $output['last_active_at'] = $userInfo->lastActiveAt->toIso8601ZuluString(); - - $baseUrl = $this->siteInfo->url; - $output['profile_url'] = $baseUrl . $this->urls->format('user-profile', ['user' => $userInfo->id]); - $output['avatar_url'] = $baseUrl . $this->urls->format('user-avatar', ['user' => $userInfo->id]); - - if($userInfo->deleted) - $output['is_deleted'] = true; - } - - if($includeEMail) - $output['email'] = $userInfo->emailAddress; - - return $output; + return $transformer->convert($userInfo); } }