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);
     }
 }