M4: admin settings page and JSON API
- AdminApiController (admin only, CSRF-protected): status, create key, download recovery kit, confirm (statement accepted + exact phrase), backends with their usable auth mechanisms, add/test/remove locations, start backups, schedule, snapshots, browse, queue restores - RestoreJob runs web-initiated restores in the background; backup and restore runs are kept apart in the run table - admin section 'NG Backup' with a Vue 3 / @nextcloud/vue 9 page: key and recovery-kit flow (download, tick statement, 'Yes, I confirm'), location form built from files_external's parameter definitions, schedule, run list with polling, snapshot browser and restore with the three modes - auth mechanisms that depend on a user session or on files_external's own stored credentials are not offered API tested with curl on NC34 (status, backends, schedule validation, confirmation refusal, browse, queued restore and backup completed by cron); non-admins get 403, anonymous 401.
This commit is contained in:
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
/vendor/
|
|
||||||
/node_modules/
|
|
||||||
/js/
|
|
||||||
/build/
|
/build/
|
||||||
|
/js/
|
||||||
|
/node_modules/
|
||||||
/spikes/out/
|
/spikes/out/
|
||||||
|
/vendor/
|
||||||
|
|||||||
+5
-1
@@ -5,7 +5,7 @@
|
|||||||
<name>NG Backup</name>
|
<name>NG Backup</name>
|
||||||
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
||||||
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
||||||
<version>0.0.4</version>
|
<version>0.0.5</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>Dillard Blom</author>
|
<author>Dillard Blom</author>
|
||||||
<namespace>NgBackup</namespace>
|
<namespace>NgBackup</namespace>
|
||||||
@@ -33,4 +33,8 @@
|
|||||||
<command>OCA\NgBackup\Command\Browse</command>
|
<command>OCA\NgBackup\Command\Browse</command>
|
||||||
<command>OCA\NgBackup\Command\Restore</command>
|
<command>OCA\NgBackup\Command\Restore</command>
|
||||||
</commands>
|
</commands>
|
||||||
|
<settings>
|
||||||
|
<admin>OCA\NgBackup\Settings\Admin</admin>
|
||||||
|
<admin-section>OCA\NgBackup\Settings\AdminSection</admin-section>
|
||||||
|
</settings>
|
||||||
</info>
|
</info>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
return [
|
||||||
|
'routes' => [
|
||||||
|
['name' => 'AdminApi#status', 'url' => '/api/status', 'verb' => 'GET'],
|
||||||
|
['name' => 'AdminApi#initKey', 'url' => '/api/key', 'verb' => 'POST'],
|
||||||
|
['name' => 'AdminApi#downloadKit', 'url' => '/api/key/kit', 'verb' => 'GET'],
|
||||||
|
['name' => 'AdminApi#confirmKit', 'url' => '/api/key/confirm', 'verb' => 'POST'],
|
||||||
|
['name' => 'AdminApi#backends', 'url' => '/api/backends', 'verb' => 'GET'],
|
||||||
|
['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'],
|
||||||
|
['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'],
|
||||||
|
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
|
||||||
|
['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'],
|
||||||
|
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
|
||||||
|
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
|
||||||
|
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
|
||||||
|
['name' => 'AdminApi#startRestore', 'url' => '/api/restores', 'verb' => 'POST'],
|
||||||
|
],
|
||||||
|
];
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
/* extracted by css-entry-points-plugin */
|
||||||
|
@import './admin-DE6mtgMp.chunk.css';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#000" d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3Zm0 5a3 3 0 0 1 3 3v1h1v6H8v-6h1v-1a3 3 0 0 1 3-3Zm0 1.6c-.8 0-1.4.6-1.4 1.4v1h2.8v-1c0-.8-.6-1.4-1.4-1.4Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 272 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#fff" d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3Zm0 5a3 3 0 0 1 3 3v1h1v6H8v-6h1v-1a3 3 0 0 1 3-3Zm0 1.6c-.8 0-1.4.6-1.4 1.4v1h2.8v-1c0-.8-.6-1.4-1.4-1.4Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 272 B |
@@ -26,16 +26,32 @@ final class ExternalStorageFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array{id:string, name:string, auth:list<string>, parameters:array<string, mixed>}>
|
* @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array<string, mixed>}>
|
||||||
* available backends, to build the settings form from
|
* available backends, to build the settings form from
|
||||||
*/
|
*/
|
||||||
public function describeBackends(): array {
|
public function describeBackends(): array {
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($this->backends->getAvailableBackends() as $backend) {
|
foreach ($this->backends->getAvailableBackends() as $backend) {
|
||||||
|
$auth = [];
|
||||||
|
foreach ($this->backends->getAuthMechanismsByScheme(array_keys($backend->getAuthSchemes())) as $mech) {
|
||||||
|
// Mechanisms that need the logged-in user's session cannot work for unattended backups.
|
||||||
|
if (in_array($mech->getScheme(), ['builtin', 'null'], true) && $mech->getIdentifier() !== 'null::null') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_starts_with($mech->getIdentifier(), 'password::session') || str_starts_with($mech->getIdentifier(), 'password::logincredentials')
|
||||||
|
|| str_starts_with($mech->getIdentifier(), 'password::userprovided') || str_starts_with($mech->getIdentifier(), 'password::global')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$auth[] = [
|
||||||
|
'id' => $mech->getIdentifier(),
|
||||||
|
'name' => $mech->getText(),
|
||||||
|
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $mech->getParameters()),
|
||||||
|
];
|
||||||
|
}
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'id' => $backend->getIdentifier(),
|
'id' => $backend->getIdentifier(),
|
||||||
'name' => $backend->getText(),
|
'name' => $backend->getText(),
|
||||||
'auth' => array_keys($backend->getAuthSchemes()),
|
'auth' => $auth,
|
||||||
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $backend->getParameters()),
|
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $backend->getParameters()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class BackupJob extends TimedJob {
|
|||||||
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
||||||
$deadline = microtime(true) + $budget;
|
$deadline = microtime(true) + $budget;
|
||||||
$this->startScheduled();
|
$this->startScheduled();
|
||||||
foreach ($this->runs->findRunning() as $run) {
|
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
||||||
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
||||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ class BackupJob extends TimedJob {
|
|||||||
}
|
}
|
||||||
foreach ($this->targets->list() as $t) {
|
foreach ($this->targets->list() as $t) {
|
||||||
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
||||||
if ($this->runs->findRunning($t->getId()) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
if ($this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\BackgroundJob;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Db\Run;
|
||||||
|
use OCA\NgBackup\Db\RunMapper;
|
||||||
|
use OCA\NgBackup\Service\RestoreService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use OCP\AppFramework\Utility\ITimeFactory;
|
||||||
|
use OCP\BackgroundJob\QueuedJob;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/** Runs a restore requested from the web interface, outside the web request. */
|
||||||
|
class RestoreJob extends QueuedJob {
|
||||||
|
public function __construct(
|
||||||
|
ITimeFactory $time,
|
||||||
|
private RunMapper $runs,
|
||||||
|
private TargetService $targets,
|
||||||
|
private RestoreService $restore,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct($time);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function run($argument): void {
|
||||||
|
$run = $this->runs->find((int)$argument['run']);
|
||||||
|
if ($run->getStatus() !== Run::RUNNING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$req = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$run->setPhase('restoring');
|
||||||
|
$run->setUpdatedAt(time());
|
||||||
|
$this->runs->update($run);
|
||||||
|
@set_time_limit(0);
|
||||||
|
try {
|
||||||
|
$result = $this->restore->restoreUserFiles($this->targets->get((string)$run->getTargetId()), $req['snapshot'], $req['path'], $req['mode']);
|
||||||
|
$run->setStatus(Run::DONE);
|
||||||
|
$run->setPhase('done');
|
||||||
|
$run->setStats(json_encode($result, JSON_THROW_ON_ERROR));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
|
$run->setStatus(Run::FAILED);
|
||||||
|
$run->setError($e->getMessage());
|
||||||
|
}
|
||||||
|
$run->setFinishedAt(time());
|
||||||
|
$run->setUpdatedAt(time());
|
||||||
|
$this->runs->update($run);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,10 +32,16 @@ class TargetBackends extends Base {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
foreach ($backends as $b) {
|
foreach ($backends as $b) {
|
||||||
$output->writeln(sprintf('<info>%s</info> (%s) auth schemes: %s', $b['id'], $b['name'], implode(', ', $b['auth'])));
|
$output->writeln(sprintf('<info>%s</info> (%s)', $b['id'], $b['name']));
|
||||||
foreach ($b['parameters'] as $key => $p) {
|
foreach ($b['parameters'] as $key => $p) {
|
||||||
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
||||||
}
|
}
|
||||||
|
foreach ($b['auth'] as $a) {
|
||||||
|
$output->writeln(sprintf(' -a %s (%s)', $a['id'], $a['name']));
|
||||||
|
foreach ($a['parameters'] as $key => $p) {
|
||||||
|
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Controller;
|
||||||
|
|
||||||
|
use OCA\NgBackup\AppInfo\Application;
|
||||||
|
use OCA\NgBackup\BackgroundJob\RestoreJob;
|
||||||
|
use OCA\NgBackup\Command\KeyConfirm;
|
||||||
|
use OCA\NgBackup\Db\Run;
|
||||||
|
use OCA\NgBackup\Db\RunMapper;
|
||||||
|
use OCA\NgBackup\Db\SnapshotMapper;
|
||||||
|
use OCA\NgBackup\Service\BackupService;
|
||||||
|
use OCA\NgBackup\Service\KeyService;
|
||||||
|
use OCA\NgBackup\Service\RestoreService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use OCP\AppFramework\Controller;
|
||||||
|
use OCP\AppFramework\Http;
|
||||||
|
use OCP\AppFramework\Http\DataDownloadResponse;
|
||||||
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
|
use OCP\BackgroundJob\IJobList;
|
||||||
|
use OCP\IAppConfig;
|
||||||
|
use OCP\IRequest;
|
||||||
|
use OCP\IUserSession;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON API for the admin settings page. Every method requires an admin (the AppFramework
|
||||||
|
* default for controllers without NoAdminRequired) and a valid CSRF token.
|
||||||
|
*/
|
||||||
|
class AdminApiController extends Controller {
|
||||||
|
public function __construct(
|
||||||
|
IRequest $request,
|
||||||
|
private KeyService $keys,
|
||||||
|
private TargetService $targets,
|
||||||
|
private BackupService $backups,
|
||||||
|
private RestoreService $restore,
|
||||||
|
private RunMapper $runs,
|
||||||
|
private SnapshotMapper $snapshots,
|
||||||
|
private IAppConfig $appConfig,
|
||||||
|
private IJobList $jobs,
|
||||||
|
private IUserSession $session,
|
||||||
|
) {
|
||||||
|
parent::__construct(Application::APP_ID, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(): JSONResponse {
|
||||||
|
$targets = [];
|
||||||
|
foreach ($this->targets->list() as $t) {
|
||||||
|
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
||||||
|
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt()];
|
||||||
|
}
|
||||||
|
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
|
||||||
|
return new JSONResponse([
|
||||||
|
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
|
||||||
|
'confirmation' => $this->keys->recoveryKitConfirmation()],
|
||||||
|
'statement' => KeyConfirm::STATEMENT,
|
||||||
|
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
|
||||||
|
'targets' => $targets,
|
||||||
|
'runs' => $runs,
|
||||||
|
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initKey(string $passphrase): JSONResponse {
|
||||||
|
try {
|
||||||
|
$this->keys->initialize($passphrase);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
return new JSONResponse(['fingerprint' => $this->keys->fingerprint()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadKit(): DataDownloadResponse|JSONResponse {
|
||||||
|
if (!$this->keys->isInitialized()) {
|
||||||
|
return new JSONResponse(['error' => 'No backup key yet'], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
|
return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin must tick the statement and send the exact confirmation phrase. */
|
||||||
|
public function confirmKit(bool $accepted, string $phrase): JSONResponse {
|
||||||
|
if (!$accepted || $phrase !== KeyConfirm::CONFIRMATION) {
|
||||||
|
return new JSONResponse(['error' => 'Not confirmed'], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
$this->keys->confirmRecoveryKit($this->session->getUser()?->getUID() ?? 'unknown');
|
||||||
|
return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backends(): JSONResponse {
|
||||||
|
return new JSONResponse($this->targets->availableBackends());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse {
|
||||||
|
try {
|
||||||
|
$r = $this->targets->add($name, $backend, $auth, $options, $path);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTarget(int $id): JSONResponse {
|
||||||
|
$this->targets->remove($this->targets->get((string)$id));
|
||||||
|
return new JSONResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTarget(int $id): JSONResponse {
|
||||||
|
try {
|
||||||
|
$repo = $this->targets->repository($this->targets->get((string)$id));
|
||||||
|
return new JSONResponse(['ok' => true, 'snapshots' => count($repo->listSnapshots())]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new JSONResponse(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startBackup(int $targetId, string $label = ''): JSONResponse {
|
||||||
|
try {
|
||||||
|
$run = $this->backups->start($this->targets->get((string)$targetId), $label);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
return new JSONResponse($this->runJson($run));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSchedule(string $time): JSONResponse {
|
||||||
|
if ($time !== '' && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
||||||
|
return new JSONResponse(['error' => 'Use HH:MM or empty'], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, 'schedule_time', $time);
|
||||||
|
return new JSONResponse(['schedule' => $time]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function snapshots(int $targetId): JSONResponse {
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->snapshots->findForTarget($targetId) as $s) {
|
||||||
|
$out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(),
|
||||||
|
'files' => $s->getFiles(), 'bytes' => $s->getBytes()];
|
||||||
|
}
|
||||||
|
return new JSONResponse($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse {
|
||||||
|
try {
|
||||||
|
return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue a restore of user files; it runs as a background job and shows up in the runs. */
|
||||||
|
public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse {
|
||||||
|
if (!in_array($mode, [RestoreService::MODE_NEW_FOLDER, RestoreService::MODE_MERGE, RestoreService::MODE_REPLACE], true)
|
||||||
|
|| !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) {
|
||||||
|
return new JSONResponse(['error' => 'Invalid path or mode'], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
$run = new Run();
|
||||||
|
$run->setTargetId($targetId);
|
||||||
|
$run->setKind('restore');
|
||||||
|
$run->setStatus(Run::RUNNING);
|
||||||
|
$run->setPhase('queued');
|
||||||
|
$run->setState(json_encode(['snapshot' => $snapshotId, 'path' => $path, 'mode' => $mode,
|
||||||
|
'by' => $this->session->getUser()?->getUID()], JSON_THROW_ON_ERROR));
|
||||||
|
$run->setSnapshotId($snapshotId);
|
||||||
|
$run->setStartedAt(time());
|
||||||
|
$run->setUpdatedAt(time());
|
||||||
|
$run = $this->runs->insert($run);
|
||||||
|
$this->jobs->add(RestoreJob::class, ['run' => $run->getId()]);
|
||||||
|
return new JSONResponse($this->runJson($run));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runJson(Run $r): array {
|
||||||
|
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
|
||||||
|
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'startedAt' => $r->getStartedAt(),
|
||||||
|
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
|
||||||
|
'stats' => json_decode($r->getStats() ?? 'null', true)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,9 +24,12 @@ class RunMapper extends QBMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<Run> */
|
/** @return list<Run> */
|
||||||
public function findRunning(?int $targetId = null): array {
|
public function findRunning(?int $targetId = null, ?string $kind = null): array {
|
||||||
$qb = $this->db->getQueryBuilder();
|
$qb = $this->db->getQueryBuilder();
|
||||||
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)));
|
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)));
|
||||||
|
if ($kind !== null) {
|
||||||
|
$qb->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)));
|
||||||
|
}
|
||||||
if ($targetId !== null) {
|
if ($targetId !== null) {
|
||||||
$qb->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)));
|
$qb->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class BackupService {
|
|||||||
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
||||||
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
||||||
}
|
}
|
||||||
$running = $this->runs->findRunning($target->getId());
|
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
||||||
if ($running !== []) {
|
if ($running !== []) {
|
||||||
return $running[0];
|
return $running[0];
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
||||||
if ($run->getStatus() !== Run::RUNNING) {
|
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
||||||
return $run;
|
return $run;
|
||||||
}
|
}
|
||||||
$target = $this->targets->get((string)$run->getTargetId());
|
$target = $this->targets->get((string)$run->getTargetId());
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Settings;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
|
use OCP\Settings\ISettings;
|
||||||
|
use OCP\Util;
|
||||||
|
|
||||||
|
class Admin implements ISettings {
|
||||||
|
public function getForm(): TemplateResponse {
|
||||||
|
Util::addScript('ng_backup', 'ng_backup-admin');
|
||||||
|
Util::addStyle('ng_backup', 'ng_backup-admin');
|
||||||
|
return new TemplateResponse('ng_backup', 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSection(): string {
|
||||||
|
return 'ng_backup';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Settings;
|
||||||
|
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\Settings\IIconSection;
|
||||||
|
|
||||||
|
class AdminSection implements IIconSection {
|
||||||
|
public function __construct(
|
||||||
|
private IL10N $l,
|
||||||
|
private IURLGenerator $url,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getID(): string {
|
||||||
|
return 'ng_backup';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string {
|
||||||
|
return $this->l->t('NG Backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int {
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIcon(): string {
|
||||||
|
return $this->url->imagePath('ng_backup', 'app-dark.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8362
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "ng_backup",
|
||||||
|
"version": "0.0.5",
|
||||||
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite --mode production build",
|
||||||
|
"dev": "vite --mode development build --watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nextcloud/axios": "^2.5.1",
|
||||||
|
"@nextcloud/dialogs": "^7.4.0",
|
||||||
|
"@nextcloud/l10n": "^3.4.0",
|
||||||
|
"@nextcloud/router": "^3.0.1",
|
||||||
|
"@nextcloud/vue": "^9.0.0",
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nextcloud/vite-config": "^2.3.5",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="ng-backup">
|
||||||
|
<NcSettingsSection :name="t('ng_backup', 'NG Backup')"
|
||||||
|
:description="t('ng_backup', 'Encrypted, incremental backups of this Nextcloud (database, configuration and data). Backup locations are never mounted and are not visible in Files.')">
|
||||||
|
<NcLoadingIcon v-if="!status" :size="32" />
|
||||||
|
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||||
|
</NcSettingsSection>
|
||||||
|
|
||||||
|
<template v-if="status">
|
||||||
|
<KeySetup :status="status" @changed="refresh" />
|
||||||
|
<template v-if="status.key.confirmation">
|
||||||
|
<Locations :status="status" @changed="refresh" />
|
||||||
|
<Backups v-if="status.targets.length" :status="status" @changed="refresh" />
|
||||||
|
<RestoreBrowser v-if="status.targets.length" :status="status" @changed="refresh" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { t } from '@nextcloud/l10n'
|
||||||
|
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||||
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
|
import api from './api.js'
|
||||||
|
import Backups from './components/Backups.vue'
|
||||||
|
import KeySetup from './components/KeySetup.vue'
|
||||||
|
import Locations from './components/Locations.vue'
|
||||||
|
import RestoreBrowser from './components/RestoreBrowser.vue'
|
||||||
|
|
||||||
|
const status = ref(null)
|
||||||
|
const error = ref('')
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
status.value = await api.status()
|
||||||
|
error.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
// Poll while something runs, so progress shows up without reloading.
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (status.value?.runs.some(r => r.status === 'running')) {
|
||||||
|
timer = setTimeout(refresh, 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh)
|
||||||
|
onBeforeUnmount(() => clearTimeout(timer))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ng-backup :deep(.settings-section) {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import AdminSettings from './AdminSettings.vue'
|
||||||
|
|
||||||
|
createApp(AdminSettings).mount('#ng-backup-admin')
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
|
||||||
|
const url = (path) => generateUrl('/apps/ng_backup/api' + path)
|
||||||
|
|
||||||
|
/** Unwraps {error} responses into thrown Errors with the server's message. */
|
||||||
|
async function call(method, path, data) {
|
||||||
|
try {
|
||||||
|
const response = await axios({ method, url: url(path), data, params: method === 'get' ? data : undefined })
|
||||||
|
return response.data
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e.response?.data?.error || e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
status: () => call('get', '/status'),
|
||||||
|
initKey: (passphrase) => call('post', '/key', { passphrase }),
|
||||||
|
kitUrl: () => url('/key/kit'),
|
||||||
|
confirmKit: (accepted, phrase) => call('post', '/key/confirm', { accepted, phrase }),
|
||||||
|
backends: () => call('get', '/backends'),
|
||||||
|
addTarget: (target) => call('post', '/targets', target),
|
||||||
|
removeTarget: (id) => call('delete', `/targets/${id}`),
|
||||||
|
testTarget: (id) => call('post', `/targets/${id}/test`),
|
||||||
|
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
|
||||||
|
setSchedule: (time) => call('put', '/schedule', { time }),
|
||||||
|
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
|
||||||
|
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
|
||||||
|
startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<NcSettingsSection :name="t('ng_backup', 'Backups')"
|
||||||
|
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
|
||||||
|
<div class="ngb-row">
|
||||||
|
<NcTextField v-model="schedule" class="ngb-time" :label="t('ng_backup', 'Daily at (HH:MM, empty = off)')" />
|
||||||
|
<NcButton :disabled="schedule === status.schedule" @click="saveSchedule">{{ t('ng_backup', 'Save') }}</NcButton>
|
||||||
|
</div>
|
||||||
|
<div class="ngb-row">
|
||||||
|
<NcButton v-for="target in status.targets" :key="target.id" :disabled="isRunning(target.id)" @click="start(target)">
|
||||||
|
{{ isRunning(target.id) ? t('ng_backup', 'Backup to {name} running …', { name: target.name }) : t('ng_backup', 'Back up now to {name}', { name: target.name }) }}
|
||||||
|
</NcButton>
|
||||||
|
</div>
|
||||||
|
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||||
|
|
||||||
|
<table v-if="status.runs.length" class="ngb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('ng_backup', 'Started') }}</th>
|
||||||
|
<th>{{ t('ng_backup', 'Location') }}</th>
|
||||||
|
<th>{{ t('ng_backup', 'Kind') }}</th>
|
||||||
|
<th>{{ t('ng_backup', 'Status') }}</th>
|
||||||
|
<th>{{ t('ng_backup', 'Files') }}</th>
|
||||||
|
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
||||||
|
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
|
||||||
|
<td>{{ targetName(run.targetId) }}</td>
|
||||||
|
<td>{{ run.kind }}</td>
|
||||||
|
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||||
|
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||||
|
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</NcSettingsSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { t } from '@nextcloud/l10n'
|
||||||
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||||
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
|
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||||
|
import api from '../api.js'
|
||||||
|
import { formatSize } from '../format.js'
|
||||||
|
|
||||||
|
const props = defineProps({ status: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['changed'])
|
||||||
|
const schedule = ref(props.status.schedule)
|
||||||
|
const error = ref('')
|
||||||
|
watch(() => props.status.schedule, (s) => { schedule.value = s })
|
||||||
|
|
||||||
|
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
|
||||||
|
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
|
||||||
|
|
||||||
|
async function start(target) {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.startBackup(target.id, '')
|
||||||
|
emit('changed')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSchedule() {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.setSchedule(schedule.value.trim())
|
||||||
|
emit('changed')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.ngb-time { max-width: 260px; }
|
||||||
|
.ngb-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.ngb-failed { color: var(--color-error-text); }
|
||||||
|
.ngb-done { color: var(--color-success-text); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<NcSettingsSection :name="t('ng_backup', 'Backup key and recovery kit')"
|
||||||
|
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups; the recovery kit, protected by your passphrase, is what you need to restore on a new server.')">
|
||||||
|
<!-- 1. Create the key -->
|
||||||
|
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
|
||||||
|
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
|
||||||
|
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
|
||||||
|
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
|
||||||
|
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2">
|
||||||
|
{{ t('ng_backup', 'Create backup key') }}
|
||||||
|
</NcButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p>{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }}</p>
|
||||||
|
|
||||||
|
<!-- 2. Download the kit and confirm -->
|
||||||
|
<div v-if="!status.key.confirmation" class="ngb-confirm">
|
||||||
|
<NcNoteCard type="warning">
|
||||||
|
{{ t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with your passphrase, somewhere safe outside this server.') }}
|
||||||
|
</NcNoteCard>
|
||||||
|
<NcButton :href="kitUrl" download @click="downloaded = true">
|
||||||
|
{{ t('ng_backup', 'Download recovery kit') }}
|
||||||
|
</NcButton>
|
||||||
|
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">
|
||||||
|
{{ status.statement }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
<NcButton variant="primary" :disabled="!downloaded || !accepted || busy" @click="confirm">
|
||||||
|
{{ status.confirmationPhrase }}
|
||||||
|
</NcButton>
|
||||||
|
</div>
|
||||||
|
<p v-else>
|
||||||
|
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
|
||||||
|
<a :href="kitUrl" download>{{ t('ng_backup', 'Download the kit again') }}</a>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||||
|
</NcSettingsSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { t } from '@nextcloud/l10n'
|
||||||
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||||
|
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||||
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
|
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
||||||
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
const props = defineProps({ status: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['changed'])
|
||||||
|
|
||||||
|
const pass1 = ref('')
|
||||||
|
const pass2 = ref('')
|
||||||
|
const downloaded = ref(false)
|
||||||
|
const accepted = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const kitUrl = api.kitUrl()
|
||||||
|
|
||||||
|
async function createKey() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.initKey(pass1.value)
|
||||||
|
pass1.value = pass2.value = ''
|
||||||
|
emit('changed')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.confirmKit(accepted.value, props.status.confirmationPhrase)
|
||||||
|
emit('changed')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ngb-form, .ngb-confirm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<NcSettingsSection :name="t('ng_backup', 'Backup locations')"
|
||||||
|
:description="t('ng_backup', 'Uses the connection types of External storage (S3, SFTP, WebDAV, ...), but never mounts them: the location is only connected while a backup or restore runs. Credentials are stored encrypted.')">
|
||||||
|
<ul v-if="status.targets.length" class="ngb-list">
|
||||||
|
<li v-for="target in status.targets" :key="target.id">
|
||||||
|
<strong>{{ target.name }}</strong>
|
||||||
|
<span class="ngb-muted">{{ target.backend }} · {{ target.path }}</span>
|
||||||
|
<span v-if="tests[target.id]" :class="tests[target.id].ok ? 'ngb-ok' : 'ngb-err'">
|
||||||
|
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
|
||||||
|
</span>
|
||||||
|
<NcButton variant="tertiary" @click="test(target)">{{ t('ng_backup', 'Test') }}</NcButton>
|
||||||
|
<NcButton variant="tertiary" @click="remove(target)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<NcButton v-if="!adding" @click="startAdd">{{ t('ng_backup', 'Add location') }}</NcButton>
|
||||||
|
<form v-else class="ngb-form" @submit.prevent="add">
|
||||||
|
<NcTextField v-model="form.name" :label="t('ng_backup', 'Name')" />
|
||||||
|
<NcSelect v-model="backend" :options="backends" label="name" :input-label="t('ng_backup', 'Type')" :clearable="false" />
|
||||||
|
<NcSelect v-if="backend" v-model="auth" :options="backend.auth" label="name" :input-label="t('ng_backup', 'Authentication')" :clearable="false" />
|
||||||
|
<template v-for="field in fields" :key="field.key">
|
||||||
|
<NcCheckboxRadioSwitch v-if="field.type === 1" v-model="form.options[field.key]">{{ field.label }}</NcCheckboxRadioSwitch>
|
||||||
|
<NcPasswordField v-else-if="field.type === 2" v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" autocomplete="off" />
|
||||||
|
<NcTextField v-else v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" />
|
||||||
|
</template>
|
||||||
|
<NcTextField v-model="form.path" :label="t('ng_backup', 'Folder on the location for the backups')" />
|
||||||
|
<div class="ngb-row">
|
||||||
|
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton>
|
||||||
|
<NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||||
|
</NcSettingsSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { t } from '@nextcloud/l10n'
|
||||||
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||||
|
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||||
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
|
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
||||||
|
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||||
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
|
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
defineProps({ status: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['changed'])
|
||||||
|
|
||||||
|
const adding = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const backends = ref([])
|
||||||
|
const backend = ref(null)
|
||||||
|
const auth = ref(null)
|
||||||
|
const tests = reactive({})
|
||||||
|
const form = reactive({ name: '', path: 'ng_backup', options: {} })
|
||||||
|
|
||||||
|
// files_external DefinitionParameter: type 0 text, 1 boolean, 2 password; flag 1 = optional, 4 = hidden
|
||||||
|
const toFields = (params) => Object.entries(params || {})
|
||||||
|
.filter(([, p]) => !(p.flags & 4))
|
||||||
|
.map(([key, p]) => ({ key, label: p.value, type: p.type, optional: !!(p.flags & 1) }))
|
||||||
|
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
|
||||||
|
|
||||||
|
watch(backend, (b) => {
|
||||||
|
auth.value = b?.auth?.[0] ?? null
|
||||||
|
form.options = {}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function startAdd() {
|
||||||
|
adding.value = true
|
||||||
|
error.value = ''
|
||||||
|
if (!backends.value.length) {
|
||||||
|
try {
|
||||||
|
backends.value = await api.backends()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path })
|
||||||
|
adding.value = false
|
||||||
|
Object.assign(form, { name: '', path: 'ng_backup', options: {} })
|
||||||
|
emit('changed')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test(target) {
|
||||||
|
tests[target.id] = await api.testTarget(target.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(target) {
|
||||||
|
if (!window.confirm(t('ng_backup', 'Remove "{name}" from NG Backup? The backups on the location itself are kept.', { name: target.name }))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await api.removeTarget(target.id)
|
||||||
|
emit('changed')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ngb-list li { display: flex; align-items: center; gap: 12px; padding: 4px 0; }
|
||||||
|
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||||
|
.ngb-ok { color: var(--color-success-text); }
|
||||||
|
.ngb-err { color: var(--color-error-text); }
|
||||||
|
.ngb-form { display: flex; flex-direction: column; gap: 10px; max-width: 600px; margin-top: 12px; }
|
||||||
|
.ngb-row { display: flex; gap: 8px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<NcSettingsSection :name="t('ng_backup', 'Restore')"
|
||||||
|
:description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')">
|
||||||
|
<div class="ngb-row">
|
||||||
|
<NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" />
|
||||||
|
<NcSelect v-if="target" v-model="snapshot" :options="snapshots" :get-option-label="snapshotLabel" :input-label="t('ng_backup', 'Restore point')" :clearable="false" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="snapshot">
|
||||||
|
<div class="ngb-crumbs">
|
||||||
|
<NcButton variant="tertiary" @click="open('data')">data</NcButton>
|
||||||
|
<template v-for="(crumb, i) in crumbs" :key="crumb.path">
|
||||||
|
<span>/</span>
|
||||||
|
<NcButton variant="tertiary" :disabled="i === crumbs.length - 1" @click="open(crumb.path)">{{ crumb.name }}</NcButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<NcLoadingIcon v-if="loading" />
|
||||||
|
<ul v-else class="ngb-entries">
|
||||||
|
<li v-for="item in items" :key="item.name" :class="{ selected: selected === join(item.name) }">
|
||||||
|
<NcButton v-if="item.type === 'dir'" variant="tertiary" @click="open(join(item.name))">📁 {{ item.name }}/</NcButton>
|
||||||
|
<span v-else class="ngb-file">📄 {{ item.name }}</span>
|
||||||
|
<span class="ngb-muted">{{ formatSize(item.size) }}{{ item.type === 'dir' ? ' · ' + item.files + ' files' : '' }}</span>
|
||||||
|
<NcButton v-if="restorable(join(item.name))" variant="tertiary" @click="selected = join(item.name)">{{ t('ng_backup', 'Select') }}</NcButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<NcButton v-if="restorable(path) && path !== selected" variant="tertiary" @click="selected = path">{{ t('ng_backup', 'Select this folder') }}</NcButton>
|
||||||
|
|
||||||
|
<div v-if="selected" class="ngb-restore">
|
||||||
|
<p>{{ t('ng_backup', 'Selected: {path}', { path: selected }) }}</p>
|
||||||
|
<NcCheckboxRadioSwitch v-for="m in modes" :key="m.id" v-model="mode" :value="m.id" type="radio" name="ngb-mode">{{ m.label }}</NcCheckboxRadioSwitch>
|
||||||
|
<NcButton variant="primary" :disabled="busy" @click="restore">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<NcNoteCard v-if="message" type="success">{{ message }}</NcNoteCard>
|
||||||
|
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||||
|
</NcSettingsSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { t } from '@nextcloud/l10n'
|
||||||
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||||
|
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||||
|
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||||
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
|
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||||
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
|
import api from '../api.js'
|
||||||
|
import { formatSize } from '../format.js'
|
||||||
|
|
||||||
|
const props = defineProps({ status: { type: Object, required: true } })
|
||||||
|
const emit = defineEmits(['changed'])
|
||||||
|
|
||||||
|
const target = ref(props.status.targets[0] ?? null)
|
||||||
|
const snapshots = ref([])
|
||||||
|
const snapshot = ref(null)
|
||||||
|
const path = ref('data')
|
||||||
|
const items = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const selected = ref('')
|
||||||
|
const mode = ref('new-folder')
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const modes = [
|
||||||
|
{ id: 'new-folder', label: t('ng_backup', 'Into a new folder "Restored <date>" (nothing existing is changed)') },
|
||||||
|
{ id: 'merge', label: t('ng_backup', 'Into the original place, overwrite (old content kept as a version)') },
|
||||||
|
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const snapshotLabel = (s) => new Date(s.createdAt * 1000).toLocaleString() + (s.label ? ' – ' + s.label : '')
|
||||||
|
const join = (name) => path.value + '/' + name
|
||||||
|
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
|
||||||
|
const crumbs = computed(() => {
|
||||||
|
const parts = path.value.split('/').slice(1)
|
||||||
|
return parts.map((name, i) => ({ name, path: ['data', ...parts.slice(0, i + 1)].join('/') }))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(target, async (tg) => {
|
||||||
|
snapshot.value = null
|
||||||
|
snapshots.value = tg ? await api.snapshots(tg.id) : []
|
||||||
|
snapshot.value = snapshots.value[0] ?? null
|
||||||
|
}, { immediate: true })
|
||||||
|
watch(snapshot, () => { selected.value = ''; open('data') })
|
||||||
|
|
||||||
|
async function open(p) {
|
||||||
|
if (!snapshot.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path.value = p
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
items.value = await api.browse(target.value.id, snapshot.value.id, p)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = message.value = ''
|
||||||
|
try {
|
||||||
|
await api.startRestore(target.value.id, snapshot.value.id, selected.value, mode.value)
|
||||||
|
message.value = t('ng_backup', 'Restore queued. It runs with the next cron job; progress is shown under Backups.')
|
||||||
|
emit('changed')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
|
||||||
|
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
|
||||||
|
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
|
||||||
|
.ngb-file { padding-inline-start: 12px; }
|
||||||
|
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||||
|
.ngb-restore { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; max-width: 700px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
export function formatSize(bytes) {
|
||||||
|
if (bytes === null || bytes === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||||
|
let i = 0
|
||||||
|
let b = bytes
|
||||||
|
while (b >= 1024 && i < units.length - 1) {
|
||||||
|
b /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i === 0 ? `${b} B` : `${b.toFixed(1)} ${units[i]}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
?>
|
||||||
|
<div id="ng-backup-admin"></div>
|
||||||
@@ -1 +1 @@
|
|||||||
{"version":2,"defects":{"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":7},"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.129,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.126,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.005,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.004,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.004,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.007,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.334,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#truncated":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#trailing data":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#cut at frame boundary":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#bad magic":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongAssociatedDataIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongKeyIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testRunInManyTinyStepsRestoresIdentically":1.428,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":1.902,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":1.951,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.018,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.184,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.001,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.027,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":1.056,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.074,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.258,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.514,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":2.848,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testOrderMatchesCompare":0.003,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testResumeAfterCursorYieldsTheRest":0.006,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testExcludeAndMultipleRoots":0.008,"OCA\\NgBackup\\Tests\\Unit\\Repository\\IndexCacheTest::testSecondOpenDownloadsNoIndexFiles":2.618}}
|
{"version":2,"defects":{"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":7},"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.369,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.117,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.002,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.003,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.24,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#truncated":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#trailing data":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#cut at frame boundary":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#bad magic":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongAssociatedDataIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongKeyIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testRunInManyTinyStepsRestoresIdentically":1.599,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":1.92,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":1.888,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.01,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.166,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.021,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.054,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":1.043,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.185,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.217,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.524,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":3.206,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testOrderMatchesCompare":0.003,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testResumeAfterCursorYieldsTheRest":0.003,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testExcludeAndMultipleRoots":0.006,"OCA\\NgBackup\\Tests\\Unit\\Repository\\IndexCacheTest::testSecondOpenDownloadsNoIndexFiles":2.529}}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createAppConfig } from '@nextcloud/vite-config'
|
||||||
|
|
||||||
|
export default createAppConfig({
|
||||||
|
admin: 'src/admin.js',
|
||||||
|
}, {
|
||||||
|
inlineCSS: false,
|
||||||
|
extractLicenseInformation: false,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user