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