Files
watchstate/src/Libs/helpers.php
2024-08-10 22:11:01 +03:00

1840 lines
62 KiB
PHP

<?php
/** @noinspection PhpUnhandledExceptionInspection, PhpDocMissingThrowsInspection */
declare(strict_types=1);
use App\Backends\Common\Cache as BackendCache;
use App\Backends\Common\ClientInterface as iClient;
use App\Backends\Common\Context;
use App\Libs\APIResponse;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Extends\Date;
use App\Libs\Guid;
use App\Libs\Initializer;
use App\Libs\Options;
use App\Libs\Response;
use App\Libs\Router;
use App\Libs\Stream;
use App\Libs\Uri;
use Monolog\Utils;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Http\Message\StreamInterface as iStream;
use Psr\Http\Message\UriInterface as iUri;
use Psr\Log\LoggerInterface as iLogger;
use Psr\SimpleCache\CacheInterface as iCache;
use Symfony\Component\Process\Process;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
if (!function_exists('env')) {
/**
* Get the value of an environment variable.
*
* @param string $key The key of the environment variable.
* @param mixed $default The default value to return if the environment variable is not found.
*
* @return mixed The value of the environment variable, or the default value if not found.
*/
function env(string $key, mixed $default = null): mixed
{
if (false === ($value = $_ENV[$key] ?? getenv($key))) {
return getValue($default);
}
return match (is_string($value) ? strtolower($value) : $value) {
'true', '(true)' => true,
'false', '(false)' => false,
'empty', '(empty)' => '',
'null', '(null)' => null,
default => $value,
};
}
}
if (!function_exists('getValue')) {
/**
* Get the value of a variable.
*
* @param mixed $var The variable to get the value from.
*
* @return mixed The value of the variable.
*/
function getValue(mixed $var): mixed
{
return ($var instanceof Closure) ? $var() : $var;
}
}
if (!function_exists('makeDate')) {
/**
* Make date time object.
*
* @param string|int|DateTimeInterface $date Defaults to now
* @param string|DateTimeZone|null $tz For given $date, not for display.
*
* @return Date
*/
function makeDate(string|int|DateTimeInterface $date = 'now', DateTimeZone|string|null $tz = null): Date
{
if ((is_string($date) || is_int($date)) && ctype_digit((string)$date)) {
$date = '@' . $date;
}
if (null === $tz) {
$tz = date_default_timezone_get();
}
if (!($tz instanceof DateTimeZone)) {
$tz = new DateTimeZone($tz);
}
if (true === ($date instanceof DateTimeInterface)) {
$date = $date->format(DateTimeInterface::ATOM);
}
return (new Date($date))->setTimezone($tz);
}
}
if (!function_exists('ag')) {
/**
* Get value from array or object using dot notation.
*
* @param array|object $array The array or object to search in.
* @param string|array|int|null $path The key path to get the value from.
* @param mixed $default The default value to return if the key path doesn't exist.
* @param string $separator The separator used in the key path (default is '.').
*
* @return mixed The value at the specified key path, or the default value if not found.
*/
function ag(array|object $array, string|array|int|null $path, mixed $default = null, string $separator = '.'): mixed
{
if (empty($path)) {
return $array;
}
if (!is_array($array)) {
$array = get_object_vars($array);
}
if (is_array($path)) {
foreach ($path as $key) {
$val = ag($array, $key, '_not_set');
if ('_not_set' === $val) {
continue;
}
return $val;
}
return getValue($default);
}
if (null !== ($array[$path] ?? null)) {
return $array[$path];
}
if (!str_contains($path, $separator)) {
return $array[$path] ?? getValue($default);
}
foreach (explode($separator, $path) as $segment) {
if (is_array($array) && array_key_exists($segment, $array)) {
$array = $array[$segment];
} else {
return getValue($default);
}
}
return $array;
}
}
if (!function_exists('ag_set')) {
/**
* Set an array item to a given value using "dot" notation.
*
* If no key is given to the method, the entire array will be replaced.
*
* @param array $array
* @param string $path
* @param mixed $value
* @param string $separator
*
* @return array return modified array.
*/
function ag_set(array $array, string $path, mixed $value, string $separator = '.'): array
{
$keys = explode($separator, $path);
$at = &$array;
while (count($keys) > 0) {
if (1 === count($keys)) {
if (is_array($at)) {
$at[array_shift($keys)] = $value;
} else {
throw new RuntimeException("Can not set value at this path ($path) because its not array.");
}
} else {
$path = array_shift($keys);
if (!isset($at[$path])) {
$at[$path] = [];
}
$at = &$at[$path];
}
}
return $array;
}
}
if (!function_exists('ag_exists')) {
/**
* Determine if the given key exists in the provided array.
*
* @param array $array The array to search in.
* @param string|int $path The key path to check for.
* @param string $separator The separator used in the key path (default is '.').
*
* @return bool True if the key path exists, false otherwise.
*/
function ag_exists(array $array, string|int $path, string $separator = '.'): bool
{
if (isset($array[$path])) {
return true;
}
foreach (explode($separator, (string)$path) as $lookup) {
if (isset($array[$lookup])) {
$array = $array[$lookup];
} else {
return false;
}
}
return true;
}
}
if (!function_exists('ag_delete')) {
/**
* Delete given key path.
*
* @param array $array The array to search in.
* @param int|string $path The key path to delete.
* @param string $separator The separator used in the key path (default is '.').
*
* @return array The modified array.
*/
function ag_delete(array $array, string|int $path, string $separator = '.'): array
{
if (array_key_exists($path, $array)) {
unset($array[$path]);
return $array;
}
if (is_int($path)) {
if (isset($array[$path])) {
unset($array[$path]);
}
return $array;
}
$items = &$array;
$segments = explode($separator, $path);
$lastSegment = array_pop($segments);
foreach ($segments as $segment) {
if (!isset($items[$segment]) || !is_array($items[$segment])) {
continue;
}
$items = &$items[$segment];
}
if (null !== $lastSegment && array_key_exists($lastSegment, $items)) {
unset($items[$lastSegment]);
}
return $array;
}
}
if (!function_exists('fixPath')) {
/**
* Fix the given file path by removing any trailing directory separators.
*
* @param string $path The file path to fix.
*
* @return string The fixed file path.
*/
function fixPath(string $path): string
{
return rtrim(implode(DIRECTORY_SEPARATOR, explode(DIRECTORY_SEPARATOR, $path)), DIRECTORY_SEPARATOR);
}
}
if (!function_exists('fsize')) {
/**
* Calculate the file size in human-readable format.
*
* @param int|string $bytes The size of the file in bytes (default is 0).
* @param bool $showUnit Whether to include the unit in the result (default is true).
* @param int $decimals The number of decimal places to round the result (default is 2).
* @param int $mod The base value used for conversion (default is 1000).
*
* @return string The file size in a human-readable format.
*/
function fsize(string|int $bytes = 0, bool $showUnit = true, int $decimals = 2, int $mod = 1000): string
{
$sz = 'BKMGTP';
$factor = floor((strlen((string)$bytes) - 1) / 3);
return sprintf("%.{$decimals}f", (int)($bytes) / ($mod ** $factor)) . ($showUnit ? $sz[(int)$factor] : '');
}
}
if (!function_exists('saveWebhookPayload')) {
/**
* Save webhook payload to stream.
*
* @param iState $entity Entity object.
* @param iRequest $request Request object.
* @param iStream|null $file When given a stream, it will be used to write payload.
*/
function saveWebhookPayload(iState $entity, iRequest $request, iStream|null $file = null): void
{
$content = [
'request' => [
'server' => $request->getServerParams(),
'body' => (string)$request->getBody(),
'query' => $request->getQueryParams(),
],
'parsed' => $request->getParsedBody(),
'attributes' => $request->getAttributes(),
'entity' => $entity->getAll(),
];
$stream = $file ?? new Stream(
r('{path}/webhooks/' . Config::get('webhook.file_format', 'webhook.{backend}.{event}.{id}.json'), [
'path' => Config::get('tmpDir'),
'time' => (string)time(),
'backend' => $entity->via,
'event' => ag($entity->getExtra($entity->via), 'event', 'unknown'),
'id' => ag($request->getServerParams(), 'X_REQUEST_ID', time()),
'date' => makeDate('now')->format('Ymd'),
'context' => $content,
]), 'w'
);
$stream->write(
json_encode(
value: $content,
flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE
)
);
if (null === $file) {
$stream->close();
}
}
}
if (!function_exists('saveRequestPayload')) {
/**
* Save request payload to stream.
*
* @param iRequest $request Request object.
* @param iStream|null $file When given a stream, it will be used to write payload.
*/
function saveRequestPayload(iRequest $request, iStream|null $file = null): void
{
$content = [
'query' => $request->getQueryParams(),
'parsed' => $request->getParsedBody(),
'server' => $request->getServerParams(),
'body' => (string)$request->getBody(),
'attributes' => $request->getAttributes(),
];
$stream = $file ?? new Stream(r('{path}/debug/request.{id}.json', [
'path' => Config::get('tmpDir'),
'id' => ag($request->getServerParams(), 'X_REQUEST_ID', (string)time()),
]), 'w');
$stream->write(
json_encode(
value: $content,
flags: JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE
)
);
if (null === $file) {
$stream->close();
}
}
}
if (!function_exists('api_response')) {
/**
* Create a raw API response.
*
* @param Status|int $status Optional. The HTTP status code. Default is {@see Status::OK}.
* @param array|iStream|null $body The body to include in the response body.
* @param array $headers Optional. Additional headers to include in the response.
* @param string|null $reason Optional. The reason phrase to include in the response. Default is null.
*
* @return iResponse A PSR-7 compatible response object.
*/
function api_response(
Status|int $status = Status::OK,
array|null|iStream $body = null,
array $headers = [],
string|null $reason = null
): iResponse {
if (is_int($status)) {
$status = Status::from($status);
}
$response = new Response(
status: $status->value,
headers: $headers,
body: is_array($body) ? json_encode($body, flags: Config::get('api.response.encode', 0)) : $body,
reason: $reason,
);
if (null !== $body && !$response->hasHeader('Content-Length') && ($size = $response->getBody()->getSize())) {
$response = $response->withHeader('Content-Length', (string)$size);
}
if (is_array($body) && false === $response->hasHeader('Content-Type')) {
$response = $response->withHeader('Content-Type', 'application/json');
}
foreach (Config::get('api.response.headers', []) as $key => $val) {
if ($response->hasHeader($key)) {
continue;
}
$response = $response->withHeader($key, getValue($val));
}
return $response;
}
}
if (!function_exists('api_error')) {
/**
* Create a API error response.
*
* @param string $message The error message.
* @param Status $httpCode Optional. The HTTP status code. Default is {@see Status::BAD_REQUEST}.
* @param array $body Optional. Additional fields to include in the response body.
* @param array $opts Optional. Additional options.
*
* @return iResponse A PSR-7 compatible response object.
*/
function api_error(
string $message,
Status $httpCode = Status::BAD_REQUEST,
array $body = [],
array $headers = [],
string|null $reason = null,
array $opts = []
): iResponse {
$response = api_response(
status: $httpCode,
body: array_replace_recursive($body, [
'error' => [
'code' => $httpCode->value,
'message' => $message
]
]),
headers: $headers,
reason: $reason
);
foreach ($headers as $key => $val) {
$response = $response->withHeader($key, $val);
}
if (array_key_exists('callback', $opts) && ($opts['callback'] instanceof Closure)) {
$response = $opts['callback']($response);
}
return $response;
}
}
if (!function_exists('httpClientChunks')) {
/**
* Handle response stream as chunks.
*
* @param ResponseStreamInterface $stream Response stream.
*
* @return Generator Generator that yields chunks.
*
* @throws TransportExceptionInterface if stream is not readable.
*/
function httpClientChunks(ResponseStreamInterface $stream): Generator
{
foreach ($stream as $chunk) {
yield $chunk->getContent();
}
}
}
if (!function_exists('queuePush')) {
/**
* Pushes the entity to the queue.
*
* This method adds the entity to the queue for further processing.
*
* @param iState $entity The entity to push to the queue.
* @param bool $remove (optional) Whether to remove the entity from the queue if it already exists (default is false).
*/
function queuePush(iState $entity, bool $remove = false): void
{
if (!$remove && !$entity->hasGuids() && !$entity->hasRelativeGuid()) {
return;
}
try {
$cache = Container::get(iCache::class);
$list = $cache->get('queue', []);
if (true === $remove && array_key_exists($entity->id, $list)) {
unset($list[$entity->id]);
} else {
$list[$entity->id] = ['id' => $entity->id];
}
$cache->set('queue', $list, new DateInterval('P7D'));
} catch (\Psr\SimpleCache\InvalidArgumentException $e) {
Container::get(iLogger::class)->error(
message: 'Exception [{error.kind}] was thrown unhandled during saving [{backend} - {title}} into queue. Error [{error.message} @ {error.file}:{error.line}].',
context: [
'backend' => $entity->via,
'title' => $entity->getName(),
'error' => [
'kind' => $e::class,
'line' => $e->getLine(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
'trace' => $e->getTrace(),
],
);
}
}
}
if (!function_exists('afterLast')) {
/**
* Get the substring after the last occurrence of a search string.
*
* @param string $subject The string to search in.
* @param string $search The string to search for.
*
* @return string The substring after the last occurrence of the search string.
* If the search string is empty or not found in the subject string, the subject string is returned.
*/
function afterLast(string $subject, string $search): string
{
if (empty($search)) {
return $subject;
}
$position = mb_strrpos($subject, $search, 0);
if (false === $position) {
return $subject;
}
return mb_substr($subject, $position + mb_strlen($search));
}
}
if (!function_exists('before')) {
/**
* Get the substring before the first occurrence of a search string.
*
* @param string $subject The subject string to search in.
* @param string $search The search string.
*
* @return string The substring before the first occurrence of the search string.
* If the search string is empty or not found in the subject string, the subject string is returned.
*/
function before(string $subject, string $search): string
{
return $search === '' ? $subject : explode($search, $subject)[0];
}
}
if (!function_exists('after')) {
/**
* Get the string after first occurrence of a search string.
*
* @param string $subject The original string.
* @param string $search The search string.
*
* @return string The string after the first occurrence of the search string.
* If the search string is empty or not found in the subject string, an empty string is returned.
*/
function after(string $subject, string $search): string
{
return empty($search) ? $subject : array_reverse(explode($search, $subject, 2))[0];
}
}
if (!function_exists('makeBackend')) {
/**
* Create new backend client instance.
*
* @param array{name:string|null, type:string, url:string, token:string|int|null, user:string|int|null, options:array} $backend
* @param string|null $name server name.
*
* @return iClient backend client instance.
* @throws InvalidArgumentException if configuration is wrong.
*/
function makeBackend(array $backend, string|null $name = null): iClient
{
if (null === ($backendType = ag($backend, 'type'))) {
throw new InvalidArgumentException('No backend type was set.');
}
if (null === ag($backend, 'url')) {
throw new InvalidArgumentException('No backend url was set.');
}
if (null === ($class = Config::get("supported.{$backendType}", null))) {
throw new InvalidArgumentException(
r('Unexpected client type [{type}] was given. Expecting [{list}]', [
'type' => $backendType,
'list' => array_keys(Config::get('supported', [])),
])
);
}
return Container::getNew($class)->withContext(
new Context(
clientName: $backendType,
backendName: $name ?? ag($backend, 'name', '??'),
backendUrl: new Uri(ag($backend, 'url')),
cache: Container::get(BackendCache::class),
backendId: ag($backend, 'uuid', null),
backendToken: ag($backend, 'token', null),
backendUser: ag($backend, 'user', null),
trace: (bool)ag($backend, 'options.' . Options::DEBUG_TRACE, false),
options: ag($backend, 'options', []),
)
);
}
}
if (!function_exists('arrayToString')) {
/**
* Convert an array to a string representation.
*
* @param array $arr The array to convert.
* @param string $separator The separator used to concatenate the elements (default is ', ').
*
* @return string The string representation of the array.
*/
function arrayToString(array $arr, string $separator = ', '): string
{
$list = [];
foreach ($arr as $key => $val) {
if (is_object($val)) {
if (($val instanceof JsonSerializable)) {
$val = json_encode($val, flags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} elseif (($val instanceof Stringable) || method_exists($val, '__toString')) {
$val = (string)$val;
} else {
$val = get_object_vars($val);
}
}
if (is_array($val)) {
$val = '[ ' . arrayToString($val) . ' ]';
} elseif (is_bool($val)) {
$val = true === $val ? 'true' : 'false';
} else {
$val = $val ?? 'None';
}
$list[] = sprintf("(%s: %s)", $key, $val);
}
return implode($separator, $list);
}
}
if (!function_exists('commandContext')) {
/**
* Returns the command context based on the environment.
*
* @return string The command context string.
*/
function commandContext(): string
{
if (inContainer()) {
return r('{command} exec -ti {name} console ', [
'command' => @file_exists('/run/.containerenv') ? 'podman' : 'docker',
'name' => env('CONTAINER_NAME', 'watchstate'),
]);
}
return ($_SERVER['argv'][0] ?? 'php bin/console') . ' ';
}
}
if (!function_exists('getAppVersion')) {
/**
* Get the current version of the application.
*
* @return string The application version.
*/
function getAppVersion(): string
{
$version = Config::get('version', 'dev-master');
if ('$(version_via_ci)' === $version) {
$gitDir = ROOT_PATH . '/.git/';
if (is_dir($gitDir)) {
$cmd = 'git --git-dir=%1$s describe --exact-match --tags 2> /dev/null || git --git-dir=%1$s rev-parse --short HEAD';
exec(sprintf($cmd, escapeshellarg($gitDir)), $output, $status);
if (0 === $status) {
return $output[0] ?? 'dev-master';
}
}
return 'dev-master';
}
return $version;
}
}
if (!function_exists('isValidName')) {
/**
* Check if the given name is valid.
*
* The name must contain only alphanumeric characters and underscores.
*
* @param string $name The name to validate.
*
* @return bool True if the name is valid, false otherwise.
*/
function isValidName(string $name): bool
{
return 1 === preg_match('/^\w+$/', $name);
}
}
if (false === function_exists('formatDuration')) {
/**
* Format duration in milliseconds to HH:MM:SS format.
*
* @param int|float $milliseconds The duration in milliseconds.
*
* @return string The formatted duration in HH:MM:SS format.
*/
function formatDuration(int|float $milliseconds): string
{
$seconds = floor($milliseconds / 1000);
$minutes = floor($seconds / 60);
$hours = floor($minutes / 60);
$seconds %= 60;
$minutes %= 60;
return sprintf('%02u:%02u:%02u', $hours, $minutes, $seconds);
}
}
if (false === function_exists('array_keys_diff')) {
/**
* Return keys that match or does not match keys in list.
*
* @param array $base array containing all keys.
* @param array $list list of keys that you want to filter based on.
* @param bool $has Whether to get keys that exist in $list or exclude them.
*
* @return array The filtered array.
*/
function array_keys_diff(array $base, array $list, bool $has = true): array
{
return array_filter($base, fn($key) => $has === in_array($key, $list), ARRAY_FILTER_USE_KEY);
}
}
if (false === function_exists('getMemoryUsage')) {
/**
* Get the current memory usage.
*
* @return string The memory usage in human-readable format.
*/
function getMemoryUsage(): string
{
return fsize(memory_get_usage() - BASE_MEMORY);
}
}
if (false === function_exists('getPeakMemoryUsage')) {
/**
* Get the peak memory usage of the script.
*
* @return string The peak memory usage in human-readable format.
*/
function getPeakMemoryUsage(): string
{
return fsize(memory_get_peak_usage() - BASE_PEAK_MEMORY);
}
}
if (false === function_exists('makeIgnoreId')) {
/**
* Make ignore id from given URL.
*
* @param string $url The URL to manipulate.
*
* @return iUri The modified URI.
*/
function makeIgnoreId(string $url): iUri
{
static $filterQuery = null;
if (null === $filterQuery) {
$filterQuery = function (string $query): string {
$list = $final = [];
$allowed = ['id'];
parse_str($query, $list);
foreach ($list as $key => $val) {
if (empty($val) || false === in_array($key, $allowed)) {
continue;
}
$final[$key] = $val;
}
return http_build_query($final);
};
}
$id = (new Uri($url))->withPath('')->withFragment('')->withPort(null);
return $id->withQuery($filterQuery($id->getQuery()));
}
}
if (false === function_exists('isIgnoredId')) {
/**
* Check if an ID is ignored.
*
* @param string $backend The backend.
* @param string $type The type.
* @param string $db The database.
* @param int|string $id The ID.
* @param int|string|null $backendId The backend ID (optional).
*
* @return bool Returns true if the ID is ignored, false otherwise.
* @throws InvalidArgumentException Throws an exception if an invalid context type is given.
*/
function isIgnoredId(
string $backend,
string $type,
string $db,
string|int $id,
string|int|null $backendId = null
): bool {
if (false === in_array($type, iState::TYPES_LIST)) {
throw new InvalidArgumentException(sprintf('Invalid context type \'%s\' was given.', $type));
}
$list = Config::get('ignore', []);
$key = makeIgnoreId(sprintf('%s://%s:%s@%s?id=%s', $type, $db, $id, $backend, $backendId));
if (null !== ($list[(string)$key->withQuery('')] ?? null)) {
return true;
}
if (null === $backendId) {
return false;
}
return null !== ($list[(string)$key] ?? null);
}
}
if (false === function_exists('r')) {
/**
* Substitute words enclosed in special tags for values from context.
*
* @param string $text text that contains tokens.
* @param array $context A key/value pairs list.
* @param array $opts
*
* @return string
*/
function r(string $text, array $context = [], array $opts = []): string
{
return r_array($text, $context, $opts)['message'];
}
}
if (false === function_exists('r_array')) {
/**
* Substitute words enclosed in special tags for values from context.
*
* @param string $text text that contains tokens.
* @param array $context A key/value pairs list.
* @param array $opts
*
* @return array{message:string, context:array}
*/
function r_array(string $text, array $context = [], array $opts = []): array
{
$tagLeft = $opts['tag_left'] ?? '{';
$tagRight = $opts['tag_right'] ?? '}';
$logBehavior = $opts['log_behavior'] ?? false;
if (false === str_contains($text, $tagLeft) || false === str_contains($text, $tagRight)) {
return ['message' => $text, 'context' => $context];
}
$pattern = '#' . preg_quote($tagLeft, '#') . '([\w_.]+)' . preg_quote($tagRight, '#') . '#is';
$status = preg_match_all($pattern, $text, $matches);
if (false === $status || $status < 1) {
return ['message' => $text, 'context' => $context];
}
$replacements = [];
foreach ($matches[1] as $key) {
$placeholder = $tagLeft . $key . $tagRight;
if (false === str_contains($text, $placeholder)) {
continue;
}
if (false === ag_exists($context, $key)) {
continue;
}
$val = ag($context, $key);
$context = ag_delete($context, $key);
if (is_null($val) || is_scalar($val) || (is_object($val) && method_exists($val, '__toString'))) {
$replacements[$placeholder] = $val;
} elseif ($val instanceof DateTimeInterface) {
$replacements[$placeholder] = (string)$val;
} elseif ($val instanceof UnitEnum) {
$replacements[$placeholder] = $val instanceof BackedEnum ? $val->value : $val->name;
} elseif (is_object($val)) {
$replacements[$placeholder] = $logBehavior ? '[object ' . Utils::getClass($val) . ']' : implode(
',',
get_object_vars($val)
);
} elseif (is_array($val)) {
$replacements[$placeholder] = $logBehavior ? 'array' . Utils::jsonEncode($val, null, true) : implode(
',',
$val
);
} else {
$replacements[$placeholder] = '[' . gettype($val) . ']';
}
}
return [
'message' => strtr($text, $replacements),
'context' => $context
];
}
}
if (false === function_exists('generateRoutes')) {
/**
* Generate routes based on the available commands.
*
* @param string $type The type of routes to return. defaults to is cli.
*
* @return array The generated routes.
*/
function generateRoutes(string $type = 'cli'): array
{
$dirs = [__DIR__ . '/../Commands'];
foreach (array_keys(Config::get('supported', [])) as $backend) {
$dir = r(__DIR__ . '/../Backends/{backend}/Commands', ['backend' => ucfirst($backend)]);
if (!file_exists($dir)) {
continue;
}
$dirs[] = $dir;
}
$routes_cli = (new Router($dirs))->generate();
$cache = Container::get(iCache::class);
try {
$cache->set('routes_cli', $routes_cli, new DateInterval('PT1H'));
} catch (\Psr\SimpleCache\InvalidArgumentException) {
}
$routes_http = (new Router([__DIR__ . '/../API']))->generate();
try {
$cache->set('routes_http', $routes_http, new DateInterval('P1D'));
} catch (\Psr\SimpleCache\InvalidArgumentException) {
}
return 'http' === $type ? $routes_http : $routes_cli;
}
}
if (!function_exists('getClientIp')) {
/**
* Get the client IP address.
*
* @param iRequest|null $request (optional) The request object.
*
* @return string The client IP address.
*/
function getClientIp(?iRequest $request = null): string
{
$params = $request?->getServerParams() ?? $_SERVER;
$realIp = (string)ag($params, 'REMOTE_ADDR', '0.0.0.0');
if (false === (bool)Config::get('trust.proxy', false)) {
return $realIp;
}
$forwardIp = ag(
$params,
'HTTP_' . strtoupper(trim(str_replace('-', '_', Config::get('trust.header', 'X-Forwarded-For'))))
);
if ($forwardIp === $realIp || empty($forwardIp)) {
return $realIp;
}
if (null === ($firstIp = explode(',', $forwardIp)[0] ?? null) || empty($firstIp)) {
return $realIp;
}
$firstIp = trim($firstIp);
if (false === filter_var($firstIp, FILTER_VALIDATE_IP)) {
return $realIp;
}
return trim($firstIp);
}
}
if (false === function_exists('inContainer')) {
/**
* Check if the code is running within a container.
*
* @return bool True if the code is running within a container, false otherwise.
*/
function inContainer(): bool
{
if (true === (bool)env('IN_CONTAINER')) {
return true;
}
if (true === @file_exists('/.dockerenv') || true === @file_exists('/run/.containerenv')) {
return true;
}
return false;
}
}
if (false === function_exists('isValidURL')) {
/**
* Validate URL per RFC3987 (IRI)
*
* @param string $url The URL to validate.
*
* @return bool True if the URL is valid, false otherwise.
* @SuppressWarnings
*/
function isValidURL(string $url): bool
{
// RFC 3987 For absolute IRIs (internationalized):
return (bool)@preg_match(
'/^[a-z](?:[-a-z0-9\+\.])*:(?:\/\/(?:(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:])*@)?(?:\[(?:(?:(?:[0-9a-f]{1,4}:){6}(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(?:\.(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3})|::(?:[0-9a-f]{1,4}:){5}(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(?:\.(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3})|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(?:\.(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3})|(?:[0-9a-f]{1,4}:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(?:\.(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3})|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(?:\.(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3})|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(?:\.(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3})|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(?:\.(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3})|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|v[0-9a-f]+[-a-z0-9\._~!\$&\'\(\)\*\+,;=:]+)\]|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(?:\.(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])){3}|(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=@])*)(?::[0-9]*)?(?:\/(?:(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:@]))*)*|\/(?:(?:(?:(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:@]))+)(?:\/(?:(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:@]))*)*)?|(?:(?:(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:@]))+)(?:\/(?:(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:@]))*)*|(?!(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:@])))(?:\?(?:(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:@])|[\x{E000}-\x{F8FF}\x{F0000}-\x{FFFFD}|\x{100000}-\x{10FFFD}\/\?])*)?(?:\#(?:(?:%[0-9a-f][0-9a-f]|[-a-z0-9\._~\x{A0}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFEF}\x{10000}-\x{1FFFD}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}\x{40000}-\x{4FFFD}\x{50000}-\x{5FFFD}\x{60000}-\x{6FFFD}\x{70000}-\x{7FFFD}\x{80000}-\x{8FFFD}\x{90000}-\x{9FFFD}\x{A0000}-\x{AFFFD}\x{B0000}-\x{BFFFD}\x{C0000}-\x{CFFFD}\x{D0000}-\x{DFFFD}\x{E1000}-\x{EFFFD}!\$&\'\(\)\*\+,;=:@])|[\/\?])*)?$/iu',
$url
);
}
}
if (false === function_exists('getSystemMemoryInfo')) {
/**
* Get system memory information.
*
* @return array{ MemTotal: float, MemFree: float, MemAvailable: float, SwapTotal: float, SwapFree: float }
*/
function getSystemMemoryInfo(): array
{
$keys = [
'MemTotal' => 'mem_total',
'MemFree' => 'mem_free',
'MemAvailable' => 'mem_available',
'SwapTotal' => 'swap_total',
'SwapFree' => 'swap_free',
];
$result = [];
if (!is_readable('/proc/meminfo')) {
return $result;
}
if (false === ($lines = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))) {
return $result;
}
foreach ($lines as $line) {
if (empty($line)) {
continue;
}
$line = explode(':', $line);
$key = trim($line[0]);
if (false === array_key_exists($key, $keys)) {
continue;
}
$val = trim(str_ireplace(' kB', '', $line[1]));
$value = 1000 * (float)$val;
$result[$keys[$key]] = $value;
}
return $result;
}
}
if (!function_exists('parseConfigValue')) {
function parseConfigValue(mixed $value, Closure|null $callback = null): mixed
{
if (is_string($value) && preg_match('#%{(.+?)}#s', $value)) {
$val = preg_replace_callback('#%{(.+?)}#s', fn($match) => Config::get($match[1], $match[1]), $value);
return null !== $callback && null !== $val ? $callback($val) : $val;
}
return $value;
}
}
if (!function_exists('tryCache')) {
/**
* Try to get a value from the cache, if it does not exist, call the callback and cache the result.
*
* @param iCache $cache The cache instance.
* @param string $key The cache key.
* @param Closure $callback The callback to call if the key does not exist.
* @param DateInterval $ttl The time to live for the cache.
* @param iLogger|null $logger The logger instance (optional).
*
* @return mixed The value from the cache or the callback.
*/
function tryCache(
iCache $cache,
string $key,
Closure $callback,
DateInterval $ttl,
iLogger|null $logger = null
): mixed {
if (true === $cache->has($key)) {
$logger?->debug("Cache hit for key '{key}'.", ['key' => $key]);
return $cache->get($key);
}
$data = $callback();
try {
$cache->set($key, $data, $ttl);
} catch (\Psr\SimpleCache\InvalidArgumentException) {
$logger?->error("Failed to cache data for key '{key}'.", ['key' => $key]);
}
return $data;
}
}
if (!function_exists('checkIgnoreRule')) {
/**
* Check if the given ignore rule is valid.
*
* @param string $guid The ignore rule to check.
*
* @return bool True if the ignore rule is valid, false otherwise.
* @throws RuntimeException Throws an exception if the ignore rule is invalid.
*/
function checkIgnoreRule(string $guid): bool
{
$urlParts = parse_url($guid);
if (null === ($db = ag($urlParts, 'user'))) {
throw new RuntimeException('No db source was given.');
}
$sources = array_keys(Guid::getSupported());
if (false === in_array('guid_' . $db, $sources)) {
throw new RuntimeException(r("Invalid db source name '{db}' was given. Expected values are '{dbs}'.", [
'db' => $db,
'dbs' => implode(', ', array_map(fn($f) => after($f, 'guid_'), $sources)),
]));
}
if (null === ($id = ag($urlParts, 'pass'))) {
throw new RuntimeException('No external id was given.');
}
Guid::validate($db, $id);
if (null === ($type = ag($urlParts, 'scheme'))) {
throw new RuntimeException('No type was given.');
}
if (false === in_array($type, iState::TYPES_LIST)) {
throw new RuntimeException(r("Invalid type '{type}' was given. Expected values are '{types}'.", [
'type' => $type,
'types' => implode(', ', iState::TYPES_LIST)
]));
}
if (null === ($backend = ag($urlParts, 'host'))) {
throw new RuntimeException('No backend was given.');
}
$backends = array_keys(Config::get('servers', []));
if (false === in_array($backend, $backends)) {
throw new RuntimeException(r("Invalid backend name '{backend}' was given. Expected values are '{list}'.", [
'backend' => $backend,
'list' => implode(', ', $backends),
]));
}
return true;
}
}
if (!function_exists('addCors')) {
function addCors(iResponse $response, array $headers = [], array $methods = []): iResponse
{
$headers += [
'Access-Control-Max-Age' => 600,
'Access-Control-Allow-Headers' => 'X-Application-Version, X-Request-Id, *',
'Access-Control-Allow-Origin' => '*',
];
if (count($methods) > 0) {
$headers['Access-Control-Allow-Methods'] = implode(', ', $methods);
}
foreach ($headers as $key => $val) {
if (true === $response->hasHeader($key)) {
continue;
}
$response = $response->withHeader($key, $val);
}
return $response;
}
}
if (!function_exists('deepArrayMerge')) {
/**
* Recursively merge arrays.
*
* @param array $arrays The arrays to merge.
* @param bool $preserve_integer_keys (Optional) Whether to preserve integer keys.
*
* @return array The merged array.
*/
function deepArrayMerge(array $arrays, bool $preserve_integer_keys = false): array
{
$result = [];
foreach ($arrays as $array) {
foreach ($array as $key => $value) {
// Renumber integer keys as array_merge_recursive() does unless
// $preserve_integer_keys is set to TRUE. Note that PHP automatically
// converts array keys that are integer strings (e.g., '1') to integers.
if (is_int($key) && !$preserve_integer_keys) {
$result[] = $value;
} // Recurse when both values are arrays.
elseif (isset($result[$key]) && is_array($result[$key]) && is_array($value)) {
$result[$key] = deepArrayMerge([$result[$key], $value], $preserve_integer_keys);
} // Otherwise, use the latter value, overriding any previous value.
else {
$result[$key] = $value;
}
}
}
return $result;
}
}
if (!function_exists('runCommand')) {
/**
* Run a command.
*
* @param string $command The command to run.
* @param array $args The command arguments.
* @param bool $asArray (Optional) Whether to return the output as an array.
* @param array $opts (Optional) Additional options.
*
* @return string|array The output of the command.
*/
function runCommand(string $command, array $args = [], bool $asArray = false, array $opts = []): string|array
{
$path = realpath(__DIR__ . '/../../');
$opts = DataUtil::fromArray($opts);
set_time_limit(0);
$process = new Process(
command: ["{$path}/bin/console", $command, ...$args],
cwd: $path,
env: $_ENV,
timeout: $opts->get('timeout', 3600),
);
$output = $asArray ? [] : '';
$process->run(function ($type, $data) use (&$output, $asArray) {
if ($asArray) {
$output[] = $data;
return;
}
$output .= $data;
});
return $output;
}
}
if (!function_exists('tryCatch')) {
/**
* Try to execute a callback and catch any exceptions.
*
* @param Closure $callback The callback to execute.
* @param Closure(Throwable):mixed|null $catch (Optional) Executes when an exception is caught.
* @param Closure|null $finally (Optional) Executes after the callback and catch.
*
* @return mixed The result of the callback or the catch. or null if no catch is provided.
*/
function tryCatch(Closure $callback, Closure|null $catch = null, Closure|null $finally = null): mixed
{
try {
return $callback();
} catch (Throwable $e) {
return null !== $catch ? $catch($e) : null;
} finally {
if (null !== $finally) {
$finally();
}
}
}
}
if (!function_exists('APIRequest')) {
/**
* Make internal request to the API.
*
* @param string $method The request method.
* @param string $path The request path.
* @param array $json The request body.
* @param array{ server: array, query: array, headers: array} $opts Additional options.
*
* @return APIResponse The response object.
*/
function APIRequest(string $method, string $path, array $json = [], array $opts = []): APIResponse
{
$initializer = Container::get(Initializer::class);
$factory = new Psr17Factory();
$creator = new ServerRequestCreator($factory, $factory, $factory, $factory);
$uri = new Uri($path);
$server = [
'REQUEST_METHOD' => $method,
'SCRIPT_FILENAME' => realpath(__DIR__ . '/../../public/index.php'),
'REMOTE_ADDR' => '127.0.0.1',
'REQUEST_URI' => Config::get('api.prefix') . $uri->getPath(),
'SERVER_NAME' => 'localhost',
'SERVER_PORT' => 80,
'HTTP_USER_AGENT' => Config::get('http.default.options.headers.User-Agent', 'APIRequest'),
...ag($opts, 'server', []),
];
$headers = [
'Host' => 'localhost',
'Accept' => 'application/json',
...ag($opts, 'headers', []),
];
$body = null;
if (!empty($json)) {
$body = json_encode($json);
$headers['CONTENT_TYPE'] = 'application/json';
$headers['CONTENT_LENGTH'] = strlen($body);
$server['CONTENT_TYPE'] = $headers['CONTENT_TYPE'];
$server['CONTENT_LENGTH'] = $headers['CONTENT_LENGTH'];
}
$query = ag($opts, 'query', []);
if (!empty($uri->getQuery())) {
parse_str($uri->getQuery(), $queryFromPath);
$query = deepArrayMerge([$queryFromPath, $query]);
}
if (!empty($query)) {
$server['QUERY_STRING'] = http_build_query($query);
}
$response = $initializer->http(
$creator->fromArrays(
server: $server,
headers: $headers,
get: $query,
post: $json,
body: $body
)->withAttribute('INTERNAL_REQUEST', true)
);
$statusCode = Status::tryFrom($response->getStatusCode()) ?? Status::SERVICE_UNAVAILABLE;
if ($response->getBody()->getSize() < 1) {
return new APIResponse($statusCode, $response->getHeaders());
}
$response->getBody()->rewind();
if (false !== str_contains($response->getHeaderLine('Content-Type'), 'application/json')) {
try {
$json = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
$json = [];
}
$response->getBody()->rewind();
return new APIResponse($statusCode, $response->getHeaders(), $json, $response->getBody());
}
return new APIResponse($statusCode, $response->getHeaders(), [], $response->getBody());
}
}
if (!function_exists('getServerColumnSpec')) {
/**
* Returns the spec for the given server column.
*
* @param string $column
*
* @return array The spec for the given column. Otherwise, an empty array.
*/
function getServerColumnSpec(string $column): array
{
static $_serverSpec = null;
if (null === $_serverSpec) {
$_serverSpec = require __DIR__ . '/../../config/servers.spec.php';
}
foreach ($_serverSpec as $spec) {
if (ag($spec, 'key') === $column) {
return $spec;
}
}
return [];
}
}
if (!function_exists('getEnvSpec')) {
/**
* Returns the spec for the given environment variable.
*
* @param string $env
*
* @return array The spec for the given column. Otherwise, an empty array.
*/
function getEnvSpec(string $env): array
{
static $_envSpec = null;
if (null === $_envSpec) {
$_envSpec = require __DIR__ . '/../../config/env.spec.php';
}
foreach ($_envSpec as $spec) {
if (ag($spec, 'key') === $env) {
return $spec;
}
}
return [];
}
}
if (!function_exists('parseEnvFile')) {
/**
* Parse the environment file, and returns key/value pairs.
*
* @param string $file The file to load.
*
* @return array<string, string> The environment variables.
* @throws InvalidArgumentException Throws an exception if the file does not exist.
*/
function parseEnvFile(string $file): array
{
$env = [];
if (false === file_exists($file)) {
throw new InvalidArgumentException(r("The file '{file}' does not exist.", ['file' => $file]));
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (empty($line)) {
continue;
}
if (true === str_starts_with($line, '#') || false === str_contains($line, '=')) {
continue;
}
[$name, $value] = explode('=', $line, 2);
// -- check if value is quoted.
if ((true === str_starts_with($value, '"') && true === str_ends_with($value, '"')) ||
(true === str_starts_with($value, "'") && true === str_ends_with($value, "'"))) {
$value = substr($value, 1, -1);
}
$value = trim($value);
if ('' === $value) {
continue;
}
$env[$name] = $value;
}
return $env;
}
}
if (!function_exists('loadEnvFile')) {
/**
* Load the environment file.
*
* @param string $file The file to load.
* @param bool $usePutEnv (Optional) Whether to use putenv.
* @param bool $override (Optional) Whether to override existing values.
*
* @return void
*/
function loadEnvFile(string $file, bool $usePutEnv = false, bool $override = true): void
{
try {
$env = parseEnvFile($file);
if (count($env) < 1) {
return;
}
} catch (InvalidArgumentException) {
return;
}
foreach ($env as $name => $value) {
if (false === $override && true === array_key_exists($name, $_ENV)) {
continue;
}
if (true === $usePutEnv) {
putenv("{$name}={$value}");
}
$_ENV[$name] = $value;
if (!str_starts_with($name, 'HTTP_')) {
$_SERVER[$name] = $value;
}
}
}
}
if (!function_exists('isTaskWorkerRunning')) {
/**
* Check if the task worker is running. This function is only available when running in a container.
*
* @param bool $ignoreContainer (Optional) Whether to ignore the container check.
*
* @return array{ status: bool, message: string }
*/
function isTaskWorkerRunning(bool $ignoreContainer = false): array
{
if (false === $ignoreContainer && !inContainer()) {
return [
'status' => true,
'restartable' => false,
'message' => 'We can only track the task worker status when running in a container.'
];
}
if (true === (bool)env('DISABLE_CRON', false)) {
return [
'status' => false,
'restartable' => false,
'message' => "Task runner is disabled via 'DISABLE_CRON' environment variable."
];
}
$pidFile = '/tmp/ws-job-runner.pid';
if (!file_exists($pidFile)) {
return [
'status' => false,
'restartable' => true,
'message' => 'No PID file was found - Likely means task worker failed to run.'
];
}
try {
$pid = trim((string)(new Stream($pidFile)));
} catch (Throwable $e) {
return ['status' => false, 'message' => $e->getMessage()];
}
if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) {
return ['status' => true, 'restartable' => true, 'message' => 'Task worker is running.'];
}
return [
'status' => false,
'restartable' => true,
'message' => r("Found PID '{pid}' in file, but it seems the process is not active.", [
'pid' => $pid
])
];
}
}
if (!function_exists('restartTaskWorker')) {
/**
* Restart the task worker.
*
* @param bool $ignoreContainer (Optional) Whether to ignore the container check.
* @param bool $force (Optional) Whether to force kill the task worker.
*
* @return array{ status: bool, message: string }
*/
function restartTaskWorker(bool $ignoreContainer = false, bool $force = false): array
{
if (false === $ignoreContainer && !inContainer()) {
return [
'status' => true,
'restartable' => false,
'message' => 'We can only restart the task worker when running in a container.'
];
}
$pidFile = '/tmp/ws-job-runner.pid';
if (true === file_exists($pidFile)) {
try {
$pid = trim((string)(new Stream($pidFile)));
} catch (Throwable $e) {
return ['status' => false, 'restartable' => true, 'message' => $e->getMessage()];
}
if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) {
@posix_kill((int)$pid, $force ? SIGKILL : SIGHUP);
}
clearstatcache(true, $pidFile);
if (true === file_exists($pidFile)) {
@unlink($pidFile);
}
}
$process = Process::fromShellCommandline('/opt/bin/job-runner 2>&1 &');
$process->run();
return [
'status' => $process->isSuccessful(),
'restartable' => true,
'message' => $process->isSuccessful() ? 'Task worker restarted.' : $process->getErrorOutput(),
];
}
}
if (!function_exists('findSideCarFiles')) {
function findSideCarFiles(SplFileInfo $path): array
{
$list = [];
$possibleExtensions = ['jpg', 'jpeg', 'png'];
foreach ($possibleExtensions as $ext) {
if (file_exists($path->getPath() . "/poster.{$ext}")) {
$list[] = $path->getPath() . "/poster.{$ext}";
}
if (file_exists($path->getPath() . "/fanart.{$ext}")) {
$list[] = $path->getPath() . "/fanart.{$ext}";
}
}
$pat = $path->getPath() . '/' . before($path->getFilename(), '.');
$pat = preg_replace('/([*?\[])/', '[$1]', $pat);
$glob = glob($pat . '*');
if (false === $glob) {
return $list;
}
foreach ($glob as $item) {
$item = new SplFileInfo($item);
if (!$item->isFile() || $item->getFilename() === $path->getFilename()) {
continue;
}
$list[] = $item->getRealPath();
}
return $list;
}
}
if (!function_exists('array_change_key_case_recursive')) {
function array_change_key_case_recursive(array $input, int $case = CASE_LOWER): array
{
if (!in_array($case, [CASE_UPPER, CASE_LOWER])) {
throw new RuntimeException("Case parameter '{$case}' is invalid.");
}
$input = array_change_key_case($input, $case);
foreach ($input as $key => $array) {
if (is_array($array)) {
$input[$key] = array_change_key_case_recursive($array, $case);
}
}
return $input;
}
}
if (!function_exists('getMimeType')) {
function getMimeType(string $file): string
{
static $fileInfo = null;
if (null === $fileInfo) {
$fileInfo = new finfo(FILEINFO_MIME_TYPE);
}
return $fileInfo->file($file);
}
}
if (!function_exists('getExtension')) {
function getExtension(string $filename): string
{
return (new SplFileInfo($filename))->getExtension();
}
}
if (!function_exists('ffprobe_file')) {
/**
* Get FFProbe Info.
*
* @param string $path
* @param iCache|null $cache
* @return array
* @noinspection PhpDocMissingThrowsInspection
*/
function ffprobe_file(string $path, iCache|null $cache = null): array
{
$cacheKey = md5($path . filesize($path));
if (null !== $cache && $cache->has($cacheKey)) {
$data = $cache->get($cacheKey);
return (is_array($data) ? $data : json_decode($data, true));
}
$mimeType = getMimeType($path);
$isTs = str_ends_with($path, '.ts') && 'application/octet-stream' === $mimeType;
if (!str_starts_with($mimeType, 'video/') && !str_starts_with($mimeType, 'audio/') && !$isTs) {
throw new RuntimeException(sprintf("Unable to run ffprobe on '%s'", $path));
}
$process = new Process([
'ffprobe',
'-v',
'quiet',
'-print_format',
'json',
'-show_format',
'-show_streams',
'file:' . basename($path)
], cwd: dirname($path));
$process->run();
if (!$process->isSuccessful()) {
throw new RuntimeException(sprintf("Failed to run ffprobe on '%s'. %s", $path, $process->getErrorOutput()));
}
$json = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
$data = array_change_key_case_recursive($json, CASE_LOWER);
$cache?->set($cacheKey, $data, new DateInterval('PT24H'));
return $data;
}
}