Made it so internal API requests wouldn't make it into access log.

There is a new ENV variable WS_API_LOG_INTERNAL to enable it if you desire, it's mostly helpful for development.
Internal API requests no longer require API key. Migrated system:env to use the API in effort to reduce code duplicating
There going to be more code cleaning later on once we migrate the majority of the code to the API.
This commit is contained in:
Abdulmhsen B. A. A
2024-05-18 16:48:32 +03:00
parent 126b0600d1
commit 2050676663
12 changed files with 108 additions and 145 deletions

View File

@@ -2,10 +2,10 @@
declare(strict_types=1);
use App\Libs\Middlewares\APIKeyRequiredMiddleware;
use App\Libs\Middlewares\ParseJsonBodyMiddleware;
use App\Libs\Middlewares\{APIKeyRequiredMiddleware, NoAccessLogMiddleware, ParseJsonBodyMiddleware};
return static fn(): array => [
fn() => new APIKeyRequiredMiddleware(),
fn() => new ParseJsonBodyMiddleware(),
fn() => new NoAccessLogMiddleware(),
];

View File

@@ -37,6 +37,7 @@ return (function () {
'pattern_match' => [
'backend' => '[a-zA-Z0-9_-]+',
],
'logInternal' => (bool)env('WS_API_LOG_INTERNAL', false),
],
'webui' => [
'enabled' => (bool)env('WEBUI_ENABLED', env('WS_WEBUI_ENABLED', true)),

View File

@@ -140,6 +140,11 @@ return (function () {
'description' => 'Close all open routes and enforce API key authentication on all endpoints.',
'type' => 'bool',
],
[
'key' => 'WS_API_LOG_INTERNAL',
'description' => 'Log internal requests to the API.',
'type' => 'bool',
],
];
$validateCronExpression = function ($value): bool {

View File

@@ -170,11 +170,10 @@ final class Index
$stream->rewind();
return new Response(
status: HTTP_STATUS::HTTP_OK->value,
headers: ['Content-Type' => 'text/plain'],
body: $stream
);
return new Response(status: HTTP_STATUS::HTTP_OK->value, headers: [
'Content-Type' => 'text/plain',
'X-No-AccessLog' => '1'
], body: $stream);
}
private function download(string $filePath): iResponse

View File

@@ -256,7 +256,12 @@ class Command extends BaseCommand
if (true === is_array($leaf)) {
continue;
}
$subItem[$key] = $leaf;
if (ag_exists($item, 'type') && 'bool' === ag($item, 'type', 'string')) {
$subItem[$key] = $leaf ? 'true' : 'false';
}
}
$list[] = $subItem;

View File

@@ -7,8 +7,7 @@ namespace App\Commands\System;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Config;
use App\Libs\EnvFile;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\HTTP_STATUS;
use Symfony\Component\Console\Input\InputInterface as iInput;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface as iOutput;
@@ -23,8 +22,6 @@ final class EnvCommand extends Command
{
public const string ROUTE = 'system:env';
public const array EXEMPT_KEYS = ['HTTP_PORT', 'TZ'];
/**
* Configure the command.
*/
@@ -59,8 +56,6 @@ final class EnvCommand extends Command
* the key SHOULD attempt to mirror the key path in default config, If not applicable or otherwise impossible it
should then use an approximate path.
* The following keys are exempt from the rules: [<flag>{exempt_keys}</flag>].
-------
<notice>[ FAQ ]</notice>
-------
@@ -98,7 +93,6 @@ final class EnvCommand extends Command
HELP,
[
'path' => after(Config::get('path') . '/config', ROOT_PATH),
'exempt_keys' => implode(', ', self::EXEMPT_KEYS),
]
)
);
@@ -115,109 +109,71 @@ final class EnvCommand extends Command
protected function runCommand(iInput $input, iOutput $output): int
{
if ($input->getOption('list')) {
return $this->handleEnvList($input, $output);
return $this->handleEnvList($input, $output, true);
}
if ($input->getOption('key')) {
return $this->handleEnvUpdate($input, $output);
}
$mode = $input->getOption('output');
$keys = [];
foreach (getenv() as $key => $val) {
if (false === str_starts_with($key, 'WS_') && false === in_array($key, self::EXEMPT_KEYS)) {
continue;
}
$keys[$key] = $val;
}
if ('table' === $mode) {
$list = [];
foreach ($keys as $key => $val) {
$list[] = ['key' => $key, 'value' => $val];
}
$keys = $list;
}
$this->displayContent($keys, $output, $mode);
return self::SUCCESS;
return $this->handleEnvList($input, $output, false);
}
private function handleEnvUpdate(iInput $input, iOutput $output): int
{
$key = strtoupper($input->getOption('key'));
if (false === str_starts_with($key, 'WS_')) {
$output->writeln(r("<error>Invalid key '{key}'. Key must start with 'WS_'.</error>", ['key' => $key]));
return self::FAILURE;
}
if (!$input->getOption('set') && !$input->getOption('delete')) {
$output->writeln((string)env($key, ''));
return self::SUCCESS;
}
$envFile = new EnvFile($input->getOption('envfile'), create: true);
if (true === (bool)$input->getOption('delete')) {
$envFile->remove($key);
$response = APIRequest('DELETE', '/system/env/' . $key);
} else {
$spec = $this->getSpec($key);
if (empty($spec)) {
$output->writeln(
r(
"<error>Invalid key '{key}' was used. Run the command with --list flag to see list of supported vars.</error>",
['key' => $key]
)
);
return self::FAILURE;
}
try {
if (!$this->checkValue($spec, $input->getOption('set'))) {
$output->writeln(r("<error>Invalid value for '{key}'.</error>", ['key' => $key]));
return self::FAILURE;
}
} catch (InvalidArgumentException $e) {
$output->writeln(r("<error>Value validation for '{key}' failed. {message}</error>", [
'key' => $key,
'message' => $e->getMessage()
]));
return self::FAILURE;
}
$envFile->set($key, $input->getOption('set'));
$response = APIRequest('POST', '/system/env/' . $key, ['value' => $input->getOption('set')]);
}
$output->writeln(r("<info>Key '{key}' {operation} successfully.</info>", [
'key' => $key,
'operation' => (true === (bool)$input->getOption('delete')) ? 'deleted' : 'updated'
]));
if (HTTP_STATUS::HTTP_OK !== $response->status) {
$output->writeln(r("<error>API error. {status}: {message}</error>", [
'key' => $key,
'status' => $response->status->value,
'message' => ag($response->body, 'error.message', 'Unknown error.')
]));
return self::FAILURE;
}
$envFile->persist();
$output->writeln(r("<info>Key '{key}' was {action}.</info>", [
'key' => $key,
'action' => true === (bool)$input->getOption('delete') ? 'deleted' : 'updated',
]));
return self::SUCCESS;
}
private function handleEnvList(iInput $input, iOutput $output): int
private function handleEnvList(iInput $input, iOutput $output, bool $all = true): int
{
$spec = require __DIR__ . '/../../../config/env.spec.php';
$query = [];
if (false === $all) {
$query['set'] = 1;
}
$response = APIRequest('GET', '/system/env', opts: [
'query' => $query,
]);
$keys = [];
$mode = $input->getOption('output');
foreach ($spec as $info) {
$data = ag($response->body, 'data', []);
foreach ($data as $info) {
$item = [
'key' => $info['key'],
'description' => $info['description'],
'type' => $info['type'],
'value' => env($info['key'], 'Not Set')
'value' => ag($info, 'value', 'Not set'),
];
$keys[] = $item;
@@ -227,44 +183,4 @@ final class EnvCommand extends Command
return self::SUCCESS;
}
private function getSpec(string $key): array
{
$spec = require __DIR__ . '/../../../config/env.spec.php';
foreach ($spec as $info) {
if ($info['key'] !== $key) {
continue;
}
return $info;
}
return [];
}
/**
* Check if the value is valid.
*
* @param array $spec the specification for the key.
* @param mixed $value the value to check.
*
* @return bool true if the value is valid, false otherwise.
*/
private function checkValue(array $spec, mixed $value): bool
{
if (str_contains($value, ' ') && (!str_starts_with($value, '"') || !str_ends_with($value, '"'))) {
throw new InvalidArgumentException(
r("The value for '{key}' must be \"quoted string\", as it contains a space.", [
'key' => ag($spec, 'key'),
])
);
}
if (ag_exists($spec, 'validate')) {
return (bool)$spec['validate']($value);
}
return true;
}
}

View File

@@ -14,7 +14,7 @@ use Psr\Http\Message\StreamInterface;
final readonly class APIResponse
{
public function __construct(
public int $status,
public HTTP_STATUS $status,
public array $headers = [],
public array $body = [],
public StreamInterface|null $stream = null

View File

@@ -197,17 +197,20 @@ final class Initializer
try {
$response = null === $fn ? $this->defaultHttpServer($request) : $fn($request);
if (false === $response->hasHeader('X-Application-Version')) {
$response = $response->withAddedHeader('X-Application-Version', getAppVersion());
}
if ('OPTIONS' !== $request->getMethod()) {
$this->write(
$request,
$response->getStatusCode() >= 400 ? Level::Error : Level::Info,
$this->formatLog($request, $response)
);
if ($response->hasHeader('X-No-AccessLog') || 'OPTIONS' === $request->getMethod()) {
return $response->withoutHeader('X-No-AccessLog');
}
$this->write(
$request,
$response->getStatusCode() >= 400 ? Level::Error : Level::Info,
$this->formatLog($request, $response)
);
} catch (HttpException|RouterHttpException $e) {
$realStatusCode = ($e instanceof RouterHttpException) ? $e->getStatusCode() : $e->getCode();
$statusCode = $realStatusCode >= 200 && $realStatusCode <= 499 ? $realStatusCode : 503;

View File

@@ -36,7 +36,7 @@ final class APIKeyRequiredMiddleware implements MiddlewareInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ('OPTIONS' === $request->getMethod()) {
if ('OPTIONS' === $request->getMethod() || true === (bool)$request->getAttribute('INTERNAL_REQUEST', false)) {
return $handler->handle($request);
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Libs\Middlewares;
use App\Libs\Config;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class NoAccessLogMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (false === (bool)$request->getAttribute('INTERNAL_REQUEST', false)) {
return $handler->handle($request);
}
if (true === (bool)Config::get('api.logInternal', false)) {
return $handler->handle($request);
}
return $handler->handle($request)->withHeader('X-No-AccessLog', '1');
}
}

View File

@@ -35,18 +35,22 @@ class ParseJsonBodyMiddleware implements MiddlewareInterface
private function parse(ServerRequestInterface $request): ServerRequestInterface
{
$rawBody = (string)$request->getBody();
$body = (string)$request->getBody();
if (empty($rawBody)) {
return $request->withAttribute('rawBody', $rawBody)->withParsedBody(null);
if ($request->getBody()->isSeekable()) {
$request->getBody()->rewind();
}
$parsedBody = json_decode($rawBody, true);
if (JSON_ERROR_NONE !== json_last_error()) {
throw new RuntimeException(sprintf('Error when parsing JSON request body: %s', json_last_error_msg()));
if (empty($body)) {
return $request;
}
return $request->withAttribute('rawBody', $rawBody)->withParsedBody($parsedBody);
try {
return $request->withParsedBody(json_decode($body, true, flags: JSON_THROW_ON_ERROR));
} catch (\JsonException $e) {
throw new RuntimeException(r('Error when parsing JSON request body. {error}', [
'error' => $e->getMessage()
]), $e->getCode(), $e);
}
}
}

View File

@@ -1383,14 +1383,13 @@ if (!function_exists('APIRequest')) {
'REQUEST_URI' => Config::get('api.prefix') . $uri->getPath(),
'SERVER_NAME' => 'localhost',
'SERVER_PORT' => 80,
'HTTP_USER_AGENT' => 'WatchState/' . getAppVersion() . ' (Internal API Request)',
'HTTP_USER_AGENT' => 'Mozilla/5.0 (WatchState/' . getAppVersion() . '; Internal API Request)',
...ag($opts, 'server', []),
];
$headers = [
'Host' => 'localhost',
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . Config::get('api.key'),
...ag($opts, 'headers', []),
];
@@ -1405,8 +1404,10 @@ if (!function_exists('APIRequest')) {
}
$query = ag($opts, 'query', []);
if (empty($query) && !empty($uri->getQuery())) {
parse_str($uri->getQuery(), $query);
if (!empty($uri->getQuery())) {
parse_str($uri->getQuery(), $queryFromPath);
$query = deepArrayMerge([$queryFromPath, $query]);
}
if (!empty($query)) {
@@ -1420,11 +1421,13 @@ if (!function_exists('APIRequest')) {
get: $query,
post: $json,
body: $body
)
)->withAttribute('INTERNAL_REQUEST', true)
);
$statusCode = HTTP_STATUS::tryFrom($response->getStatusCode()) ?? HTTP_STATUS::HTTP_SERVICE_UNAVAILABLE;
if ($response->getBody()->getSize() < 1) {
return new APIResponse($response->getStatusCode(), $response->getHeaders());
return new APIResponse($statusCode, $response->getHeaders());
}
$response->getBody()->rewind();
@@ -1436,10 +1439,10 @@ if (!function_exists('APIRequest')) {
$json = [];
}
$response->getBody()->rewind();
return new APIResponse($response->getStatusCode(), $response->getHeaders(), $json, $response->getBody());
return new APIResponse($statusCode, $response->getHeaders(), $json, $response->getBody());
}
return new APIResponse($response->getStatusCode(), $response->getHeaders(), [], $response->getBody());
return new APIResponse($statusCode, $response->getHeaders(), [], $response->getBody());
}
}