M5: notifications, setup check, audit log and password confirmation

- notifications to administrators for failed backup/restore runs and
  locations without a successful backup for two days (once a day)
- setup check in the admin overview: error when the key/kit is not set
  up, warning for missing, old or failed backups
- security events (recovery kit downloaded, location removed,
  append-only turned off, retention changed, snapshots forgotten) go to
  the audit log and are announced to all administrators, from the web
  and from occ; the scheduled weekly cleanup is logged only
- fix: the recovery-kit download link failed the CSRF check (412); it
  now requires a recent password confirmation instead, like creating the
  key, removing a location and restoring
This commit is contained in:
2026-10-02 02:29:46 +02:00
parent 450e1a5ee6
commit 2933608747
18 changed files with 318 additions and 8 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<name>NG Backup</name>
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
<description><![CDATA[Work in progress, not ready for use.]]></description>
<version>0.0.6</version>
<version>0.0.8</version>
<licence>agpl</licence>
<author>Dillard Blom</author>
<namespace>NgBackup</namespace>
+2
View File
@@ -20,6 +20,8 @@ class Application extends App implements IBootstrap {
}
public function register(IRegistrationContext $context): void {
$context->registerNotifierService(\OCA\NgBackup\Notification\Notifier::class);
$context->registerSetupCheck(\OCA\NgBackup\SetupCheck\BackupStatusCheck::class);
}
public function boot(IBootContext $context): void {
+5 -1
View File
@@ -27,6 +27,7 @@ class BackupJob extends TimedJob {
ITimeFactory $time,
private BackupService $backups,
private \OCA\NgBackup\Service\PruneService $prune,
private \OCA\NgBackup\Service\AlertService $alerts,
private TargetService $targets,
private RunMapper $runs,
private IAppConfig $appConfig,
@@ -47,6 +48,9 @@ class BackupJob extends TimedJob {
}
}
$this->weeklyPrune();
foreach ($this->targets->list() as $t) {
$this->alerts->checkStale($t);
}
}
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
@@ -60,7 +64,7 @@ class BackupJob extends TimedJob {
continue;
}
try {
$this->prune->apply($t);
$this->prune->apply($t, false, true);
} catch (\Throwable $e) {
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
}
+2
View File
@@ -23,6 +23,7 @@ class RestoreJob extends QueuedJob {
private TargetService $targets,
private RestoreService $restore,
private LoggerInterface $logger,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct($time);
}
@@ -46,6 +47,7 @@ class RestoreJob extends QueuedJob {
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
$run->setStatus(Run::FAILED);
$run->setError($e->getMessage());
$this->alerts->runFailed($run, '#' . $run->getTargetId());
}
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
+2
View File
@@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class KeyKit extends Command {
public function __construct(
private KeyService $keys,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct();
}
@@ -31,6 +32,7 @@ class KeyKit extends Command {
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
return 1;
}
$this->alerts->securityEvent('kit_downloaded');
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
$file = $input->getOption('output');
if ($file === null) {
+4
View File
@@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class TargetAppendOnly extends Command {
public function __construct(
private TargetService $targets,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct();
}
@@ -35,6 +36,9 @@ class TargetAppendOnly extends Command {
return 1;
}
$t = $this->targets->setAppendOnly($this->targets->get($input->getArgument('target')), $state === 'on');
if ($state === 'off') {
$this->alerts->securityEvent('append_only_off', ['target' => $t->getName()]);
}
$output->writeln($t->getName() . ' is ' . ($t->getAppendOnly() ? 'append-only' : 'normal') . '.');
return 0;
}
+2
View File
@@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class TargetRemove extends Command {
public function __construct(
private TargetService $targets,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct();
}
@@ -34,6 +35,7 @@ class TargetRemove extends Command {
return 1;
}
$this->targets->remove($t);
$this->alerts->securityEvent('target_removed', ['target' => $t->getName()]);
$output->writeln('Removed <info>' . $t->getName() . '</info>. The backups on the location itself were not touched; add it again to use them.');
return 0;
}
+17 -1
View File
@@ -19,6 +19,8 @@ use OCA\NgBackup\Service\RestoreService;
use OCA\NgBackup\Service\TargetService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\BackgroundJob\IJobList;
@@ -42,6 +44,7 @@ class AdminApiController extends Controller {
private IAppConfig $appConfig,
private IJobList $jobs,
private IUserSession $session,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct(Application::APP_ID, $request);
}
@@ -64,6 +67,7 @@ class AdminApiController extends Controller {
]);
}
#[PasswordConfirmationRequired]
public function initKey(string $passphrase): JSONResponse {
try {
$this->keys->initialize($passphrase);
@@ -73,10 +77,18 @@ class AdminApiController extends Controller {
return new JSONResponse(['fingerprint' => $this->keys->fingerprint()]);
}
/**
* Plain download link (no CSRF token on a navigation), therefore guarded by a recent password
* confirmation instead, logged to the audit log and announced to all administrators. The kit
* only holds the passphrase-wrapped key: useless without the passphrase.
*/
#[NoCSRFRequired]
#[PasswordConfirmationRequired]
public function downloadKit(): DataDownloadResponse|JSONResponse {
if (!$this->keys->isInitialized()) {
return new JSONResponse(['error' => 'No backup key yet'], Http::STATUS_BAD_REQUEST);
}
$this->alerts->securityEvent('kit_downloaded');
$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');
}
@@ -103,8 +115,11 @@ class AdminApiController extends Controller {
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
}
#[PasswordConfirmationRequired]
public function removeTarget(int $id): JSONResponse {
$this->targets->remove($this->targets->get((string)$id));
$target = $this->targets->get((string)$id);
$this->targets->remove($target);
$this->alerts->securityEvent('target_removed', ['target' => $target->getName()]);
return new JSONResponse([]);
}
@@ -152,6 +167,7 @@ class AdminApiController extends Controller {
}
/** Queue a restore of user files; it runs as a background job and shows up in the runs. */
#[PasswordConfirmationRequired]
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)) {
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Notification;
use OCA\NgBackup\AppInfo\Application;
use OCP\IURLGenerator;
use OCP\L10N\IFactory;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
use OCP\Notification\UnknownNotificationException;
class Notifier implements INotifier {
public function __construct(
private IFactory $l10n,
private IURLGenerator $url,
) {
}
public function getID(): string {
return Application::APP_ID;
}
public function getName(): string {
return $this->l10n->get(Application::APP_ID)->t('NG Backup');
}
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== Application::APP_ID) {
throw new UnknownNotificationException();
}
$l = $this->l10n->get(Application::APP_ID, $languageCode);
$p = $notification->getSubjectParameters();
switch ($notification->getSubject()) {
case 'run_failed':
$notification->setParsedSubject($p['kind'] === 'restore'
? $l->t('Restore from %s failed', [$p['target']])
: $l->t('Backup to %s failed', [$p['target']]));
$notification->setParsedMessage((string)($p['error'] ?? ''));
break;
case 'backup_stale':
$notification->setParsedSubject($l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
$notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
break;
case 'security_event':
$who = (string)($p['actor'] ?? '');
$notification->setParsedSubject(match ($p['event'] ?? '') {
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
'snapshots_forgotten' => $l->t('%1$s removed %2$s snapshots from %3$s', [$who, $p['count'] ?? '', $p['target'] ?? '']),
default => $l->t('NG Backup security event'),
});
$notification->setParsedMessage($l->t('If this was not you or another administrator, check who has admin access.'));
break;
default:
throw new UnknownNotificationException();
}
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']));
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')));
return $notification;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\Target;
use OCP\IGroupManager;
use OCP\Notification\IManager;
/** Notifies administrators about failed runs and locations without a recent successful backup. */
class AlertService {
public const STALE_AFTER = 2 * 86400;
public function __construct(
private IManager $notifications,
private IGroupManager $groups,
private RunMapper $runs,
private \OCP\IAppConfig $appConfig,
private \OCP\EventDispatcher\IEventDispatcher $events,
private \OCP\IUserSession $session,
) {
}
/**
* Security-relevant change (recovery kit downloaded, location removed, append-only off,
* retention changed, snapshots forgotten): written to the audit log (admin_audit) and sent
* to every administrator, so a single compromised admin account cannot do it unnoticed.
*
* @param array<string, string> $params
*/
public function securityEvent(string $event, array $params = [], bool $notify = true): void {
$actor = $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
$params = ['actor' => $actor] + $params;
$this->events->dispatchTyped(new \OCP\Log\Audit\CriticalActionPerformedEvent(
'NG Backup: %s by %s ' . json_encode(array_diff_key($params, ['actor' => 1]), JSON_UNESCAPED_SLASHES), ['event' => $event, 'actor' => $actor]));
if ($notify) {
$this->notifyAdmins('security_event', $event . '-' . time(), ['event' => $event] + $params);
}
}
public function runFailed(Run $run, string $targetName): void {
$this->notifyAdmins('run_failed', 'run-' . $run->getId(),
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
}
/** At most one notification per location per day. */
public function checkStale(Target $target): void {
$last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
$lastTime = $last?->getFinishedAt() ?? $target->getCreatedAt();
if (time() - $lastTime < self::STALE_AFTER || $this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
return;
}
$key = 'stale_notified_' . $target->getId();
if ($this->appConfig->getValueString(Application::APP_ID, $key, '') === date('Y-m-d')) {
return;
}
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : 'never']);
}
private function notifyAdmins(string $subject, string $objectId, array $params): void {
foreach ($this->groups->get('admin')?->getUsers() ?? [] as $admin) {
$n = $this->notifications->createNotification();
$n->setApp(Application::APP_ID)
->setUser($admin->getUID())
->setDateTime(new \DateTime())
->setObject($subject, $objectId)
->setSubject($subject, $params);
$this->notifications->notify($n);
}
}
}
+7 -1
View File
@@ -41,6 +41,7 @@ class BackupService {
private IAppManager $appManager,
private ILockingProvider $locking,
private LoggerInterface $logger,
private AlertService $alerts,
) {
}
@@ -132,7 +133,12 @@ class BackupService {
$run->setError($e->getMessage());
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
return $this->runs->update($run);
$run = $this->runs->update($run);
try {
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
} catch (\Throwable) {
}
return $run;
} finally {
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
}
+10 -1
View File
@@ -27,6 +27,7 @@ class PruneService {
private SnapshotMapper $snapshots,
private IAppConfig $appConfig,
private ILockingProvider $locking,
private AlertService $alerts,
) {
}
@@ -42,13 +43,17 @@ class PruneService {
if ($policy->last < 1) {
throw new \InvalidArgumentException('Keep at least the last snapshot');
}
$old = $this->policy()->toArray();
$this->appConfig->setValueString(Application::APP_ID, 'retention', json_encode($policy->toArray(), JSON_THROW_ON_ERROR));
if ($old !== $policy->toArray()) {
$this->alerts->securityEvent('retention_changed', ['policy' => json_encode($policy->toArray())]);
}
}
/**
* @return array{kept: array<string, list<string>>, forgotten: list<string>, prune: array}
*/
public function apply(Target $target, bool $dryRun = false): array {
public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array {
if ($target->getAppendOnly()) {
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
}
@@ -79,6 +84,10 @@ class PruneService {
}
}
// Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone.
if (!$dryRun && $forget !== []) {
// Scheduled cleanup follows the policy: audit log only, no notification every week.
$this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], !$scheduled);
}
$prune = $repo->prune($dryRun, 0.5, $dryRun ? $forget : []);
return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune];
} finally {
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\SetupCheck;
use OCA\NgBackup\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Service\AlertService;
use OCA\NgBackup\Service\BackupService;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\TargetService;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\SetupCheck\ISetupCheck;
use OCP\SetupCheck\SetupResult;
/** Shows the backup state in the admin overview (Security & setup warnings). */
class BackupStatusCheck implements ISetupCheck {
public function __construct(
private IL10N $l,
private IURLGenerator $url,
private KeyService $keys,
private TargetService $targets,
private RunMapper $runs,
) {
}
public function getCategory(): string {
return 'system';
}
public function getName(): string {
return $this->l->t('NG Backup');
}
public function run(): SetupResult {
$link = $this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']);
if (!$this->keys->isInitialized() || $this->keys->recoveryKitConfirmation() === null) {
return SetupResult::error($this->l->t('Backups are not set up: create the backup key and confirm the recovery kit.'), $link);
}
$targets = $this->targets->list();
if ($targets === []) {
return SetupResult::warning($this->l->t('No backup location is configured.'), $link);
}
$problems = [];
foreach ($targets as $t) {
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
if ($last === null) {
$problems[] = $this->l->t('%s: no successful backup yet', [$t->getName()]);
} elseif (time() - (int)$last->getFinishedAt() > AlertService::STALE_AFTER) {
$problems[] = $this->l->t('%1$s: last successful backup %2$s', [$t->getName(), date('Y-m-d H:i', (int)$last->getFinishedAt())]);
}
}
foreach ($this->runs->findRecent(10) as $r) {
if ($r->getStatus() === Run::FAILED && $r->getStartedAt() > time() - AlertService::STALE_AFTER) {
$problems[] = $this->l->t('A %1$s run failed: %2$s', [$r->getKind(), mb_strimwidth((string)$r->getError(), 0, 120, '…')]);
break;
}
}
return $problems === []
? SetupResult::success($this->l->t('Recent backups to all locations succeeded.'))
: SetupResult::warning(implode("\n", $problems), $link);
}
}
+19
View File
@@ -12,6 +12,7 @@
"@nextcloud/axios": "^2.5.1",
"@nextcloud/dialogs": "^7.4.0",
"@nextcloud/l10n": "^3.4.0",
"@nextcloud/password-confirmation": "^6.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^9.0.0",
"vue": "^3.5.0"
@@ -885,6 +886,24 @@
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/password-confirmation": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@nextcloud/password-confirmation/-/password-confirmation-6.1.0.tgz",
"integrity": "sha512-N2ChXDbPcUcQCoAjj1yc65zfc61yiKpdM3qm/y3Y/y//NG7XWNNINwBg85lqJ2SISgmVStpsQ1d0blpFCoQXkQ==",
"license": "MIT",
"dependencies": {
"@nextcloud/auth": "^2.5.3",
"@nextcloud/axios": "^2.5.2",
"@nextcloud/l10n": "^3.4.1",
"@nextcloud/logger": "^3.0.3",
"@nextcloud/router": "^3.1.0",
"@nextcloud/vue": "^9.6.0",
"vue": "^3.5.30"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
}
},
"node_modules/@nextcloud/paths": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
"@nextcloud/axios": "^2.5.1",
"@nextcloud/dialogs": "^7.4.0",
"@nextcloud/l10n": "^3.4.0",
"@nextcloud/password-confirmation": "^6.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^9.0.0",
"vue": "^3.5.0"
+18 -2
View File
@@ -23,7 +23,7 @@
<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">
<NcButton @click="downloadKit">
{{ t('ng_backup', 'Download recovery kit') }}
</NcButton>
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">
@@ -35,7 +35,8 @@
</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>
<NcButton variant="tertiary" @click="downloadKit">{{ t('ng_backup', 'Download the kit again') }}</NcButton>
<span class="ngb-muted">{{ t('ng_backup', 'Requires your password; all administrators are notified.') }}</span>
</p>
</template>
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
@@ -50,6 +51,8 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwit
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/style.css'
import api from '../api.js'
const props = defineProps({ status: { type: Object, required: true } })
@@ -63,10 +66,22 @@ const busy = ref(false)
const error = ref('')
const kitUrl = api.kitUrl()
async function downloadKit() {
error.value = ''
try {
await confirmPassword()
window.location.href = kitUrl
downloaded.value = true
} catch (e) {
error.value = e?.message || ''
}
}
async function createKey() {
busy.value = true
error.value = ''
try {
await confirmPassword()
await api.initKey(pass1.value)
pass1.value = pass2.value = ''
emit('changed')
@@ -92,6 +107,7 @@ async function confirm() {
</script>
<style scoped>
.ngb-muted { color: var(--color-text-maxcontrast); margin-inline-start: 8px; }
.ngb-form, .ngb-confirm {
display: flex;
flex-direction: column;
+9 -1
View File
@@ -47,6 +47,8 @@ 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 { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/style.css'
import api from '../api.js'
defineProps({ status: { type: Object, required: true } })
@@ -107,7 +109,13 @@ 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)
try {
await confirmPassword()
await api.removeTarget(target.id)
} catch (e) {
error.value = e?.message || ''
return
}
emit('changed')
}
</script>
+3
View File
@@ -49,6 +49,8 @@ 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 { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/style.css'
import api from '../api.js'
import { formatSize } from '../format.js'
@@ -107,6 +109,7 @@ async function restore() {
busy.value = true
error.value = message.value = ''
try {
await confirmPassword()
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')