Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 209520165e | |||
| 9554e774e5 | |||
| 8b2dca28e3 | |||
| 5742f77253 | |||
| 550249570b | |||
| 0eb23f56e7 | |||
| a0c1f1cd58 | |||
| 26cc9a897e | |||
| fe0385ad8c | |||
| b37a953cfd | |||
| b89b5b5fa6 | |||
| 16e09f4fde |
+65
-3
@@ -2,9 +2,69 @@
|
|||||||
|
|
||||||
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.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
|
||||||
|
|
||||||
|
First beta. Tested thoroughly and usable **alongside** your current backup, but **not yet a
|
||||||
|
drop-in replacement** for it. We test what we can ourselves (Nextcloud 32–35, SQLite, MariaDB and
|
||||||
|
PostgreSQL, S3, SFTP, FTP, SMB and NFS), but we need field experience from other setups: please
|
||||||
|
report what works and what doesn't, especially on large instances and on storage providers not
|
||||||
|
listed in the README.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Disaster recovery: `occ backup:key:import` and `occ backup:restore:full` restore the database,
|
||||||
|
data directory and config onto a fresh installation of the same major version.
|
||||||
|
- Per-user backup and restore via user_migration: `occ backup:user:backup` and
|
||||||
|
`occ backup:user:restore` (replace the account after a safety export, or restore as
|
||||||
|
`<uid>-bak`), also onto a server where the user does not exist yet. The commands ask before
|
||||||
|
installing user_migration (or take `--install-user-migration`).
|
||||||
|
- Checksum audit: `occ backup:verify`, shallow or `--deep`, for snapshots and per-user exports.
|
||||||
|
- Retention for per-user exports: `occ backup:retention --user-last=N`.
|
||||||
|
- FTP and SMB covered by the integration tests; NFS through the `local` backend on a mounted share.
|
||||||
|
- `docs/HOWTO.md`: step by step from setup to a tested restore.
|
||||||
|
- Warnings when a backup finishes with unreadable files or folders, or with files that kept
|
||||||
|
changing while being read (log, `occ`, notification, snapshot).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `backup:restore:full` refuses on an installation with more than one user and asks first;
|
||||||
|
`backup:user:restore --mode=replace` asks before deleting an existing account (`--force` for
|
||||||
|
scripts in both cases).
|
||||||
|
- A failed scheduled backup is retried at most three times a day, an hour apart.
|
||||||
|
- The recovery kit carries the deletion delay; the kit file is created owner-only.
|
||||||
|
- Adding a location through the web API needs password confirmation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Exports of users whose id contains `.part-` could be deleted by cleanup on remote locations.
|
||||||
|
- Cleanup no longer frees the data of a per-user export whose manifest is missing.
|
||||||
|
- Crash safety of moving snapshots to and from the trash, of catalog writes by concurrent
|
||||||
|
backups and per-user exports, and of short uploads.
|
||||||
|
- A file that keeps changing no longer makes a backup restart forever.
|
||||||
|
- Long restores and exports keep their lock on the location alive.
|
||||||
|
- Floating-point database columns are dumped exactly.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
See "Known limitations" in the README: memory use on very large instances, never two servers
|
||||||
|
writing to one location, removing a passphrase slot does not revoke access, no rollback detection
|
||||||
|
before a disaster recovery, and a few smaller points. None of them block the road to 1.0.0; they
|
||||||
|
are on the backlog in `PLAN.md`.
|
||||||
|
|
||||||
## [0.1.0-alpha.1] - 2026-10-05
|
## [0.1.0-alpha.1] - 2026-10-05
|
||||||
|
|
||||||
@@ -43,4 +103,6 @@ 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.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.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
|
||||||
|
|||||||
@@ -359,6 +359,24 @@ minimale volledige restore (`occ backup:restore:full` op een verse installatie)
|
|||||||
Backlog: (a) de originele weergavenaam herstellen (het label uit de export is niet getest als
|
Backlog: (a) de originele weergavenaam herstellen (het label uit de export is niet getest als
|
||||||
weergavenaam); (b) de interne `migrated-`-naam hernoemen (vereist directe databasewijziging, niet voor de beta).
|
weergavenaam); (b) de interne `migrated-`-naam hernoemen (vereist directe databasewijziging, niet voor de beta).
|
||||||
|
|
||||||
|
**Backlog uit de pre-beta review (2026-10-07)** — geen belemmering voor 0.9.0-beta of 1.0.0, staat
|
||||||
|
in README "Known limitations":
|
||||||
|
1. Geheugen bij grote instanties (>1 TB, miljoenen bestanden): blob-index, prune-set, browse van een
|
||||||
|
snapshot in de web-UI, `trashExtras` bij restore en het manifest van een user-export staan volledig
|
||||||
|
in het geheugen; streaming/paginering nodig.
|
||||||
|
2. Twee servers op één locatie: catalogus kan breken (generatie overschreven, of anchor >200 generaties
|
||||||
|
achter). Docs-waarschuwing staat er; mogelijk later een echte put-if-absent per backend.
|
||||||
|
3. Sleutelrotatie: een slot verwijderen trekt geen toegang in (master-key blijft gelijk).
|
||||||
|
4. Rollback vóór DR niet detecteerbaar: referentie-generatie en -hash in de kit opnemen of tonen.
|
||||||
|
5. Kit-velden buiten `wrapped_key` (o.a. `delete_delay_days`) zijn niet geauthenticeerd; nu alleen getoond.
|
||||||
|
6. Repository-id in de AD van objecten (formaatwijziging).
|
||||||
|
7. Wachtwoorden van locaties via `--option-file` of omgevingsvariabele i.p.v. de commandoregel.
|
||||||
|
8. Tijdelijke restore-bestanden niet via de Files-API verwijderen (komen nu in de prullenbak).
|
||||||
|
9. DB-restore in foreign-key-volgorde (cascades).
|
||||||
|
10. Packs zonder index (index-upload mislukt) opruimen.
|
||||||
|
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.
|
||||||
|
|
||||||
Doorgeschoven naar **v1.3.0** (fase 3, niet meer fase 2): in-place volledige restore
|
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
|
(eigen restore-modus i.p.v. onderhoudsmodus) en zelfbediening voor gebruikers (eigen bestanden
|
||||||
terugzetten vanuit de Files-app).
|
terugzetten vanuit de Files-app).
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ 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: pre-release. Not yet tested on production.** See `PLAN.md` for the design and `docs/HOWTO.md` for a
|
**Status: beta (0.9.1).** Tested thoroughly, usable alongside your current backup, but not yet a
|
||||||
step-by-step guide from setup to a tested restore.
|
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.
|
License: AGPL-3.0-or-later.
|
||||||
|
|
||||||
@@ -62,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.
|
||||||
|
|
||||||
@@ -147,6 +149,29 @@ Contributions welcome via PR — add what you ran into with your own provider.
|
|||||||
mid-run behaves like any other local-path failure (the run resumes). See PLAN.md 3.5 for why NFS
|
mid-run behaves like any other local-path failure (the run resumes). See PLAN.md 3.5 for why NFS
|
||||||
can't be driven from PHP directly.
|
can't be driven from PHP directly.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
On the backlog; none of these block the beta or 1.0.0, but know them before you rely on it:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **One location, one server.** Never let two Nextcloud servers write to the same location (for
|
||||||
|
example a disaster recovery test server pointed at the production location): they can break each
|
||||||
|
other's catalog, and the location is then refused as possibly tampered with. Test a disaster
|
||||||
|
recovery against a copy of the location, or only read from it.
|
||||||
|
- **Removing a passphrase slot does not revoke access.** The master key stays the same, so someone
|
||||||
|
with an old recovery kit and that slot's passphrase can still decrypt the location if they can
|
||||||
|
read it. Key rotation is not available yet.
|
||||||
|
- **Rollback before a disaster recovery is not detected.** A fresh server has no record of the
|
||||||
|
newest catalog generation, so a location rolled back to an older state looks valid there.
|
||||||
|
- Location passwords passed with `-o password=...` are visible in the process list and shell
|
||||||
|
history while the command runs.
|
||||||
|
- Temporary files of a restore into a user's files end up in that user's trash.
|
||||||
|
- Restoring the database does not order tables by foreign keys; apps with cascading foreign keys
|
||||||
|
could lose rows (Nextcloud core does not use them).
|
||||||
|
- A pack whose index upload failed stays on the location until removed by hand.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|
|||||||
+14
-6
@@ -4,17 +4,25 @@
|
|||||||
<id>ng_backup</id>
|
<id>ng_backup</id>
|
||||||
<name>NG Backup</name>
|
<name>NG Backup</name>
|
||||||
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
||||||
<description><![CDATA[Alpha: feature-complete for scheduled, encrypted, incremental backups to
|
<description><![CDATA[Beta: scheduled, encrypted, incremental backups of the database, config and data
|
||||||
S3/SFTP/WebDAV/local, with restore of files and folders from the web interface and occ. Meant for
|
directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files and folders, of
|
||||||
testing on a copy of production (dev, test, staging) -- not yet for production use, and full
|
single users (via user_migration) and of the whole server onto a fresh installation. Tested
|
||||||
disaster recovery is not implemented yet. See the recovery kit section of the README before
|
thoroughly and usable alongside your current backup, but not yet a drop-in replacement for it.
|
||||||
relying on this for anything.]]></description>
|
See the README for known limitations and the recovery kit before relying on it.]]></description>
|
||||||
<version>0.1.0</version>
|
<version>0.9.1</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>
|
||||||
|
|||||||
+5
-3
@@ -14,7 +14,8 @@ occ backup:key:confirm -u <your-admin-uid> --code=<code shown> --confirm="Yes, I
|
|||||||
|
|
||||||
- The passphrase protects the backup. Keep it somewhere other than this server.
|
- The passphrase protects the backup. Keep it somewhere other than this server.
|
||||||
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
|
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
|
||||||
Any one passphrase is enough to restore.
|
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
|
- 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
|
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`. Without the kit and a passphrase, the backup cannot be read.
|
||||||
@@ -31,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -117,7 +118,8 @@ occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
|
|||||||
## 7. Disaster recovery: restore the whole server
|
## 7. Disaster recovery: restore the whole server
|
||||||
|
|
||||||
Use a fresh Nextcloud installation of the same major version, with NG Backup installed and
|
Use a fresh Nextcloud installation of the same major version, with NG Backup installed and
|
||||||
the same location available:
|
the same location available. For a test, use a **copy** of the location: two servers writing to one
|
||||||
|
location break each other's catalog.
|
||||||
|
|
||||||
```
|
```
|
||||||
occ backup:key:import /path/ng_backup-kit.json --passphrase-file=/path/passphrase
|
occ backup:key:import /path/ng_backup-kit.json --passphrase-file=/path/passphrase
|
||||||
|
|||||||
@@ -155,12 +155,16 @@ final class StorageBackend implements IBackend {
|
|||||||
closedir($dh);
|
closedir($dh);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bytes left to read in a seekable $stream, or null when it cannot tell. @param resource $stream */
|
/**
|
||||||
|
* Bytes left to read in a local $stream (php://temp, php://memory or a plain file), or null:
|
||||||
|
* stream wrappers of remote storages do not report a reliable size.
|
||||||
|
*/
|
||||||
public static function remaining(mixed $stream): ?int {
|
public static function remaining(mixed $stream): ?int {
|
||||||
if (!is_resource($stream)) {
|
if (!is_resource($stream)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!(stream_get_meta_data($stream)['seekable'] ?? false)) {
|
$meta = stream_get_meta_data($stream);
|
||||||
|
if (!($meta['seekable'] ?? false) || !in_array($meta['wrapper_type'] ?? '', ['PHP', 'plainfile'], true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$stat = fstat($stream);
|
$stat = fstat($stream);
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ final class BackupJob extends TimedJob {
|
|||||||
$this->startScheduled();
|
$this->startScheduled();
|
||||||
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
||||||
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
||||||
$before = $run->getUpdatedAt();
|
$next = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
if ($next === $run) {
|
||||||
if ($run->getUpdatedAt() === $before && $run->getStatus() === 'running') {
|
break; // step() hands back the same object when another process holds the run's lease
|
||||||
break; // another process holds this run's lease: leave it to that process
|
|
||||||
}
|
}
|
||||||
|
$run = $next;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->failStaleRestores();
|
$this->failStaleRestores();
|
||||||
|
|||||||
@@ -47,8 +47,14 @@ final class KeyKit extends Command {
|
|||||||
} finally {
|
} finally {
|
||||||
umask($old);
|
umask($old);
|
||||||
}
|
}
|
||||||
if ($fh === false || fwrite($fh, $json) !== strlen($json) || !fclose($fh)) {
|
if ($fh === false) {
|
||||||
$output->writeln("<error>Cannot write $file (it must not exist yet)</error>");
|
$output->writeln("<error>Cannot create $file (it must not exist yet)</error>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$ok = fwrite($fh, $json) === strlen($json);
|
||||||
|
if (!fclose($fh) || !$ok) {
|
||||||
|
@unlink($file);
|
||||||
|
$output->writeln("<error>Cannot write $file</error>");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
|
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ final class Repository {
|
|||||||
// either the old state or the new one plus unreferenced leftovers, never a catalog entry
|
// either the old state or the new one plus unreferenced leftovers, never a catalog entry
|
||||||
// pointing at a missing object. The snapshot object is bound to its id (not its path),
|
// pointing at a missing object. The snapshot object is bound to its id (not its path),
|
||||||
// so it can be copied as raw bytes.
|
// so it can be copied as raw bytes.
|
||||||
$this->backend->put('trash/snapshots/' . $snapshotId, $this->backend->get('snapshots/' . $snapshotId));
|
$this->copyRaw('snapshots/' . $snapshotId, 'trash/snapshots/' . $snapshotId);
|
||||||
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
|
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
|
||||||
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
|
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
|
||||||
unset($this->catalog->snapshots[$snapshotId]);
|
unset($this->catalog->snapshots[$snapshotId]);
|
||||||
@@ -405,7 +405,7 @@ final class Repository {
|
|||||||
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
|
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
|
||||||
}
|
}
|
||||||
// Same order as forget(): copy, switch the catalog, then delete the trash copies.
|
// Same order as forget(): copy, switch the catalog, then delete the trash copies.
|
||||||
$this->backend->put('snapshots/' . $snapshotId, $this->backend->get('trash/snapshots/' . $snapshotId));
|
$this->copyRaw('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId);
|
||||||
$this->catalog->snapshots[$snapshotId] = true;
|
$this->catalog->snapshots[$snapshotId] = true;
|
||||||
unset($this->catalog->trash[$snapshotId]);
|
unset($this->catalog->trash[$snapshotId]);
|
||||||
$this->catalog->write();
|
$this->catalog->write();
|
||||||
@@ -416,6 +416,25 @@ final class Repository {
|
|||||||
$this->backend->delete('trash/info/' . $snapshotId);
|
$this->backend->delete('trash/info/' . $snapshotId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Copy an object as raw bytes, buffered locally (remote read streams cannot be uploaded directly everywhere). */
|
||||||
|
private function copyRaw(string $from, string $to): void {
|
||||||
|
$in = $this->backend->get($from);
|
||||||
|
$buffer = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
||||||
|
if ($buffer === false) {
|
||||||
|
fclose($in);
|
||||||
|
throw new RepositoryException("Cannot buffer $from");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (stream_copy_to_stream($in, $buffer) === false) {
|
||||||
|
throw new RepositoryException("Cannot read $from");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($in);
|
||||||
|
}
|
||||||
|
rewind($buffer);
|
||||||
|
$this->backend->put($to, $buffer);
|
||||||
|
}
|
||||||
|
|
||||||
/** Record a user_migration export's manifest in the catalog (rollback-protected like a snapshot). */
|
/** Record a user_migration export's manifest in the catalog (rollback-protected like a snapshot). */
|
||||||
public function recordUserExport(string $manifestPath): void {
|
public function recordUserExport(string $manifestPath): void {
|
||||||
$this->catalog->userExports[$manifestPath] = true;
|
$this->catalog->userExports[$manifestPath] = true;
|
||||||
|
|||||||
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 |
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ run_on ngb-frt-restore backup:target:add offsite local -a null::null -o datadir=
|
|||||||
run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location"
|
run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location"
|
||||||
ok "recovery kit imported, existing repository adopted, snapshot visible"
|
ok "recovery kit imported, existing repository adopted, snapshot visible"
|
||||||
|
|
||||||
run_on ngb-frt-restore backup:restore:full offsite "$SNAP" >/dev/null || fail "restore:full"
|
run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction >/dev/null || fail "restore:full"
|
||||||
ok "backup:restore:full completed"
|
ok "backup:restore:full completed"
|
||||||
|
|
||||||
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since
|
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since
|
||||||
|
|||||||
Reference in New Issue
Block a user