Added system/reset API endpoint and exposed it via WebUI/CLI.

This commit is contained in:
abdulmohsen
2024-06-21 15:00:30 +03:00
parent 5a0678cc7a
commit 041f49e3e2
9 changed files with 421 additions and 4 deletions

View File

@@ -23,6 +23,7 @@
"ext-sodium": "*",
"ext-simplexml": "*",
"ext-fileinfo": "*",
"ext-redis": "*",
"monolog/monolog": "^3.4",
"symfony/console": "^6.1.4",
"symfony/cache": "^6.1.3",
@@ -44,8 +45,7 @@
},
"suggest": {
"ext-sockets": "For UDP commincations.",
"ext-posix": "to check if running under super user.",
"ext-redis": "For caching."
"ext-posix": "to check if running under super user."
},
"require-dev": {
"roave/security-advisories": "dev-latest",

View File

@@ -77,6 +77,41 @@ return (function (): array {
'class' => fn() => new QueueRequests()
],
Redis::class => [
'class' => function (): Redis {
$cacheUrl = Config::get('cache.url');
if (empty($cacheUrl)) {
throw new RuntimeException('No cache server was set.');
}
if (!extension_loaded('redis')) {
throw new RuntimeException('Redis extension is not loaded.');
}
$uri = new Uri($cacheUrl);
$params = [];
if (!empty($uri->getQuery())) {
parse_str($uri->getQuery(), $params);
}
$redis = new Redis();
$redis->connect($uri->getHost(), $uri->getPort() ?? 6379);
if (null !== ag($params, 'password')) {
$redis->auth(ag($params, 'password'));
}
if (null !== ag($params, 'db')) {
$redis->select((int)ag($params, 'db'));
}
return $redis;
}
],
CacheInterface::class => [
'class' => function () {
if (true === (bool)env('WS_CACHE_NULL', false)) {

View File

@@ -95,6 +95,12 @@
<span class="icon"><i class="fas fa-sd-card"></i></span>
<span>Backups</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/reset" @click.native="showMenu=false">
<span class="icon"><i class="fas fa-redo"></i></span>
<span>System reset</span>
</NuxtLink>
</div>
</div>

167
frontend/pages/reset.vue Normal file
View File

@@ -0,0 +1,167 @@
<template>
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon"><i class="fas fa-redo"></i></span>
System reset
</span>
<div class="is-pulled-right">
<div class="field is-grouped"></div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Reset the system state.</span>
</div>
</div>
<div class="column is-12" v-if="error">
<Message message_class="is-background-warning-80 has-text-dark" title="Error" icon="fas fa-exclamation-circle"
:use-close="true" @close="navigateTo('/backends')"
:message="`${error.error.code}: ${error.error.message}`"/>
</div>
<template v-if="isResetting">
<div class="column is-12">
<Message message_class="has-background-warning-90 has-text-dark" title="Working..."
icon="fas fa-spin fa-exclamation-triangle" message="Reset in progress, Please wait..."/>
</div>
</template>
<template v-else>
<div class="column is-12">
<Message message_class="is-background-warning-80 has-text-dark" title="Important information"
icon="fas fa-exclamation-triangle">
<p>
Are you sure you want to reset the system state? This operation will remove all records and metadata from
the database. This action is irreversible.
</p>
<h5 class="has-text-dark">This operation will do the following</h5>
<ul>
<li>Remove all data from local database.</li>
<li>Attempt to flush the cache.</li>
<li>Reset the backends last sync date.</li>
</ul>
<p>There is no undo operation. This action is irreversible.</p>
</Message>
</div>
<div class="column is-12">
<form @submit.prevent="resetSystem()">
<div class="card">
<header class="card-header">
<p class="card-header-title">System reset</p>
<p class="card-header-icon"><span class="icon"><i class="fas fa-redo"></i></span></p>
</header>
<div class="card-content">
<div class="field">
<label class="label">
To confirm, please write '<code>{{ random_secret }}</code>' in the box below.
</label>
<div class="control">
<input class="input" type="text" v-model="user_secret" placeholder="Enter the secret key"/>
</div>
<p class="help">
<span class="icon has-text-warning">
<i class="fas fa-info-circle"></i>
</span>
Depending on your hardware speed, the reset operation might take long time. do not interrupt the
process, or close the browser tab. You will be redirected to the index page automatically once the
process is complete. Otherwise, you might end up with a corrupted database and/or state.
</p>
</div>
</div>
<footer class="card-footer">
<div class="card-footer-item">
<NuxtLink to="/" class="button is-fullwidth is-primary">
<span class="icon"><i class="fas fa-cancel"></i></span>
<span>Cancel</span>
</NuxtLink>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" type="submit" :disabled="user_secret !== random_secret">
<span class="icon"><i class="fas fa-redo"></i></span>
<span>Proceed</span>
</button>
</div>
</footer>
</div>
</form>
</div>
</template>
</div>
</template>
<script setup>
import 'assets/css/bulma-switch.css'
import request from '~/utils/request'
import Message from '~/components/Message'
import {notification} from '~/utils/index'
const error = ref()
const isResetting = ref(false)
const random_secret = ref('')
const user_secret = ref('')
const resetSystem = async () => {
if (user_secret.value !== random_secret.value) {
notification('error', 'Error', 'Invalid secret key. Please try again.')
return
}
if (!confirm('Last chance! Are you sure you want to reset the system state?')) {
return
}
isResetting.value = true
try {
const response = await request(`/system/reset`, {method: 'DELETE'})
let json;
try {
json = await response.json()
} catch (e) {
json = {
error: {
code: response.status,
message: response.statusText
}
}
}
if (200 !== response.status) {
error.value = json
return
}
notification('success', 'Success', `System has been successfully reset.`)
await navigateTo('/')
} catch (e) {
error.value = {
error: {
code: 500,
message: e.message
}
}
} finally {
isResetting.value = false
}
}
onMounted(() => {
const strLength = 8;
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let counter = 0;
while (counter < strLength) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
counter += 1;
}
random_secret.value = result
})
</script>

View File

@@ -132,11 +132,11 @@ final class Add
'uuid' => $data->get('uuid'),
'export' => [
'enabled' => (bool)$data->get('export.enabled', false),
'lastSync' => (int)$data->get('export.lastSync', 0),
'lastSync' => (int)$data->get('export.lastSync', null),
],
'import' => [
'enabled' => (bool)$data->get('import.enabled', false),
'lastSync' => (int)$data->get('import.lastSync', 0),
'lastSync' => (int)$data->get('import.lastSync', null),
],
'webhook' => [
'token' => $data->get('webhook.token'),

46
src/API/System/Reset.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\API\System;
use App\Libs\Attributes\Route\Delete;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\HTTP_STATUS;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Redis;
use RedisException;
final class Reset
{
public const string URL = '%{api.prefix}/system/reset';
public function __construct(private Redis $redis, private iDB $db)
{
}
#[Delete(self::URL . '[/]', name: 'system.reset')]
public function __invoke(iRequest $request, array $args = []): iResponse
{
$this->db->reset();
try {
$this->redis->flushDB();
} catch (RedisException) {
}
$list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true);
foreach ($list->getAll() as $name => $backend) {
$list->set("{$name}.import.lastSync", null);
$list->set("{$name}.export.lastSync", null);
}
$list->persist();
return api_response(HTTP_STATUS::HTTP_OK, ['message' => 'System reset.']);
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Commands\System;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\HTTP_STATUS;
use Symfony\Component\Console\Input\InputInterface as iInput;
use Symfony\Component\Console\Output\OutputInterface as iOutput;
use Symfony\Component\Console\Question\Question;
/**
* Class SuppressCommand
*
* This command manage the Log Suppressor.
*/
#[Cli(command: self::ROUTE)]
final class ResetCommand extends Command
{
public const string ROUTE = 'system:reset';
/**
* Configure the command.
*/
protected function configure(): void
{
$this->setName(self::ROUTE)
->setDescription('Reset the system state.')
->setHelp(
<<<HELP
Reset the system history state.
This command will do the following:
1. Reset the local database.
2. Attempt to flush the cache.
3. Reset the last sync time for all backends.
HELP,
);
}
/**
* Make sure the command is not running in parallel.
*
* @param iInput $input The input object containing the command data.
* @param iOutput $output The output object for displaying command output.
*
* @return int The exit code of the command execution.
*/
protected function runCommand(iInput $input, iOutput $output): int
{
return $this->single(fn(): int => $this->process($input, $output), $output);
}
private function process(iInput $input, iOutput $output): int
{
if (function_exists('stream_isatty') && defined('STDERR')) {
$tty = stream_isatty(STDERR);
} else {
$tty = true;
}
if (false === $tty && !$input->getOption('no-interaction')) {
$output->writeln(
r(
<<<ERROR
<error>ERROR:</error> This command require <notice>interaction</notice>. For example:
{cmd} <cmd>{route}</cmd>
ERROR,
[
'cmd' => trim(commandContext()),
'route' => self::ROUTE,
]
)
);
return self::FAILURE;
}
$helper = $this->getHelper('question');
if (!$input->getOption('no-interaction')) {
try {
$random = bin2hex(random_bytes(4));
} catch (\Throwable) {
$random = substr(md5(uniqid((string)mt_rand(), true)), 0, 8);
}
$question = new Question(
r(
<<<HELP
<question>Are you sure you want to reset WatchState?</question>
------------------
<notice>WARNING:</notice> This command will completely reset your WatchState local database.
------------------
<notice>To confirm deletion please write '<value>{random}</value>' in the box below</notice>
HELP. PHP_EOL . '> ',
[
'random' => $random
]
)
);
$response = $helper->ask($input, $output, $question);
$output->writeln('');
if ($random !== $response) {
$output->writeln(
r(
"<question>Reset has failed. Incorrect value provided '<value>{response}</value>' vs '<value>{random}</value>'.</question>",
[
'random' => $random,
'response' => $response
]
)
);
return self::FAILURE;
}
}
$output->writeln(
r("<info>Requesting The API to reset the local state... Please wait, it will take a while.</info>")
);
$response = APIRequest('DELETE', '/system/reset');
if (HTTP_STATUS::HTTP_OK !== $response->status) {
$output->writeln(r("<error>API error. {status}: {message}</error>", [
'status' => $response->status->value,
'message' => ag($response->body, 'error.message', 'Unknown error.')
]));
return self::FAILURE;
}
$output->writeln(r("<info>Local database has been successfully reset.</info>"));
return self::SUCCESS;
}
}

View File

@@ -167,6 +167,13 @@ interface DatabaseInterface
*/
public function makeMigration(string $name, array $opts = []): mixed;
/**
* Reset database to initial state.
*
* @return bool
*/
public function reset(): bool;
/**
* Inject Logger.
*

View File

@@ -580,6 +580,20 @@ final class PDOAdapter implements iDB
return (new PDOMigrations($this->pdo, $this->logger))->runMaintenance();
}
/**
* @inheritdoc
*/
public function reset(): bool
{
$this->pdo->exec('DELETE FROM `state` WHERE `id` > 0');
if ('sqlite' === $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)) {
$this->pdo->exec('DELETE FROM sqlite_sequence WHERE name = "state"');
}
return true;
}
/**
* @inheritdoc
*/