Compare commits
2 Commits
e5eaf6df14
...
7b154e322b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b154e322b | |||
| 7b3c9cabf6 |
+4
-1
@@ -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.5</version>
|
||||
<version>0.0.8</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Dillard Blom</author>
|
||||
<namespace>NgBackup</namespace>
|
||||
@@ -32,6 +32,9 @@
|
||||
<command>OCA\NgBackup\Command\ListSnapshots</command>
|
||||
<command>OCA\NgBackup\Command\Browse</command>
|
||||
<command>OCA\NgBackup\Command\Restore</command>
|
||||
<command>OCA\NgBackup\Command\Prune</command>
|
||||
<command>OCA\NgBackup\Command\Retention</command>
|
||||
<command>OCA\NgBackup\Command\TargetAppendOnly</command>
|
||||
</commands>
|
||||
<settings>
|
||||
<admin>OCA\NgBackup\Settings\Admin</admin>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,6 +26,8 @@ class BackupJob extends TimedJob {
|
||||
public function __construct(
|
||||
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,
|
||||
@@ -45,6 +47,29 @@ class BackupJob extends TimedJob {
|
||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||
}
|
||||
}
|
||||
$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. */
|
||||
private function weeklyPrune(): void {
|
||||
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
|
||||
if (date('N') !== '7' || $lastWeek === date('o-W') || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->targets->list() as $t) {
|
||||
if ($t->getAppendOnly()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->prune->apply($t, false, true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', date('o-W'));
|
||||
}
|
||||
|
||||
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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\Command;
|
||||
|
||||
use OCA\NgBackup\Service\PruneService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Prune extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private PruneService $prune,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:prune')
|
||||
->setDescription('Apply the retention policy: remove old snapshots and the data only they used')
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all that are not append-only)')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show what would be removed');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('target');
|
||||
$targets = $name === null ? array_filter($this->targets->list(), fn ($t) => !$t->getAppendOnly()) : [$this->targets->get($name)];
|
||||
$dry = (bool)$input->getOption('dry-run');
|
||||
$output->writeln('Policy: ' . json_encode($this->prune->policy()->toArray()) . ($dry ? ' (dry run)' : ''));
|
||||
$rc = 0;
|
||||
foreach ($targets as $t) {
|
||||
try {
|
||||
$r = $this->prune->apply($t, $dry);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $t->getName() . ': ' . $e->getMessage() . '</error>');
|
||||
$rc = 1;
|
||||
continue;
|
||||
}
|
||||
$p = $r['prune'];
|
||||
$output->writeln(sprintf('<info>%s</info>: keep %d, %s %d snapshots; packs: %d, %s %d, repack %d, frees %.1f MiB',
|
||||
$t->getName(), count($r['kept']), $dry ? 'would forget' : 'forgot', count($r['forgotten']), $p['packs'],
|
||||
$dry ? 'would delete' : 'deleted', $p['deleted'], $p['repacked'], $p['freedBytes'] / 1048576));
|
||||
}
|
||||
return $rc;
|
||||
}
|
||||
}
|
||||
@@ -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\Command;
|
||||
|
||||
use OCA\NgBackup\Service\PruneService;
|
||||
use OCA\NgBackup\Service\RetentionPolicy;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Retention extends Command {
|
||||
public function __construct(
|
||||
private PruneService $prune,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:retention')
|
||||
->setDescription('Show or set how many snapshots are kept')
|
||||
->addOption('last', null, InputOption::VALUE_REQUIRED, 'Always keep the newest N')
|
||||
->addOption('daily', null, InputOption::VALUE_REQUIRED, 'Keep one per day for the last N days with a backup')
|
||||
->addOption('weekly', null, InputOption::VALUE_REQUIRED, 'Keep one per week for N weeks')
|
||||
->addOption('monthly', null, InputOption::VALUE_REQUIRED, 'Keep one per month for N months')
|
||||
->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12");
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$p = $this->prune->policy()->toArray();
|
||||
$changed = false;
|
||||
foreach (['last', 'daily', 'weekly', 'monthly'] as $k) {
|
||||
if ($input->getOption($k) !== null) {
|
||||
$p[$k] = max(0, (int)$input->getOption($k));
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
try {
|
||||
$this->prune->setPolicy(RetentionPolicy::fromArray($p));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf('Keep the last %d, plus one per day for %d days, per week for %d weeks, per month for %d months.', $p['last'], $p['daily'], $p['weekly'], $p['monthly']));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class TargetAppendOnly extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:target:append-only')
|
||||
->setDescription('Make a location append-only (NG Backup never deletes there) or normal again')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('state', InputArgument::REQUIRED, 'on or off')
|
||||
->setHelp("Append-only protects backups if this server is compromised: combine it with credentials\nwithout delete rights and a lifecycle rule or object lock on the location itself.");
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$state = strtolower((string)$input->getArgument('state'));
|
||||
if (!in_array($state, ['on', 'off'], true)) {
|
||||
$output->writeln('<error>state must be on or off</error>');
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -22,6 +22,8 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setBasePath(string $v)
|
||||
* @method string|null getRepositoryId()
|
||||
* @method void setRepositoryId(?string $v)
|
||||
* @method bool|null getAppendOnly()
|
||||
* @method void setAppendOnly(bool $v)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $v)
|
||||
*/
|
||||
@@ -33,8 +35,10 @@ class Target extends Entity {
|
||||
protected string $basePath = '';
|
||||
protected ?string $repositoryId = null;
|
||||
protected int $createdAt = 0;
|
||||
protected ?bool $appendOnly = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('appendOnly', 'boolean');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/** Append-only locations: NG Backup never deletes anything there (cleanup by the location's own lifecycle rules). */
|
||||
class Version000003Date20261002020000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
$t = $schema->getTable('ngb_targets');
|
||||
if (!$t->hasColumn('append_only')) {
|
||||
$t->addColumn('append_only', Types::BOOLEAN, ['notnull' => false, 'default' => false]);
|
||||
return $schema;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,26 @@ final class BlobIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, list<array{id:string, offset:int, length:int}>> packId => blobs currently located in that pack */
|
||||
public function packs(): array {
|
||||
$packs = [];
|
||||
foreach ($this->blobs as $id => [$pack, $offset, $length]) {
|
||||
$packs[$pack][] = ['id' => $id, 'offset' => $offset, 'length' => $length];
|
||||
}
|
||||
return $packs;
|
||||
}
|
||||
|
||||
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
|
||||
public function removePack(string $packId): void {
|
||||
$this->backend->delete('index/' . $packId);
|
||||
$this->cache?->remove($this->repositoryId, [$packId]);
|
||||
foreach ($this->blobs as $id => $loc) {
|
||||
if ($loc[0] === $packId) {
|
||||
unset($this->blobs[$id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function count(): int {
|
||||
return count($this->blobs);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,16 @@ final class PackWriter {
|
||||
return strlen($encrypted);
|
||||
}
|
||||
|
||||
/** Add an already encrypted blob as-is (repacking: no decryption needed, the blob is bound to its id, not to a pack). */
|
||||
public function addRaw(string $id, string $encrypted, int $rawLength, int $flags): void {
|
||||
fwrite($this->buffer, $encrypted);
|
||||
$this->entries[] = ['id' => $id, 'offset' => $this->size, 'length' => strlen($encrypted), 'raw' => $rawLength, 'flags' => $flags];
|
||||
$this->size += strlen($encrypted);
|
||||
if ($this->size >= $this->targetSize) {
|
||||
$this->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload the current pack (if any) and record it in the index. */
|
||||
public function flush(): void {
|
||||
if ($this->entries === []) {
|
||||
|
||||
@@ -273,6 +273,103 @@ final class Repository {
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Remove a snapshot (and its database dump). Its data is freed by the next prune(). */
|
||||
public function forget(string $snapshotId): void {
|
||||
try {
|
||||
$meta = $this->snapshotMeta($snapshotId);
|
||||
} catch (\Throwable) {
|
||||
$meta = [];
|
||||
}
|
||||
if (isset($meta['db']) && is_string($meta['db']) && str_starts_with($meta['db'], 'db/')) {
|
||||
$this->backend->delete($meta['db']);
|
||||
}
|
||||
$this->backend->delete('snapshots/' . $snapshotId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete packs no snapshot or user export uses any more, and repack packs where more than
|
||||
* $repackThreshold of the bytes are unused. Must not run while a backup to this repository
|
||||
* is in progress (its blobs are not referenced by a snapshot yet); callers hold the lock.
|
||||
*
|
||||
* @return array{packs:int, deleted:int, repacked:int, freedBytes:int, keptBlobs:int, unusedBlobs:int}
|
||||
*/
|
||||
public function prune(bool $dryRun = false, float $repackThreshold = 0.5, array $ignoreSnapshots = []): array {
|
||||
$used = [];
|
||||
$ignore = array_flip($ignoreSnapshots); // e.g. snapshots a dry run would forget first
|
||||
foreach ($this->listSnapshots() as $sid) {
|
||||
if (isset($ignore[$sid])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($this->streamEntries($sid) as $e) {
|
||||
foreach ($e['b'] as $b) {
|
||||
$used[$b] = true;
|
||||
}
|
||||
}
|
||||
$meta = $this->snapshotMeta($sid);
|
||||
if (isset($meta['db']) && $this->backend->exists($meta['db'])) {
|
||||
foreach (json_decode($this->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR)['tables'] as $t) {
|
||||
foreach ($t['blobs'] as $b) {
|
||||
$used[$b] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($this->backend->list('users') as $path) {
|
||||
foreach (json_decode($this->getObject($path), true, 512, JSON_THROW_ON_ERROR)['entries'] as $e) {
|
||||
foreach ($e['b'] ?? [] as $b) {
|
||||
$used[$b] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stats = ['packs' => 0, 'deleted' => 0, 'repacked' => 0, 'freedBytes' => 0, 'keptBlobs' => count($used), 'unusedBlobs' => 0];
|
||||
$packs = $this->index->packs();
|
||||
$stats['packs'] = count($packs);
|
||||
$writer = null;
|
||||
$toRemove = [];
|
||||
foreach ($packs as $packId => $blobs) {
|
||||
$total = 0;
|
||||
$unused = 0;
|
||||
foreach ($blobs as $b) {
|
||||
$total += $b['length'];
|
||||
if (!isset($used[$b['id']])) {
|
||||
$unused += $b['length'];
|
||||
$stats['unusedBlobs']++;
|
||||
}
|
||||
}
|
||||
if ($unused === 0 || ($unused < $total && $unused / $total <= $repackThreshold)) {
|
||||
continue;
|
||||
}
|
||||
$stats['freedBytes'] += $unused;
|
||||
if ($unused === $total) {
|
||||
$stats['deleted']++;
|
||||
} else {
|
||||
$stats['repacked']++;
|
||||
if (!$dryRun) {
|
||||
$writer ??= new PackWriter($this->backend, $this->cipher, $this->index);
|
||||
$path = 'packs/' . substr($packId, 0, 2) . '/' . $packId;
|
||||
foreach ($blobs as $b) {
|
||||
if (isset($used[$b['id']])) {
|
||||
[, , , $raw, $flags] = $this->index->get($b['id']);
|
||||
$writer->addRaw($b['id'], $this->backend->getRange($path, $b['offset'], $b['length']), $raw, $flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$toRemove[] = $packId;
|
||||
}
|
||||
if ($dryRun) {
|
||||
return $stats;
|
||||
}
|
||||
// New packs and their index entries are durable before any old pack is removed.
|
||||
$writer?->flush();
|
||||
foreach ($toRemove as $packId) {
|
||||
$this->index->removePack($packId);
|
||||
$this->backend->delete('packs/' . substr($packId, 0, 2) . '/' . $packId);
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function listSnapshots(): array {
|
||||
return array_map('basename', $this->backend->list('snapshots'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ class BackupService {
|
||||
private IAppManager $appManager,
|
||||
private ILockingProvider $locking,
|
||||
private LoggerInterface $logger,
|
||||
private AlertService $alerts,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -51,6 +52,9 @@ class BackupService {
|
||||
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
||||
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
||||
}
|
||||
if ($this->locking->isLocked(PruneService::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE)) {
|
||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later');
|
||||
}
|
||||
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
||||
if ($running !== []) {
|
||||
return $running[0];
|
||||
@@ -129,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?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\RunMapper;
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
|
||||
/**
|
||||
* Applies the retention policy to a location: forgets snapshots the policy does not keep and
|
||||
* prunes data no remaining snapshot uses. Never runs on append-only locations, never while a
|
||||
* backup to the same location is running (the location lock is shared with BackupService).
|
||||
*/
|
||||
class PruneService {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private RunMapper $runs,
|
||||
private SnapshotMapper $snapshots,
|
||||
private IAppConfig $appConfig,
|
||||
private ILockingProvider $locking,
|
||||
private AlertService $alerts,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function lockKey(Target $target): string {
|
||||
return 'ng_backup/target/' . $target->getId();
|
||||
}
|
||||
|
||||
public function policy(): RetentionPolicy {
|
||||
return RetentionPolicy::fromArray(json_decode($this->appConfig->getValueString(Application::APP_ID, 'retention', '{}'), true) ?: []);
|
||||
}
|
||||
|
||||
public function setPolicy(RetentionPolicy $policy): void {
|
||||
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, bool $scheduled = false): array {
|
||||
if ($target->getAppendOnly()) {
|
||||
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
|
||||
}
|
||||
if ($this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
|
||||
throw new \RuntimeException('A backup to ' . $target->getName() . ' is running; try again later');
|
||||
}
|
||||
try {
|
||||
$this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('Another backup or cleanup of ' . $target->getName() . ' is running');
|
||||
}
|
||||
try {
|
||||
$repo = $this->targets->repository($target);
|
||||
$times = [];
|
||||
foreach ($repo->listSnapshots() as $id) {
|
||||
$meta = $repo->snapshotMeta($id);
|
||||
$times[$id] = (int)strtotime((string)($meta['time'] ?? '@0'));
|
||||
}
|
||||
$keep = $this->policy()->keep($times);
|
||||
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
|
||||
if (!$dryRun) {
|
||||
foreach ($forget as $id) {
|
||||
$repo->forget($id);
|
||||
try {
|
||||
$this->snapshots->delete($this->snapshots->findOne($target->getId(), $id));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
/**
|
||||
* Which snapshots to keep: the newest $last, plus the newest snapshot of each of the most recent
|
||||
* $daily days, $weekly ISO weeks and $monthly months that have a snapshot (like restic/borg).
|
||||
*/
|
||||
final class RetentionPolicy {
|
||||
public function __construct(
|
||||
public readonly int $last = 3,
|
||||
public readonly int $daily = 7,
|
||||
public readonly int $weekly = 4,
|
||||
public readonly int $monthly = 6,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromArray(array $a): self {
|
||||
return new self((int)($a['last'] ?? 3), (int)($a['daily'] ?? 7), (int)($a['weekly'] ?? 4), (int)($a['monthly'] ?? 6));
|
||||
}
|
||||
|
||||
public function toArray(): array {
|
||||
return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $snapshots id => unix time
|
||||
* @return array<string, list<string>> id => reasons it is kept (ids not in the result are removed)
|
||||
*/
|
||||
public function keep(array $snapshots): array {
|
||||
arsort($snapshots);
|
||||
$keep = [];
|
||||
$i = 0;
|
||||
foreach ($snapshots as $id => $time) {
|
||||
if ($i++ < $this->last) {
|
||||
$keep[$id][] = 'last';
|
||||
}
|
||||
}
|
||||
foreach (['daily' => 'Y-m-d', 'weekly' => 'o-W', 'monthly' => 'Y-m'] as $kind => $format) {
|
||||
$seen = [];
|
||||
foreach ($snapshots as $id => $time) {
|
||||
$bucket = date($format, $time);
|
||||
if (isset($seen[$bucket])) {
|
||||
continue;
|
||||
}
|
||||
if (count($seen) >= $this->$kind) {
|
||||
break;
|
||||
}
|
||||
$seen[$bucket] = true;
|
||||
$keep[$id][] = $kind;
|
||||
}
|
||||
}
|
||||
return $keep;
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,11 @@ class TargetService {
|
||||
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
|
||||
}
|
||||
|
||||
public function setAppendOnly(Target $target, bool $appendOnly): Target {
|
||||
$target->setAppendOnly($appendOnly);
|
||||
return $this->mapper->update($target);
|
||||
}
|
||||
|
||||
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
|
||||
public function remove(Target $target): void {
|
||||
$this->mapper->delete($target);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Generated
+19
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||
|
||||
use OCA\NgBackup\Backend\LocalBackend;
|
||||
use OCA\NgBackup\Crypto\KeyRing;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PruneTest extends TestCase {
|
||||
use TempDirTrait;
|
||||
|
||||
private function du(LocalBackend $b): int {
|
||||
$root = (new \ReflectionProperty(LocalBackend::class, 'root'))->getValue($b);
|
||||
$total = 0;
|
||||
foreach ($b->list('packs') as $p) {
|
||||
$total += filesize("$root/$p");
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function testForgetAndPruneFreesSpaceAndKeepsTheRest(): void {
|
||||
$src = $this->tempDir();
|
||||
$backend = new LocalBackend($this->tempDir());
|
||||
$keys = KeyRing::generate();
|
||||
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||
|
||||
file_put_contents("$src/keep.bin", random_bytes(6 * 1048576));
|
||||
file_put_contents("$src/old.bin", random_bytes(40 * 1048576));
|
||||
$old = $repo->backupDirectory($src);
|
||||
unlink("$src/old.bin");
|
||||
file_put_contents("$src/new.bin", random_bytes(3 * 1048576));
|
||||
$new = $repo->backupDirectory($src, $old['snapshot']);
|
||||
$expected = self::hashTree($src);
|
||||
$before = $this->du($backend);
|
||||
|
||||
$dry = $repo->prune(true);
|
||||
$this->assertSame(0, $dry['freedBytes'], 'nothing is unused while both snapshots exist');
|
||||
$preview = $repo->prune(true, 0.5, [$old['snapshot']]);
|
||||
$this->assertGreaterThan(35 * 1048576, $preview['freedBytes'], 'dry run can preview forgetting a snapshot');
|
||||
|
||||
$repo->forget($old['snapshot']);
|
||||
$plan = $repo->prune(true);
|
||||
$this->assertSame($plan, $repo->prune(true, 0.5, []));
|
||||
$this->assertGreaterThan(35 * 1048576, $plan['freedBytes']);
|
||||
$this->assertSame($before, $this->du($backend), 'dry run changes nothing');
|
||||
|
||||
$result = $repo->prune();
|
||||
$this->assertGreaterThan(0, $result['deleted'] + $result['repacked']);
|
||||
$this->assertLessThan($before - 35 * 1048576, $this->du($backend));
|
||||
|
||||
$reopened = Repository::openWithKey($backend, $keys);
|
||||
$target = $this->tempDir();
|
||||
$reopened->restore($new['snapshot'], $target);
|
||||
$this->assertSame($expected, self::hashTree($target));
|
||||
$this->assertSame([$new['snapshot']], $reopened->listSnapshots());
|
||||
}
|
||||
|
||||
public function testRepackKeepsUsedBlobsOfMixedPacks(): void {
|
||||
$src = $this->tempDir();
|
||||
$backend = new LocalBackend($this->tempDir());
|
||||
$keys = KeyRing::generate();
|
||||
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||
// Small files share packs: deleting most of them leaves mixed packs to repack.
|
||||
for ($i = 0; $i < 40; $i++) {
|
||||
file_put_contents("$src/f$i", random_bytes(500000));
|
||||
}
|
||||
$a = $repo->backupDirectory($src);
|
||||
for ($i = 0; $i < 40; $i++) {
|
||||
if ($i % 5 !== 0) {
|
||||
unlink("$src/f$i");
|
||||
}
|
||||
}
|
||||
$b = $repo->backupDirectory($src, $a['snapshot']);
|
||||
$repo->forget($a['snapshot']);
|
||||
$r = $repo->prune(false, 0.5);
|
||||
$this->assertGreaterThan(0, $r['repacked']);
|
||||
$target = $this->tempDir();
|
||||
Repository::openWithKey($backend, $keys)->restore($b['snapshot'], $target);
|
||||
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Service;
|
||||
|
||||
use OCA\NgBackup\Service\RetentionPolicy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RetentionPolicyTest extends TestCase {
|
||||
public function testDailyWeeklyMonthly(): void {
|
||||
$snaps = [];
|
||||
$start = strtotime('2026-01-01 03:00:00');
|
||||
for ($d = 0; $d < 120; $d++) {
|
||||
$snaps["d$d"] = $start + $d * 86400; // one per day for 120 days
|
||||
$snaps["d$d-b"] = $start + $d * 86400 + 3600; // and a second one an hour later
|
||||
}
|
||||
$keep = (new RetentionPolicy(2, 7, 4, 3))->keep($snaps);
|
||||
$this->assertArrayHasKey('d119-b', $keep, 'newest is always kept');
|
||||
$this->assertArrayHasKey('d119', $keep, 'last=2');
|
||||
$this->assertContains('daily', $keep['d113-b']);
|
||||
$this->assertArrayNotHasKey('d113', $keep, 'only the newest of a day');
|
||||
$this->assertLessThanOrEqual(2 + 7 + 4 + 3, count($keep));
|
||||
$months = array_unique(array_map(fn ($id) => date('Y-m', $snaps[$id]), array_keys(array_filter($keep, fn ($r) => in_array('monthly', $r, true)))));
|
||||
$this->assertCount(3, $months);
|
||||
}
|
||||
|
||||
public function testKeepsEverythingWhenFewSnapshots(): void {
|
||||
$keep = (new RetentionPolicy())->keep(['a' => 100, 'b' => 200]);
|
||||
$this->assertSame(['a', 'b'], array_values(array_intersect(['a', 'b'], array_keys($keep))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user