Compare commits

...

15 Commits

Author SHA1 Message Date
Dillard Blom bfd54447b0 Merge pull request #6: Release 0.9.2-beta.3
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
2026-10-08 01:28:52 +02:00
dillard 0b50bb6b81 Verify: retry storage exceptions only, let programming errors surface
Catch \Exception (storage adapters throw their own exception types) instead
of \Throwable, so an \Error such as a TypeError is not retried and reported
as an unreadable blob.
2026-10-08 01:21:15 +02:00
dillard f415bab519 Verify, README: address review notes
An unreadable blob may still be a truncated pack, so don't rule out damage
in the verify message; README: Hetzner needs WebDAV/SMB and external
reachability enabled for the sub-account.
2026-10-08 01:11:21 +02:00
dillard 27eb0b32fc Release 0.9.2-beta.3 2026-10-08 01:00:52 +02:00
dillard b1d4f6a469 README: Hetzner Storage Box over WebDAV and SMB
Tested both on sub-accounts: works end to end; WebDAV needs an existing
root, SMB needs port 445 and the smbclient extension; fixed cost per run
and slow deep verify over remote storage.
2026-10-08 01:00:46 +02:00
dillard fe7ad9b4b5 Verify: report read errors as unreadable, not as corrupted
verifyBlob() caught every exception from loading a blob and counted it as
a failed (corrupted or tampered) blob, so a transient network error during
a deep verify over WebDAV looked like data corruption. Read the range
separately, retry it, and report a read that keeps failing as unreadable
with its error; only a blob that was read but does not decrypt or match
its id counts as failed.
2026-10-08 00:53:31 +02:00
dillard 695d972785 Backups: let the runs table scroll on narrow screens
The table could not shrink below its content width and ran off the page,
cutting off the last columns; wrap it in an overflow-x container.
2026-10-08 00:47:47 +02:00
dillard 972a52e665 Settings: align the tabs with the sections
The tabs had no inline margin, so they sat against the edge while every
NcSettingsSection is inset by 7 grid baselines.
2026-10-08 00:47:47 +02:00
dillard 7c08a10621 Locations: ask for password confirmation before adding
addTarget requires a recent password confirmation, but the form never
asked for one. Within 30 minutes of logging in that went unnoticed; after
that, adding a location failed with a bare 403.
2026-10-08 00:40:48 +02:00
Dillard Blom 209520165e Merge pull request #5: Release 0.9.1-beta.2
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
2026-10-07 18:09:23 +02:00
dillard 9554e774e5 CHANGELOG: correct why info.xml carries no -beta suffix 2026-10-07 18:01:20 +02:00
dillard 8b2dca28e3 Release 0.9.1-beta.2 2026-10-07 17:47:40 +02:00
dillard 5742f77253 App store listing: screenshots, links and UI fixes
Add three screenshots (backups, restore, settings) taken on a demo
instance, plus website, repository and the HOWTO as admin documentation
in info.xml.

Fixes found while taking them:
- the "new folder" restore option lost "<date>" (l10n sanitizes it as
  a tag); show the folder name pattern via a placeholder instead
- the restore-point dropdown cut off the time
- passphrase actions stacked vertically in the slot table
- HOWTO: SFTP example used "sftp::password", the auth mechanism is
  "password::password"
2026-10-07 17:43:41 +02:00
Dillard Blom 550249570b Merge pull request #4: README key rotation note
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
README: key rotation for revoking old kits is on the backlog
2026-10-07 02:26:19 +02:00
dillard 0eb23f56e7 README: note that key rotation for revoking old kits is on the backlog 2026-10-07 02:18:45 +02:00
16 changed files with 218 additions and 45 deletions
+39 -3
View File
@@ -2,9 +2,43 @@
All notable changes to NG Backup are documented here. The format follows All notable changes to NG Backup are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows the stages in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows the stages in
`PLAN.md` section 9. The `<version>` in `appinfo/info.xml` is plain SemVer-numeric (Nextcloud's `PLAN.md` section 9. The `<version>` in `appinfo/info.xml` is plain SemVer-numeric on purpose:
own `info.xsd` doesn't allow a `-alpha`/`-beta`/`-rc` suffix there); the stability stage is instead the app store accepts a `-beta` suffix, but Nextcloud servers then only offer the release on the
carried by the git tag, the release, and this file. 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.
## [0.9.2-beta.3] - 2026-10-08
Bugfix release.
### Added
- README: notes on using a Hetzner Storage Box over WebDAV and SMB.
### Fixed
- Adding a backup location in the web interface failed with "Request failed with status code 403"
when the login was more than 30 minutes old: the form did not ask for password confirmation.
- Settings page: the "Backup and restore" / "Settings" tabs line up with the sections below them.
- Backups: the runs table scrolls horizontally on narrow screens instead of running off the page.
- `occ backup:verify --deep`: a blob that could not be read (network or storage error) was reported
as "corrupted or tampered". Reads are now retried, and a read that keeps failing is reported
separately as unreadable, with the error (run the check again; if the same blobs stay unreadable,
their pack may be damaged or truncated). Only a blob that was read but fails decryption or its
hash check counts as corrupted. Found on a Hetzner Storage Box over WebDAV, where the blob
turned out to be intact.
## [0.9.1-beta.2] - 2026-10-07
Second beta, the first release in the Nextcloud app store (signed).
### Added
- App store listing: screenshots, website, repository and documentation link in `appinfo/info.xml`.
### Fixed
- Restore: the "new folder" option showed `"Restored "` without the date pattern.
- Restore: the restore-point dropdown cut off the time.
- Settings: the passphrase actions are on one line instead of stacked.
- `docs/HOWTO.md`: the SFTP example used a non-existent auth mechanism (`sftp::password`
instead of `password::password`).
## [0.9.0-beta.1] - 2026-10-07 ## [0.9.0-beta.1] - 2026-10-07
@@ -88,5 +122,7 @@ and not implemented yet.
- See the "Storage backend experiences" section of the README for provider-specific quirks found - See the "Storage backend experiences" section of the README for provider-specific quirks found
so far (e.g. Hetzner Storage Box sub-accounts). so far (e.g. Hetzner Storage Box sub-accounts).
[0.9.2-beta.3]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.2-beta.3
[0.9.1-beta.2]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.1-beta.2
[0.9.0-beta.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.0-beta.1 [0.9.0-beta.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.0-beta.1
[0.1.0-alpha.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.1.0-alpha.1 [0.1.0-alpha.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.1.0-alpha.1
+17 -2
View File
@@ -7,7 +7,7 @@ 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 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.) ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
**Status: beta (0.9.0).** Tested thoroughly, usable alongside your current backup, but not yet a **Status: beta (0.9.2).** 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 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. `docs/HOWTO.md` for a step-by-step guide from setup to a tested restore.
@@ -63,7 +63,8 @@ All backups of an installation are encrypted with one master key.
a single slot is possible, but then that one passphrase is a single point of failure. a single slot is possible, but then that one passphrase is a single point of failure.
- When a holder leaves, give their slot a new passphrase (`occ backup:key:slot:replace`), download - When a holder leaves, give their slot a new passphrase (`occ backup:key:slot:replace`), download
the new kit and confirm it. Note: an old kit together with the old passphrase still opens the the new kit and confirm it. Note: an old kit together with the old passphrase still opens the
same master key; to revoke it completely, start new locations with a new key. same master key; to revoke it completely, start new locations with a new key. This is noted on
the backlog and will be addressed in a future version (key rotation).
- Downloading the kit, and every change to the slots, requires the administrator's password, is - Downloading the kit, and every change to the slots, requires the administrator's password, is
written to the audit log and is announced to all administrators. written to the audit log and is announced to all administrators.
@@ -125,6 +126,20 @@ Contributions welcome via PR — add what you ran into with your own provider.
verified whether this also applies to a Hetzner Storage Box **main** account (only tested with a verified whether this also applies to a Hetzner Storage Box **main** account (only tested with a
sub-account so far) — if you've tried a main account, please say so in a PR. sub-account so far) — if you've tried a main account, please say so in a PR.
- **Hetzner Storage Box over WebDAV and SMB (sub-accounts, 2026-10-07):** both work end to end
(backup, incremental run, restore of a folder, verify), tested from outside the Hetzner network
with about 66 MiB of data. In the Hetzner Console, enable WebDAV or SMB support for the
sub-account, and "External reachability" when your server is not in the Hetzner network.
WebDAV: host `https://uXXXXX-subN.your-storagebox.de`, and the `root`
must be a folder that already exists (or empty): WebDAV does not create missing parent folders,
so a root that doesn't exist yet fails `backup:target:add` with `Sabre\HTTP\ClientHttpException:
Conflict`. SMB: share = the sub-account name, port 445 must be reachable from your server (it was
from a home connection here, but some providers block it), and the `smbclient` PECL extension is
required (see the SMB entry below). Both have a noticeable fixed cost per run: an incremental run
that uploads nothing took about 20 s over WebDAV and 50 s over SMB. A deep verify is slow over
remote storage, since each blob is fetched with its own request (19 minutes for 66 MiB over
WebDAV); run it occasionally, not after every backup.
- **FTP, server-dependent absolute-path handling:** files_external's FTP backend always builds - **FTP, server-dependent absolute-path handling:** files_external's FTP backend always builds
absolute paths (e.g. `MKD /repo`) for the configured root, never relative ones. `atmoz/sftp`-style absolute paths (e.g. `MKD /repo`) for the configured root, never relative ones. `atmoz/sftp`-style
`vsftpd` test servers (chrooted) can reject that with a generic "create directory operation `vsftpd` test servers (chrooted) can reject that with a generic "create directory operation
+9 -1
View File
@@ -9,12 +9,20 @@ directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files a
single users (via user_migration) and of the whole server onto a fresh installation. Tested 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. 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> See the README for known limitations and the recovery kit before relying on it.]]></description>
<version>0.9.0</version> <version>0.9.2</version>
<licence>agpl</licence> <licence>agpl</licence>
<author>Dillard Blom</author> <author>Dillard Blom</author>
<namespace>NgBackup</namespace> <namespace>NgBackup</namespace>
<documentation>
<admin>https://github.com/dillardblom/ng_backup/blob/main/docs/HOWTO.md</admin>
</documentation>
<category>tools</category> <category>tools</category>
<website>https://apps.nextcloud.com/apps/ng_backup</website>
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs> <bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
<repository type="git">https://github.com/dillardblom/ng_backup</repository>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/1-backups.png</screenshot>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/2-restore.png</screenshot>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/3-settings.png</screenshot>
<dependencies> <dependencies>
<php min-version="8.1"/> <php min-version="8.1"/>
<lib>sodium</lib> <lib>sodium</lib>
+1 -1
View File
@@ -32,7 +32,7 @@ Examples:
``` ```
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=<bucket> -o key=<key> -o secret=<secret> occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=<bucket> -o key=<key> -o secret=<secret>
occ backup:target:add nas sftp -a sftp::password -o host=<host> -o user=<user> -o password=<pw> -o root=home occ backup:target:add nas sftp -a password::password -o host=<host> -o user=<user> -o password=<pw> -o root=home
occ backup:target:add local local -a null::null -o datadir=/mnt/backup occ backup:target:add local local -a null::null -o datadir=/mnt/backup
``` ```
+7 -1
View File
@@ -66,7 +66,13 @@ HELP);
if ($result['failed'] !== []) { if ($result['failed'] !== []) {
$output->writeln('<error>' . count($result['failed']) . ' blob(s) failed verification (corrupted or tampered):</error> ' . implode(', ', $result['failed'])); $output->writeln('<error>' . count($result['failed']) . ' blob(s) failed verification (corrupted or tampered):</error> ' . implode(', ', $result['failed']));
} }
if ($result['missing'] === [] && $result['failed'] === []) { if ($result['unreadable'] !== []) {
$output->writeln('<error>' . count($result['unreadable']) . ' blob(s) could not be read from the location</error> (often a temporary storage '
. 'or network error: run the check again; if the same blobs stay unreadable, their pack may be damaged or truncated): '
. implode(', ', $result['unreadable']));
$output->writeln('Last read error: ' . $result['readError']);
}
if ($result['missing'] === [] && $result['failed'] === [] && $result['unreadable'] === []) {
$output->writeln('<info>OK</info>'); $output->writeln('<info>OK</info>');
return 0; return 0;
} }
+31 -7
View File
@@ -28,6 +28,8 @@ final class Repository {
public const FORMAT = 1; public const FORMAT = 1;
public const BLOB_SIZE = 4 * 1048576; public const BLOB_SIZE = 4 * 1048576;
private const FLAG_DEFLATE = 1; private const FLAG_DEFLATE = 1;
/** Reads per blob in a deep verify before it is reported as unreadable (not corrupted). */
private const VERIFY_READ_ATTEMPTS = 3;
private StreamCipher $cipher; private StreamCipher $cipher;
private BlobIndex $index; private BlobIndex $index;
@@ -696,8 +698,13 @@ final class Repository {
} }
private function loadBlob(string $id): string { private function loadBlob(string $id): string {
[$pack, $offset, $length, $raw, $flags] = $this->index->get($id); [$pack, $offset, $length] = $this->index->get($id);
$encrypted = $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length); return $this->decodeBlob($id, $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length));
}
/** Decrypts and checks a blob read from its pack; throws when it is corrupted or tampered with. */
private function decodeBlob(string $id, string $encrypted): string {
[, , , $raw, $flags] = $this->index->get($id);
$payload = $this->cipher->decryptString($encrypted, 'blob:' . $id); $payload = $this->cipher->decryptString($encrypted, 'blob:' . $id);
$data = ($flags & self::FLAG_DEFLATE) ? gzinflate($payload) : $payload; $data = ($flags & self::FLAG_DEFLATE) ? gzinflate($payload) : $payload;
if ($data === false || strlen($data) !== $raw || $this->keys->blobId($data) !== $id) { if ($data === false || strlen($data) !== $raw || $this->keys->blobId($data) !== $id) {
@@ -733,13 +740,13 @@ final class Repository {
* *
* @param ?callable $heartbeat called after every blob (shallow) or every blob read (deep); * @param ?callable $heartbeat called after every blob (shallow) or every blob read (deep);
* callers use it to keep a time-boxed lease alive during a large audit * callers use it to keep a time-boxed lease alive during a large audit
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>} * @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
*/ */
public function verify(string $snapshotId, bool $deep = false, ?callable $heartbeat = null): array { public function verify(string $snapshotId, bool $deep = false, ?callable $heartbeat = null): array {
$heartbeat ??= static function (): void { $heartbeat ??= static function (): void {
}; };
$this->index->load(true); $this->index->load(true);
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => []]; $stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
$seen = []; $seen = [];
$packSeen = []; // pack id => exists on the backend; avoids one exists() call per blob $packSeen = []; // pack id => exists on the backend; avoids one exists() call per blob
foreach ($this->streamEntries($snapshotId) as $entry) { foreach ($this->streamEntries($snapshotId) as $entry) {
@@ -778,7 +785,7 @@ final class Repository {
* Same checks as verify(), for a user export: every blob its manifest references must be in * Same checks as verify(), for a user export: every blob its manifest references must be in
* the freshly re-authenticated index with its pack present; $deep also decrypts each. * the freshly re-authenticated index with its pack present; $deep also decrypts each.
* *
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>} * @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
*/ */
public function verifyUserExport(string $manifestPath, bool $deep = false, ?callable $heartbeat = null): array { public function verifyUserExport(string $manifestPath, bool $deep = false, ?callable $heartbeat = null): array {
if (!$this->isRecordedUserExport($manifestPath)) { if (!$this->isRecordedUserExport($manifestPath)) {
@@ -787,7 +794,7 @@ final class Repository {
$heartbeat ??= static function (): void { $heartbeat ??= static function (): void {
}; };
$this->index->load(true); $this->index->load(true);
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => []]; $stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
$seen = []; $seen = [];
$packSeen = []; $packSeen = [];
$manifest = json_decode($this->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR); $manifest = json_decode($this->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
@@ -824,8 +831,25 @@ final class Repository {
if (!$deep) { if (!$deep) {
return; return;
} }
// Reading and checking are kept apart: a read that fails is usually a network or
// storage error, so it is retried and reported as unreadable (a pack that stays
// unreadable may still be truncated). Only a blob that was read but does not
// decrypt or match its id counts as failed: that is proof of damage.
[, $offset, $length] = $this->index->get($id);
$encrypted = null;
for ($attempt = 1; $attempt <= self::VERIFY_READ_ATTEMPTS && $encrypted === null; $attempt++) {
try {
$encrypted = $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length);
} catch (\Exception $e) { // storage adapters throw their own exceptions; \Error (a bug) is not retried
$stats['readError'] = $e->getMessage();
}
}
if ($encrypted === null) {
$stats['unreadable'][] = $id;
return;
}
try { try {
$stats['bytesChecked'] += strlen($this->loadBlob($id)); $stats['bytesChecked'] += strlen($this->decodeBlob($id, $encrypted));
} catch (\Throwable) { } catch (\Throwable) {
$stats['failed'][] = $id; $stats['failed'][] = $id;
} }
+2 -2
View File
@@ -18,12 +18,12 @@ final class VerifyService {
) { ) {
} }
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>} */ /** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string} */
public function verify(Target $target, string $snapshotId, bool $deep = false): array { public function verify(Target $target, string $snapshotId, bool $deep = false): array {
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat)); return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
} }
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>} */ /** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string} */
public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false): array { public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false): array {
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat)); return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

+2 -1
View File
@@ -82,6 +82,7 @@ onBeforeUnmount(() => clearTimeout(timer))
.ngb-tabs { .ngb-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin: 0 0 12px; /* Same inline margin as NcSettingsSection, so the tabs line up with the section content */
margin: 0 calc(var(--default-grid-baseline) * 7) 12px;
} }
</style> </style>
+27 -24
View File
@@ -30,30 +30,32 @@
</div> </div>
</template> </template>
<table v-if="status.runs.length" class="ngb-table"> <div v-if="status.runs.length" class="ngb-table-wrap">
<thead> <table class="ngb-table">
<tr> <thead>
<th>{{ t('ng_backup', 'Started') }}</th> <tr>
<th>{{ t('ng_backup', 'Location') }}</th> <th>{{ t('ng_backup', 'Started') }}</th>
<th>{{ t('ng_backup', 'Kind') }}</th> <th>{{ t('ng_backup', 'Location') }}</th>
<th>{{ t('ng_backup', 'Restore point') }}</th> <th>{{ t('ng_backup', 'Kind') }}</th>
<th>{{ t('ng_backup', 'Status') }}</th> <th>{{ t('ng_backup', 'Restore point') }}</th>
<th>{{ t('ng_backup', 'Files') }}</th> <th>{{ t('ng_backup', 'Status') }}</th>
<th>{{ t('ng_backup', 'Uploaded') }}</th> <th>{{ t('ng_backup', 'Files') }}</th>
</tr> <th>{{ t('ng_backup', 'Uploaded') }}</th>
</thead> </tr>
<tbody> </thead>
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''"> <tbody>
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td> <tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
<td>{{ targetName(run.targetId) }}</td> <td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
<td>{{ run.kind }}</td> <td>{{ targetName(run.targetId) }}</td>
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td> <td>{{ run.kind }}</td>
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td> <td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td> <td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td> <td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
</tr> <td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
</tbody> </tr>
</table> </tbody>
</table>
</div>
</NcSettingsSection> </NcSettingsSection>
</template> </template>
@@ -126,6 +128,7 @@ async function saveSchedule() {
<style scoped> <style scoped>
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; } .ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
.ngb-time { max-width: 260px; } .ngb-time { max-width: 260px; }
.ngb-table-wrap { overflow-x: auto; }
.ngb-table { width: 100%; border-collapse: collapse; } .ngb-table { width: 100%; border-collapse: collapse; }
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); } .ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
.ngb-failed { color: var(--color-error-text); } .ngb-failed { color: var(--color-error-text); }
+2 -1
View File
@@ -36,7 +36,7 @@
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td> <td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
<td><strong>{{ slot.label }}</strong></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 ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
<td> <td class="ngb-actions">
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton> <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> <NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
</td> </td>
@@ -162,5 +162,6 @@ const confirm = () => guarded(async () => {
.ngb-form, .ngb-confirm { display: flex; flex-direction: column; gap: 12px; max-width: 600px; margin: 8px 0; } .ngb-form, .ngb-confirm { display: flex; flex-direction: column; gap: 12px; max-width: 600px; margin: 8px 0; }
.ngb-row { display: flex; gap: 8px; } .ngb-row { display: flex; gap: 8px; }
.ngb-table td { padding: 2px 12px 2px 0; } .ngb-table td { padding: 2px 12px 2px 0; }
.ngb-actions { display: flex; gap: 4px; }
h3 { margin-top: 16px; font-weight: bold; } h3 { margin-top: 16px; font-weight: bold; }
</style> </style>
+1
View File
@@ -104,6 +104,7 @@ async function add() {
busy.value = true busy.value = true
error.value = '' error.value = ''
try { try {
await confirmPassword()
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path, await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path,
maxGb: form.maxGb === '' ? null : Number(form.maxGb) }) maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
adding.value = false adding.value = false
+3 -2
View File
@@ -7,7 +7,7 @@
:description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')"> :description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')">
<div class="ngb-row"> <div class="ngb-row">
<NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" /> <NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" />
<NcSelect v-if="target" v-model="snapshot" :options="snapshots" label="displayLabel" :input-label="t('ng_backup', 'Restore point')" :clearable="false" /> <NcSelect v-if="target" v-model="snapshot" :options="snapshots" label="displayLabel" class="ngb-snapshot" :input-label="t('ng_backup', 'Restore point')" :clearable="false" />
</div> </div>
<template v-if="snapshot"> <template v-if="snapshot">
@@ -69,7 +69,7 @@ const busy = ref(false)
const error = ref('') const error = ref('')
const message = ref('') const message = ref('')
const modes = [ const modes = [
{ id: 'new-folder', label: t('ng_backup', 'Into a new folder "Restored <date>" (nothing existing is changed)') }, { id: 'new-folder', label: t('ng_backup', 'Into a new folder "Restored {date}" (nothing existing is changed)', { date: 'YYYY-MM-DD HH.MM' }) },
{ id: 'merge', label: t('ng_backup', 'Into the original place, overwrite (old content kept as a version)') }, { id: 'merge', label: t('ng_backup', 'Into the original place, overwrite (old content kept as a version)') },
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') }, { id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
] ]
@@ -124,6 +124,7 @@ async function restore() {
<style scoped> <style scoped>
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; } .ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.ngb-row :deep(.ngb-snapshot) { min-width: min(360px, 100%); }
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; } .ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; } .ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); } .ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
+77
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Repository; namespace OCA\NgBackup\Tests\Unit\Repository;
use OCA\NgBackup\Backend\BackendException;
use OCA\NgBackup\Backend\IBackend;
use OCA\NgBackup\Backend\LocalBackend; use OCA\NgBackup\Backend\LocalBackend;
use OCA\NgBackup\Crypto\KeyRing; use OCA\NgBackup\Crypto\KeyRing;
use OCA\NgBackup\Repository\Repository; use OCA\NgBackup\Repository\Repository;
@@ -92,4 +94,79 @@ class VerifyTest extends TestCase {
$this->assertNotSame([], $result['failed']); $this->assertNotSame([], $result['failed']);
} }
public function testDeepVerifyRetriesAReadThatFailsOnce(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$flaky = new FlakyRangeBackend($this->backend, failures: 1);
$result = Repository::openWithKey($flaky, $this->keys, null, null)->verify($s['snapshot'], deep: true);
$this->assertSame(200000, $result['bytesChecked']);
$this->assertSame([], $result['failed']);
$this->assertSame([], $result['unreadable']);
}
public function testDeepVerifyReportsAPersistentReadErrorAsUnreadableNotCorrupted(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$broken = new FlakyRangeBackend($this->backend, failures: PHP_INT_MAX);
$result = Repository::openWithKey($broken, $this->keys, null, null)->verify($s['snapshot'], deep: true);
$this->assertSame([], $result['failed']);
$this->assertNotSame([], $result['unreadable']);
$this->assertSame('connection reset', $result['readError']);
}
public function testDeepVerifyDoesNotHideAProgrammingError(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$buggy = new FlakyRangeBackend($this->backend, failures: PHP_INT_MAX, error: new \TypeError('bug'));
$this->expectException(\TypeError::class);
Repository::openWithKey($buggy, $this->keys, null, null)->verify($s['snapshot'], deep: true);
}
}
/** Delegates to a real backend, but getRange() throws for the first $failures calls. */
final class FlakyRangeBackend implements IBackend {
public function __construct(
private IBackend $inner,
private int $failures,
private ?\Throwable $error = null,
) {
}
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 {
if ($this->failures > 0) {
$this->failures--;
throw $this->error ?? new BackendException('connection reset');
}
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 delete(string $path): void {
$this->inner->delete($path);
}
} }