Added field filtering to API endpoints and store colour presets in the database.

This commit is contained in:
flash 2025-03-28 01:47:08 +00:00
parent 12d40e69a5
commit a1398fb179
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
16 changed files with 727 additions and 117 deletions

View file

@ -1 +1 @@
20250326.1
20250327

View file

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

View file

@ -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.',

View file

@ -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(),
]);

View file

@ -0,0 +1,8 @@
<?php
namespace Misuzu\Colours;
enum ColourPresetGetField {
case Id;
case Name;
case IdOrName;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

139
src/FieldTransformer.php Normal file
View file

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

View file

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

View file

@ -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[] = [

View file

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