Merge pull request #12: Release 1.0.0-rc.1 (app version 0.9.7)
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled

Release 1.0.0-rc.1 (app version 0.9.7)
This commit is contained in:
Dillard Blom
2026-10-17 15:04:09 +02:00
committed by GitHub
35 changed files with 651 additions and 61 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Experience report
description: Tell us how NG Backup works for you, also when everything went fine.
title: "Experience: "
body:
- type: markdown
attributes:
value: |
Thanks for trying NG Backup! Reports of what works are just as useful as bug reports:
they tell us which setups are tested. Please leave out host names, user names and keys.
- type: dropdown
id: location
attributes:
label: Backup location
multiple: true
options:
- S3 / S3-compatible
- SFTP
- WebDAV
- SMB/CIFS
- FTP
- Swift
- Local path (disk, NFS mount, ...)
validations:
required: true
- type: input
id: provider
attributes:
label: Storage provider (optional)
placeholder: e.g. Hetzner Storage Box, OVH, Backblaze B2, own NAS
- type: input
id: setup
attributes:
label: Nextcloud version and setup
placeholder: e.g. 32.0.1, AIO / Docker / bare metal, PostgreSQL / MariaDB
- type: input
id: size
attributes:
label: Rough size of the backup
placeholder: e.g. 50 GB, 200,000 files
- type: checkboxes
id: tried
attributes:
label: What did you try?
options:
- label: Full backup
- label: Incremental backup
- label: Verify
- label: Restore of files or folders
- label: Full restore on another server
- type: textarea
id: notes
attributes:
label: How did it go?
description: What worked, what was unclear, slow or broken.
validations:
required: true
+44
View File
@@ -7,6 +7,49 @@ the app store accepts a `-beta` suffix, but Nextcloud servers then only offer th
beta/daily update channel, which would hide the app from most admins. The stability stage is
instead carried by the app description, the git tag, the release, and this file.
## [Unreleased]
## [1.0.0-rc.1] - 2026-10-17
Release candidate for 1.0.0; the app version is 0.9.7. Only bug fixes until 1.0.0. Tested on a
production instance (about 1 TB, 150,000 files): full and incremental backups and a verify on
SFTP and SMB (Hetzner Storage Box) and S3 (OVH), a deep verify on SFTP, and partial restores from
SMB and S3 checked against checksums.
### Added
- The admin page asks for feedback, with a link to a short "Experience report" form on GitHub:
which location, setup and size you used, and how it went.
### Changed
- New passphrases must be at least 16 characters (was 12). The location stores the key
encrypted with each passphrase, so the passphrase is what really protects the backups.
Existing passphrases keep working. The README and the how-to now say so, and why the
recovery kit must still be kept off the server.
- Dates and times in the web interface follow the locale in the user's Nextcloud settings instead
of the browser's language, so a 24-hour locale shows 16:00 rather than 4:00 PM.
- The list of backup runs (web interface and `occ backup:status`) shows the runs of the last 7
days, those still running, and at least the 5 newest; a failed run stays visible for that week.
`occ backup:status --all` and "Show older runs" in the web interface list all of them.
- `occ backup:status` and `occ backup:verify` show durations of a day or more with the days
("1d 12:10:13" instead of "12:10:13").
### Fixed
- With system cron, a running backup moved forward only every other cron run: Nextcloud
skipped the background job when cron came a few seconds before its 5-minute interval was
over, so a backup was idle 6 of every 10 minutes. The job now runs at every cron run.
- Photos, video and other compressed data are no longer deflated only to throw the result away:
a quick test on three small slices of each block skips it. Compressing was the slowest part of
a backup (about 24 MiB/s).
- Checking a backup on an S3 location (`occ backup:verify`) took hours on a large repository:
the index files were downloaded one at a time, at about two a second. They are now downloaded
16 at a time: on a production instance (27,585 index files, 1 TB) the check went from about
4 hours to 3.5 minutes.
- Writing the snapshot at the end of a long backup to S3 took a quarter of an hour without any
progress shown: it read back the file list of every step one at a time. These reads are now
done 16 at a time on S3 (96 reads: 82 seconds before, 2 now), blobs that follow each other in
a pack are read at once on every location, the snapshot is written in a step of its own, and
the run shows "commit" while it does so.
## [0.9.6-beta.7] - 2026-10-14
Bugfix release for SMB locations, with `occ backup:cancel` to stop a backup that does not get
@@ -245,6 +288,7 @@ and not implemented yet.
- See the "Storage backend experiences" section of the README for provider-specific quirks found
so far (e.g. Hetzner Storage Box sub-accounts).
[1.0.0-rc.1]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.1
[0.9.6-beta.7]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.6-beta.7
[0.9.5-beta.6]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.5-beta.6
[0.9.4-beta.5]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.4-beta.5
+23
View File
@@ -377,6 +377,29 @@ in README "Known limitations":
11. `backup:restore:full` in onderhoudsmodus en hervatbaar maken; nu: opnieuw draaien met `--force`.
12. Config/rename op FTP/SMB-servers die geen rename over een bestaand bestand toestaan.
**Plan richting 1.0.0 en 1.1.0 (besluit 2026-10-15/16):**
- **1.0.0-rc.1:** wat nu op `polish` staat (snelle S3-verify en snapshot-commit, kortere runlijst,
duur met dagen, job bij elke cron-run, deflate-test, feedbackformulier, datums in de locale van
de gebruiker, wachtwoordzin min. 16 tekens). Geen nieuwe functies meer.
- **Vóór 1.0.0 (verplicht):** volledige restore met kit op een tweede server (PVE02), alleen lezend
of op een kopie van de locatie; plus een user-restore. Zonder geslaagde test geen 1.0.0.
- **1.1.0:**
1. `backup:key:import` zonder kit: de sleutel-slots van de locatie (`config`) lezen, met de kit
als reserve als `config` ontbreekt of niet opent. Vergt een andere volgorde van
`target:add` en sleutelimport (het gevoeligste pad), daarom niet in rc1. Daarna de
DR-test op PVE02 herhalen zonder kit.
2. Instelling "zware achtergrondjobs pauzeren tijdens een backup" (standaard aan, uit te zetten):
Memories, files_antivirus BackgroundScanner, Recognize, Preview Generator, fulltextsearch.
Via de reservering/last_run van die jobs (geen apps uitzetten), crashbestendig, maximaal
~6 uur achter elkaar, zichtbaar in UI en log. Op nc01 met de hand getest tijdens SMB-run 10.
3. Snelheid: lezen en uploaden laten overlappen, meerdere bestanden tegelijk lezen, eventueel
een eigen worker (`occ background-job:worker`). Gemeten op nc01: 55% wachten op de schijf,
33% op de SMB-upload, ~7% CPU. Vergt extra PHP-processen; botst mogelijk met uitgangspunt 1.
- **Later (geen versie):** optie om de sleutel niet op de locatie op te slaan (kit is dan de enige
kopie; uitdrukkelijke waarschuwing). Primaire object storage en External-storage-koppelingen
meenemen via de opslaglaag (§3.4; nu niet ondersteund, zie README "Known limitations").
- **Open:** of de bevestigingscode van de kit dubbelop is (nog niet over besloten).
Doorgeschoven naar **v1.3.0** (fase 3, niet meer fase 2): in-place volledige restore
(eigen restore-modus i.p.v. onderhoudsmodus) en zelfbediening voor gebruikers (eigen bestanden
terugzetten vanuit de Files-app).
+25 -6
View File
@@ -7,8 +7,8 @@ installations and hosted Nextcloud. (Choosing SMB as a storage backend is the on
see "Storage backend experiences" below — that requirement comes from files_external, not from
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
**Status: beta (0.9.6).** Tested thoroughly, usable alongside your current backup, but not yet a
drop-in replacement for it. See "Known limitations" below, `PLAN.md` for the design and
**Status: release candidate for 1.0.0 (1.0.0-rc.1, app version 0.9.7).** Tested thoroughly, usable
alongside your current backup, but not yet a drop-in replacement for it. See "Known limitations" below, `PLAN.md` for the design and
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore.
License: AGPL-3.0-or-later.
@@ -23,6 +23,10 @@ License: AGPL-3.0-or-later.
leaves the server, and streamed straight to the location: no local copy, no double disk space.
Unchanged files are not read again; only new data is uploaded.
- The database is dumped through Nextcloud's own connection, inside one consistent transaction.
- A backup runs in short steps from Nextcloud's background jobs, and every step saves where it
got to. A restart of the server or the containers does not lose the run: it continues at the
next background job run. Tested on a production Nextcloud AIO instance: a 1 TB full backup over
SMB carried on through the nightly AIO update, which restarts every container.
- Restores go through Nextcloud, so overwritten files keep their old content as a version and
removed files go to the trash.
@@ -47,9 +51,19 @@ All backups of an installation are encrypted with one master key.
- The **server** keeps the master key, encrypted with the instance secret, so scheduled backups
and restores work without anyone typing a passphrase. Any administrator can restore from the
web interface. Consequence: whoever fully controls the server can read the backups; whoever
only gets hold of the backup location cannot.
- For a **restore on a new server** (disaster recovery) you need the **recovery kit** and **one
passphrase**. The kit contains the master key, encrypted separately with each passphrase.
only gets hold of the backup location cannot, as long as the passphrases are strong (see below).
- The **location holds the key too**, by design (like restic and Borg in repokey mode): its
`config` file contains the master key, encrypted separately with each passphrase (Argon2id),
the same as in the recovery kit. Without a passphrase it is of no use, but whoever can read the
location can try passphrases offline. **Your passphrase is therefore the actual key**: it must
be at least 16 characters; five or more random words are a good choice.
- The **recovery kit** is your own copy of that encrypted key. Still keep it off the server and
off the location: an attacker who gets write access to the location can damage or delete its
`config` file on purpose, and without the kit that makes every backup there unreadable.
- For a **restore on a new server** (disaster recovery) you currently need the **recovery kit**
and **one passphrase**.
- Planned: an option to not store the key on the location at all. Then the kit is the only copy,
and losing it means losing every backup; that option will come with an explicit warning.
- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not
lock the company out. A suggested setup:
@@ -168,6 +182,11 @@ Contributions welcome via PR — add what you ran into with your own provider.
On the backlog; none of these block the beta or 1.0.0, but know them before you rely on it:
- **Only the data directory is backed up.** Object storage as primary storage (`objectstore` in
`config.php`) is not supported: a backup refuses to start there. Folders mounted through the
External storage app are not in the data directory and are **not included**, without a warning.
Back them up separately. On the roadmap: reading files through Nextcloud's storage layer, which
covers both, with external mounts optional per mount (`PLAN.md` section 3.4).
- **Very large instances** (well over 1 TB, millions of files): the blob index, cleanup, browsing a
snapshot in the web interface and per-user export manifests are held in memory. Expect high
memory use there; reports from such instances are very welcome.
@@ -194,7 +213,7 @@ On the backlog; none of these block the beta or 1.0.0, but know them before you
| `backup:key:init`, `backup:key:kit`, `backup:key:confirm` | create the key, recovery kit, confirmation |
| `backup:key:slots`, `backup:key:slot:add/replace/remove` | passphrase slots |
| `backup:target:backends/add/edit/list/remove/append-only` | backup locations |
| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points |
| `backup:run`, `backup:status`, `backup:list` | back up, status (runs of the last 7 days; `--all` for all), restore points |
| `backup:cancel <run>` | stop a running backup |
| `backup:browse`, `backup:restore` | browse and restore |
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
+2 -2
View File
@@ -4,12 +4,12 @@
<id>ng_backup</id>
<name>NG Backup</name>
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
<description><![CDATA[Beta: scheduled, encrypted, incremental backups of the database, config and data
<description><![CDATA[Release candidate for 1.0.0: scheduled, encrypted, incremental backups of the database, config and data
directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files and folders, of
single users (via user_migration) and of the whole server onto a fresh installation. Tested
thoroughly and usable alongside your current backup, but not yet a drop-in replacement for it.
See the README for known limitations and the recovery kit before relying on it.]]></description>
<version>0.9.6</version>
<version>0.9.7</version>
<licence>agpl</licence>
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
<namespace>NgBackup</namespace>
+5 -2
View File
@@ -12,13 +12,16 @@ occ backup:key:kit --output=/var/tmp/ng_backup-kit.json
occ backup:key:confirm -u <your-admin-uid> --code=<code shown> --confirm="Yes, I confirm"
```
- The passphrase protects the backup. Keep it somewhere other than this server.
- The passphrase protects the backup: the location stores the key encrypted with it, so whoever
can read the location can try to guess it. Use at least 16 characters (five or more random
words work well) and keep it somewhere other than this server.
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
Any one passphrase is enough to restore. Removing a slot later does not revoke access for someone
who kept an old kit and that passphrase (see "Known limitations" in the README).
- The recovery kit (`/var/tmp/ng_backup-kit.json` above) is also required for disaster recovery. The web
user writes it there; then move it off the server as root, next to the passphrase, and delete the
copy in `/var/tmp`. Without the kit and a passphrase, the backup cannot be read.
copy in `/var/tmp`. The location holds the same encrypted key, but keep the kit anyway: if
someone damages or deletes the key on the location, the kit is the only way back in.
## 2. Add a backup location
+11 -1
View File
@@ -7,7 +7,7 @@ declare(strict_types=1);
namespace OCA\NgBackup\Backend;
/** IBackend::getMany() for a backend where reading files one after the other costs nothing extra. */
/** IBackend::getMany() and getRanges() for a backend where reading one after the other costs nothing extra. */
trait GetsOneByOne {
/**
* @param list<string> $paths
@@ -27,4 +27,14 @@ trait GetsOneByOne {
yield $path => $data;
}
}
/**
* @param list<array{0:string, 1:int, 2:int}> $ranges
* @return \Generator<int, string>
*/
public function getRanges(array $ranges): \Generator {
foreach ($ranges as $i => [$path, $offset, $length]) {
yield $i => $this->getRange($path, $offset, $length);
}
}
}
+11
View File
@@ -35,6 +35,17 @@ interface IBackend {
/** @return string bytes [$offset, $offset + $length) of the object (used to fetch single blobs from a pack) */
public function getRange(string $path, int $offset, int $length): string;
/**
* Many byte ranges, in the order given (blobs spread over many packs, such as the tree
* segments of a backup). A backend where every request costs a round trip can fetch them
* several at a time.
*
* @param list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] each
* @return \Generator<int, string> contents by position in $ranges
* @throws BackendException for a range that cannot be read
*/
public function getRanges(array $ranges): \Generator;
public function exists(string $path): bool;
/**
+101 -3
View File
@@ -19,6 +19,13 @@ final class StorageBackend implements IBackend {
private bool $atomicPut;
/** SFTP: read through SftpReadStream, which keeps several requests in flight. */
private bool $sftpReads;
/** S3: many small reads go straight to the storage's S3 client, several at a time. */
private bool $s3Reads;
/** S3 requests in flight at once for getMany() and getRanges(). */
public const S3_CONCURRENCY = 16;
/** Most ranges, and bytes, getRanges() holds in memory at once. */
private const RANGES_LOT = 64;
private const RANGES_LOT_BYTES = 64 * 1048576;
/** Seconds without a request after which an SFTP connection is checked before it is used. */
public const IDLE_CHECK = 60;
private float $lastUsed;
@@ -40,6 +47,7 @@ final class StorageBackend implements IBackend {
self::validate($this->base, true);
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
$this->sftpReads = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\SFTP::class);
$this->s3Reads = $storage instanceof \OCA\Files_External\Lib\Storage\AmazonS3;
if ($createBase && $this->base !== '') {
$this->mkdirs($this->base);
}
@@ -116,11 +124,19 @@ final class StorageBackend implements IBackend {
return $fh;
}
/** Over SFTP several at a time (SftpReadStream::readMany()); elsewhere one after the other. */
/**
* Over SFTP and S3 several at a time (SftpReadStream::readMany(), s3Fetch()); elsewhere one
* after the other.
*/
public function getMany(array $paths): \Generator {
foreach (array_chunk($paths, 8 * SftpReadStream::BATCH) as $chunk) {
$size = $this->s3Reads ? 16 * self::S3_CONCURRENCY : 8 * SftpReadStream::BATCH;
foreach (array_chunk($paths, $size) as $chunk) {
$fast = null;
if ($this->sftpReads) {
if ($this->s3Reads) {
$this->idleCheck();
$fast = $this->s3Fetch(array_map(fn (string $p): array => [$p, null], $chunk));
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (int $i): ?string => $fast[$i] ?? null, array_keys($chunk)));
} elseif ($this->sftpReads) {
$abs = array_map(fn (string $p): string => $this->abs($p), $chunk);
$fast = $this->call(fn () => SftpReadStream::readMany($this->storage, $abs));
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (string $a): ?string => $fast[$a] ?? null, $abs));
@@ -145,6 +161,88 @@ final class StorageBackend implements IBackend {
}
}
/** Over S3 several at a time (s3Fetch()); elsewhere one after the other. */
public function getRanges(array $ranges): \Generator {
$lot = [];
$bytes = 0;
foreach ($ranges as $i => $range) {
if ($lot !== [] && (count($lot) >= self::RANGES_LOT || $bytes + $range[2] > self::RANGES_LOT_BYTES)) {
yield from $this->rangesLot($lot);
$lot = [];
$bytes = 0;
}
$lot[$i] = $range;
$bytes += $range[2];
}
if ($lot !== []) {
yield from $this->rangesLot($lot);
}
}
/**
* @param array<int, array{0:string, 1:int, 2:int}> $lot
* @return \Generator<int, string>
*/
private function rangesLot(array $lot): \Generator {
$fast = null;
if ($this->s3Reads) {
$this->idleCheck();
$fast = $this->s3Fetch(array_map(fn (array $r): array => [$r[0], $r[1] . '-' . ($r[1] + $r[2] - 1)], $lot));
}
foreach ($lot as $i => [$path, $offset, $length]) {
$data = $fast[$i] ?? null;
// not read in the batch, or not all of it: the usual way, which also reports why
yield $i => ($data !== null && strlen($data) === $length) ? $data : $this->getRange($path, $offset, $length);
}
}
/**
* Read objects (or byte ranges of them, "first-last") straight through the S3 client of
* files_external's S3 storage, S3_CONCURRENCY at a time. Its own reads open a new HTTPS
* connection per object, after a HEAD request: one after the other that is about two reads a
* second, which made checking the index of a large repository take hours. What fails here
* is left out of the result, for the caller to read the usual way (which reports the error).
*
* @param array<int, array{0:string, 1:?string}> $requests [path, range or null] by key
* @return array<int, string>|null contents by key; null when the storage is not as expected
* @psalm-suppress UndefinedClass the AWS SDK comes with Nextcloud, not with this app
*/
private function s3Fetch(array $requests): ?array {
try {
/** @psalm-suppress UndefinedMethod, UndefinedThisPropertyFetch */
[$client, $bucket, $sse] = (function (): array {
$sse = method_exists($this, 'getServerSideEncryptionParameters') ? $this->getServerSideEncryptionParameters()
: (method_exists($this, 'getSSECParameters') ? $this->getSSECParameters() : []);
return [$this->getConnection(), $this->getBucket(), $sse];
})->call($this->storage);
if (!$client instanceof \Aws\S3\S3Client || !is_string($bucket)) {
return null;
}
$results = [];
$keys = array_keys($requests); // the pool numbers results 0, 1, ...: mapped back below
$commands = (function () use ($requests, $client, $bucket, $sse): \Generator {
foreach (array_values($requests) as $n => [$path, $range]) {
$args = ['Bucket' => $bucket, 'Key' => trim($this->abs($path), '/')] + $sse;
if ($range !== null) {
$args['Range'] = 'bytes=' . $range;
}
yield $n => $client->getCommand('GetObject', $args);
}
})();
(new \Aws\CommandPool($client, $commands, [
'concurrency' => self::S3_CONCURRENCY,
'fulfilled' => function (mixed $result, int $n) use (&$results, $keys): void {
$results[$keys[$n]] = (string)$result['Body'];
},
'rejected' => static function (): void {
},
]))->promise()->wait();
return $results;
} catch (\Throwable) {
return null;
}
}
public function getRange(string $path, int $offset, int $length): string {
return $this->call(fn () => $this->readRange($path, $offset, $length));
}
+5 -1
View File
@@ -34,7 +34,11 @@ final class BackupJob extends TimedJob {
private LoggerInterface $logger,
) {
parent::__construct($time);
$this->setInterval(300);
// Well below the usual 5-minute cron interval: the last run is stamped a moment after cron
// starts, so with 300 the next cron run came a few seconds too early and the job ran only
// every other time, idle for 6 of every 10 minutes during a backup. When nothing runs, the
// job only does a few cheap database checks.
$this->setInterval(60);
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
}
+1 -1
View File
@@ -50,7 +50,7 @@ HELP);
$pass = rtrim((string)@file_get_contents($file), "\r\n");
} else {
$helper = $this->getHelper('question');
$q = (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false);
$q = (new Question('Passphrase (min. ' . \OCA\NgBackup\Service\KeyService::MIN_PASSPHRASE . ' characters): '))->setHidden(true)->setHiddenFallback(false);
$pass = (string)$helper->ask($input, $output, $q);
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
if ($pass !== $again) {
+1 -1
View File
@@ -88,7 +88,7 @@ HELP;
return rtrim((string)@file_get_contents($file), "\r\n");
}
$h = $this->getHelper('question');
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false));
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. ' . \OCA\NgBackup\Service\KeyService::MIN_PASSPHRASE . ' characters): '))->setHidden(true)->setHiddenFallback(false));
if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) {
throw new \InvalidArgumentException('The passphrases do not match');
}
+14 -3
View File
@@ -14,9 +14,13 @@ use OCA\NgBackup\Service\VerifyService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class Status extends Command {
/** Runs of the last days that are listed by default (failed ones stay visible that long). */
public const RECENT_DAYS = 7;
public function __construct(
private KeyService $keys,
private TargetService $targets,
@@ -27,7 +31,8 @@ final class Status extends Command {
}
protected function configure(): void {
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs');
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs')
->addOption('all', null, InputOption::VALUE_NONE, 'List all runs, not only those of the last ' . self::RECENT_DAYS . ' days');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
@@ -44,16 +49,22 @@ final class Status extends Command {
}
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
$rows = [];
foreach ($this->runs->findRecent(10) as $r) {
$all = (bool)$input->getOption('all');
$shown = $this->runs->findShown($all ? null : time() - self::RECENT_DAYS * 86400);
foreach ($shown as $r) {
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . self::percent($r) . ')' : ''),
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '',
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? \OCA\NgBackup\Format::duration($r->getFinishedAt() - $r->getStartedAt()) : '',
$s['files'] ?? '', isset($s['bytes']) ? self::size((int)$s['bytes']) : '',
isset($s['uploaded']) ? self::size((int)$s['uploaded']) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
}
if ($rows !== []) {
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'size', 'uploaded', 'error'])->setRows($rows)->render();
}
$older = $all ? 0 : $this->runs->countAll() - count($shown);
if ($older > 0) {
$output->writeln(sprintf('%d older run%s not shown (--all)', $older, $older === 1 ? '' : 's'));
}
foreach ($this->verify->progress() as $targetId => $state) {
$output->writeln(sprintf('Verify of %s on %s: %s', mb_strimwidth((string)($state['what'] ?? ''), 0, 40, '…'),
$names[$targetId] ?? '#' . $targetId, VerifyService::describe($state)));
+6 -2
View File
@@ -51,14 +51,16 @@ final class AdminApiController extends Controller {
parent::__construct(Application::APP_ID, $request);
}
public function status(): JSONResponse {
/** @param bool $allRuns all runs instead of those of the last days (Status::RECENT_DAYS) */
public function status(bool $allRuns = false): 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(), 'appendOnly' => (bool)$t->getAppendOnly(),
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)];
}
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
$shown = $this->runs->findShown($allRuns ? null : time() - \OCA\NgBackup\Command\Status::RECENT_DAYS * 86400);
$runs = array_map(fn (Run $r) => $this->runJson($r), $shown);
return new JSONResponse([
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(),
@@ -68,6 +70,8 @@ final class AdminApiController extends Controller {
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
'targets' => $targets,
'runs' => $runs,
'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown),
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
]);
}
+34
View File
@@ -73,6 +73,40 @@ final class RunMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* The runs to list, newest first: those started at or after $since, those still running, and
* at least the newest $atLeast, so the list is never empty after a quiet week. All runs when
* $since is null.
*
* @return list<Run>
*/
public function findShown(?int $since, int $atLeast = 5): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->orderBy('id', 'DESC');
if ($since === null) {
return $this->findEntities($qb);
}
$qb->where($qb->expr()->orX(
$qb->expr()->gte('started_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)),
));
$runs = [];
foreach ([...$this->findEntities($qb), ...$this->findRecent($atLeast)] as $r) {
$runs[$r->getId()] = $r;
}
krsort($runs);
return array_values($runs);
}
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'n'))->from($this->getTableName());
$result = $qb->executeQuery();
$n = (int)$result->fetchOne();
$result->closeCursor();
return $n;
}
/** @return list<Run> runs of $kind started at or after $since, newest first */
public function startedSince(int $targetId, string $kind, int $since): array {
$qb = $this->db->getQueryBuilder();
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup;
final class Format {
/** "12:10:13", or "1d 12:10:13" from a day on (a long first backup takes days). */
public static function duration(int $seconds): string {
$seconds = max(0, $seconds);
$days = intdiv($seconds, 86400);
$time = gmdate('H:i:s', $seconds % 86400);
return $days > 0 ? $days . 'd ' . $time : $time;
}
}
+6 -6
View File
@@ -140,21 +140,21 @@ final class BackupRun {
$state['parentPos'] = $parent->position();
$state['stats'] = $stats;
if ($finishedWalk) {
// The snapshot is written in a step of its own, with its own time: after a long
// run it reads back thousands of segments, and the run reports that it got here.
$state['phase'] = 'finish';
}
return $state;
}
if ($state['phase'] === 'finish') {
$segments = $state['segments'];
// Every segment ends with a newline, so they read as one stream: fetched together,
// several at a time where the location allows it.
$repo->putSnapshot($state['snapshot'],
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
'warnings' => self::warnings($state)] + $state['meta'],
(function () use ($repo, $segments) {
foreach ($segments as $blobs) {
yield from $repo->readLines($blobs);
}
})());
$repo->readLines(array_values(array_merge(...$state['segments']))));
$state['phase'] = 'commit';
}
return $state;
+5
View File
@@ -38,6 +38,11 @@ final class PackReaderGuard implements IBackend {
return $this->inner->getRange($path, $offset, $length);
}
public function getRanges(array $ranges): \Generator {
$this->reader->close();
return $this->inner->getRanges($ranges);
}
public function exists(string $path): bool {
$this->reader->close();
return $this->inner->exists($path);
+70 -3
View File
@@ -28,8 +28,14 @@ final class Repository {
public const FORMAT = 1;
public const BLOB_SIZE = 4 * 1048576;
private const FLAG_DEFLATE = 1;
/** Blobs from this size on get a quick compression test before the full compression. */
private const DEFLATE_PROBE_FROM = 256 * 1024;
/** Bytes of each of the three slices (start, middle, end) in that test. */
private const DEFLATE_PROBE_SLICE = 32 * 1024;
/** Reads per blob in a deep verify before it is reported as unreadable (not corrupted). */
private const VERIFY_READ_ATTEMPTS = 3;
/** Largest read that loadBlobs() makes of blobs that follow each other in a pack. */
private const MERGED_READ_MAX = 16 * 1048576;
private StreamCipher $cipher;
private BlobIndex $index;
@@ -288,8 +294,8 @@ final class Repository {
*/
public function readLines(array $blobIds): \Generator {
$carry = '';
foreach ($blobIds as $id) {
$data = $carry . $this->loadBlob($id);
foreach ($this->loadBlobs($blobIds) as $blob) {
$data = $carry . $blob;
$lines = explode("\n", $data);
$carry = array_pop($lines);
yield from $lines;
@@ -701,7 +707,7 @@ final class Repository {
}
$flags = 0;
$payload = $data;
$deflated = gzdeflate($data, 6);
$deflated = self::worthDeflating($data) ? gzdeflate($data, 6) : false;
if ($deflated !== false && strlen($deflated) < strlen($data) * 0.95) {
$payload = $deflated;
$flags |= self::FLAG_DEFLATE;
@@ -711,6 +717,67 @@ final class Repository {
return $id;
}
/**
* Photos, video and archives are compressed already: deflating them takes most of a backup's
* CPU time (about 24 MiB/s) only to be thrown away, as it saves less than the 5% that keeps
* it. A fast deflate of three small slices tells first; below 10% savings there, the full
* deflate is skipped.
*/
private static function worthDeflating(string $data): bool {
$n = strlen($data);
if ($n < self::DEFLATE_PROBE_FROM) {
return true;
}
$k = self::DEFLATE_PROBE_SLICE;
$probe = substr($data, 0, $k) . substr($data, intdiv($n - $k, 2), $k) . substr($data, $n - $k);
$deflated = gzdeflate($probe, 1);
return $deflated !== false && strlen($deflated) * 10 < strlen($probe) * 9;
}
/**
* Blobs spread over many packs (the tree segments of a run, one per step), through
* IBackend::getRanges(): on a remote location one at a time is a round trip each, which for
* a long run made writing its snapshot take a quarter of an hour. Blobs that follow each
* other in a pack are read as one range.
*
* @param list<string> $ids
* @return \Generator<int, string> verified blob contents, in order
*/
private function loadBlobs(array $ids): \Generator {
/** @var list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] of each read */
$ranges = [];
/** @var list<list<array{0:string, 1:int, 2:int}>> $parts per read: [blob id, offset in the read, length] */
$parts = [];
// the read being extended
$curPath = null;
$curOffset = 0;
$curLength = 0;
$curParts = [];
foreach ($ids as $id) {
[$pack, $offset, $length] = $this->index->get($id);
$path = 'packs/' . substr($pack, 0, 2) . '/' . $pack;
if ($curPath === $path && $curOffset + $curLength === $offset && $curLength + $length <= self::MERGED_READ_MAX) {
$curParts[] = [$id, $curLength, $length];
$curLength += $length;
continue;
}
if ($curPath !== null) {
$ranges[] = [$curPath, $curOffset, $curLength];
$parts[] = $curParts;
}
[$curPath, $curOffset, $curLength, $curParts] = [$path, $offset, $length, [[$id, 0, $length]]];
}
if ($curPath !== null) {
$ranges[] = [$curPath, $curOffset, $curLength];
$parts[] = $curParts;
}
foreach ($this->backend->getRanges($ranges) as $i => $data) {
foreach ($parts[$i] as [$id, $at, $length]) {
yield $this->decodeBlob($id, substr($data, $at, $length));
}
}
}
private function loadBlob(string $id): string {
[$pack, $offset, $length] = $this->index->get($id);
return $this->decodeBlob($id, $this->packReader->read($pack, $offset, $length));
+3
View File
@@ -225,6 +225,9 @@ final class BackupService {
} else {
$state['files']['estimate'] ??= $this->estimateBytes($run, $state['files']);
$state['files'] = BackupRun::step($repo, $state['files'], $deadline);
if (in_array($state['files']['phase'], ['finish', 'commit'], true)) {
$run->setPhase('commit'); // all files read: writing and recording the snapshot
}
if ($state['files']['phase'] === 'commit') {
// Catalog write under the catalog lock, from a repository opened inside it: a per-user
// export or restore may have written the catalog since this step opened $repo.
+8 -5
View File
@@ -31,6 +31,11 @@ final class KeyService {
private const KEY_KIT_VERSION = 'recovery_kit_version';
private const KEY_KIT_CODE = 'recovery_kit_code';
public const DEFAULT_DELETE_DELAY = 7;
/**
* The location holds the passphrase-wrapped key too, so whoever can read it can try
* passphrases offline: the passphrase is what really protects the backups.
*/
public const MIN_PASSPHRASE = 16;
private ?KeyRing $cached = null;
@@ -56,9 +61,7 @@ final class KeyService {
if ($this->isInitialized()) {
throw new \RuntimeException('A backup key already exists');
}
if (strlen($passphrase) < 12) {
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
}
self::checkPassphrase($passphrase);
$keys = KeyRing::generate();
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
@@ -234,8 +237,8 @@ final class KeyService {
}
private static function checkPassphrase(string $passphrase): void {
if (strlen($passphrase) < 12) {
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
if (strlen($passphrase) < self::MIN_PASSPHRASE) {
throw new \InvalidArgumentException('The passphrase must be at least ' . self::MIN_PASSPHRASE . ' characters');
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ final class VerifyService {
$age < 120 ? $age . ' s' : intdiv($age, 60) . ' min',
$age > 600 ? ' (no progress for a while: the process may have stopped)' : '');
}
$took = gmdate('H:i:s', max(0, (int)($s['finished'] ?? 0) - (int)($s['started'] ?? 0)));
$took = \OCA\NgBackup\Format::duration((int)($s['finished'] ?? 0) - (int)($s['started'] ?? 0));
return match ($s['status'] ?? '') {
'ok' => sprintf('%s OK on %s (took %s): %s files, %s blobs%s', $kind, date('Y-m-d H:i', (int)$s['finished']), $took,
number_format((int)($s['files'] ?? 0)), number_format((int)($s['blobs'] ?? 0)), $deep ? ', ' . $size((int)($s['bytes'] ?? 0)) . ' decrypted' : ''),
-5
View File
@@ -218,16 +218,11 @@
</PropertyNotSetInConstructor>
</file>
<file src="lib/Job/BackupRun.php">
<ArgumentTypeCoercion>
<code><![CDATA[$blobs]]></code>
</ArgumentTypeCoercion>
<MissingParamType>
<code><![CDATA[$fh]]></code>
</MissingParamType>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$mtime]]></code>
<code><![CDATA[$state['phase']]]></code>
<code><![CDATA[$state['stats']]]></code>
</PossiblyUndefinedArrayOffset>
</file>
<file src="lib/Job/ParentCursor.php">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 227 KiB

+23 -2
View File
@@ -6,6 +6,10 @@
<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.')">
<p class="ngb-feedback">
{{ t('ng_backup', 'NG Backup is in beta. We would like to hear how it works for you, also when everything went fine.') }}
<a :href="feedbackUrl" target="_blank" rel="noopener noreferrer">{{ t('ng_backup', 'Share your experience ↗') }}</a>
</p>
<NcLoadingIcon v-if="!status" :size="32" />
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
</NcSettingsSection>
@@ -22,7 +26,7 @@
</template>
<template v-else-if="status.key.everConfirmed">
<template v-if="status.targets.length">
<Backups :status="status" @changed="refresh" />
<Backups :status="status" :all-runs="allRuns" @changed="refresh" @show-all-runs="showAllRuns" />
<RestoreBrowser :status="status" @changed="refresh" />
</template>
<NcNoteCard v-else type="info">{{ t('ng_backup', 'Add a backup location under "Settings" first.') }}</NcNoteCard>
@@ -45,15 +49,24 @@ import KeySetup from './components/KeySetup.vue'
import Locations from './components/Locations.vue'
import RestoreBrowser from './components/RestoreBrowser.vue'
// Opens the experience report form in .github/ISSUE_TEMPLATE
const feedbackUrl = 'https://github.com/dillardblom/ng_backup/issues/new?template=experience.yml'
const status = ref(null)
const allRuns = ref(false) // runs older than the last days too
const error = ref('')
const tab = ref('settings')
let tabInitialized = false
let timer = null
function showAllRuns(all) {
allRuns.value = all
refresh()
}
async function refresh() {
try {
status.value = await api.status()
status.value = await api.status(allRuns.value)
error.value = ''
// Default to the daily-use tab once the key is set up; stay on Settings otherwise.
// Only decided on first load, so switching tabs ourselves isn't undone by polling.
@@ -79,6 +92,14 @@ onBeforeUnmount(() => clearTimeout(timer))
.ng-backup :deep(.settings-section) {
max-width: 900px;
}
.ngb-feedback {
color: var(--color-text-maxcontrast);
margin-bottom: 12px;
}
.ngb-feedback a {
color: var(--color-primary-element);
text-decoration: underline;
}
.ngb-tabs {
display: flex;
gap: 8px;
+1 -1
View File
@@ -16,7 +16,7 @@ async function call(method, path, data) {
}
export default {
status: () => call('get', '/status'),
status: (allRuns = false) => call('get', '/status', allRuns ? { allRuns: true } : undefined),
initKey: (passphrase, deleteDelayDays, label) => call('post', '/key', { passphrase, deleteDelayDays, label }),
addSlot: (passphrase, label) => call('post', '/key/slots', { passphrase, label }),
replaceSlot: (slot, passphrase, label) => call('put', `/key/slots/${slot}`, { passphrase, label }),
+18 -8
View File
@@ -22,8 +22,8 @@
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
<ul>
<li v-for="item in trash[target.id]" :key="item.id">
{{ item.time ? new Date(item.time).toLocaleString() : item.id }}{{ item.label ? ' – ' + item.label : '' }}
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: new Date(item.deleteAfter * 1000).toLocaleDateString() }) }}</span>
{{ item.time ? formatDateTime(item.time) : item.id }}{{ item.label ? ' – ' + item.label : '' }}
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: formatDate(item.deleteAfter) }) }}</span>
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
</li>
</ul>
@@ -46,10 +46,10 @@
</thead>
<tbody>
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
<td>{{ formatDateTime(run.startedAt) }}</td>
<td>{{ targetName(run.targetId) }}</td>
<td>{{ run.kind }}</td>
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
<td>{{ run.snapshot ? formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + percent(run) + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
@@ -58,23 +58,33 @@
</tbody>
</table>
</div>
<p v-if="allRuns || status.olderRuns" class="ngb-muted">
<template v-if="allRuns">
{{ t('ng_backup', 'All runs.') }}
<NcButton variant="tertiary" @click="emit('show-all-runs', false)">{{ n('ng_backup', 'Only the last day', 'Only the last %n days', status.recentDays) }}</NcButton>
</template>
<template v-else>
{{ n('ng_backup', 'Runs of the last day.', 'Runs of the last %n days.', status.recentDays) }}
<NcButton variant="tertiary" @click="emit('show-all-runs', true)">{{ n('ng_backup', 'Show %n older run', 'Show %n older runs', status.olderRuns) }}</NcButton>
</template>
</p>
</NcSettingsSection>
</template>
<script setup>
import { ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import { n, 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'
import { formatDateTime, formatDate, formatSize } from '../format.js'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { reactive } from 'vue'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const props = defineProps({ status: { type: Object, required: true }, allRuns: { type: Boolean, default: false } })
const emit = defineEmits(['changed', 'show-all-runs'])
const schedule = ref(props.status.schedule)
const error = ref('')
watch(() => props.status.schedule, (s) => { schedule.value = s })
+7 -6
View File
@@ -8,13 +8,13 @@
<!-- 1. Create the key -->
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
<NcTextField v-model="label" :label="t('ng_backup', 'Who keeps this passphrase (e.g. Safe, CTO)')" />
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 16 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') : ''" />
<NcTextField v-model="delay" type="number" min="1" max="365"
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
:helper-text="t('ng_backup', 'Protects against a malicious or mistaken cleanup. Cannot be changed later.')" />
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 16 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
{{ t('ng_backup', 'Create backup key') }}
</NcButton>
</form>
@@ -35,7 +35,7 @@
<tr v-for="slot in status.key.slots" :key="slot.slot">
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
<td><strong>{{ slot.label }}</strong></td>
<td class="ngb-muted">{{ slot.created ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
<td class="ngb-muted">{{ slot.created ? formatDate(slot.created) : '' }}</td>
<td class="ngb-actions">
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
@@ -46,10 +46,10 @@
<NcButton v-if="!slotForm && status.key.slots.length < status.key.maxSlots" @click="openSlotForm(null)">{{ t('ng_backup', 'Add passphrase') }}</NcButton>
<form v-if="slotForm" class="ngb-form" @submit.prevent="saveSlot">
<NcTextField v-model="slotForm.label" :label="t('ng_backup', 'Who keeps this passphrase')" />
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="slotForm.pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password" :error="slotForm.pass2 !== '' && slotForm.pass1 !== slotForm.pass2" />
<div class="ngb-row">
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 12 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 16 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
<NcButton @click="slotForm = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
</div>
</form>
@@ -69,7 +69,7 @@
<NcButton variant="primary" :disabled="!downloaded || !accepted || code.length < 8 || 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() }) }}
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: formatDateTime(status.key.confirmation.time) }) }}
<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>
@@ -90,6 +90,7 @@ import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import api from '../api.js'
import { formatDateTime, formatDate } from '../format.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
+2 -2
View File
@@ -52,7 +52,7 @@ 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'
import { formatDateTime, formatSize } from '../format.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
@@ -74,7 +74,7 @@ const modes = [
{ 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 snapshotLabel = (s) => formatDateTime(s.createdAt) + (s.label ? ' – ' + s.label : '')
const join = (name) => path.value + '/' + name
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
const crumbs = computed(() => {
+13
View File
@@ -1,5 +1,18 @@
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
import { getCanonicalLocale } from '@nextcloud/l10n'
// The locale of the user's Nextcloud settings (Personal info > Locale), not the browser's:
// a browser set to English would otherwise show 4:00 PM to someone with a 24-hour locale.
const locale = getCanonicalLocale()
// A number is a unix timestamp in seconds, a string an ISO 8601 date (snapshot times).
const toDate = (value) => typeof value === 'number' ? new Date(value * 1000) : new Date(value)
export const formatDateTime = (value) => toDate(value).toLocaleString(locale)
export const formatDate = (value) => toDate(value).toLocaleDateString(locale)
export function formatSize(bytes) {
if (bytes === null || bytes === undefined) {
return ''
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit;
use OCA\NgBackup\Format;
use PHPUnit\Framework\TestCase;
class FormatTest extends TestCase {
public function testDurationShowsDaysFromOneDayOn(): void {
$this->assertSame('00:00:00', Format::duration(-5));
$this->assertSame('12:10:13', Format::duration(12 * 3600 + 10 * 60 + 13));
$this->assertSame('1d 00:00:00', Format::duration(86400));
$this->assertSame('1d 12:10:13', Format::duration(86400 + 12 * 3600 + 10 * 60 + 13));
}
}
+106
View File
@@ -42,6 +42,61 @@ class BackupRunTest extends TestCase {
$this->assertSame(self::hashTree($src), self::hashTree($target));
}
public function testTheSnapshotIsWrittenInAStepOfItsOwnFromAllSegmentsAtOnce(): void {
$src = $this->tempDir();
for ($i = 0; $i < 60; $i++) {
file_put_contents("$src/f$i.txt", random_bytes(200000));
}
$backend = new RangeCountingBackend(new LocalBackend($this->tempDir()));
$keys = KeyRing::generate();
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
$state = BackupRun::start($src);
for ($i = 0; $i < 500 && $state['phase'] === 'files'; $i++) {
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.01);
}
$this->assertSame('finish', $state['phase'], 'the step that read the last file does not write the snapshot');
$this->assertGreaterThan(2, count($state['segments']));
$backend->batches = [];
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.01);
$this->assertSame('commit', $state['phase']);
// One batch; the blobs of a segment follow each other in its pack and are one read.
$this->assertSame([count($state['segments'])], $backend->batches);
Repository::openWithKey($backend, $keys)->recordSnapshot($state['snapshot']);
$target = $this->tempDir();
Repository::openWithKey($backend, $keys)->restore($state['snapshot'], $target);
$this->assertSame(self::hashTree($src), self::hashTree($target));
}
public function testBlobsThatFollowEachOtherInAPackAreReadAsOneRange(): void {
$backend = new RangeCountingBackend(new LocalBackend($this->tempDir()));
$keys = KeyRing::generate();
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
$stats = [];
$segments = [];
$expected = [];
for ($s = 0; $s < 3; $s++) {
$writer = new \OCA\NgBackup\Repository\BlobWriter(fn (string $data): string => $repo->storeData($data, $stats), 1000);
$lines = [];
for ($i = 0; $i < 100; $i++) {
$lines[] = json_encode(['segment' => $s, 'line' => $i, 'pad' => bin2hex(random_bytes(20))]);
}
$writer->write(implode("\n", $lines) . "\n");
$segments[] = $writer->finish();
$repo->flushPacks(); // one pack per segment, as one per step
array_push($expected, ...$lines);
}
$this->assertGreaterThan(3, count(array_merge(...$segments)));
$backend->batches = [];
$read = iterator_to_array(Repository::openWithKey($backend, $keys)->readLines(array_merge(...$segments)), false);
$this->assertSame($expected, array_values(array_filter($read, fn (string $l): bool => $l !== '')));
$this->assertSame([3], $backend->batches, 'one read per segment, all in one batch');
}
public function testFileChangedBetweenStepsIsReadAgain(): void {
$src = $this->tempDir();
file_put_contents("$src/big.bin", random_bytes(20 * 1048576));
@@ -100,3 +155,54 @@ class BackupRunTest extends TestCase {
$this->assertSame(self::hashTree($src), self::hashTree("$target/data"));
}
}
final class RangeCountingBackend implements \OCA\NgBackup\Backend\IBackend {
use \OCA\NgBackup\Backend\GetsOneByOne {
getRanges as private oneByOne;
}
/** @var list<int> number of ranges per getRanges() call */
public array $batches = [];
public function __construct(
private \OCA\NgBackup\Backend\IBackend $inner,
) {
}
public function getRanges(array $ranges): \Generator {
$this->batches[] = count($ranges);
return $this->oneByOne($ranges);
}
public function put(string $path, $stream): void {
$this->inner->put($path, $stream);
}
public function get(string $path) {
return $this->inner->get($path);
}
public function getRange(string $path, int $offset, int $length): string {
return $this->inner->getRange($path, $offset, $length);
}
public function exists(string $path): bool {
return $this->inner->exists($path);
}
public function move(string $from, string $to): void {
$this->inner->move($from, $to);
}
public function list(string $prefix): array {
return $this->inner->list($prefix);
}
public function listFiles(string $dir): array {
return $this->inner->listFiles($dir);
}
public function delete(string $path): void {
$this->inner->delete($path);
}
}
+14
View File
@@ -66,6 +66,20 @@ class RepositoryTest extends TestCase {
$this->assertSame(1, $r2['newBlobs']);
}
public function testCompressedDataSkipsDeflateAndOtherDataIsStillCompressed(): void {
$repo = Repository::init($this->backend, 'pw');
$text = str_repeat("a line of a log file, much like the one before\n", 30000); // ~1.4 MiB
$noise = random_bytes(1048576); // like a photo: deflate saves nothing
$stats = [];
$textId = $repo->storeData($text, $stats);
$textUploaded = $stats['uploaded'];
$noiseId = $repo->storeData($noise, $stats);
$repo->flushPacks();
$this->assertLessThan(strlen($text) / 10, $textUploaded);
$this->assertLessThan(strlen($noise) + 1024, $stats['uploaded'] - $textUploaded);
$this->assertSame($text . $noise, implode('', iterator_to_array(Repository::open($this->backend, 'pw')->readBlobs([$textId, $noiseId]), false)));
}
public function testPartialRestore(): void {
$repo = Repository::init($this->backend, 'pw');
$r = $repo->backupDirectory($this->src);