Compare commits

...

247 Commits

Author SHA1 Message Date
dillard 8e06d05cf4 Roadmap 1.2.0: restore only the database from a restore point, with occ
CI / unit (8.2) (push) Waiting to run
CI / unit (8.3) (push) Waiting to run
CI / unit (8.4) (push) Waiting to run
CI / psalm (push) Waiting to run
CI / frontend (push) Waiting to run
CI / integration (mariadb, 32) (push) Waiting to run
CI / integration (mariadb, 33) (push) Waiting to run
CI / integration (mariadb, 34) (push) Waiting to run
CI / integration (mariadb, 35) (push) Waiting to run
CI / integration (pgsql, 32) (push) Waiting to run
CI / integration (pgsql, 33) (push) Waiting to run
CI / integration (pgsql, 34) (push) Waiting to run
CI / integration (pgsql, 35) (push) Waiting to run
CI / integration (sqlite, 32) (push) Waiting to run
CI / integration (sqlite, 33) (push) Waiting to run
CI / integration (sqlite, 34) (push) Waiting to run
CI / integration (sqlite, 35) (push) Waiting to run
CI / integration-targets (push) Waiting to run
CI / integration-user (32) (push) Waiting to run
CI / integration-user (33) (push) Waiting to run
CI / integration-user (34) (push) Waiting to run
CI / integration-user (35) (push) Waiting to run
CI / integration-full-restore (push) Waiting to run
2026-11-10 09:39:12 +01:00
Dillard Blom c6fdaad487 Merge pull request #31 from dillardblom/1.1.0
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 / frontend (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.1.0
2026-11-09 17:44:23 +01:00
dillard 6ae1b2277f Version 1.1.0: release date
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 / frontend (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-11-09 17:02:30 +01:00
dillard 2a72acb70b Review fixes: the derived key is wiped also when a key is damaged, each tidy-up step after a removal on its own
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 / frontend (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
- The key derived from the passphrase is wiped on every path, also when the key or nonce turns out damaged
- After a removal the pending flag and the cached index are tidied up independently; a failing log cannot stop the removal event
- Tests: a short salt or nonce gives the damaged-key error
2026-11-09 02:33:50 +01:00
dillard fb8e8d0694 Review fixes: a malformed key reads as damaged, removing a location always reports it, a key from before 1.1.0 in the recovery guide
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 / frontend (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
- Invalid base64 or a wrong salt/nonce length in a key gives the damaged-key error, so the recovery options are shown
- Tidying up after a removed location cannot fail the removal anymore, so the removal event is always sent
- Recovery guide: a key made before 1.1.0 counts as used and cannot make way
2026-11-09 00:55:20 +01:00
dillard 042fdae686 Review fixes: the longest deletion delay when the location's settings cannot be read, a damaged passphrase slot does not stop the next one
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 / frontend (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
- Unreadable settings or an invalid delay on the location: 365 days, so nothing leaves the Bin sooner than on the old server
- A passphrase change that still cannot reach a location is logged; removing a location clears its flag
- A damaged slot (missing parts, invalid base64) is skipped and the next slot is tried
- Tests: damaged settings scenario, damaged slot, upgrade test checks the migration and the time zone
2026-11-08 18:29:31 +01:00
dillard 99f1655d04 Review fixes: a missed passphrase change follows at the next backup, a key from before 1.1.0 counts as used, schedules edited in their own time zone
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 / frontend (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
- A location that a passphrase change could not reach gets it at its next backup
- A key from before 1.1.0 is never replaced as an unused one
- An unreadable settings file on a location is told apart from a missing one
- A schedule is shown and saved in the zone it was saved in
- Saving an unchanged schedule keeps its moment; --timezone needs a schedule
- Schedule coverage uses the time the backup began reading its data
- Tests: catch-up scenario, upgrade test restores the new file, autumn clock change
2026-11-08 17:18:13 +01:00
dillard 373d7e0424 Screenshots of 1.1.0: a schedule per location, a pinned restore point with a note, passphrase slots, the Bin
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 / frontend (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-11-08 16:12:07 +01:00
dillard c5024d3ddc Version 1.1.0: changelog, README status and roadmap 2026-11-08 13:02:28 +01:00
dillard 345b1d545e Keep the list of backups within the page: long text wraps instead of scrolling 2026-11-08 12:28:23 +01:00
dillard 98067f7746 Roadmap: what 1.1.0 brings, parallel uploads in 1.2.0 2026-11-08 12:11:15 +01:00
dillard cd951af1a1 Test the update from a released version on a server that was backing up 2026-11-08 11:58:36 +01:00
dillard cf9cf50286 Replace a backup key that was never used by the key of existing backups
A key made by mistake on a server that is being restored left no way back: key:import and the
passphrase route refused because a key existed, and nothing removed it. As long as the key never
protected anything (no location created or adopted with it, no location or run in the database),
occ backup:key:import --replace-unused-key and occ backup:target:add --replace-unused-key replace
it, after the new key was opened. A key that was used is never replaced, also after its location
was removed. The refusal names the option, and all administrators are notified of a replacement.
2026-11-08 11:53:55 +01:00
dillard 2b21596b3a Plan: speed measurements, parallel uploads planned for 1.2.0 2026-11-08 11:37:13 +01:00
dillard 953312bf44 Read files for a backup in pieces of 4 MiB instead of 8 KiB
PHP reads a file 8 KiB at a time by default. On a disk where every
request waits, such as a network or cloud volume, that made reading five
times slower: on the test server 5 MiB/s instead of 27 MiB/s. Without
PHP's read buffer every read of a blob is one request to the disk. The
same for packs on a local location.
2026-11-07 12:44:57 +01:00
dillard 569077fa69 Restore without the recovery kit: the key from the location, with a passphrase
On a server without a backup key, backup:target:add finds the backups at
the location and takes their key from its config with one of the old
server's passphrases. Backups then wait until a new recovery kit is
confirmed. The deletion delay and the repository id are now stored on the
location, sealed with the key, so the delay survives a restore without a
kit and --repair-config can write a lost config again from the kit.

The key page warns against creating a new key when restoring a server.
New test: tests/integration/recovery-scenarios.sh, and a no-kit mode in
full-restore-roundtrip.sh.
2026-11-07 12:22:38 +01:00
dillard 51a4a67bc4 Heavy background jobs of other apps pause while a backup runs
Reserved in Nextcloud's job list, so no app is turned off and a job that is running goes on.
At most 6 hours in a row, then they get an hour; Nextcloud ends a reservation by itself after
12 hours, should the end of a pause never come. On by default, with a setting to turn it off.
2026-11-07 01:56:44 +01:00
dillard a8b5e85c9b All folders get their original modification time back on a restore
The backup records the time of the folders with something in them, packed in the meta record,
so an older version still reads the snapshot. A restore through Nextcloud leaves a folder that
was already there alone. The round trip checks what Nextcloud shows, not only the disk.
2026-11-07 01:07:09 +01:00
dillard 36f2427e9a Tell administrators about a location without a schedule
A notification when a schedule is turned off, then a reminder: once a week while another
location is backed up, every day when none is. The round trip now also checks the text of
these notifications and of the one about names, and that one location never gets two runs.
2026-11-07 00:23:28 +01:00
dillard e58b86fc48 A schedule per location, and locations backed up one after the other
Off, once a day or every N hours from a time, each in its own time zone. Existing locations
keep the time that held for all of them. A backup that has to wait shows as queued, and its
restore point has the time it really began.
2026-11-07 00:04:00 +01:00
dillard 193f65940b Pin a restore point and keep a note on it
Both live in the catalog on the location, so a new server sees them; the database holds
a local copy for the lists. The retention policy leaves a pinned restore point alone and
does not count it. Available on the restore page and as occ backup:pin and backup:note.
2026-11-06 23:49:50 +01:00
dillard 6540a9314a A name a snapshot cannot hold gets a notification of its own
Reported apart from unreadable files, with example paths and the advice to rename
them, instead of advice about permissions. Start of the 1.1.0 plan in PLAN.md.
2026-11-06 23:38:40 +01:00
Dillard Blom f81c5e13e9 Merge pull request #30 from dillardblom/1.0.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 / frontend (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.3
2026-11-06 20:29:09 +01:00
dillard 1b621b7149 Backup state stays valid JSON with a name that is not UTF-8; Bin reads on from an id, not a position 2026-11-06 20:02:09 +01:00
dillard 1736005648 Review of 1.0.3: names a snapshot cannot hold, restore-directory errors under salvage, browse speed
- Leave out and report a file or folder whose name a snapshot cannot hold
- A staging write failure stops a full restore under --salvage
- Browse reuses its repository; empty folders read along with the entries
- Keep leases alive while empty folders are created; exact-prefix empty folder is the target
- Clean up tables that were partly created or before the transaction started
- Bin: no empty message after a read error; longer pages
2026-11-06 19:44:52 +01:00
dillard c7a651f269 Version 1.0.3: changelog, README status and roadmap 2026-11-06 19:05:54 +01:00
dillard 8897d8074f Restore through Nextcloud: also a restored folder that is itself empty gets its time back 2026-11-06 18:40:10 +01:00
dillard 0b9fa1ae92 Restore through Nextcloud: empty folders get their time back 2026-11-06 18:36:15 +01:00
dillard ee406c481e Restore: go on after a partial write; created tables stay once the database is committed 2026-11-06 18:27:35 +01:00
dillard dd2e1af997 Roadmap: original modification time for all folders; guide: folders reported as updated 2026-11-06 18:08:34 +01:00
dillard d96229f370 Release the locks of a killed restore when it runs again; legacy test without empty folders
A killed restore left its leases for up to two hours, so the first backup of the recovered
server was refused. A backup from before 1.0.3 records no empty folders: the test now expects that.
2026-11-06 17:34:33 +01:00
dillard b14b16c66c Empty folders in backup and restore, a full disk stops a restore, progress in the Bin
- Record folders without files in the meta record of a snapshot and restore them (#27)
- Errors writing to the restore directory stop the restore, also with --salvage
- Resume: check the folder before skipping, clean temporaries in folders that are complete
- Clean only read-ahead folders of our own; refresh the lease on skipped files
- Drop tables a failed database restore created; keep --salvage from calling a restored database fresh
- Retry a pack that fails to open on the storage (SMB)
- Without cron, running backups take turns from the open page
- Bin: read in parts with a counter and progress bar, show read errors, refresh when a run ends
2026-11-06 15:50:02 +01:00
dillard ce40053cec Merge remote-tracking branch 'github/main' into 1.0.3 2026-11-06 12:23:09 +01:00
Dillard Blom 46e0186f35 Merge pull request #29 from dillardblom/polish
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 / frontend (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
Docs: --salvage after a stopped restore; a full disk during a restore
2026-11-06 12:22:58 +01:00
dillard d5eb7abbff Guide: after a full disk, run the restore again as in step 8
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 / frontend (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-11-06 12:02:38 +01:00
dillard 4c973d5cd8 Docs: a full disk during a restore looks like a damaged backup, to be fixed in 1.0.3 2026-11-06 12:00:52 +01:00
dillard e9592cb6c6 PLAN: findings from the reviews of 1.0.2, for 1.0.3 2026-11-06 11:57:08 +01:00
dillard b86b07a49c Disaster recovery guide: --salvage after a restore that stopped halfway 2026-11-06 11:56:10 +01:00
dillard ecae6e9cdd PLAN: 1.0.2 released; a branch per version
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 / frontend (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-11-06 11:29:57 +01:00
Dillard Blom d36c40e77b Merge pull request #28 from dillardblom/release-1.0.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 / frontend (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.2
2026-11-06 11:29:14 +01:00
dillard 50c2636926 Version 1.0.2: changelog, README status and roadmap
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 / frontend (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-11-06 11:03:14 +01:00
dillard d1e9a53ce5 App store text: the line asking for a star in bold
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 / frontend (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-11-06 10:42:16 +01:00
dillard b625fe7666 A restore that stops at a file gone from the location points at --salvage; test a full restore with a damaged and a missing pack
The DR test of 1.0.2 over SMB is done: result in PLAN, empty folders as a known issue (#27).
2026-11-06 10:27:41 +01:00
dillard 74bfa7052f PLAN: DR test 1.0.2 over SMB, findings so far
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 / frontend (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-11-05 08:35:46 +01:00
dillard bf48d339e4 Remove the read-ahead folder of a killed restore with what it kept; changelog and DR guide: a stopped full restore goes on where it was
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 / frontend (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-11-05 08:35:28 +01:00
dillard 029b3b9cc7 Full restore goes on where it stopped; SMB recovers from a failed read
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 / frontend (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
The DR test of 1.0.2 over SMB stopped after 392 GiB of 983: the connection to the Storage Box
dropped, and the smbclient extension then returns a string of length -1 for a failed read (it
stores smbc_read()'s -1 in an unsigned length). The read loop counted that as data and went
backwards forever at 100% CPU. Reads over SMB are now checked; a failed one is read again the
usual way over a new connection.

backup:restore:full that runs again (--force) leaves the data files already in place with the
snapshot's size and modification time, as a backup takes them as unchanged, and reads ahead only
what is still missing: after 392 GiB it goes on instead of starting over.
2026-11-05 08:18:50 +01:00
dillard dce93aa75e Over SMB, read index files in fewer round trips
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 / frontend (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
files_external's fopen() first stats the file and icewind's stream reads 1 MiB at a time. Open,
read and close straight through the native extension's state instead: from Canada to a Hetzner
Storage Box (130 ms) 0.66 instead of 1.05 s per index file, about 5 instead of 8 hours for the
27,000 index files of a 1 TB repository in backup:target:add. Anything unexpected falls back to
the usual way.
2026-11-04 20:18:46 +01:00
dillard 3ec23a3072 PLAN: DR test for 1.0.2 over SMB; screenshots approved
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 / frontend (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-11-04 13:28:04 +01:00
dillard 4a395c75f2 PLAN: 1.0.2 status, DR test planned before the release 2026-11-04 04:22:40 +01:00
dillard 36a30732ad Without system cron: warning and a backup that moves on while the page is open (#13); progress of the database phase; Bin tab
- The admin page and backup:status warn when background jobs do not run on a
  system cron, or the cron stopped, with a link to how to set one up.
- Without a working system cron, the open admin page moves a running backup
  forward through /api/tick, a short step per poll.
- The database phase shows the tables dumped ("db: 47/183 tables"); a backup
  that has not moved for 15 minutes says it is waiting for background jobs.
- The trash of restore points is on a tab of its own, Bin.
- New screenshots; changelog and plan for 1.0.2.
2026-11-04 04:08:20 +01:00
dillard d7c7ca093b target:add: estimate the time left only once enough of the index is in
The first index file also carries the time to connect, which made an S3
location say "about 642 min left" for something done within a minute.
2026-11-04 04:08:20 +01:00
dillard 05d02f3c70 Faster full restores from S3 and SMB
S3: the packs are read ahead, eight ranges at a time, straight into files in
the spool. SMB: ranges are read in 8 MiB reads through libsmbclient instead
of icewind's 1 MiB reads; over a line with a 130 ms round trip, reading was
four times faster in a test (18 instead of 4.5 MiB/s).
2026-11-04 04:08:20 +01:00
dillard dfe1fe9aad --salvage for backup:restore and backup:restore:full: restore what can be read from a damaged backup
A file whose data cannot be read or decrypted is left out and listed, and
the command exits with code 2. When the database cannot be restored (its
dump is damaged, or it does not fit this installation), backup:restore:full
leaves the fresh database as it is and puts the data directory and config of
the backup in ngb-salvage-<snapshot> in the data directory. Without --salvage
a restore of damaged data stops as before and points at the option.
2026-11-04 04:08:20 +01:00
dillard f0293e6d01 Roadmap 1.0.2: the trash of restore points on a tab of its own (Bin)
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 / frontend (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-11-01 17:13:50 +01:00
dillard f7c9894b78 PLAN: 1.0.1 released; new screenshots in 1.0.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 / frontend (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-11-01 12:59:49 +01:00
Dillard Blom be3746e1c3 Merge pull request #26 from dillardblom/release-1.0.1
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 / frontend (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.1
2026-11-01 12:58:34 +01:00
dillard f5473495ce App description and README: ask for a star in the app store and on GitHub
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 / frontend (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-11-01 12:08:01 +01:00
dillard 3694184674 Version 1.0.1: changelog, README status and roadmap
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 / frontend (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-11-01 12:03:19 +01:00
dillard a0fc91a700 Integration tests: grep the whole occ output instead of grep -q, which stopped reading and failed the pipe under pipefail
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 / frontend (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-31 23:13:27 +01:00
dillard a11e6c7254 1.0.1: label for "Back up now", retention explained in the trash, occ times in the schedule's zone, single-file restore to a directory, leftovers of a stopped restore removed
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 / frontend (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
- Admin page: an optional label (at most 100 characters) for a backup started with "Back up now";
  an (i) by "removed by retention policy" in the trash that explains what it means and until when
  the restore point can be brought back. backup:trash says the same below its list.
- backup:status, backup:list, backup:trash and backup:browse show times in the zone of the daily
  schedule and name it, instead of the server's UTC.
- backup:restore --to-directory of a file restored nothing: the path got a "/" appended.
- A restore removes "<name>.ngb-restore-<hex>" temporaries that a stopped restore left in the
  folders it writes to.
- Roadmap: the cron warning (#13), reading ahead from S3/SMB and --salvage move to 1.0.2.
2026-10-30 19:32:53 +01:00
dillard 6f1a37f4f4 Changelog: release candidate headings carry their app version, so the app store no longer shows all of them under 1.0.0 2026-10-29 13:07:25 +01:00
dillard 5cde57bc1b Changelog: 1.0.0 says what the app is instead of release bookkeeping; rc.6 drops its outdated outlook
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 / frontend (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-29 12:42:44 +01:00
dillard 3eec2e9554 Full restore on SQLite: take the write lock first, so another writer is waited for instead of "database is locked"
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 / frontend (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
SQLite in WAL mode starts a transaction as a reader; a write from another
process between our first read and our first write makes our first write
fail at once, whatever the busy timeout. The SQLite full-restore test now
runs a second writer alongside the restore.
2026-10-29 03:06:05 +01:00
dillard cdf0b70e25 Changelog: the admin page's single-line key field as the cause of #24; PLAN 1.0.1: #24, database locked at the start of a full restore on SQLite
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 / frontend (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-29 02:40:03 +01:00
dillard 66615c2a88 Merge branch 'main' into polish
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 / frontend (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
# Conflicts:
#	CHANGELOG.md
2026-10-29 02:27:01 +01:00
Dillard Blom cc75c7128c Merge pull request #25 from dillardblom/release-1.0.0
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
2026-10-29 02:26:04 +01:00
dillard a0c4abb60a Version 1.0.0: same code as 1.0.0-rc.6; changelog, README status
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-29 01:32:12 +01:00
dillard 4847ce293d SFTP: restore the line breaks of an OpenSSH private key before use (#24)
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 / frontend (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
Nextcloud 35 reads keys with phpseclib 3, which refuses an OpenSSH-format
key whose line breaks were lost ("Unable to read key"); phpseclib 2 on
Nextcloud 34 read it. The key is fixed when used, so stored locations work
again without re-entering it.
2026-10-28 01:41:58 +01:00
dillard 90dd97977a CI: build the admin page on every push 2026-10-27 11:34:53 +01:00
dillard 08534975d7 Admin page: Docs tab with links to the how-to, disaster recovery guide and roadmap; drop "in beta" from the feedback line
PLAN: 1.0.1 item 11 (Docs tab, done); 1.1.0 item 9: remove the feedback request in 1.1.0 or 1.2.0.
2026-10-27 11:23:34 +01:00
dillard 95e3dfcf6d User restore by user id, backup:user:list (#22)
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
backup:user:restore <location> <uid> only said "is not a recorded user export": the argument
is the manifest path, which only backup:user:backup printed once. backup:user:list shows the
exports again; a bare user id works when that user has exactly one export, otherwise the error
lists them. The export is resolved before the replace confirmation.

Fixes #22
2026-10-27 00:10:59 +01:00
dillard 7f4cae0e42 Roadmap: pin a restore point (1.1.0); PLAN 1.0.1: explain retention in the trash, time zone in backup:status
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-25 02:40:13 +02:00
Dillard Blom 965440d1f7 Merge pull request #20 from dillardblom/rc6
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.6
2026-10-24 01:04:23 +02:00
dillard 3a0f882a38 1.0.0-rc.6: changelog, SMB disaster recovery result in the README, 1.0.1 items (salvage mode, leftover restore files, single-file restore)
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-24 00:40:51 +02:00
dillard 632d2a2838 Admin page: widen the sections through their width; NcSettingsSection sets width, not max-width
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-23 18:10:48 +02:00
dillard 9e9234e2a8 Admin page: sections up to 1400px wide, so the runs table fits without scrolling
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-23 13:29:47 +02:00
dillard 3ac3298313 Database restore: close the pack stream of a blob read again before the next range 2026-10-22 20:30:18 +02:00
dillard 384b2d2792 Full-restore test: the emoji deck board sets archived as a boolean (PostgreSQL refuses 0) 2026-10-22 20:09:58 +02:00
dillard 8032411789 Restore: the warning for a blob read again says how the first read differed (zeros, shifted, other bytes) 2026-10-22 19:50:27 +02:00
dillard b4f20cb070 Version 0.9.12 (1.0.0-rc.6) 2026-10-22 19:48:57 +02:00
dillard 343389b98b Changelog: the second read covers the database phase too 2026-10-22 19:48:22 +02:00
dillard 133f5f9d34 Schedule: say why a server zone of UTC is passed over 2026-10-22 19:47:30 +02:00
dillard cf4bd9db7f Review fixes: the database restore also reads a damaged blob once more; schedule conversion right on days the clocks change; the field follows a changed schedule time zone 2026-10-22 19:45:49 +02:00
dillard 67cc350b8c Daily backup time in the administrator's time zone (#19); a time saved before is converted once so it shows as it ran 2026-10-22 16:38:54 +02:00
dillard fb5b234e8a Restore: a blob that does not decrypt as first read is read once more on a request of its own before the restore gives up 2026-10-22 14:43:48 +02:00
dillard fd12c586b6 Roadmap: restore mode for hosted Nextcloud, read-ahead for S3 and SMB; changelog for rc6 2026-10-22 13:07:09 +02:00
dillard 6ffe979ea7 Merge branch 'polish' into rc6 2026-10-22 13:06:59 +02:00
dillard a03b7354b3 README: tested disaster recoveries, backend measurements, restores without shell access; guide lists shell access; changelog for rc6 2026-10-22 13:06:49 +02:00
dillard be1c628f07 Full-restore test: install Mail and Deck with up to three tries (the app store is slow at times) 2026-10-22 03:38:30 +02:00
dillard a8355df12e Full restore: tables the restore creates get this installation's charset and collation (utf8mb4 on MySQL/MariaDB); test with an emoji 2026-10-22 03:36:46 +02:00
Dillard Blom f0537f0f2c Merge pull request #18 from dillardblom/rc5
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.5
2026-10-22 02:28:05 +02:00
dillard fd0f786f5f Full restore: a run again after an interrupted restore keeps maintenance mode on when its check refuses; retry text about apps 2026-10-22 01:59:01 +02:00
dillard 2eabdf44d5 README and store description: background jobs must run on system cron, with how to set it up; rc5 date 2026-10-22 01:03:52 +02:00
dillard 61e2a28b67 Full restore: maintenance mode on before the checks (off again when they refuse); the printed retry command is complete 2026-10-22 00:53:08 +02:00
dillard 7ed53ad0eb Full restore: after an error maintenance mode stays on, with the one line to switch it off and run again; maintenance that was on before stays on 2026-10-22 00:36:58 +02:00
dillard bdcb4f0cae Full restore runs in maintenance mode, switched off again at the end (also after an error) 2026-10-22 00:31:34 +02:00
dillard 2616b6e543 Init guard also counts snapshots in the trash and the catalog as backup data 2026-10-21 22:42:43 +02:00
dillard 9bc4e4a4ab Never create a repository where backup data lies but no config could be read 2026-10-21 22:25:48 +02:00
dillard 827e46381e Log a snapshot that cannot be adopted when a location is added; full-restore test prints what target:add and backup:list said when the snapshot is missing 2026-10-21 22:08:14 +02:00
dillard 8eb6c4c033 Index: check known index files spread over the list 2026-10-21 20:56:45 +02:00
dillard d623416306 Index: an empty listing is taken only when known index files are gone too 2026-10-21 20:49:32 +02:00
dillard cd251cffb3 PLAN: SMB index reads one at a time (1.1.0) 2026-10-21 20:02:43 +02:00
dillard 0b4872599d PLAN: the 1.0.1 cron PR closes #13 2026-10-21 19:51:57 +02:00
dillard 95184dadc7 Index: an empty listing is checked (config present, listed once more) before it is taken, so a repository another server emptied is accepted and a hiccup recovers 2026-10-21 19:51:46 +02:00
dillard 9b6bd7d2ab Release 1.0.0-rc.5 (app version 0.9.11) 2026-10-21 13:10:03 +02:00
dillard 2fcd38f478 Full-restore test: Mail and Deck checks only when the origin had them (not on a SQLite origin) 2026-10-21 13:09:54 +02:00
dillard b7d09c9784 PLAN: 1.1.0 faster SMB (local and long-line measurements) 2026-10-21 09:27:15 +02:00
dillard 177361acae Index load keeps the index when it refuses an empty listing; counters of unchanged tables are repaired too, never moved backwards 2026-10-21 09:08:50 +02:00
dillard 24686897c3 PLAN: DR tests across databases (integration for every crossing; S3 sim onto MariaDB, SMB sim onto SQLite before 1.0.0; nc01 onto MariaDB after) 2026-10-20 23:12:19 +02:00
dillard 85ea0acc50 Full-restore test: restore onto another database (to-pgsql, to-mysql, to-sqlite), every crossing in CI 2026-10-20 23:10:25 +02:00
dillard ac57e31c93 Full restore onto PostgreSQL: counters of restored tables go after their data when the backup records none (MySQL, SQLite); test MariaDB onto PostgreSQL 2026-10-20 23:04:53 +02:00
dillard 315e83b847 PLAN: 1.0.1 progress in the database phase and a waiting notice, so a backup does not look stuck 2026-10-20 20:49:44 +02:00
dillard e461c48056 CI: back to Nextcloud 35 for the full-restore test (the failures were a slow app store, not the version) 2026-10-19 22:18:33 +02:00
dillard d504067cd8 Index: an empty listing is an error in checks and cleanups too; refresh() fetches only index files it has not loaded; full-restore CI on Nextcloud 35.0.0 2026-10-19 22:13:26 +02:00
dillard c37b26ea38 Full restore: an empty listing never empties the index; read a failed pack once more; recognise and replace SFTP connections a Storage Box dropped quietly; version 0.9.11 2026-10-19 21:53:05 +02:00
dillard b728db36c7 README: roadmap 2026-10-19 21:35:23 +02:00
dillard 6ed0a555cf PLAN: label field next to Back up now (1.0.1); per-location schedules, every N hours, locations one after the other, editable notes (1.1.0) 2026-10-19 21:26:20 +02:00
dillard 221ed9dc17 PLAN: the cron warning says cron is needed for all backups, links to how to set it up, and that without it a backup started with the button only moves while the page is open 2026-10-19 21:18:23 +02:00
dillard 285153a968 PLAN: 1.0.1 cron warning says to keep the page open under AJAX, with a link to the Nextcloud docs 2026-10-19 21:16:23 +02:00
dillard 6c8a78f6bb PLAN: 1.0.1 background jobs without system cron: warn, and let the open admin page move a backup 2026-10-19 20:41:11 +02:00
Dillard Blom 0accd5a3f2 Merge pull request #16 from dillardblom/rc4
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.4
2026-10-19 11:04:15 +02:00
dillard 583592f69b Release 1.0.0-rc.4 (app version 0.9.10) 2026-10-19 10:34:01 +02:00
dillard a384ab41bb App store description: Ensembia line 2026-10-19 10:31:21 +02:00
dillard 9b85221e50 Read ahead: a plan that cannot be made leaves the restore to the usual reads; only a whole buffered SFTP packet counts as an answer waiting 2026-10-19 08:54:13 +02:00
dillard c95f552cc6 Read ahead over SFTP: one connection with 32 requests in flight by default, more with restore_sftp_connections; a Storage Box gets slower with parallel readers 2026-10-19 07:46:12 +02:00
dillard 6121f684e9 Full restore: say to run occ upgrade before rerunning a restore that stopped after the database; remove empty spool folders of a stopped restore 2026-10-19 05:34:54 +02:00
dillard 43a71684f9 Read ahead over SFTP: take answers phpseclib has read already instead of waiting up to 10 s on the socket 2026-10-19 05:28:26 +02:00
dillard 88b719879d Release candidate 4 (app version 0.9.10): full restore over SFTP reads ahead; SFTP leg in the full-restore test 2026-10-19 03:51:21 +02:00
dillard 1d7f7e6423 Read ahead: resolve paths before opening connections; a failing readAhead() leaves no spool folder 2026-10-19 01:52:58 +02:00
dillard ccbbf01c54 Full restore over SFTP: read packs ahead on four connections at once
A restore read one pack at a time over one SFTP connection, which over a long
line is bound by the round trip and by what one TCP connection gets: about
2-3 MiB/s from a Hetzner Storage Box to Canada, with PHP mostly waiting.

Repository::restore() now first lists the ranges of packs the restore needs,
in order (ReadAheadPlan), and the location reads them ahead into unlinked
files in a spool folder in the target (IReadsAhead). Over SFTP,
SftpRangeFetcher does that on four connections of its own, 16 requests in
flight each, taking the answers of whichever connection has one. A range that
fails, a damaged copy, or a blob that moved since the plan is read the usual
way, which reports what is wrong. Other locations restore as before.

On a test line with 120 ms RTT and 0.2% loss, a 1.6 GiB restore went from
5.1 to 17.8 MiB/s, all files identical.
2026-10-19 01:40:49 +02:00
dillard b789d17636 Screenshot URLs per release tag, so the app store fetches new images 2026-10-18 23:47:32 +02:00
dillard 8e5c8ccda4 Full restore: skip the ID counters of tables left out with --skip-missing-tables; test a backup made by rc.1; version 0.9.9 2026-10-18 23:44:00 +02:00
dillard e0b79939f9 PLAN: 1.0.0 only reliability; 1.0.1 restore speed, cron warning, DR test with table structure 2026-10-18 23:19:18 +02:00
Dillard Blom 39b4fe359b Merge pull request #15 from dillardblom/release-rc2
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.2
2026-10-18 23:16:32 +02:00
dillard 40e147f3a0 Release 1.0.0-rc.2 (app version 0.9.8) 2026-10-18 23:10:02 +02:00
Dillard Blom 7f6e5c15f8 Merge pull request #14 from dillardblom/rc2
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
1.0.0-rc.2: disaster recovery fixes
2026-10-18 23:08:55 +02:00
dillard 11ac30635a target:add: show progress and time left while reading the index of an existing repository 2026-10-18 22:14:45 +02:00
dillard 9e1378c6ab PLAN: DR finding 16, restore during another server's cleanup 2026-10-18 21:57:17 +02:00
dillard 04e91359a0 Restore: follow blobs that another server's cleanup repacked during the restore 2026-10-18 21:56:58 +02:00
dillard 46eaceb928 DR guide step 9 from the test; PLAN: small DR test with rc2 passed 2026-10-18 08:51:00 +02:00
dillard 9eeaefd938 Refuse a full restore when the table prefix differs 2026-10-18 02:39:14 +02:00
dillard 4f6a2e5b70 Check MySQL foreign keys explicitly; keep index prefix lengths and foreign keys of created tables
Switching FOREIGN_KEY_CHECKS back on does not check the rows loaded
meanwhile, and DDL would commit halfway, so on MySQL every foreign key is
checked with a query for rows that point at nothing, before the commit.

The recorded table structure now keeps index options (the prefix lengths
MySQL needs for long text columns) and foreign keys; tables the restore
creates get their foreign keys after the commit.

The full-restore test also runs on MariaDB.
2026-10-18 02:09:00 +02:00
dillard 7b735ea941 DR guide step 7 for rc2; changelog; version 0.9.8 2026-10-18 01:45:39 +02:00
dillard d8ef50732f Full restore creates missing tables and suspends foreign keys; --install-apps; clearer messages
Backups now record each table's structure in the dump manifest. The full
restore creates tables that do not exist on the new instance (left behind
by apps that were removed or are switched off), lists all problems at
once, and no longer stops on apps that are missing. Foreign keys are not
checked while rows are loaded in name order, and checked again before the
restore commits (PostgreSQL: dropped and added back in the transaction).

--install-apps installs or enables the apps that were enabled at backup
time; --skip-missing-tables leaves out tables of older backups whose
structure is unknown. nextcloud-aio is never required.

backup:target:add without a key points to backup:key:import, and says that
connecting can take a while. Removing a location drops its cached index;
a migration removes the cache of locations that are gone.

The full-restore test now also runs on PostgreSQL with Mail (foreign keys)
and a disabled app.
2026-10-18 01:45:01 +02:00
dillard 5af712acc3 README: BBR for backups over a long distance 2026-10-18 01:26:27 +02:00
dillard 01e16199f7 DR guide: use your own container name 2026-10-18 01:15:43 +02:00
dillard 0924da48ac PLAN: upload speed over long distances; DR guide container name 2026-10-18 01:15:34 +02:00
dillard 88477a1a9f PLAN: SFTP restore speed over long distances; foreign keys valid after the restore 2026-10-18 00:59:06 +02:00
dillard f633de59b4 PLAN: foreign keys block the database restore (Mail) 2026-10-17 23:15:13 +02:00
dillard 523620acde DR guide: concrete commands for background jobs, copying the kit and the key import 2026-10-17 21:48:31 +02:00
dillard bbddc83f80 DR guide: background jobs, copying the kit, why not key:init; PLAN: app list and table structure in the backup 2026-10-17 21:38:55 +02:00
dillard 456dffc880 Step-by-step disaster recovery guide 2026-10-17 21:33:15 +02:00
dillard 4d423825a0 PLAN: findings of the DR test, rc2 scope and a second DR test before 1.0.0 2026-10-17 21:29:51 +02:00
dillard 29a56e432b Mention paid restore help by Ensembia; GitHub sponsor link 2026-10-17 17:48:37 +02:00
dillard 7f29d009f3 App store description for 1.0.0; developed and sponsored by Ensembia 2026-10-17 15:52:33 +02:00
Dillard Blom 511c0fc285 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)
2026-10-17 15:04:09 +02:00
dillard 76e8bb0245 Release 1.0.0-rc.1 (app version 0.9.7) 2026-10-17 13:07:41 +02:00
dillard f32568f83c Screenshots of the current layout 2026-10-16 00:57:17 +02:00
dillard ff8619ae6b PLAN: plan towards 1.0.0 and 1.1.0 2026-10-16 00:28:24 +02:00
dillard 629b8dd315 Passphrases of at least 16 characters; explain that the location holds the key too 2026-10-16 00:07:39 +02:00
dillard c7cbc0b058 README: primary object storage and External storage mounts are not backed up 2026-10-15 22:47:45 +02:00
dillard 942e8ec003 Web interface: dates and times in the user's Nextcloud locale 2026-10-15 21:43:09 +02:00
dillard 144097d159 Run the backup job at every cron run; skip deflate on compressed data 2026-10-15 20:59:04 +02:00
dillard 5b7dd08e71 Ask for feedback on the admin page, with an experience report form 2026-10-15 10:32:21 +02:00
dillard 30f93fbb8a README: a backup survives a restart of the containers 2026-10-15 10:20:21 +02:00
dillard ca003c88ba Fast S3 verify and snapshot commit; shorter runs list; durations with days
On S3, index files and the tree segments read back when a snapshot is
written are fetched 16 at a time through the storage's S3 client
(IBackend::getRanges(), getMany()). Blobs that follow each other in a pack
are read as one range on every location. The snapshot is written in a step
of its own and the run shows "commit" meanwhile. On a 1 TB production
repository the check went from about 4 hours to 3.5 minutes.

backup:status and the web interface list the runs of the last 7 days,
running ones and at least the newest 5; --all / "Show older runs" list
all. Durations of a day or more show the days.
2026-10-14 17:12:41 +02:00
Dillard Blom 9a3bb99d12 Merge pull request #11: Release 0.9.6-beta.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 0.9.6-beta.7
2026-10-14 13:20:56 +02:00
dillard c38cc47534 Release 0.9.6-beta.7 2026-10-14 13:14:46 +02:00
dillard dfd3f38d8c Backups to SMB no longer crawl; add occ backup:cancel
Each 30-second step opened the location again and read its list of
index files. Over SMB that cost a request per file, so on a large
repository opening took longer than the step: each step stored one
4 MiB pack and the list grew further. A first backup of nc01 to a
Storage Box over SMB managed 12 GiB in 35 hours.

occ backup:run and system cron now keep the repository open for the
whole run (reopened when another process took a step or the location
was edited), opening no longer counts towards the step's time, and
SMB folders are listed in one request.

occ backup:cancel stops a running backup: no new step starts and the
step in progress finishes first. A run of an earlier version, which
does not hold back its next step, is marked cancelled after two
minutes unless it is recording its snapshot.
2026-10-14 12:15:27 +02:00
dillard 92e693cfe6 Runs list: show the total size of each backup next to what it uploaded 2026-10-13 11:02:18 +02:00
dillard bec070b1b5 Full-restore test: show the output and the log when restore:full fails 2026-10-12 17:15:25 +02:00
Dillard Blom 89a126999f Merge pull request #10: Release 0.9.5-beta.6
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
SFTP: open the channel first, replace dropped connections; run takeover; index cache in one transaction; faster checks with progress
2026-10-12 15:05:59 +02:00
dillard 1b986c9d31 Test: no random_bytes(0) in the batched SFTP read test 2026-10-12 14:59:31 +02:00
dillard 025bbfbb62 Verify: list the pack folders only with more packs than folders
With few packs a request per pack is fewer requests than 256 listings. On
FTP every listing takes a data connection; the FTP server of the integration
tests has ten passive ports, and 256 listings in a row slowed it down until a
read failed ("Not found: snapshots/...").
2026-10-12 14:50:56 +02:00
dillard 8ed7455619 Release 0.9.5-beta.6 2026-10-12 14:27:39 +02:00
dillard 30a250bc11 Stale-backup notification: "No successful backup to … yet" instead of "since never" 2026-10-12 11:32:42 +02:00
dillard 9c06ab2841 SFTP: replace a dropped connection; write the index cache in one transaction
A Hetzner Storage Box drops an SFTP connection that carried a lot of data
and then stayed quiet for a few minutes; files_external keeps using the dead
client, on which every file looks missing. StorageBackend now checks the
connection after a minute without requests, and replaces a connection that
is dead after a failed request (once per request; put, move and delete are
not repeated). SftpReadStream closes a connection it left in an unknown state.

The verify cache refresh took more than 6 minutes on PostgreSQL for 27,547
packs, one autocommitted insert per pack: putMany()/replaceAll() write in one
transaction. After a verify the cache holds the index files as they are, so a
blob in two packs stays cached for both.
2026-10-12 01:58:54 +02:00
dillard d659322e60 Merge branch 'fix/sftp-fresh-connection' into polish 2026-10-11 17:16:33 +02:00
dillard 7726c179f0 SftpReadStream: say why opening failed on the connection 2026-10-11 15:58:20 +02:00
dillard df732f185a Index: refresh the cache after a verify in one pass over the blobs, not one per pack 2026-10-11 14:31:57 +02:00
dillard b3c8068f8d Batched SFTP reads: no more batches on a connection whose server did not confirm a close 2026-10-11 14:05:29 +02:00
dillard 0a9bd963fb Verify and batched SFTP reads: address review notes 2026-10-11 14:00:48 +02:00
dillard 379b81db02 Verify: index files several at a time over SFTP, pack folders listed, progress stored and shown 2026-10-11 13:39:38 +02:00
dillard 828f3f142a Merge branch 'fix/sftp-fresh-connection' into polish 2026-10-11 13:26:47 +02:00
dillard c146d700e8 Merge branch 'main' into polish 2026-10-11 13:25:06 +02:00
dillard 8e273ac259 Backup step: reporting on a committed snapshot cannot fail the run; check the snapshot row after a unique violation 2026-10-11 12:30:05 +02:00
dillard 6385925845 Backup step: write the run only if no other process wrote it since it was loaded 2026-10-11 12:24:00 +02:00
dillard 894258f836 SFTP: open the channel before the first fast read; backup step: survive a takeover by another process 2026-10-11 11:45:27 +02:00
Dillard Blom 54750e9742 Merge pull request #8: Release 0.9.4-beta.5
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
Restore and deep verify: read a pack as one stream; pipelined SFTP reads
2026-10-09 12:05:31 +02:00
dillard fe92ef70bf Release 0.9.4-beta.5 2026-10-09 11:58:52 +02:00
dillard b3e3054737 SftpReadStream: fall back to the storage's own stream instead of failing a read 2026-10-09 11:24:37 +02:00
dillard c775e27dd2 CHANGELOG: SFTP reads with several requests in flight 2026-10-09 09:59:10 +02:00
dillard d8e772137f StorageBackend: read SFTP locations with several requests in flight
files_external's SFTP read stream asks for the next 256 KiB only after
the previous answer has arrived, so reading costs one round trip per
request whatever the line speed: 4.9 MB/s at 47 ms to a Hetzner Storage
Box. SftpReadStream keeps up to eight READ requests in flight over the
same phpseclib connection (15 MB/s on that link), starting with one so
a small read does not fetch megabytes ahead.

A server that answers less than asked is handled by asking again from
where the answer ended; when more data follows, that size is used for
later reads over the same connection, never below the 32 KiB every
server must support. Without the needed connection methods, or when the
open fails, the storage's own stream is used as before.
2026-10-09 04:27:21 +02:00
dillard bcfec17cfe README: WebDAV request timeout on slow uploads; deep verify no longer fetches per blob 2026-10-08 22:38:32 +02:00
dillard b309081d1a Repository: close the pack stream before any other request on the location
Review notes on the open pack stream: it stayed open while a backup run went on to upload
(e.g. the snapshot after reading the file list back), which SFTP cannot do on one connection.
Reading moves to PackReader, and Repository reaches the location through PackReaderGuard,
which closes the stream before every other request. A pack stream that cannot seek is read up
to the offset. Deep verify no longer reports an \Error during decoding as damage.
2026-10-08 22:34:02 +02:00
dillard 5f8d5faee7 Repository: read the blobs of a pack from one open stream
Restores and deep verifies requested every blob separately: a new file handle and a seek on
the location each time. They read the blobs of a snapshot in the order they were written, so
the stream of the pack read last now stays open and is read on; a jump backwards, to another
pack, or more than 8 MiB ahead opens the pack again at the offset, as before. On a Hetzner
Storage Box over SMB, restoring 86 MB in 202 files went from 0.78 MB/s to 4.2 MB/s.
2026-10-08 22:17:53 +02:00
dillard 65f14b4380 Merge branch 'main' into polish
# Conflicts:
#	CHANGELOG.md
2026-10-08 21:19:34 +02:00
Dillard Blom 4fa7cbf6ef Merge pull request #7: Release 0.9.3-beta.4
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 0.9.3-beta.4
2026-10-08 21:18:00 +02:00
dillard f4abfe0a8d Release 0.9.3-beta.4 2026-10-08 20:46:14 +02:00
dillard fe1a289c2d Backend: an existing folder that cannot be listed is an error, not empty
Read as empty, a catalog/ or index/ folder would make an existing repository look new.
2026-10-08 20:46:14 +02:00
dillard ab3265e42c Backup: release the held index cache writes also when the dump transaction fails to start
holdIndexCache() ran before beginTransaction(), outside the try, and the release came after
rollBack(); an exception from either left later cache writes queued for the rest of the run.
2026-10-08 20:38:25 +02:00
dillard 466cff8857 CHANGELOG: fixes for the next hotfix 2026-10-08 18:28:29 +02:00
dillard 6bba0b59fc Repository: list flat folders without a type check per entry
Opening a repository lists index/, which holds one file per pack.
StorageBackend::list() checked every entry with is_dir(), and on a
remote storage each check is a round trip. Over SFTP to a Hetzner Storage
Box (33 ms) with a 60 GiB repository, occ backup:run (30 s steps) spent
nearly every step on these requests and uploaded 45 KiB/s; with this
change it uploads 5.8 MB/s. The cost grows with the number of packs.

index/, snapshots/, catalog/ and trash/info/ only ever hold objects:
list them with the new IBackend::listFiles(), one listing without
per-entry checks.
2026-10-08 18:28:29 +02:00
dillard a01aa16ccd Backups: show the progress of the files phase
A run only showed "running (files)", which says nothing during a backup
of a terabyte. The files phase now records an expected size on its first
step: the previous snapshot's size, or for a first backup the users'
files, versions and trash from Nextcloud's file cache (approximate, shown
with a ~). The web interface and occ backup:status show the share of it
done so far, including a large file that is still being read; it stays
below 100% until the run finishes.
2026-10-08 13:48:12 +02:00
dillard cc20b9174f info.xml: author e-mail address 2026-10-08 13:23:21 +02:00
dillard 239aa1b923 StorageBackend: drop a redundant cast 2026-10-08 13:03:57 +02:00
dillard 62766e1e71 Backup: do not write the index cache inside the read-only dump transaction
A database dump that fills a whole pack (32 MiB) uploads that pack while
the dump transaction is still open, and the pack's index cache row was
inserted on the same connection. PostgreSQL refuses that (25006, read-only
transaction) and the run failed; on other databases the row was rolled
back with the dump transaction. The index now holds those cache writes
back until the transaction is over.
2026-10-08 13:03:52 +02:00
dillard 9775cdc475 Locations: edit connection settings without losing the history
A changed password or host name could only be handled by adding a new
location, which starts an empty repository and, with a retention lock at
the provider, keeps the old one billed. Edit now changes a location in
place (web interface and occ backup:target:edit). The settings are only
saved when they lead to the same repository; a failed connection and a
missing repository give separate errors, and the folder is not created
while checking. Stored passwords are never sent to the browser: an empty
password field keeps the stored value.
2026-10-08 12:56:15 +02:00
dillard a8da8cb54a info.xml: website points to the GitHub repository
The app store shows <website> as the app's homepage; pointing it at the
store page itself made the link go nowhere.
2026-10-08 02:23:59 +02:00
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
Dillard Blom a0c1f1cd58 Merge pull request #3: Release 0.9.0-beta.1
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 0.9.0-beta.1
2026-10-07 01:15:19 +02:00
dillard 26cc9a897e Release 0.9.0-beta.1
Version 0.9.0 in info.xml, the app description for the beta, and the
CHANGELOG entry for 0.9.0-beta.1.
2026-10-07 01:07:55 +02:00
Dillard Blom fe0385ad8c Merge pull request #2: Pre-beta review
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
Pre-beta review: data safety, safer commands, known limitations
2026-10-07 01:06:40 +02:00
dillard b37a953cfd Detect a busy run by identity, and clean up a failed kit write
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
step() hands back the same Run object when another process holds the lease;
comparing update times per second could stop the scheduled job early after a
quick step. A failed write of the recovery kit file now closes the handle and
removes the partial file.
2026-10-07 00:48:11 +02:00
dillard b89b5b5fa6 Document known limitations for the beta and record the review backlog
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
The open points from the pre-beta review are on the backlog in PLAN.md and
listed as known limitations in the README; none block 0.9.0-beta or 1.0.0.
The how-to warns against pointing a test server at a live location and
explains that removing a slot does not revoke access.
2026-10-07 00:19:46 +02:00
dillard 16e09f4fde Fix trash on SFTP and the disaster recovery test after the review fixes
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
forget() and untrash() uploaded a remote read stream directly, which the
new size check could not judge on SFTP, so cleanup failed there. They now
copy through a local buffer, and the size check only trusts local streams
(php://temp, php://memory, plain files).

The disaster recovery test calls backup:restore:full with --no-interaction:
docker exec without a terminal still counts as interactive, so the new
confirmation question read an empty answer and cancelled.
2026-10-06 23:59:17 +02:00
dillard 45069994e9 Pre-beta review: safer destructive commands and hardening
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
- backup:restore:full refuses on an installation with more than one user
  and asks before it starts (--force skips both, also to rerun after a
  failure halfway), and warns when the snapshot's database dump was not
  one consistent transaction.
- backup:user:restore --mode=replace asks before deleting an existing
  account; non-interactive use needs --force.
- The recovery kit file is created owner-only and never over an existing
  file or symlink; backup:key:import shows the deletion delay it read from
  the kit.
- Adding a location through the web API needs password confirmation, like
  removing one.
- Key derivation parameters from a kit or repository config are bounded,
  and a stalled network stream no longer spins at full CPU.
- Floating-point columns are dumped exactly instead of with 14 digits.
2026-10-06 23:48:41 +02:00
dillard 0f4a238414 Pre-beta review: data safety in storage, catalog and backup runs
Storage and repository:
- Listing skipped every name containing ".part-", directories too, so the
  exports of a user like "john.part-time" were invisible to prune on S3,
  SFTP, FTP and SMB and their data was deleted. Only real temporary uploads
  ("<name>.part-<8 hex>") are skipped now.
- prune refuses to run while a recorded per-user export's manifest is
  missing, instead of freeing that export's data.
- forget() and untrash() copy first, switch the catalog, and delete the
  originals last, so a crash can no longer leave the catalog pointing at a
  missing snapshot or database dump.
- Writes are checked against the expected size, so a short upload is an
  error instead of a truncated pack; raw restores check rename() and touch().
- A catalog write refuses to overwrite a generation another process just
  wrote, and the catalog lock lasts 300 s, so opening a large repository
  inside it no longer lets the lease run out.

Backup runs and scheduling:
- A file that keeps changing is read again at most three times; then the
  last read is kept and reported, instead of restarting forever and
  blocking cleanup.
- Unreadable files and folders are recorded, logged, shown by occ
  backup:run, stored in the snapshot and notified to admins, instead of
  being skipped silently.
- After a failed scheduled backup, at most three attempts a day, an hour
  apart, instead of a new run and notification every five minutes.
- The job no longer spins on a run whose lease another process holds, and
  the weekly cleanup catches up after a Sunday spent backing up.
- Notifications for the per-user export retention events.
2026-10-06 23:48:40 +02:00
Dillard Blom f39ca71dab Merge pull request #1: Phase 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
Phase 2: disaster recovery, per-user backup, verify, FTP/SMB/NFS
2026-10-06 23:21:55 +02:00
dillard e8ecb77df5 Refresh the location lease while a per-user restore reads its export
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
A long import could outlive the lease, letting a cleanup forget the export
being restored and free its blobs mid-read.
2026-10-06 22:59:49 +02:00
dillard 70965dd4bd Keep leases alive during long restores and exports; PR review fixes
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
Full restore, raw directory restore, per-user export and the safety export
held a fixed-TTL lease on the location that nothing refreshed. A restore or
export running longer than that TTL let a cleanup take over and delete data
still in use. They now refresh the lease while they work (per blob, per row
batch, per written chunk), throttled to once a minute.

Also:
- backup:prune still applies snapshot retention when user-export retention
  fails for a location.
- The recovery kit carries the deletion delay, so a disaster recovery keeps
  the original protection instead of falling back to 7 days. Older kits
  still import with the default.
- CI checkouts no longer persist the token in the git config.
- recoveryKit() has its real return type; the stale baseline entry is gone.
2026-10-06 22:38:47 +02:00
dillard cc09d74942 Commit full-backup snapshots to the catalog under the catalog lock
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
A full backup opened its repository at the start of a step and wrote the
catalog at the end of it, without the catalog lock. A per-user export or
restore that wrote the catalog in between could lose its entry, or the
snapshot could lose its own.

The final step now only stores the snapshot object and moves the run to a
new 'commit' phase. BackupService then records the snapshot under the
catalog lock, from a repository opened inside that lock; if the lock is
busy, the next step retries the commit. Repository::writeSnapshot() keeps
its old behaviour as putSnapshot() plus recordSnapshot().
2026-10-06 17:58:21 +02:00
dillard eb9100a74d Fix review findings: catalog writes, user restore consent, retention validation
Catalog: forgetting a user export or purging trash now writes the catalog
before deleting the objects, so a crash leaves at most an unreferenced file
instead of a catalog entry pointing at nothing. Per-user backup and the
safety export reopen the repository inside the catalog lock, so they build
on the latest catalog generation.

Commands: backup:user:backup and backup:user:restore resolve the location
before asking to install user_migration, and a successful install always
stops with a rerun instruction. backup:retention rejects a malformed
--user-last before changing any policy. backup:key:import fails clearly
when the passphrase file cannot be read.
2026-10-06 17:40:51 +02:00
dillard 614caf9f63 Document migrated- calendar names in the how-to; add backlog items
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-06 16:59:45 +02:00
dillard 943c95bf07 Ask before installing user_migration; add the beta how-to
backup:user:backup and backup:user:restore now ask the administrator before
installing or enabling user_migration (or take --install-user-migration), so
nothing changes on the server without consent. The restore warns that
user_migration imports only its allowlisted app settings.

Also: a clear error for unknown locations instead of a raw database message;
prune reports the number of affected users, not the number of kept ones; and
--user-last rejects values that are not whole numbers. Adds docs/HOWTO.md and
updates the README and PLAN.md for the beta.
2026-10-06 16:57:56 +02:00
dillard ba7f70e0f8 Rename per-user backup/restore commands to backup:user:backup and backup:user:restore
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-06 03:51:35 +02:00
dillard d70a869070 Phase 2: verify per-user exports, refuse SMB without the native extension, matrix user CI
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
backup:verify accepts a user export path (users/<uid>/<export>): every blob its manifest references
is checked the same way as for a snapshot, shallow or --deep.

The SMB backend is refused at backup:target:add when the native smbclient PECL extension is not
loaded, instead of registering and then failing the first backup with "Seek failed": with only the
smbclient binary files_external falls back to a shell stream that cannot seek.

The per-user integration job now runs on Nextcloud 32-35, not just 35: it calls user_migration's
internal UserMigrationService, whose API may change between versions.
2026-10-06 03:47:02 +02:00
dillard c9f82a79e4 Phase 2: retention for per-user exports (occ backup:retention --user-last)
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 (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
PruneService::userExportRetention()/setUserExportRetention(): a single, app-wide "keep the
newest N" setting (default 3, same default as the snapshot policy's "last"), deliberately simpler
than the full daily/weekly/monthly RetentionPolicy -- per-user exports are on-demand only (occ
backup:backup:user, see the fold-out task about whether they ever join the automatic schedule),
so there is no regular cadence yet to bucket by day/week/month.

PruneService::applyUserExports(): groups a location's user exports by uid (read from each
manifest's own 'time' field, the same field snapshot retention already reads), keeps the newest N
per user, forgets the rest. No trash, unlike a snapshot: these are not yet the primary safety net
the deletion delay protects. Runs under the same exclusive lock as snapshot retention.

occ backup:retention gained --user-last=N; occ backup:prune now also applies it for every target
it processes, reporting counts the same way it already does for snapshots and blob packs.

Verified live against the dev rig (four real exports across four users, confirmed exactly the
expected ones were kept/forgotten at two different --user-last values) and added coverage to
tests/integration/user-roundtrip.sh (an account's manual export plus the automatic safety-export
a replace-mode restore makes of it, pruned down to --user-last=2).
2026-10-06 03:18:14 +02:00
dillard 932f60bc5e Phase 2: FTP, SMB and NFS storage backends
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 (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
No ng_backup code changes needed: files_external's generic backend/auth adapter (the same
ExternalStorageFactory/StorageBackend already used for S3/SFTP) already covers FTP and SMB, and
NFS was never a files_external backend to begin with (PLAN.md 3.5) -- the "local" backend plus an
admin-managed OS-level mount already does the job. This commit is test coverage and documentation.

tests/integration/targets-matrix.sh now also runs the full round trip against:
- FTP (stilliard/pure-ftpd). A first attempt with delfer/alpine-ftp-server (vsftpd) failed with a
  generic "create directory operation failed": files_external's FTP backend always builds
  absolute paths, which that server's chroot rejects even via plain php ftp_mkdir() with an
  absolute path (a relative path on the same server works fine) -- not a files_external or
  ng_backup bug, just a server-specific quirk, confirmed by switching test servers.
- SMB (dperson/samba), via a small Nextcloud image built on the fly with the smbclient PECL
  extension (libsmbclient-dev + pecl install), in its own temp directory as the build context
  (never /tmp directly). The backend *lists* as available with just the smbclient CLI binary too
  (files_external's shell-wrapped fallback), but that fallback's stream cannot seek, and ng_backup
  reads blobs back by byte range while backing up -- confirmed live, it fails the very first
  (small) backup with "Seek failed in packs/...". The native extension is genuinely required here,
  not just preferred.

README: new "Storage backend experiences" entries for both findings (and a pointer from the
"no extra PHP extensions" intro line, since SMB is the one real exception), plus NFS guidance
(mount it yourself, point the local backend at the mount point).

PLAN.md: FTP/SMB marked done; SMB/NFS testing on the user's own OMV instance stays open (internal,
not something CI can cover).

Local review (Codex + CodeRabbit) found three issues, all fixed: the README's first-pass SMB claim
("CLI tool is not enough") was imprecise about *why* -- it lists fine without the extension, it
just doesn't work for ng_backup's read pattern; the cleanup trap could abort before removing the
network if an earlier docker rm failed, and didn't remove the built image/Dockerfile; and the
Docker build used all of /tmp as its context instead of a dedicated directory.
2026-10-06 00:31:29 +02:00
dillard 935c7b6a08 Phase 2: checksum audit (occ backup:verify)
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 (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
Repository::verify(): every blob a snapshot's files and database dump reference must be in a
freshly re-downloaded, re-authenticated index (never trusted from the local cache, same reasoning
as prune()) and have its pack still present on the location -- shallow, cheap, no blob content
read, catches a deleted pack, a tampered/rolled-back index, or a database manifest that went
missing (reported as a failure, not silently skipped). With $deep, each blob is also downloaded
and decrypted, same as loadBlob() already does on every real read (AEAD authentication tag,
content hash against its blob id), done here deliberately for everything in the snapshot rather
than waiting for it to come up during an actual restore. A blob referenced by more than one file
is only checked once; pack presence is cached per pack, not re-checked per blob.

occ backup:verify <target> <snapshot> [--deep], meant to run periodically (cron, a sample of
recent snapshots), per PLAN.md's "Verificatie: periodiek checksums controleren, steekproef-restore".
The lease covering the audit is refreshed roughly every half of its TTL while blobs are checked,
rather than relying on a single long-lived lease like the other new phase-2 commands do.

Verified against a real S3-backed 1.4 GiB / 603-blob snapshot (shallow: instant; --deep: ~28s,
fully decrypted) in addition to the new unit tests and the roundtrip.sh addition (shallow and deep
on a snapshot with real, varied file sizes, now covered on every CI matrix combination).

Local review (Codex) on the first pass found three real issues, all fixed: a missing database
manifest was silently skipped instead of reported; shallow verification trusted the (possibly
stale or unauthenticated) local index cache instead of re-checking the authenticated index on the
location; and the lease had no refresh, so a --deep audit of a very large snapshot could outrun
its TTL and run unprotected against a concurrent prune.
2026-10-05 23:56:16 +02:00
dillard f18b4f0862 Phase 2: disaster recovery (occ backup:restore:full)
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 (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
New commands, per PLAN.md 3.7:
- occ backup:key:import: adopt the master key from a recovery kit on a fresh installation
  (instead of occ backup:key:init generating a new one), so occ backup:target:add opens the
  existing repository on the location rather than creating an empty one.
- occ backup:restore:full: replace the database (consistent, one transaction), restore the data
  directory in place, and merge config.php -- refuses up front and lists what's missing when the
  Nextcloud major version or any app enabled at backup time doesn't match here (their tables must
  exist before the database restore can use them), rather than guessing or partially applying.

Repository::restore() gained an optional $stripPrefix (write "data/<uid>/..." straight into the
real data directory, not a "data/" subdirectory of it) -- restoring "data/" and "config/" this way
needs no temporary copy of what can be the largest part of an instance.

occ backup:target:add now backfills the local snapshot cache (ngb_snapshots, what occ backup:list
actually reads) when it adopts an existing repository: without this, a freshly connected location
showed no restore points at all, even though the repository itself had them. One unreadable
snapshot is skipped rather than aborting the backfill or the target add itself.

Three self-referential problems found and fixed by testing this against two real, separate
Nextcloud instances sharing only the backup location (tests/integration/full-restore-roundtrip.sh
covers this; the per-process nature of these bugs meant they were invisible from inside a single
occ invocation, including the one used to write the fixes, which is exactly why a second, fresh
process re-running occ backup:status etc. was necessary to catch them):
- The full database dump includes ng_backup's own tables and appconfig rows; restoring them
  verbatim would overwrite the key/target that is actively performing the restore, on THIS
  installation, with the backed-up instance's. Both are now preserved: ng_backup's own tables
  (and, on PostgreSQL, their sequences -- excluding a table does not exclude its sequence) are
  excluded from what gets restored, and its appconfig rows are captured before the database
  restore and written back after, in their own transaction.
- config.php's 'secret' is merged in from the backup (needed so already-encrypted content in the
  restored database, e.g. share tokens, stays decryptable), but Target::options, the master key,
  the wrapped key and the recovery kit code are all encrypted with that same instance secret
  (OCP's ICrypto, or Nextcloud AppConfig's own "sensitive" flag underneath), not with ng_backup's
  own key. Changing it without re-encrypting them first left all four permanently undecryptable
  the moment any later occ command ran. Fixed with two helpers
  (TargetService::reencryptOptionsAround(), KeyService::reencryptForSecretRotation()) that decrypt
  before and re-encrypt after whatever callback actually changes the secret; config.php is applied
  dead last in restoreFull(), after every other repository access. Not crash-safe (a process kill
  mid-rotation would leave a key missing, not just stale) -- disaster recovery is run once by
  hand, right after occ maintenance:install, a window too narrow yet to justify a two-phase write.
- A subtler variant of the same bug: Nextcloud's own AppConfig additionally encrypts a "sensitive"
  value itself, tied to the same secret, underneath our own encryption. Overwriting an existing
  sensitive key makes it read the old value first to compare, which then also fails against the
  new secret. Deleting the key before setting the new value avoids that read entirely.

Roadmap (PLAN.md 9): phase 3 (in-place full restore, user self-service) moved out to v1.3.0; phase
2 continues toward v1.2.0 with FTP/SMB, a verification/checksum-audit command, user-export
retention, and deciding whether per-user export joins the automatic schedule.
2026-10-05 23:31:08 +02:00
dillard 6cbc5d79d4 Phase 2: per-user backup and restore via user_migration
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 (push) Has been cancelled
Wires the existing RepositoryExportDestination/RepositoryImportSource (see
lib/UserMigration/) into user_migration's own UserMigrationService, resolved on demand
through a new UserMigrationServiceLocator since the app is an optional dependency
(PLAN.md 3.8) -- without it, only this feature is unavailable, the full backup still
works.

New: UserBackupService::backupUser() (occ backup:backup:user) streams a user_migration
export straight into the repository, under the same shared location lock as a regular
backup. UserRestoreService::restoreUser() (occ backup:restore:user) never imports over a
live account: --mode=replace safety-exports the current state first, then deletes and
recreates the account from the backup under the same uid; --mode=as-backup restores into
a new "<uid>-bak" account instead, since Nextcloud cannot rename a uid.

The catalog now also tracks user export manifests (Repository::recordUserExport/
listUserExports/forgetUserExport/isRecordedUserExport), rollback-protected like a
snapshot: listUserExports() verifies each one is still present on the location, and
restore only ever reads a manifest the verified catalog actually recorded, never an
arbitrary caller-supplied path. prune()'s existing raw users/ scan for blob retention is
unchanged. A new short exclusive "catalog" lock (PruneService::catalogLockKey())
serializes the catalog write itself, so two concurrent per-user backups (or one
alongside a full backup) can no longer race to write the same next generation and
silently drop one of them -- the broader per-target shared lock only ever protected
against a concurrent cleanup, not against other shared holders.

Two real bugs found by testing this against a live Nextcloud instance, not by unit
tests (RepositoryExportDestination/RepositoryImportSource implement OCP interfaces that
plain PHPUnit can't load, see tests/Unit/Repository/UserExportTest.php's docblock; the
new tests/integration/user-roundtrip.sh covers the real round trip instead):
- RepositoryImportSource::copyToFolder() double-closed the stream it handed to
  putContent()/newFile() (both already close it themselves), which throws a TypeError on
  PHP 8 and broke every restored file with content.
- A replace-mode restore failed on NotPermittedException: IRootFolder::getUserFolder()
  caches a Folder per uid for the process lifetime, so after delete+recreate of the same
  uid the import still got the deleted account's (now storage-less) folder. Fixed by
  dispatching FilesystemTornDownEvent, which Root already listens for to clear exactly
  that cache -- no internal OC\ classes needed.

Local review (CodeRabbit + Codex) against this diff found three more real issues, all
fixed: the catalog write race above, a failed as-backup import leaving a broken stub
account behind (now deleted on failure), and an export stream leak on a read/backend
failure in RepositoryExportDestination::copyFolder() (now closed in a finally).

Deliberately left out: wiring per-user export into the automatic backup schedule
(on-demand via occ only, for now) and retention/pruning of old user exports -- both open
design questions, not yet decided. The lease covering a single export/import still has a
fixed TTL (3600s) with no mid-call refresh, since UserMigrationService exposes no
progress; a resumable, step-based per-user backup (like the main BackupService run)
would remove that cap but is a bigger change than this pass.
2026-10-05 17:36:46 +02:00
dillard 58711b67ef CHANGELOG: add the release link reference
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
2026-10-05 12:17:10 +02:00
150 changed files with 16367 additions and 522 deletions
+1
View File
@@ -0,0 +1 @@
custom: ["https://www.ensembia.com"]
+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
+82
View File
@@ -17,6 +17,8 @@ jobs:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
@@ -29,6 +31,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
@@ -37,6 +41,20 @@ jobs:
- run: composer install --no-interaction --no-progress
- run: composer psalm
frontend:
# Builds the admin page, so a broken Vue component fails CI instead of showing up after a release.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci --no-audit --no-fund
- run: npm run build
integration:
# Full round trip (key, kit, location, backup, incremental, restore modes, database dump and
# restore, retention, trash) on a fresh Nextcloud per version and database.
@@ -48,6 +66,8 @@ jobs:
database: ['sqlite', 'mariadb', 'pgsql']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Round trip on Nextcloud ${{ matrix.nextcloud }} / ${{ matrix.database }}
run: tests/integration/matrix.sh "${{ matrix.nextcloud }}" "${{ matrix.database }}"
@@ -58,5 +78,67 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Round trip against S3 and SFTP
run: tests/integration/targets-matrix.sh
integration-user:
# Per-user backup/restore via user_migration (occ backup:user:backup / backup:user:restore),
# an optional dependency installed from the app store. It calls user_migration's internal
# UserMigrationService, whose API can change between Nextcloud versions, so this runs on every
# supported version (not the database, which the main integration job already covers).
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
nextcloud: ['32', '33', '34', '35']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up a fresh Nextcloud instance
run: |
docker run -d --name ngbu -e SQLITE_DATABASE=nextcloud -e NEXTCLOUD_ADMIN_USER=admin \
-e NEXTCLOUD_ADMIN_PASSWORD=admin-user-roundtrip -e NEXTCLOUD_TRUSTED_DOMAINS=localhost \
-v "$PWD:/var/www/html/custom_apps/ng_backup:ro" nextcloud:${{ matrix.nextcloud }}-apache
for i in $(seq 1 90); do docker exec -u www-data ngbu php occ status 2>/dev/null | grep -q "installed: true" && break; sleep 4; done
docker exec ngbu chown www-data:www-data /var/www/html/custom_apps
- name: Per-user backup/restore round trip on Nextcloud ${{ matrix.nextcloud }}
run: tests/integration/user-roundtrip.sh "docker exec -u www-data ngbu" /var/www/html/data
integration-full-restore:
# Disaster recovery (occ backup:restore:full) on a SECOND, fresh Nextcloud instance that
# never saw the first one, sharing only the backup location. Does not depend on the database
# beyond what the main integration job already covers, so this runs once, on one NC version.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Full restore round trip
run: tests/integration/full-restore-roundtrip.sh 35-apache
- name: Full restore round trip on PostgreSQL, with Mail (foreign keys) and a disabled app
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql
- name: Full restore round trip on MariaDB, with Mail (foreign keys) and a disabled app
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql
- name: Full restore of a backup made by 1.0.0-rc.1 (no table structure) on PostgreSQL
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql legacy
- name: Full restore of a backup made by 1.0.0-rc.1 (no table structure) on MariaDB
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql legacy
- name: Full restore over SFTP on PostgreSQL, reading packs ahead on several connections
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql sftp
- name: Full restore of a MariaDB backup onto PostgreSQL (ID counters after the data)
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql to-pgsql
- name: Full restore of a PostgreSQL backup onto MariaDB
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql to-mysql
- name: Full restore of a PostgreSQL backup onto SQLite
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql to-sqlite
- name: Full restore of a MariaDB backup onto SQLite
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql to-sqlite
- name: Full restore of a SQLite backup onto PostgreSQL
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite to-pgsql
- name: Full restore with a damaged database dump (--salvage)
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite salvage
- name: Full restore with a damaged and a missing pack (--salvage)
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite salvage-files
+582 -3
View File
@@ -2,9 +2,572 @@
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
`PLAN.md` section 9. The `<version>` in `appinfo/info.xml` is plain SemVer-numeric (Nextcloud's
own `info.xsd` doesn't allow a `-alpha`/`-beta`/`-rc` suffix there); the stability stage is instead
carried by the git tag, the release, and this file.
`PLAN.md` section 9. The `<version>` in `appinfo/info.xml` is plain SemVer-numeric on purpose:
the app store accepts a `-beta` suffix, but Nextcloud servers then only offer the release on the
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.
Each heading starts with that `<version>`, once: the app store shows a release the section(s)
whose heading starts with its `x.y.z` and ignores anything after it, so `## [1.0.0-rc.6]` would be
shown under 1.0.0. The git tag of a release candidate goes after the date.
## [1.1.0] - 2026-11-09
### Added
- Pin a restore point, on the restore page or with `occ backup:pin`: the retention policy leaves it
alone, for example the one from before an upgrade. Unpinning notifies all administrators.
- A note on a restore point that you can change afterwards, on the restore page or with
`occ backup:note`. Pins and notes are stored encrypted on the location, so a new server sees them.
An older version of NG Backup can still read the location, but drops them when it writes to it.
- A schedule per location: off, once a day, or every N hours from a given time, on the page under
Backups or with `occ backup:target:schedule`. After the update every location keeps the time that
held for all of them; a new location starts with the schedule of the first one. A schedule is
shown and changed in the time zone it was saved in, which the page names next to the time.
- All folders get their original modification time back on a restore, not only the empty ones, from
the first backup made with this version. An older version of NG Backup can still read new restore
points. A restore through Nextcloud into a folder that is already there leaves its time alone.
- Heavy background jobs (Memories, Recognize, Preview Generator, full text search, the antivirus
background scan) pause while a backup runs, at most 6 hours in a row, then they get an hour. No app
is turned off, and a job that is running goes on. On by default; turn it off under Backups or with
`occ backup:pause-jobs off`.
- Disaster recovery without the recovery kit: on a new server without a backup key,
`occ backup:target:add` finds the backups on the location and takes their key from there with one
of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The
deletion delay is now also stored on the location, sealed with the key, from its first backup
with this version; a location backed up only by an older version gives the default of 7 days.
When the settings on the location cannot be read, the longest delay (365 days) is taken, so
nothing leaves the Bin sooner than on the old server; the command says so.
- A passphrase change that cannot reach a location (offline at the time) now reaches it at the
location's next backup, so a new server can open it with the new passphrase.
- `occ backup:target:add --repair-config`: when the config file of a location is gone, the recovery
kit writes it again (for locations backed up by this version or newer). Before, the message said
this was possible, but it was not.
- The key page warns against creating a new key when you are restoring a server. A key made there
by mistake can still make way for the key of the backups, as long as it was never used (no
location, no backup): `occ backup:key:import --replace-unused-key` with the kit, or
`occ backup:target:add --replace-unused-key` with a passphrase of the old server. A key that
protected a location is never replaced, and neither is a key from before this version. All
administrators are notified of a replacement.
### Changed
- The list of backups stays within the page: a long label, note or error wraps onto more lines
instead of making the table scroll sideways.
- Reading files for a backup is much faster on disks where every request waits, such as network and
cloud volumes: files are read in pieces of 4 MiB instead of 8 KiB. On a test server the disk
delivered 27 MiB/s instead of 5 MiB/s.
- Locations are backed up one after the other: a backup that has to wait shows as queued. A restore
point has the time its backup really began, not the time it was queued.
- Choosing a time no longer starts a backup right away when that time has already passed today.
- A location without a schedule no longer gets the notification that it has had no recent backup.
Instead, all administrators are told when a schedule is turned off, and reminded once a week while
another location is still backed up, or every day when none is.
- A file or folder that is left out because of its name now gets a notification of its own, in the
log and in `occ backup:run`. It says the item is missing from the backup, gives example paths and
asks to rename it; before, it was reported as unreadable, with advice about permissions.
## [1.0.3] - 2026-11-06
### Fixed
- Empty folders are backed up and restored (#27), in `occ backup:restore`, `occ backup:restore:full`
and the restore page, where they are shown too. They come back from the first backup made with
this version; older restore points do not contain them. An older version of NG Backup can still
read new restore points, and then only leaves the empty folders out.
- A full disk during a restore no longer looks like a damaged backup. The restore now stops with a
message about the disk, also with `--salvage`, and does not point at `--salvage` any more. The same
goes for other trouble writing to the restore directory, such as permissions.
- A file or folder whose name a snapshot cannot hold (a backslash, a control character, not UTF-8) is
left out and reported as unreadable. Before, a backslash or control character could make the whole
restore point unreadable, and a name that is not UTF-8 made the backup fail.
- A restore that goes on after a stop also removes the temporary files in folders where every file
is already in place, and checks that those folders are inside the restore directory before it
leaves their files alone.
- After a restore that was killed and then ran again, a backup no longer has to wait up to two hours
for the locks of the killed run.
- A restore only removes read-ahead folders of its own (`.ngb-restore-spool-` with 8 hex digits).
- A restore keeps its lock on the location during a long row of files that are already in place.
- A database restore that fails no longer leaves behind tables it created, which could stop the
installation of that app later. When only NG Backup's own settings cannot be put back after the
database was restored, `--salvage` no longer says the database is still the fresh one.
- A connection that breaks while opening a pack on the location (seen with SMB) is retried once and
is handled by `--salvage`, instead of stopping the restore with the error of the storage.
- Without a system cron, several running backups take turns while the page is open.
- The Bin tab shows how far it is while it reads the removed restore points, which can take a while.
It also shows when it cannot read a location, instead of an empty bin, and refreshes when a run
ends.
## [1.0.2] - 2026-11-06
### Added
- A warning on the admin page and in `occ backup:status` when background jobs do not run on a
system cron, or the cron stopped, with a link to how to set one up. Backups, also the scheduled
ones, need it (#13).
- Without a working system cron, a backup started with "Back up now" moves on while the NG Backup
page stays open (#13).
- `--salvage` for `occ backup:restore` and `occ backup:restore:full`: restore whatever can be
read when a backup is damaged. Files that cannot be read or decrypted are left out and listed,
and the command exits with code 2. When the database cannot be restored, `backup:restore:full`
leaves the fresh database as it is and puts the data directory and config of the backup in
`ngb-salvage-<snapshot>` in the data directory, to put back by hand.
- The trash of restore points has a tab of its own, "Bin", instead of being shown below the
backups.
### Changed
- A backup in its database phase shows how far it is, for example "running (db: 47/183 tables)",
and one that has not moved for 15 minutes says "waiting for background jobs" with when it last
took a step. Before, it only said "running (db)", which looked the same as being stuck (#13).
- Full restores from S3 read the packs ahead, eight ranges at a time, as restores over SFTP do
since 1.0.0-rc.4. Over SMB the packs are read in 8 MiB reads instead of 1 MiB: over a line with
a 130 ms round trip, reading was four times faster in a test (18 instead of 4.5 MiB/s).
- Over SMB the index files, which a new server reads in `backup:target:add`, take fewer round
trips: from Canada to a Hetzner Storage Box (130 ms), 0.66 instead of 1.05 s per file, so about
5 instead of 8 hours for the 27,000 index files of a 1 TB repository.
- `occ backup:restore:full` that runs again after it stopped (`--force`) goes on where it was: data
files already in place with the size and modification time of the backup are left, and only
the rest is read from the location. Before, a stopped restore of 1 TB started over.
### Fixed
- A full restore over SMB could hang at 100% CPU when the connection to the server dropped: a
failed read was counted as data. It is now read again over a new connection. Found in a
disaster recovery test of 1 TB from Canada to a Hetzner Storage Box, after 392 GiB.
- The read-ahead folder of a restore that was killed while it wrote is removed by the next run;
it kept one file of up to 8 MiB.
- `occ backup:target:add` only estimates the time left for reading the index once enough of it
is in. The first file also carries the time to connect, which made it say "about 642 min left"
for something done within a minute.
- A restore that stops because a file is gone from the location now points at `--salvage`, as it
already did for damaged data.
### Known issues
- Empty folders are not backed up or restored; to be fixed in 1.0.3 (#27).
- A full disk during a restore looks like a damaged backup ("Size mismatch"), and with `--salvage`
the restore goes on and lists the remaining files. The backup itself is fine: make room and run
the restore again. To be fixed in 1.0.3.
## [1.0.1] - 2026-11-01
Fixes for what was reported after 1.0.0 (#22, #24) and small improvements. Nothing changes in
your backups or settings.
### Added
- `occ backup:user:list [location] [user]` shows the user exports on a location, with the manifest
path `backup:user:restore` expects. Before, that path was only printed once by
`backup:user:backup` (#22).
- A "Docs" tab on the admin page, with links to the how-to, the disaster recovery guide and the
roadmap. It also shows before the backup key is set up.
- A label for a backup started with "Back up now" (optional; `occ backup:run -l` already had one).
It shows with the restore point.
- An explanation in the trash of restore points of what "removed by retention policy" means: the
policy keeps the newest restore points and the last one of each recent day, week and month, and
moves the others to the trash, where they can be brought back until they are deleted
permanently. `occ backup:trash` says the same below its list.
### Changed
- `occ backup:status`, `backup:list`, `backup:trash` and `backup:browse` show times in the time
zone of the daily schedule and name it, as the admin page shows them in the administrator's.
Before, they showed the server's UTC, which read as a wrong start time.
- The admin page no longer says NG Backup is in beta.
- `occ backup:user:restore` also takes a user id instead of the manifest path, when that user has
exactly one export on the location. With more, and for a manifest path that is not on the
location, the error lists that user's exports; for a user without exports it names the
expected form (#22). Before, `backup:user:restore <location> <uid>` only said "is not a
recorded user export for this location".
### Fixed
- An SFTP location with an RSA key in OpenSSH format ("BEGIN OPENSSH PRIVATE KEY") stopped
working after an upgrade to Nextcloud 35 with "Unable to read key", and such a key could not be
added there at all (#24). The "Private key" field on the admin page is a single line, so a
pasted key lost its line breaks; Nextcloud 34 still read it, Nextcloud 35 reads keys with a
stricter library. The line breaks are now restored before the key is used, so a stored location
works again without re-entering it.
- A full restore onto SQLite could stop at once with "database is locked" when another process
wrote to the database just as the restore began (a background job that was already running).
The restore now takes the write lock first and waits for the other process instead. The
full-restore test on SQLite runs with a second process writing alongside.
- `occ backup:restore ... --to-directory` of a single file restored nothing ("Restored 0
files"); only a folder worked. A path to a file now restores that file.
- A restore that was stopped (a crash, a timeout, a restart) left its half-written temporary file
(`<name>.ngb-restore-<id>`) next to the file it was restoring, and a `files:scan` then showed it
in Files. A restore now removes such leftovers in each folder it writes to.
- The app store showed the notes of every release candidate under 1.0.0, because their headings
started with "1.0.0-rc". Each heading now starts with the app version of that release.
## [1.0.0] - 2026-10-29
First stable release. NG Backup makes encrypted, incremental backups of your whole Nextcloud
(files, database and configuration) to S3, SFTP, WebDAV, SMB and other storage, and restores them
from the web interface or with occ. Disaster recovery has been tested on real installations; see
"Tested disaster recoveries" in the README, and read "Known limitations" before you rely on it.
The code is the same as 1.0.0-rc.6 (app version 0.9.12): updating from it changes nothing in your
backups or settings.
## [0.9.12] - 2026-10-24 (1.0.0-rc.6)
Sixth release candidate for 1.0.0; the app version is 0.9.12. Fixes from two disaster recovery
simulations and a user report.
### Fixed
- The daily backup time was taken as UTC (Nextcloud runs PHP in UTC): 03:00 ran at 05:00 in
Berlin (#19). The time is now saved with the administrator's time zone and shown with it. A time
saved before is converted once to the zone of the server or an administrator, so it shows as it
effectively ran (02:30 UTC becomes 04:30 Europe/Berlin in summer) and nothing moves.
- A full restore over SMB stopped after 17 hours with "Decryption failed": a read over a long
line handed out wrong bytes (the backup itself was fine, the same files restored identically
afterwards). A blob that does not decrypt as first read, of a file or of the database, is now
read once more on a request of its own, and only a second failure stops the restore.
- A full restore onto MySQL/MariaDB stopped with "Incorrect string value" when it created a table
(of an app that is not installed here) whose rows hold emoji or other 4-byte characters: the
table got 3-byte utf8. Tables the restore creates now get this installation's character set and
collation (utf8mb4). Found in a disaster recovery simulation onto MariaDB.
### Changed
- The admin page uses more of the screen (sections up to 1400 pixels instead of 900), so the
list of backup runs fits without scrolling sideways.
- README: a roadmap, the tested disaster recoveries, measurements for OVH S3, the Storage Box over
SFTP and SMB, how to build the SMB extension when PECL is down, and what works without shell
access. The guide lists shell access among what you need.
- The full-restore integration test checks a 4-byte character in a table the restore creates, and
retries app installs from the app store.
## [0.9.11] - 2026-10-22 (1.0.0-rc.5)
Fifth release candidate for 1.0.0; the app version is 0.9.11. A full restore of 1 TB over SFTP
stopped after 13 hours with "Blob not in index"; the backup itself was fine.
### Changed
- README and app store description: NG Backup needs background jobs on system cron, with how to
set that up (issues #13, #17).
### Fixed
- A full restore no longer stops with "Blob not in index" after a pack could not be read for a
moment. Reading the index again then got an empty listing (from a connection the location had
dropped) and emptied the index. Reading the index again now only adds, an empty listing of a
location that has an index is an error instead of an empty index, and a pack that could not be
read is read once more before the restore gives up.
- After a full restore of a MySQL/MariaDB or SQLite backup onto PostgreSQL, new files, users or
activities could not be added ("duplicate key", "File entry could not be inserted"): such
backups record no ID counters, and they stayed at 1. Every counter of a restored table now goes
after its data. Found in a disaster recovery simulation of 138 GB from MariaDB to PostgreSQL,
where all 15,772 files and every count were identical.
- A full restore now runs in maintenance mode: a background job, a page visit or a user writing
into the instance meanwhile could collide with restored rows ("duplicate key") or be lost. It
is switched off when the restore completes; after an error it stays on, and the message gives
the one line to switch it off and run the restore again. Maintenance mode that was on before
the restore stays on.
- Adding a location whose repository config could not be read, while backup data is there,
no longer creates a new repository over it: it stops with an explanation. A snapshot that
cannot be taken over when a location is added is now logged instead of left out silently.
- An SFTP connection that a Hetzner Storage Box dropped without phpseclib noticing is now
recognised and replaced: the check asks the server and counts its answer.
- The extra connection a full restore reads ahead on is replaced when it breaks (up to 20 times),
instead of reading the rest of the restore the slow way.
## [0.9.10] - 2026-10-19 (1.0.0-rc.4)
Fourth release candidate for 1.0.0; the app version is 0.9.10. 1.0.0-rc.3 (0.9.9) was not
released: its fixes are in here.
### Changed
- A full restore over SFTP reads the packs ahead on a connection of its own, 32 requests in
flight, into temporary files in the restore folder. From a Hetzner Storage Box to a server in
Canada (122 ms) packs now come in at about 14 MiB/s instead of 2.8. More connections can be set
with `restore_sftp_connections` (README). A part that cannot be read this way is read as
before. Restores from other locations work as before.
- App store description: the line about Ensembia.
### Fixed
- After a full restore stopped halfway, running it again with `--force` was refused when the
database was already restored: Nextcloud then first wants `occ upgrade`. The guide and the
error message now say so.
- A full restore of a backup made before 1.0.0-rc.2 with `--skip-missing-tables` failed on
PostgreSQL with "relation ... does not exist": the left-out tables were still looked up to set
their ID counters. It now skips them. A new test restores a backup made by 1.0.0-rc.1 on
PostgreSQL and MariaDB.
- The app store showed old screenshots: it keeps a copy per URL, and the URLs always pointed at
`main`. They now point at the release's tag.
## [0.9.8] - 2026-10-18 (1.0.0-rc.2)
Second release candidate for 1.0.0; the app version is 0.9.8. Fixes from a full disaster recovery
test of a production instance (about 1 TB, Nextcloud All-in-One onto a plain Nextcloud with
PostgreSQL).
### Added
- `docs/DISASTER-RECOVERY.md`: step-by-step guide for when your server is gone.
- `occ backup:restore:full --install-apps` installs (from the app store) or enables the apps the
old server had enabled, before the restore starts.
### Changed
- Backups record the structure of every database table. A full restore creates tables that do not
exist on the new instance, for example those left behind by an app that was removed long ago or
is switched off, instead of stopping at the first one.
- A full restore no longer refuses when apps are missing: it restores their data and lists the
apps to install afterwards. `nextcloud-aio` is never required, so a backup of Nextcloud
All-in-One can be restored onto a plain Nextcloud.
- A full restore onto an installation with a different table prefix (`dbtableprefix`) is refused
before it starts, with what to change, instead of restoring into tables nobody uses.
- All problems are listed at once, with the app each missing table probably belongs to. For
backups made before this version, `--skip-missing-tables` leaves out tables whose structure is
unknown.
- A fuller app store description, ready for 1.0.0, and a note on paid restore help by Ensembia.
- README: switching the server to TCP BBR made backups over a long distance about 3 times faster.
### Fixed
- A full restore failed on foreign keys (for example those of the Mail app), because tables are
restored in name order. Foreign keys are now checked after all rows are loaded, before the
restore commits.
- `occ backup:target:add` without a key pointed to `backup:key:init`, which makes a new key that
cannot read existing backups. It now points to `backup:key:import` for a restore, and says that
connecting to a location with backups can take a while. While it reads the index of a location
with backups, it shows how far it is and about how long it still takes.
- A restore stopped with "Not found: packs/..." when the location was cleaned up while it ran,
for example by the weekly cleanup of the original server during a recovery test on a second
server: blobs that were moved into new packs were still looked for in the old ones. A restore
now reloads the index and reads them from where they are now.
- Removing a location left its cached index in the database (tens of thousands of rows for a large
backup). It is removed with the location now, and once for locations that are already gone.
## [0.9.7] - 2026-10-17 (1.0.0-rc.1)
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
anywhere. Tested on a production instance: a first full backup of 151,021 files (807 GiB) to OVH
S3 completed, and a backup to a Hetzner Storage Box over SMB that had slowed to 12 GiB in 35
hours ran at about 5.6 MiB/s after the update, while the S3 backup was still reading.
### Added
- The list of backup runs (web interface and `occ backup:status`) shows the total size of each
backup next to what it uploaded: an incremental backup of 1 TB that uploads 800 MB now shows
both. `occ backup:status` shows sizes in GiB where that reads better than MiB.
- `occ backup:cancel <run>` stops a running backup; `occ backup:status` shows the run numbers.
No new step of the run starts, the step in progress finishes (usually within a minute), then
the process working on the run (`occ backup:run` or cron) stops. What it already stored is
removed by the next cleanup. A backup started by an earlier version is stopped as well: after
two minutes it is marked cancelled while it works, and its process stops at the end of the
step.
### Fixed
- Backups to SMB locations, and to other slow locations with many packs, slowed to a crawl: each
30-second step opened the location again, and reading the list of index files took longer
than the step itself. Each step then stored a single 4 MiB pack, which made the list longer
still. A first full backup of 0.8 TB to a Hetzner Storage Box over SMB managed 12 GiB in 35
hours. `occ backup:run` and system cron now open the location once per run, opening no longer
counts towards the step's time, and SMB folders are listed in one request instead of one per
file. A backup to an SMB location that is not getting anywhere keeps running in its old
process after the update: update first, then stop it with `occ backup:cancel <run>` and start
it again. The new backup reuses the packs the old one stored.
## [0.9.5-beta.6] - 2026-10-12
Bugfix release for SFTP locations and large repositories. Tested on a production instance with a
1 TB repository on a Hetzner Storage Box over SFTP: a check in 5.5 minutes, a deep check of all
316,529 blobs (815 GiB) in 11 hours, and an incremental backup in under 8 minutes.
### Added
- Progress of a running backup in the web interface and `occ backup:status`, e.g.
"running (files) 68%". The total is the size of the previous snapshot; for a first backup it
is estimated from Nextcloud's file cache (users' files, versions and trash) and shown as
"~68%".
- Edit a backup location (web interface and `occ backup:target:edit`): change the password, host,
folder, connection type or name without losing its history. Before this the only option was a
new location, which starts an empty repository and, with a retention lock at the provider,
keeps paying for the old one. The new settings are only saved when they lead to the same
repository; stored passwords are never sent to the browser, and an empty password field keeps
the stored one. Administrators are notified of the change.
- `occ backup:verify` reports how far it is every 30 seconds, and `occ backup:status` shows the
progress of a running check and how the last check of each location ended. On a large
repository a check takes hours, a deep one days.
### Changed
- Checking and opening a large repository over SFTP is much faster: the index files (one per
pack, 27,547 for 1 TB) are downloaded 32 at a time instead of one after the other, 114 instead
of 6 files per second to a Hetzner Storage Box (about 4 minutes instead of 77 for 1 TB). A check
of a repository with more than 256 packs also lists the pack folders (256 requests) instead of
asking for every pack.
- App store listing: the author's e-mail address is shown.
- App store listing: the homepage link points to the GitHub repository instead of back to the
app store page itself.
- The notification for a location without a successful backup read "No successful backup to …
since never" when there had never been one; it now reads "No successful backup to … yet".
### Fixed
- 0.9.4 could not open a repository at the root of an SFTP location (empty path): its faster
SFTP reads sent their first request before phpseclib had opened the SFTP channel, which only
happens on the first SFTP call after login. Every backup then failed with "No repository at
this location". The channel is now opened first.
- A check (`occ backup:verify`) did not renew its hold on the location while it downloaded the
index; on a large repository that takes longer than the hold lasts, so a weekly cleanup could
have started during the check.
- A long first backup could end as failed although its snapshot was complete: when its final
step took longer than the step's lease (150 s with `occ backup:run`), the background job took
the run over, finished it a second time and failed on the snapshot it had already recorded,
overwriting the result. The lease now lasts at least 15 minutes, a step writes the run only if
no other process wrote it since the step loaded it (in one database statement), and recording
a snapshot that is already recorded is no longer an error.
- `occ backup:verify` on a large repository failed with "SFTP connection failed while opening
snapshots/…" or "Not found: snapshots/…". A Hetzner Storage Box drops an SFTP connection that
carried a lot of data (some 40 MB) and then stayed quiet for a few minutes, and the check went
quiet after downloading the index: it refreshed the local index cache, first in 29 minutes (a
pass over all blobs for every pack: 27,547 packs, 400,000 blobs for 1 TB), then still in more
than 6 minutes (a database transaction per pack on PostgreSQL). Nextcloud's SFTP storage keeps
using a dropped connection, on which every file looks missing. Now:
- an SFTP connection that was not used for a minute is checked first, and one that turns out
to be dead after a failed request is replaced, once per request; uploads, moves and deletes
are only checked first, never repeated;
- the index cache is written in one transaction (27,547 packs in 11 seconds on a test server,
against about a minute row by row);
- after a check the cache holds the index files as they are: before, a blob that was in two
packs was only cached for one of them.
## [0.9.4-beta.5] - 2026-10-09
Bugfix release: faster restores.
### Fixed
- Restores and `occ backup:verify --deep` fetched every blob with its own request, so on a
remote location they ran far below the speed of the connection. The blobs of a pack are now
read from one open stream, in the order they were written. Restoring 86 MB in 202 files from a
Hetzner Storage Box over SMB went from 0.78 MB/s to 4.2 MB/s, the speed of reading the packs
as plain files over that connection.
- Reading from an SFTP location waited a full round trip for every 256 KiB, whatever the line
speed. Reads now keep up to 8 requests in flight. Restoring 1.97 GB in 500 files from a Hetzner
Storage Box (47 ms away) took 694 s before the fix above, 499 s with it and 142 s with both
(2.8 → 3.9 → 13.9 MB/s); a full 51 GB restore ran at 13.0 MB/s. When a server error
interrupts such a read, it goes on from the same position with Nextcloud's own SFTP stream;
after three failing files in a row, or on a Nextcloud whose SFTP library lacks what this needs,
only Nextcloud's own stream is used.
- README: a WebDAV location on a slow upload needs a higher `davstorage.request_timeout`.
## [0.9.3-beta.4] - 2026-10-08
Bugfix release.
### Fixed
- A backup failed with "cannot execute INSERT in a read-only transaction" on PostgreSQL as soon
as the database dump filled a whole pack (32 MiB): the pack's index cache row was written
inside the read-only dump transaction. On other databases that row was rolled back silently.
- Backups to a remote location slowed down as the repository grew: every backup step listed the
repository's index with a round trip per file. Over SFTP to a Hetzner Storage Box a 60 GiB
repository already lost a large part of each step to it; backing up continuously
(`occ backup:run`) went from 45 KiB/s to 5.8 MB/s with the fix.
## [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
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
@@ -42,3 +605,19 @@ and not implemented yet.
- No FTP/SMB coverage in CI yet, and no verification/checksum-audit command yet (phase 2).
- 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.1]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.1
[1.0.0]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0
[0.9.12]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.6
[0.9.11]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.5
[0.9.10]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.4
[0.9.8]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.2
[0.9.7]: 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
[0.9.3-beta.4]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.3-beta.4
[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.1.0-alpha.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.1.0-alpha.1
+380 -1
View File
@@ -276,7 +276,7 @@ onderdelen wel en niet terugkomen, op basis van de geregistreerde migrators.
| `backup:list [--target=]` | herstelpunten |
| `backup:verify <snapshot> [--deep]` | checksums controleren, optioneel alles ontsleutelen |
| `backup:restore:file <snapshot> <user> <pad> [--to=]` | bestand/map terugzetten |
| `backup:restore:user <snapshot> <user> [--to-user=]` | gebruiker terugzetten via user_migration (fase 2) |
| `backup:user:restore <snapshot> <user> [--to-user=]` | gebruiker terugzetten via user_migration (fase 2) |
| `backup:restore:full <snapshot>` | volledige restore (fase 2, verse installatie) |
| `backup:target:add/list/remove/test` | opslagdoelen beheren en testen |
| `backup:key:export / import` | herstelkit |
@@ -338,6 +338,385 @@ een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
Volgorde: fase 1 grondig testen, reviewen en debuggen; dan nadenken over 0.1.0-alpha.1. Een
minimale volledige restore (`occ backup:restore:full` op een verse installatie) komt vóór de beta.
**Roadmap-indeling fase 2 (besluit 2026-10-05):** naar v1.2.0 toewerken met in fase 2:
1. `backup:restore:full` (disaster recovery, verse installatie) — **klaar** (2026-10-05)
2. FTP/SMB-ondersteuning — **klaar** (2026-10-06): beide liepen al via files_external's generieke
adapter zonder enige ng_backup-code; alleen testdekking toegevoegd
(`tests/integration/targets-matrix.sh`) + bevindingen in README ("Storage backend experiences").
SMB vereist de `smbclient` PECL-extensie (niet standaard in de Nextcloud-image, in CI nu
on-the-fly gebouwd). NFS is geen eigen backend: lokaal mounten + `local`-backend erop richten.
3. Verificatie/checksum-audit-commando — **klaar** (2026-10-05)
4. Retentie/prune voor user-exports — **klaar** (2026-10-06): `occ backup:retention --user-last=N`
(standaard 3, per installatie, net als de bestaande snapshot-policy), toegepast door
`occ backup:prune` (geen prullenbak voor user-exports, in tegenstelling tot snapshots — on-demand,
niet het primaire vangnet).
5. Automatische user-export in het reguliere schema — **naar backlog** (niet voor deze release; on-demand blijft)
9. SMB-roundtrip (protocol, niet een specifieke server) — **klaar** (2026-10-06): CI-matrix, 2 GB-roundtrip en
user-export via SMB; NFS via de OMV-share getest (`local`-backend op een gemounte share, 2026-10-06)
10. `backup:user:restore` zonder user_migration: vraagt om toestemming om de app te installeren en te activeren (of
`--install-user-migration`) — **klaar** (2026-10-06); waarschuwing over de settings-allowlist — **klaar**;
agenda/contacten bij migratie naar een server zonder die gebruiker — **getest** (2026-10-06, naar `migrated-personal`/`migrated-contacts`)
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).
**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.
**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.0.0 (besluit 2026-10-18):** alleen betrouwbaarheid. rc2 uitgebracht; slaagt de grote
DR-test op VM 980 (snapshot van 12-10, met `--skip-missing-tables`), dan 1.0.0. Zo niet: fix,
rc3, en rc3 pas uitbrengen na een geslaagde volledige DR-test.
- **rc4 (besluit 2026-10-19):** rc3 is niet uitgebracht. De DR-test met rc3 liep ~2,8 MiB/s
(~3,5 dag); parallel lezen over SFTP haalde vanaf VM 980 ~7,6 MiB/s. Daarom rc4 = rc3 +
vooruitlezen over SFTP (4 eigen verbindingen). Volgorde: volledige DR-test op VM 980 volgens de
gids (SFTP, Storage Box) → rc4 uitbrengen en om tests vragen → zelf een volledige DR van een
productie-instantie van ~250 GB via S3 en via SMB. Drie volledige DR-restores vóór 1.0.0.
Problemen of meldingen van gebruikers: rc5.
- **1.0.2** (plan akkoord 2026-11-04): punten 1, 2 en 5 hieronder, de Bin, nieuwe screenshots en
het polish-punt over de schatting van `target:add`. Besluiten 2026-11-04:
- `--salvage` haalt ook bij een kapotte database terug wat kan: bestanden zijn per gebruiker
gescheiden en het waardevolst. Kan de database niet terug (dump onleesbaar, of past niet op
deze installatie), dan blijft de verse database staan en gaan data en config van de backup
naar `<datadir>/ngb-salvage-<snapshot>/`; vanaf daar is het handwerk. Exitcode 2.
- De DR-test met een backup mét tabelstructuur, zonder `--skip-missing-tables` (punt 3), is
nog niet gedaan en wordt ingepland vóór de release van 1.0.2. Ingepland (2026-11-04): een
verse snapshot van nc01 (gemaakt met 1.0.1, dus met tabelstructuur) volgens de DR-gids
terugzetten op VM 980 met de 1.0.2-build, over **SMB** (Storage Box; besluit 2026-11-04:
SMB in plaats van SFTP, zodat de DR-test de nieuwe SMB-leescode op echte schaal en over de
echte lijn vanuit Canada test; vorige meting 2,6 MiB/s, verwachting ~1 dag voor 1 TB), zonder
`--skip-missing-tables`; daarna `--salvage` op een deel van de bestanden (moet hetzelfde
resultaat geven, exitcode 0). Uitvoeren met Dillard, die VM 980 klaarzet; deze week, vóór
de release. Screenshots akkoord (2026-11-04).
**DR-test 1.0.2 over SMB (VM 980, snapshot a86271ed van nc01, 983 GiB, gestart 2026-11-04):**
index lezen 8 uur (~1 bestand/s; daarna versneld naar 0,66 s/bestand, gemeten vanaf VM 980);
restore zonder opties (geen `--install-apps`, geen `--skip-missing-tables`) liep ~12 MiB/s
(SFTP eerder 2,8-7,6; SMB vóór 1.0.2 2,6). Na 392 GiB viel de SMB-verbinding weg en bleef de
restore op 100% CPU hangen: de smbclient-extensie geeft bij een mislukte read een string met
lengte -1. Opgelost (controle + nieuwe verbinding), plus hervatten: `--force` laat bestanden met
dezelfde grootte en mtime staan. Hervat 2026-11-05 07:21 UTC na `maintenance:mode --off`
volgens de gids; binnen 10 min voorbij de 392 GiB. SMB op sub5 na de test weer uitzetten.
**Resultaat (klaar 2026-11-06 02:11 UTC, nazorg 02:27):** hervatte run 18 u 50 min, exit 0, 150.755
bestanden; 7-13 MiB/s (langzamer vanaf ~18 uur Duitse tijd, oorzaak niet vastgesteld). Vergeleken
met nc01: 150.467 van 150.758 bestanden gelijk in grootte en mtime, 397 op hash zonder verschil;
gebruikers, agenda's, contacten, shares en Deck gelijk; bestanden, agenda en contacten via WebDAV
getest als gebruiker. `backup:restore --salvage` op 16.904 bestanden: exit 0, geen verschil. Niet
vastgesteld: de 50.080 "updated" bij `files:scan`. Zonder `--install-apps` 51 van de 81 apps aan.
VM's 980-982 en de sim-backups op OVH en sub6 opgeruimd.
**Naar 1.0.3 (besluit 2026-11-06):** lege mappen komen niet terug (3.982 op nc01, 2.076 daarvan in
`files`, verklaart vrijwel zeker de 2.370 "removed" bij `files:scan`). De backup legt alleen
bestanden vast (`walk()`), dus oudere snapshots hebben ze niet. Voor een DR acceptabel, wel
fixen; issue #27. Daarna kleine volledige simulaties; een DR van 1 TB is hiervoor niet nodig.
**Salvage vóór de release getest (besluit 2026-11-06):** nieuwe modus `salvage-files` in
`full-restore-roundtrip.sh`, ook in CI: één pack beschadigd en één weg. Eerst stopt de restore,
daarna `--salvage --force` volgens de gids. Vond één fout: bij een ontbrekend bestand wees de
melding niet naar `--salvage` (opgelost).
Stand 2026-11-04 (`polish`, gepusht): cron-waarschuwing + tick vanaf de pagina +
voortgang van de db-fase; `--salvage` voor `backup:restore` en `backup:restore:full`; S3
vooruitlezen (8 ranges tegelijk, met `SaveAs` naar de spool); SMB in leesacties van 8 MiB
(gemeten met netem, 130 ms RTT: 4,5 → 18 MiB/s per leesactie; een volledige restore over SMB
met vertraging is nog niet gemeten); Bin-tabblad; schatting `target:add`; nieuwe screenshots
(1–3 vervangen, 4-bin erbij; in `info.xml` bij de release naar tag v1.0.2 laten wijzen en
4-bin toevoegen). Getest: unit (208), psalm, integratie NC35 sqlite/pgsql, S3/SFTP/FTP/SMB,
volledige restore met `--salvage`. **1.0.2 uitgebracht 2026-11-06** (tag `v1.0.2`, PR #28).
- **Naar 1.0.3, uit de reviews van 1.0.2 (achteraf gedraaid, 2026-11-06):**
1. Lege mappen (#27).
2. Een volle schijf onder `--salvage` telt als beschadigde backup en de restore gaat door; zonder
`--salvage` wijst de melding er ten onrechte naar. `fwrite` controleren, schrijffouten stoppen de
restore (`Repository::restoreEntries`; ook `RestoreService::writeFile` nakijken). Beide reviews.
3. Hervatten: controle op de doelmap vóór het overslaan, en tijdelijke bestanden ook opruimen in
een map waar alles al klopt.
4. Alleen `.ngb-restore-spool-<hex>` opruimen.
5. Salvage-test: tijdelijke bestanden recursief zoeken; `admin` en `frtest` apart controleren.
6. Nog nakijken: lease verversen bij overgeslagen bestanden; fout ná de database-commit
(`restoreOwnAppConfig`) en tabellen die na een rollback blijven staan; backups zonder cron
via de pagina (eerlijke beurten, stappen die de webtimeout halen); Bin verversen en fouten
tonen; SMB-uitzondering bij het openen van een pack onder `--salvage`.
Niet doen: bij hervatten de inhoud vergelijken (grootte en mtime is ook het criterium van de backup).
**Stand op branch `1.0.3` (2026-11-06):** punten 1-6 gebouwd. Lege mappen staan in het metarecord
van de snapshot, niet tussen de bestanden: 1.0.2 en ouder lezen zo'n snapshot gewoon en slaan alleen
de lege mappen over. Geen webtimeout-wijziging zonder cron: alleen in de code nagelopen, niet getest
(Nextcloud zet de PHP-tijdslimiet ruim, een gestopte stap wordt overgedaan); wel eerlijke beurten.
Bin leest in delen met teller en balk (besluit 2026-11-06, ook met oog op meerdere backups per
dag). Getest: unit (volle schijf op echte tmpfs), integratie NC35 sqlite/pgsql/mysql, sftp, salvage,
salvage-files; Bin in een echte browser met vertraagde verzoeken (1 van 7 t/m 7). Nog: reviews
vóór de PR, README-regels bij de versiecommit.
**Simulaties (2026-11-06, NC 34.0.4, 2 GB, lokale SFTP, `~/dev-local/ngb-sim/sim.sh`):** normaal
(pgsql), stop en hervat (pgsql → MariaDB, restore met kill -9 gestopt), volle schijf met `--salvage`:
alle drie geslaagd, alle 3.101 gebruikersbestanden en 7 lege mappen gelijk, share, agenda en WebDAV
werken. Vond één fout, opgelost: na een kill bleven vergrendelingen tot 2 uur staan, waardoor de
backup van stap 11 werd geweigerd. `files:scan` "Updated" zijn in de gemeten run alleen mappen
(mtime van mappen wordt niet teruggezet, dus nieuwe mtime en etag; bestanden ongemoeid). "Removed":
o.a. `cache` van een gebruiker, die de backup uitsluit. Waarschijnlijk verklaart dit ook de 50.080
"updated" op nc01, niet op nc01 zelf vastgesteld.
**Naar 1.1.0 of 1.2.0 (besluit 2026-11-06):** alle mappen krijgen bij een restore hun oorspronkelijke
tijd terug. Hoort zo, vooral bij oudere verzamelingen: de maptijd wordt gebruikt om in te schatten
wanneer een map is aangemaakt. De backup moet dan de tijd van alle mappen vastleggen en de restore
die aan het eind terugzetten; werkt pas vanaf de eerste backup met die versie. Op de roadmap onder 1.1.0.
**1.0.3 uitgebracht (2026-11-06)**, ook op nc01 geïnstalleerd.
- **Branches (besluit 2026-11-06):** werk voor een versie op een branch met die naam (`1.0.3`);
`polish` alleen voor kleine cosmetische zaken, per geval bekijken of en wanneer die gemerged
worden. SMB staat uit op sub5 en sub6; sub6 is het testaccount, eerst aanzetten voor een SMB-test.
Na 1.0.3 gaat het werk naar `1.1.0`. Een spoedfix die niet kan wachten gaat op `1.0.4` vanaf `main`,
krijgt een eigen merge en release; daarna `main` in `1.1.0` mergen, zodat daar met de fix getest wordt.
- **1.0.1** (uitgebracht 2026-11-01): alleen de kleine punten (besluit 2026-10-30): 4, 6–13. Punten 1, 2 en 5 gaan naar
1.0.2 (README-roadmap aangepast); punt 3 is een test, ingepland voor de week van 2 november,
niet aangekondigd en los van de release. Voor 1.0.2 ook: nieuwe screenshots (het labelveld bij
"Back up now" en het tabblad Docs staan er nog niet op; besluit 2026-11-01), en de prullenbak
van herstelpunten naar een eigen tabblad "Bin" naast "Backup and restore", "Settings" en "Docs":
onder de lijst met herstelpunten vervuilt hij het scherm (vooral met wat het bewaarbeleid erin
zet; besluit 2026-11-01). Nieuwe screenshots daarna, zodat de Bin er ook op staat.
1. **Naar 1.0.2.** (Naar rc4: herstelsnelheid over SFTP.) S3 leest al 16 tegelijk bij index en tree, maar een
restore leest de packs nog één voor één: ook vooruitlezen via de S3-client.
2. **Naar 1.0.2.** Achtergrondjobs zonder systeem-cron (issue #13; besluit 2026-10-19). De PR die dit
oplost zegt "Fixes #13" (bevestigd: #13 was de cron; besluit 2026-10-21):
a. **Waarschuwen** in de beheerpagina en `backup:status`: modus uit `core backgroundjobs_mode`
(`ajax`/`webcron`/`cron`) en de laatste run uit `core lastcron`. Niet op `cron`, of de
laatste run te lang geleden: melding met de cron-regel erbij ("Backups only move while
someone has Nextcloud open"), en "waiting for background jobs" als een lopende backup lang
geen stap zette.
b. **De pagina laat de backup lopen** zolang ze openstaat: in `ajax`/`webcron` voert de
statusaanroep die de pagina toch al herhaalt ook een korte, begrensde stap uit (zoals de
korte stappen die AJAX-cron nu al krijgt). "Back up now" werkt dan ook zonder cron zolang
het tabblad openstaat; de melding zegt dat geplande backups echte cron nodig hebben.
De melding zegt (zonder werkende systeem-cron): backups hebben systeem-cron nodig, ook de
gewone nachtelijke; zo stel je cron in (link naar de Nextcloud-documentatie over
achtergrondjobs voor de versie van de server, nu
https://docs.nextcloud.com/server/34/admin_manual/configuration_server/background_jobs_configuration.html,
versienummer uit de server, niet vast); zolang er geen cron is, loopt een backup die je
met de knop start alleen door terwijl de NG Backup-pagina openstaat.
c. **Voortgang in de databasefase** (issue #13): nu staat er alleen "running (db)", wat er
hetzelfde uitziet als vastzitten. Toon "running (db: 47/183 tables)" zoals de bestandsfase
een percentage toont, en "waiting for background jobs (last ran 12 min ago)" als een run
een tijd geen stap zette, ook bij een cron die is blijven staan.
Niet doen: de cron-modus vanuit de app omzetten (zonder echte cron-regel stopt alles, en
`cron.php` zet de modus zelf op `cron`), en geen `exec php occ backup:run &` vanuit de knop
(vaak uitgeschakeld, niet op hosted, buiten Nextcloud om lastig te volgen en te stoppen).
3. Volledige DR-test met een backup die al de tabelstructuur bevat (gemaakt met rc2 of later),
zonder `--skip-missing-tables`: dat pad is op 1 TB nog niet getest.
4. **Label bij "Back up now"** (besluit 2026-10-19): een optioneel tekstveld naast de knop; de
tekst gaat als label van de snapshot mee (nu kan dat alleen met `occ backup:run -l`; de knop
stuurt een leeg label, geplande runs krijgen "scheduled"). Raakt alleen de UI en een
bestaande parameter. Ten hoogste 100 tekens — **klaar** (2026-10-30).
5. **`--salvage`** voor `backup:restore` en `backup:restore:full` (besluit 2026-10-22): terughalen
wat kan, een bestand dat niet te lezen of te ontsleutelen is overslaan en aan het eind
opsommen, in plaats van stoppen. **Naar 1.0.2** (besluit 2026-10-30).
6. **Tijdelijke bestanden van een afgebroken restore opruimen** (gevonden in de SMB-DR van
rc6, 2026-10-24): de rc5-poging die stopte liet `VID_0009.mp4.ngb-restore-<id>` staan; de
geslaagde restore daarna haalde het niet weg, en `files:scan` maakte het zichtbaar in Files.
Een restore ruimt `*.ngb-restore-*` in de mappen die hij terugzet op (alleen bestanden met
precies dat achtervoegsel en 6 of 12 hextekens) — **klaar** (2026-10-30).
7. **Eén bestand terugzetten met `backup:restore`**: een pad naar een bestand zet 0 bestanden
terug (er komt een `/` achter); nu werkt alleen een map. Gold alleen voor `--to-directory`;
naar de bestanden van een gebruiker werkte een los bestand al — **klaar** (2026-10-30).
8. **Uitleg (i) bij de prullenbak van herstelpunten** (besluit 2026-10-25): bij "removed by
retention policy" een infoknop met wat het betekent: het bewaarbeleid houdt voor oudere dagen
één herstelpunt per dag (het laatste), weken en maanden; de rest gaat naar de prullenbak en
wordt pas na de ingestelde wachttijd (`delete_delay_days`, standaard 30) echt verwijderd, en is
tot die datum terug te zetten. Aanleiding: de beheerder zag "19-10 13:12 – after rc4 upgrade
removed by retention policy" en dacht dat er iets was weggegooid. `occ backup:trash` zegt
hetzelfde onder de lijst — **klaar** (2026-10-30).
9. **Tijdzone in `occ backup:status`**: de tabel toont de servertijd (UTC), de webinterface de
tijd van de beheerder; dat leest als een verkeerde starttijd. Zet de zone in de kolomkop
("started (UTC)") of toon de tijden in de zone van het schema. Gedaan: beide, en ook in
`backup:list`, `backup:trash` en `backup:browse` — **klaar** (2026-10-30).
10. **User-restore op gebruikersnaam** (issue #22, 2026-10-27): `backup:user:restore <locatie>
New_User` gaf alleen "is not a recorded user export"; het argument moet het manifestpad
zijn, dat alleen `backup:user:backup` eenmalig toonde. Nu: `backup:user:list` toont de
exports, een kale uid werkt bij precies één export, anders een lijst om uit te kiezen —
**klaar** (2026-10-27).
11. **Tabblad "Docs" in de beheerpagina** (besluit 2026-10-27): een derde knop naast "Backup and
restore" en "Settings", met links naar de HOWTO (`docs/HOWTO.md`), de DR-gids
(`docs/DISASTER-RECOVERY.md`) en de roadmap (README, `#roadmap`) op GitHub, elk met een
regel wat erin staat. Nu zijn ze alleen via de README te vinden. Zichtbaar ook zonder
sleutel, zodat de gids vindbaar is vóór de eerste installatie-stap. "NG Backup is in beta"
gaat uit de feedbackregel — **klaar** (2026-10-27).
12. **SFTP-sleutel zonder regeleinden** (issue #24, 2026-10-28): het veld "Private key" op de
beheerpagina is één regel, dus een geplakte sleutel verliest zijn regeleinden. NC34
(phpseclib 2) las dat nog, NC35 (phpseclib 3) weigert een OpenSSH-sleutel dan met "Unable
to read key". De regeleinden worden nu teruggezet vlak voor gebruik — **klaar** (2026-10-28).
13. **"database is locked" bij het begin van `backup:restore:full` op SQLite** (CI, 2026-10-26
bij #22 en 2026-10-29 op `main` na de merge van 1.0.0): de restore stopt meteen na het
aanzetten van de onderhoudsmodus. Niet altijd, dus een tweede proces dat op hetzelfde moment
de database gebruikt (cron, een webverzoek of de opstart van de container). Kan ook bij een
echte restore naar SQLite gebeuren. Oorzaak (2026-10-29): SQLite (WAL) begint een transactie
als lezer; schrijft een ander proces tussen ons eerste lezen en ons eerste schrijven, dan
faalt dat schrijven direct (geen wachttijd helpt). In CI was dat de installer, die
"installed" zet en daarna nog seconden doorschrijft. Nu neemt de restore eerst het
schrijfslot en wacht hij op de ander; de SQLite-test draait met een tweede schrijver
ernaast — **klaar** (2026-10-29).
- **DR-tests over databasegrenzen** (besluit 2026-10-20; productie nc01 draait PostgreSQL): na
de MariaDB→PostgreSQL-fout (tellers, rc5) elke overstap in de integratietest (`to-pgsql`,
`to-mysql`, `to-sqlite`, ook in CI). Volle DR-tests met echte data: vóór 1.0.0 de S3-simulatie
(MariaDB-bron, 138 GB) nogmaals naar een MariaDB-doel en de SMB-simulatie naar een SQLite-doel;
**na 1.0.0** de 1 TB van nc01 (PostgreSQL) naar een MariaDB-doel.
- **Polish:** `target:add` toonde bij het eerste indexbestand al "about 642 min left" (S3,
4.568 bestanden, daarna binnen een minuut klaar): een schatting pas na ~5% of na meer dan
een paar bestanden.
- **1.1.0** (geen harde datum: een week was een voorzet, langer mag; de releasefrequentie mag omlaag,
besluit 2026-11-06):
**Betere melding voor namen die een snapshot niet kan bevatten** (backslash, stuurteken, geen UTF-8):
een eigen melding in plaats van "unreadable" met het advies over rechten. Zegt dat deze bestanden of
mappen in de backup ontbreken, geeft voorbeelden van de paden en vraagt ze te hernoemen; de volgende
backup neemt ze mee. Alleen als Nextcloud-melding: de app mailt niet zelf, of meldingen gemaild worden
is de keuze van de beheerder (besluit 2026-11-06).
**Stand op branch `1.1.0` (2026-11-07):** klaar: betere melding, punten 2, 4, 5, 6 en 8, en de maptijden
(`files:scan` na een volledige restore: 0 "Updated" in de simulatie; opslag gepakt in het metarecord).
Punt 2 reserveert de jobs in `oc_jobs` met één tijdstempel, alleen wat Nextcloud op dat moment zou kunnen
starten; 6 uur aan, dan 1 uur vrij; bij een crash geeft Nextcloud de reservering na 12 uur zelf vrij;
de logregels staan op niveau info, dus standaard niet zichtbaar. Pin en notitie staan
in de catalogus, met een kopie in de database voor de lijsten. Een moment van vóór het instellen van
een schema start niets. Geen schema: geen melding "geen recente backup", wel een melding aan alle
beheerders bij het uitzetten en een herinnering, wekelijks zolang een andere locatie een schema heeft,
anders dagelijks (besluit 2026-11-07). Nog niet getest: de update vanaf 1.0.3, de pagina in een browser.
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.
**Gebouwd (2026-11-07):** `backup:target:add` op een server zonder sleutel kijkt eerst of de
locatie een repository heeft, vraagt dan een wachtwoordzin (of `--passphrase-file`), opent de
slots uit `config`, controleert de sleutel tegen de `check` en slaat hem pas dan op; geen
kitbevestiging, dus backups wachten op een nieuwe kit. Nieuw bestand `settings` op de locatie,
versleuteld met de hoofdsleutel en eenmalig geschreven (bij `target:add` en bij het begin van
een backup): de verwijdertermijn, anders de standaard 7 dagen. Getest: unit-tests en
`full-restore-roundtrip.sh 35-apache pgsql no-kit`. Nog open: de DR-test op PVE02 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.
**Gemeten 2026-11-07 op nc01 (alleen lezen):** de schijf (sdb, 2 TB, ROTA=1) haalt met `dd`
21 MiB/s met 1 stroom, 37 met 4, 55 met 8. PHP las 8 KiB per verzoek: 5,2 MiB/s. Met
`stream_set_read_buffer(0)` en blokken van 4 MiB: 26,8 MiB/s. Hulpprocessen die vooruitlezen
gaven daarbovenop niets (20 MiB/s, ze concurreren). Gebouwd: grote leesverzoeken (commit
"Read files ... 4 MiB"). Niet gebouwd: meerdere uploads tegelijk; SMB bundelt al per 1 MB
(icewind/smb), dus winst over een lange lijn vraagt meerdere packs tegelijk.
**Besluit 2026-11-08: meerdere uploads tegelijk in 1.2.0**, met een instelling (standaard 2)
zodat de beheerder het afstemt op zijn server en de omvang van zijn backup.
4. **Schema per locatie** (besluit 2026-10-19): in de instellingen van een locatie "uit",
"dagelijks om HH:MM" of "elke N uur (vanaf HH:MM)". Nu is er één `schedule_time` voor alle
locaties, één keer per dag. Bij de update wordt de huidige tijd het schema van elke
bestaande locatie (voor bestaande gebruikers verandert er niets).
5. **Locaties na elkaar laten starten** in plaats van tegelijk: twee runs die dezelfde schijven
lezen zijn samen niet sneller (gezien op nc01); wachtende runs zichtbaar als "queued".
7. **SMB sneller** (gemeten 2026-10-21): lokaal netwerk zonder vertraging (Samba-container op de
laptop, 1,8 GB) back-up 24,9 MB/s, restore 27,6 MiB/s; vanaf Canada naar de Storage Box (130 ms)
2,6 MiB/s, omdat SMB per blok op het antwoord wacht. Grotere schrijfblokken, en vooruitlezen
bij een restore zoals SFTP (IReadsAhead) of meerdere packs tegelijk. Een NAS met 1 Gbit haalt
~110 MB/s: daar is winst.
Ook de index: `target:add` over SMB vanaf Canada haalt ~1 indexbestand per seconde (4.927
bestanden ~85 min), want SMB krijgt geen `getMany()` met verzoeken tegelijk zoals S3 en SFTP.
6. **Notitie achteraf bij een restorepunt**, te wijzigen in de lijst. Het label in de snapshot
is versleuteld en ligt vast; de notitie apart op de locatie bewaren (bijv. in de catalogus),
zodat een nieuwe server na een DR hem ook ziet.
8. **Herstelpunt vastpinnen** (besluit 2026-10-25): een herstelpunt dat het bewaarbeleid
nooit opruimt (bijv. "vóór de upgrade naar NC 35"), in de lijst en met
`occ backup:pin <snapshot>` / `--unpin`. Op de locatie bewaren (catalogus), zodat de
opschoning het ziet, ook na een DR; vastgepinde punten tellen niet mee in last/daily/weekly.
9. **Feedbackvraag weghalen in 1.2.0** (besluit 2026-11-07, niet in 1.1.0): de regel "We would like
to hear how NG Backup works for you ... Share your experience" in de beheerpagina blijft tot
dan staan. Er komt weinig feedback bij zo'n 150 downloads per versie; geen bericht is goed
bericht. Het formulier (`experience.yml`) in de issues blijft.
10. **Alleen de database terughalen, CLI-only** (besluit 2026-11-10). Aanleiding: op nc01 liep
de automatische AIO-update 34 → 35 vast op een migratie van de (uitgeschakelde) app
files_archive; AIO had alle apps uitgezet en hun status alleen in het geheugen bewaard, dus
wat aan stond (en voor welke groepen) was weg. Uit de database in het restorepunt van 02:45
is `oc_appconfig` (rijen `enabled`) met een eenmalig script gelezen. Te bouwen:
`occ backup:restore:db <locatie> <snapshot>` met `--tables=` (één of meer tabellen) en
`--to-directory=DIR` (per tabel een leesbaar bestand, zonder iets aan de database te doen),
of zonder `--to-directory` de gekozen tabellen terugzetten in de draaiende instantie, in
onderhoudsmodus, met dezelfde controles als `restore:full` (schema, eigen ngb-rijen blijven).
Eventueel `--where` voor alleen sommige rijen (zoals `configkey = 'enabled'`). Geen web-UI:
daar kan een beheerder weinig mee.
- **Bevindingen DR-test op PVE02 (2026-10-17, snapshot van nc01, 1 TB, AIO → gewone NC 34 met
PostgreSQL 18)** — voor **1.0.0-rc.2**:
1. Tabellen van apps die bij de backup **niet aanstonden** (restanten: Deck, ONLYOFFICE,
AppAPI, een archief-app) blokkeren de restore met één tabelnaam per poging. Standaard
overslaan met één waarschuwing per app; optie om ze toch terug te zetten.
2. Alle ontbrekende apps en tabellen **vooraf in één lijst**, per app benoemd (nu één per poging,
ook voor uitgeschakelde standaard-apps zoals `admin_audit`).
3. `--skip-missing-apps`; `nextcloud-aio` standaard overslaan (bestaat buiten AIO niet), zodat
AIO → gewone Nextcloud werkt.
4. `--install-apps`: ontbrekende apps uit de app store installeren.
5. Apps buiten de app store (eigen builds, `side_menu`, `nextcloud-aio`): documenteren; later
eventueel `custom_apps` meenemen in de backup.
6. Tabellen van een app die wél aanstond maar die hun tabel later zelf maakt (Memories:
`memories_planet_geometry`) netjes afhandelen.
7. `backup:target:add` zonder sleutel verwijst naar `key:init`; als er al een repository staat,
moet het naar `key:import <kit>` wijzen.
8. `backup:target:add` toont geen voortgang bij het ophalen van de index (27.582 bestanden).
9. `backup:target:remove` laat de index-cache van die locatie in de database staan; opruimen,
ook bestaande wezen.
10. Snapshot-meta uitbreiden: ook geïnstalleerde maar uitgeschakelde apps, met per app of hij
uit de app store komt; `occ backup:apps <locatie> <snapshot>` toont de lijst leesbaar
(geen platte tekst op de locatie: dat lekt welke apps/versies er draaien).
11. **Tabelstructuur meedumpen** (kolommen, typen, indexen, primary key), zodat de restore een
ontbrekende tabel zelf aanmaakt met de gegevens erin. Lost restanten van oude apps op zonder
ze over te slaan; een later opnieuw geïnstalleerde app vindt zijn oude gegevens terug.
12. **Foreign keys**: de restore zet tabellen op alfabet terug; `oc_mail_accounts` verwijst naar
`oc_mail_mailboxes` en PostgreSQL weigert (fk_61a7e87f59a77cdc). Iedereen met Mail loopt hier
vast. Oplossing: in de juiste volgorde terugzetten (afhankelijkheden eerst), of de controle
tijdelijk uitzetten en na afloop terugzetten en controleren. In de test: 15 FK's (alle van
Mail) tijdelijk verwijderd en na de restore teruggezet: alle 15 geldig op de teruggezette
data, dus "uitzetten, terugzetten, achteraf controleren" werkt.
13. **SFTP-restore over een lange lijn is traag**: 0,75 MiB/s vanaf Hetzner naar Canada (RTT 122 ms),
terwijl OpenSSH `sftp` 16,6 MiB/s haalt. De SFTP-stream van files_external (phpseclib) wacht na
elk klein blok op antwoord. Oplossing: pack-ranges bij SFTP met phpseclib's eigen `get()`
(veel verzoeken tegelijk) lezen in plaats van via de stream, zoals bij S3. 1 TB duurt nu ~2 weken.
Na meten: de grootste oorzaak was het netwerk (cubic, zie 14): met BBR gaat de restore van 0,75
naar 2,3 MiB/s, een hele pack lezen 4-5 MiB/s. Meer verzoeken in één stream (DEPTH 16/32) gaf geen
duidelijke winst. Verder komen vraagt meerdere verbindingen tegelijk: naar **1.1.0**.
14. **Uploaden over een lange lijn**: backup naar OVH S3 Warschau vanaf Canada (RTT 114 ms) haalt
~1 MiB/s met één pack tegelijk; `aws s3 cp` (multipart, parallel) 6 MiB/s. Meerdere packs tegelijk
uploaden of S3 multipart. Minder dringend dan 13 (restore).
Oorzaak netwerk (met Sysop): pakketverlies trans-Atlantisch + cubic in de VM; met BBR gaat één
PUT van 2,4 naar 7,5 MiB/s, ng_backup van ~1 naar ~1,6 MiB/s (rest = serieel lezen/uploaden).
Tip over BBR in de README gezet. Let op: PVE02 staat in Canada; een restore in de EU gaat sneller.
15. DR-document: in het Docker-voorbeeld "je containernaam" (`docker ps`) i.p.v. `nextcloud`.
16. Grote restore (VM 980, snapshot aa7cce8d van de Storage Box) stopte na 22 uur met
`Not found: packs/26/2642ef39…`. Oorzaak: de wekelijkse opruiming van nc01 (zo 18-10) heeft
dat pakket tijdens de restore omgepakt (blob staat nu in `389567b7…`); de herstelserver had de
index van vóór de opruiming. Productie heel: `backup:verify` op nc01 OK (150.639 bestanden,
316.830 blobs). Opgelost in rc2: bij een mislukte pakketlezing de index herladen en de blob
lezen waar hij nu staat (test `testRestoreFollowsPacksRepackedByAnotherServer`).
**Kleine DR-test met rc2 (2026-10-18), volautomatisch geslaagd:** bron VM 981 (NC 34, PostgreSQL,
51 GB, Mail met gesynct account, Deck uitgeschakeld, Calendar/Contacts, admin_audit) → OVH S3 →
verse VM 982 met alleen de DR-gids en `--install-apps`: apps vanzelf geïnstalleerd, 10 Deck-tabellen
aangemaakt, 15 FK's in orde, 23.519/23.519 checksums gelijk, agenda/contact/mail/bord terug.
Restore 1 u 47 vanuit Warschau naar Canada (met BBR). Lokaal slaagt de test ook op SQLite en MariaDB.
**Daarna verplicht:** een tweede volledige restore op een verse VM, alleen met de HOWTO en de
nieuwe opties, zonder handwerk. Pas als die in één keer slaagt: 1.0.0.
- **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).
**Let op (ontdekt 2026-10-05):** Nextcloud's eigen `info.xsd` staat voor `<version>` alleen
`[0-9]+(\.[0-9]+){0,2}` toe — geen `-alpha`/`-beta`/`-rc`-achtervoegsel. De stabiliteitsaanduiding
uit deze tabel gaat dus niet in `info.xml` (dat blijft gewoon oplopend, bijv. `0.1.0`), maar in de
+220 -12
View File
@@ -2,12 +2,22 @@
Encrypted, incremental backups of Nextcloud (database, configuration and data) with restore
from the web interface. Works without shell access: no external programs, no extra PHP
extensions beyond sodium. Runs on Nextcloud All-in-One, regular installations and hosted
Nextcloud.
extensions beyond sodium for ng_backup itself. Runs on Nextcloud All-in-One, regular
installations and hosted Nextcloud. (Choosing SMB as a storage backend is the one exception:
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: in development (phase 1). Not ready for production use.** See `PLAN.md` for the design.
**Status: 1.1.0, stable.** Disaster recovery has been tested on real installations (see "Tested
disaster recoveries" below). 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. Server lost? See
`docs/DISASTER-RECOVERY.md`.
License: AGPL-3.0-or-later.
If you like NG Backup, don't forget to give the app a star in the
[app store](https://apps.nextcloud.com/apps/ng_backup) and this repository a star on GitHub!
License: AGPL-3.0-or-later. Developed and sponsored by [Ensembia](https://www.ensembia.com).
Need help restoring a backup after a crash? Ensembia offers paid restore assistance:
[www.ensembia.com](https://www.ensembia.com).
## How it works
@@ -19,16 +29,41 @@ 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.
## Requirements
- **Background jobs on system cron.** NG Backup runs backups in steps from Nextcloud's
background job, so a backup only moves forward when that job runs. Choosing "Cron" under
**Administration settings → Basic settings → Background jobs** is not enough on its own: it
tells Nextcloud that the system will call it, so you also need a cron line that does. Without
it scheduled backups do not run; the admin page and `occ backup:status` warn about it, and a
backup started with "Back up now" only moves on while the NG Backup page stays open.
`occ backup:run` in a terminal works without cron (it runs in the foreground).
- Regular server: `crontab -u www-data -e` and add
`*/5 * * * * php -f /var/www/nextcloud/cron.php` (your Nextcloud path).
- Docker (the official `nextcloud` image): on the host, `crontab -e` as root and add
`*/5 * * * * docker exec -u www-data <container> php -f /var/www/html/cron.php`, or add a
service with the same image and `entrypoint: /cron.sh` to your `docker-compose.yml`.
- Nextcloud All-in-One already runs cron.
After a few minutes, the Background jobs section shows that the last job ran just now. See the
[Nextcloud documentation on background jobs](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/background_jobs_configuration.html).
- A storage location: S3 or compatible, SFTP, WebDAV, SMB (needs the PHP `smbclient` extension,
see below), FTP or a local path.
## Quick start
```
occ backup:key:init --label="Safe" # first passphrase, deletion delay (default 7 days)
occ backup:key:slot:add --label="CTO" # optional second and third passphrase
occ backup:key:slot:add --label="Head of IT"
occ backup:key:kit --output=/root/ng_backup-kit.json
occ backup:key:kit --output=/var/tmp/ng_backup-kit.json
occ backup:key:confirm
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=... -o key=... -o secret=...
occ backup:run
@@ -43,9 +78,20 @@ 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) **one passphrase** is enough: the new
server takes the key from the location. With the **recovery kit** you can restore even when the
location's `config` file is damaged or gone.
- 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:
@@ -59,7 +105,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.
- 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
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
written to the audit log and is announced to all administrators.
@@ -104,7 +151,33 @@ What you should do on the location (strongly recommended):
- **Files and folders** of a user: in the web interface or `occ backup:restore`, into a new folder
("Restored <date>"), merged into the original place, or replacing it.
- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`.
- **Whole server** (disaster recovery onto a fresh installation): planned for phase 2.
- **Whole server** (disaster recovery onto a fresh installation): `occ backup:restore:full`, see
[docs/DISASTER-RECOVERY.md](docs/DISASTER-RECOVERY.md). It runs in maintenance mode, so that
nobody and no background job writes into the instance while it is being replaced.
- **One user** (account, settings, files, calendars, contacts via user_migration): `occ backup:user:backup` and
`occ backup:user:restore`, replacing the account or restoring it as `<uid>-bak`. See the how-to.
**Without shell access** (a hosted Nextcloud where you cannot run `occ`): files and folders can be
restored in the web interface. Restoring a whole user or the whole server needs `occ`, so on such
hosting it is something your provider (or whoever has shell access) runs. A restore mode that
works without maintenance mode, and so from the web interface, is on the roadmap.
## Tested disaster recoveries
Full restores onto a fresh server, following only [the guide](docs/DISASTER-RECOVERY.md):
| Backup | Location | Database | Result |
|---|---|---|---|
| Production, about 1 TB, 150,000 files (Nextcloud All-in-One) | Hetzner Storage Box (SFTP), Germany → Canada | PostgreSQL → PostgreSQL | 150,622 files restored in about 25 h (13-14 MiB/s), sample identical |
| The same, 1.0.2 | Hetzner Storage Box (SMB), Germany → Canada | PostgreSQL → PostgreSQL | 150,755 files restored in about 29 h (7-13 MiB/s), going on after a lost connection; sample identical, empty folders not restored ([#27](https://github.com/dillardblom/ng_backup/issues/27)) |
| 1.0.3, small simulations of 2 GB: as planned, with the restore killed halfway, and with a disk that runs full | Local SFTP server | PostgreSQL → PostgreSQL and MariaDB | all 3,101 user files and all empty folders identical; users, a share and a calendar back |
| 1.1.0, the same three simulations | Local SFTP server | PostgreSQL → PostgreSQL and MariaDB | all 3,101 user files identical in content, size and time, all empty folders back; users, a share and a calendar back |
| 138 GB simulation: 20 users, calendars, contacts, mail, shares, team folders with an ACL, versions, trash | OVH S3, Warsaw → Canada | MariaDB → PostgreSQL | all 15,772 files and every database count identical |
| The same | OVH S3 | MariaDB → MariaDB | all 15,772 files and every database count identical |
| The same | Hetzner Storage Box (SMB), Germany → Canada | MariaDB → SQLite | all 15,772 files and every database count identical (27.6 h, about 1.5 MB/s) |
| 51 GB test instance | OVH S3 | PostgreSQL → PostgreSQL | 23,519/23,519 checksums identical |
The integration tests restore across every pair of PostgreSQL, MariaDB and SQLite.
## Storage backend experiences
@@ -119,13 +192,148 @@ 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
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. Over WebDAV, Nextcloud gives
every request at most 30 s by default, also an upload of a whole 32 MiB pack: on an upload
slower than about 9 Mbit/s a backup fails with `cURL error 28: Operation timed out`. Raise the
limit with `occ config:system:set davstorage.request_timeout --value=600 --type=integer`.
- **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
`vsftpd` test servers (chrooted) can reject that with a generic "create directory operation
failed", even though a plain `ftp_mkdir()` with a *relative* path on the same server succeeds;
`pure-ftpd` accepts the absolute form fine. If a working FTP server rejects ng_backup's writes,
try a different FTP server implementation before assuming the backend itself is broken.
- **SMB/CIFS *lists* as available with just the `smbclient` CLI binary, but does not actually
work for ng_backup without the `smbclient` PECL extension too.** files_external falls back to a
shell-wrapped `smbclient` process when the native extension is missing, which registers the
backend in `occ backup:target:backends` fine, but that fallback's stream cannot seek. ng_backup
reads blobs back by byte range while backing up, so even the very first (small) backup fails
with `Seek failed in packs/...`. Install the extension (`libsmbclient-dev` + `pecl install
smbclient` + `docker-php-ext-enable smbclient` on Debian-based images), not just the CLI tool —
the backend being listed is not proof it will work here. Once the extension is present it works
the same as any other backend; no ng_backup-specific config beyond host/share/user/password.
- **Backups over a long distance (high latency):** on Linux, the default TCP congestion control
(cubic) slows a single connection down a lot on a long line with some packet loss. From Canada to
an S3 bucket in Warsaw (114 ms), one upload went from 2.4 to 7.5 MiB/s after switching the
server (or the VM/container host where TCP ends) to BBR:
`sysctl -w net.core.default_qdisc=fq net.ipv4.tcp_congestion_control=bbr`
(`modprobe tcp_bbr` first; make it permanent in `/etc/sysctl.d/`).
A full restore over SFTP reads the packs ahead on a second connection, with many requests in
flight. A server that handles parallel reads well (not a Hetzner Storage Box, which gets slower)
can be read on more connections: `occ config:app:set ng_backup restore_sftp_connections
--value=4` (at most 8; the server must allow that many plus one at once for the user).
- **OVH Object Storage (S3, Warsaw, 2026-10):** works with the `amazons3` backend (path style,
region `waw`). A full backup of 1 TB from Germany ran at about 7 MiB/s, from Canada about
6 MiB/s with BBR; a check of a 1 TB backup took 3.5 minutes (index files are fetched 16 at a
time); a restore of 138 GB from Warsaw to Canada took about 6 hours. Turn on versioning and
Object Lock on the bucket (see above); OVH charges no egress.
- **Hetzner Storage Box over SFTP, full restore:** about 13-14 MiB/s from Germany to Canada
(122 ms) with the read-ahead of 1.0.0-rc.4; without it about 2.8 MiB/s. A Storage Box gets
slower with several connections reading at once (4-6 MiB/s for 2-4 connections, it reads from
disks), so one extra connection is the default. A Storage Box drops an SFTP connection that
stayed quiet for a while without telling the client; NG Backup checks and replaces it.
- **SMB speed:** on a local network (Samba, no delay) a backup ran at about 25 MB/s and a
restore at about 28 MiB/s. Over a long line SMB waits for an answer to each request: from
Canada to a Storage Box in Germany (130 ms) about 2.6 MiB/s for a backup and 1.6 MiB/s for a
restore, and connecting a location with an existing backup reads its index at about one file a
second. SMB is meant for a local network; use SFTP or S3 for a location far away. When
`pecl install smbclient` fails (pecl.php.net was unreachable for hours on 2026-10-21), the
extension builds from its source: `libsmbclient-php` on GitHub, `phpize && ./configure && make
&& make install`, then `docker-php-ext-enable smbclient`.
- **NFS is not a files_external backend at all.** Mount the NFS export on the server yourself
(e.g. via `/etc/fstab` or a systemd `.mount` unit) and point ng_backup's `local` backend at the
mount point; ng_backup never mounts or unmounts anything on its own, so an NFS mount dropping
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.
## Roadmap
What is planned, without dates. Ideas and votes are welcome in the
[issues](https://github.com/dillardblom/ng_backup/issues); if something you need is listed here,
a 👍 on its issue helps decide what comes first.
**1.2.0**
- Faster backups over a long line: several uploads at the same time, with a setting
(2 by default) to suit the server and the size of the backup.
- Only the database from a restore point, with `occ` (no web interface): export one or more
tables to a local directory to look something up, or put chosen tables back into the running
instance in maintenance mode. For example the app settings after an update that went wrong.
**Later**
- A full restore onto the existing installation, without first installing a fresh Nextcloud, in a
restore mode of its own instead of maintenance mode, so that it can also run from the web
interface (for hosted Nextcloud without shell access).
- Users restoring their own files from the Files app.
- Primary object storage and External Storage mounts (see Known limitations).
- An option not to keep the backup key on the location (the recovery kit is then the only copy).
## Known limitations
On the backlog; know them before you rely on NG Backup:
- **Empty folders only come back from backups made with 1.0.3 or newer.** Older restore points do
not contain them.
- **Folders with files in them only get their original modification time back from backups made with
1.1.0 or newer.** From older restore points they show the time of the restore; the files keep theirs.
A restore through Nextcloud into a folder that is already there leaves that folder's time alone.
- **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.
- **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 a whole user or the whole server needs `occ`** (shell access); see "Without shell
access" above.
- **`--install-apps` depends on the app store.** Nextcloud downloads the whole app list (about
38 MB) and gives up after 120 seconds; on a slow day apps are not installed. The restore then
lists them: install them afterwards with `occ app:install`, their data is restored already.
- A pack whose index upload failed stays on the location until removed by hand.
## Commands
| Command | Purpose |
|---|---|
| `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/list/remove/append-only` | backup locations |
| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points |
| `backup:target:backends/add/edit/list/remove/append-only` | backup locations |
| `backup:target:schedule` | when a location is backed up: off, daily, or every N hours |
| `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 |
| `backup:pin`, `backup:note` | keep a restore point, a note you can change later |
| `backup:pause-jobs` | whether heavy background jobs of other apps pause during a backup |
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
| `backup:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) |
| `backup:key:import` | import a recovery kit on a fresh installation |
| `backup:user:backup`, `backup:user:list`, `backup:user:restore` | one user via user_migration (asks before installing it); list the exports |
+65 -8
View File
@@ -3,18 +3,63 @@
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>ng_backup</id>
<name>NG Backup</name>
<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
S3/SFTP/WebDAV/local, with restore of files and folders from the web interface and occ. Meant for
testing on a copy of production (dev, test, staging) -- not yet for production use, and full
disaster recovery is not implemented yet. See the recovery kit section of the README before
relying on this for anything.]]></description>
<version>0.1.0</version>
<summary>Encrypted, incremental backups of your Nextcloud — files, database and config — with restore from the web interface</summary>
<description><![CDATA[Back up your whole Nextcloud — data directory, database and configuration — encrypted and
incremental, to storage you choose. Restore single files, folders, a user, or the whole server from
the web interface or `occ`. No shell access or external tools needed, so it also runs on Nextcloud
All-in-One and hosted Nextcloud.
**Where to**
- S3 and S3-compatible storage, SFTP, WebDAV, SMB, FTP, Swift or a local path (NFS mount), through
the connection code of the External storage app. A location is never mounted and never shows up
in Files.
**How**
- Deduplicated, compressed and encrypted (XChaCha20-Poly1305) on the server before upload, streamed
straight to the location: no local copy, no double disk space. Unchanged files are not read again.
- Database dump through Nextcloud's own connection, in one consistent transaction (PostgreSQL,
MySQL/MariaDB, SQLite).
- Runs in short steps from the background jobs, so a server or container restart does not lose a
running backup.
- Daily schedule, retention policy, and `occ backup:verify` (optionally `--deep`) to check that a
backup can be read.
**Safe**
- Up to three passphrases plus a recovery kit; the passphrase is the actual key.
- Deletion delay and append-only locations against ransomware or a compromised server; S3 object
lock supported.
- Admin notifications on failures, stale backups and changes to keys and locations.
**Restore**
- Files and folders into a new folder, merged, or replacing the original — through Nextcloud, so
overwritten files keep a version and removed ones go to the trash.
- One user (account, files, calendars, contacts) via user_migration.
- The whole server onto a fresh installation (disaster recovery).
NG Backup needs Nextcloud's background jobs on system cron (a real cron line, not only the setting).
Read "Known limitations" in the README before you rely on it, and test a restore. Feedback is very
welcome: use the "Share your experience" link on the admin page.
**If you like NG Backup, don't forget to give the app a star in the app store and the
[repository](https://github.com/dillardblom/ng_backup) a star on GitHub!**
Developed and sponsored by [Ensembia](https://www.ensembia.com). We can offer (paid) assistance if
you need help with restoring a backup after a crash.]]></description>
<version>1.1.0</version>
<licence>agpl</licence>
<author>Dillard Blom</author>
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
<namespace>NgBackup</namespace>
<documentation>
<admin>https://github.com/dillardblom/ng_backup/blob/main/docs/HOWTO.md</admin>
</documentation>
<category>tools</category>
<website>https://github.com/dillardblom/ng_backup</website>
<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/v1.1.0/screenshots/1-backups.png</screenshot>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/2-restore.png</screenshot>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/3-settings.png</screenshot>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/4-bin.png</screenshot>
<dependencies>
<php min-version="8.1"/>
<lib>sodium</lib>
@@ -29,24 +74,36 @@ relying on this for anything.]]></description>
<command>OCA\NgBackup\Command\KeyConfirm</command>
<command>OCA\NgBackup\Command\TargetBackends</command>
<command>OCA\NgBackup\Command\TargetAdd</command>
<command>OCA\NgBackup\Command\TargetEdit</command>
<command>OCA\NgBackup\Command\TargetList</command>
<command>OCA\NgBackup\Command\TargetRemove</command>
<command>OCA\NgBackup\Command\RunBackup</command>
<command>OCA\NgBackup\Command\CancelRun</command>
<command>OCA\NgBackup\Command\Status</command>
<command>OCA\NgBackup\Command\ListSnapshots</command>
<command>OCA\NgBackup\Command\Browse</command>
<command>OCA\NgBackup\Command\Restore</command>
<command>OCA\NgBackup\Command\PauseJobs</command>
<command>OCA\NgBackup\Command\Pin</command>
<command>OCA\NgBackup\Command\Note</command>
<command>OCA\NgBackup\Command\Prune</command>
<command>OCA\NgBackup\Command\Retention</command>
<command>OCA\NgBackup\Command\TargetAppendOnly</command>
<command>OCA\NgBackup\Command\TargetTrustCurrent</command>
<command>OCA\NgBackup\Command\TargetLimit</command>
<command>OCA\NgBackup\Command\TargetSchedule</command>
<command>OCA\NgBackup\Command\Trash</command>
<command>OCA\NgBackup\Command\TrashRestore</command>
<command>OCA\NgBackup\Command\KeySlots</command>
<command>OCA\NgBackup\Command\KeySlotAdd</command>
<command>OCA\NgBackup\Command\KeySlotReplace</command>
<command>OCA\NgBackup\Command\KeySlotRemove</command>
<command>OCA\NgBackup\Command\BackupUser</command>
<command>OCA\NgBackup\Command\RestoreUser</command>
<command>OCA\NgBackup\Command\ListUserExports</command>
<command>OCA\NgBackup\Command\KeyImport</command>
<command>OCA\NgBackup\Command\RestoreFull</command>
<command>OCA\NgBackup\Command\Verify</command>
</commands>
<settings>
<admin>OCA\NgBackup\Settings\Admin</admin>
+7 -1
View File
@@ -18,12 +18,18 @@ return [
['name' => 'AdminApi#untrash', 'url' => '/api/targets/{targetId}/trash/{snapshotId}', 'verb' => 'POST'],
['name' => 'AdminApi#backends', 'url' => '/api/backends', 'verb' => 'GET'],
['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'],
['name' => 'AdminApi#targetSettings', 'url' => '/api/targets/{id}/settings', 'verb' => 'GET'],
['name' => 'AdminApi#updateTarget', 'url' => '/api/targets/{id}', 'verb' => 'PUT'],
['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'],
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'],
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'],
['name' => 'AdminApi#setPauseHeavyJobs', 'url' => '/api/settings/pause-heavy-jobs', 'verb' => 'PUT'],
['name' => 'AdminApi#setTargetSchedule', 'url' => '/api/targets/{id}/schedule', 'verb' => 'PUT'],
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
['name' => 'AdminApi#setSnapshotNote', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/note', 'verb' => 'PUT'],
['name' => 'AdminApi#setSnapshotPinned', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/pin', 'verb' => 'PUT'],
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
['name' => 'AdminApi#startRestore', 'url' => '/api/restores', 'verb' => 'POST'],
],
+200
View File
@@ -0,0 +1,200 @@
# Disaster recovery: your server is gone
Your Nextcloud server is lost. You have a new, empty server. These are the steps to get your
Nextcloud back from an NG Backup location. Do them in this order.
## What you need
- [ ] **One passphrase** of the old server, and if you have it, the **recovery kit** file
(`ng_backup-kit.json`).
- [ ] The **connection details of the backup location**: host, user, password or keys, bucket, folder.
- [ ] A new server with the **same Nextcloud major version** as the old one (for example 34),
a database (PostgreSQL, MySQL/MariaDB or SQLite; it may differ from the old one) and room for
the whole backup plus a margin.
- [ ] **Shell access** to the new server, to run `occ` (on a hosted Nextcloud without it, your
provider runs these steps).
- [ ] A list of the apps that are **not in the app store** (your own builds, apps copied in by
hand). Keep their packages somewhere safe beforehand.
No kit? Since version 1.1.0 the passphrase is enough: the key is also stored on the location (in
its `config` file), locked with the same passphrases. Skip step 4. The kit is still the safer
copy: it helps when that file on the location is damaged. Keep it off the server, next to the
passphrase.
## Steps
**1. Install a fresh Nextcloud** of the same major version. Create an admin account. Do not add
users or files.
**2. Do not let background jobs run yet.** A new Nextcloud runs them on page visits (AJAX).
Switch that off:
occ background:cron
As long as no cron is set up, nothing runs. Check that none is:
- Regular server: `sudo crontab -u www-data -l`. If it shows a line with `cron.php`, remove it
for now with `sudo crontab -u www-data -e` (put it back in step 9).
- Docker Compose with a cron service: `docker compose stop cron`.
- Nextcloud All-in-One: cron runs inside the Nextcloud container; leave it. It does no harm here:
the restore keeps NG Backup's own settings of the new server, so it does not start backups.
This keeps the new server quiet (no mail syncs, no notifications, no backups) until it is ready.
**3. Install NG Backup** (the same version or newer) and enable it.
**4. Import the backup key** (with a kit; without one, go to step 5). Copy the kit to the new
server so the web server user can read it.
From your own computer:
scp ng_backup-kit.json root@newserver:/tmp/
Then on the server, one of:
chown www-data /tmp/ng_backup-kit.json # regular server
docker cp /tmp/ng_backup-kit.json <container>:/tmp/ && \
docker exec <container> chown www-data /tmp/ng_backup-kit.json # Docker
`<container>` is the name of your Nextcloud container: `docker ps` shows it (for example
`nextcloud-aio-nextcloud`, or `myproject-nextcloud-1` with Docker Compose).
Import it; you type the passphrase yourself:
occ backup:key:import /tmp/ng_backup-kit.json
It shows the key fingerprint: it must match the one of your old server (on the recovery kit).
**5. Add the backup location** with the same details as before. On a large backup this first reads
the index and shows how far it is, for example
`Reading the index: 6,913/26,791 files (25%), about 12 min left`.
occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=... -o root=... --path=...
Without a kit (step 4 skipped), it finds the backups and asks for a passphrase of the old server;
you type it yourself. It then shows the key fingerprint and the deletion delay of the old server.
A wrong passphrase changes nothing: run the command again. Backups made before version 1.1.0 did
not store the deletion delay on the location; it is then set to 7 days.
If it says that the location's **config file is gone**, the key is only in the recovery kit:
import it (step 4), then add the location with `--repair-config` at the end of the same command.
That writes the config again from the kit; it works for locations backed up by version 1.1.0 or
newer.
If it says "No backup key yet, and no backups at this location", the connection details or
`--path` differ from the old server's. Check them; do **not** run `backup:key:init`: that makes a
brand new key, and your backups were encrypted with the old one. NG Backup then refuses the
location ("created with a different key").
**Made a new key by mistake** (with `backup:key:init` or on the page) before adding the location?
As long as that key was never used (no location added with it, no backup), it can make way for the
key of your backups. NG Backup says so when it refuses the location. Add `--replace-unused-key` to
`occ backup:key:import` (with the kit) or to `occ backup:target:add` (without the kit; it asks for
a passphrase of the old server). The passphrases and kit of the unused key then stop working. A key
that was used for a location is never replaced: its own backups depend on it. A key made with a
version before 1.1.0 always counts as used (those versions did not keep track), also when it never
protected a location; recover onto a fresh Nextcloud installation instead.
**6. List the restore points** and pick one, normally the newest:
occ backup:list offsite
**7. Start the restore, and let it install the apps.**
occ backup:restore:full offsite <snapshot> --install-apps
`--install-apps` first installs every app the old server had enabled from the app store (or
switches it on, if it is part of Nextcloud). Tables of apps that are missing or were removed long
ago are created from the backup, so they do not stop the restore.
At the end it lists the apps it could not install, usually your own builds that are not in the
app store. Copy those into `custom_apps` and enable them (`occ app:enable <app>`); their data is
already restored.
Backups made before 1.0.0-rc.2 do not record the structure of their tables. If the restore lists
tables that do not exist here, install the apps they belong to and run it again, or leave them
out with `--skip-missing-tables`.
**8. Let the restore run.** It restores the database, then the data directory, and takes hours for
a large backup. Nextcloud is in maintenance mode meanwhile, so nobody and no background job
writes into it; the restore switches it off when it completes. (If you had switched maintenance
mode on yourself before, it stays on: switch it off with `occ maintenance:mode --off` when ready.)
If it stops halfway, maintenance mode stays on (the instance may be partly restored). Fix the cause,
then switch maintenance mode off and run the restore again with `--force`, in one line (the message
prints it): `occ maintenance:mode --off && occ backup:restore:full <location> <snapshot> --force`.
It goes on where it stopped: data files that are already in place with the size and modification
time of the backup are left, so only the rest comes from the location again (since 1.0.2; before,
it started over). The database is restored again in full, which takes minutes. Leave out `--install-apps` this time, so that the instance is open only for the seconds until the
restore has switched maintenance mode on again; apps the first run could not install are listed at
the end of the restore, install those afterwards.
If occ then says that Nextcloud or one of the apps requires an upgrade, the database is already
the old server's: run `occ upgrade` first, then the same command with `--force`.
**If the backup itself is damaged** (the restore stops with "Decryption failed", "Blob
verification failed" or a file that is missing on the location), try an older restore point
first. If none is better, `--salvage` restores whatever can be read:
occ backup:restore:full offsite <snapshot> --install-apps --salvage
If a restore already stopped halfway, run it as in step 8: maintenance mode off, with `--force` and
without `--install-apps`:
occ maintenance:mode --off && occ backup:restore:full offsite <snapshot> --salvage --force
- Files that cannot be read are left out and listed at the end; everything else is restored.
- A full disk is not damage: the restore stops, also with `--salvage`, and says so. Make room and
run the restore again as in step 8.
- **On version 1.0.2, check the disk first if the reason is "Size mismatch".** There a full disk
looks like damaged data: the message points at `--salvage`, and with `--salvage` the restore goes
on and lists the remaining files. The backup itself is fine. Update NG Backup, make room and run
the restore again as in step 8.
- If the database cannot be restored (its dump is damaged, or it does not fit this
installation), the database stays as the fresh installation made it, and the data directory
and config of the backup go to a folder of their own in the data directory,
`ngb-salvage-<snapshot>`. Each user's files are in `data/<user>/files` there: create the users
again and copy their files back by hand (then `occ files:scan --all`). Calendars, contacts and
shares are in the database and cannot be recovered this way.
The command then ends with exit code 2 instead of 0. The same option exists for
`occ backup:restore`, to restore single folders.
**9. After the restore.** These worked in our tests:
occ upgrade
occ maintenance:repair
occ files:scan --all
- Check `config/config.php`: `trusted_domains`, `overwrite.cli.url` and `overwritehost` come from
the old server; change them if the new server has another name. The database settings and the
data directory are kept from the new installation.
- From Nextcloud All-in-One to a regular server: also check the cache settings (`memcache.*`,
`redis`), which point at AIO's containers.
- Log in, open a few files, a calendar and a contact.
- `occ files:scan` can report many folders as "Updated". That is expected: a restore does not yet put
back the modification time of folders with files in them, so they show the time of the restore.
The files themselves keep their time and content.
- Empty folders only come back from backups made with version 1.0.3 or newer. With an older
restore point they are gone: `occ files:scan` then removes them from the file cache, so it reports
them as removed.
- Start background jobs again (cron), as in step 2 in reverse.
**10. Point your domain at the new server** and check that clients reconnect.
**11. Make a backup of the new server** to the same location (`occ backup:run offsite`). The old
server must stay off: two servers must never write to the same location.
Restored without a kit? Then backups wait until you have a new one: download it on the backup
page (or `occ backup:key:kit --output=...`), store it off the server and confirm it
(`occ backup:key:confirm`).
## Never
- Never let the old server write to the location again if it comes back. Switch its backups off
first.
- Never give your passphrase to anyone. Nobody helping you needs it.
## Help
Stuck? [Ensembia](https://www.ensembia.com), the developer of NG Backup, offers paid help with
restoring a backup after a crash.
+200
View File
@@ -0,0 +1,200 @@
# NG Backup how-to
Step by step: from first install to a tested restore. All commands run as the web user
(`sudo -u www-data php occ ...`, or `docker exec -u www-data <container> php occ ...` in
All-in-One and Docker setups). Replace names in `<angle brackets>` with your own.
## 1. Create the key and the recovery kit
```
occ backup:key:init --label="Main" --delete-delay-days=7
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: 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`. 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
NG Backup uses Nextcloud's External storage backends. Check what your server supports first:
```
occ backup:target:backends
```
Examples:
```
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 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
```
- **SFTP on a Hetzner Storage Box sub-account:** use `-o root=home`, not `/` (see README).
- **FTP:** use a server that accepts absolute paths (for example `pure-ftpd`).
- **SMB:** requires the native `smbclient` PHP extension. The CLI binary alone is not enough (see README).
`backup:target:add` refuses SMB when the extension is missing.
- **NFS:** not a backend of its own. Mount the export on the server and use the `local` backend
with the mount point as `datadir`.
Verify the location before relying on it:
```
occ backup:target:list
occ backup:status
```
A backup that hangs or takes far too long can be stopped. `backup:status` shows the run number;
the command waits until the step in progress is finished, then the process working on the run
stops, and the next cleanup removes what it stored:
```
occ backup:cancel 7
```
Changed password, new host name, or moved the repository? Edit the location instead of adding a
new one, so its history stays (in the web interface: **Edit** next to the location). The new
settings must lead to the same repository; options you do not pass keep their value:
```
occ backup:target:edit nas -o password=<new pw>
```
## 3. Run the first backup
```
occ backup:run offsite -l first
occ backup:list
```
Later runs upload only changed data. An unchanged run uploads (almost) nothing; the output shows
the number of uploaded bytes, so check that value when you test incremental behaviour.
Set when each location is backed up in the Administration settings, or with
`occ backup:target:schedule offsite 02:00` (once a day), `02:00/6` (every 6 hours from 02:00) or `off`.
Locations are backed up one after the other.
## 4. Check the backup regularly
```
occ backup:verify offsite <snapshot> # shallow: all blocks are present
occ backup:verify offsite <snapshot> --deep # also downloads and decrypts every block
```
Run the deep check at least once after setting up a location, and after changing it.
## 5. Restore files and folders
In the web interface, or with occ:
```
occ backup:browse offsite <snapshot>
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=new-folder
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=merge
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=replace
occ backup:restore offsite <snapshot> config --to-directory=/var/tmp/restore-config
```
- `new-folder` (default): puts the files in "Restored <date>" next to the original. Safest choice.
- `merge`: adds missing files, keeps current ones.
- `replace`: overwrites the folder. Replaced files keep their old content as a version, and removed
files go to the trash.
- `--to-directory`: writes raw files to a local directory (for example `config/`).
## 6. Back up and restore one user
Needs the `user_migration` app. NG Backup asks before installing it:
```
occ backup:user:backup offsite <uid>
occ backup:user:list offsite [<uid>] # the exports and their manifest
occ backup:user:restore offsite users/<uid>/<exportId> # replace (default)
occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
```
- `backup:user:restore` needs the manifest path that `backup:user:backup` prints at the end
(`users/<uid>/<exportId>`); `backup:user:list` shows it again. A user id alone also works when
that user has exactly one export on the location. With more (a `replace` restore adds a safety
export), the command lists them and you pass one.
- If `user_migration` is missing, the command asks to install and enable it. For non-interactive
use, add `--install-user-migration` to approve that explicitly. The command stops after the
installation; run it once more.
- `replace` first makes a safety export of the current account, then recreates the user under the
same id. When the account exists, the command asks first; in scripts, add `--force`. Shares to
others and data of apps without a user_migration migrator are not restored, so prefer
`as-backup` when in doubt: it restores into `<uid>-bak` and leaves the original untouched.
- A restore onto a server where the user does not exist works. Calendars and address books get an
internal name with the prefix `migrated-` (for example `migrated-personal`). That prefix comes from
user_migration and cannot be turned off. The user can change the display name of these calendars and
address books in the app as usual; the internal name stays as it is.
- **Settings:** on restore, `user_migration` imports only the app settings on its own allowlist
(for example calendar view and reminder settings). Other app settings of the user are skipped.
NG Backup reuses `user_migration`'s importer, so this list is not extended.
- Keep the last few exports per user: `occ backup:retention --user-last=3`.
## 7. Disaster recovery: restore the whole server
Server lost? Follow the step-by-step [DISASTER-RECOVERY.md](DISASTER-RECOVERY.md).
Use a fresh Nextcloud installation of the same major version, with NG Backup installed and
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:target:add offsite <backend> ... # same details as on the original server
occ backup:list offsite # the snapshots must be listed
occ backup:restore:full offsite <snapshot>
```
Without the kit, leave out `backup:key:import`: `backup:target:add` finds the backups and takes
the key from the location with a passphrase (`--passphrase-file=/path/passphrase`, or it asks).
Backups then wait until a new kit is downloaded and confirmed.
The command refuses on an installation that already has more than one user, and asks before it
starts; `--force` skips both (also needed to rerun it after it stopped halfway).
Afterwards, check that users, files and the database are complete, and run a backup of the restored
server. The restored installation keeps its own app configuration and secret.
Stuck? [Ensembia](https://www.ensembia.com), the developer of NG Backup, offers paid help with
restoring a backup after a crash. You keep your passphrase to yourself: it is never needed by
anyone helping you.
## 8. Retention
```
occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12
occ backup:prune offsite --dry-run # show first
occ backup:prune offsite # apply the policy; forgotten snapshots go to the trash first
occ backup:trash # list the trash
occ backup:trash:restore offsite <id>
occ backup:pin offsite <id> # keep this restore point; --unpin to undo
occ backup:note offsite <id> "text" # a note you can change later; without text it is removed
```
The retention policy leaves a pinned restore point alone, and a pinned one does not take the place of
a daily, weekly or monthly one. Pin and note are stored on the location, so a new server sees them too.
Snapshots are kept according to the policy, and their data is deleted only after the deletion delay
(set with `backup:key:init --delete-delay-days`). `occ backup:prune offsite --dry-run` shows what would be
removed without changing anything.
## 9. Checklist before you rely on it
- [ ] Key created, passphrase stored off-server, recovery kit stored off-server
- [ ] Location added; `backup:status` shows it reachable
- [ ] First backup done and listed in `backup:list`
- [ ] `backup:verify --deep` succeeds
- [ ] One file restore tested (`new-folder`)
- [ ] Disaster recovery tested on a second, fresh installation (or in a test environment)
+22
View File
@@ -72,6 +72,10 @@ final class ExternalStorageFactory {
throw new BackendException("Auth mechanism $authId does not fit backend $backendId");
}
if (is_string($options['private_key'] ?? null)) {
$options['private_key'] = self::normalizePrivateKey($options['private_key']);
}
$config = new StorageConfig();
$config->setBackend($backend);
$config->setAuthMechanism($auth);
@@ -86,4 +90,22 @@ final class ExternalStorageFactory {
$storage = $backend->wrapStorage($storage);
return $auth->wrapStorage($storage);
}
/**
* An OpenSSH-format private key with its line breaks restored. Up to Nextcloud 34
* (phpseclib 2) a key whose newlines were lost or turned into spaces, e.g. on its way through
* a shell or a form, still loaded; Nextcloud 35 (phpseclib 3) refuses it with "Unable to read
* key" (#24). The key itself is not changed, so it is fixed when it is used, not when stored.
* Other formats (PEM, PKCS#8) still load as they are and are returned unchanged.
*/
public static function normalizePrivateKey(string $key): string {
if (!preg_match('/-----BEGIN OPENSSH PRIVATE KEY-----(.*?)-----END OPENSSH PRIVATE KEY-----/s', $key, $m)) {
return $key;
}
$body = (string)preg_replace('/\s+/', '', $m[1]);
if (!preg_match('#^[A-Za-z0-9+/]+=*$#', $body)) {
return $key;
}
return "-----BEGIN OPENSSH PRIVATE KEY-----\n" . chunk_split($body, 70, "\n") . "-----END OPENSSH PRIVATE KEY-----\n";
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Backend;
/** IBackend::getMany() and getRanges() for a backend where reading one after the other costs nothing extra. */
trait GetsOneByOne {
/**
* @param list<string> $paths
* @return \Generator<string, string>
*/
public function getMany(array $paths): \Generator {
foreach ($paths as $path) {
$fh = $this->get($path);
try {
$data = stream_get_contents($fh);
} finally {
fclose($fh);
}
if ($data === false) {
throw new BackendException('Read failed for ' . $path);
}
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);
}
}
}
+31
View File
@@ -22,9 +22,30 @@ interface IBackend {
/** @return resource readable stream of the whole object */
public function get(string $path);
/**
* Many small objects whole, in the order given (the repository's index files). A backend
* where every request costs a round trip can fetch them several at a time.
*
* @param list<string> $paths
* @return \Generator<string, string> contents by path
* @throws BackendException for an object that cannot be read
*/
public function getMany(array $paths): \Generator;
/** @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;
/**
@@ -37,5 +58,15 @@ interface IBackend {
/** @return list<string> object paths below $prefix (recursive) */
public function list(string $prefix): array;
/**
* Object paths directly in $dir, for a folder that only holds objects (index/, snapshots/,
* catalog/, trash/info/). One listing, without checking the type of every entry: on a remote
* storage list() costs a round trip per entry, which for index/ (one file per pack) grows
* with the size of the repository.
*
* @return list<string>
*/
public function listFiles(string $dir): array;
public function delete(string $path): void;
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Backend;
/**
* A backend that can read many byte ranges ahead of use, several at a time over connections of
* its own (a full restore, where one connection at a time is bound by the round trip).
*/
interface IReadsAhead {
/** Whether readAhead() is worth planning for: false when it would return null anyway. */
public function canReadAhead(): bool;
/**
* The ranges in the order given, each as a local stream at offset 0 holding its bytes, read a
* few ranges ahead of the one the caller is at. A stream is closed when the generator is
* resumed. Null for a range that could not be read this way (a missing object, a server or
* connection error): the caller reads it the usual way, which reports why.
*
* @param list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] each
* @param string $spool an existing local directory for the files that hold the ranges
* @return \Generator<int, resource|null>|null by position in $ranges; null when this backend
* cannot read ahead now
*/
public function readAhead(array $ranges, string $spool): ?\Generator;
}
+30 -1
View File
@@ -8,6 +8,8 @@ declare(strict_types=1);
namespace OCA\NgBackup\Backend;
final class LocalBackend implements IBackend {
use GetsOneByOne;
public function __construct(
private string $root,
) {
@@ -33,11 +35,16 @@ final class LocalBackend implements IBackend {
if ($out === false) {
throw new BackendException('Cannot write ' . $path);
}
$expected = StorageBackend::remaining($stream);
if (stream_copy_to_stream($stream, $out) === false || !fflush($out)) {
throw new BackendException('Write failed for ' . $path);
}
fclose($out);
$out = false;
clearstatcache(true, $tmp);
if ($expected !== null && filesize($tmp) !== $expected) {
throw new BackendException('Incomplete write for ' . $path);
}
if (!rename($tmp, $target)) {
throw new BackendException('Cannot finalise ' . $path);
}
@@ -60,6 +67,9 @@ final class LocalBackend implements IBackend {
if ($fh === false) {
throw new BackendException('Not found: ' . $path);
}
// Packs are read in large pieces: one request each, not 8 KiB at a time (see Repository::openForReading()).
stream_set_read_buffer($fh, 0);
stream_set_chunk_size($fh, 4 * 1048576);
return $fh;
}
@@ -94,7 +104,7 @@ final class LocalBackend implements IBackend {
$result = [];
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if ($file->isFile() && !str_contains($file->getFilename(), '.part-')) {
if ($file->isFile() && !StorageBackend::isTemporary($file->getFilename())) {
$result[] = ltrim(substr($file->getPathname(), strlen($this->root)), '/');
}
}
@@ -102,6 +112,25 @@ final class LocalBackend implements IBackend {
return $result;
}
public function listFiles(string $dir): array {
$base = $this->abs($dir);
if (!is_dir($base)) {
return [];
}
$result = [];
$names = scandir($base);
if ($names === false) {
throw new BackendException('Cannot list ' . $dir);
}
foreach ($names as $name) {
if ($name !== '.' && $name !== '..' && !StorageBackend::isTemporary($name)) {
$result[] = trim($dir, '/') . '/' . $name;
}
}
sort($result);
return $result;
}
public function delete(string $path): void {
@unlink($this->abs($path));
}
+395
View File
@@ -0,0 +1,395 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Backend;
/**
* Reads byte ranges of files on an SFTP location ahead of use, over several connections at once,
* into local files (StorageBackend::readAhead(), for a full restore).
*
* A restore that reads its packs as it goes is bound by the round trip: from a Hetzner Storage
* Box to Canada (RTT 122 ms) it reached about 2.8 MiB/s, with the PHP process mostly waiting.
* Here every connection reads its own range with DEPTH requests in flight, ahead of the restore. PHP runs one thing at a time: while it waits for an answer on one connection, the
* answers of the others arrive in the kernel's buffers, so the connections still transfer at the
* same time.
*
* Uses the same internal phpseclib 2 methods as SftpReadStream (see SftpReadStream::connection()).
* The connections are this class's own, never the storage's: no other request goes over them.
* A range that fails (missing file, server error, a connection that breaks) comes out as null,
* for the caller to read the usual way; a broken connection is not used again.
*/
final class SftpRangeFetcher {
/**
* Connections a full restore uses, unless the app setting restore_sftp_connections says
* otherwise. One: a Hetzner Storage Box gave about 14 MiB/s on one connection with DEPTH
* requests in flight, and 4-6 MiB/s on 2 or 4 (it reads from disks, and parallel readers make
* them seek). On a line with packet loss to a server that handles parallel reads well, 4
* connections did better (21 against 14 MiB/s).
*/
public const DEFAULT_CONNECTIONS = 1;
public const MAX_CONNECTIONS = 8;
/** READ requests in flight per connection. */
public const DEPTH = 32;
/** Ranges read ahead of the one the caller is at, in flight or waiting in the spool. */
public const AHEAD = 8;
/**
* Seconds ready() waits for any connection to answer before it waits for each in turn: short,
* in case a connection holds an answer where ready() does not look.
*/
private const SELECT_TIMEOUT = 1;
/** See SftpReadStream::CHUNK and MIN_CHUNK. */
private const CHUNK = 261120;
private const MIN_CHUNK = 32768;
private const FXP_OPEN = 3;
private const FXP_CLOSE = 4;
private const FXP_READ = 5;
private const FXP_STATUS = 101;
private const FXP_HANDLE = 102;
private const FXP_DATA = 103;
private const FXF_READ = 1;
private const FX_OK = 0;
/** Connections that broke and are replaced, at most, in one restore. */
public const MAX_RECONNECTS = 20;
private const OPEN = 0;
private const READ = 1;
private const CLOSE = 2;
/** @var array<int, int> READ size per connection, lowered when a server answers less than asked */
private array $chunks = [];
/** @var array<int, SftpRangeJob> the range each busy connection reads */
private array $jobs = [];
/** @var array<int, resource|null> ranges read (or failed) and not yet given to the caller */
private array $done = [];
private int $requestId = 0x4e520000;
/** Connections replaced so far (see MAX_RECONNECTS). */
public int $reconnects = 0;
/**
* @param array<int, object> $connections phpseclib 2 connections with their SFTP channel open
* @param list<array{0:string, 1:int, 2:int}> $ranges paths below $root
*/
private function __construct(
private array $connections,
private string $root,
private array $ranges,
private string $spool,
private ?\Closure $connect = null,
) {
foreach (array_keys($connections) as $c) {
$this->chunks[$c] = self::CHUNK;
}
}
/**
* See IReadsAhead::readAhead(). The connections are ended when the generator ends.
*
* @param list<object> $connections
* @param list<array{0:string, 1:int, 2:int}> $ranges
* @param (\Closure():?object)|null $connect a new connection in place of one that broke
* (null when it cannot be made)
* @return \Generator<int, resource|null>
*/
public static function fetch(array $connections, string $root, array $ranges, string $spool, ?\Closure $connect = null): \Generator {
$fetcher = new self($connections, rtrim($root, '/'), $ranges, $spool, $connect);
try {
yield from $fetcher->run();
} finally {
$fetcher->end();
}
}
/** @return \Generator<int, resource|null> */
private function run(): \Generator {
$count = count($this->ranges);
$next = 0;
for ($want = 0; $want < $count; $want++) {
while (!array_key_exists($want, $this->done)) {
foreach (array_keys($this->connections) as $c) {
if (!isset($this->jobs[$c]) && $next < $count && $next < $want + self::AHEAD) {
$this->start($c, $next++);
}
}
if ($this->jobs === [] && !array_key_exists($want, $this->done)) {
// no connection left: the rest the usual way
$this->done[$want] = null;
$next = max($next, $want + 1);
break;
}
foreach ($this->ready() as $c) {
$this->step($c);
}
}
$fh = $this->done[$want];
unset($this->done[$want]);
try {
yield $want => $fh;
} finally {
if (is_resource($fh)) {
fclose($fh);
}
}
}
}
/**
* The busy connections that have an answer waiting, so that a connection that waits for a
* lost packet to be sent again does not hold up the others; all of them when the connections
* do not tell (then they are taken in turn).
*
* @return list<int>
*/
private function ready(): array {
$sockets = [];
$ready = [];
foreach ($this->jobs as $c => $job) {
$sftp = $this->connections[$c];
$vars = get_object_vars($sftp); // only what is public: phpseclib 2's are
// Answers phpseclib has read already: the socket does not show them. Its request
// buffer holds whole answers read while it waited for another; its packet buffer and
// channel buffers hold what came in with the last packet it read (small answers, such
// as a CLOSE and the next OPEN, often arrive together).
$buffered = $vars['requestBuffer'] ?? null;
$channels = $vars['channel_buffers'] ?? null;
if ((is_array($buffered) && isset($job->queue[0]) && array_key_exists($job->queue[0][0], $buffered))
|| (is_string($vars['packet_buffer'] ?? null) && self::wholePacket($vars['packet_buffer']))
|| (is_array($channels) && array_filter($channels) !== [])) {
$ready[] = $c;
continue;
}
$socket = $vars['fsock'] ?? null;
if (!is_resource($socket)) {
return array_keys($this->jobs);
}
$sockets[$c] = $socket;
}
if ($ready !== [] || $sockets === []) {
return $ready;
}
$read = $sockets;
$write = null;
$except = null;
// Data in PHP's own buffer of a socket counts as readable. Nothing within the time:
// each in turn, where phpseclib's own timeout applies.
$n = @stream_select($read, $write, $except, self::SELECT_TIMEOUT);
return $n === false || $n === 0 ? array_keys($this->jobs) : array_keys($read);
}
/** Whether $buffer starts with a whole SFTP packet (length, then that many bytes). */
private static function wholePacket(string $buffer): bool {
$length = strlen($buffer) >= 4 ? unpack('N', $buffer) : false;
return is_array($length) && strlen($buffer) >= 4 + $length[1];
}
private function start(int $c, int $i): void {
[$path, $offset, $length] = $this->ranges[$i];
$fh = $this->spoolFile();
if ($fh === null) {
$this->done[$i] = null;
return;
}
if ($length <= 0) {
$this->done[$i] = $fh;
return;
}
$this->jobs[$c] = new SftpRangeJob($i, $fh, $offset, $offset + $length);
try {
$remote = $this->connections[$c]->_realpath($this->root . '/' . ltrim($path, '/'));
if (!is_string($remote)) {
$this->finish($c);
return;
}
$this->send($c, self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), self::OPEN);
} catch (\Throwable) {
$this->drop($c);
}
}
/** Take the oldest answer on connection $c, then keep its requests in flight. */
private function step(int $c): void {
$job = $this->jobs[$c];
[$id, $kind, $offset, $length] = array_shift($job->queue) ?? throw new \LogicException('no request in flight');
$sftp = $this->connections[$c];
try {
$response = $sftp->_get_sftp_packet($id);
if (!is_string($response)) {
throw new BackendException('SFTP connection lost');
}
$type = $sftp->packet_type;
if ($kind === self::OPEN) {
if ($type === self::FXP_HANDLE) {
$job->handle = substr($response, 4);
} else {
$job->failed = true; // missing, or no permission
}
} elseif ($kind === self::READ) {
if (!$job->failed) {
$this->received($c, $type, $response, $offset, $length);
}
} else {
$status = $type === self::FXP_STATUS && strlen($response) >= 4 ? unpack('N', $response) : false;
$closed = is_array($status) && $status[1] === self::FX_OK;
$this->finish($c);
if (!$closed) {
// handles might pile up on the server: not used again
$this->drop($c);
}
return;
}
$this->refill($c);
} catch (\Throwable) {
$this->drop($c);
}
}
private function received(int $c, int $type, string $response, int $offset, int $length): void {
$job = $this->jobs[$c];
$declared = strlen($response) >= 4 ? unpack('N', $response) : false;
$n = is_array($declared) ? $declared[1] : 0;
if ($type !== self::FXP_DATA || $n <= 0 || $n !== strlen($response) - 4 || $n > $length) {
// end of file before the end of the range, a server error or a malformed answer
$job->failed = true;
return;
}
if (fseek($job->fh, $offset - $job->start) !== 0 || fwrite($job->fh, substr($response, 4)) !== $n) {
$job->failed = true;
return;
}
$job->got += $n;
if ($n < $length) {
// a server that caps a READ: ask for the rest, and no more than it gives from now on
$job->redo[] = [$offset + $n, $length - $n];
$this->chunks[$c] = max(self::MIN_CHUNK, min($this->chunks[$c], $n));
}
}
/** Send READs until DEPTH are in flight; when nothing is left to ask for, close the file. */
private function refill(int $c): void {
$job = $this->jobs[$c];
if ($job->handle !== '' && !$job->failed) {
while (count($job->queue) < self::DEPTH) {
if ($job->redo !== []) {
[$offset, $length] = array_shift($job->redo);
} elseif ($job->next < $job->end) {
$offset = $job->next;
$length = min($this->chunks[$c], $job->end - $offset);
$job->next += $length;
} else {
break;
}
$packet = pack('Na*N3', strlen($job->handle), $job->handle, intdiv($offset, 4294967296), $offset % 4294967296, $length);
$this->send($c, self::FXP_READ, $packet, self::READ, $offset, $length);
}
}
if ($job->queue !== []) {
return;
}
if ($job->handle === '') {
$this->finish($c); // not opened: nothing to close
return;
}
$this->send($c, self::FXP_CLOSE, pack('Na*', strlen($job->handle), $job->handle), self::CLOSE);
}
private function send(int $c, int $type, string $packet, int $kind, int $offset = 0, int $length = 0): void {
$this->requestId = $this->requestId >= 0x7fffffff ? 0x4e520000 : $this->requestId + 1;
if (!$this->connections[$c]->_send_sftp_packet($type, $packet, $this->requestId)) {
throw new BackendException('SFTP connection failed while sending a request');
}
$this->jobs[$c]->queue[] = [$this->requestId, $kind, $offset, $length];
}
/** The range of connection $c is done: its stream for the caller, or null when it failed. */
private function finish(int $c): void {
$job = $this->jobs[$c];
unset($this->jobs[$c]);
if ($job->failed || $job->got !== $job->end - $job->start || !rewind($job->fh)) {
self::closeFile($job->fh);
$this->done[$job->i] = null;
return;
}
$this->done[$job->i] = $job->fh;
}
/** A connection in an unknown state: its range fails, and it is not used again. */
private function drop(int $c): void {
if (isset($this->jobs[$c])) {
$job = $this->jobs[$c];
unset($this->jobs[$c]);
self::closeFile($job->fh);
$this->done[$job->i] = null;
}
self::disconnect($this->connections[$c]);
unset($this->connections[$c]);
// A long restore outlives connections (a Storage Box drops one now and then): without a
// new one, the rest would be read the slow way.
if ($this->connect === null || $this->reconnects >= self::MAX_RECONNECTS) {
return;
}
$this->reconnects++;
try {
$new = ($this->connect)();
} catch (\Throwable) {
$new = null;
}
if (function_exists('OCP\Log\logger')) {
\OCP\Log\logger('ng_backup')->info($new === null
? 'Reading ahead: an SFTP connection broke and no new one could be made'
: 'Reading ahead: an SFTP connection broke; reading on with a new one');
}
if ($new !== null) {
$key = max(array_merge(array_keys($this->chunks), [-1])) + 1;
$this->connections[$key] = $new;
$this->chunks[$key] = self::CHUNK;
}
}
/** @return resource|null a new file in the spool, already unlinked: nothing is left behind if the process dies */
private function spoolFile() {
$path = $this->spool . '/' . bin2hex(random_bytes(8));
$fh = @fopen($path, 'x+b');
if ($fh === false) {
return null;
}
@unlink($path);
return $fh;
}
private function end(): void {
foreach ($this->jobs as $job) {
self::closeFile($job->fh);
}
foreach ($this->done as $fh) {
if (is_resource($fh)) {
fclose($fh);
}
}
$this->jobs = [];
$this->done = [];
foreach ($this->connections as $sftp) {
self::disconnect($sftp);
}
$this->connections = [];
}
private static function closeFile(mixed $fh): void {
if (is_resource($fh)) {
fclose($fh);
}
}
private static function disconnect(object $sftp): void {
try {
if (is_callable([$sftp, 'disconnect'])) {
$sftp->disconnect();
}
} catch (\Throwable) {
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Backend;
/**
* One range that SftpRangeFetcher reads on one connection.
*
* @internal
*/
final class SftpRangeJob {
public string $handle = '';
/** Offset of the next READ to send. */
public int $next;
/** @var list<array{int, int}> offset and length of parts to ask for again (after a short answer) */
public array $redo = [];
/** @var list<array{int, int, int, int}> request id, kind, offset and length, in the order sent */
public array $queue = [];
/** Bytes received. */
public int $got = 0;
public bool $failed = false;
/** @var resource the spool file that holds the range */
public $fh;
/** @param resource $fh */
public function __construct(
public int $i,
$fh,
public int $start,
public int $end,
) {
$this->fh = $fh;
$this->next = $start;
}
}
+628
View File
@@ -0,0 +1,628 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Backend;
/**
* Read stream for files_external's SFTP storage that keeps several READ requests in flight.
*
* files_external's own read stream asks for the next 256 KiB only after the previous one has
* arrived, so every request costs a full round trip: about 5 MB/s at 47 ms to a Hetzner Storage
* Box, whatever the line speed. With 8 requests in flight the same connection reached 15 MB/s.
* phpseclib's get() pipelines too, but only into memory or a local file, and only from offset 0.
*
* Talks to the storage's phpseclib connection through the same internal methods files_external's
* read stream uses (_send_sftp_packet, _get_sftp_packet, _realpath); open() returns null when the
* connection does not offer them (phpseclib 3 does not), and StorageBackend then falls back to the
* storage's own stream. A restore must rather be slow than fail: when a read fails halfway, the
* stream goes on from the same position with the storage's own stream. After MAX_FAILURES streams
* in a row failed, open() returns null for the rest of the process; one that works resets the count.
* Like that stream, it owns the connection while open: no other request may go over the same
* connection until it is closed (PackReaderGuard takes care of that for the repository).
*/
final class SftpReadStream {
private const PROTOCOL = 'ngbsftpread';
/** Requests in flight once a stream reads sequentially; it starts at one and doubles. */
public const DEPTH = 8;
/** Files read at once by readMany(): each holds a handle on the server while its batch runs. */
public const BATCH = 32;
/** Larger files are left to the usual read by readMany(), which holds every file in memory. */
private const MANY_MAX_BYTES = 16 * 1048576;
/** OpenSSH and the Hetzner Storage Box both answer at most 255 KiB per READ. */
private const CHUNK = 261120;
/** Every SFTP server must handle packets of this size (draft-ietf-secsh-filexfer-02, section 3). */
private const MIN_CHUNK = 32768;
private const FXP_OPEN = 3;
private const FXP_CLOSE = 4;
private const FXP_READ = 5;
private const FXP_STATUS = 101;
private const FXP_HANDLE = 102;
private const FXP_DATA = 103;
private const FXF_READ = 1;
private const FX_OK = 0;
private const FX_EOF = 1;
/**
* READ size per connection, lowered when a server answers less than asked and then goes on, so
* later streams over that connection ask for no more than it gives.
*
* @var \WeakMap<object, int>|null
*/
private static ?\WeakMap $chunks = null;
private static int $requestId = 0x4e470000;
/**
* Connections whose server did not confirm closing a batch's files: readMany() is not used on
* them again, so handles cannot pile up.
*
* @var \WeakMap<object, true>|null
*/
private static ?\WeakMap $noBatches = null;
/** Streams in a row that failed; at MAX_FAILURES only the storage's own stream is used. */
private static int $failures = 0;
public const MAX_FAILURES = 3;
/** Set by stream_open() when the connection failed rather than the file. */
private static bool $openBroke = false;
/** Why the last stream_open() failed on the connection. */
private static string $openCause = '';
/** @var resource|null set by PHP for stream wrappers */
public $context;
/**
* The phpseclib connection. PHP creates stream wrappers without constructor arguments; it is
* set in stream_open(), and no other method is called when that fails.
*
* @psalm-suppress PropertyNotSetInConstructor
*/
private object $sftp;
/** The storage and the path on it, to fall back to the storage's own stream. */
private ?object $storage = null;
private string $file = '';
/** @var resource|null the storage's own stream, once this one has failed */
private $fallback = null;
private string $handle = '';
/** Position of the reader: the first byte of $buffer. */
private int $pos = 0;
/** Offset of the next READ to send. */
private int $next = 0;
private string $buffer = '';
/** @var list<array{int, int, int}> request id, offset, length, in the order sent */
private array $inFlight = [];
private int $depth = 1;
private bool $eof = false;
/** This stream gave up on its own requests. */
private bool $failed = false;
/** The connection is in an unknown state: nothing more is sent over it or read from it. */
private bool $broken = false;
/** Length of the last short answer, until the next answer tells whether it was the end of the file. */
private ?int $shortAnswer = null;
/**
* @param object $storage files_external's SFTP storage (getConnection(), getRoot())
* @return resource|false|null the stream, false when the file cannot be opened, null when the
* connection does not offer what this stream needs
* @throws BackendException when the connection failed after the OPEN request went out: the
* storage's own stream must not be tried on it, as it would take a
* late answer to that request for its own
*/
public static function open(object $storage, string $path) {
if (self::$failures >= self::MAX_FAILURES) {
return null;
}
try {
$sftp = self::connection($storage);
if ($sftp === null) {
return null;
}
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
stream_wrapper_register(self::PROTOCOL, self::class);
}
$root = rtrim((string)$storage->getRoot(), '/');
$context = stream_context_create([self::PROTOCOL => [
'session' => $sftp,
'path' => $root . '/' . ltrim($path, '/'),
'storage' => $storage,
'file' => $path,
]]);
self::$openBroke = false;
$fh = @fopen(self::PROTOCOL . '://file', 'r', false, $context);
} catch (\Throwable) {
// nothing was sent yet
self::$failures++;
return null;
}
if ($fh === false && self::openBroke()) {
throw new BackendException('SFTP connection failed while opening ' . $path . ': ' . self::$openCause);
}
return $fh;
}
/**
* The storage's phpseclib connection, with its SFTP channel open, when it offers what these
* streams (and SftpRangeFetcher) need; null otherwise.
*/
public static function connection(object $storage): ?object {
if (!is_callable([$storage, 'getConnection']) || !is_callable([$storage, 'getRoot'])) {
return null;
}
$sftp = $storage->getConnection();
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
// methods of these names neither public nor at all.
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|| !is_callable([$sftp, '_realpath']) || !is_callable([$sftp, 'pwd'])
|| !array_key_exists('packet_type', get_object_vars($sftp))) {
return null;
}
// phpseclib opens the SFTP channel on its first SFTP call, not at login: on a fresh
// connection a packet sent directly would go nowhere. pwd() opens it like any call does.
if ($sftp->pwd() === false) {
return null;
}
return $sftp;
}
/**
* Read many small files whole, BATCH at a time: the OPEN, READ and CLOSE requests of a batch
* all go out before their answers are read, so a batch costs a few round trips instead of
* four per file. Meant for files of up to a few hundred KiB (the repository's index files):
* every file is held in memory.
*
* @param list<string> $paths below the storage's root
* @return array<string, string|null>|null contents by path; null for a file this could not
* read (missing, or a server error), so the caller
* reads it the usual way; null for all when the
* connection does not offer what this needs
* @throws BackendException when the connection failed while requests were out: the storage's
* own stream must not be tried on it
*/
public static function readMany(object $storage, array $paths): ?array {
if (self::$failures >= self::MAX_FAILURES) {
return null;
}
try {
$sftp = self::connection($storage);
$root = $sftp === null ? '' : rtrim((string)$storage->getRoot(), '/');
} catch (\Throwable) {
self::$failures++;
return null;
}
if ($sftp === null || self::$noBatches?->offsetExists($sftp) === true) {
return null;
}
$result = array_fill_keys($paths, null);
foreach (array_chunk($paths, self::BATCH) as $batch) {
try {
[$read, $closed] = self::readBatch($sftp, $root, $batch);
} catch (\Throwable $e) {
self::$failures++;
// Answers to requests already sent may still arrive, and files_external would take
// them for its own: end the connection rather than leave it out of step.
self::disconnect($sftp);
throw $e instanceof BackendException ? $e : new BackendException('SFTP connection failed while reading ' . count($batch) . ' files: ' . $e->getMessage(), 0, $e);
}
foreach ($read as $path => $contents) {
$result[$path] = $contents;
}
if (!$closed) {
// The server did not confirm closing these files: no more batches on this
// connection, so handles cannot pile up. The rest is read the usual way.
self::noMoreBatches($sftp);
break;
}
}
self::$failures = 0;
return $result;
}
private static function noMoreBatches(object $sftp): void {
self::$noBatches ??= new \WeakMap();
self::$noBatches[$sftp] = true;
}
private static function disconnect(object $sftp): void {
try {
if (is_callable([$sftp, 'disconnect'])) {
$sftp->disconnect();
}
} catch (\Throwable) {
}
}
/** Requests of one kind for a whole batch, then their answers in order. @return list<array{int, string}> */
private static function exchange(object $sftp, int $type, array $packets): array {
$ids = [];
foreach ($packets as $packet) {
$id = self::nextId();
if (!$sftp->_send_sftp_packet($type, $packet, $id)) {
throw new BackendException('SFTP connection failed while sending a request');
}
$ids[] = $id;
}
$answers = [];
foreach ($ids as $id) {
$response = $sftp->_get_sftp_packet($id);
if (!is_string($response)) {
throw new BackendException('SFTP connection failed while waiting for an answer');
}
$answers[] = [$sftp->packet_type, $response];
}
return $answers;
}
/**
* @param list<string> $batch
* @return array{0: array<string, string>, 1: bool} the files read whole, and whether the
* server confirmed closing every handle
*/
private static function readBatch(object $sftp, string $root, array $batch): array {
$opens = [];
$openPaths = [];
foreach ($batch as $path) {
$remote = $sftp->_realpath($root . '/' . ltrim($path, '/'));
if (is_string($remote)) {
$opens[] = pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0);
$openPaths[] = $path;
}
// a path that cannot be resolved is left to the caller's usual read
}
$handles = []; // path => handle
foreach (self::exchange($sftp, self::FXP_OPEN, $opens) as $i => [$type, $response]) {
if ($type === self::FXP_HANDLE) {
$handles[$openPaths[$i]] = substr($response, 4);
}
// any other answer (missing, no permission): the caller reads it the usual way
}
$data = array_fill_keys(array_keys($handles), '');
$reading = $handles;
while ($reading !== []) {
$length = self::chunk($sftp);
$reads = [];
foreach ($reading as $path => $handle) {
$offset = strlen($data[$path]);
$reads[] = pack('Na*N3', strlen($handle), $handle, intdiv($offset, 4294967296), $offset % 4294967296, $length);
}
$paths = array_keys($reading);
foreach (self::exchange($sftp, self::FXP_READ, $reads) as $i => [$type, $response]) {
$path = $paths[$i];
$declared = strlen($response) >= 4 ? unpack('N', $response) : false;
if ($type === self::FXP_DATA && is_array($declared) && $declared[1] > 0 && $declared[1] === strlen($response) - 4
&& $declared[1] <= $length && strlen($data[$path]) + $declared[1] <= self::MANY_MAX_BYTES) {
$data[$path] .= substr($response, 4); // more may follow: read on from there
continue;
}
unset($reading[$path]);
$status = $type === self::FXP_STATUS && is_array($declared) ? $declared[1] : null;
if ($status !== self::FX_EOF) {
// a server error, a malformed answer or a file too large for this: the caller
// reads it the usual way
unset($data[$path]);
}
}
}
$closes = [];
foreach ($handles as $handle) {
$closes[] = pack('Na*', strlen($handle), $handle);
}
$closed = true;
foreach (self::exchange($sftp, self::FXP_CLOSE, $closes) as [$type, $response]) {
$status = $type === self::FXP_STATUS && strlen($response) >= 4 ? unpack('N', $response) : false;
if (!is_array($status) || $status[1] !== self::FX_OK) {
$closed = false;
}
}
return [$data, $closed];
}
/** Test hook: forget the learned READ sizes and earlier failures. */
public static function reset(): void {
self::$chunks = null;
self::$noBatches = null;
self::$failures = 0;
}
public function stream_open(string $url, string $mode, int $options, ?string &$openedPath): bool {
$options = stream_context_get_options($this->context ?? stream_context_get_default())[self::PROTOCOL] ?? [];
if (!isset($options['session'], $options['path']) || !is_object($options['session'])) {
return false;
}
$this->sftp = $options['session'];
$storage = $options['storage'] ?? null;
$this->storage = is_object($storage) ? $storage : null;
$this->file = (string)($options['file'] ?? '');
try {
$remote = $this->sftp->_realpath((string)$options['path']);
if (!is_string($remote)) {
return false;
}
$id = self::nextId();
if (!$this->sftp->_send_sftp_packet(self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), $id)) {
return $this->connectionFailed('the request could not be sent');
}
$response = $this->sftp->_get_sftp_packet($id);
} catch (\Throwable $e) {
return $this->connectionFailed(get_class($e) . ': ' . $e->getMessage());
}
if (!is_string($response)) {
return $this->connectionFailed('no answer');
}
if ($this->sftp->packet_type !== self::FXP_HANDLE) {
return false; // an answer, but no handle: the file cannot be opened
}
$this->handle = substr($response, 4);
return true;
}
/** Not a missing file but a connection that does not behave as expected. */
private function connectionFailed(string $cause): bool {
self::$failures++;
self::$openBroke = true;
self::$openCause = $cause . self::connectionState($this->sftp);
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
self::disconnect($this->sftp);
return false;
}
/** What phpseclib knows about the connection, for an error message. */
private static function connectionState(object $sftp): string {
$state = [];
try {
if (is_callable([$sftp, 'isConnected'])) {
$state[] = 'connected: ' . ($sftp->isConnected() ? 'yes' : 'no');
}
if (is_callable([$sftp, 'getLastError'])) {
$last = (string)$sftp->getLastError();
if ($last !== '') {
$state[] = 'ssh: ' . $last;
}
}
if (is_callable([$sftp, 'getLastSFTPError'])) {
$last = (string)$sftp->getLastSFTPError();
if ($last !== '') {
$state[] = 'sftp: ' . $last;
}
}
} catch (\Throwable) {
}
return $state === [] ? '' : ' (' . implode('; ', $state) . ')';
}
/** Whether the last stream_open() failed on the connection; a call, as fopen() sets it. */
private static function openBroke(): bool {
return self::$openBroke;
}
public function stream_read(int $count): string|false {
if ($this->fallback !== null) {
return fread($this->fallback, $count);
}
while (strlen($this->buffer) < $count && !$this->eof) {
if (!$this->receive()) {
return $this->useFallback() ? $this->stream_read($count) : false;
}
}
$data = substr($this->buffer, 0, $count);
$this->buffer = substr($this->buffer, strlen($data));
$this->pos += strlen($data);
return $data;
}
public function stream_eof(): bool {
if ($this->fallback !== null) {
return feof($this->fallback);
}
return $this->eof && $this->buffer === '';
}
public function stream_tell(): int {
if ($this->fallback !== null) {
$pos = ftell($this->fallback);
return $pos === false ? $this->pos : $pos;
}
return $this->pos;
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool {
if ($this->fallback !== null) {
return fseek($this->fallback, $offset, $whence) === 0;
}
if ($whence === SEEK_CUR) {
$offset += $this->pos;
} elseif ($whence !== SEEK_SET) {
return false; // the size is never asked for, to save a round trip
}
if ($offset < 0) {
return false;
}
if ($this->failed) {
$this->pos = $offset;
return $this->useFallback();
}
if ($offset >= $this->pos && $offset - $this->pos <= strlen($this->buffer)) {
$this->buffer = substr($this->buffer, $offset - $this->pos);
} else {
$this->drain();
$this->buffer = '';
$this->next = $offset;
$this->eof = false;
$this->depth = 1;
$this->shortAnswer = null;
}
$this->pos = $offset;
return true;
}
/** @return array<int|string, int> */
public function stream_stat(): array {
return [];
}
public function stream_close(): void {
if (!$this->failed) {
self::$failures = 0;
}
$fallback = $this->fallback;
$this->fallback = null;
if ($fallback !== null) {
fclose($fallback);
}
$this->closeHandle();
}
/**
* Go on with the storage's own stream from the current position, after a server error on this
* one. The data still buffered is dropped and read again. False after a connection error, or
* when that stream cannot be opened or positioned: then the read fails as it would have
* without this class. Reopening by path is safe for a repository: packs and index files are
* never rewritten, and every object is authenticated when it is decrypted.
*/
private function useFallback(): bool {
$this->closeHandle();
$this->buffer = '';
// files_external's stream takes whatever answer comes next on the connection: after a
// connection error, answers to this stream's requests may still be on their way.
if ($this->broken || $this->storage === null || !is_callable([$this->storage, 'fopen'])) {
return false;
}
try {
$fh = $this->storage->fopen($this->file, 'r');
if (!is_resource($fh)) {
return false;
}
if ($this->pos > 0 && fseek($fh, $this->pos) !== 0) {
fclose($fh);
return false;
}
} catch (\Throwable) {
return false;
}
$this->fallback = $fh;
return true;
}
private function closeHandle(): void {
$this->drain();
if ($this->handle !== '' && !$this->broken) {
try {
$id = self::nextId();
if (!$this->sftp->_send_sftp_packet(self::FXP_CLOSE, pack('Na*', strlen($this->handle), $this->handle), $id)
|| !is_string($this->sftp->_get_sftp_packet($id))) {
$this->broken = true;
}
} catch (\Throwable) {
$this->broken = true;
}
if ($this->broken) {
self::disconnect($this->sftp);
}
}
$this->handle = '';
}
/** Keep the pipeline full, then take the oldest answer. False on a connection or server error. */
private function receive(): bool {
if ($this->failed) {
return false;
}
try {
return $this->receiveOne();
} catch (\Throwable) {
$this->broken = true;
return $this->fail();
}
}
private function receiveOne(): bool {
while (count($this->inFlight) < $this->depth) {
$id = self::nextId();
$length = self::chunk($this->sftp);
$packet = pack('Na*N3', strlen($this->handle), $this->handle, intdiv($this->next, 4294967296), $this->next % 4294967296, $length);
if (!$this->sftp->_send_sftp_packet(self::FXP_READ, $packet, $id)) {
$this->broken = true;
return $this->fail();
}
$this->inFlight[] = [$id, $this->next, $length];
$this->next += $length;
}
[$id, $offset, $length] = array_shift($this->inFlight);
$response = $this->sftp->_get_sftp_packet($id);
if (!is_string($response)) {
$this->broken = true;
return $this->fail();
}
if ($this->sftp->packet_type === self::FXP_DATA) {
$data = substr($response, 4);
if ($data === '') {
return $this->fail();
}
$this->buffer .= $data;
$this->depth = min(self::DEPTH, $this->depth * 2);
if ($this->shortAnswer !== null) {
// More data after a short answer: that one was the server's cap, not the end.
// Never below what every server must support: a one-off small answer is no cap.
self::$chunks ??= new \WeakMap();
self::$chunks[$this->sftp] = max(self::MIN_CHUNK, min(self::chunk($this->sftp), $this->shortAnswer));
$this->shortAnswer = null;
}
if (strlen($data) < $length) {
// Fewer bytes than asked: the end of the file, or a server that caps a READ. The
// requests behind this one assumed a full answer, so drop them and ask again from here.
$this->drain();
$this->next = $offset + strlen($data);
$this->shortAnswer = strlen($data);
}
return true;
}
$status = $this->sftp->packet_type === self::FXP_STATUS ? unpack('N', $response) : false;
if (is_array($status) && ($status[1] ?? null) === self::FX_EOF) {
$this->eof = true;
$this->shortAnswer = null;
$this->drain();
return true;
}
return $this->fail();
}
private function fail(): bool {
$this->drain();
if (!$this->failed) {
self::$failures++;
}
$this->failed = true;
return false;
}
/** Read and drop the answers still on their way, so the connection is free again. */
private function drain(): void {
$inFlight = $this->inFlight;
$this->inFlight = [];
try {
foreach ($this->broken ? [] : $inFlight as [$id]) {
if (!is_string($this->sftp->_get_sftp_packet($id))) {
$this->broken = true;
break;
}
}
} catch (\Throwable) {
$this->broken = true;
}
if ($this->broken) {
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
self::disconnect($this->sftp);
}
}
private static function chunk(object $sftp): int {
$learned = self::$chunks?->offsetExists($sftp) === true ? self::$chunks->offsetGet($sftp) : null;
return is_int($learned) ? $learned : self::CHUNK;
}
private static function nextId(): int {
self::$requestId = self::$requestId >= 0x7fffffff ? 0x4e470000 : self::$requestId + 1;
return self::$requestId;
}
}
+769 -8
View File
@@ -14,23 +14,61 @@ use OCP\Files\Storage\IWriteStreamStorage;
* IBackend on top of a Nextcloud storage object (from files_external or core), below a base
* directory on that storage.
*/
final class StorageBackend implements IBackend {
final class StorageBackend implements IBackend, IReadsAhead {
/** Object stores write whole objects atomically; other storages get a temp name + rename. */
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;
/**
* Ranges readAhead() fetches at once over S3, each straight into a file in the spool (a range
* is at most ReadAheadPlan::MAX_RANGE: the spool holds at most this many times that).
*/
public const S3_READ_AHEAD = 8;
/** 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;
/** Connections readAhead() opens (see SftpRangeFetcher::DEFAULT_CONNECTIONS). */
public int $readAheadConnections = SftpRangeFetcher::DEFAULT_CONNECTIONS;
/** Times the storage was built anew after its connection died (for tests and diagnostics). */
public int $reconnects = 0;
/**
* @param (\Closure():IStorage)|null $reconnect builds the same storage anew, with a new
* connection
*/
public function __construct(
private IStorage $storage,
private string $base = '',
bool $createBase = true,
private ?\Closure $reconnect = null,
) {
$this->lastUsed = microtime(true);
$this->base = trim($base, '/');
self::validate($this->base, true);
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
if ($this->base !== '') {
$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);
}
}
/** A temporary upload of put(): "<name>.part-<8 hex>". */
public static function isTemporary(string $name): bool {
return preg_match('/\.part-[0-9a-f]{8}$/', $name) === 1;
}
public function put(string $path, $stream): void {
// not repeated on a new connection: the stream is (partly) read
$this->idleCheck();
$expected = self::remaining($stream);
$target = $this->abs($path);
$this->mkdirs(dirname($target));
$writeTo = $this->atomicPut ? $target : $target . '.part-' . bin2hex(random_bytes(4));
@@ -54,6 +92,10 @@ final class StorageBackend implements IBackend {
if (is_resource($stream)) {
fclose($stream);
}
// Some adapters report success after a short write or a failed flush: check the size.
if ($expected !== null && $this->storage->filesize($writeTo) !== $expected) {
throw new BackendException('Incomplete write for ' . $path);
}
if ($writeTo !== $target && !$this->storage->rename($writeTo, $target)) {
throw new BackendException('Cannot finalise ' . $path);
}
@@ -69,14 +111,316 @@ final class StorageBackend implements IBackend {
}
public function get(string $path) {
$fh = $this->storage->fopen($this->abs($path), 'r');
return $this->call(fn () => $this->open($path));
}
/** @return resource */
private function open(string $path) {
if ($this->sftpReads) {
$fh = SftpReadStream::open($this->storage, $this->abs($path));
if (is_resource($fh)) {
return $fh;
}
// null: not supported or failing for now; false: not opened, which need not mean
// missing. Either way the storage's own stream decides.
}
try {
$fh = $this->storage->fopen($this->abs($path), 'r');
} catch (\Exception $e) {
// Some storages throw for what is not a missing file (SMB: a connection that broke).
// As a BackendException, the caller reads once more and --salvage can go on.
throw new BackendException('Cannot read ' . $path . ': ' . $e->getMessage(), 0, $e);
}
if ($fh === false) {
throw new BackendException('Not found: ' . $path);
}
return $fh;
}
/**
* Over SFTP and S3 several at a time (SftpReadStream::readMany(), s3Fetch()); elsewhere one
* after the other, over SMB in fewer round trips (smbWhole()).
*/
public function getMany(array $paths): \Generator {
$size = $this->s3Reads ? 16 * self::S3_CONCURRENCY : 8 * SftpReadStream::BATCH;
foreach (array_chunk($paths, $size) as $chunk) {
$fast = null;
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));
}
foreach ($chunk as $path) {
// over SMB in fewer round trips, one after the other (smbWhole())
$data = $fast[$path] ?? $this->smbWhole($this->abs($path));
if ($data === null) {
// not read in the batch (or no batches here): the usual way, which also
// reports a missing file as such
$fh = $this->get($path);
try {
$data = stream_get_contents($fh);
} finally {
fclose($fh);
}
if ($data === false) {
throw new BackendException('Read failed for ' . $path);
}
}
yield $path => $data;
}
}
}
/** 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 {
$s3 = $this->s3Client();
if ($s3 === null) {
return null;
}
[$client, $bucket, $sse] = $s3;
$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;
}
}
/**
* files_external's S3 client, bucket and server-side encryption parameters; null when the
* storage is not as expected.
*
* @return array{0: \Aws\S3\S3Client, 1: string, 2: array}|null
* @psalm-suppress UndefinedClass the AWS SDK comes with Nextcloud, not with this app
*/
private function s3Client(): ?array {
/** @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);
return $client instanceof \Aws\S3\S3Client && is_string($bucket) ? [$client, $bucket, is_array($sse) ? $sse : []] : null;
}
/**
* readAhead() over S3: S3_READ_AHEAD ranges at a time, fetched together straight into files in
* the spool, then handed out in order. One request per range instead of one per blob (with a
* new HTTPS connection each), and several at once: a restore is no longer bound by the round
* trip. A range that could not be fetched is handed out as null (the caller reads it the
* usual way, which reports why).
*
* @param list<array{0:string, 1:int, 2:int}> $ranges
* @return \Generator<int, resource|null>|null
* @psalm-suppress UndefinedClass the AWS SDK comes with Nextcloud, not with this app
*/
private function s3ReadAhead(array $ranges, string $spool): ?\Generator {
// before anything is fetched: an invalid path throws
$keys = array_map(fn (array $r): string => trim($this->abs($r[0]), '/'), $ranges);
try {
$s3 = $this->s3Client();
} catch (\Throwable) {
$s3 = null;
}
if ($s3 === null) {
return null;
}
[$client, $bucket, $sse] = $s3;
$this->idleCheck();
return (static function () use ($client, $bucket, $sse, $ranges, $keys, $spool): \Generator {
$prefix = rtrim($spool, '/') . '/s3-' . bin2hex(random_bytes(4)) . '-';
foreach (array_chunk(array_keys($ranges), self::S3_READ_AHEAD) as $lot) {
$files = [];
$commands = [];
foreach ($lot as $n => $i) {
$files[$i] = $prefix . $i;
$commands[$n] = $client->getCommand('GetObject', ['Bucket' => $bucket, 'Key' => $keys[$i],
'Range' => 'bytes=' . $ranges[$i][1] . '-' . ($ranges[$i][1] + $ranges[$i][2] - 1), 'SaveAs' => $files[$i]] + $sse);
}
$fetched = [];
try {
(new \Aws\CommandPool($client, $commands, [
'concurrency' => self::S3_READ_AHEAD,
'fulfilled' => function (mixed $result, int $n) use (&$fetched, $lot): void {
$fetched[$lot[$n]] = true;
},
'rejected' => static function (): void {
},
]))->promise()->wait();
} catch (\Throwable) {
// what was not fetched is read the usual way
}
try {
foreach ($lot as $i) {
clearstatcache(true, $files[$i]);
$fh = isset($fetched[$i]) && @filesize($files[$i]) === $ranges[$i][2] ? @fopen($files[$i], 'rb') : false;
@unlink($files[$i]); // the open stream keeps its data
yield $i => is_resource($fh) ? $fh : null;
if (is_resource($fh)) {
fclose($fh);
}
}
} finally {
// stopped halfway: the rest of this lot
foreach ($files as $file) {
@unlink($file);
}
}
}
})();
}
/**
* Over SFTP: SftpRangeFetcher, on readAheadConnections new connections of its own (built with the
* reconnect function), so the storage's connection stays free for other requests. Over S3:
* S3_READ_AHEAD ranges at a time through the S3 client (s3ReadAhead()). Elsewhere not (yet):
* null.
*/
public function canReadAhead(): bool {
return ($this->sftpReads && $this->reconnect !== null) || $this->s3Reads || $this->smbNative();
}
private function smbNative(): bool {
return $this->storage instanceof \OCA\Files_External\Lib\Storage\SMB && extension_loaded('smbclient');
}
public function readAhead(array $ranges, string $spool): ?\Generator {
if ($ranges !== [] && $this->s3Reads) {
return $this->s3ReadAhead($ranges, $spool);
}
if ($ranges !== [] && $this->smbNative()) {
return $this->smbReadAhead($ranges, $spool);
}
$reconnect = $this->reconnect;
if (!$this->sftpReads || $reconnect === null || $ranges === []) {
return null;
}
// before any connection is opened: an invalid path throws
$abs = array_map(fn (array $r): array => [$this->abs($r[0]), $r[1], $r[2]], $ranges);
$storages = [];
$connections = [];
$root = '';
$wanted = max(1, min(SftpRangeFetcher::MAX_CONNECTIONS, $this->readAheadConnections));
for ($n = 0; $n < $wanted; $n++) {
try {
$storage = $reconnect();
$sftp = SftpReadStream::connection($storage);
if ($sftp === null || !is_callable([$storage, 'getRoot'])) {
break;
}
$root = (string)$storage->getRoot();
} catch (\Throwable) {
break; // the server may allow fewer connections: go on with those that work
}
$storages[] = $storage;
$connections[] = $sftp;
}
if ($connections === []) {
return null;
}
// in place of a connection that broke: a new storage, kept like the others
$connect = static function () use ($reconnect, &$storages): ?object {
$storage = $reconnect();
$sftp = SftpReadStream::connection($storage);
if ($sftp !== null) {
$storages[] = $storage;
}
return $sftp;
};
return (static function () use (&$storages, $connections, $root, $abs, $spool, $connect): \Generator {
// the storages hold their connections: kept until the fetcher is done
try {
yield from SftpRangeFetcher::fetch($connections, $root, $abs, $spool, $connect);
} finally {
unset($storages);
}
})();
}
public function getRange(string $path, int $offset, int $length): string {
return $this->call(fn () => $this->readRange($path, $offset, $length));
}
private function readRange(string $path, int $offset, int $length): string {
try {
$chunks = $this->smbChunks($this->abs($path), $offset, $length);
if ($chunks !== null) {
$data = implode('', iterator_to_array($chunks, false));
if (strlen($data) === $length) {
return $data;
}
}
} catch (\Throwable) {
// read the usual way below, which reports what is wrong, over a new connection
$this->smbReconnect();
}
$fh = $this->get($path);
try {
if (fseek($fh, $offset) !== 0) {
@@ -99,23 +443,40 @@ final class StorageBackend implements IBackend {
}
}
/** Whether the storage accepts the connection settings (files_external's own check). */
public function canConnect(): bool {
try {
return !method_exists($this->storage, 'test') || $this->storage->test();
} catch (\Throwable) {
return false;
}
}
public function exists(string $path): bool {
return $this->storage->file_exists($this->abs($path));
$abs = $this->abs($path);
return $this->call(fn () => $this->storage->file_exists($abs), fn (bool $found) => !$found);
}
public function list(string $prefix): array {
$result = [];
$this->listInto($this->abs($prefix), $result);
$abs = $this->abs($prefix);
$result = $this->call(function () use ($abs): array {
$result = [];
$this->listInto($abs, $result);
return $result;
}, fn (array $found) => $found === []);
sort($result);
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
return array_map(fn ($p) => substr($p, $cut), $result);
}
public function delete(string $path): void {
$this->idleCheck();
$this->storage->unlink($this->abs($path));
}
public function move(string $from, string $to): void {
// not repeated on a new connection: the server may have done it before the connection died
$this->idleCheck();
$target = $this->abs($to);
$this->mkdirs(dirname($target));
if (!$this->storage->rename($this->abs($from), $target)) {
@@ -123,6 +484,386 @@ final class StorageBackend implements IBackend {
}
}
public function listFiles(string $dir): array {
$abs = $this->abs($dir);
return $this->call(fn () => $this->filesIn($dir, $abs), fn (array $found) => $found === []);
}
/** @return list<string> */
private function filesIn(string $dir, string $abs): array {
if (!$this->storage->is_dir($abs)) {
return []; // not created yet
}
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
$names = $this->smbNames($abs);
if ($names !== null) {
$result = [];
foreach ($names as $name) {
if (!self::isTemporary($name)) {
$result[] = substr($abs . '/' . $name, $cut);
}
}
sort($result);
return $result;
}
$dh = $this->storage->opendir($abs);
if ($dh === false) {
// An existing folder read as empty would look like a new repository.
throw new BackendException('Cannot list ' . $dir);
}
$result = [];
while (($name = readdir($dh)) !== false) {
if ($name !== '.' && $name !== '..' && !self::isTemporary($name)) {
$result[] = substr($abs . '/' . $name, $cut);
}
}
closedir($dh);
sort($result);
return $result;
}
/**
* The names in an SMB folder, read in one listing; null for other storages, with access
* control lists on, or when the storage's internals are not as expected. The SMB storage's
* opendir() asks the server for the attributes of each entry to leave out hidden ones: with
* thousands of index files that took minutes. Hidden entries are still left out: names
* starting with a dot, and, after asking, any name a repository does not use (letters,
* digits, - and _), such as desktop.ini or Thumbs.db. The repository's own files have such
* names: they are not checked one by one, which was the cost.
*
* @return list<string>|null
*/
/**
* Bytes per read over SMB (smbChunks()). libsmbclient keeps a large read in flight as many
* requests at once; icewind's stream reads 1 MiB at a time. Over a line with a 130 ms round
* trip: 4.5 MiB/s in 1 MiB reads, 18 MiB/s in 8 MiB reads (measured 2026-11-04, Samba in a
* container behind netem). Without latency it makes no difference.
*/
public const SMB_READ = 8 * 1048576;
/**
* $length bytes of $abs from $offset over SMB, SMB_READ at a time straight through the native
* extension's state of files_external's SMB storage. Null when the storage is not SMB through
* the extension, or its internals are not as expected (the usual way reads it then). The
* generator throws when a read fails.
*
* @return \Generator<int, string>|null
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
*/
private function smbChunks(string $abs, int $offset, int $length): ?\Generator {
$handle = $this->smbHandle($abs);
if ($handle === null) {
return null;
}
[$state, $url] = $handle;
/** @psalm-suppress MixedMethodCall the state is icewind's NativeState */
return (static function () use ($state, $url, $offset, $length): \Generator {
$fh = $state->open($url, 'r');
try {
$state->lseek($fh, $offset, SEEK_SET, $url);
for ($got = 0; $got < $length;) {
$want = min(self::SMB_READ, $length - $got);
$chunk = self::smbRead($state, $fh, $want, $url);
if ($chunk === '') {
throw new BackendException('Short read in ' . $url);
}
$got += strlen($chunk);
yield $chunk;
}
} finally {
$state->close($fh, $url);
}
})();
}
/**
* A whole (small) file over SMB in four round trips: open, read, read up to the end, close.
* files_external's fopen() first checks that the file exists (a stat with its attributes) and
* icewind's stream reads 1 MiB at a time: about 7 round trips, 1 s per index file from Canada
* to the Storage Box (130 ms), so ~8 h for the 27,000 index files of a 1 TB repository. Null
* when the storage is not SMB through the extension or anything fails: the usual way then
* reads the file and reports a missing one as such.
*
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
*/
private function smbWhole(string $abs): ?string {
$handle = $this->smbHandle($abs);
if ($handle === null) {
return null;
}
[$state, $url] = $handle;
try {
/** @psalm-suppress MixedMethodCall the state is icewind's NativeState */
$fh = $state->open($url, 'r');
} catch (\Throwable) {
return null;
}
$data = '';
try {
do {
$chunk = self::smbRead($state, $fh, self::SMB_READ, $url);
$data .= $chunk;
} while ($chunk !== '');
} catch (\Throwable) {
$data = null;
}
try {
/** @psalm-suppress MixedMethodCall */
$state->close($fh, $url);
} catch (\Throwable) {
}
if ($data === null) {
$this->smbReconnect(); // a read failed: the usual way reads it over a new connection
}
return $data;
}
/**
* One read of at most $bytes through the native state, checked. When libsmbclient fails a read
* (seen when the connection to the server dropped during a long restore), the smbclient
* extension does not return false but a string whose length is -1, because it stores
* smbc_read()'s -1 in an unsigned length; icewind passes it on. Counting that as data made
* the read loop go backwards forever at 100% CPU (DR test 2026-11-05, after 392 GiB).
*
* @param resource $fh
* @psalm-suppress MixedMethodCall, MixedArgument the state is icewind's NativeState
*/
private static function smbRead(object $state, $fh, int $bytes, string $url): string {
$chunk = $state->read($fh, $bytes, $url);
$n = is_string($chunk) ? strlen($chunk) : -1;
if ($n < 0 || $n > $bytes) {
throw new BackendException('Read failed in ' . $url);
}
return $chunk;
}
/**
* After a failed read: drop icewind's connection state, so the next read (the usual way, or
* the next range) connects again instead of failing on a connection that is gone.
*
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
*/
private function smbReconnect(): void {
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB) {
return;
}
try {
(function (): void {
/** @psalm-suppress UndefinedThisPropertyFetch */
$share = $this->share;
if ($share instanceof \Icewind\SMB\Native\NativeShare) {
(function (): void {
/** @psalm-suppress UndefinedThisPropertyAssignment */
$this->state = null;
})->call($share);
}
})->call($this->storage);
} catch (\Throwable) {
}
}
/**
* icewind's NativeState and the smb:// URL of $abs behind files_external's SMB storage, reached
* through bound closures; null when the storage is not SMB through the native extension or its
* internals are not as expected.
*
* @return array{0: object, 1: string}|null
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
*/
private function smbHandle(string $abs): ?array {
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB || !extension_loaded('smbclient')) {
return null;
}
try {
/** @var array{0: object, 1: string}|null $handle */
$handle = (function (string $path): ?array {
/** @psalm-suppress UndefinedThisPropertyFetch */
$share = $this->share;
if (!$share instanceof \Icewind\SMB\Native\NativeShare) {
return null;
}
/** @psalm-suppress UndefinedMethod */
$sharePath = ltrim($this->buildPath($path), '/');
/** @psalm-suppress UndefinedMethod */
return (fn (string $p): array => [$this->getState(), $this->buildUrl($p)])->call($share, $sharePath);
})->call($this->storage, $abs);
} catch (\Throwable) {
return null;
}
return $handle;
}
/**
* readAhead() over SMB: one range after the other, each in SMB_READ reads into a file in the
* spool. Not several at once (PHP cannot wait on more than one libsmbclient call), but whole
* ranges of up to ReadAheadPlan::MAX_RANGE in large reads instead of blob by blob in 1 MiB
* reads. A range that cannot be read is handed out as null.
*
* @param list<array{0:string, 1:int, 2:int}> $ranges
* @return \Generator<int, resource|null>
*/
private function smbReadAhead(array $ranges, string $spool): \Generator {
// before anything is read: an invalid path throws
$abs = array_map(fn (array $r): array => [$this->abs($r[0]), $r[1], $r[2]], $ranges);
return (function () use ($abs, $spool): \Generator {
$file = rtrim($spool, '/') . '/smb-' . bin2hex(random_bytes(4));
try {
foreach ($abs as $i => [$path, $offset, $length]) {
$fh = null;
$out = false;
try {
$chunks = $this->smbChunks($path, $offset, $length);
$out = $chunks === null ? false : fopen($file, 'w+b');
if ($chunks !== null && $out !== false) {
foreach ($chunks as $chunk) {
if (fwrite($out, $chunk) !== strlen($chunk)) {
throw new BackendException('Cannot write to the spool');
}
}
$fh = ftell($out) === $length && rewind($out) ? $out : null;
if ($fh === null) {
fclose($out);
}
}
} catch (\Throwable) {
if (is_resource($out)) {
fclose($out);
}
$fh = null; // read the usual way, which reports why, over a new connection
$this->smbReconnect();
} finally {
@unlink($file); // an open stream keeps its data
}
yield $i => $fh;
if (is_resource($fh)) {
fclose($fh);
}
}
} finally {
@unlink($file);
}
})();
}
private function smbNames(string $abs): ?array {
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB) {
return null;
}
try {
/** @var list<string>|null */
return (function (string $path): ?array {
/** @psalm-suppress UndefinedThisPropertyFetch */
if ($this->checkAcl) {
return null;
}
$names = [];
/** @psalm-suppress UndefinedThisPropertyFetch, UndefinedMethod */
foreach ($this->share->dir(ltrim($this->buildPath($path), '/')) as $info) {
$name = (string)$info->getName();
/** @psalm-suppress UndefinedThisPropertyFetch */
if (!$this->showHidden && (str_starts_with($name, '.')
|| (preg_match('/^[A-Za-z0-9_-]+$/', $name) !== 1 && $info->isHidden()))) {
continue;
}
$names[] = $name;
}
return $names;
})->call($this->storage, $abs);
} catch (\Throwable) {
return null; // another SMB storage version, or an error: the usual way reports it
}
}
/**
* Run $op; when it throws, or $failed says its result may come from a dead connection, and
* the SFTP connection turns out to be dead, run it once more on a new connection.
*
* @template T
* @param \Closure():T $op
* @param (\Closure(T):bool)|null $failed
* @return T
*/
private function call(\Closure $op, ?\Closure $failed = null): mixed {
$this->idleCheck();
try {
$result = $op();
} catch (\Throwable $e) {
if (!$this->reopenIfDead()) {
throw $e;
}
return $op();
}
if ($failed !== null && $failed($result) && $this->reopenIfDead()) {
return $op();
}
return $result;
}
/**
* A Hetzner Storage Box drops an SFTP connection that carried a lot of data and then stayed
* quiet for a few minutes, while phpseclib still takes it for open; files_external never
* builds a new one, and every request on it fails as if the file were missing. So after
* such a pause the connection is checked (one round trip) before it is used again.
*/
private function idleCheck(): void {
$now = microtime(true);
if ($this->reconnect !== null && $this->sftpReads && $now - $this->lastUsed >= self::IDLE_CHECK
&& $this->alive(true) === false) {
$this->reopen();
}
$this->lastUsed = microtime(true);
}
/** After a failed request: ask the server (one round trip), as phpseclib may not know it is gone. */
private function reopenIfDead(): bool {
if ($this->reconnect === null || !$this->sftpReads || $this->alive(true) !== false) {
return false;
}
$this->reopen();
return true;
}
/**
* Whether the storage's SFTP connection is alive; null when it cannot tell.
* $ask: send a request instead of trusting what phpseclib saw last.
*/
private function alive(bool $ask): ?bool {
try {
if (!is_callable([$this->storage, 'getConnection'])) {
return null;
}
$sftp = $this->storage->getConnection();
if (!is_object($sftp) || !is_callable([$sftp, 'isConnected'])) {
return null;
}
if (!$sftp->isConnected()) {
return false;
}
if (!$ask || !is_callable([$sftp, 'stat'])) {
return true;
}
if (is_callable([$sftp, 'clearStatCache'])) {
$sftp->clearStatCache(); // or stat() answers from memory
}
// A Storage Box drops a quiet connection without phpseclib noticing: it still says
// connected, and every request fails. So the answer counts, not what it says after.
return $sftp->stat('.') !== false && (bool)$sftp->isConnected();
} catch (\Throwable) {
return false;
}
}
private function reopen(): void {
/** @var \Closure():IStorage $reconnect */
$reconnect = $this->reconnect;
$this->storage = $reconnect();
$this->reconnects++;
$this->lastUsed = microtime(true);
if (function_exists('OCP\Log\logger')) {
\OCP\Log\logger('ng_backup')->info('The SFTP connection to the backup location was lost; reconnected');
}
}
private function listInto(string $dir, array &$result): void {
if (!$this->storage->is_dir($dir)) {
return;
@@ -132,19 +873,39 @@ final class StorageBackend implements IBackend {
return;
}
while (($name = readdir($dh)) !== false) {
if ($name === '.' || $name === '..' || str_contains($name, '.part-')) {
if ($name === '.' || $name === '..') {
continue;
}
$child = $dir . '/' . $name;
if ($this->storage->is_dir($child)) {
$this->listInto($child, $result);
} else {
} elseif (!self::isTemporary($name)) {
$result[] = $child;
}
}
closedir($dh);
}
/**
* 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 {
if (!is_resource($stream)) {
return null;
}
$meta = stream_get_meta_data($stream);
if (!($meta['seekable'] ?? false) || !in_array($meta['wrapper_type'] ?? '', ['PHP', 'plainfile'], true)) {
return null;
}
$stat = fstat($stream);
$pos = ftell($stream);
if ($stat === false || $pos === false) {
return null;
}
return max(0, $stat['size'] - $pos);
}
private function mkdirs(string $dir): void {
if ($dir === '' || $dir === '.' || $this->storage->is_dir($dir)) {
return;
+48 -18
View File
@@ -32,9 +32,15 @@ final class BackupJob extends TimedJob {
private RunMapper $runs,
private IAppConfig $appConfig,
private LoggerInterface $logger,
private \OCA\NgBackup\Service\Schedule $schedule,
private \OCA\NgBackup\Service\HeavyJobPause $heavyJobs,
) {
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);
}
@@ -42,15 +48,24 @@ final class BackupJob extends TimedJob {
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
$deadline = microtime(true) + $budget;
$this->startScheduled();
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
$this->heavyJobs->sync($this->backups->runsInTurn() !== []);
// One after the other: runs that read the same disks at the same time are not faster together.
foreach ($this->backups->runsInTurn() as $run) {
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
$next = $this->backups->step($run, min($deadline, microtime(true) + $budget));
if ($next === $run) {
break 2; // step() hands back the same object when another process holds the run's lease
}
$run = $next;
}
}
$this->failStaleRestores();
$this->weeklyPrune();
foreach ($this->targets->list() as $t) {
$targets = $this->targets->list();
foreach ($targets as $t) {
$this->alerts->checkStale($t);
$othersActive = array_filter($targets, static fn ($o) => $o->getId() !== $t->getId() && (string)$o->getSchedule() !== '') !== [];
$this->alerts->checkOff($t, $othersActive);
}
}
@@ -67,10 +82,15 @@ final class BackupJob extends TimedJob {
}
}
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
/**
* Once a week, apply the retention policy to non-append-only locations: from Sunday on, at the
* first moment no backup is running, so a long Sunday backup only delays it instead of skipping
* the week.
*/
private function weeklyPrune(): void {
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
if (date('N') !== '7' || $lastWeek === date('o-W') || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) {
$due = date('N') === '7' ? date('o-W') : date('o-W', (int)strtotime('last sunday'));
if (strcmp($lastWeek, $due) >= 0 || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) {
return;
}
foreach ($this->targets->list() as $t) {
@@ -83,22 +103,21 @@ final class BackupJob extends TimedJob {
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
}
}
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', date('o-W'));
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', $due);
}
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
/** Start the backups the schedules of the locations ask for; they run in turn (see LocationSchedule). */
private function startScheduled(): void {
$at = $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', '');
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) {
return;
}
$todayAt = strtotime(date('Y-m-d') . ' ' . $at);
if (time() < $todayAt) {
return;
}
$now = time();
foreach ($this->targets->list() as $t) {
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
if ($this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
$moment = \OCA\NgBackup\Service\LocationSchedule::lastMoment((string)$t->getSchedule(), $this->schedule->zoneOr($t->getScheduleZone()), $now);
if ($moment === null || $this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== []) {
continue;
}
$attempts = $this->runs->startedSince($t->getId(), BackupService::KIND_FULL, $moment);
if (!\OCA\NgBackup\Service\LocationSchedule::isDue($moment, (int)$t->getScheduleSince(),
$this->dataStarted($this->runs->lastFinished($t->getId(), BackupService::KIND_FULL)),
count($attempts), ($attempts[0] ?? null)?->getFinishedAt(), $now)) {
continue;
}
try {
@@ -108,4 +127,15 @@ final class BackupJob extends TimedJob {
}
}
}
/** When a backup began reading its data: a queued one can start well after it was asked for. */
private function dataStarted(?\OCA\NgBackup\Db\Run $run): ?int {
if ($run === null) {
return null;
}
$state = json_decode($run->getState(), true);
$started = is_array($state) ? ($state['files']['started'] ?? null) : null;
$at = is_string($started) ? strtotime($started) : false;
return $at !== false ? $at : $run->getStartedAt();
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\UserBackupService;
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class BackupUser extends Command {
public function __construct(
private TargetService $targets,
private UserBackupService $userBackup,
private IUserManager $userManager,
private UserMigrationInstaller $installer,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:user:backup')
->setDescription('Back up one user via user_migration (account, settings, files and everything else a migrator covers)')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('user', InputArgument::REQUIRED, 'User id')
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
->setHelp(<<<'HELP'
Streams a user_migration export straight into the encrypted repository: no zip, no copy in the
user's own storage. Shares data with the regular backup; this needs the user_migration app
enabled. The manifest path this prints is what occ backup:user:restore expects.
Example:
occ backup:user:backup offsite alice
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$uid = (string)$input->getArgument('user');
$user = $this->userManager->get($uid);
if ($user === null) {
$output->writeln("<error>No such user: $uid</error>");
return 1;
}
try {
$target = $this->targets->get($input->getArgument('target'));
$this->installer->ensureEnabled($input, $output);
$manifest = $this->userBackup->backupUser($target, $user);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln("Backed up <info>$uid</info> to $manifest.");
return 0;
}
}
+6 -2
View File
@@ -7,7 +7,9 @@ declare(strict_types=1);
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Format;
use OCA\NgBackup\Service\RestoreService;
use OCA\NgBackup\Service\Schedule;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
@@ -19,6 +21,7 @@ final class Browse extends Command {
public function __construct(
private TargetService $targets,
private RestoreService $restore,
private Schedule $schedule,
) {
parent::__construct();
}
@@ -43,9 +46,10 @@ final class Browse extends Command {
$output->writeln('Nothing here.');
return 0;
}
$zone = $this->schedule->get()['timezone'];
$rows = array_map(fn ($i) => [$i['type'] === 'dir' ? $i['name'] . '/' : $i['name'], $this->size($i['size']),
$i['type'] === 'dir' ? $i['files'] . ' files' : date('Y-m-d H:i', (int)$i['mtime'])], $items);
(new Table($output))->setHeaders(['name', 'size', 'files / modified'])->setRows($rows)->render();
$i['type'] === 'dir' ? $i['files'] . ' files' : Format::time((int)$i['mtime'], $zone)], $items);
(new Table($output))->setHeaders(['name', 'size', "files / modified ($zone)"])->setRows($rows)->render();
return 0;
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Service\BackupService;
use OCP\AppFramework\Db\DoesNotExistException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class CancelRun extends Command {
public function __construct(
private RunMapper $runs,
private BackupService $backups,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:cancel')
->setDescription('Stop a running backup')
->addArgument('run', InputArgument::REQUIRED, 'Run number, as shown by backup:status')
->setHelp("No new step of the run starts; the step in progress finishes first (usually within a
"
. "minute), so a backup that is just being completed is not cut off. The process working on the
"
. "run (occ backup:run, or cron) then stops. A backup started by an earlier version is marked
"
. "cancelled after two minutes and stops at the end of its step. What it already stored is
"
. "removed by the next cleanup.");
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$id = (string)$input->getArgument('run');
if (!ctype_digit($id)) {
$output->writeln('<error>Give the run number, as shown by backup:status.</error>');
return 1;
}
try {
$run = $this->runs->find((int)$id);
} catch (DoesNotExistException) {
$output->writeln("<error>There is no run $id.</error>");
return 1;
}
if ($run->getKind() !== BackupService::KIND_FULL) {
$output->writeln("<error>Run $id is not a backup; only backups can be cancelled.</error>");
return 1;
}
if ($run->getStatus() === Run::RUNNING) {
$output->writeln("Waiting for the current step of run $id to finish...");
}
try {
$run = $this->backups->cancel($run);
} catch (\RuntimeException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
if ($run->getStatus() !== Run::CANCELLED) {
$output->writeln("Run $id is not running (status: {$run->getStatus()}); nothing to cancel.");
return 0;
}
$output->writeln("Run $id is cancelled.");
return 0;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
final class KeyImport extends Command {
public function __construct(
private KeyService $keys,
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:key:import')
->setDescription('Disaster recovery: adopt the backup key from a recovery kit on a fresh installation')
->addArgument('kit-file', InputArgument::REQUIRED, 'The recovery kit file (occ backup:key:kit on the original installation)')
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking')
->addOption('replace-unused-key', null, InputOption::VALUE_NONE, 'A new key was made here by mistake: replace it by the kit\'s key, if it was never used (no location, no backup)')
->setHelp(<<<'HELP'
For a fresh installation only (occ backup:key:init must not have been run here yet). Reads one
of the kit's passphrase slots to recover the original master key, so occ backup:target:add opens
the existing repository on the location instead of creating a new, empty one.
A new key was made here by mistake before the location was added? As long as it was never used
(no location, no backup), --replace-unused-key replaces it by the kit's key. A key that was used
is never replaced.
No kit? Skip this command: occ backup:target:add then takes the key from the location with one
of the old server's passphrases. The kit still helps when the location's config file is damaged.
Example:
occ backup:key:import /root/ng_backup-recovery-kit.json
occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=...
occ backup:restore:full offsite <snapshot>
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$replace = (bool)$input->getOption('replace-unused-key');
if ($replace && !$this->keys->isInitialized()) {
$output->writeln('<error>There is no backup key here to replace: leave out --replace-unused-key.</error>');
return 1;
}
if ($this->keys->isInitialized()) {
if (!$this->targets->keyIsUnused()) {
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . ') and it has been used for a backup location: it is never replaced.</error>');
$output->writeln('occ backup:key:import is for a server that is being restored.');
return 1;
}
if (!$replace) {
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '), but it was never used: no location, no backup.</error>');
$output->writeln('Made by mistake on a server that is being restored? Run this command again with --replace-unused-key:');
$output->writeln('the kit\'s key then takes its place, and the passphrases and recovery kit of the unused key stop working.');
return 1;
}
}
$kitFile = (string)$input->getArgument('kit-file');
$raw = @file_get_contents($kitFile);
if ($raw === false) {
$output->writeln("<error>Cannot read $kitFile</error>");
return 1;
}
try {
$kit = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$output->writeln('<error>Not a valid recovery kit file: ' . $e->getMessage() . '</error>');
return 1;
}
$file = $input->getOption('passphrase-file');
if ($file !== null) {
$raw = @file_get_contents($file);
if ($raw === false) {
$output->writeln('<error>Cannot read the passphrase file</error>');
return 1;
}
$pass = rtrim($raw, "\r\n");
} else {
$helper = $this->getHelper('question');
$q = (new Question('Passphrase of one of the kit\'s slots: '))->setHidden(true)->setHiddenFallback(false);
$pass = (string)$helper->ask($input, $output, $q);
}
try {
if ($replace) {
$this->targets->importKitReplacingUnusedKey($kit, $pass);
} else {
$this->keys->importRecoveryKit($kit, $pass);
}
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
// Read from the kit file, which is not authenticated beyond the wrapped key: show it so a
// wrong or altered value stands out.
$output->writeln('Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the kit).');
$output->writeln('Next: connect the backup location with the same details as before (<info>occ backup:target:add</info>), then <info>occ backup:list</info> and <info>occ backup:restore:full</info>.');
return 0;
}
}
+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) {
+15 -2
View File
@@ -39,11 +39,24 @@ final class KeyKit extends Command {
$output->write($json, false, OutputInterface::OUTPUT_RAW);
return 0;
}
if (file_put_contents($file, $json) === false) {
// Created owner-only from the start (no window where it is world-readable), never over an
// existing file or symlink someone else may have placed at a predictable path.
$old = umask(0077);
try {
$fh = @fopen($file, 'xb');
} finally {
umask($old);
}
if ($fh === false) {
$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;
}
@chmod($file, 0600);
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
return 0;
}
+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');
}
+7 -3
View File
@@ -8,6 +8,8 @@ declare(strict_types=1);
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Format;
use OCA\NgBackup\Service\Schedule;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
@@ -19,6 +21,7 @@ final class ListSnapshots extends Command {
public function __construct(
private TargetService $targets,
private SnapshotMapper $snapshots,
private Schedule $schedule,
) {
parent::__construct();
}
@@ -32,18 +35,19 @@ final class ListSnapshots extends Command {
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('target');
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
$zone = $this->schedule->get()['timezone'];
$rows = [];
foreach ($targets as $t) {
foreach ($this->snapshots->findForTarget($t->getId()) as $s) {
$rows[] = [$t->getName(), $s->getSnapshotId(), date('Y-m-d H:i', $s->getCreatedAt()), $s->getKind(),
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? ''];
$rows[] = [$t->getName(), $s->getSnapshotId(), Format::time($s->getCreatedAt(), $zone), $s->getKind(),
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getPinned() ? 'yes' : '', $s->getLabel() ?? '', $s->getNote() ?? ''];
}
}
if ($rows === []) {
$output->writeln('No snapshots yet (occ backup:run).');
return 0;
}
(new Table($output))->setHeaders(['location', 'snapshot', 'date', 'kind', 'files', 'size', 'label'])->setRows($rows)->render();
(new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'pinned', 'label', 'note'])->setRows($rows)->render();
return 0;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class ListUserExports extends Command {
public function __construct(
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:user:list')
->setDescription('List the user exports (backup:user:backup) on a location')
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)')
->addArgument('user', InputArgument::OPTIONAL, 'Only the exports of this user id')
->setHelp(<<<'HELP'
The manifest column is what occ backup:user:restore expects. A safety export made by a restore
with --mode=replace is listed here as well.
Example:
occ backup:user:list offsite alice
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('target');
$uid = $input->getArgument('user');
$rows = [];
try {
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
foreach ($targets as $t) {
foreach ($this->targets->repository($t)->listUserExports($uid) as $path) {
$parts = explode('/', $path, 3);
$user = $parts[1] ?? '';
$exportId = $parts[2] ?? '';
$time = \DateTimeImmutable::createFromFormat('Ymd\THis\Z', substr($exportId, 0, 16), new \DateTimeZone('UTC'));
$rows[] = [$t->getName(), $user, $time !== false ? $time->format('Y-m-d H:i') : '', $path];
}
}
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
if ($rows === []) {
$output->writeln('No user exports yet (occ backup:user:backup).');
return 0;
}
(new Table($output))->setHeaders(['location', 'user', 'date (UTC)', 'manifest'])->setRows($rows)->render();
return 0;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\PruneService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class Note extends Command {
public function __construct(
private TargetService $targets,
private PruneService $prune,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:note')
->setDescription('Set or remove the note on a snapshot')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addArgument('note', InputArgument::OPTIONAL, 'The note, at most ' . PruneService::NOTE_MAX . ' characters; without it the note is removed', '');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$note = (string)$input->getArgument('note');
try {
$this->prune->setNote($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), $note);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln(trim($note) === '' ? 'Note removed.' : 'Note saved.');
return 0;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\HeavyJobPause;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class PauseJobs extends Command {
public function __construct(
private HeavyJobPause $pause,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:pause-jobs')
->setDescription('Show or set whether heavy background jobs (Memories, Recognize, Preview Generator, ...) pause while a backup runs')
->addArgument('setting', InputArgument::OPTIONAL, '"on" or "off"; omit to show the current one');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$setting = $input->getArgument('setting');
if ($setting !== null) {
if ($setting !== 'on' && $setting !== 'off') {
$output->writeln('<error>Use "on" or "off".</error>');
return 1;
}
$this->pause->setEnabled($setting === 'on');
}
$status = $this->pause->status();
$output->writeln('Pausing heavy background jobs during a backup: ' . ($status['enabled'] ? 'on' : 'off'));
if ($status['jobs'] !== []) {
$output->writeln('Paused now: ' . implode(', ', $status['jobs']));
}
return 0;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\PruneService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class Pin extends Command {
public function __construct(
private TargetService $targets,
private PruneService $prune,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:pin')
->setDescription('Keep a snapshot: the retention policy does not remove a pinned one')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addOption('unpin', null, InputOption::VALUE_NONE, 'Leave the snapshot to the retention policy again');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$unpin = (bool)$input->getOption('unpin');
try {
$this->prune->setPinned($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), !$unpin);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln($unpin ? 'Snapshot unpinned; the retention policy applies to it again.' : 'Snapshot pinned; the retention policy leaves it alone.');
return 0;
}
}
+16
View File
@@ -37,6 +37,22 @@ final class Prune extends Command {
$output->writeln('Policy: ' . json_encode($this->prune->policy()->toArray()) . ($dry ? ' (dry run)' : ''));
$rc = 0;
foreach ($targets as $t) {
// User exports first: Repository::prune()'s blob garbage collection (run as part of
// apply() below) scans the users/ manifests still on the location at that point, so
// forgetting one here lets its now-unused blobs be freed in this same pass.
try {
$ru = $this->prune->applyUserExports($t, $dry);
} catch (\Throwable $e) {
// Snapshot retention is independent of this: report and carry on with it.
$output->writeln('<error>' . $t->getName() . ' (user exports): ' . $e->getMessage() . '</error>');
$rc = 1;
$ru = ['forgotten' => []];
}
if ($ru['forgotten'] !== []) {
$affected = count(array_unique(array_map(static fn (string $path): string => explode('/', $path)[1] ?? '', $ru['forgotten'])));
$output->writeln(sprintf('<info>%s</info>: %s %d user export(s) for %d user(s)',
$t->getName(), $dry ? 'would forget' : 'forgot', count($ru['forgotten']), $affected));
}
try {
$r = $this->prune->apply($t, $dry);
} catch (\Throwable $e) {
+34 -4
View File
@@ -31,6 +31,7 @@ final class Restore extends Command {
->addArgument('path', InputArgument::REQUIRED, 'Path in the snapshot (see backup:browse)')
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'new-folder, merge or replace (user files)', RestoreService::MODE_NEW_FOLDER)
->addOption('to-directory', 'd', InputOption::VALUE_REQUIRED, 'Write to this local directory instead (any path, raw files)')
->addOption('salvage', null, InputOption::VALUE_NONE, 'Leave out a file that cannot be read or decrypted, list it at the end and go on, instead of stopping')
->setHelp(<<<'HELP'
User files (data/<user>/files/...) are restored through Nextcloud, so versions, trash and the
file cache stay correct. Modes:
@@ -42,30 +43,59 @@ Examples:
occ backup:restore offsite 537bf7e9... data/alice/files/Documents
occ backup:restore offsite 537bf7e9... data/alice/files/Documents/plan.odt --mode=merge
occ backup:restore offsite 537bf7e9... config --to-directory=/root/restored-config
With --salvage, a file whose data on the location is damaged or missing is left out and listed
at the end; everything else is restored. The command then exits with code 2.
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$skipped = $input->getOption('salvage') ? [] : null;
try {
$target = $this->targets->get($input->getArgument('target'));
$snapshot = $input->getArgument('snapshot');
$path = trim((string)$input->getArgument('path'), '/'); // CLI convenience; the service itself only accepts canonical paths
if ($dir = $input->getOption('to-directory')) {
$n = $this->restore->restoreToDirectory($target, $snapshot, $path, $dir);
$n = $this->restore->restoreToDirectory($target, $snapshot, $path, $dir, false, null, $skipped);
$output->writeln("Restored <info>$n</info> files to $dir.");
return 0;
return self::reportSkipped($skipped, $output);
}
$r = $this->restore->restoreUserFiles($target, $snapshot, $path, (string)$input->getOption('mode'),
function (int $done, int $total, string $name) use ($output) {
if ($output->isVerbose() || $done === $total || $done % 100 === 0) {
$output->writeln(" $done/$total $name");
}
});
}, $skipped);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
if ($skipped === null && self::backupLooksDamaged($e)) {
$output->writeln('The data of a file on the location is damaged or missing. Run the same command with --salvage to restore everything else and list what could not be restored.');
}
return 1;
}
$output->writeln(sprintf('Restored <info>%d</info> files to %s%s.', $r['restored'], $r['target'], $r['trashed'] ? ", moved {$r['trashed']} extra files to the trash" : ''));
return 0;
return self::reportSkipped($skipped, $output);
}
/** Damaged data, or a file that is gone from the location: what --salvage is for. */
public static function backupLooksDamaged(\Throwable $e): bool {
return $e instanceof \OCA\NgBackup\Repository\RepositoryException
|| $e instanceof \OCA\NgBackup\Crypto\CryptoException
|| ($e instanceof \OCA\NgBackup\Backend\BackendException && str_starts_with($e->getMessage(), 'Not found'));
}
/**
* The files --salvage left out; exit code 2 when there are any.
*
* @param array<string, string>|null $skipped
*/
public static function reportSkipped(?array $skipped, OutputInterface $output): int {
if ($skipped === null || $skipped === []) {
return 0;
}
$output->writeln(sprintf('<comment>%d file%s could not be restored (damaged or missing on the location):</comment>', count($skipped), count($skipped) === 1 ? '' : 's'));
foreach ($skipped as $path => $reason) {
$output->writeln(" $path: $reason");
}
return 2;
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\RestoreFullService;
use OCA\NgBackup\Service\TargetService;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class RestoreFull extends Command {
public function __construct(
private TargetService $targets,
private RestoreFullService $restoreFull,
private IUserManager $userManager,
private \OCP\App\IAppManager $appManager,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:restore:full')
->setDescription('Disaster recovery: restore the database, data directory and config on a fresh installation')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addOption('force', null, InputOption::VALUE_NONE, 'Also on an installation that already has more than one user, and without asking')
->addOption('install-apps', null, InputOption::VALUE_NONE, 'First install (from the app store) or enable the apps that were enabled at backup time and are missing here')
->addOption('skip-missing-tables', null, InputOption::VALUE_NONE, 'Older backups only: leave out tables that do not exist here and whose structure the backup does not record')
->addOption('salvage', null, InputOption::VALUE_NONE, 'Restore what can be restored: leave out files that cannot be read, and when the database cannot be restored, put the files in a folder of their own')
->setHelp(<<<'HELP'
For a FRESH Nextcloud installation only, same major version as the backup, right after
occ maintenance:install, occ backup:key:import and occ backup:target:add -- before anyone else
uses this instance.
Apps: --install-apps first installs (from the app store) or enables every app that was enabled
when the snapshot was taken. Apps that are not in the app store (own builds) must be copied into
custom_apps by hand; the command lists what is still missing. Tables of apps that are missing or
were removed long ago are created from the structure recorded in the backup (backups made with
1.0.0-rc.2 or later), so they do not block the restore. Older backups do not record it: the
command then lists those tables per app, and --skip-missing-tables leaves them out.
Steps: the database is replaced (consistent, in one transaction); the data directory is restored
directly in place; config.php is merged in -- everything from the backup except the database
connection and data directory settings, which stay as occ maintenance:install configured them
here. Other files under config/ (if any) are not restored.
Afterwards: check trusted_domains and overwritehost/overwriteprotocol match this server, and
occ maintenance:repair.
When the backup is damaged: --salvage restores what can be restored. Files whose data cannot be
read or decrypted are left out and listed. When the database cannot be restored (its dump is
damaged, or it does not fit this installation), the database stays as occ maintenance:install
made it, and the data directory and config of the backup go to ngb-salvage-<snapshot> in the
data directory instead: from there, the files can be put back by hand. The command then exits
with code 2.
Example:
occ backup:restore:full offsite 537bf7e9c1a4...
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$force = (bool)$input->getOption('force');
// A whole-server restore replaces the database and data directory of THIS instance. On
// anything but a fresh install (only the admin from occ maintenance:install) that is
// almost certainly a mistake, so it needs --force.
$users = $this->userManager->countUsersTotal(2);
if (!$force && ($users === false || $users > 1)) {
$output->writeln('<error>This installation already has more than one user: backup:restore:full would replace its whole database and data directory. It is meant for a fresh installation. Use --force if this really is what you want.</error>');
return 1;
}
if (!$force && $input->isInteractive()) {
$q = new ConfirmationQuestion('This replaces the database, the data directory and config.php of this installation. Continue? [y/N] ', false);
if (!(new QuestionHelper())->ask($input, $output, $q)) {
$output->writeln('Cancelled.');
return 1;
}
}
try {
$target = $this->targets->get($input->getArgument('target'));
$snapshot = (string)$input->getArgument('snapshot');
$skipMissingTables = (bool)$input->getOption('skip-missing-tables');
$salvage = (bool)$input->getOption('salvage');
if ($input->getOption('install-apps')) {
$check = $this->restoreFull->checkCompatibility($target, $snapshot, $skipMissingTables, $salvage);
foreach ($check['missingApps'] as $app) {
$this->installApp($app, $output);
}
}
$output->writeln('Nextcloud is in maintenance mode while the restore runs; it is switched off when the restore completes.');
$result = $this->restoreFull->restoreFull($target, $snapshot, $skipMissingTables, $salvage);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
if (\OCP\Server::get(\OCP\IConfig::class)->getSystemValueBool('maintenance', false)) {
$output->writeln('The restore had started: the instance may be partly restored, and maintenance mode stays on so that nobody uses it like that.');
$output->writeln('Fix the cause, then switch maintenance mode off and run the same command again with --force, in one line:');
$again = 'occ backup:restore:full ' . escapeshellarg((string)$input->getArgument('target')) . ' ' . escapeshellarg((string)$input->getArgument('snapshot'))
. ($input->getOption('skip-missing-tables') ? ' --skip-missing-tables' : '') . ($input->getOption('salvage') ? ' --salvage' : '') . ' --force --no-interaction';
$output->writeln(' occ maintenance:mode --off && ' . $again);
$output->writeln('(Without --install-apps, so the instance is open only for the seconds until the restore starts again. Apps the first run could not install are listed at the end: install those afterwards.)');
} else {
$output->writeln('If this happened after the restore had started, the instance may be partly restored: fix the cause and run the same command again with --force.');
}
$output->writeln('If occ then says that Nextcloud needs an upgrade (the database is already the old server\'s), run occ upgrade first.');
if (!$input->getOption('salvage') && Restore::backupLooksDamaged($e)) {
$output->writeln('The backup itself looks damaged here. --salvage restores everything that can be read and lists the rest.');
}
return 1;
}
if ($result['dbError'] !== null) {
$output->writeln('<error>Salvage: ' . $result['dbError'] . '</error>');
$output->writeln(sprintf('The database stays as occ maintenance:install made it. The data directory and config of the backup (<info>%d</info> data files) are in:', $result['dataFiles']));
$output->writeln(' ' . $result['salvageDir']);
$output->writeln('Each user\'s files are in data/<user>/files there. Put back what is needed by hand, for example into the files of new users (then occ files:scan).');
Restore::reportSkipped($result['skipped'], $output);
return 2;
}
$output->writeln(sprintf(
'Database restored (<info>%d</info> tables, <info>%d</info> unchanged), config merged (<info>%d</info> keys), <info>%d</info> data files restored.',
count($result['dbReport']['restored']),
count($result['dbReport']['skipped']),
$result['configKeys'],
$result['dataFiles'],
));
if ($result['dbReport']['created'] !== []) {
$output->writeln(sprintf('Created <info>%d</info> tables that did not exist here (apps that are missing or were removed): %s',
count($result['dbReport']['created']), implode(', ', $result['dbReport']['created'])));
}
foreach ($result['dbReport']['foreignKeyErrors'] ?? [] as $error) {
$output->writeln('<comment>Could not add a foreign key to a created table (the data is restored): ' . $error . '</comment>');
}
if ($result['dbReport']['missing'] !== []) {
$output->writeln('<comment>Left out (structure unknown): ' . implode(', ', $result['dbReport']['missing']) . '</comment>');
}
if ($result['missingApps'] !== []) {
$output->writeln('<comment>These apps were enabled on the old server but are not installed here. Their data is restored; install them so they work again:</comment>');
foreach ($result['missingApps'] as $app) {
$output->writeln(" occ app:install $app (not in the app store? copy it into custom_apps and run occ app:enable $app)");
}
}
if (!$result['dbConsistent']) {
$output->writeln('<comment>Warning: this snapshot\'s database dump was made over several steps (web cron), so it is not one consistent point in time. Check the instance carefully; prefer a snapshot made with system cron.</comment>');
}
if ($result['maintenanceLeftOn']) {
$output->writeln('<comment>Maintenance mode was on before the restore and stays on: switch it off with occ maintenance:mode --off when the instance is ready.</comment>');
}
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
$code = 0;
if ($result['configError'] !== null) {
$output->writeln('<comment>Salvage: config.php of the backup was not merged (' . $result['configError'] . '). Set at least \'instanceid\', \'passwordsalt\' and \'secret\' to the old server\'s values if you have them, or data encrypted with them (server-side encryption, stored passwords) cannot be read.</comment>');
$code = 2;
}
return max($code, Restore::reportSkipped($result['skipped'], $output));
}
/** Enable an app that is here but switched off, or install it from the app store. */
private function installApp(string $app, OutputInterface $output): void {
try {
$this->appManager->getAppPath($app);
$command = 'app:enable'; // here, but switched off
} catch (\OCP\App\AppPathNotFoundException) {
$command = 'app:install';
}
$output->writeln("Running occ $command $app ...");
try {
$code = $this->getApplication()?->find($command)->run(new \Symfony\Component\Console\Input\ArrayInput(['app-id' => $command === 'app:enable' ? [$app] : $app]), $output);
} catch (\Throwable $e) {
$code = 1;
$output->writeln('<comment>' . $e->getMessage() . '</comment>');
}
if ($code !== 0) {
$output->writeln("<comment>Could not $command $app (not in the app store?). Copy it into custom_apps and enable it, or restore without it.</comment>");
}
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\UserRestoreService;
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class RestoreUser extends Command {
public function __construct(
private TargetService $targets,
private UserRestoreService $userRestore,
private UserMigrationInstaller $installer,
private IUserManager $userManager,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:user:restore')
->setDescription('Restore a user from a user_migration export (see backup:user:backup)')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>), or a user id with exactly one export')
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
->addOption('force', null, InputOption::VALUE_NONE, 'Replace an existing account without asking (required when not interactive)')
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
->setHelp(<<<'HELP'
Never imports over a live account. Modes:
replace (default) safety-exports the current account first, then deletes and recreates it
fresh from the backup, under the same user id
as-backup restores into a new account "<uid>-bak" (or "-bak-2", ...) instead, so an admin can
compare and transfer by hand; Nextcloud cannot rename a user id, so the backup gets
the new name, never the existing account
occ backup:user:list shows the exports on a location. A user id alone works when that user
has exactly one export there; with more, the command lists them and you pass one.
Example:
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34
occ backup:user:restore offsite alice
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34 --mode=as-backup
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$mode = (string)$input->getOption('mode');
try {
$target = $this->targets->get($input->getArgument('target'));
$ref = (string)$input->getArgument('manifest');
$manifest = $this->targets->repository($target)->resolveUserExport($ref);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
if ($manifest !== $ref) {
$output->writeln("Using $manifest.");
}
$uid = explode('/', $manifest)[1];
if ($mode === UserRestoreService::MODE_REPLACE && $this->userManager->userExists($uid) && !$input->getOption('force')) {
// Replace deletes the live account (after a safety export). Shares to others and data of
// apps without a user_migration migrator do not come back, so never do it unasked.
if (!$input->isInteractive()) {
$output->writeln("<error>$uid exists: --mode=replace deletes and recreates it. Add --force, or use --mode=as-backup.</error>");
return 1;
}
$q = new ConfirmationQuestion("$uid exists and will be deleted and recreated from the backup (a safety export is made first; its shares to others and data of apps without a migrator are not restored). Continue? [y/N] ", false);
if (!(new QuestionHelper())->ask($input, $output, $q)) {
$output->writeln('Cancelled.');
return 1;
}
}
try {
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
$this->installer->ensureEnabled($input, $output);
$user = $this->userRestore->restoreUser($target, $manifest, $mode);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('Restored <info>' . $user->getUID() . '</info>.');
return 0;
}
}
+15 -1
View File
@@ -28,10 +28,15 @@ final class Retention extends Command {
->addOption('daily', null, InputOption::VALUE_REQUIRED, 'Keep one per day for the last N days with a backup')
->addOption('weekly', null, InputOption::VALUE_REQUIRED, 'Keep one per week for N weeks')
->addOption('monthly', null, InputOption::VALUE_REQUIRED, 'Keep one per month for N months')
->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12");
->addOption('user-last', null, InputOption::VALUE_REQUIRED, 'Keep the newest N of each user\'s own exports (occ backup:user:backup)')
->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12 --user-last=3");
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if ($input->getOption('user-last') !== null && !ctype_digit((string)$input->getOption('user-last'))) {
$output->writeln('<error>--user-last must be a whole number</error>');
return 1;
}
$p = $this->prune->policy()->toArray();
$changed = false;
foreach (['last', 'daily', 'weekly', 'monthly'] as $k) {
@@ -48,7 +53,16 @@ final class Retention extends Command {
return 1;
}
}
if ($input->getOption('user-last') !== null) {
try {
$this->prune->setUserExportRetention((int)$input->getOption('user-last'));
} catch (\InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
}
$output->writeln(sprintf('Keep the last %d, plus one per day for %d days, per week for %d weeks, per month for %d months.', $p['last'], $p['daily'], $p['weekly'], $p['monthly']));
$output->writeln(sprintf('Keep the newest %d of each user\'s own exports.', $this->prune->userExportRetention()));
return 0;
}
}
+10
View File
@@ -75,6 +75,16 @@ HELP);
});
if ($run->getStatus() === Run::DONE) {
$output->writeln(sprintf(' done in %.0fs', microtime(true) - $start));
$f = json_decode($run->getStats() ?? '{}', true)['files'] ?? [];
if (($f['unreadable'] ?? 0) > 0) {
$output->writeln(sprintf('<comment> warning: %d unreadable files or folders are missing from this backup (see the server log)</comment>', $f['unreadable']));
}
if (($f['badNames'] ?? 0) > 0) {
$output->writeln(sprintf('<comment> warning: %d files or folders are missing from this backup because their name is not supported; rename them and the next backup includes them (see the server log)</comment>', $f['badNames']));
}
if (($f['changing'] ?? 0) > 0) {
$output->writeln(sprintf('<comment> warning: %d files kept changing and were saved as read (see the server log)</comment>', $f['changing']));
}
} else {
$output->writeln('<error> ' . $run->getStatus() . ': ' . $run->getError() . '</error>');
$failed++;
+71 -7
View File
@@ -8,33 +8,51 @@ declare(strict_types=1);
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Format;
use OCA\NgBackup\Service\CronHealth;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\Schedule;
use OCA\NgBackup\Service\TargetService;
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,
private RunMapper $runs,
private VerifyService $verify,
private Schedule $schedule,
private CronHealth $cron,
private \OCA\NgBackup\Service\BackupService $backups,
) {
parent::__construct();
}
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 {
$zone = $this->schedule->get()['timezone'];
$cronProblem = CronHealth::describe($this->cron->check());
if ($cronProblem !== null) {
$output->writeln('<error>' . $cronProblem . '</error>');
}
if (!$this->keys->isInitialized()) {
$output->writeln('Key: <error>not created</error> (occ backup:key:init)');
} else {
$c = $this->keys->recoveryKitConfirmation();
$output->writeln('Key: ' . $this->keys->fingerprint() . ', recovery kit '
. ($c ? 'confirmed by ' . $c['uid'] . ' on ' . date('Y-m-d H:i', $c['time']) : '<error>not confirmed</error> (occ backup:key:confirm)'));
. ($c ? 'confirmed by ' . $c['uid'] . ' on ' . Format::time($c['time'], $zone) . " ($zone)" : '<error>not confirmed</error> (occ backup:key:confirm)'));
}
$names = [];
foreach ($this->targets->list() as $t) {
@@ -42,15 +60,61 @@ 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);
$queued = array_map(static fn (\OCA\NgBackup\Db\Run $r): int => $r->getId(), array_slice($this->backups->runsInTurn(), 1));
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() . ')' : ''),
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '',
$s['files'] ?? '', isset($s['uploaded']) ? sprintf('%.1f MiB', $s['uploaded'] / 1048576) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(),
in_array($r->getId(), $queued, true) ? 'queued' : ($r->getStatus() === 'running' ? self::runningStatus($r) : $r->getStatus()),
Format::time($r->getStartedAt(), $zone), $r->getFinishedAt() ? 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', 'uploaded', 'error'])->setRows($rows)->render();
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', "started ($zone)", '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)));
}
return 0;
}
/** " 68%", or " ~68%" when estimated from the file cache; empty without an estimate */
private static function size(int $bytes): string {
$b = (float)$bytes;
foreach (['B', 'KiB', 'MiB', 'GiB'] as $u) {
if ($b < 1024) {
return $u === 'B' ? "$bytes B" : sprintf('%.1f %s', $b, $u);
}
$b /= 1024.0;
}
return sprintf('%.1f TiB', $b);
}
/** " 47/183 tables" in the database phase, " 68%" or " ~68%" in the files phase; empty without a figure */
private static function percent(\OCA\NgBackup\Db\Run $r): string {
$p = \OCA\NgBackup\Job\BackupRun::progressOf($r);
if ($p === null) {
return '';
}
if (isset($p['tables'], $p['totalTables'])) {
return sprintf(': %d/%d tables', $p['tables'], $p['totalTables']);
}
return sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
}
/** "running (db: 47/183 tables)", or "waiting for background jobs (last step 12 min ago)" */
private static function runningStatus(\OCA\NgBackup\Db\Run $r): string {
$waiting = $r->getKind() === \OCA\NgBackup\Service\BackupService::KIND_FULL ? CronHealth::waitingFor($r) : null;
if ($waiting !== null) {
return 'waiting for background jobs (' . $r->getPhase() . self::percent($r) . ', last step ' . CronHealth::ago($waiting) . ')';
}
return 'running (' . $r->getPhase() . self::percent($r) . ')';
}
}
+172 -6
View File
@@ -13,7 +13,9 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
final class TargetAdd extends Command {
public function __construct(
@@ -31,10 +33,25 @@ final class TargetAdd extends Command {
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)')
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)')
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup')
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Disaster recovery without the recovery kit: read the passphrase from this file instead of asking')
->addOption('repair-config', null, InputOption::VALUE_NONE, 'The location\'s config file is gone: write it again with the key of this server or of the imported recovery kit (locations backed up by 1.1.0 or newer)')
->addOption('replace-unused-key', null, InputOption::VALUE_NONE, 'Disaster recovery after a new key was made here by mistake: replace that key, if it was never used, by the key of the backups at this location (asks for a passphrase of the old server)')
->setHelp(<<<'HELP'
The location is reached with files_external's own connection code, but it is never added as an
external storage: it does not appear in Files for anyone. Credentials are stored encrypted.
Disaster recovery without the recovery kit: on a server without a backup key, a location that
holds backups gives its key with one of the old server's passphrases. Run this command first
(no occ backup:key:import), then occ backup:list and occ backup:restore:full.
A new key was made here by mistake (occ backup:key:init or the web page) before the location
was added? As long as that key was never used (no location, no backup), --replace-unused-key
replaces it by the key of the backups. A key that was used is never replaced.
With the recovery kit: occ backup:key:import first; this command then asks for nothing more.
If the location's config file is gone, only the kit has the key: import it, then add the
location with --repair-config.
Examples:
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud
@@ -43,10 +60,6 @@ HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if (!$this->keys->isInitialized()) {
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
return 1;
}
$options = [];
foreach ($input->getOption('option') as $pair) {
if (!str_contains($pair, '=')) {
@@ -60,14 +73,167 @@ HELP);
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
return 1;
}
$name = (string)$input->getArgument('name');
$backend = (string)$input->getArgument('backend');
$auth = (string)$input->getOption('auth');
$path = (string)$input->getOption('path');
$passphrase = null;
$repair = (bool)$input->getOption('repair-config');
$replace = (bool)$input->getOption('replace-unused-key');
if ($replace) {
if ($repair) {
$output->writeln('<error>--replace-unused-key needs the config at the location; with the config gone only the kit helps: occ backup:key:import <kit file> --replace-unused-key, then --repair-config.</error>');
return 1;
}
if (!$this->keys->isInitialized()) {
$output->writeln('<error>There is no backup key here to replace: leave out --replace-unused-key.</error>');
return 1;
}
if (!$this->targets->keyIsUnused()) {
$output->writeln('<error>The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced.</error>');
return 1;
}
$output->writeln('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') was never used. It is replaced by the key of the backups at');
$output->writeln('this location, once a passphrase of the old server opens it. Its own passphrases and recovery kit then stop working.');
$passphrase = $this->passphrase($input, $output);
if ($passphrase === null) {
return 1;
}
} elseif (!$this->keys->isInitialized()) {
try {
$state = $this->targets->locationState($backend, $auth, $options, $path);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
if ($state === TargetService::LOCATION_EMPTY) {
$output->writeln('<error>No backup key yet, and no backups at this location.</error>');
$output->writeln('Restoring a server (disaster recovery)? Check the connection details and --path: they must be the same as on the old server.');
$output->writeln('Only for a new location: create a new key first with occ backup:key:init. A new key cannot read existing backups.');
return 1;
}
if ($state === TargetService::LOCATION_DATA_WITHOUT_CONFIG) {
$output->writeln('<error>This location holds backups, but its config file is gone, and the key was stored in it.</error>');
$output->writeln('Now only the recovery kit has the key. Import it, then run this command again with --repair-config:');
$output->writeln(' occ backup:key:import /path/to/ng_backup-kit.json');
$output->writeln('Do not run occ backup:key:init: a new key cannot read these backups.');
return 1;
}
if ($repair) {
$output->writeln('<error>The config at this location is there: leave out --repair-config.</error>');
return 1;
}
$output->writeln('No backup key on this server yet, and this location holds backups. Their key is stored there too,');
$output->writeln('locked with the passphrases of the old server: one of them unlocks it (the recovery kit is not needed).');
$passphrase = $this->passphrase($input, $output);
if ($passphrase === null) {
return 1;
}
}
// Adopting an existing repository reads its whole index first, which takes a while on a
// large backup or a slow line; show how far it is instead of looking stuck.
$output->writeln('Connecting to the location; with existing backups this reads their index first ...');
try {
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path'));
} catch (\Throwable $e) {
$r = $passphrase === null
? $this->targets->add($name, $backend, $auth, $options, $path, self::progress($output), $repair)
: $this->targets->addWithPassphrase($name, $backend, $auth, $options, $path, $passphrase, self::progress($output), $replace);
} catch (\OCA\NgBackup\Crypto\CryptoException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
if (!$this->keys->isInitialized() || $replace) {
$output->writeln('Nothing was changed. Run the command again to try another passphrase, or use the recovery kit (occ backup:key:import).');
}
return 1;
} catch (\Throwable $e) {
if ($output->isDecorated()) {
$output->write("\r\033[K"); // clear an unfinished progress line
}
$output->writeln('<error>' . $e->getMessage() . '</error>');
if (str_contains($e->getMessage(), 'different key') && !$replace) {
$output->writeln('The backup key here (fingerprint ' . $this->keys->fingerprint() . ') is not the one these backups were made with.');
$output->writeln('Restoring a server? The recovery kit must be the old server\'s: its fingerprint is in the kit file. Nothing was changed.');
}
return 1;
}
if ($repair) {
$output->writeln('The config of the location was written again from the recovery kit.');
}
$output->writeln(sprintf('Location <info>%s</info> added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(),
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
if ($passphrase !== null) {
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key taken from the location, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
$days = 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days';
$output->writeln(match (true) {
($r['deleteDelayDays'] ?? null) !== null => $days . ' (from the location).',
$r['settingsUnreadable'] ?? false => '<comment>' . $days . ' (the longest: the settings on the location could not be read or hold no valid delay, so nothing leaves the Bin sooner than on the old server; removed restore points take space for that long).</comment>',
default => $days . ' (the default: the location has none stored, its backups were made before NG Backup 1.1.0).',
});
$output->writeln('Next: <info>occ backup:list</info> and <info>occ backup:restore:full</info>. Before backups run again, download a new recovery kit and confirm it (<info>occ backup:key:kit</info>, <info>occ backup:key:confirm</info>).');
}
return 0;
}
private function passphrase(InputInterface $input, OutputInterface $output): ?string {
$file = $input->getOption('passphrase-file');
if ($file !== null) {
$raw = @file_get_contents((string)$file);
if ($raw === false) {
$output->writeln('<error>Cannot read the passphrase file</error>');
return null;
}
return rtrim($raw, "\r\n");
}
if (!$input->isInteractive()) {
$output->writeln('<error>Give the passphrase with --passphrase-file, or run the command without --no-interaction to type it.</error>');
return null;
}
$q = (new Question('Passphrase of the old server (one of its slots): '))->setHidden(true)->setHiddenFallback(false);
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
return (string)$helper->ask($input, $output, $q);
}
/**
* Progress of reading the index: on a terminal one line that is updated in place, otherwise
* (a log, a pipe) a line every 30 seconds. The time left is estimated from the rate so far.
*
* @return callable(int, int): void
*/
private static function progress(OutputInterface $output): callable {
$shown = 0.0;
$first = null; // [time, files done] when the first files were in
$inPlace = $output->isDecorated();
return static function (int $done, int $total) use ($output, &$shown, &$first, $inPlace): void {
$now = microtime(true);
if ($first === null && $done > 0) {
$first = [$now, $done];
}
if ($done < $total && $now - $shown < ($inPlace ? 1 : 30)) {
return;
}
$shown = $now;
$line = sprintf('Reading the index: %s/%s files (%d%%)', number_format($done), number_format($total), intdiv(100 * $done, max(1, $total)));
$left = $first === null ? null : self::minutesLeft($now - $first[0], $done - $first[1], $total - $done, $total);
if ($left !== null) {
$line .= $left <= 1 ? ', less than a minute left' : sprintf(', about %d min left', $left);
}
if ($inPlace) {
$output->write("\r\033[K" . $line . ($done >= $total ? "\n" : ''));
} else {
$output->writeln($line);
}
};
}
/**
* Minutes left for reading the index, from the rate since the first files were in: the first
* file also carries the time to connect, which made an S3 location with 4,568 index files
* say "about 642 min left" for something done within a minute. No estimate until 5% of the
* files, or 50 files, came in after that, and at least 5 seconds passed.
*/
public static function minutesLeft(float $elapsed, int $done, int $left, int $total): ?int {
if ($left <= 0 || $done <= 0 || $elapsed < 5.0 || ($done < 50 && $done * 20 < $total)) {
return null;
}
return (int)ceil($elapsed / (float)$done * (float)$left / 60.0);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\AlertService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class TargetEdit extends Command {
public function __construct(
private TargetService $targets,
private AlertService $alerts,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:edit')
->setDescription('Change the connection settings or the name of a backup location, keeping its backups')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addOption('name', null, InputOption::VALUE_REQUIRED, 'New name')
->addOption('backend', 'b', InputOption::VALUE_REQUIRED, 'New files_external backend id (see backup:target:backends)')
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'New auth mechanism id')
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Option key=value to change (repeatable); other options keep their value')
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'New folder on the location')
->setHelp(<<<'HELP'
The new settings must lead to the same repository as before, so the history of the location is
kept. Use it after a password change, a new host name, or after moving the repository.
Example:
occ backup:target:edit nas -o password=new-secret
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$t = $this->targets->get($input->getArgument('target'));
$options = $this->targets->options($t);
if ($input->getOption('backend') !== null || $input->getOption('auth') !== null) {
$options = []; // a different connection type has its own options
}
foreach ($input->getOption('option') as $pair) {
if (!str_contains($pair, '=')) {
$output->writeln("<error>Option '$pair' must be key=value</error>");
return 1;
}
$k = (string)strstr($pair, '=', true);
$v = substr($pair, strlen($k) + 1);
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
}
$before = $t->getName();
$t = $this->targets->update($t, $input->getOption('name') ?? $t->getName(), $input->getOption('backend') ?? $t->getBackend(),
$input->getOption('auth') ?? $t->getAuth(), $options, $input->getOption('path') ?? $t->getBasePath());
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$this->alerts->securityEvent('target_changed', ['target' => $before]);
$output->writeln(sprintf('Location <info>%s</info> updated; repository %s found with the new settings.', $t->getName(), (string)$t->getRepositoryId()));
return 0;
}
}
+2 -1
View File
@@ -31,7 +31,8 @@ final class TargetList extends Base {
$rows = [];
foreach ($this->targets->list() as $t) {
$row = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt())];
'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt()),
'schedule' => (string)$t->getSchedule() !== '' ? $t->getSchedule() : 'off'];
if ($input->getOption('test')) {
try {
$repo = $this->targets->repository($t);
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\Schedule;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class TargetSchedule extends Command {
public function __construct(
private TargetService $targets,
private Schedule $schedule,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:schedule')
->setDescription('Show or set when a location is backed up')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('schedule', InputArgument::OPTIONAL, '"off", HH:MM for once a day, or HH:MM/N for every N hours from HH:MM; omit to show the current one')
->addOption('timezone', null, InputOption::VALUE_REQUIRED, 'Time zone of the time, e.g. Europe/Amsterdam');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$t = $this->targets->get($input->getArgument('target'));
$schedule = $input->getArgument('schedule');
if ($schedule === null && $input->getOption('timezone') !== null) {
$output->writeln('<error>--timezone goes with a schedule, e.g. backup:target:schedule ' . $t->getName() . ' 02:30 --timezone=Europe/Amsterdam</error>');
return 1;
}
$zone = $input->getOption('timezone') ?? $this->schedule->zoneOr($t->getScheduleZone());
if ($schedule !== null) {
try {
$t = $this->targets->setSchedule($t, $schedule, (string)$zone);
} catch (\InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
}
$current = (string)$t->getSchedule();
$output->writeln($t->getName() . ': ' . ($current === '' ? 'off' : $current . ' (' . $zone . ')'));
return 0;
}
}
+9 -2
View File
@@ -7,7 +7,9 @@ declare(strict_types=1);
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Format;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\Schedule;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
@@ -19,6 +21,7 @@ final class Trash extends Command {
public function __construct(
private TargetService $targets,
private KeyService $keys,
private Schedule $schedule,
) {
parent::__construct();
}
@@ -32,11 +35,14 @@ final class Trash extends Command {
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('target');
$delay = $this->keys->deleteDelayDays() * 86400;
$zone = $this->schedule->get()['timezone'];
$rows = [];
foreach ($name === null ? $this->targets->list() : [$this->targets->get($name)] as $t) {
foreach ($this->targets->repository($t)->trash() as $id => $info) {
$rows[] = [$t->getName(), $id, (string)($info['time'] ?? ''), $info['label'], date('Y-m-d H:i', $info['forgottenAt']), $info['by'],
date('Y-m-d H:i', $info['forgottenAt'] + $delay)];
$time = $info['time'] ?? null;
$made = is_string($time) ? strtotime($time) : false;
$rows[] = [$t->getName(), $id, $made !== false ? Format::time($made, $zone) : '', $info['label'], Format::time($info['forgottenAt'], $zone), $info['by'],
Format::time($info['forgottenAt'] + $delay, $zone)];
}
}
if ($rows === []) {
@@ -44,6 +50,7 @@ final class Trash extends Command {
return 0;
}
(new Table($output))->setHeaders(['location', 'snapshot', 'made', 'label', 'removed', 'by', 'deleted after'])->setRows($rows)->render();
$output->writeln("Times in $zone. The retention policy keeps the newest snapshots and the last of each recent day, week and month; it moves the others here, and they are deleted permanently after " . $this->keys->deleteDelayDays() . ' days.');
return 0;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\VerifyService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class Verify extends Command {
public function __construct(
private TargetService $targets,
private VerifyService $verify,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:verify')
->setDescription('Checksum audit of a snapshot: every blob it needs must be in the (freshly re-checked) index and its pack present')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addOption('deep', null, InputOption::VALUE_NONE, 'Also download and decrypt every blob (verifies its authentication tag and content hash, not just that it is listed); much slower')
->setHelp(<<<'HELP'
Without --deep this re-downloads and re-authenticates the index (never trusting the local cache)
and confirms every blob the snapshot needs is listed and its pack is present -- fast, no blob
content is read, so it cannot by itself catch a truncated or bit-rotted pack. With --deep every
blob is also downloaded and decrypted, which fails loudly on exactly that even if the index still
lists it -- the same verification an actual restore would do, run here deliberately instead of
waiting to find out during one.
While it runs it prints how far it is every 30 seconds; occ backup:status shows the same, and
how the last check of each location ended.
Run this periodically (cron, picking a sample of recent snapshots) rather than only right before
you'd need to rely on a restore.
Examples:
occ backup:verify offsite 537bf7e9c1a4...
occ backup:verify offsite 537bf7e9c1a4... --deep
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$target = $this->targets->get($input->getArgument('target'));
$ref = (string)$input->getArgument('snapshot');
$deep = (bool)$input->getOption('deep');
$onProgress = static function (array $state) use ($output): void {
$output->writeln(date('H:i:s') . ' ' . VerifyService::describe($state));
};
$result = str_starts_with($ref, 'users/')
? $this->verify->verifyUserExport($target, $ref, $deep, $onProgress)
: $this->verify->verify($target, $ref, $deep, $onProgress);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln(sprintf('%d files, %d blobs checked%s.', $result['filesChecked'], $result['blobsChecked'],
$deep ? sprintf(' (%.1f MiB decrypted)', $result['bytesChecked'] / 1048576) : ''));
if ($result['missing'] !== []) {
$output->writeln('<error>' . count($result['missing']) . ' blob(s) missing from the index:</error> ' . implode(', ', $result['missing']));
}
if ($result['failed'] !== []) {
$output->writeln('<error>' . count($result['failed']) . ' blob(s) failed verification (corrupted or tampered):</error> ' . implode(', ', $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>');
return 0;
}
return 1;
}
}
+109 -15
View File
@@ -47,18 +47,25 @@ final class AdminApiController extends Controller {
private \OCA\NgBackup\Service\AlertService $alerts,
private \OCA\NgBackup\Service\KeySlotService $slots,
private \OCA\NgBackup\Service\PruneService $prune,
private \OCA\NgBackup\Service\Schedule $schedule,
private \OCA\NgBackup\Service\HeavyJobPause $heavyJobs,
private \OCA\NgBackup\Service\CronHealth $cron,
) {
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)];
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t),
'schedule' => (string)$t->getSchedule(), 'scheduleZone' => $this->schedule->zoneOr($t->getScheduleZone())];
}
$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);
$queued = array_flip(array_map(static fn (Run $r): int => $r->getId(), array_slice($this->backups->runsInTurn(), 1)));
$runs = array_map(fn (Run $r) => $this->runJson($r) + ['queued' => isset($queued[$r->getId()])], $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,10 +75,50 @@ final class AdminApiController extends Controller {
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
'targets' => $targets,
'runs' => $runs,
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown),
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
'cron' => $this->cron->check(),
'heavyJobs' => $this->heavyJobs->status(),
]);
}
public function setPauseHeavyJobs(bool $enabled): JSONResponse {
$this->heavyJobs->setEnabled($enabled);
return new JSONResponse(['enabled' => $enabled]);
}
/** Seconds of backup work one tick() does: well within a normal web request. */
public const TICK_SECONDS = 10.0;
/** Seconds one request for the Bin reads, before the page shows how far it is and asks for more. */
private const TRASH_PAGE_SECONDS = 5.0;
/**
* Move running backups forward from the open admin page, when background jobs do not run on a
* working system cron (#13). The page calls this while a backup runs, so "Back up now" works
* without cron as long as the page stays open; scheduled backups still need cron. With a
* working system cron this does nothing: the background job does the work.
*/
public function tick(): JSONResponse {
if ($this->cron->check()['ok']) {
return new JSONResponse(['stepped' => 0]);
}
$deadline = microtime(true) + self::TICK_SECONDS;
$stepped = 0;
$this->heavyJobs->sync($this->backups->runsInTurn() !== []);
foreach ($this->backups->runsInTurn() as $run) {
if (microtime(true) >= $deadline) {
break;
}
// step() returns at once when the background job (or another tab) holds the run.
$next = $this->backups->step($run, $deadline);
$stepped++;
if ($next->getStatus() === Run::RUNNING) {
break; // not finished: the next one waits for its turn
}
}
return new JSONResponse(['stepped' => $stepped]);
}
#[PasswordConfirmationRequired]
public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse {
try {
@@ -140,15 +187,18 @@ final class AdminApiController extends Controller {
}
}
public function trash(int $targetId): JSONResponse {
public function trash(int $targetId, string $after = ''): JSONResponse {
try {
$delay = $this->keys->deleteDelayDays() * 86400;
$repo = $this->targets->repository($this->targets->get((string)$targetId));
// In parts, so that the page can show how far it is (a read per removed restore point).
$page = $repo->trashPage($after, microtime(true) + self::TRASH_PAGE_SECONDS);
$out = [];
foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() as $id => $info) {
foreach ($page['items'] as $id => $info) {
$out[] = ['id' => $id, 'label' => $info['label'], 'time' => $info['time'], 'removedAt' => $info['forgottenAt'],
'by' => $info['by'], 'deleteAfter' => $info['forgottenAt'] + $delay];
}
return new JSONResponse($out);
return new JSONResponse(['total' => $page['total'], 'items' => $out]);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
@@ -168,6 +218,7 @@ final class AdminApiController extends Controller {
return new JSONResponse($this->targets->availableBackends());
}
#[PasswordConfirmationRequired]
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse {
try {
$r = $this->targets->add($name, $backend, $auth, $options, $path);
@@ -180,6 +231,28 @@ final class AdminApiController extends Controller {
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
}
/** The stored settings for the edit form; password values are left out (null). */
#[PasswordConfirmationRequired]
public function targetSettings(int $id): JSONResponse {
$t = $this->targets->get((string)$id);
return new JSONResponse(['name' => $t->getName(), 'backend' => $t->getBackend(), 'auth' => $t->getAuth(),
'options' => $this->targets->editableOptions($t), 'path' => $t->getBasePath()]);
}
/** Options with a null value keep their stored value. */
#[PasswordConfirmationRequired]
public function updateTarget(int $id, string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse {
try {
$t = $this->targets->get((string)$id);
$before = $t->getName();
$t = $this->targets->update($t, $name, $backend, $auth, $options, $path);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$this->alerts->securityEvent('target_changed', ['target' => $before]);
return new JSONResponse(['id' => $t->getId()]);
}
/** Size limit in GiB; null or 0 = unlimited. */
#[PasswordConfirmationRequired]
public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse {
@@ -213,23 +286,43 @@ final class AdminApiController extends Controller {
return new JSONResponse($this->runJson($run));
}
public function setSchedule(string $time): JSONResponse {
if ($time !== '' && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
return new JSONResponse(['error' => 'Use HH:MM or empty'], Http::STATUS_BAD_REQUEST);
/** @param string $timezone the administrator's zone (the browser's); empty: the default one */
public function setTargetSchedule(int $id, string $schedule, string $timezone = ''): JSONResponse {
try {
$t = $this->targets->setSchedule($this->targets->get((string)$id), $schedule, $timezone !== '' ? $timezone : $this->schedule->get()['timezone']);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$this->appConfig->setValueString(Application::APP_ID, 'schedule_time', $time);
return new JSONResponse(['schedule' => $time]);
return new JSONResponse(['schedule' => $t->getSchedule(), 'scheduleZone' => $t->getScheduleZone()]);
}
public function snapshots(int $targetId): JSONResponse {
$out = [];
foreach ($this->snapshots->findForTarget($targetId) as $s) {
$out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(),
'files' => $s->getFiles(), 'bytes' => $s->getBytes()];
'files' => $s->getFiles(), 'bytes' => $s->getBytes(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()];
}
return new JSONResponse($out);
}
public function setSnapshotNote(int $targetId, string $snapshotId, string $note = ''): JSONResponse {
try {
$this->prune->setNote($this->targets->get((string)$targetId), $snapshotId, $note);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new JSONResponse(['ok' => true]);
}
public function setSnapshotPinned(int $targetId, string $snapshotId, bool $pinned = true): JSONResponse {
try {
$this->prune->setPinned($this->targets->get((string)$targetId), $snapshotId, $pinned);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new JSONResponse(['ok' => true]);
}
public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse {
try {
return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path));
@@ -274,7 +367,7 @@ final class AdminApiController extends Controller {
if ($r->getSnapshotId() !== null) {
try {
$s = $this->snapshots->findOne($r->getTargetId(), $r->getSnapshotId());
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel()];
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()];
} catch (\Throwable) {
// Snapshot already pruned or trashed: the run keeps its id, just not the date.
}
@@ -282,6 +375,7 @@ final class AdminApiController extends Controller {
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
'stats' => json_decode($r->getStats() ?? 'null', true)];
'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r),
'waitingFor' => $r->getKind() === BackupService::KIND_FULL ? \OCA\NgBackup\Service\CronHealth::waitingFor($r) : null];
}
}
+30 -6
View File
@@ -87,19 +87,43 @@ final class KeyRing {
foreach ($field['slots'] as $slot) {
try {
return self::unwrap($slot['key'], $passphrase);
} catch (CryptoException) {
} catch (CryptoException|\SodiumException|\TypeError|\ValueError) {
// a wrong passphrase for this slot, or a damaged slot: the next one may still open
}
}
throw new CryptoException('Wrong passphrase or damaged key');
}
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
/** @param array<string, mixed> $wrapped from a kit file or a location's config: checked here, not trusted */
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
if (!isset($wrapped['salt'], $wrapped['nonce'], $wrapped['key'], $wrapped['ops'], $wrapped['mem'])
|| !is_string($wrapped['salt']) || !is_string($wrapped['nonce']) || !is_string($wrapped['key'])
|| !is_int($wrapped['ops']) || !is_int($wrapped['mem'])) {
throw new CryptoException('Damaged key file: a part of the key is missing');
}
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $b64($wrapped['salt']),
$wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
$master = sodium_crypto_secretbox_open($b64($wrapped['key']), $b64($wrapped['nonce']), $kek);
sodium_memzero($kek);
// ops/mem come from unauthenticated JSON (kit file, repository config): bound them, so a
// damaged or crafted file cannot make the KDF use unbounded memory or time.
if ($wrapped['ops'] < SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE || $wrapped['ops'] > SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE
|| $wrapped['mem'] < SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE || $wrapped['mem'] > SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE) {
throw new CryptoException('Unsupported key derivation parameters (damaged or foreign key file)');
}
$kek = null;
try {
$salt = $b64($wrapped['salt']);
$sealed = $b64($wrapped['key']);
$nonce = $b64($wrapped['nonce']);
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $salt,
$wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
$master = sodium_crypto_secretbox_open($sealed, $nonce, $kek);
} catch (\SodiumException|\ValueError $e) {
// invalid base64 or a salt/nonce of the wrong length: damaged, so the caller shows its recovery options
throw new CryptoException('Damaged key file: ' . $e->getMessage(), 0, $e);
} finally {
if ($kek !== null) {
sodium_memzero($kek);
}
}
if ($master === false) {
throw new CryptoException('Wrong passphrase or damaged key');
}
+8
View File
@@ -122,6 +122,7 @@ final class StreamCipher {
/** fread() may return short reads on network streams; keep reading until $length or EOF. */
private static function readFull($in, int $length): string {
$buffer = '';
$empty = 0;
while (strlen($buffer) < $length && !feof($in)) {
$chunk = fread($in, $length - strlen($buffer));
if ($chunk === false) {
@@ -132,8 +133,15 @@ final class StreamCipher {
if (feof($in)) {
break;
}
// A stalled network stream returns '' without EOF forever: back off, then give up
// (about a minute) instead of spinning at full CPU until the lease expires.
if (++$empty > 600) {
throw new CryptoException('Read stalled');
}
usleep(100000);
continue;
}
$empty = 0;
$buffer .= $chunk;
}
return $buffer;
+367 -50
View File
@@ -23,8 +23,12 @@ use OCA\NgBackup\Repository\Repository;
* sets, so a single big SELECT would not stream).
* - Each table becomes its own blob stream of JSON lines: a header line, then one JSON array per
* row. A table that did not change produces the same blobs and costs nothing in the next run.
* - The schema itself is not dumped: it comes from Nextcloud's migrations on the instance that is
* restored into (same Nextcloud and app versions). The dump records those versions.
* - The structure of each table (columns, types, primary key, indexes) is recorded in the manifest,
* so a restore can create a table that does not exist on the new instance, for example one left
* behind by an app that was removed long ago. Tables that exist keep the structure the new
* instance's migrations gave them.
* - Foreign keys are not checked while rows are loaded (tables are restored in name order, not in
* dependency order), and are checked again before the restore commits.
*
* Phase 0 uses the Doctrine connection behind IDBConnection (private API) for schema
* introspection; to be replaced by information_schema queries or a public API.
@@ -72,50 +76,57 @@ final class DbDumper {
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
$state['steps']++;
$sm = $this->db->createSchemaManager();
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
$this->db->beginTransaction();
// A pack that fills up during the dump is uploaded right away; its index cache row must
// wait until the read-only transaction is over.
$repo->holdIndexCache();
try {
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
$this->db->executeStatement('SET TRANSACTION READ ONLY');
}
while ($state['i'] < count($state['tables'])) {
$table = $state['tables'][$state['i']];
$schema = $sm->introspectTable($table);
$writer = $repo->blobWriter($stats);
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
if ($state['after'] === null && $state['segments'] === []) {
$writer->write($this->header($schema) . "\n");
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
$this->db->beginTransaction();
try {
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
$this->db->executeStatement('SET TRANSACTION READ ONLY');
}
$complete = true;
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
$writer->write($line . "\n");
hash_update($hash, $line . "\n");
$state['rows']++;
$state['after'] = $key;
if (microtime(true) >= $deadline) {
$complete = false;
while ($state['i'] < count($state['tables'])) {
$table = $state['tables'][$state['i']];
$schema = $sm->introspectTable($table);
$writer = $repo->blobWriter($stats);
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
if ($state['after'] === null && $state['segments'] === []) {
$writer->write($this->header($schema) . "\n");
}
$complete = true;
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
$writer->write($line . "\n");
hash_update($hash, $line . "\n");
$state['rows']++;
$state['after'] = $key;
if (microtime(true) >= $deadline) {
$complete = false;
break;
}
}
$state['segments'][] = $writer->finish();
$state['bytes'] += $writer->bytes();
if (!$complete) {
$state['hash'] = base64_encode(serialize($hash));
break;
}
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
'bytes' => $state['bytes'], 'sha256' => hash_final($hash), 'schema' => self::structure($schema)];
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
break;
}
}
$state['segments'][] = $writer->finish();
$state['bytes'] += $writer->bytes();
if (!$complete) {
$state['hash'] = base64_encode(serialize($hash));
break;
if ($state['i'] >= count($state['tables'])) {
$state['sequences'] = $this->sequenceStates($state['tables']);
$state['done'] = true;
}
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
'bytes' => $state['bytes'], 'sha256' => hash_final($hash)];
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
break;
}
}
if ($state['i'] >= count($state['tables'])) {
$state['sequences'] = $this->sequenceStates($state['tables']);
$state['done'] = true;
} finally {
$this->db->rollBack();
}
} finally {
$this->db->rollBack();
$repo->releaseIndexCache();
}
$repo->flushPacks();
return $state;
@@ -130,18 +141,48 @@ final class DbDumper {
* Replace the contents of every table in the dump. Tables that exist but are not in the dump
* are left alone (e.g. a newer app); the caller decides whether that is acceptable.
*
* @param array{tables: array<string, array{blobs: list<string>, rows: int}>} $manifest
* @param array{tables: array<string, array{blobs: list<string>, rows: int, sha256: string, schema?: array}>, sequences?: array} $manifest
*/
public function restore(Repository $repo, array $manifest, ?array &$report = null): int {
$sm = $this->db->createSchemaManager();
$existing = array_flip($sm->listTableNames());
$total = 0;
$report = ['skipped' => [], 'restored' => []];
$this->db->beginTransaction();
public function restore(Repository $repo, array $manifest, ?array &$report = null, ?callable $heartbeat = null, bool $skipMissing = false): int {
$report = ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []];
$source = self::dumpPrefix($manifest);
if ($source !== null && $source !== $this->prefix) {
// Every table would look missing and be created under the old prefix, unused.
throw new \RuntimeException("The backup's tables use the prefix '$source', this installation '{$this->prefix}'. Install Nextcloud again with 'dbtableprefix' => '$source' in config/config.php before occ maintenance:install, and restore again.");
}
$missing = $this->missingTables($manifest);
$unknown = array_values(array_filter($missing, fn (string $t) => !isset($manifest['tables'][$t]['schema'])));
if ($unknown !== [] && !$skipMissing) {
throw new \RuntimeException(self::describeMissing($unknown, $this->prefix));
}
// Tables the backup knows the structure of are created first (DDL is not transactional
// everywhere); tables it does not know are left out, as asked.
try {
foreach ($missing as $table) {
if (isset($manifest['tables'][$table]['schema'])) {
$report['created'][] = $table; // first: creating it can stop halfway (its indexes)
$this->db->createSchemaManager()->createTable(self::tableFromStructure($table, $manifest['tables'][$table]['schema'], $this->tableOptions()));
} else {
$report['missing'][] = $table;
}
}
} catch (\Throwable $e) {
$this->dropCreatedTables($report);
throw $e;
}
$total = 0;
$foreignKeys = [];
try {
$this->db->beginTransaction();
} catch (\Throwable $e) {
$this->dropCreatedTables($report);
throw $e;
}
try {
$foreignKeys = $this->suspendForeignKeys(); // first: on SQLite it also takes the write lock
foreach ($manifest['tables'] as $table => $info) {
if (!isset($existing[$table])) {
throw new \RuntimeException("Table $table from the backup does not exist here (different app versions?)");
if (in_array($table, $report['missing'], true)) {
continue;
}
// Unchanged since the backup: leave it alone (much faster than delete + insert).
$live = $this->checksum($table);
@@ -175,6 +216,9 @@ final class DbDumper {
if (count($batch) >= self::INSERT_ROWS) {
$total += $this->insertBatch($table, $columns, $batch, $binary);
$batch = [];
if ($heartbeat !== null) {
$heartbeat();
}
}
}
if ($batch !== []) {
@@ -182,15 +226,253 @@ final class DbDumper {
}
$report['restored'][] = $table;
}
$this->restoreSequences($manifest['sequences'] ?? []);
// unchanged tables too: a restore that stopped earlier may have left their counters at 1
$this->restoreSequences($manifest['sequences'] ?? [], $report['missing'], array_merge($report['restored'], $report['skipped']));
$this->resumeForeignKeys($foreignKeys);
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
// PostgreSQL rolls the dropped foreign keys back with the transaction; MySQL's session
// switch must be turned on again by hand.
if (in_array('SET FOREIGN_KEY_CHECKS = 1', $foreignKeys, true)) {
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
}
$this->dropCreatedTables($report);
throw $e;
}
// Committed. Foreign keys of the tables created above, now their rows are in. Not inside the
// transaction: MySQL commits on DDL. A failure is reported, not fatal: the data is restored
// either way, and the created tables stay.
foreach ($report['created'] as $table) {
$structure = $manifest['tables'][$table]['schema'] ?? [];
foreach ($structure['foreignKeys'] ?? [] as $fk) {
try {
$this->db->createSchemaManager()->createForeignKey(
new \Doctrine\DBAL\Schema\ForeignKeyConstraint($fk['columns'], $fk['table'], $fk['foreign'], $fk['name'], $fk['options'] ?? []),
$table);
} catch (\Throwable $e) {
$report['foreignKeyErrors'][] = $table . '.' . $fk['name'] . ': ' . $e->getMessage();
}
}
}
return $total;
}
/**
* The restore did not go through: a rollback does not undo CREATE TABLE everywhere. Left behind,
* the empty tables of an app would stop that app's installation later ("table already exists").
*
* @param array{created: list<string>, ...} $report
*/
private function dropCreatedTables(array &$report): void {
foreach ($report['created'] as $table) {
try {
$this->db->createSchemaManager()->dropTable($table);
} catch (\Throwable) {
// the error that brought us here is the one to report; a run again uses the table
}
}
$report['created'] = [];
}
/**
* The table prefix of the instance the dump was made on (from its appconfig table), or null
* when the dump has none.
*
*/
public static function dumpPrefix(array $manifest): ?string {
/** @var list<string> $tables table names */
$tables = array_keys($manifest['tables'] ?? []);
foreach ($tables as $name) {
if (str_ends_with($name, 'appconfig') && !str_ends_with($name, '_ex_appconfig')) {
return substr($name, 0, -strlen('appconfig'));
}
}
return null;
}
/**
* Tables in the dump that do not exist on this instance.
*
* @return list<string>
*/
public function missingTables(array $manifest): array {
$existing = array_flip($this->db->createSchemaManager()->listTableNames());
return array_values(array_filter(array_keys($manifest['tables']), fn (string $t) => !isset($existing[$t])));
}
/** One message for all missing tables of an older backup, grouped by the app they probably belong to. */
public static function describeMissing(array $tables, string $prefix): string {
$groups = [];
foreach ($tables as $t) {
$name = substr($t, strlen($prefix));
$groups[explode('_', $name, 2)[0]][] = $t;
}
$lines = [];
foreach ($groups as $app => $list) {
$lines[] = '- ' . $app . ': ' . implode(', ', $list);
}
return "These tables from the backup do not exist here, and this backup does not record their structure (made before 1.0.0-rc.2):\n"
. implode("\n", $lines)
. "\nInstall the apps they belong to (often apps that were removed or switched off long ago) and run the restore again, or leave these tables out with --skip-missing-tables.";
}
/**
* Portable description of a table: enough to create it again, also on another database type.
*
* @return array{columns: list<array<string, mixed>>, pk: list<string>, indexes: list<array{name: string, columns: list<string>, unique: bool}>}
*/
private static function structure(\Doctrine\DBAL\Schema\Table $table): array {
$columns = [];
foreach ($table->getColumns() as $c) {
$columns[] = ['name' => $c->getName(), 'type' => \Doctrine\DBAL\Types\Type::lookupName($c->getType()),
'length' => $c->getLength(), 'precision' => $c->getPrecision(), 'scale' => $c->getScale(),
'unsigned' => $c->getUnsigned(), 'fixed' => $c->getFixed(), 'notnull' => $c->getNotnull(),
'default' => $c->getDefault(), 'autoincrement' => $c->getAutoincrement()];
}
$indexes = [];
foreach ($table->getIndexes() as $i) {
if (!$i->isPrimary()) {
// options holds the prefix lengths MySQL needs for indexes on long text columns
$indexes[] = ['name' => $i->getName(), 'columns' => array_values($i->getColumns()), 'unique' => $i->isUnique(),
'flags' => array_values($i->getFlags()), 'options' => $i->getOptions()];
}
}
$foreignKeys = [];
foreach ($table->getForeignKeys() as $fk) {
$foreignKeys[] = ['name' => $fk->getName(), 'columns' => array_values($fk->getLocalColumns()),
'table' => $fk->getForeignTableName(), 'foreign' => array_values($fk->getForeignColumns()), 'options' => $fk->getOptions()];
}
return ['columns' => $columns, 'pk' => array_values($table->getPrimaryKey()?->getColumns() ?? []), 'indexes' => $indexes,
'foreignKeys' => $foreignKeys];
}
/**
* Table options for a table the restore creates: those of this installation's own tables.
* On MySQL/MariaDB that is the character set and collation, as Nextcloud's tables have them
* (utf8mb4, so emoji and other 4-byte characters fit); DBAL alone would use 3-byte utf8.
*
* @return array<string, mixed>
* @psalm-suppress UndefinedClass the DBAL classes come with Nextcloud, not with this app
*/
private function tableOptions(): array {
$options = $this->db->createSchemaManager()->createSchemaConfig()->getDefaultTableOptions();
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
$collation = $this->db->fetchOne('SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?',
[$this->prefix . 'appconfig']);
if (is_string($collation) && $collation !== '') {
$options['collation'] = $collation;
$options['charset'] = explode('_', $collation, 2)[0];
unset($options['collate']);
}
}
return $options;
}
/** @param array<string, mixed> $options table options (see tableOptions()) */
private static function tableFromStructure(string $name, array $structure, array $options = []): \Doctrine\DBAL\Schema\Table {
$table = new \Doctrine\DBAL\Schema\Table($name, [], [], [], [], $options);
foreach ($structure['columns'] as $c) {
$table->addColumn($c['name'], $c['type'], array_filter([
'length' => $c['length'], 'precision' => $c['precision'], 'scale' => $c['scale'],
'unsigned' => $c['unsigned'], 'fixed' => $c['fixed'], 'notnull' => $c['notnull'],
'default' => $c['default'], 'autoincrement' => $c['autoincrement'],
], fn ($v) => $v !== null));
}
if ($structure['pk'] !== []) {
$table->setPrimaryKey($structure['pk']);
}
foreach ($structure['indexes'] as $i) {
$i['unique']
? $table->addUniqueIndex($i['columns'], $i['name'], $i['options'] ?? [])
: $table->addIndex($i['columns'], $i['name'], $i['flags'] ?? [], $i['options'] ?? []);
}
return $table;
}
/**
* Stop checking foreign keys while rows are loaded in name order. PostgreSQL has no session
* switch for this without superuser rights, so its foreign keys of Nextcloud tables are dropped
* here and added back (and so checked) by resumeForeignKeys(), inside the same transaction.
*
* @return list<string> statements that restore the foreign keys
*/
private function suspendForeignKeys(): array {
$platform = $this->db->getDatabasePlatform();
if ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
$rows = $this->db->fetchAllAssociative(
"SELECT conrelid::regclass::text AS tbl, conname, pg_get_constraintdef(oid) AS def
FROM pg_constraint WHERE contype = 'f' AND connamespace = current_schema()::regnamespace");
$restore = [];
foreach ($rows as $r) {
if (!str_starts_with(trim($r['tbl'], '"'), $this->prefix)) {
continue;
}
$this->db->executeStatement(sprintf('ALTER TABLE %s DROP CONSTRAINT %s', $r['tbl'], $this->db->quoteIdentifier($r['conname'])));
$restore[] = sprintf('ALTER TABLE %s ADD CONSTRAINT %s %s', $r['tbl'], $this->db->quoteIdentifier($r['conname']), $r['def']);
}
return $restore;
}
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
// MySQL does not check rows again when FOREIGN_KEY_CHECKS goes back to 1, and its DDL
// would commit the transaction halfway. So every foreign key is checked explicitly,
// with a query that finds rows pointing at nothing (resumeForeignKeys()).
$rows = $this->db->fetchAllAssociative(
'SELECT TABLE_NAME AS tbl, CONSTRAINT_NAME AS name, COLUMN_NAME AS col, REFERENCED_TABLE_NAME AS rtbl, REFERENCED_COLUMN_NAME AS rcol
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL
ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION');
$keys = [];
foreach ($rows as $r) {
if (str_starts_with((string)$r['tbl'], $this->prefix)) {
$keys[$r['tbl'] . "\0" . $r['name']][] = $r;
}
}
$q = fn (string $i): string => $this->db->quoteIdentifier($i);
$checks = ['SET FOREIGN_KEY_CHECKS = 1'];
foreach ($keys as $parts) {
$join = implode(' AND ', array_map(fn (array $r): string => 'c.' . $q($r['col']) . ' = p.' . $q($r['rcol']), $parts));
$set = implode(' AND ', array_map(fn (array $r): string => 'c.' . $q($r['col']) . ' IS NOT NULL', $parts));
$checks[] = sprintf('SELECT COUNT(*) FROM %s c LEFT JOIN %s p ON %s WHERE %s AND p.%s IS NULL /* %s */',
$q($parts[0]['tbl']), $q($parts[0]['rtbl']), $join, $set, $q($parts[0]['rcol']), $parts[0]['name']);
}
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
return $checks;
}
if ($platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform) {
// SQLite (WAL) starts a transaction as a reader. When another process (a cron job, the
// installer still finishing) writes between our first read and our first write, that
// write fails at once with "database is locked": no waiting helps a reader whose view
// is out of date. Take the write lock first, so we wait for the other writer instead
// and it waits for us after that.
$this->db->executeStatement('DELETE FROM ' . $this->db->quoteIdentifier($this->prefix . 'appconfig') . ' WHERE 0 = 1');
$this->db->executeStatement('PRAGMA defer_foreign_keys = ON'); // checked at commit
}
return [];
}
/**
* Check the foreign keys again; a row that breaks one fails the restore here, before it commits.
* Statements add them back (PostgreSQL) or switch the checks on (MySQL); queries count the rows
* that point at nothing (MySQL) and must find none.
*/
private function resumeForeignKeys(array $statements): void {
foreach ($statements as $sql) {
try {
if (str_starts_with($sql, 'SELECT ')) {
$orphans = (int)$this->db->fetchOne($sql);
if ($orphans > 0) {
throw new \RuntimeException("$orphans rows point at nothing");
}
} else {
$this->db->executeStatement($sql);
}
} catch (\Throwable $e) {
throw new \RuntimeException('The restored data breaks a foreign key (' . $sql . '): ' . $e->getMessage(), 0, $e);
}
}
}
/** Same checksum as dump(): sha256 over the row lines in primary-key order. */
public function checksum(string $table): array {
$hash = hash_init('sha256');
@@ -253,7 +535,11 @@ final class DbDumper {
} elseif (is_string($row[$i]) && !mb_check_encoding($row[$i], 'UTF-8')) {
// Keep invalid UTF-8 byte-exact instead of substituting characters.
$row[$i] = ['$b' => base64_encode($row[$i])];
} elseif (is_int($row[$i]) || is_float($row[$i]) || is_bool($row[$i])) {
} elseif (is_float($row[$i])) {
// var_export uses serialize_precision (shortest exact form), not the lossy
// 14-digit 'precision' of a (string) cast.
$row[$i] = var_export($row[$i], true);
} elseif (is_int($row[$i]) || is_bool($row[$i])) {
// Normalise: drivers differ in returning numbers as int or string.
$row[$i] = (string)(is_bool($row[$i]) ? (int)$row[$i] : $row[$i]);
}
@@ -345,11 +631,21 @@ final class DbDumper {
return $name;
}
private function restoreSequences(array $sequences): void {
/**
* @param list<string> $leftOut tables not restored (--skip-missing-tables): they do not exist here
* @param list<string> $restored tables restored or unchanged: their other counters go after their data
*/
private function restoreSequences(array $sequences, array $leftOut = [], array $restored = []): void {
if (!$this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
return;
}
$leftOut = array_flip($leftOut);
$set = [];
foreach ($sequences as $info) {
if (isset($leftOut[$info['table']])) {
continue;
}
$set[$info['table'] . '.' . $info['column']] = true;
$seq = $this->db->fetchOne('SELECT pg_get_serial_sequence(quote_ident(?), ?)', [$info['table'], $info['column']]);
if ($seq === false || $seq === null) {
continue;
@@ -364,5 +660,26 @@ final class DbDumper {
[\Doctrine\DBAL\ParameterType::STRING, \Doctrine\DBAL\ParameterType::INTEGER, \Doctrine\DBAL\ParameterType::BOOLEAN]);
}
}
// A backup from MySQL/MariaDB or SQLite records no counters (those derive them from the
// data): every other counter of a restored table goes after its data, or new rows (a new
// file, an activity) would collide with restored ones.
$restored = array_flip($restored);
$rows = $this->db->fetchAllAssociative(
"SELECT c.table_name, c.column_name, pg_get_serial_sequence(quote_ident(c.table_name), c.column_name) AS seq
FROM information_schema.columns c
WHERE c.table_schema = current_schema() AND pg_get_serial_sequence(quote_ident(c.table_name), c.column_name) IS NOT NULL");
foreach ($rows as $r) {
if (!isset($restored[$r['table_name']]) || isset($set[$r['table_name'] . '.' . $r['column_name']])) {
continue;
}
$max = (int)$this->db->fetchOne(sprintf('SELECT COALESCE(MAX(%s), 0) FROM %s',
$this->db->quoteIdentifier($r['column_name']), $this->db->quoteIdentifier($r['table_name'])));
// never backwards: a counter already past the data stays where it is
$state = $this->db->fetchAssociative('SELECT last_value, is_called FROM ' . self::sequenceIdentifier($r['seq']));
$next = $state === false ? 1 : (int)$state['last_value'] + ($state['is_called'] ? 1 : 0);
if ($next <= $max) {
$this->db->executeQuery('SELECT setval(CAST(? AS regclass), ?, false)', [$r['seq'], $max + 1]);
}
}
}
}
+79 -7
View File
@@ -30,19 +30,91 @@ final class DbIndexCache implements IIndexCache {
}
public function put(string $repositoryId, string $packId, array $entries): void {
try {
$this->insert($repositoryId, $packId, $entries);
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
}
/**
* In one transaction: on PostgreSQL with its default settings every statement of its own
* waits for a disk sync, which made 27,547 rows take more than 6 minutes. Rows already there
* are replaced. Should the transaction fail (another process writing the same rows), the
* rows are written one by one, keeping rows that are already there.
*/
public function putMany(string $repositoryId, array $packs): void {
if ($packs === []) {
return;
}
try {
$this->transaction(function () use ($repositoryId, $packs): void {
$this->remove($repositoryId, array_keys($packs));
$this->insertAll($repositoryId, $packs);
});
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
throw $e; // a transaction of the caller's, which may be spoilt now
}
foreach ($packs as $packId => $entries) {
$this->put($repositoryId, $packId, $entries);
}
}
}
/** Drop the cached index of a repository that no location uses any more. */
public function forget(string $repositoryId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
$qb->executeStatement();
}
/** All or nothing: when it fails, the cache stays as it was. */
public function replaceAll(string $repositoryId, array $packs): void {
$this->transaction(function () use ($repositoryId, $packs): void {
$qb = $this->db->getQueryBuilder();
$qb->delete('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
$qb->executeStatement();
$this->insertAll($repositoryId, $packs);
});
}
/** Run $write in a transaction of its own, or as part of the caller's when there is one. */
private function transaction(\Closure $write): void {
if ($this->db->inTransaction()) {
$write();
return;
}
$this->db->beginTransaction();
try {
$write();
$this->db->commit();
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
/** @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs */
private function insertAll(string $repositoryId, array $packs): void {
foreach ($packs as $packId => $entries) {
$this->insert($repositoryId, $packId, $entries);
}
}
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
private function insert(string $repositoryId, string $packId, array $entries): void {
$qb = $this->db->getQueryBuilder();
$qb->insert('ngb_index')->values([
'repository_id' => $qb->createNamedParameter($repositoryId),
'pack_id' => $qb->createNamedParameter($packId),
'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)),
]);
try {
$qb->executeStatement();
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
$qb->executeStatement();
}
public function remove(string $repositoryId, array $packIds): void {
+75
View File
@@ -17,6 +17,36 @@ final class RunMapper extends QBMapper {
parent::__construct($db, 'ngb_runs', Run::class);
}
/**
* Write $run only if it is still running and unchanged since it was loaded (updated_at
* $loadedAt), in one statement: two processes that loaded the same run cannot both write it.
* The new updated_at is always later than $loadedAt, so every write is seen as a change.
*
* @return Run|null the written run, or null when another process wrote it first
*/
public function updateIfUnchanged(Run $run, int $loadedAt): ?Run {
$run->setUpdatedAt(max(time(), $loadedAt + 1));
$types = $run->getFieldTypes();
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName());
foreach (array_keys($run->getUpdatedFields()) as $field) {
if ($field === 'id') {
continue;
}
$value = $run->{'get' . ucfirst($field)}();
$type = ($types[$field] ?? 'string') === 'integer' ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR;
$qb->set($run->propertyToColumn($field), $qb->createNamedParameter($value, $value === null ? IQueryBuilder::PARAM_NULL : $type));
}
$qb->where($qb->expr()->eq('id', $qb->createNamedParameter($run->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)))
->andWhere($qb->expr()->eq('updated_at', $qb->createNamedParameter($loadedAt, IQueryBuilder::PARAM_INT)));
if ($qb->executeStatement() !== 1) {
return null;
}
$run->resetUpdatedFields();
return $run;
}
public function find(int $id): Run {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
@@ -43,6 +73,51 @@ 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();
$qb->select('*')->from($this->getTableName())
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
->andWhere($qb->expr()->gte('started_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)))
->orderBy('id', 'DESC');
return $this->findEntities($qb);
}
public function lastFinished(int $targetId, string $kind): ?Run {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())
+7
View File
@@ -24,6 +24,10 @@ use OCP\AppFramework\Db\Entity;
* @method void setFiles(?int $v)
* @method int|null getBytes()
* @method void setBytes(?int $v)
* @method string|null getNote()
* @method void setNote(?string $v)
* @method bool|null getPinned()
* @method void setPinned(?bool $v)
*/
final class Snapshot extends Entity {
protected int $targetId = 0;
@@ -33,8 +37,11 @@ final class Snapshot extends Entity {
protected int $createdAt = 0;
protected ?int $files = null;
protected ?int $bytes = null;
protected ?string $note = null;
protected ?bool $pinned = null;
public function __construct() {
$this->addType('pinned', 'boolean');
$this->addType('targetId', 'integer');
$this->addType('createdAt', 'integer');
$this->addType('files', 'integer');
+10
View File
@@ -28,6 +28,12 @@ use OCP\AppFramework\Db\Entity;
* @method void setMaxBytes(?int $v)
* @method int getCreatedAt()
* @method void setCreatedAt(int $v)
* @method string|null getSchedule()
* @method void setSchedule(?string $v)
* @method string|null getScheduleZone()
* @method void setScheduleZone(?string $v)
* @method int|null getScheduleSince()
* @method void setScheduleSince(?int $v)
*/
final class Target extends Entity {
protected string $name = '';
@@ -39,8 +45,12 @@ final class Target extends Entity {
protected int $createdAt = 0;
protected ?bool $appendOnly = null;
protected ?int $maxBytes = null;
protected ?string $schedule = null;
protected ?string $scheduleZone = null;
protected ?int $scheduleSince = null;
public function __construct() {
$this->addType('scheduleSince', 'integer');
$this->addType('createdAt', 'integer');
$this->addType('appendOnly', 'boolean');
$this->addType('maxBytes', 'integer');
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup;
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;
}
/**
* "2026-10-29 04:30" in $zone. Nextcloud runs PHP in UTC; occ shows times in the zone of the
* schedule, the administrator's, as the web interface does.
*
* @param non-empty-string $zone
*/
public static function time(int $timestamp, string $zone): string {
return (new \DateTimeImmutable('@' . $timestamp))->setTimezone(new \DateTimeZone($zone))->format('Y-m-d H:i');
}
}
+199 -22
View File
@@ -49,13 +49,54 @@ final class BackupRun {
public static function step(Repository $repo, array $state, float $deadline): array {
$state['steps']++;
if ($state['phase'] === 'files') {
$walker = new TreeWalker($state['roots'], $state['exclude']);
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
$stats = $state['stats'];
$walker = new TreeWalker($state['roots'], $state['exclude'], function (string $logical) use (&$state, &$stats): void {
self::recordUnreadable($state, $stats, $logical);
}, true);
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
$lines = [];
$emptyDirs = [];
$folders = [];
$finishedWalk = true;
foreach ($walker->walk($state['after']) as $logical => $path) {
$safe = mb_check_encoding($logical, 'UTF-8') && Repository::isSafePath($logical);
if ($walker->atFolderWithContent()) {
// Only its time: what is in it is reported by itself, and a bad name in the path of
// each of those is what gets reported. The cursor stays for such a name, see below.
$mtime = $safe ? @filemtime($path) : false;
if ($safe && $mtime !== false) {
$folders[] = json_encode([$logical, $mtime], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$state['after'] = $logical;
if (microtime(true) >= $deadline) {
$finishedWalk = false;
break;
}
}
continue;
}
if (!$safe) {
// A name a snapshot cannot hold (a backslash, a control character, not UTF-8). One
// such path must not make a backup fail, or the whole snapshot unreadable later.
// The cursor stays where it is: the state is JSON, which cannot hold such a name. A
// next step meets it again, and it is reported once.
self::recordBadName($state, $stats, $logical);
continue;
}
if (is_dir($path)) {
$mtime = @filemtime($path);
if ($mtime === false) {
continue; // removed while walking
}
$emptyDirs[] = json_encode([$logical, $mtime], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$stats['emptyDirs'] = ($stats['emptyDirs'] ?? 0) + 1;
$state['after'] = $logical;
if (microtime(true) >= $deadline) {
$finishedWalk = false;
break;
}
continue;
}
$version = Repository::fileVersion($path);
if ($version === 'missing') {
continue; // deleted while walking
@@ -68,15 +109,20 @@ final class BackupRun {
$stats['reused']++;
} else {
$cur = $state['cur'];
if ($cur === null || $cur['path'] !== $logical || $cur['version'] !== $version) {
if ($cur !== null && $cur['path'] === $logical) {
$stats['retried'] = ($stats['retried'] ?? 0) + 1; // changed since the previous step
if ($cur !== null && $cur['path'] === $logical && $cur['version'] !== $version) {
// Changed since the previous step: start over, unless it keeps changing.
if (self::countRetry($state, $stats, $logical)) {
$cur['version'] = $version;
} else {
$cur = null;
}
}
if ($cur === null || $cur['path'] !== $logical) {
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
}
$fh = fopen($path, 'rb');
$fh = Repository::openForReading($path);
if ($fh === false) {
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
self::recordUnreadable($state, $stats, $logical);
$state['cur'] = null;
continue;
}
@@ -91,12 +137,17 @@ final class BackupRun {
}
}
fclose($fh);
if (Repository::fileVersion($path) !== $cur['version']) {
// Changed while reading: read it again from the start (now or next step).
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
$state['cur'] = null;
$finishedWalk = false;
break;
$now = Repository::fileVersion($path);
if ($now !== $cur['version']) {
if (!self::countRetry($state, $stats, $logical)) {
// Changed while reading: read it again from the start (now or next step).
$state['cur'] = null;
$finishedWalk = false;
break;
}
// Keeps changing (a live database, VM image or log): keep what was read,
// like any copy of a file in use, and report it instead of retrying forever.
$cur['version'] = $now;
}
if (!$complete) {
$state['cur'] = $cur;
@@ -104,6 +155,7 @@ final class BackupRun {
break;
}
$blobs = $cur['blobs'];
$size = $cur['offset'];
$state['cur'] = null;
$stats['read']++;
}
@@ -123,29 +175,154 @@ final class BackupRun {
$writer->write(implode("\n", $lines) . "\n");
$state['segments'][] = $writer->finish();
}
if ($emptyDirs !== []) {
$writer = $repo->blobWriter($stats);
$writer->write(implode("\n", $emptyDirs) . "\n");
$state['dirSegments'][] = $writer->finish();
}
if ($folders !== []) {
$writer = $repo->blobWriter($stats);
$writer->write(implode("\n", $folders) . "\n");
$state['folderSegments'][] = $writer->finish();
}
$repo->flushPacks();
$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'];
$repo->writeSnapshot($state['snapshot'],
// Every segment ends with a newline, so they read as one stream: fetched together,
// several at a time where the location allows it.
// Empty folders go in the meta record, not between the files: a version that does not
// know them reads the snapshot as before, and only restores no empty folders.
$emptyDirs = [];
foreach (isset($state['dirSegments']) ? $repo->readLines(array_values(array_merge(...$state['dirSegments']))) : [] as $line) {
if ($line !== '') {
$emptyDirs[] = json_decode($line, true, 8, JSON_THROW_ON_ERROR);
}
}
// The times of the other folders too, packed: every look at a snapshot's meta record reads
// this, and a string of a few hundred KiB costs that far less than tens of thousands of arrays.
$folders = isset($state['folderSegments']) ? implode("\n", iterator_to_array($repo->readLines(array_values(array_merge(...$state['folderSegments']))), false)) : '';
$repo->putSnapshot($state['snapshot'],
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
'roots' => array_keys($state['roots']), 'stats' => $state['stats']] + $state['meta'],
(function () use ($repo, $segments) {
foreach ($segments as $blobs) {
yield from $repo->readLines($blobs);
}
})());
$state['phase'] = 'done';
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
'warnings' => self::warnings($state)] + ($emptyDirs === [] ? [] : ['emptyDirs' => $emptyDirs])
+ ($folders === '' ? [] : ['folderTimes' => base64_encode((string)gzdeflate($folders))]) + $state['meta'],
$repo->readLines(array_values(array_merge(...$state['segments']))));
$state['phase'] = 'commit';
}
return $state;
}
/**
* Progress of the files phase as a fraction (at most 0.99 until it is done), or null without an
* estimate. Bytes of a large file that is still being read count too.
*
* @return array{fraction: float, approximate: bool}|null
*/
public static function progress(array $state): ?array {
$estimate = $state['estimate'] ?? null;
if (($state['phase'] ?? null) !== 'files' || !is_array($estimate) || ($estimate['bytes'] ?? 0) <= 0) {
return null;
}
$done = (int)($state['stats']['bytes'] ?? 0) + (int)($state['cur']['offset'] ?? 0);
return ['fraction' => min(0.99, $done / (int)$estimate['bytes']), 'approximate' => (bool)($estimate['approximate'] ?? true)];
}
/**
* progress() of a run in its files phase; in its database phase, the tables dumped so far.
*
* @return array{fraction: float, approximate: bool, tables?: int, totalTables?: int}|null
*/
public static function progressOf(\OCA\NgBackup\Db\Run $run): ?array {
if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || !in_array($run->getPhase(), ['db', 'files'], true)) {
return null;
}
try {
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if ($run->getPhase() === 'db') {
// Tables dumped so far, as of the last step (none known before the first step ends)
$tables = count($state['dump']['tables'] ?? []);
return $tables > 0
? ['fraction' => min(1.0, ($state['dump']['i'] ?? 0) / $tables), 'approximate' => false,
'tables' => (int)($state['dump']['i'] ?? 0), 'totalTables' => $tables]
: null;
}
return is_array($state['files'] ?? null) ? self::progress($state['files']) : null;
}
/**
* A file or directory that could not be read: it is missing from this snapshot. Remembered (first
* 1000, deduplicated across steps) so the run can report it instead of silently succeeding.
*/
private static function recordUnreadable(array &$state, array &$stats, string $logical): void {
if (isset($state['unreadable'][$logical])) {
return;
}
if (count($state['unreadable'] ?? []) < 1000) {
$state['unreadable'][$logical] = true;
}
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
}
/**
* A file or directory left out because of its name. Remembered like an unreadable one, in a form
* that fits the JSON state and is safe to show: control characters and invalid bytes replaced.
*/
private static function recordBadName(array &$state, array &$stats, string $logical): void {
$shown = (string)preg_replace('/[\x00-\x1f\x7f]/', '?', mb_scrub($logical, 'UTF-8'));
if (isset($state['badNames'][$shown])) {
return;
}
if (count($state['badNames'] ?? []) < 1000) {
$state['badNames'][$shown] = true;
}
$stats['badNames'] = ($stats['badNames'] ?? 0) + 1;
}
/** @return array{unreadable: list<string>, badNames: list<string>, changing: list<string>} examples for the report (first 50 each) */
public static function warnings(array $state): array {
return [
'unreadable' => array_slice(array_map('strval', array_keys($state['unreadable'] ?? [])), 0, 50),
'badNames' => array_slice(array_map('strval', array_keys($state['badNames'] ?? [])), 0, 50),
'changing' => array_slice(array_map('strval', array_values($state['changing'] ?? [])), 0, 50),
];
}
/** Retries of one file that changes while it is read, before its last read is kept anyway. */
public const MAX_RETRIES = 3;
/**
* Count a retry of $logical. Returns true once it has changed MAX_RETRIES times: the caller then
* keeps the data it read, and the file is listed in $state['changing'] (first 50) for the report.
*/
private static function countRetry(array &$state, array &$stats, string $logical): bool {
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
$n = (($state['retryPath'] ?? null) === $logical ? (int)($state['retryCount'] ?? 0) : 0) + 1;
$state['retryPath'] = $logical;
$state['retryCount'] = $n;
if ($n < self::MAX_RETRIES) {
return false;
}
if ($n === self::MAX_RETRIES) {
$stats['changing'] = ($stats['changing'] ?? 0) + 1;
if (count($state['changing'] ?? []) < 50) {
$state['changing'][] = $logical;
}
}
return true;
}
private static function readFull($fh, int $length): string {
$buffer = '';
while (strlen($buffer) < $length && !feof($fh)) {
+33 -3
View File
@@ -9,12 +9,16 @@ namespace OCA\NgBackup\Job;
/**
* Walks one or more source roots in a stable depth-first order (entries of a directory sorted
* by name, children right after their directory), yielding "<root>/<relative path>" for files.
* by name, children right after their directory), yielding "<root>/<relative path>" for files
* and for directories with nothing in them to walk (the caller tells them apart with is_dir()).
* A root itself without a name is never yielded.
*
* The same order is used for comparing paths (compare()), so a walk can resume after a cursor
* and be merge-joined with the previous snapshot without holding any list in memory.
*/
final class TreeWalker {
private bool $atFolder = false;
/**
* @param array<string, string> $roots name => absolute directory, walked in this order
* @param list<string> $exclude path prefixes ("<root>/<relative>") that are skipped entirely;
@@ -23,9 +27,18 @@ final class TreeWalker {
public function __construct(
private array $roots,
private array $exclude = [],
/** Called with the logical path of a directory that cannot be listed. */
private ?\Closure $onUnreadable = null,
/** Also yield directories with something in them, right before the first thing in them. */
private bool $withFolders = false,
) {
}
/** Whether what was just yielded is a directory with something in it (see $withFolders). */
public function atFolderWithContent(): bool {
return $this->atFolder;
}
/** @return \Generator<string, string> logical path => absolute path, strictly after $after */
public function walk(?string $after = null): \Generator {
foreach ($this->roots as $name => $dir) {
@@ -57,31 +70,48 @@ final class TreeWalker {
}
$names = @scandir($abs);
if ($names === false) {
if ($this->onUnreadable !== null) {
($this->onUnreadable)($logical === '' ? '/' : $logical);
}
return;
}
$names = array_values(array_diff($names, ['.', '..']));
sort($names, SORT_STRING);
$empty = true; // no file or directory below it that is walked, whatever the cursor skips
foreach ($names as $name) {
$childLogical = $logical === '' ? $name : $logical . '/' . $name;
$childAbs = $abs . '/' . $name;
if (is_link($childAbs)) {
continue; // never follow symlinks out of the data directory
}
if (is_dir($childAbs)) {
$childIsDir = is_dir($childAbs);
if (($childIsDir || is_file($childAbs)) && $empty && !$this->isExcluded($childLogical, $childIsDir)
&& $this->withFolders && $logical !== '' && ($after === null || self::compare($logical, $after) > 0)) {
$this->atFolder = true;
yield $logical => $abs;
$this->atFolder = false;
}
if ($childIsDir) {
$empty = $empty && $this->isExcluded($childLogical, true);
// Skip a directory entirely if everything in it sorts before the cursor.
if ($after !== null && self::compare($childLogical, $after) < 0 && !self::isPrefix($childLogical, $after)) {
continue;
}
yield from $this->walkDir($childAbs, $childLogical, $after);
} elseif (is_file($childAbs)) {
$excluded = $this->isExcluded($childLogical, false);
$empty = $empty && $excluded;
if ($after !== null && self::compare($childLogical, $after) <= 0) {
continue;
}
if (!$this->isExcluded($childLogical, false)) {
if (!$excluded) {
yield $childLogical => $childAbs;
}
}
}
if ($empty && $logical !== '' && ($after === null || self::compare($logical, $after) > 0)) {
yield $logical => $abs;
}
}
private function isExcluded(string $logical, bool $isDir): bool {
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Migration;
use Closure;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Removing a location left the cached index of its repository behind (tens of thousands of rows
* for a large backup). Drop the cache of repositories no location uses any more.
*/
final class Version000006Date20261018000000 extends SimpleMigrationStep {
public function __construct(
private IDBConnection $db,
) {
}
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$used = $this->db->getQueryBuilder();
$used->select('repository_id')->from('ngb_targets')->where($used->expr()->isNotNull('repository_id'));
$keep = array_column($used->executeQuery()->fetchAll(), 'repository_id');
$delete = $this->db->getQueryBuilder();
$delete->delete('ngb_index');
if ($keep !== []) {
$delete->where($delete->expr()->notIn('repository_id', $delete->createNamedParameter($keep, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR_ARRAY)));
}
$removed = $delete->executeStatement();
if ($removed > 0) {
$output->info("Removed $removed cached index rows of locations that no longer exist");
}
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/** Local copy of what the catalog on the location says about a snapshot: its note and pin. */
final class Version000007Date20261107000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$t = $schema->getTable('ngb_snapshots');
$changed = false;
if (!$t->hasColumn('note')) {
$t->addColumn('note', Types::STRING, ['notnull' => false, 'length' => 1000]);
$changed = true;
}
if (!$t->hasColumn('pinned')) {
$t->addColumn('pinned', Types::BOOLEAN, ['notnull' => false, 'default' => false]);
$changed = true;
}
return $changed ? $schema : null;
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Migration;
use Closure;
use OCA\NgBackup\Service\LocationSchedule;
use OCA\NgBackup\Service\Schedule;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/** A schedule per location. Each existing location gets the daily time that held for all of them. */
final class Version000008Date20261107010000 extends SimpleMigrationStep {
public function __construct(
private IDBConnection $db,
private Schedule $schedule,
) {
}
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$t = $schema->getTable('ngb_targets');
if ($t->hasColumn('schedule')) {
return null;
}
$t->addColumn('schedule', Types::STRING, ['notnull' => false, 'length' => 16]);
$t->addColumn('schedule_zone', Types::STRING, ['notnull' => false, 'length' => 64]);
$t->addColumn('schedule_since', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
return $schema;
}
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
['time' => $time, 'timezone' => $zone] = $this->schedule->get();
$qb = $this->db->getQueryBuilder();
$qb->update('ngb_targets')
->set('schedule', $qb->createNamedParameter(Schedule::validTime($time) ? LocationSchedule::normalize($time) : ''))
->set('schedule_zone', $qb->createNamedParameter($zone))
->where($qb->expr()->isNull('schedule'));
$qb->executeStatement();
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Migration;
use Closure;
use OCA\NgBackup\Service\KeyService;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* A key from before 1.1.0 counts as used: those versions did not keep track, and it may protect
* backups on a location that was removed from this server later (user exports included). Only a
* key made with 1.1.0 or newer can be replaced as an unused one.
*/
final class Version000009Date20261108000000 extends SimpleMigrationStep {
public function __construct(
private KeyService $keys,
) {
}
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
if ($this->keys->isInitialized()) {
$this->keys->markUsed();
}
}
}
+24 -2
View File
@@ -42,26 +42,48 @@ final class Notifier implements INotifier {
: $l->t('Backup to %s failed', [$p['target']]));
$notification->setParsedMessage((string)($p['error'] ?? ''));
break;
case 'run_warnings':
$notification->setParsedSubject($l->t('Backup to %1$s finished with warnings: %2$s unreadable, %3$s changed while being read', [$p['target'], $p['unreadable'], $p['changing']]));
$notification->setParsedMessage($l->t('Unreadable files and folders are missing from this backup (check their permissions). Files that kept changing were saved as they were while being read. Examples: %s', [$p['examples'] ?? '']));
break;
case 'run_bad_names':
$notification->setParsedSubject($l->n('Backup to %s finished, but %n item is missing because of its name', 'Backup to %s finished, but %n items are missing because of their names', (int)$p['count'], [$p['target']]));
$notification->setParsedMessage($l->t('Names with a backslash or a control character, or that are not valid UTF-8, are not supported, so these files and folders are not in this backup. Rename them; the next backup includes them. Examples: %s', [$p['examples'] ?? '']));
break;
case 'quota_warning':
$notification->setParsedSubject($l->t('Backup location %1$s is at %2$s%% of its limit (%3$s of %4$s)', [$p['target'], $p['percent'], $p['used'], $p['limit']]));
$notification->setParsedMessage($l->t('Backups stop when the limit is reached. Raise the limit, or let the retention policy remove older backups.'));
break;
case 'backup_stale':
$notification->setParsedSubject($l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
// 'never' was stored by 0.9.4 and earlier
$notification->setParsedSubject(in_array($p['since'] ?? '', ['', 'never'], true)
? $l->t('No successful backup to %s yet', [$p['target']])
: $l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
$notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
break;
case 'schedule_off':
$notification->setParsedSubject($l->t('Backup location %s has no schedule', [$p['target']]));
$notification->setParsedMessage(($p['others'] ?? '') === '1'
? $l->t('No backups run to this location until it gets a schedule or someone starts one. If that is what you want, you can ignore this reminder.')
: $l->t('No location has a schedule, so no backups run at all unless someone starts one. Set a schedule under Backups.'));
break;
case 'security_event':
$who = (string)($p['actor'] ?? '');
$notification->setParsedSubject(match ($p['event'] ?? '') {
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
'key_replaced' => $l->t('%1$s replaced the unused backup key %2$s by the key of existing backups, %3$s; download and confirm the new recovery kit', [$who, $p['old'] ?? '', $p['new'] ?? '']),
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
'target_changed' => $l->t('%1$s changed the connection settings of backup location %2$s', [$who, $p['target'] ?? '']),
'schedule_off' => $l->t('%1$s turned off the schedule of backup location %2$s', [$who, $p['target'] ?? '']),
'append_only_off' =>$l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']),
'snapshot_untrashed' => $l->t('%1$s restored snapshot %2$s of %3$s from the trash', [$who, $p['snapshot'] ?? '', $p['target'] ?? '']),
'catalog_trusted' => $l->t('%1$s accepted an older state of backup location %2$s (catalog generation %3$s)', [$who, $p['target'] ?? '', $p['generation'] ?? '']),
'slot_added' => $l->t('%1$s added backup passphrase slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
'slot_replaced' => $l->t('%1$s set a new passphrase for backup key slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
'user_export_retention_changed' => $l->t('%1$s changed how many exports per user are kept to %2$s', [$who, $p['keep'] ?? '']),
'user_exports_forgotten' => $l->t('%1$s removed %2$s per-user exports from %3$s', [$who, $p['count'] ?? '', $p['target'] ?? '']),
'slot_removed' => $l->t('%1$s removed backup passphrase slot %2$s; download and confirm the new recovery kit', [$who, $p['slot'] ?? '']),
default => $l->t('NG Backup security event'),
});
+183 -26
View File
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace OCA\NgBackup\Repository;
use OCA\NgBackup\Backend\BackendException;
use OCA\NgBackup\Backend\IBackend;
use OCA\NgBackup\Crypto\StreamCipher;
@@ -25,9 +26,15 @@ final class BlobIndex {
private array $pending = [];
private ?IIndexCache $cache = null;
/** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */
private ?array $heldCacheWrites = null;
private string $repositoryId = '';
/** Index files written to the cache per database transaction while loading. */
private const CACHE_LOT = 256;
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
public int $downloaded = 0;
/** @var array<string, true> packs whose index file is loaded (refresh() fetches only others) */
private array $loaded = [];
public function __construct(
private IBackend $backend,
@@ -40,52 +47,124 @@ final class BlobIndex {
$this->repositoryId = $repositoryId;
}
/**
* Hold back cache writes until releaseCache(). The database dump reads inside a read-only
* transaction on the same connection that the cache writes to: a write there fails on
* PostgreSQL and would be rolled back with the dump transaction elsewhere.
*/
public function holdCache(): void {
$this->heldCacheWrites ??= [];
}
/** Write the cache changes held back since holdCache(). */
public function releaseCache(): void {
$writes = $this->heldCacheWrites ?? [];
$this->heldCacheWrites = null;
foreach ($writes as $write) {
$write();
}
}
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
private function cachePut(string $packId, array $entries): void {
if ($this->heldCacheWrites !== null) {
$this->heldCacheWrites[] = function () use ($packId, $entries): void {
$this->cache?->put($this->repositoryId, $packId, $entries);
};
return;
}
$this->cache?->put($this->repositoryId, $packId, $entries);
}
/** @param list<string> $packIds */
private function cacheRemove(array $packIds): void {
if ($this->heldCacheWrites !== null) {
$this->heldCacheWrites[] = function () use ($packIds): void {
$this->cache?->remove($this->repositoryId, $packIds);
};
return;
}
$this->cache?->remove($this->repositoryId, $packIds);
}
/**
* Load the index: one listing of index/, entries from the local cache where available,
* and only the index files that are not cached yet are downloaded. Cached packs that no
* longer exist on the backend are dropped from the cache.
*/
public function load(bool $verify = false): void {
$this->blobs = [];
public function load(bool $verify = false, ?callable $progress = null): void {
$hadIndex = $this->blobs !== [];
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
$present = [];
$this->downloaded = 0;
foreach ($this->backend->list('index') as $path) {
$order = []; // pack ids in listing order: where a blob is in two packs, the last listed wins
$fetch = [];
$known = array_keys($this->loaded + $cached + ($verify ? ($this->cache?->all($this->repositoryId) ?? []) : []));
foreach ($this->listIndex($known) as $path) {
$packId = basename($path);
$present[$packId] = true;
if (isset($cached[$packId])) {
$entries = $cached[$packId];
} else {
$fh = $this->backend->get($path);
$entries = json_decode($this->cipher->decryptString((string)stream_get_contents($fh), 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
fclose($fh);
$this->downloaded++;
$this->cache?->put($this->repositoryId, $packId, $entries);
}
foreach ($entries as $e) {
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
$order[] = $packId;
if (!isset($cached[$packId])) {
$fetch[] = $path;
}
}
if ($verify && $this->cache !== null) {
// Refresh the cache from what was just verified.
$this->cache->remove($this->repositoryId, array_keys($this->cache->all($this->repositoryId)));
foreach ($this->packs() as $packId => $_) {
$entries = [];
foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) {
if ($pack === $packId) {
$entries[] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags];
}
// only now: an empty listing that listIndex() refused leaves the index as it was
$this->blobs = [];
$this->loaded = $present;
// The rest is downloaded several at a time where the location allows it: on a large
// repository these are tens of thousands of small files. getMany() keeps their order, so
// the cached packs listed before each one are added first.
$next = 0;
$total = count($fetch);
$toCache = []; // written to the cache a few hundred at a time, each lot in one transaction
foreach ($this->backend->getMany($fetch) as $path => $data) {
$packId = basename($path);
$entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
$this->downloaded++;
if ($this->cache !== null) {
$toCache[$packId] = $entries;
// a check writes the whole cache afresh at the end
if (!$verify && count($toCache) >= self::CACHE_LOT) {
$this->cache->putMany($this->repositoryId, $toCache);
$toCache = [];
}
$this->cache->put($this->repositoryId, $packId, $entries);
}
for (; $next < count($order) && $order[$next] !== $packId; $next++) {
$this->add($order[$next], $cached[$order[$next]] ?? []);
}
$this->add($packId, $entries);
$next++;
if ($progress !== null) {
$progress($this->downloaded, $total);
}
}
for (; $next < count($order); $next++) {
$this->add($order[$next], $cached[$order[$next]] ?? []);
}
if ($verify && $this->cache !== null) {
// Refresh the cache from what was just verified, as the index files list it: a blob in
// two packs is in both rows, so that removing one of the packs leaves the other right.
// In one transaction: one row at a time took more than 6 minutes on a 1 TB repository
// (27,547 packs), long enough for the location to drop the connection.
$this->cache->replaceAll($this->repositoryId, $toCache);
return;
}
if ($toCache !== []) {
$this->cache?->putMany($this->repositoryId, $toCache);
}
$stale = array_keys(array_diff_key($cached, $present));
if ($stale !== []) {
$this->cache?->remove($this->repositoryId, $stale);
}
}
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
private function add(string $packId, array $entries): void {
foreach ($entries as $e) {
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
}
}
public function has(string $id): bool {
return isset($this->blobs[$id]) || isset($this->pending[$id]);
}
@@ -112,13 +191,81 @@ final class BlobIndex {
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
rewind($stream);
$this->backend->put('index/' . $packId, $stream);
$this->cache?->put($this->repositoryId, $packId, $entries);
$this->cachePut($packId, $entries);
$this->loaded[$packId] = true;
foreach ($entries as $e) {
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
unset($this->pending[$e['id']]);
}
}
/**
* Add the index files that appeared on the location since load(), such as those of packs
* another server's cleanup wrote while a restore runs. Only adds and updates: a blob is
* never dropped, as a listing that misses files (a connection that failed) must not make a
* restore lose blobs it knows. A blob in a new pack takes that pack's place.
*/
public function refresh(): void {
$fetch = [];
$listed = 0;
foreach ($this->listIndex(array_keys($this->loaded)) as $path) {
$listed++;
if (!isset($this->loaded[basename($path)])) {
$fetch[] = $path;
}
}
$toCache = [];
foreach ($this->backend->getMany($fetch) as $path => $data) {
$packId = basename($path);
/** @var list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries authenticated: written by NG Backup */
$entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
$this->downloaded++;
$this->add($packId, $entries);
$this->loaded[$packId] = true;
$toCache[$packId] = $entries;
}
if ($toCache !== []) {
$this->cache?->putMany($this->repositoryId, $toCache);
}
}
/**
* The index files on the location. An empty listing while index files are known ($known,
* pack ids) is usually a connection that failed quietly; taken as is, it would empty the
* index and the cache, make a check report every blob missing, or make a cleanup wipe the
* cache. So the connection is checked (the repository's config must be there) and the folder
* listed once more. Empty again, three of the known index files are looked up one by one:
* only when they are gone too is the repository taken as empty (another server removed its
* last snapshot and cleaned up). Even when that is wrong, nothing on the location is lost:
* only this index and the cache are empty until the next load.
*
* @param list<string> $known
* @return list<string>
* @throws BackendException when the location does not answer as it should
*/
private function listIndex(array $known): array {
$paths = $this->backend->listFiles('index');
if ($paths !== [] || $known === []) {
return $paths;
}
if (!$this->backend->exists('config')) {
throw new BackendException('The location listed no index files and has no repository config: not trusting that listing');
}
$paths = $this->backend->listFiles('index');
if ($paths !== []) {
return $paths;
}
// spread over the list: first, middle and last (another server's cleanup removes packs
// here and there, not in this order)
$n = count($known);
foreach (array_unique([$known[0], $known[intdiv($n, 2)], $known[$n - 1]]) as $packId) {
if ($this->backend->exists('index/' . $packId)) {
throw new BackendException('The location listed no index files, while index/' . $packId . ' is there: not trusting that listing');
}
}
return [];
}
/** @return array<string, list<array{id:string, offset:int, length:int}>> packId => blobs currently located in that pack */
public function packs(): array {
$packs = [];
@@ -131,7 +278,8 @@ final class BlobIndex {
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
public function removePack(string $packId): void {
$this->backend->delete('index/' . $packId);
$this->cache?->remove($this->repositoryId, [$packId]);
$this->cacheRemove([$packId]);
unset($this->loaded[$packId]);
foreach ($this->blobs as $id => $loc) {
if ($loc[0] === $packId) {
unset($this->blobs[$id]);
@@ -139,6 +287,15 @@ final class BlobIndex {
}
}
/** Number of packs that hold blobs of the index. */
public function packCount(): int {
$packs = [];
foreach ($this->blobs as [$pack]) {
$packs[$pack] = true;
}
return count($packs);
}
/** Bytes of all blobs in the index (encrypted size, i.e. what is stored in packs). */
public function storedBytes(): int {
$total = 0;
+24 -3
View File
@@ -28,6 +28,12 @@ final class Catalog {
public array $snapshots = [];
/** @var array<string, true> */
public array $trash = [];
/** @var array<string, true> manifest path => true, for a per-user export (users/<uid>/<exportId>) */
public array $userExports = [];
/** @var array<string, true> snapshots the retention policy leaves alone */
public array $pinned = [];
/** @var array<string, string> snapshot id => note, also while the snapshot is in the trash */
public array $notes = [];
public string $hash = '';
public function __construct(
@@ -39,7 +45,7 @@ final class Catalog {
}
/** Load and verify the newest catalog; create the first one for repositories that have none. */
public function load(callable $listSnapshots, callable $listTrash): void {
public function load(callable $listSnapshots, callable $listTrash, callable $listUserExports): void {
$gens = $this->generations();
if ($gens === []) {
// First use (new or pre-catalog repository): adopt the current state as generation 1.
@@ -49,6 +55,7 @@ final class Catalog {
}
$this->snapshots = array_fill_keys($listSnapshots(), true);
$this->trash = array_fill_keys($listTrash(), true);
$this->userExports = array_fill_keys($listUserExports(), true);
$this->write();
return;
}
@@ -76,14 +83,28 @@ final class Catalog {
$this->hash = $hash;
$this->snapshots = array_fill_keys($data['snapshots'], true);
$this->trash = array_fill_keys($data['trash'], true);
// Catalogs written before user exports existed have no such key: default to empty rather
// than adopting the location's current users/ listing (that would skip verification).
$this->userExports = array_fill_keys($data['userExports'] ?? [], true);
$this->pinned = array_fill_keys($data['pinned'] ?? [], true);
$this->notes = $data['notes'] ?? [];
$this->anchor?->set($this->repositoryId, $this->gen, $this->hash);
}
/** Write the next generation after the in-memory state was changed. */
public function write(): void {
$next = $this->gen + 1;
// Two writers starting from the same generation would otherwise overwrite each other
// silently and break the chain for whichever server anchored the lost write.
if ($this->backend->exists($this->path($next))) {
throw new RepositoryException("Catalog generation $next was written by another process at the same time; try again");
}
// A pin ends when its snapshot leaves the list, a note when the snapshot is gone for good.
$this->pinned = array_intersect_key($this->pinned, $this->snapshots);
$this->notes = array_intersect_key($this->notes, $this->snapshots + $this->trash);
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash)], JSON_THROW_ON_ERROR);
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash),
'userExports' => array_keys($this->userExports), 'pinned' => array_keys($this->pinned), 'notes' => $this->notes], JSON_THROW_ON_ERROR);
$encrypted = $this->cipher->encryptString($plain, $this->ad($next));
$stream = fopen('php://memory', 'w+b');
fwrite($stream, $encrypted);
@@ -116,7 +137,7 @@ final class Catalog {
/** @return list<int> */
private function generations(): array {
return array_map(fn ($p) => (int)basename($p), $this->backend->list('catalog'));
return array_map(fn ($p) => (int)basename($p), $this->backend->listFiles('catalog'));
}
private function path(int $gen): string {
+14
View File
@@ -15,6 +15,20 @@ interface IIndexCache {
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
public function put(string $repositoryId, string $packId, array $entries): void;
/**
* put() for many packs at once (one database transaction).
*
* @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs packId => entries
*/
public function putMany(string $repositoryId, array $packs): void;
/**
* Replace everything cached for the repository with $packs (one database transaction).
*
* @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs packId => entries
*/
public function replaceAll(string $repositoryId, array $packs): void;
/** @param list<string> $packIds */
public function remove(string $repositoryId, array $packIds): void;
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Repository;
use OCA\NgBackup\Backend\IBackend;
/**
* Reads blobs from packs. Restores and deep verifies read the blobs of a snapshot in the order
* they were written, so the stream of the pack read last is kept open and read on from where it
* is: one request per pack instead of one per blob (a round trip each, and on most remote
* storages a new connection or file handle too). Anything else opens the pack at the offset,
* as a single read always did.
*
* The open stream must not overlap other requests on the location (SFTP cannot read and write
* on one connection at once): Repository reaches the location through a PackReaderGuard, which
* closes it first.
*/
final class PackReader {
/** A gap up to this size in the open stream is read past instead of opening the pack again. */
private const SKIP_MAX = 8 * 1048576;
private const CHUNK = 1048576;
/** @var resource|null */
private $stream = null;
private string $pack = '';
private int $pos = 0;
public function __construct(
private IBackend $backend,
) {
}
public function read(string $pack, int $offset, int $length): string {
if ($this->stream !== null && $this->pack === $pack && $offset >= $this->pos && $offset - $this->pos <= self::SKIP_MAX) {
try {
$this->skipTo($offset);
return $this->readExactly($length);
} catch (\Exception) {
// The open stream broke (e.g. a timeout); open the pack again below.
}
}
$this->close();
$fh = $this->backend->get('packs/' . substr($pack, 0, 2) . '/' . $pack);
$this->stream = $fh;
$this->pack = $pack;
$this->pos = 0;
try {
if ($offset > 0 && (stream_get_meta_data($fh)['seekable'] ?? false) && fseek($fh, $offset) === 0) {
$this->pos = $offset;
}
$this->skipTo($offset); // a stream that cannot seek is read up to the offset
return $this->readExactly($length);
} catch (\Exception $e) {
$this->close();
throw $e;
}
}
public function close(): void {
$fh = $this->stream;
$this->stream = null;
if ($fh !== null) {
@fclose($fh);
}
}
public function __destruct() {
$this->close();
}
private function skipTo(int $offset): void {
while ($this->pos < $offset) {
$this->readExactly(min($offset - $this->pos, self::CHUNK));
}
}
/** Read $length bytes from the open stream; a short read closes it. */
private function readExactly(int $length): string {
$fh = $this->stream ?? throw new RepositoryException('No open pack stream');
$data = '';
while (strlen($data) < $length) {
$chunk = fread($fh, min($length - strlen($data), self::CHUNK));
if ($chunk === false || $chunk === '') {
$this->close();
throw new RepositoryException('Short read in pack ' . $this->pack);
}
$data .= $chunk;
}
$this->pos += $length;
return $data;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Repository;
use OCA\NgBackup\Backend\IBackend;
use OCA\NgBackup\Backend\IReadsAhead;
/** The location as Repository sees it: closes the PackReader's open stream before every other request. */
final class PackReaderGuard implements IBackend, IReadsAhead {
public function __construct(
private IBackend $inner,
private PackReader $reader,
) {
}
/** @param resource $stream */
public function put(string $path, $stream): void {
$this->reader->close();
$this->inner->put($path, $stream);
}
public function get(string $path) {
$this->reader->close();
return $this->inner->get($path);
}
public function getMany(array $paths): \Generator {
$this->reader->close();
return $this->inner->getMany($paths);
}
public function getRange(string $path, int $offset, int $length): string {
$this->reader->close();
return $this->inner->getRange($path, $offset, $length);
}
public function getRanges(array $ranges): \Generator {
$this->reader->close();
return $this->inner->getRanges($ranges);
}
public function canReadAhead(): bool {
return $this->inner instanceof IReadsAhead && $this->inner->canReadAhead();
}
/** Over connections of the location's own: the open stream may stay open. */
public function readAhead(array $ranges, string $spool): ?\Generator {
return $this->inner instanceof IReadsAhead ? $this->inner->readAhead($ranges, $spool) : null;
}
public function exists(string $path): bool {
$this->reader->close();
return $this->inner->exists($path);
}
public function move(string $from, string $to): void {
$this->reader->close();
$this->inner->move($from, $to);
}
public function list(string $prefix): array {
$this->reader->close();
return $this->inner->list($prefix);
}
public function listFiles(string $dir): array {
$this->reader->close();
return $this->inner->listFiles($dir);
}
public function delete(string $path): void {
$this->reader->close();
$this->inner->delete($path);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Repository;
/**
* The ranges of packs a restore reads, in order, for IReadsAhead::readAhead(), and the reading of
* blobs from them as the restore goes.
*
* add() takes the blobs in the order the restore needs them. A blob in the same pack as the range
* before it, not before that range and at most GAP after its end, extends that range; anything
* else starts a new one. read() takes the same blobs in the same order: a blob is in the current
* range or in the next one, which then becomes current; a blob not in the plan is read elsewhere.
*/
final class ReadAheadPlan {
/** Unused bytes between two blobs of a pack that are read along rather than asked for apart. */
public const GAP = 4 * 1048576;
/** Longest range: packs are 32 MiB, repacked ones too. */
public const MAX_RANGE = 64 * 1048576;
/** Ranges after the current one that read() looks in for a blob. */
private const SEARCH = 16;
/** @var list<array{0:string, 1:int, 2:int}> [path, offset, length] */
private array $ranges = [];
/** @var \Generator<int, resource|null>|null */
private ?\Generator $streams = null;
private int $current = -1;
/** @var resource|null */
private $stream = null;
/** @param string $spool local folder for the ranges read ahead (see IReadsAhead::readAhead()) */
public function __construct(
public readonly string $spool,
) {
}
public function add(string $path, int $offset, int $length): void {
$last = array_pop($this->ranges);
if ($last !== null) {
[$p, $start, $len] = $last;
if ($p === $path && $offset >= $start && $offset <= $start + $len + self::GAP
&& $offset + $length - $start <= self::MAX_RANGE) {
$this->ranges[] = [$p, $start, max($len, $offset + $length - $start)];
return;
}
$this->ranges[] = $last;
}
$this->ranges[] = [$path, $offset, $length];
}
/** @return list<array{0:string, 1:int, 2:int}> */
public function ranges(): array {
return $this->ranges;
}
/** @param \Generator<int, resource|null> $streams what IReadsAhead::readAhead() gave for ranges() */
public function readFrom(\Generator $streams): void {
$this->streams = $streams;
}
/** The bytes of a blob, or null when its range could not be read ahead (read it the usual way). */
public function read(string $path, int $offset, int $length): ?string {
if ($this->streams === null) {
return null;
}
if (!$this->contains($this->current, $path, $offset, $length)) {
// Normally the next range. Further on when blobs before it were read elsewhere: the
// index was reloaded after another server's cleanup moved them to new packs.
$to = null;
for ($i = $this->current + 1; $i <= $this->current + self::SEARCH && $to === null; $i++) {
if ($this->contains($i, $path, $offset, $length)) {
$to = $i;
}
}
if ($to === null) {
return null; // not where the plan has it
}
if ($this->current < 0) {
$this->streams->current();
$this->current = 0;
}
while ($this->current < $to) {
$this->streams->next();
$this->current++;
}
$stream = $this->streams->valid() ? $this->streams->current() : null;
$this->stream = is_resource($stream) ? $stream : null;
}
$fh = $this->stream;
if ($fh === null || fseek($fh, $offset - $this->ranges[$this->current][1]) !== 0) {
return null;
}
$data = '';
while (strlen($data) < $length) {
$chunk = fread($fh, $length - strlen($data));
if ($chunk === false || $chunk === '') {
return null;
}
$data .= $chunk;
}
return $data;
}
/** Ends the reads ahead, also halfway: closes their streams and connections. */
public function close(): void {
$this->stream = null;
$streams = $this->streams;
$this->streams = null;
unset($streams); // the generator's finally blocks run when it is destroyed
}
private function contains(int $i, string $path, int $offset, int $length): bool {
if ($i < 0 || !isset($this->ranges[$i])) {
return false;
}
[$p, $start, $len] = $this->ranges[$i];
return $p === $path && $offset >= $start && $offset + $length <= $start + $len;
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Repository;
/**
* A restore cannot write to where it restores to (a full disk, permissions, something in the way).
* The backup is fine, so this never counts as damaged data: the restore stops, also with --salvage.
*/
final class RestoreTargetException extends \RuntimeException {
}
+46 -2
View File
@@ -54,6 +54,23 @@ final class AlertService {
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
}
/** A backup that finished but is missing unreadable files, or holds files that changed while read. */
public function runWarnings(Run $run, string $targetName, int $unreadable, int $changing, array $examples): void {
$this->notifyAdmins('run_warnings', 'run-' . $run->getId(), ['target' => $targetName,
'unreadable' => (string)$unreadable, 'changing' => (string)$changing,
'examples' => mb_strimwidth(implode(', ', array_merge($examples['unreadable'], $examples['changing'])), 0, 300, '…')]);
}
/**
* A backup that finished but left out files or folders because of their name.
*
* @param list<string> $examples
*/
public function runBadNames(Run $run, string $targetName, int $count, array $examples): void {
$this->notifyAdmins('run_bad_names', 'run-' . $run->getId(), ['target' => $targetName, 'count' => (string)$count,
'examples' => mb_strimwidth(implode(', ', $examples), 0, 300, '…')]);
}
/**
* Remember how much is stored on a location (for the settings page) and warn administrators
* once a day when it is at 80% of its limit or more.
@@ -84,9 +101,36 @@ final class AlertService {
return $v === '' ? null : (int)$v;
}
/**
* Whether it is time to remind administrators of a location without a schedule: once a week
* while another location is still backed up, every day when none is. An hour of margin, so
* that the moment of the cron run does not push a reminder to the next day.
*/
public static function offReminderDue(int $last, int $now, bool $othersActive): bool {
return $now - $last >= ($othersActive ? 7 * 86400 : 86400) - 3600;
}
public function checkOff(Target $target, bool $othersActive): void {
if ((string)$target->getSchedule() !== '') {
return;
}
$key = 'off_notified_' . $target->getId();
$now = time();
$last = max((int)$target->getScheduleSince(), $this->appConfig->getValueInt(Application::APP_ID, $key, 0));
if (!self::offReminderDue($last, $now, $othersActive)) {
return;
}
$this->appConfig->setValueInt(Application::APP_ID, $key, $now);
$this->notifyAdmins('schedule_off', 'off-' . $target->getId() . '-' . date('Y-m-d', $now),
['target' => $target->getName(), 'others' => $othersActive ? '1' : '0']);
}
/** At most one notification per location per day. */
public function checkStale(Target $target): void {
$last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
if ((string)$target->getSchedule() === '') {
return; // no schedule is a choice, as for a location that is only connected now and then
}
$last =$this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
$lastTime = $last?->getFinishedAt() ?? $target->getCreatedAt();
if (time() - $lastTime < self::STALE_AFTER || $this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
return;
@@ -97,7 +141,7 @@ final class AlertService {
}
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : 'never']);
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : '']);
}
private function notifyAdmins(string $subject, string $objectId, array $params): void {
+250 -17
View File
@@ -14,8 +14,12 @@ use OCA\NgBackup\Db\Snapshot;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\Job\BackupRun;
use OCA\NgBackup\Repository\Repository;
use OCA\NgBackup\Repository\RepositoryException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
@@ -30,6 +34,11 @@ use Psr\Log\LoggerInterface;
*/
final class BackupService {
public const KIND_FULL = 'full';
/** Characters of a snapshot label (the column holds 255) */
public const LABEL_MAX = 100;
/** @var \WeakMap<Repository, string> the location settings each kept repository was opened with */
private \WeakMap $openedWith;
public function __construct(
private TargetService $targets,
@@ -44,9 +53,28 @@ final class BackupService {
private AlertService $alerts,
private LeaseService $leases,
) {
/** @var \WeakMap<Repository, string> $opened */
$opened = new \WeakMap();
$this->openedWith = $opened;
}
/**
* The running backups in the order they get their turn: first come, first served. The first one
* runs, the others are queued; only one at a time reads the disks.
*
* @return list<Run>
*/
public function runsInTurn(): array {
$running = $this->runs->findRunning(null, self::KIND_FULL);
usort($running, static fn (Run $a, Run $b): int => [$a->getStartedAt(), $a->getId()] <=> [$b->getStartedAt(), $b->getId()]);
return $running;
}
public function start(Target $target, string $label = ''): Run {
$label = trim($label);
if (mb_strlen($label) > self::LABEL_MAX) {
throw new \InvalidArgumentException('A label has at most ' . self::LABEL_MAX . ' characters');
}
if (!$this->keys->everConfirmed()) {
throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)');
}
@@ -63,7 +91,47 @@ final class BackupService {
try {
return $this->leases->with(PruneService::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $this->createRun($target, $label));
} catch (LockedException) {
throw new \RuntimeException('A cleanup or another start for ' . $target->getName() . ' is running; try again later');
throw new \RuntimeException('A cleanup, a restore or another start for ' . $target->getName() . ' is running, or one was stopped by force (that clears by itself within two hours); try again later');
}
}
/**
* The expected size of the files phase, for the progress shown while it runs: the size of the
* previous snapshot, or for a first backup the users' files, versions and trash as Nextcloud's
* file cache knows them (approximate: other data in the data directory is not counted).
*
* @return array{bytes: int, approximate: bool}
*/
private function estimateBytes(Run $run, array $files): array {
if (($files['parent'] ?? null) !== null) {
try {
return ['bytes' => $this->snapshots->findOne($run->getTargetId(), (string)$files['parent'])->getBytes() ?? 0, 'approximate' => false];
} catch (\Throwable) {
}
}
try {
$qb = $this->db->getQueryBuilder();
$qb->select('numeric_id')->from('storages')
->where($qb->expr()->like('id', $qb->createNamedParameter('home::%')))
->orWhere($qb->expr()->like('id', $qb->createNamedParameter('object::user:%')));
$result = $qb->executeQuery();
$storages = [];
while (($id = $result->fetchOne()) !== false) {
$storages[] = (int)$id;
}
$result->closeCursor();
$bytes = 0;
foreach (array_chunk($storages, 500) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->sum('size'))->from('filecache')
->where($qb->expr()->in('storage', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->in('path_hash', $qb->createNamedParameter(array_map('md5', ['files', 'files_versions', 'files_trashbin']), IQueryBuilder::PARAM_STR_ARRAY)))
->andWhere($qb->expr()->gt('size', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
$bytes += (int)$qb->executeQuery()->fetchOne();
}
return ['bytes' => $bytes, 'approximate' => true];
} catch (\Throwable) {
return ['bytes' => 0, 'approximate' => true];
}
}
@@ -101,10 +169,22 @@ final class BackupService {
/**
* Do one step of a run until $deadline. Safe to call concurrently: a second caller finds the
* run locked and returns immediately.
*
* @param Repository|null $repo the location's repository, opened by an earlier step of this run
* in this process (see runToCompletion()); set when this step opens it
*/
public function step(Run $run, float $deadline): Run {
public function step(Run $run, float $deadline, ?Repository &$repo = null): Run {
// Leases expire on their own: a step killed by a time limit or OOM blocks nothing for long.
$ttl = (int)min(3600, max(120, $deadline - microtime(true) + 120));
// A step can run well past its deadline (the commit of a large snapshot writes its tree and
// index), so the lease holds at least 15 minutes; should it still expire and another process
// take the run over, the checks below keep the two from overwriting each other.
$ttl = (int)min(3600, max(900, $deadline - microtime(true) + 600));
// No new step once a cancel has begun: it waits for the step in progress, if any.
try {
$this->leases->release($this->leases->acquire(self::cancelKey($run->getId()), LeaseService::SHARED, 60));
} catch (LockedException) {
return $run;
}
try {
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
} catch (LockedException) {
@@ -117,16 +197,38 @@ final class BackupService {
$this->leases->release($runLease);
return $run;
}
$loadedAt = null;
$savedBefore = $run->getUpdatedAt();
try {
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
return $run;
}
$loadedAt = $run->getUpdatedAt();
$target = $this->targets->get((string)$run->getTargetId());
$repo = $this->targets->repository($target);
// Reopen when another process took a step since ours (its packs are not in our copy of
// the index) or the location was edited (password, folder, size limit).
$settings = json_encode([$target->getBackend(), $target->getAuth(), $target->getOptions(),
$target->getBasePath(), $target->getRepositoryId(), $target->getMaxBytes()], JSON_THROW_ON_ERROR);
if ($repo === null || $loadedAt !== $savedBefore || ($this->openedWith[$repo] ?? null) !== $settings) {
// Opening reads the index listing, which takes minutes on a slow location with many
// packs: that time does not count, or each step would store one blob and stop.
$opening = microtime(true);
$repo = null;
$repo = $this->targets->repository($target);
$this->openedWith[$repo] = $settings;
$deadline += microtime(true) - $opening;
}
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
if ($run->getPhase() === 'db') {
if (!isset($state['dump'])) {
// It may have been queued for hours: the restore point has the time of its data.
$state['files']['started'] = gmdate('c');
// Locations from before 1.1.0 get their sealed settings at their next backup.
$this->targets->recordSettings($repo);
$this->targets->catchUpWrappedKey($target, $repo);
}
$dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_'));
$dbStats = $state['dbStats'] ?? [];
// With system cron the whole dump usually fits in one step (one transaction, fully
@@ -146,10 +248,45 @@ final class BackupService {
$state['dump'] = $dump;
}
} 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.
try {
$this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300,
fn () => $this->targets->repository($target)->recordSnapshot((string)$state['files']['snapshot']));
$state['files']['phase'] = 'done';
} catch (LockedException|RepositoryException) {
// Another catalog write is in progress (or just raced us to the next
// generation): the next step retries this commit on the fresh catalog.
}
}
if ($state['files']['phase'] === 'done') {
$this->recordSnapshot($run, $state['files']);
$this->alerts->storedBytes($target, $repo->storedBytes());
// The snapshot is committed: what follows only reports on it and must not turn
// the run into a failure.
try {
$this->alerts->storedBytes($target, $repo->storedBytes());
$stats = $state['files']['stats'];
$warnings = BackupRun::warnings($state['files']);
if (($stats['badNames'] ?? 0) > 0) {
$this->logger->warning(sprintf('NG Backup run %d to %s: %d left out because their name is not supported (rename them): %s',
$run->getId(), $target->getName(), $stats['badNames'], implode(', ', $warnings['badNames'])));
$this->alerts->runBadNames($run, $target->getName(), (int)$stats['badNames'], $warnings['badNames']);
}
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
$this->logger->warning(sprintf('NG Backup run %d to %s: %d unreadable (missing from the snapshot): %s; %d changed while being read: %s',
$run->getId(), $target->getName(), $stats['unreadable'] ?? 0, implode(', ', $warnings['unreadable']),
$stats['changing'] ?? 0, implode(', ', $warnings['changing'])));
$this->alerts->runWarnings($run, $target->getName(), (int)($stats['unreadable'] ?? 0), (int)($stats['changing'] ?? 0), $warnings);
}
} catch (\Throwable $e) {
$this->logger->warning('NG Backup run ' . $run->getId() . ': reporting on the finished backup failed: ' . $e->getMessage(), ['exception' => $e]);
}
$run->setStatus(Run::DONE);
$run->setPhase('done');
$run->setFinishedAt(time());
@@ -157,15 +294,33 @@ final class BackupService {
}
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
$run->setStats(json_encode(['db' => $state['dbStats'] ?? null, 'files' => $state['files']['stats']], JSON_THROW_ON_ERROR));
$run->setUpdatedAt(time());
return $this->runs->update($run);
// Another process may have taken the run over after this step's lease expired: then its
// writes stand and this step's are dropped.
$saved = $this->runs->updateIfUnchanged($run, $loadedAt);
if ($saved === null) {
$repo = null; // the run is another process's now: its packs are not in our index
return $this->runs->find($run->getId());
}
return $saved;
} catch (\Throwable $e) {
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
$repo = null; // may hold blobs of a half-written pack: the next step opens it afresh
if ($loadedAt === null) {
// The run could not even be loaded: there is nothing fresh to mark as failed, and
// the caller's copy may be stale.
$this->logger->error('NG Backup run ' . $run->getId() . ': step could not load the run: ' . $e->getMessage(), ['exception' => $e]);
return $run;
}
$run->setStatus(Run::FAILED);
$run->setError($e->getMessage());
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
$run = $this->runs->update($run);
$failed = $this->runs->updateIfUnchanged($run, $loadedAt);
if ($failed === null) {
// Another process that took the run over may have finished it: its result stands.
$this->logger->warning('NG Backup run ' . $run->getId() . ': a step that another process had taken over ended with: ' . $e->getMessage());
return $this->runs->find($run->getId());
}
$run = $failed;
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
try {
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
} catch (\Throwable) {
@@ -179,14 +334,16 @@ final class BackupService {
/** Run to completion in this process (occ, system cron), reporting progress. */
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
$repo = null; // opened once, by the first step, and kept for the next ones
while ($run->getStatus() === Run::RUNNING) {
$before = $run->getUpdatedAt();
$run = $this->step($run, microtime(true) + $stepSeconds);
$run = $this->step($run, microtime(true) + $stepSeconds, $repo);
if ($run->getStatus() === Run::RUNNING && $run->getUpdatedAt() === $before) {
// Step was not possible (another process holds the run, or a crashed step's lease has
// not expired yet): wait instead of spinning.
sleep(5);
$run = $this->runs->find($run->getId());
$repo = null; // another process may have stored packs in the meantime
}
if ($progress !== null) {
$progress($run);
@@ -195,14 +352,78 @@ final class BackupService {
return $run;
}
public function cancel(Run $run): Run {
$run->setStatus(Run::CANCELLED);
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
return $this->runs->update($run);
/**
* Stop a running backup. First no new step may start, then this waits (up to $wait seconds)
* for the step in progress, so a step that is recording the snapshot finishes first. A process
* running the backup then finds it cancelled and stops. The packs it stored stay until the
* next cleanup.
*
* A process of an earlier version does not hold back its next step and may never leave a gap:
* after $wait the run is marked cancelled while it works, unless it is recording the snapshot.
* Its step then fails to save and the process stops.
*/
public function cancel(Run $run, int $wait = 120): Run {
$id = $run->getId();
$until = time() + $wait;
$hold = null;
for ($try = 0; $hold === null; $try++) {
try {
$hold = $this->leases->acquire(self::cancelKey($id), LeaseService::EXCLUSIVE, $wait + 300);
} catch (LockedException) {
if ($try >= 20) {
throw new \RuntimeException("Run $id is being cancelled already");
}
usleep(100000); // a step checking whether it may start
}
}
try {
$lease = null;
while ($lease === null && time() < $until) {
try {
$lease = $this->leases->acquire('run/' . $id, LeaseService::EXCLUSIVE, 60);
} catch (LockedException) {
sleep(2);
}
}
try {
for ($try = 0; $try < 10; $try++) {
$run = $this->runs->find($id);
if ($run->getStatus() !== Run::RUNNING) {
return $run;
}
$state = json_decode($run->getState(), true) ?: [];
if ($lease === null && ($state['files']['phase'] ?? '') === 'commit') {
throw new \RuntimeException("Run $id is recording its snapshot; try again in a minute");
}
$loadedAt = $run->getUpdatedAt();
$run->setStatus(Run::CANCELLED);
$run->setFinishedAt(time());
if (($cancelled = $this->runs->updateIfUnchanged($run, $loadedAt)) !== null) {
return $cancelled;
}
usleep(200000); // a step of another process saved just now
}
throw new \RuntimeException("Run $id keeps changing; try again");
} finally {
if ($lease !== null) {
$this->leases->release($lease);
}
}
} finally {
$this->leases->release($hold);
}
}
private static function cancelKey(int $runId): string {
return 'run/' . $runId . '/cancel';
}
private function recordSnapshot(Run $run, array $files): void {
try {
$this->snapshots->findOne($run->getTargetId(), $files['snapshot']);
return; // already recorded, by an earlier attempt at this step
} catch (DoesNotExistException) {
}
$s = new Snapshot();
$s->setTargetId($run->getTargetId());
$s->setSnapshotId($files['snapshot']);
@@ -211,7 +432,19 @@ final class BackupService {
$s->setCreatedAt(time());
$s->setFiles($files['stats']['files']);
$s->setBytes($files['stats']['bytes']);
$this->snapshots->insert($s);
try {
$this->snapshots->insert($s);
} catch (\OCP\DB\Exception $e) {
// Recorded by another process between the check above and this insert?
if ($e->getReason() !== \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
try {
$this->snapshots->findOne($run->getTargetId(), $files['snapshot']);
} catch (DoesNotExistException) {
throw $e;
}
}
}
/** @return list<string> */
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\Db\Run;
use OCP\Exceptions\AppConfigTypeConflictException;
use OCP\IAppConfig;
use OCP\IURLGenerator;
/**
* Whether Nextcloud's background jobs run on a system cron, which backups need: a backup moves
* forward only when a background job run takes its next step (#13). Without system cron, AJAX
* cron runs a job only now and then while someone has Nextcloud open, and webcron only as often as
* the outside service calls it.
*/
final class CronHealth {
/** A system cron that has not run for this long has stopped (it runs every 5 minutes). */
public const STOPPED_AFTER = 3600;
/**
* A running backup that took no step for this long is waiting for background jobs. A step with
* system cron takes at most a few minutes (BackupJob) and the job runs every minute.
*/
public const WAITING_AFTER = 900;
public function __construct(
private IAppConfig $appConfig,
private IURLGenerator $urls,
) {
}
/**
* mode: ajax, webcron or cron; lastRun: the last background job run (0: never); ok: a system
* cron that ran recently; stopped: system cron is set but has not run for STOPPED_AFTER.
*
* @return array{mode: string, lastRun: int, ok: bool, stopped: bool, docs: string}
*/
public function check(?int $now = null): array {
$now ??= time();
$mode = $this->coreString('backgroundjobs_mode', 'ajax');
$lastRun = (int)$this->coreString('lastcron', '0');
$stopped = $mode === 'cron' && $now - $lastRun > self::STOPPED_AFTER;
return [
'mode' => $mode,
'lastRun' => $lastRun,
'ok' => $mode === 'cron' && !$stopped,
'stopped' => $stopped,
'docs' => $this->urls->linkToDocs('admin-background-jobs'),
];
}
/**
* Seconds since a running backup took its last step, when that is long enough to say it waits
* for background jobs; null while it moves (or when it is not running).
*/
public static function waitingFor(Run $run, ?int $now = null): ?int {
$idle = ($now ?? time()) - $run->getUpdatedAt();
return $run->getStatus() === Run::RUNNING && $idle >= self::WAITING_AFTER ? $idle : null;
}
/** One line for occ and the log; null when system cron runs. */
public static function describe(array $health, ?int $now = null): ?string {
if ($health['ok']) {
return null;
}
$last = $health['lastRun'] > 0 ? 'last ran ' . self::ago(($now ?? time()) - $health['lastRun']) : 'never ran';
$what = $health['stopped']
? 'Background jobs are set to system cron, but cron has not run for over an hour (' . $last . ').'
: 'Background jobs do not run on a system cron: they are set to "' . ($health['mode'] === 'webcron' ? 'Webcron' : 'AJAX') . '" and ' . $last . '.';
return $what . ' Backups, also the scheduled ones, need a system cron that runs every 5 minutes: ' . $health['docs'];
}
/** "12 min ago" style, for durations from a minute to days */
public static function ago(int $seconds): string {
if ($seconds < 3600) {
return max(1, intdiv($seconds, 60)) . ' min ago';
}
if ($seconds < 172800) {
return intdiv($seconds, 3600) . ' h ago';
}
return intdiv($seconds, 86400) . ' days ago';
}
/** Core stores these with different value types across versions: read whatever is there. */
private function coreString(string $key, string $default): string {
try {
return $this->appConfig->getValueString('core', $key, $default);
} catch (AppConfigTypeConflictException) {
return (string)$this->appConfig->getValueInt('core', $key, (int)$default);
}
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
/** A step after the database was committed failed: the database is no longer the fresh one. */
final class DatabaseAlreadyRestoredException extends \RuntimeException {
}
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IAppConfig;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;
/**
* Keeps heavy background jobs of other apps from running while a backup reads the same disks, by
* reserving them in Nextcloud's job list: no app is turned off, and a job that is running goes on.
* Nextcloud itself gives up on a reservation after 12 hours, so a pause that is never ended, because
* cron or this app is gone, ends by itself. It lasts at most MAX_SECONDS, then the jobs get REST_SECONDS.
*/
final class HeavyJobPause {
public const ENABLED_KEY = 'pause_heavy_jobs';
private const SINCE_KEY = 'heavy_jobs_paused_since';
private const REST_KEY = 'heavy_jobs_rest_until';
public const MAX_SECONDS = 6 * 3600;
public const REST_SECONDS = 3600;
/** Nextcloud runs a job again this long after it was reserved. */
private const RESERVATION_SECONDS = 12 * 3600;
/** Class, or the start of a class name ending in a backslash => what to call it */
private const JOBS = [
'OCA\\Memories\\' => 'Memories',
'OCA\\Files_Antivirus\\BackgroundJob\\BackgroundScanner' => 'Antivirus background scan',
'OCA\\Recognize\\' => 'Recognize',
'OCA\\PreviewGenerator\\' => 'Preview Generator',
'OCA\\FullTextSearch\\' => 'Full text search',
];
public const NOTHING = 'nothing';
public const PAUSE = 'pause';
public const RELEASE = 'release';
public const REST = 'rest';
public function __construct(
private IDBConnection $db,
private IAppConfig $appConfig,
private LoggerInterface $logger,
) {
}
/** @return self::NOTHING|self::PAUSE|self::RELEASE|self::REST */
public static function decide(bool $enabled, bool $backupRunning, int $since, int $restUntil, int $now): string {
if ($since > 0 && $now - $since >= self::MAX_SECONDS) {
return self::REST;
}
if ($enabled && $backupRunning && $now >= $restUntil) {
return self::PAUSE;
}
return $since > 0 ? self::RELEASE : self::NOTHING;
}
/** Call regularly: pauses or ends the pause as needed. A problem here never stops a backup. */
public function sync(bool $backupRunning): void {
try {
$this->apply($backupRunning, time());
} catch (\Throwable $e) {
$this->logger->warning('NG Backup: pausing heavy background jobs failed: ' . $e->getMessage(), ['exception' => $e]);
}
}
private function apply(bool $backupRunning, int $now): void {
$since = $this->appConfig->getValueInt(Application::APP_ID, self::SINCE_KEY, 0);
$restUntil = $this->appConfig->getValueInt(Application::APP_ID, self::REST_KEY, 0);
switch (self::decide($this->enabled(), $backupRunning, $since, $restUntil, $now)) {
case self::PAUSE:
if ($since === 0) {
$since = $now;
$this->appConfig->setValueInt(Application::APP_ID, self::SINCE_KEY, $since);
$this->logger->info('NG Backup: heavy background jobs paused while a backup runs');
}
$this->reserve($since, $now); // also the jobs that were added or running until now
break;
case self::REST:
$this->appConfig->setValueInt(Application::APP_ID, self::REST_KEY, $now + self::REST_SECONDS);
$this->release($since);
$this->logger->info('NG Backup: heavy background jobs get their turn for an hour during a long backup');
break;
case self::RELEASE:
$this->release($since);
$this->logger->info('NG Backup: heavy background jobs run again');
break;
}
}
private function reserve(int $stamp, int $now): void {
$qb = $this->db->getQueryBuilder();
$qb->update('jobs')
->set('reserved_at', $qb->createNamedParameter($stamp, IQueryBuilder::PARAM_INT))
->where($qb->expr()->lte('reserved_at', $qb->createNamedParameter($now - self::RESERVATION_SECONDS, IQueryBuilder::PARAM_INT)))
->andWhere($this->isHeavy($qb));
$qb->executeStatement();
}
/** Only our own reservations: a job that Nextcloud reserved at another moment is running. */
private function release(int $stamp): void {
$qb = $this->db->getQueryBuilder();
$qb->update('jobs')
->set('reserved_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('reserved_at', $qb->createNamedParameter($stamp, IQueryBuilder::PARAM_INT)))
->andWhere($this->isHeavy($qb));
$qb->executeStatement();
$this->appConfig->deleteKey(Application::APP_ID, self::SINCE_KEY);
}
/** @return \OCP\DB\QueryBuilder\ICompositeExpression */
private function isHeavy(IQueryBuilder $qb) {
$conditions = [];
foreach (array_keys(self::JOBS) as $class) {
$conditions[] = str_ends_with($class, '\\')
? $qb->expr()->like('class', $qb->createNamedParameter($this->db->escapeLikeParameter($class) . '%'))
: $qb->expr()->eq('class', $qb->createNamedParameter($class));
}
return $qb->expr()->orX(...$conditions);
}
public function enabled(): bool {
return $this->appConfig->getValueString(Application::APP_ID, self::ENABLED_KEY, '1') !== '0';
}
public function setEnabled(bool $enabled): void {
$this->appConfig->setValueString(Application::APP_ID, self::ENABLED_KEY, $enabled ? '1' : '0');
}
/** @return array{enabled: bool, since: int, jobs: list<string>} what is paused now */
public function status(): array {
$since = $this->appConfig->getValueInt(Application::APP_ID, self::SINCE_KEY, 0);
$jobs = [];
if ($since > 0) {
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct('class')->from('jobs')
->where($qb->expr()->eq('reserved_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)))
->andWhere($this->isHeavy($qb));
$result = $qb->executeQuery();
while (($class = $result->fetchOne()) !== false) {
foreach (self::JOBS as $start => $name) {
if (str_starts_with((string)$class, $start)) {
$jobs[$name] = true;
}
}
}
$result->closeCursor();
}
return ['enabled' => $this->enabled(), 'since' => $since, 'jobs' => array_map('strval', array_keys($jobs))];
}
}
+165 -6
View File
@@ -30,7 +30,15 @@ final class KeyService {
private const KEY_DELETE_DELAY = 'delete_delay_days';
private const KEY_KIT_VERSION = 'recovery_kit_version';
private const KEY_KIT_CODE = 'recovery_kit_code';
/** Set once a location was created or adopted with this key: from then on it is never replaced. */
private const KEY_USED = 'master_key_used';
private const KEY_WRAPPED_PENDING = 'master_key_wrapped_pending';
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 +64,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);
@@ -71,6 +77,158 @@ final class KeyService {
$this->cached = $keys;
}
/**
* Disaster recovery on a fresh installation: adopt the master key from a recovery kit
* (occ backup:key:kit on the original installation) instead of generating a new one.
* Afterwards occ backup:target:add opens the existing repository on the location with this
* key, instead of creating a new, empty one.
*
* @param array<string, mixed> $kit as produced by recoveryKit()
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase, or a damaged/foreign kit
*/
public function importRecoveryKit(array $kit, #[\SensitiveParameter] string $passphrase, bool $replaceUnused = false): void {
if ($this->isInitialized() && !$replaceUnused) {
throw new \RuntimeException('A backup key already exists');
}
if (!isset($kit['wrapped_key'])) {
throw new \InvalidArgumentException('Not a NG Backup recovery kit file');
}
$keys = KeyRing::unwrapAny($kit['wrapped_key'], $passphrase);
$this->discardUnused($keys, $replaceUnused);
// Kits from before this field existed fall back to the default.
$this->store($keys, $kit['wrapped_key'], (int)($kit['delete_delay_days'] ?? self::DEFAULT_DELETE_DELAY), (int)($kit['kit_version'] ?? 1));
// Having and successfully using a real kit file is stronger proof than the normal
// download-and-confirm flow it replaces.
$this->confirmRecoveryKit('cli-import');
}
/**
* Disaster recovery without the recovery kit: adopt the master key that a passphrase opened
* from the key slots in a location's config (TargetService::addWithPassphrase). There is no
* kit, so none is confirmed: backups wait until a new kit is downloaded and confirmed.
*
* @param ?int $deleteDelayDays from the location's sealed settings, null when it has none
*/
public function adoptKey(KeyRing $keys, array $wrappedKey, ?int $deleteDelayDays, bool $replaceUnused = false): void {
if ($this->isInitialized() && !$replaceUnused) {
throw new \RuntimeException('A backup key already exists');
}
$this->discardUnused($keys, $replaceUnused);
$this->store($keys, $wrappedKey, $deleteDelayDays ?? self::DEFAULT_DELETE_DELAY, 1);
}
/** Whether a location was ever created or adopted with this key (TargetService::keyIsUnused() also looks at the database). */
public function wasUsed(): bool {
return $this->appConfig->getValueBool(Application::APP_ID, self::KEY_USED, false, true);
}
public function markUsed(): void {
if (!$this->wasUsed()) {
$this->appConfig->setValueBool(Application::APP_ID, self::KEY_USED, true, true);
}
}
/**
* Locations that missed the last passphrase change (offline at the time): they still hold
* the previous slots until the change reaches them, which is tried again at each backup.
*/
public function wrappedKeyPending(int $targetId): bool {
return $this->appConfig->getValueBool(Application::APP_ID, self::KEY_WRAPPED_PENDING . '_' . $targetId);
}
/** One flag per location, so that two processes never overwrite each other's. */
public function setWrappedKeyPending(int $targetId, bool $pending): void {
$key = self::KEY_WRAPPED_PENDING . '_' . $targetId;
if ($pending) {
$this->appConfig->setValueBool(Application::APP_ID, $key, true);
} elseif ($this->wrappedKeyPending($targetId)) {
$this->appConfig->deleteKey(Application::APP_ID, $key);
}
}
/**
* A key made by mistake on a server that is being restored: remove it, so the key of the
* backups can take its place. Only after the new key was opened with its passphrase, and
* only when the caller checked that the old key never protected anything
* (TargetService::keyIsUnused()). Its passphrases and recovery kit become worthless.
*/
private function discardUnused(KeyRing $replacement, bool $replaceUnused): void {
if (!$replaceUnused || !$this->isInitialized()) {
return;
}
if ($this->wasUsed()) {
throw new \RuntimeException('The backup key of this server has been used for a backup location: it is never replaced');
}
if ($this->fingerprintOf($replacement) === $this->fingerprint()) {
throw new \RuntimeException('This server already has this backup key: nothing to replace');
}
foreach ([self::KEY_MASTER, self::KEY_WRAPPED, self::KEY_CONFIRMED, self::KEY_FINGERPRINT, self::KEY_DELETE_DELAY,
self::KEY_KIT_VERSION, self::KEY_KIT_CODE, self::KEY_USED] as $key) {
$this->appConfig->deleteKey(Application::APP_ID, $key);
}
$this->cached = null;
}
private function store(KeyRing $keys, array $wrappedKey, int $deleteDelayDays, int $kitVersion): void {
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
$this->cached = $keys;
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode($wrappedKey, JSON_THROW_ON_ERROR), true, true);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY,
($deleteDelayDays >= 1 && $deleteDelayDays <= 365) ? $deleteDelayDays : self::DEFAULT_DELETE_DELAY, true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, max(1, $kitVersion), true);
}
/**
* The master key and the recovery kit code are stored encrypted with Nextcloud's own
* instance secret (ICrypto), not with themselves; the wrapped key is not (it is sealed with
* the admin's passphrase instead), but Nextcloud's own AppConfig additionally encrypts any
* "sensitive" value with the instance secret regardless, underneath whatever we store. So
* changing that secret (occ backup:restore:full merging config.php) would otherwise leave
* all three permanently undecryptable. Call $applySecretChange (which must be the only thing
* that changes the secret) through here instead: all three are decrypted first, then
* re-encrypted under whatever secret is active once $applySecretChange returns.
*
* Not crash-safe: a process kill between the delete and the re-save of one of these keys
* would leave it missing rather than merely stale. Disaster recovery is run once, by hand,
* right after occ maintenance:install; a dedicated two-phase write is not justified yet for
* that narrow a window.
*/
public function reencryptForSecretRotation(callable $applySecretChange): mixed {
$master = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true);
$wrapped = $this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '', true);
$kitCodeRaw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_KIT_CODE, '', true);
$kitCode = $kitCodeRaw !== '' ? json_decode($kitCodeRaw, true, 512, JSON_THROW_ON_ERROR) : null;
$masterPlain = $master !== '' ? $this->crypto->decrypt($master) : null;
$kitCodePlain = $kitCode !== null && isset($kitCode['code']) ? $this->crypto->decrypt($kitCode['code']) : null;
$result = $applySecretChange();
$this->cached = null; // the master key is about to be re-saved; do not keep the pre-rotation copy
// Nextcloud's own AppConfig additionally encrypts a "sensitive" value itself (tied to
// the same secret, underneath our own encrypt() above); setValueString() on an existing
// key reads the old value first to compare, which would try to decrypt it with the new
// secret and fail the same way. Delete first so there is nothing to compare against.
if ($masterPlain !== null) {
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_MASTER);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER, $this->crypto->encrypt($masterPlain), true, true);
}
if ($wrapped !== '') {
// Already plaintext JSON here: getValueString() above transparently undid
// AppConfig's own sensitive-value encryption; there is no encrypt() of our own to
// redo (wrapping is sealed with the passphrase, not the instance secret).
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_WRAPPED);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, $wrapped, true, true);
}
if ($kitCodePlain !== null) {
$kitCode['code'] = $this->crypto->encrypt($kitCodePlain);
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_KIT_CODE);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_KIT_CODE, json_encode($kitCode, JSON_THROW_ON_ERROR), true, true);
}
return $result;
}
public function keyRing(): KeyRing {
if ($this->cached === null) {
$enc = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true);
@@ -154,8 +312,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');
}
}
@@ -182,13 +340,14 @@ final class KeyService {
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_FINGERPRINT, '', true);
}
/** @return array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string} */
/** @return array{version:int, app:string, fingerprint:string, kit_version:int, delete_delay_days:int, confirmation_code:string, created:string, wrapped_key:array, slots:list<array{slot:int, label:string, created:int}>, instructions:string} */
public function recoveryKit(): array {
return [
'version' => 1,
'app' => 'NG Backup',
'fingerprint' => $this->fingerprint(),
'kit_version' => $this->kitVersion(),
'delete_delay_days' => $this->deleteDelayDays(),
'confirmation_code' => $this->kitCode(),
'created' => gmdate('c'),
'wrapped_key' => $this->wrappedKey(),
+3 -1
View File
@@ -43,8 +43,10 @@ final class KeySlotService {
foreach ($this->targets->list() as $t) {
try {
$this->targets->repository($t)->updateWrappedKey($wrapped);
$this->keys->setWrappedKeyPending($t->getId(), false);
} catch (\Throwable $e) {
$failed[] = $t->getName();
$this->keys->setWrappedKeyPending($t->getId(), true);
}
}
$label = '';
@@ -55,7 +57,7 @@ final class KeySlotService {
}
$this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]);
if ($failed !== []) {
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed));
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases; the change is tried again at their next backup): ' . implode(', ', $failed));
}
}
}
+20
View File
@@ -76,6 +76,26 @@ final class LeaseService {
$qb->executeStatement();
}
/** Remove the shared leases on $name, for a caller that knows their holders were stopped. */
public function dropShared(string $name): void {
$qb = $this->db->getQueryBuilder();
$qb->delete('ngb_leases')
->where($qb->expr()->eq('name', $qb->createNamedParameter($name)))
->andWhere($qb->expr()->eq('mode', $qb->createNamedParameter(self::SHARED)));
$qb->executeStatement();
}
/** Wraps $refresh so calling it often (per file, per row batch) only touches the database every $seconds. */
public static function throttled(callable $refresh, int $seconds = 60): \Closure {
$last = time();
return static function () use ($refresh, $seconds, &$last): void {
if (time() - $last >= $seconds) {
$refresh();
$last = time();
}
};
}
/** Run $fn under a lease; $refresh() can be called during long work. */
public function with(string $name, string $mode, int $ttl, callable $fn): mixed {
$id = $this->acquire($name, $mode, $ttl);
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
/**
* When a location is backed up: "" (off), "HH:MM" (daily) or "HH:MM/N" (from HH:MM every N hours,
* starting over at HH:MM the next day), in the location's time zone.
*/
final class LocationSchedule {
/** Attempts per moment after a failure, an hour apart, instead of one every few minutes. */
public const MAX_ATTEMPTS = 3;
public const RETRY_AFTER = 3600;
/** @throws \InvalidArgumentException */
public static function normalize(string $schedule): string {
$schedule = trim($schedule);
if ($schedule === '' || strtolower($schedule) === 'off') {
return '';
}
if (preg_match('/^(([01]\d|2[0-3]):[0-5]\d)(?:\/(\d{1,2}))?$/', $schedule, $m) !== 1) {
throw new \InvalidArgumentException('Use "off", HH:MM for once a day, or HH:MM/N for every N hours from HH:MM');
}
$hours = (int)($m[3] ?? 24);
if ($hours < 1 || $hours > 24) {
throw new \InvalidArgumentException('Every 1 to 24 hours');
}
return $hours === 24 ? $m[1] : $m[1] . '/' . $hours;
}
/**
* The most recent scheduled moment at or before $now; null without a schedule.
*
* @param non-empty-string $zone
*/
public static function lastMoment(string $schedule, string $zone, int $now): ?int {
if ($schedule === '') {
return null;
}
$tz = new \DateTimeZone($zone);
$today = (new \DateTimeImmutable('@' . $now))->setTimezone($tz);
$moments = array_merge(self::moments($schedule, $today->modify('-1 day'), $tz), self::moments($schedule, $today, $tz));
$last = null;
foreach ($moments as $moment) {
if ($moment <= $now && ($last === null || $moment > $last)) {
$last = $moment;
}
}
return $last;
}
/** @return list<int> the moments of one day */
private static function moments(string $schedule, \DateTimeImmutable $day, \DateTimeZone $tz): array {
[$time, $every] = array_pad(explode('/', $schedule), 2, '24');
[$hour, $minute] = array_map('intval', explode(':', $time));
$moments = [];
for ($h = $hour; $h < 24; $h += (int)$every) {
$moments[] = (new \DateTimeImmutable($day->format('Y-m-d') . sprintf(' %02d:%02d', $h, $minute), $tz))->getTimestamp();
}
return $moments;
}
/**
* Whether a backup has to start for $moment. One that was made since then is enough, also when
* several moments went by during a long backup. A moment from before the schedule was set does
* not count: choosing a time must not start a backup right away.
*/
public static function isDue(int $moment, int $since, ?int $lastDoneStarted, int $attempts, ?int $lastAttemptFinished, int $now): bool {
if ($moment < $since || ($lastDoneStarted !== null && $lastDoneStarted >= $moment) || $attempts >= self::MAX_ATTEMPTS) {
return false;
}
return $attempts === 0 || $now - (int)$lastAttemptFinished >= self::RETRY_AFTER;
}
}
+128 -1
View File
@@ -11,6 +11,7 @@ use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\Repository\Repository;
use OCP\IAppConfig;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
@@ -33,6 +34,54 @@ final class PruneService {
) {
}
/** Characters of a note on a snapshot (the column holds 1000) */
public const NOTE_MAX = 500;
/** Pin or unpin a snapshot: the retention policy leaves a pinned one alone. */
public function setPinned(Target $target, string $snapshotId, bool $pinned): void {
$changed = $this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $pinned): bool {
$was = in_array($snapshotId, $repo->pinnedSnapshots(), true);
$repo->setPinned($snapshotId, $pinned);
return $was !== $pinned;
});
$this->targets->syncSnapshot($target, $snapshotId, pinned: $pinned);
if ($changed) {
// Unpinning opens the way to removing it, so every administrator hears about that.
$this->alerts->securityEvent($pinned ? 'snapshot_pinned' : 'snapshot_unpinned', ['target' => $target->getName(), 'snapshot' => $snapshotId], !$pinned);
}
}
public function setNote(Target $target, string $snapshotId, string $note): void {
$note = trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $note));
if (!mb_check_encoding($note, 'UTF-8')) {
throw new \InvalidArgumentException('A note must be valid UTF-8');
}
if (mb_strlen($note) > self::NOTE_MAX) {
throw new \InvalidArgumentException('A note has at most ' . self::NOTE_MAX . ' characters');
}
$this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $note): bool {
$repo->setSnapshotNote($snapshotId, $note);
return true;
});
$this->targets->syncSnapshot($target, $snapshotId, note: $note);
}
/**
* Write to the catalog next to a running backup or restore, which only takes it for a moment to
* commit. Not during a cleanup, which holds a repository opened earlier and writes from that.
*
* @param callable(Repository): bool $change
*/
private function changeCatalog(Target $target, callable $change): bool {
try {
return $this->leases->with(self::lockKey($target), LeaseService::SHARED, 300,
fn (): bool => $this->leases->with(self::catalogLockKey($target), LeaseService::EXCLUSIVE, 300,
fn (): bool => $change($this->targets->repository($target))));
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' or another change to its restore points is in progress; try again later');
}
}
public function untrash(Target $target, string $snapshotId): void {
$lease = $this->lockExclusive($target, 300);
try {
@@ -87,6 +136,7 @@ final class PruneService {
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
$s->setFiles((int)($meta['stats']['files'] ?? 0));
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
$s->setNote($repo->snapshotNotes()[$snapshotId] ?? null);
try {
$this->snapshots->insert($s);
} catch (\Throwable) {
@@ -98,6 +148,83 @@ final class PruneService {
return 'ng_backup/target/' . $target->getId();
}
/**
* A separate, short-lived exclusive lock serializing writes to the catalog itself. The
* per-target lock above only keeps readers/writers out while a cleanup runs; it does not
* stop two SHARED holders (e.g. two concurrent per-user backups, or one alongside a full
* backup) from both reading the same catalog generation and racing to write the next one,
* silently dropping whichever's change loses. The full-backup path avoids this today only
* because a Run row makes concurrent runs for the same target mutually exclusive; per-user
* backup/restore has no such row, so it takes this lock around the catalog write itself.
*/
public static function catalogLockKey(Target $target): string {
return self::lockKey($target) . '/catalog';
}
/** How many of each user's own exports (occ backup:user:backup) to keep, newest first. */
public function userExportRetention(): int {
return $this->appConfig->getValueInt(Application::APP_ID, 'user_export_retention', 3);
}
public function setUserExportRetention(int $keep): void {
if ($keep < 1) {
throw new \InvalidArgumentException('Keep at least the last export');
}
$old = $this->userExportRetention();
$this->appConfig->setValueInt(Application::APP_ID, 'user_export_retention', $keep);
if ($old !== $keep) {
$this->alerts->securityEvent('user_export_retention_changed', ['keep' => (string)$keep]);
}
}
/**
* Forgets a user's older exports once more than userExportRetention() exist for them,
* newest first; no trash, unlike a full snapshot (occ backup:user:backup is on-demand, not
* yet the primary safety net the deletion delay protects).
*
* @return array{kept: array<string, list<string>>, forgotten: list<string>}
*/
public function applyUserExports(Target $target, bool $dryRun = false): array {
if ($target->getAppendOnly()) {
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
}
$lease = $this->lockExclusive($target, 1800);
try {
$repo = $this->targets->repository($target);
$keep = $this->userExportRetention();
$byUser = [];
foreach ($repo->listUserExports() as $path) {
$uid = explode('/', $path)[1] ?? '';
$manifest = json_decode($repo->getObject($path), true, 512, JSON_THROW_ON_ERROR);
$byUser[$uid][$path] = (int)strtotime((string)($manifest['time'] ?? '@0'));
}
$kept = [];
$forget = [];
foreach ($byUser as $uid => $times) {
arsort($times);
$i = 0;
foreach (array_keys($times) as $path) {
if ($i++ < $keep) {
$kept[$uid][] = $path;
} else {
$forget[] = $path;
}
}
}
if (!$dryRun) {
foreach ($forget as $path) {
$repo->forgetUserExport($path);
}
if ($forget !== []) {
$this->alerts->securityEvent('user_exports_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], false);
}
}
return ['kept' => $kept, 'forgotten' => $forget];
} finally {
$this->leases->release($lease);
}
}
public function policy(): RetentionPolicy {
return RetentionPolicy::fromArray(json_decode($this->appConfig->getValueString(Application::APP_ID, 'retention', '{}'), true) ?: []);
}
@@ -131,7 +258,7 @@ final class PruneService {
$meta = $repo->snapshotMeta($id);
$times[$id] = (int)strtotime((string)($meta['time'] ?? '@0'));
}
$keep = $this->policy()->keep($times);
$keep = $this->policy()->keepWithPinned($times, $repo->pinnedSnapshots());
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
$purged = [];
+395
View File
@@ -0,0 +1,395 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\Db\DbDumper;
use OCA\NgBackup\Db\Target;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\Lock\LockedException;
/**
* Disaster recovery on a fresh Nextcloud installation (same major version): replace the
* database, restore the data directory and merge the backed-up config.php, see PLAN.md 3.7.
*
* Deliberately NOT handled yet (phase 3): restoring in place over a live, already-used
* installation. The admin is expected to run this once, right after occ maintenance:install and
* occ backup:key:import/backup:target:add, before anyone else touches the instance.
*/
final class RestoreFullService {
/**
* Config keys that must keep pointing at THIS installation's actual, already-working
* database and data directory (set by occ maintenance:install), never at the old server's.
*/
/** App setting: when a full restore started that has not completed (see restoreFull()). */
private const INTERRUPTED_KEY = 'restore_full_started';
private const CONNECTION_KEYS = [
'dbtype', 'dbhost', 'dbport', 'dbname', 'dbuser', 'dbpassword', 'dbpersistent',
'dbtableprefix', 'dbdriveroptions', 'datadirectory',
// set by restoreFull() itself for the length of the restore
'maintenance',
];
public function __construct(
private TargetService $targets,
private RestoreService $restore,
private LeaseService $leases,
private IConfig $config,
private IDBConnection $db,
private IAppManager $appManager,
private KeyService $keys,
) {
}
/** Apps that only exist in a particular setup and are never needed elsewhere. */
private const ENVIRONMENT_APPS = ['nextcloud-aio'];
/**
* Whether this installation can receive $snapshotId, all problems at once:
* - the same Nextcloud major version is required;
* - apps that were enabled at backup time but are not here (they work again once installed);
* - tables in the backup that do not exist here: a backup made from 1.0.0-rc.2 on records
* their structure and the restore creates them, an older one cannot (missingTables).
*
* With $salvage, a database dump that cannot be read does not block: dbUnreadable says why, and
* the restore then salvages the files instead (see restoreFull()).
*
* @return array{ok:bool, currentMajor:string, snapshotMajor:string, missingApps:list<string>, missingTables:list<string>, dbUnreadable?:string}
*/
public function checkCompatibility(Target $target, string $snapshotId, bool $skipMissingTables = false, bool $salvage = false): array {
$repo = $this->targets->repository($target);
$meta = $repo->snapshotMeta($snapshotId);
$currentMajor = explode('.', $this->config->getSystemValueString('version'))[0];
$snapshotMajor = explode('.', (string)($meta['nextcloud'] ?? ''))[0];
$enabled = array_flip($this->appManager->getEnabledApps());
$missingApps = array_values(array_diff(array_keys($meta['apps'] ?? []), array_keys($enabled), self::ENVIRONMENT_APPS));
$missingTables = [];
$prefixMismatch = null;
$dbUnreadable = null;
$manifest = null;
if (isset($meta['db'])) {
try {
$manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
if (!$salvage) {
throw $e;
}
$dbUnreadable = 'the database dump cannot be read: ' . $e->getMessage();
}
}
if (is_array($manifest)) {
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
$sourcePrefix = DbDumper::dumpPrefix($manifest);
if ($sourcePrefix !== null && $sourcePrefix !== $prefix) {
$prefixMismatch = [$sourcePrefix, $prefix];
}
$dumper = new DbDumper($this->db->getInner(), $prefix);
$missingTables = array_values(array_filter($dumper->missingTables($manifest),
fn (string $t) => !isset($manifest['tables'][$t]['schema']) && !str_starts_with($t, $prefix . 'ngb_')));
}
return [
'ok' => $currentMajor === $snapshotMajor && $prefixMismatch === null && ($missingTables === [] || $skipMissingTables),
'currentMajor' => $currentMajor,
'snapshotMajor' => $snapshotMajor,
'missingApps' => $missingApps,
'missingTables' => $prefixMismatch === null ? $missingTables : [],
'prefixMismatch' => $prefixMismatch,
] + ($dbUnreadable !== null ? ['dbUnreadable' => $dbUnreadable] : []);
}
/**
* With $salvage, restore whatever can be restored instead of stopping:
* - a file that cannot be read or decrypted is left out and listed (skipped);
* - when the database cannot be restored here (its dump is unreadable or damaged, or it does
* not fit this installation), the fresh database stays as it is, and the data directory and
* config of the backup go to a folder of their own (salvageDir) instead: the files are what
* matters most, and putting Nextcloud back together from there is work by hand;
* - a config.php that cannot be read is not merged (configError).
*
* @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool, missingApps:list<string>, maintenanceLeftOn:bool, skipped:array<string, string>, dbError:?string, salvageDir:?string, configError:?string}
*/
public function restoreFull(Target $target, string $snapshotId, bool $skipMissingTables = false, bool $salvage = false): array {
// Maintenance mode for the whole restore: background jobs (cron.php stops in maintenance
// mode), page visits (AJAX cron) and users must not write into tables or the data
// directory while they are being replaced; a row written in between would collide with a
// restored one or be silently lost. Switched off after a restore that completed. After an
// error it stays on: the instance may be partly restored, and must not be used like that.
// occ only offers app commands outside maintenance mode, so the admin switches it off
// right before running the restore again (see RestoreFull). Already on before: left on.
// On before the checks below, so that a run again after an error leaves the partly
// restored instance open for seconds at most; off again when they refuse (nothing changed).
// A marker in ng_backup's own app settings (which the restore keeps) says a restore started
// and did not complete: then the instance may be partly restored, and a check that refuses
// a run again must not open it.
$appConfig = \OCP\Server::get(\OCP\IAppConfig::class);
$interrupted = $appConfig->getValueInt(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY, 0) > 0;
$wasInMaintenance = $this->config->getSystemValueBool('maintenance', false);
$this->config->setSystemValue('maintenance', true);
$reopen = function () use ($wasInMaintenance, $interrupted): void {
if (!$wasInMaintenance && !$interrupted) {
$this->config->setSystemValue('maintenance', false);
}
};
try {
$check = $this->checkCompatibility($target, $snapshotId, $skipMissingTables, $salvage);
} catch (\Throwable $e) {
$reopen();
throw $e;
}
if (!$check['ok']) {
if (!$salvage) {
$reopen();
throw new \RuntimeException($this->describeBlockers($check));
}
// The database cannot go into this installation; the files can.
$check['dbUnreadable'] = 'the database cannot be restored here: ' . $this->describeBlockers($check);
}
if ($interrupted) {
// A restore that was killed leaves its leases, for up to two hours: they would keep
// the first backup of the recovered server waiting after this restore completes.
// In maintenance mode, on a partly restored instance, nothing else reads the location.
$this->leases->dropShared(PruneService::lockKey($target));
}
$appConfig->setValueInt(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY, time());
$result = $this->restoreInMaintenance($target, $snapshotId, $skipMissingTables, $check, $salvage);
$appConfig->deleteKey(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY);
if (!$wasInMaintenance) {
$this->config->setSystemValue('maintenance', false);
}
return $result + ['maintenanceLeftOn' => $wasInMaintenance];
}
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool, missingApps:list<string>, skipped:array<string, string>, dbError:?string, salvageDir:?string, configError:?string} */
private function restoreInMaintenance(Target $target, string $snapshotId, bool $skipMissingTables, array $check, bool $salvage): array {
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $skipMissingTables, $check, $salvage) {
$beat = LeaseService::throttled($refresh);
$repo = $this->targets->repository($target);
$meta = $repo->snapshotMeta($snapshotId);
$dbReport = null;
$manifest = [];
$dbError = $check['dbUnreadable'] ?? null;
$skipped = $salvage ? [] : null;
$dataDir = rtrim($this->config->getSystemValueString('datadirectory'), '/');
if ($dbError === null) {
try {
$this->restoreDatabase($repo, $meta, $manifest, $dbReport, $beat, $skipMissingTables);
} catch (DatabaseAlreadyRestoredException $e) {
throw $e;
} catch (\Throwable $e) {
if (!$salvage) {
throw $e;
}
// DbDumper::restore() rolled its transaction back: the database is the fresh one.
$dbError = 'the database could not be restored: ' . $e->getMessage();
$dbReport = null;
}
}
if ($dbError !== null) {
$salvageDir = $dataDir . '/ngb-salvage-' . substr($snapshotId, 0, 12);
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $salvageDir . '/data', stripPrefix: true, heartbeat: $beat, skipped: $skipped, keepUnchanged: true);
$this->restore->restoreToDirectory($target, $snapshotId, 'config', $salvageDir . '/config', stripPrefix: true, heartbeat: $beat, skipped: $skipped);
return ['dbReport' => ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => 0,
'dbConsistent' => true, 'missingApps' => $check['missingApps'], 'skipped' => $skipped ?? [], 'dbError' => $dbError,
'salvageDir' => $salvageDir, 'configError' => null];
}
// Read (but do not yet apply) the backed-up config.php before touching the data
// directory: once applied, 'secret' may differ from what ng_backup's own Target
// options were encrypted with (see restoreFull()'s docblock), and every further
// repository access through $this->targets would fail to decrypt them.
$configError = null;
try {
/** @var array<string, mixed> $backupConfig */
$backupConfig = $this->fetchBackupConfig($target, $snapshotId, $salvage);
} catch (\OCA\NgBackup\Repository\RestoreTargetException $e) {
throw $e; // nowhere to put it: not a config.php that cannot be read
} catch (\Throwable $e) {
if (!$salvage) {
throw $e;
}
$configError = $e->getMessage();
$backupConfig = [];
}
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $dataDir, stripPrefix: true, heartbeat: $beat, skipped: $skipped, keepUnchanged: true);
// Last step: nothing after this may need $this->targets->repository($target) again.
if ($backupConfig !== []) {
$this->targets->reencryptOptionsAround(
fn (): mixed => $this->keys->reencryptForSecretRotation(fn (): mixed => $this->config->setSystemValues($backupConfig)),
);
}
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
'dbConsistent' => (bool)($manifest['consistent'] ?? true), 'missingApps' => $check['missingApps'],
'skipped' => $skipped ?? [], 'dbError' => null, 'salvageDir' => null, 'configError' => $configError];
});
}
/**
* Replace the database with the snapshot's dump (one transaction, see DbDumper::restore()).
*
* @param array<string, mixed> $meta the snapshot's meta record
* @param array $manifest set to the dump's manifest
*/
private function restoreDatabase(\OCA\NgBackup\Repository\Repository $repo, array $meta, array &$manifest, ?array &$dbReport, callable $beat, bool $skipMissingTables): void {
if (!isset($meta['db'])) {
return;
}
$manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
// ng_backup's own tables (and its own rows in the shared appconfig table) must
// survive this restore untouched: they describe the backup/key/target that is
// actively performing it right now, on THIS installation, not the one that was
// backed up. Overwriting them mid-restore would pull the rug from under it.
foreach (array_keys($manifest['tables']) as $table) {
/** @var string $table */
if (str_starts_with($table, $prefix . 'ngb_')) {
unset($manifest['tables'][$table]);
}
}
// Sequences are keyed separately from tables; excluding a table does not exclude
// its sequence, which would otherwise rewind a retained ngb_* table's sequence
// below its current ids (PostgreSQL only -- see DbDumper::restoreSequences()).
foreach (array_keys($manifest['sequences'] ?? []) as $seq) {
/** @var string $seq */
if (str_starts_with((string)($manifest['sequences'][$seq]['table'] ?? ''), $prefix . 'ngb_')) {
unset($manifest['sequences'][$seq]);
}
}
$ownAppConfig = $this->preserveOwnAppConfig();
$dumper = new DbDumper($this->db->getInner(), $prefix);
try {
$dumper->restore($repo, $manifest, $dbReport, $beat, $skipMissingTables);
} catch (\Throwable $e) {
try {
$this->restoreOwnAppConfig($ownAppConfig);
} catch (\Throwable) {
// rolled back, so they are still there: the error that brought us here is the one to report
}
throw $e;
}
try {
$this->restoreOwnAppConfig($ownAppConfig);
} catch (\Throwable $e) {
// The old server's database is in place by now: not a case for "the database stays as
// the fresh installation made it" under --salvage. A run again restores these rows.
throw new DatabaseAlreadyRestoredException('The database was restored, but NG Backup could not put back its own settings: ' . $e->getMessage(), 0, $e);
}
}
/**
* @return array<string, mixed> config.php's $CONFIG from the backup, minus the connection keys
* @throws \RuntimeException with $salvage, when config.php itself cannot be read
*/
private function fetchBackupConfig(Target $target, string $snapshotId, bool $salvage = false): array {
$tmp = sys_get_temp_dir() . '/ngb-restore-config-' . bin2hex(random_bytes(6));
try {
$skipped = $salvage ? [] : null;
$this->restore->restoreToDirectory($target, $snapshotId, 'config', $tmp, stripPrefix: true, skipped: $skipped);
if (isset($skipped['config/config.php'])) {
throw new \RuntimeException('config.php cannot be read: ' . $skipped['config/config.php']);
}
$path = $tmp . '/config.php';
if (!is_file($path)) {
return []; // nothing to merge; the live config.php stays as occ maintenance:install made it
}
$backupConfig = (static function () use ($path): array {
$CONFIG = [];
include $path;
return $CONFIG;
})();
foreach (self::CONNECTION_KEYS as $key) {
unset($backupConfig[$key]);
}
return $backupConfig;
} finally {
$this->rmrf($tmp);
}
}
/** @return list<array<string, mixed>> raw appconfig rows (appid, configkey, configvalue, type, lazy) */
private function preserveOwnAppConfig(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from('appconfig')
->where($qb->expr()->eq('appid', $qb->createNamedParameter(\OCA\NgBackup\AppInfo\Application::APP_ID)));
/** @var list<array<string, mixed>> */
return $qb->executeQuery()->fetchAll();
}
/**
* @param list<array<string, mixed>> $rows raw appconfig rows, as returned by preserveOwnAppConfig()
*
* Runs in its own transaction (DbDumper::restore() already committed its own by the time
* this is called): not a full guarantee against a crash spanning both, but means this step
* itself cannot leave ng_backup's appconfig rows half deleted, half reinserted.
*/
private function restoreOwnAppConfig(array $rows): void {
$this->db->beginTransaction();
try {
$qb = $this->db->getQueryBuilder();
$qb->delete('appconfig')
->where($qb->expr()->eq('appid', $qb->createNamedParameter(\OCA\NgBackup\AppInfo\Application::APP_ID)));
$qb->executeStatement();
foreach ($rows as $row) {
$ins = $this->db->getQueryBuilder();
$ins->insert('appconfig')->values([
'appid' => $ins->createNamedParameter($row['appid']),
'configkey' => $ins->createNamedParameter($row['configkey']),
'configvalue' => $ins->createNamedParameter($row['configvalue']),
'type' => $ins->createNamedParameter($row['type'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'lazy' => $ins->createNamedParameter($row['lazy'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
]);
$ins->executeStatement();
}
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
private function rmrf(string $dir): void {
if (!is_dir($dir)) {
return;
}
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $f) {
$f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
}
rmdir($dir);
}
private function describeBlockers(array $check): string {
$parts = [];
if ($check['currentMajor'] !== $check['snapshotMajor']) {
$parts[] = "this installation is Nextcloud {$check['currentMajor']}, the backup is from {$check['snapshotMajor']} (same major version required)";
}
if (($check['prefixMismatch'] ?? null) !== null) {
[$source, $here] = $check['prefixMismatch'];
$parts[] = "the backup's tables use the prefix '$source', this installation '$here': install Nextcloud again with 'dbtableprefix' => '$source' in config/config.php before occ maintenance:install, and restore again";
}
if ($check['missingTables'] !== []) {
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
$parts[] = DbDumper::describeMissing($check['missingTables'], $prefix);
}
return implode("\n", $parts);
}
private function withSharedLock(Target $target, callable $fn): mixed {
try {
// Refreshed during the database and data restore (per row batch, per blob), so a
// cleanup cannot take over the location while a long restore is still reading it.
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 7200, $fn);
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
}
}
}
+111 -14
View File
@@ -33,6 +33,9 @@ final class RestoreService {
public const MODE_MERGE = 'merge';
public const MODE_REPLACE = 'replace';
/** @var array<string, true> folders of the current restore already cleared of stale temporaries */
private array $cleaned = [];
public function __construct(
private TargetService $targets,
private IRootFolder $root,
@@ -58,7 +61,8 @@ final class RestoreService {
$prefix = trim($path, '/');
$prefix = $prefix === '' ? '' : $prefix . '/';
$children = [];
foreach ($this->targets->repository($target)->entries($snapshotId) as $e) {
$repo = $this->targets->repository($target);
foreach ($repo->entries($snapshotId) as $e) {
if ($prefix !== '' && !str_starts_with($e['p'], $prefix)) {
continue;
}
@@ -72,6 +76,12 @@ final class RestoreService {
}
$children[$name] = $c;
}
foreach ($repo->emptyDirs($snapshotId) as [$dir]) { // read along with the entries
if ($prefix === '' || str_starts_with($dir, $prefix)) {
$name = explode('/', substr($dir, strlen($prefix)), 2)[0];
$children[$name] ??= ['name' => $name, 'type' => 'dir', 'size' => 0, 'mtime' => null, 'files' => 0];
}
}
ksort($children, SORT_STRING);
return array_values($children);
}
@@ -81,8 +91,8 @@ final class RestoreService {
*
* @return array{restored:int, trashed:int, target:string}
*/
public function restoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode = self::MODE_NEW_FOLDER, ?callable $progress = null): array {
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $logicalPath, $mode, $progress) {
public function restoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode = self::MODE_NEW_FOLDER, ?callable $progress = null, ?array &$skipped = null): array {
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $logicalPath, $mode, $progress, &$skipped) {
$last = time();
// Keep the lease alive while files are being written.
$beat = function (int $done, int $total, string $name) use ($refresh, $progress, &$last): void {
@@ -94,11 +104,12 @@ final class RestoreService {
$progress($done, $total, $name);
}
};
return $this->doRestoreUserFiles($target, $snapshotId, $logicalPath, $mode, $beat);
return $this->doRestoreUserFiles($target, $snapshotId, $logicalPath, $mode, $beat, $skipped);
});
}
private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress): array {
/** @param array<string, string>|null $skipped see Repository::restore() */
private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress, ?array &$skipped): array {
if (!Repository::isSafePath($logicalPath) || !preg_match('#^data/([^/]+)/files(?:/(.*))?$#', $logicalPath, $m)) {
throw new \InvalidArgumentException('Expected a path like data/<user>/files/<folder or file>');
}
@@ -119,7 +130,17 @@ final class RestoreService {
$entries[substr($e['p'], strlen($source) + 1)] = $e;
}
}
if ($entries === []) {
// Folders without anything in them, relative to $source ('' when $source itself is one).
/** @var array<array-key, int> $emptyDirs modification times; a folder called "2026" is an integer key */
$emptyDirs = [];
foreach ($single ? [] : $repo->emptyDirs($snapshotId) as [$dir, $mtime]) {
if ($dir === $source) {
$emptyDirs[''] = $mtime;
} elseif (str_starts_with($dir, $source . '/')) {
$emptyDirs[substr($dir, strlen($source) + 1)] = $mtime;
}
}
if ($entries === [] && $emptyDirs === []) {
throw new NotFoundException("Nothing at $source in snapshot $snapshotId");
}
@@ -130,39 +151,102 @@ final class RestoreService {
} elseif ($mode !== self::MODE_MERGE && $mode !== self::MODE_REPLACE) {
throw new \InvalidArgumentException("Unknown mode $mode");
}
$destExisted = $parentRel === '' || $userFolder->nodeExists($parentRel);
$dest = $this->ensureFolder($userFolder, $parentRel);
$names = $single ? [basename($rel) => $entries['']] : $entries;
// The folders with something in them that this restore creates get their time back; one that
// was already there keeps its own, as its content changes now. Noted before anything is written.
/** @var array<array-key, int> $newFolders a folder called "2026" is an integer key */
$newFolders = [];
foreach ($single ? [] : $repo->folderTimes($snapshotId) as [$dir, $mtime]) {
$relative = $dir === $source ? '' : (str_starts_with($dir, $source . '/') ? substr($dir, strlen($source) + 1) : null);
if ($relative !== null && ($relative === '' ? !$destExisted : !$dest->nodeExists($relative))) {
$newFolders[$relative] = $mtime;
}
}
$this->cleaned = [];
$restored = 0;
foreach ($names as $relPath => $entry) {
$this->writeFile($repo, $dest, (string)$relPath, $entry);
try {
$this->writeFile($repo, $dest, (string)$relPath, $entry);
} catch (\OCA\NgBackup\Repository\RepositoryException|\OCA\NgBackup\Backend\BackendException|\OCA\NgBackup\Crypto\CryptoException $e) {
// Damaged or missing on the location (writeFile removed its temporary)
if ($skipped === null) {
throw $e;
}
$skipped[$entry['p']] = $e->getMessage();
continue;
}
$restored++;
if ($progress !== null) {
$progress($restored, count($names), (string)$relPath);
}
}
foreach ($emptyDirs as $dir => $mtime) {
$dir = (string)$dir;
$existed = $dir === '' ? $destExisted : $dest->nodeExists($dir); // one that is there keeps its own time
$folder = $this->ensureFolder($dest, $dir);
if (!$existed) {
$folder->touch($mtime);
}
if ($progress !== null) {
$progress($restored, count($names), $dir); // keeps the lease alive too
}
}
$trashed = 0;
if ($mode === self::MODE_REPLACE && !$single) {
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)));
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)) + $emptyDirs);
}
// Last and deepest first: a change in a folder also touches the folders above it.
foreach (array_reverse($newFolders, true) as $dir => $mtime) {
$this->ensureFolder($dest, (string)$dir)->touch($mtime);
if ($progress !== null) {
$progress($restored, count($names), (string)$dir); // keeps the lease alive too
}
}
return ['restored' => $restored, 'trashed' => $trashed, 'target' => $uid . ':' . $userFolder->getRelativePath($dest->getPath())];
}
/** Raw restore of any snapshot path to a local directory (administrators, disaster recovery). */
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory): int {
/**
* Raw restore of any snapshot path to a local directory (administrators, disaster recovery).
*
* @param bool $stripPrefix write into $directory itself instead of $directory/$prefix (see
* Repository::restore()); used to restore "data/" or "config/" straight into the
* real data/config directory for occ backup:restore:full.
* @param bool $keepUnchanged leave files already in place with the snapshot's size and
* modification time (see Repository::restore()): a full restore that runs again
* goes on where it stopped
*/
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory, bool $stripPrefix = false, ?callable $heartbeat = null, ?array &$skipped = null, bool $keepUnchanged = false): int {
if ($prefix !== '' && !Repository::isSafePath($prefix)) {
throw new \InvalidArgumentException('Invalid path: ' . $prefix);
}
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create $directory");
throw new \OCA\NgBackup\Repository\RestoreTargetException("Cannot create $directory (disk full, or no permission?)");
}
return $this->withSharedLock($target, fn () => $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/'), 3600);
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $prefix, $directory, $stripPrefix, $heartbeat, &$skipped, $keepUnchanged): int {
$own = LeaseService::throttled($refresh);
$beat = static function () use ($own, $heartbeat): void {
$own();
if ($heartbeat !== null) {
$heartbeat();
}
};
return $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/', $stripPrefix, $beat, $skipped, $keepUnchanged);
}, 3600);
}
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
$dir = dirname($relPath);
$folder = $dir === '.' ? $dest : $this->ensureFolder($dest, $dir);
if (!isset($this->cleaned[$folder->getPath()])) {
$this->removeStaleTemporaries($folder);
$this->cleaned[$folder->getPath()] = true;
}
$name = basename($relPath);
// Stage under a temporary name; only rename into place after every blob was verified.
$tmpName = '.' . $name . '.ngb-restore-' . bin2hex(random_bytes(3));
@@ -180,7 +264,7 @@ final class RestoreService {
throw new \RuntimeException('Write failed for ' . $relPath);
}
if ($written !== $entry['s']) {
throw new \RuntimeException("Size mismatch restoring $relPath");
throw new \OCA\NgBackup\Repository\RepositoryException("Size mismatch restoring $relPath");
}
if ($folder->nodeExists($name)) {
$existing = $folder->get($name);
@@ -208,12 +292,25 @@ final class RestoreService {
}
}
/** See Repository::removeStaleTemporaries(): what a restore that was stopped left in this folder. */
private function removeStaleTemporaries(Folder $folder): void {
foreach ($folder->getDirectoryListing() as $node) {
if ($node instanceof File && Repository::isRestoreTemporary($node->getName())) {
try {
$node->delete();
} catch (\Throwable) {
// in the way of nothing: the new temporary gets a name of its own
}
}
}
}
private function trashExtras(Folder $folder, string $prefix, array $keep): int {
$count = 0;
foreach ($folder->getDirectoryListing() as $node) {
$rel = $prefix === '' ? $node->getName() : $prefix . '/' . $node->getName();
if ($node instanceof Folder) {
$hasKept = false;
$hasKept = isset($keep[$rel]); // a folder that was empty in the snapshot
foreach (array_keys($keep) as $k) {
if (str_starts_with((string)$k, $rel . '/')) {
$hasKept = true;
+13
View File
@@ -28,6 +28,19 @@ final class RetentionPolicy {
return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly];
}
/**
* keep() for the snapshots that are not pinned, plus the pinned ones. A pinned snapshot does not
* take the place of a daily, weekly or monthly one.
*
* @param array<string, int> $snapshots id => unix time
* @param list<string> $pinned
* @return array<string, list<string>>
*/
public function keepWithPinned(array $snapshots, array $pinned): array {
$pins = array_intersect_key($snapshots, array_flip($pinned));
return $this->keep(array_diff_key($snapshots, $pins)) + array_fill_keys(array_keys($pins), ['pinned']);
}
/**
* @param array<string, int> $snapshots id => unix time
* @return array<string, list<string>> id => reasons it is kept (ids not in the result are removed)
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IGroupManager;
/**
* The time zone times are shown in, and the daily time from before each location had its own
* schedule (see LocationSchedule). Nextcloud runs PHP in UTC, so a time without a zone meant UTC
* (issue #19: 03:00 ran at 05:00 in Berlin). A time saved before there was a zone is converted once
* to the zone of the server or of an administrator, at today's offset: it then shows as it
* effectively ran (02:30 UTC becomes 04:30 Europe/Berlin in summer).
*/
final class Schedule {
public const TIME_KEY = 'schedule_time';
public const ZONE_KEY = 'schedule_timezone';
public function __construct(
private IAppConfig $appConfig,
private IConfig $config,
private IGroupManager $groups,
) {
}
/** @return array{time: string, timezone: non-empty-string} time "" = no schedule */
public function get(): array {
$time = $this->appConfig->getValueString(Application::APP_ID, self::TIME_KEY, '');
$zone = $this->appConfig->getValueString(Application::APP_ID, self::ZONE_KEY, '');
if ($zone === '' || !self::validZone($zone)) {
$zone = $this->guessZone();
if ($time !== '' && self::validTime($time)) {
$time = self::convert($time, 'UTC', $zone);
$this->appConfig->setValueString(Application::APP_ID, self::TIME_KEY, $time);
}
$this->appConfig->setValueString(Application::APP_ID, self::ZONE_KEY, $zone);
}
return ['time' => $time, 'timezone' => $zone];
}
/** @return non-empty-string the zone of a location's schedule, or else this one */
public function zoneOr(?string $zone): string {
return $zone !== null && self::validZone($zone) ? $zone : $this->get()['timezone'];
}
/**
* "HH:MM" in zone $from as "HH:MM" in zone $to, at today's offsets.
*
* @param non-empty-string $from
* @param non-empty-string $to
*/
public static function convert(string $time, string $from, string $to, ?int $now = null): string {
$day = (new \DateTimeImmutable('@' . ($now ?? time())))->setTimezone(new \DateTimeZone($from))->format('Y-m-d');
return (new \DateTimeImmutable($day . ' ' . $time, new \DateTimeZone($from)))->setTimezone(new \DateTimeZone($to))->format('H:i');
}
public static function validTime(string $time): bool {
return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time) === 1;
}
/** @psalm-assert-if-true non-empty-string $zone */
public static function validZone(string $zone): bool {
return in_array($zone, \DateTimeZone::listIdentifiers(), true);
}
/**
* The server's default_timezone when set, else the first administrator's, else UTC. A server
* zone of UTC is passed over on purpose: it is mostly what an image or installer left there,
* while the administrator who set the time read it as their own (#19).
*
* @return non-empty-string
*/
private function guessZone(): string {
$zone = $this->config->getSystemValueString('default_timezone', '');
if ($zone !== '' && $zone !== 'UTC' && self::validZone($zone)) {
return $zone;
}
foreach ($this->groups->get('admin')?->getUsers() ?? [] as $user) {
$zone = $this->config->getUserValue($user->getUID(), 'core', 'timezone', '');
if ($zone !== '' && self::validZone($zone)) {
return $zone;
}
}
return 'UTC';
}
}
+395 -21
View File
@@ -10,10 +10,14 @@ namespace OCA\NgBackup\Service;
use OCA\NgBackup\Backend\ExternalStorageFactory;
use OCA\NgBackup\Backend\IBackend;
use OCA\NgBackup\Backend\StorageBackend;
use OCA\NgBackup\Db\Snapshot;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\Db\TargetMapper;
use OCA\NgBackup\Repository\Repository;
use OCP\App\IAppManager;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Files\Storage\IStorage;
use OCP\Security\ICrypto;
use OCP\Server;
@@ -23,6 +27,9 @@ use OCP\Server;
* classes but its enabled/disabled state is left as the admin set it.
*/
final class TargetService {
/** Deletion delay taken when a location's own cannot be read: nothing leaves the Bin sooner than on the old server. */
public const LONGEST_DELETE_DELAY = 365;
public function __construct(
private TargetMapper $mapper,
private KeyService $keys,
@@ -30,10 +37,13 @@ final class TargetService {
private IAppManager $appManager,
private \OCA\NgBackup\Db\DbIndexCache $indexCache,
private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor,
private SnapshotMapper $snapshots,
private AlertService $alerts,
private \OCA\NgBackup\Db\RunMapper $runs,
) {
}
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
/** @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array}> */
public function availableBackends(): array {
return $this->factory()->describeBackends();
}
@@ -43,26 +53,34 @@ final class TargetService {
* installation's key), then save it.
*
* @param array<string, mixed> $options
* @param ?callable(int, int): void $progress reading an existing repository's index: files done, and in all
* @return array{target: Target, created: bool}
*/
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
}
try {
$this->mapper->findByName($name);
throw new \InvalidArgumentException("A target named '$name' already exists");
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
}
public function add(string $name, string $backend, string $auth, array $options, string $basePath, ?callable $progress = null, bool $repairConfig = false): array {
$this->validateName($name);
$this->requireUsableBackend($backend);
$keys = $this->keys->keyRing();
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
if ($storageBackend->exists('config')) {
$repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor);
$created = false;
} else {
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
$created = true;
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, !$repairConfig);
try {
if ($repairConfig) {
Repository::repairConfig($storageBackend, $keys, $this->keys->wrappedKey());
}
if ($storageBackend->exists('config')) {
$repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor, $progress);
$created = false;
} else {
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
$created = true;
}
} catch (\OCA\NgBackup\Repository\RepositoryException $e) {
$otherKey = str_contains($e->getMessage(), 'different key') || str_contains($e->getMessage(), 'do not open with this key');
if ($otherKey && $this->keyIsUnused()) {
throw new \OCA\NgBackup\Repository\RepositoryException(rtrim($e->getMessage(), '.') . '. ' . self::UNUSED_KEY_HINT, 0, $e);
}
throw $e;
}
$this->keys->markUsed();
$this->recordSettings($repo);
foreach ($this->mapper->findAll() as $existing) {
if ($existing->getRepositoryId() === $repo->id()) {
@@ -78,7 +96,307 @@ final class TargetService {
$target->setBasePath(trim($basePath, '/'));
$target->setRepositoryId($repo->id());
$target->setCreatedAt(time());
return ['target' => $this->mapper->insert($target), 'created' => $created];
// Like the location before it: a backup scheme is usually one scheme.
$first = $this->mapper->findAll()[0] ?? null;
$target->setSchedule($first?->getSchedule() ?? '');
$target->setScheduleZone($first?->getScheduleZone());
$target->setScheduleSince(time());
$target = $this->mapper->insert($target);
if (!$created) {
// Disaster recovery: occ backup:list reads this local cache, not the repository
// directly, so a snapshot made by another installation must be backfilled here too.
$this->adoptSnapshots($target, $repo);
}
return ['target' => $target, 'created' => $created];
}
public const UNUSED_KEY_HINT = 'The backup key of this server was never used (no location, no backup), so it can make way for the key of these backups: '
. 'with the old server\'s recovery kit, occ backup:key:import <kit file> --replace-unused-key; without the kit, '
. 'occ backup:target:add with the same details and --replace-unused-key (asks for a passphrase of the old server)';
/**
* A key that never protected anything: no location was created or adopted with it, and the
* database has no location and no run (installations from before the flag existed). Only such
* a key may make way for the key of existing backups, e.g. after a new key was made by
* mistake on a server that is being restored.
*/
public function keyIsUnused(): bool {
return $this->keys->isInitialized() && !$this->keys->wasUsed()
&& $this->mapper->findAll() === [] && $this->runs->countAll() === 0;
}
/**
* occ backup:key:import on a server whose own key was never used: the kit's key takes its place.
*
* @param array<string, mixed> $kit
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase: nothing changed
*/
public function importKitReplacingUnusedKey(array $kit, #[\SensitiveParameter] string $passphrase): void {
$old = $this->requireUnusedKey();
$this->keys->importRecoveryKit($kit, $passphrase, true);
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
}
/** @return string the fingerprint of the unused key */
private function requireUnusedKey(): string {
if (!$this->keys->isInitialized()) {
throw new \RuntimeException('There is no backup key here to replace');
}
if (!$this->keyIsUnused()) {
throw new \RuntimeException('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced');
}
return $this->keys->fingerprint();
}
public const LOCATION_REPOSITORY = 'repository';
public const LOCATION_DATA_WITHOUT_CONFIG = 'data-without-config';
public const LOCATION_EMPTY = 'empty';
/**
* What these settings lead to: a repository, backup data whose config is gone, or nothing
* of NG Backup. Creates nothing.
*
* @return self::LOCATION_*
*/
public function locationState(string $backend, string $auth, array $options, string $basePath): string {
$this->requireUsableBackend($backend);
$storage = $this->buildBackend($backend, $auth, $options, $basePath, false);
if ($storage->exists('config')) {
return self::LOCATION_REPOSITORY;
}
return Repository::holdsData($storage) ? self::LOCATION_DATA_WITHOUT_CONFIG : self::LOCATION_EMPTY;
}
/**
* Disaster recovery without the recovery kit, on an installation without a backup key: take
* the key from the location's config with one of its passphrases, then add the location as
* usual. The key is only kept when it really is the one the repository was made with.
* With $replaceUnusedKey, a key this server has but never used makes way (keyIsUnused()).
*
* @param array<string, mixed> $options
* @param ?callable(int, int): void $progress
* @return array{target: Target, created: bool, deleteDelayDays: ?int, settingsUnreadable: bool} the deletion delay the location had stored, if any, and whether its settings could not be read
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase
*/
public function addWithPassphrase(string $name, string $backend, string $auth, array $options, string $basePath,
#[\SensitiveParameter] string $passphrase, ?callable $progress = null, bool $replaceUnusedKey = false): array {
$old = null;
if ($replaceUnusedKey) {
$old = $this->requireUnusedKey();
} elseif ($this->keys->isInitialized()) {
throw new \RuntimeException('A backup key already exists here');
}
$this->validateName($name);
$this->requireUsableBackend($backend);
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
if (!$storageBackend->exists('config')) {
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'");
}
$wrapped = Repository::wrappedKeyAt($storageBackend);
$keys = \OCA\NgBackup\Crypto\KeyRing::unwrapAny($wrapped, $passphrase);
if (!Repository::madeWithKey($storageBackend, $keys)) {
throw new \RuntimeException('The key in the config at this location does not open its backups: the config is damaged or was replaced. Only the recovery kit can help now: occ backup:key:import <kit file>, then this command again.');
}
$settings = Repository::settingsAt($storageBackend, $keys);
$stored = $settings['delete_delay_days'] ?? null;
$delay = is_int($stored) && $stored >= 1 && $stored <= 365 ? $stored : null;
// Settings that are there but cannot be read, or hold no valid delay (also none at all): the old server's delay
// is unknown, so take the longest one; a shorter guess could empty the Bin too soon.
$unknown = ($settings['unreadable'] ?? false) === true || ($settings !== [] && $delay === null);
$this->keys->adoptKey($keys, $wrapped, $delay ?? ($unknown ? self::LONGEST_DELETE_DELAY : null), $replaceUnusedKey);
if ($old !== null) {
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
}
return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay, 'settingsUnreadable' => $unknown];
}
/** A passphrase change that missed this location (offline at the time) reaches it now. A failure is tried again at the next backup. */
public function catchUpWrappedKey(Target $target, Repository $repo): void {
if (!$this->keys->wrappedKeyPending($target->getId())) {
return;
}
try {
$repo->updateWrappedKey($this->keys->wrappedKey());
$this->keys->setWrappedKeyPending($target->getId(), false);
} catch (\Throwable $e) {
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: the last passphrase change still does not reach ' . $target->getName()
. ' (it keeps the previous passphrases); tried again at its next backup: ' . $e->getMessage(), ['exception' => $e]);
}
}
/** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */
public function recordSettings(Repository $repo): void {
try {
$repo->recordSettings(['delete_delay_days' => $this->keys->deleteDelayDays()]);
} catch (\Throwable) {
}
}
/**
* Change how an existing location is reached (credentials, host, folder, even the connection
* type) or its name. The new settings must lead to the same repository: the history stays
* and a typo cannot silently start a new, empty repository. A null option value keeps the
* stored one, so the web interface never has to send stored passwords back.
*
* @param array<string, mixed> $options
*/
public function update(Target $target, string $name, string $backend, string $auth, array $options, string $basePath): Target {
$this->validateName($name, $target->getId());
$this->requireUsableBackend($backend);
$stored = $this->options($target);
foreach ($options as $key => $value) {
if ($value === null) {
if (array_key_exists($key, $stored)) {
$options[$key] = $stored[$key];
} else {
unset($options[$key]);
}
}
}
// Do not create the folder: a typo in the path must not leave an empty folder behind.
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
if (!$storageBackend->exists('config')) {
if (!$storageBackend->canConnect()) {
throw new \InvalidArgumentException('Cannot connect with these settings. Check the host, user name and password');
}
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'. To start an empty repository, add a new location instead");
}
$repo = Repository::openWithKey($storageBackend, $this->keys->keyRing(), $this->indexCache, $this->anchor);
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
throw new \InvalidArgumentException('These settings lead to a different repository than the one of this location');
}
$target->setName($name);
$target->setBackend($backend);
$target->setAuth($auth);
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
$target->setBasePath(trim($basePath, '/'));
return $this->mapper->update($target);
}
/**
* The stored options for an edit form: password-type values are replaced by null, which
* update() reads as "keep the stored value".
*
* @return array<string, mixed>
*/
public function editableOptions(Target $target): array {
$secret = [];
foreach ($this->availableBackends() as $b) {
if ($b['id'] !== $target->getBackend()) {
continue;
}
$params = $b['parameters'];
foreach ($b['auth'] as $a) {
if ($a['id'] === $target->getAuth()) {
$params += $a['parameters'];
}
}
foreach ($params as $key => $p) {
if (($p['type'] ?? 0) === 2) {
$secret[$key] = true;
}
}
}
$options = $this->options($target);
foreach ($options as $key => $value) {
if (isset($secret[$key])) {
$options[$key] = null;
}
}
return $options;
}
private function validateName(string $name, ?int $ownId = null): void {
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
}
try {
$other = $this->mapper->findByName($name);
} catch (DoesNotExistException) {
return;
}
if ($other->getId() !== $ownId) {
throw new \InvalidArgumentException("A target named '$name' already exists");
}
}
private function requireUsableBackend(string $backend): void {
// Without the native extension files_external falls back to the smbclient binary, which
// registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
// up front instead of letting the admin find out from a failed run.
if ($backend === 'smb' && !extension_loaded('smbclient')) {
throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
}
}
/**
* A target's options are encrypted with Nextcloud's own instance secret (ICrypto), not
* ng_backup's key, so changing that secret (occ backup:restore:full merging config.php)
* would otherwise leave every target permanently undecryptable. Call $applySecretChange
* (which must be the only thing that changes the secret) through here instead: every
* target's options are decrypted first, then re-encrypted under whatever secret is active
* once $applySecretChange returns.
*/
public function reencryptOptionsAround(callable $applySecretChange): mixed {
$plain = [];
foreach ($this->mapper->findAll() as $target) {
$plain[$target->getId()] = $this->options($target);
}
$result = $applySecretChange();
foreach ($this->mapper->findAll() as $target) {
if (!isset($plain[$target->getId()])) {
continue; // added by $applySecretChange itself; nothing to re-encrypt
}
$target->setOptions($this->crypto->encrypt(json_encode($plain[$target->getId()], JSON_THROW_ON_ERROR)));
$this->mapper->update($target);
}
return $result;
}
/** Update the local copy of a snapshot's note or pin, if the snapshot is listed here at all. */
public function syncSnapshot(Target $target, string $snapshotId, ?string $note = null, ?bool $pinned = null): void {
try {
$s = $this->snapshots->findOne($target->getId(), $snapshotId);
} catch (DoesNotExistException) {
return;
}
if ($note !== null) {
$s->setNote($note !== '' ? $note : null);
}
if ($pinned !== null) {
$s->setPinned($pinned);
}
$this->snapshots->update($s);
}
private function adoptSnapshots(Target $target, Repository $repo): void {
$notes = $repo->snapshotNotes();
$pinned = array_flip($repo->pinnedSnapshots());
foreach ($repo->listSnapshots() as $snapshotId) {
try {
$this->snapshots->findOne($target->getId(), $snapshotId);
continue; // already known locally
} catch (DoesNotExistException) {
}
// One unreadable/corrupt snapshot must not stop the rest of the location's
// snapshots from being backfilled, nor abort adding the target itself.
try {
$meta = $repo->snapshotMeta($snapshotId);
$s = new Snapshot();
$s->setTargetId($target->getId());
$s->setSnapshotId($snapshotId);
$s->setKind((string)($meta['kind'] ?? 'full'));
$s->setLabel(($meta['label'] ?? '') !== '' ? $meta['label'] : null);
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
$s->setFiles((int)($meta['stats']['files'] ?? 0));
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
$s->setNote($notes[$snapshotId] ?? null);
$s->setPinned(isset($pinned[$snapshotId]));
$this->snapshots->insert($s);
} catch (\Throwable $e) {
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: snapshot ' . $snapshotId . ' on ' . $target->getName() . ' not listed: ' . $e->getMessage(), ['exception' => $e]);
}
}
}
/** @return list<Target> */
@@ -87,10 +405,38 @@ final class TargetService {
}
public function get(string $nameOrId): Target {
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
try {
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
} catch (DoesNotExistException) {
throw new \InvalidArgumentException("No such location: $nameOrId");
}
}
/** Size limit for the data stored on this location (null = none). */
/**
* @param string $schedule see LocationSchedule
* @throws \InvalidArgumentException
*/
public function setSchedule(Target $target, string $schedule, string $zone): Target {
if (!Schedule::validZone($zone)) {
throw new \InvalidArgumentException('Unknown time zone: ' . $zone);
}
$new = LocationSchedule::normalize($schedule);
$turnedOff = $new === '' && (string)$target->getSchedule() !== '';
if ($new === (string)$target->getSchedule() && $zone === $target->getScheduleZone()) {
// Saved again unchanged: a moment that already passed still gets its backup
return $target;
}
$target->setSchedule($new);
$target->setScheduleZone($zone);
$target->setScheduleSince(time());
$target = $this->mapper->update($target);
if ($turnedOff) {
$this->alerts->securityEvent('schedule_off', ['target' => $target->getName()]);
}
return $target;
}
public function setMaxBytes(Target $target, ?int $maxBytes): Target {
$target->setMaxBytes($maxBytes);
return $this->mapper->update($target);
@@ -104,6 +450,30 @@ final class TargetService {
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
public function remove(Target $target): void {
$this->mapper->delete($target);
// The location is gone now: tidying up must not fail the removal, or the caller would skip
// the removal event and a retry would no longer find the location
$this->tidyUp($target, fn () => $this->keys->setWrappedKeyPending($target->getId(), false));
// The cached index of its repository is only of use while a location points at it
$this->tidyUp($target, function () use ($target): void {
$repositoryId = $target->getRepositoryId();
if ($repositoryId !== null && !array_filter($this->list(), fn (Target $t) => $t->getRepositoryId() === $repositoryId)) {
$this->indexCache->forget($repositoryId);
}
});
}
/** One tidy-up step after a removal: a failure is logged (if even that works) and never thrown. */
private function tidyUp(Target $target, callable $step): void {
try {
$step();
} catch (\Throwable $e) {
try {
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: location ' . $target->getName()
. ' removed, tidying up after it failed: ' . $e->getMessage(), ['exception' => $e]);
} catch (\Throwable) {
// nothing left to tell it to
}
}
}
public function backend(Target $target): IBackend {
@@ -124,8 +494,12 @@ final class TargetService {
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
}
private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend {
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
private function buildBackend(string $backend, string $auth, array $options, string $basePath, bool $createBase = true): StorageBackend {
$create = fn (): IStorage => $this->factory()->create($backend, $auth, $options);
$storage = new StorageBackend($create(), $basePath, $createBase, $create);
$storage->readAheadConnections = Server::get(\OCP\IAppConfig::class)
->getValueInt('ng_backup', 'restore_sftp_connections', \OCA\NgBackup\Backend\SftpRangeFetcher::DEFAULT_CONNECTIONS);
return $storage;
}
private ?ExternalStorageFactory $factory = null;
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\UserMigration\RepositoryExportDestination;
use OCA\NgBackup\UserMigration\UserMigrationServiceLocator;
use OCP\IUser;
use OCP\Lock\LockedException;
use OCP\UserMigration\UserMigrationException;
/**
* Per-user backup via user_migration: every registered migrator's export streams straight into
* the encrypted repository (no zip, no copy in the user's own storage, see
* RepositoryExportDestination). Shares blobs with the full backup; prune() keeps a user export's
* blobs referenced through its manifest under users/<uid>/.
*
* user_migration is an optional dependency (unlike files_external): without it, only this
* per-user backup/restore is unavailable, the full backup still works.
*/
final class UserBackupService {
public function __construct(
private TargetService $targets,
private LeaseService $leases,
private UserMigrationServiceLocator $locator,
) {
}
/** @throws UserMigrationException when user_migration is not enabled, or the export itself fails */
public function backupUser(Target $target, IUser $user): string {
$service = $this->locator->get();
return $this->withSharedLock($target, function (callable $refresh) use ($target, $user, $service) {
$repo = $this->targets->repository($target);
$dest = new RepositoryExportDestination($repo, $user->getUID(), LeaseService::throttled($refresh));
$service->export($dest, $user);
// A short exclusive lock around the catalog write only: the shared lock above
// allows another concurrent backup (full or per-user) to be writing the catalog at
// the same time. The repository is reopened inside the lock, so the write builds on
// the latest catalog generation instead of the one loaded before the lock was taken.
$this->withCatalogLock($target, fn () => $this->targets->repository($target)->recordUserExport($dest->manifestPath()));
return $dest->manifestPath();
});
}
private function withSharedLock(Target $target, callable $fn): mixed {
try {
// Refreshed by RepositoryExportDestination as it writes data, so a long export keeps it.
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 3600, $fn);
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
}
}
private function withCatalogLock(Target $target, callable $fn): mixed {
try {
return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300, $fn);
} catch (LockedException) {
throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again');
}
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\UserMigration\RepositoryExportDestination;
use OCA\NgBackup\UserMigration\RepositoryImportSource;
use OCA\NgBackup\UserMigration\UserMigrationServiceLocator;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Events\Node\FilesystemTornDownEvent;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Lock\LockedException;
use OCP\Security\ISecureRandom;
use OCP\UserMigration\UserMigrationException;
/**
* Restoring a user from a user_migration export (see UserBackupService), never over an existing
* account:
* - replace: a safety export of the current state goes into the repository first, then the
* account is deleted and recreated fresh from the backup, under the same uid;
* - as-backup: the backup is restored into a new account "<uid>-bak" (or "-bak-2", ...) instead,
* so an admin can compare and transfer by hand. Nextcloud cannot rename a uid, so the *backup*
* gets the new name, never the existing account.
*/
final class UserRestoreService {
public const MODE_REPLACE = 'replace';
public const MODE_AS_BACKUP = 'as-backup';
public function __construct(
private TargetService $targets,
private LeaseService $leases,
private UserMigrationServiceLocator $locator,
private IUserManager $userManager,
private ISecureRandom $random,
private IEventDispatcher $events,
) {
}
/** @throws UserMigrationException when user_migration is not enabled, or the restore itself fails */
public function restoreUser(Target $target, string $manifestPath, string $mode = self::MODE_REPLACE): IUser {
$service = $this->locator->get();
return $this->withSharedLock($target, function (callable $refresh) use ($target, $manifestPath, $mode, $service): IUser {
$repo = $this->targets->repository($target);
// Only ever read a manifest the verified catalog actually recorded: a path alone
// (even one this app generated before) proves nothing about it still being current
// or genuine once the catalog's rollback protection is supposed to vouch for it.
if (!$repo->isRecordedUserExport($manifestPath)) {
throw new \InvalidArgumentException("$manifestPath is not a recorded user export for this location");
}
$beat = LeaseService::throttled($refresh);
$source = new RepositoryImportSource($repo, $manifestPath, $beat);
$originalUid = $source->getOriginalUid();
if ($mode === self::MODE_AS_BACKUP) {
$backupUid = $this->freeBackupUid($originalUid);
$user = $this->userManager->createUser($backupUid, $this->random->generate(32));
if ($user === false) {
throw new \RuntimeException("Could not create the account $backupUid");
}
try {
return $service->import($source, $user);
} catch (\Throwable $e) {
// A partially-imported, never-used account left behind is strictly worse
// than no account at all; the original source account was never touched.
$user->delete();
throw $e;
}
}
if ($mode !== self::MODE_REPLACE) {
throw new \InvalidArgumentException("Unknown restore mode: $mode");
}
$existing = $this->userManager->get($originalUid);
$safetyManifestPath = null;
if ($existing !== null) {
// Never import over a live account: export its current state first, so nothing
// is lost if the restore turns out to be the wrong choice.
$safety = new RepositoryExportDestination($repo, $originalUid, $beat);
$service->export($safety, $existing);
$safetyManifestPath = $safety->manifestPath();
// Same race as UserBackupService::backupUser(): reopen the repository inside the
// catalog lock so the write builds on the latest generation.
$this->withCatalogLock($target, fn () => $this->targets->repository($target)->recordUserExport($safetyManifestPath));
if (!$existing->delete()) {
throw new \RuntimeException("Could not delete the existing account $originalUid before restoring; its safety export is at $safetyManifestPath");
}
// IRootFolder caches a Folder per uid for the lifetime of this process; without
// this it would still hand the just-deleted account's (now storage-less) folder
// to the import below, and every write would fail.
$this->events->dispatchTyped(new FilesystemTornDownEvent());
}
try {
return $service->import($source, null);
} catch (\Throwable $e) {
// The original account is already gone at this point (or never existed); a
// failure here can only be reported, not undone in place. Its safety export
// (if any) is the recovery path, already recorded above.
throw new UserMigrationException(
"Could not restore $originalUid" . ($safetyManifestPath !== null ? "; its safety export is at $safetyManifestPath" : '') . ': ' . $e->getMessage(),
0,
$e,
);
}
});
}
private function freeBackupUid(string $uid): string {
$candidate = $uid . '-bak';
for ($n = 2; $this->userManager->userExists($candidate); $n++) {
$candidate = $uid . '-bak-' . $n;
}
return $candidate;
}
private function withSharedLock(Target $target, callable $fn): mixed {
try {
// Refreshed while the safety export writes and the import reads (see UserBackupService::withSharedLock()).
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 3600, $fn);
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
}
}
private function withCatalogLock(Target $target, callable $fn): mixed {
try {
return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300, $fn);
} catch (LockedException) {
throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again');
}
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\Target;
use OCP\IAppConfig;
use OCP\Lock\LockedException;
/**
* Checksum audit of a snapshot (occ backup:verify), see Repository::verify(). On a large
* repository a check takes hours (a deep one days), so how far it is gets stored every
* PROGRESS_SECONDS, together with how it ended: `occ backup:status` shows it.
*/
final class VerifyService {
public const PROGRESS_SECONDS = 30;
private const KEY_PREFIX = 'verify_progress_';
public function __construct(
private TargetService $targets,
private LeaseService $leases,
private IAppConfig $appConfig,
) {
}
/**
* @param ?callable(array):void $onProgress called with the stored progress every PROGRESS_SECONDS
* @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, ?callable $onProgress = null): array {
return $this->run($target, $snapshotId, $deep, $onProgress, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
}
/**
* @param ?callable(array):void $onProgress called with the stored progress every PROGRESS_SECONDS
* @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, ?callable $onProgress = null): array {
return $this->run($target, $manifestPath, $deep, $onProgress, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
}
/** One line on a check's progress or outcome, for occ (see progress()). */
public static function describe(array $s): string {
$size = static fn (int $b): string => $b >= 1073741824 ? sprintf('%.1f GiB', (float)$b / 1073741824.0) : sprintf('%.1f MiB', (float)$b / 1048576.0);
$count = static fn (int $done, mixed $total): string => number_format($done)
. (is_int($total) && $total > 0 ? '/' . number_format($total) . sprintf(' (%d%%)', intdiv(100 * $done, $total)) : '');
$deep = ($s['deep'] ?? false) === true;
$kind = $deep ? 'deep check' : 'check';
if (($s['status'] ?? '') === 'running') {
$phase = match ($s['phase'] ?? '') {
'index' => 'index files ' . $count((int)$s['done'], $s['total'] ?? null),
'packs' => 'pack folders ' . $count((int)$s['done'], $s['total'] ?? null),
'files', 'database' => 'files ' . $count((int)$s['done'], $s['total'] ?? null) . ', ' . number_format((int)($s['blobs'] ?? 0)) . ' blobs'
. ($deep ? ', ' . $size((int)($s['bytes'] ?? 0)) . ' decrypted' : ''),
default => 'starting',
};
$age = time() - (int)($s['updated'] ?? 0);
return sprintf('%s running since %s: %s; updated %s ago%s', $kind, date('Y-m-d H:i', (int)$s['started']), $phase,
$age < 120 ? $age . ' s' : intdiv($age, 60) . ' min',
$age > 600 ? ' (no progress for a while: the process may have stopped)' : '');
}
$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' : ''),
'problems' => sprintf('%s found PROBLEMS on %s: %d missing, %d failed, %d unreadable blob(s); run occ backup:verify again for the details',
$kind, date('Y-m-d H:i', (int)$s['finished']), (int)($s['missing'] ?? 0), (int)($s['failedBlobs'] ?? 0), (int)($s['unreadable'] ?? 0)),
default => sprintf('%s FAILED on %s: %s', $kind, date('Y-m-d H:i', (int)($s['finished'] ?? 0)), (string)($s['error'] ?? 'unknown error')),
};
}
/**
* The stored state of the last check per location: running (with how far it is) or how it
* ended.
*
* @return array<int, array<string, mixed>> by location id
*/
public function progress(): array {
$result = [];
foreach ($this->appConfig->getKeys(Application::APP_ID) as $key) {
if (!str_starts_with($key, self::KEY_PREFIX)) {
continue;
}
$state = json_decode($this->appConfig->getValueString(Application::APP_ID, $key, '', lazy: true), true);
if (is_array($state)) {
$result[(int)substr($key, strlen(self::KEY_PREFIX))] = $state;
}
}
ksort($result);
return $result;
}
private function run(Target $target, string $what, bool $deep, ?callable $onProgress, callable $fn): array {
$ttl = $deep ? 600 : 300;
$state = ['what' => $what, 'deep' => $deep, 'status' => 'running', 'started' => time(), 'updated' => time(),
'pid' => getmypid(), 'phase' => 'starting', 'done' => 0, 'total' => null, 'blobs' => 0, 'bytes' => 0];
try {
$this->store($target, $state);
} catch (\Throwable) {
// progress reports are a convenience; the check itself goes ahead
}
try {
$result = $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $ttl, $fn, $onProgress, &$state): array {
$lastRefresh = time();
$lastStore = time();
$heartbeat = function (?array $progress = null) use ($refresh, $ttl, $target, $onProgress, &$lastRefresh, &$lastStore, &$state): void {
$now = time();
if ($now - $lastRefresh >= intdiv($ttl, 2)) {
$refresh();
$lastRefresh = $now;
}
if ($progress !== null && $now - $lastStore >= self::PROGRESS_SECONDS) {
$state = array_merge($state, $progress, ['updated' => $now]);
try {
$this->store($target, $state);
} catch (\Throwable) {
// a progress report that cannot be stored must not stop the check
}
$lastStore = $now;
if ($onProgress !== null) {
$onProgress($state);
}
}
};
return $fn($this->targets->repository($target), $heartbeat);
});
} catch (LockedException) {
$this->finish($target, $state, 'failed', ['error' => 'A cleanup of the location was in progress']);
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
} catch (\Throwable $e) {
$this->finish($target, $state, 'failed', ['error' => $e->getMessage()]);
throw $e;
}
$problems = count($result['missing']) + count($result['failed']) + count($result['unreadable']);
$this->finish($target, $state, $problems === 0 ? 'ok' : 'problems', [
'phase' => 'done', 'files' => $result['filesChecked'], 'blobs' => $result['blobsChecked'], 'bytes' => $result['bytesChecked'],
'missing' => count($result['missing']), 'failedBlobs' => count($result['failed']), 'unreadable' => count($result['unreadable']),
]);
return $result;
}
private function finish(Target $target, array $state, string $status, array $extra): void {
try {
$this->store($target, array_merge($state, $extra, ['status' => $status, 'updated' => time(), 'finished' => time()]));
} catch (\Throwable) {
// the check's own result counts; its stored summary is a convenience
}
}
private function store(Target $target, array $state): void {
$this->appConfig->setValueString(Application::APP_ID, self::KEY_PREFIX . $target->getId(), json_encode($state, JSON_THROW_ON_ERROR), lazy: true);
}
}
@@ -28,6 +28,8 @@ final class RepositoryExportDestination implements IExportDestination {
public function __construct(
private Repository $repo,
private string $uid,
/** Called while data is written (per chunk), to keep the location lease alive. */
private ?\Closure $heartbeat = null,
) {
$this->exportId = gmdate('Ymd\THis\Z') . '-' . bin2hex(random_bytes(4));
}
@@ -36,6 +38,7 @@ final class RepositoryExportDestination implements IExportDestination {
$writer = $this->repo->blobWriter($this->stats);
$writer->write($content);
$this->entries[self::norm($path)] = ['t' => 'f', 'b' => $writer->finish(), 's' => strlen($content)];
$this->beat();
}
public function addFileAsStream(string $path, $stream): void {
@@ -46,6 +49,7 @@ final class RepositoryExportDestination implements IExportDestination {
throw new UserMigrationException('Read error while exporting ' . $path);
}
$writer->write($chunk);
$this->beat();
}
$this->entries[self::norm($path)] = ['t' => 'f', 'b' => $writer->finish(), 's' => $writer->bytes()];
}
@@ -65,8 +69,11 @@ final class RepositoryExportDestination implements IExportDestination {
if ($stream === false) {
throw new \RuntimeException('Cannot read ' . $node->getPath());
}
$this->addFileAsStream($target, $stream);
fclose($stream);
try {
$this->addFileAsStream($target, $stream);
} finally {
fclose($stream);
}
}
}
}
@@ -89,6 +96,12 @@ final class RepositoryExportDestination implements IExportDestination {
return $this->stats;
}
private function beat(): void {
if ($this->heartbeat !== null) {
($this->heartbeat)();
}
}
private static function norm(string $path): string {
return trim($path, '/');
}
+13 -1
View File
@@ -20,6 +20,8 @@ final class RepositoryImportSource implements IImportSource {
public function __construct(
private Repository $repo,
string $manifestPath,
/** Called while blobs are read, to keep the location lease alive. */
private ?\Closure $heartbeat = null,
) {
$this->manifest = json_decode($repo->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
}
@@ -40,6 +42,9 @@ final class RepositoryImportSource implements IImportSource {
throw new UserMigrationException('Cannot buffer ' . $path);
}
foreach ($this->repo->readBlobs($entry['b']) as $data) {
if ($this->heartbeat !== null) {
($this->heartbeat)();
}
for ($done = 0, $len = strlen($data); $done < $len; $done += $n) {
$n = fwrite($stream, $done === 0 ? $data : substr($data, $done));
if ($n === false || $n === 0) {
@@ -109,7 +114,14 @@ final class RepositoryImportSource implements IImportSource {
$destination->newFile($name, $stream);
}
} finally {
fclose($stream);
// putContent()/newFile() already close the stream once they've consumed it:
// despite the @return resource docblock above, $stream is no longer a
// resource by the time we get here (verified against a real storage; a
// double fclose() throws a TypeError on PHP 8).
/** @psalm-suppress RedundantConditionGivenDocblockType */
if (is_resource($stream)) {
fclose($stream);
}
}
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\UserMigration;
use OCP\App\AppPathNotFoundException;
use OCP\App\IAppManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\ConfirmationQuestion;
/**
* Installs or enables the optional user_migration app, but only when the administrator asked for it
* (occ prompt or --install-user-migration). Runs occ in a child process, as the admin would.
*/
final class UserMigrationInstaller {
private const APP = 'user_migration';
public function __construct(
private IAppManager $appManager,
) {
}
public function isEnabled(): bool {
return $this->appManager->isEnabledForAnyone(self::APP);
}
private function codeIsPresent(): bool {
try {
$this->appManager->getAppPath(self::APP);
return true;
} catch (AppPathNotFoundException) {
return false;
}
}
/** Installs (from the app store, if missing) and enables user_migration; throws on failure. */
public function install(): void {
$args = $this->codeIsPresent() ? ['app:enable', self::APP] : ['app:install', self::APP];
/** @psalm-suppress UndefinedClass */
$occ = \OC::$SERVERROOT . '/occ';
$command = implode(' ', array_map('escapeshellarg', [PHP_BINARY, $occ, ...$args])) . ' 2>&1';
exec($command, $lines, $code);
if ($code !== 0) {
throw new \RuntimeException("occ " . implode(' ', $args) . " failed:\n" . implode("\n", $lines));
}
}
/**
* Returns when user_migration is enabled. Otherwise asks the administrator (interactive shell) or
* requires --install-user-migration, and throws when neither gives consent.
*
* @throws \RuntimeException
*/
public function ensureEnabled(InputInterface $input, OutputInterface $output): void {
if ($this->isEnabled()) {
return;
}
$consent = $input->getOption('install-user-migration') === true;
if (!$consent && $input->isInteractive()) {
$consent = (new QuestionHelper())->ask($input, $output, new ConfirmationQuestion(
'The user_migration app is not enabled. Install and enable it now? [y/N] ', false)) === true;
}
if (!$consent) {
throw new \RuntimeException('The user_migration app is not enabled; per-user backup/restore needs it. Run "occ '
. 'app:install ' . self::APP . '" and "occ app:enable ' . self::APP . '", or repeat with --install-user-migration');
}
$output->writeln('Installing and enabling user_migration (approved by the administrator)...');
$this->install();
throw new \RuntimeException('user_migration is installed and enabled; run this command again');
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\UserMigration;
use OCP\App\IAppManager;
use OCP\Server;
use OCP\UserMigration\UserMigrationException;
/**
* Resolves user_migration's own (internal, non-public) UserMigrationService on demand.
*
* Unlike files_external, user_migration is an optional dependency: without it, only per-user
* backup/restore is unavailable, the full backup still works. The class is never autoloaded by
* composer; everything that touches it is behind this one class.
*/
final class UserMigrationServiceLocator {
public function __construct(
private IAppManager $appManager,
) {
}
/** @throws UserMigrationException when user_migration is not enabled anywhere */
public function get(): object {
if (!$this->appManager->isEnabledForAnyone('user_migration')) {
throw new UserMigrationException('The user_migration app is not enabled; per-user backup/restore is unavailable');
}
$this->appManager->loadApp('user_migration');
return Server::get(\OCA\UserMigration\Service\UserMigrationService::class);
}
}
+46 -45
View File
@@ -49,6 +49,11 @@
<code><![CDATA[ask]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="lib/Command/KeyImport.php">
<UndefinedInterfaceMethod>
<code><![CDATA[ask]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="lib/Command/KeyInit.php">
<UndefinedInterfaceMethod>
<code><![CDATA[ask]]></code>
@@ -105,11 +110,6 @@
<code><![CDATA[ask]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="lib/Command/Trash.php">
<RedundantCastGivenDocblockType>
<code><![CDATA[(string)($info['time'] ?? '')]]></code>
</RedundantCastGivenDocblockType>
</file>
<file src="lib/Controller/AdminApiController.php">
<InvalidOperand>
<code><![CDATA[$maxGb * 1073741824]]></code>
@@ -148,10 +148,6 @@
</PossiblyFalseArgument>
</file>
<file src="lib/Db/DbDumper.php">
<InvalidArrayOffset>
<code><![CDATA[$info['sha256']]]></code>
<code><![CDATA[$manifest['sequences']]]></code>
</InvalidArrayOffset>
<MissingClosureReturnType>
<code><![CDATA[fn ($c) => $end[array_search($c, $columns, true)]]]></code>
<code><![CDATA[fn ($c) => $q($c)]]></code>
@@ -184,6 +180,13 @@
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[TransactionIsolationLevel]]></code>
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
@@ -191,6 +194,11 @@
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\ForeignKeyConstraint]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
@@ -213,21 +221,11 @@
</PropertyNotSetInConstructor>
</file>
<file src="lib/Job/BackupRun.php">
<ArgumentTypeCoercion>
<code><![CDATA[$blobs]]></code>
</ArgumentTypeCoercion>
<MissingParamType>
<code><![CDATA[$fh]]></code>
</MissingParamType>
<PossiblyNullIterator>
<code><![CDATA[$segments]]></code>
</PossiblyNullIterator>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$mtime]]></code>
<code><![CDATA[$state['cur']]]></code>
<code><![CDATA[$state['phase']]]></code>
<code><![CDATA[$state['segments']]]></code>
<code><![CDATA[$state['stats']]]></code>
</PossiblyUndefinedArrayOffset>
</file>
<file src="lib/Job/ParentCursor.php">
@@ -302,6 +300,22 @@
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000008Date20261107010000.php">
<UndefinedDocblockClass>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000007Date20261107000000.php">
<UndefinedDocblockClass>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000005Date20261002040000.php">
<UndefinedDocblockClass>
<code><![CDATA[$t]]></code>
@@ -353,7 +367,6 @@
<code><![CDATA[$encrypted]]></code>
<code><![CDATA[$encrypted]]></code>
<code><![CDATA[$encrypted]]></code>
<code><![CDATA[$fh]]></code>
<code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code>
@@ -395,7 +408,7 @@
</file>
<file src="lib/Service/BackupService.php">
<InvalidOperand>
<code><![CDATA[$deadline - microtime(true) + 120]]></code>
<code><![CDATA[$deadline - microtime(true) + 600]]></code>
</InvalidOperand>
<UndefinedClass>
<code><![CDATA[\OC]]></code>
@@ -409,24 +422,6 @@
<ArgumentTypeCoercion>
<code><![CDATA[array_diff(range(1, self::MAX_SLOTS), $used)]]></code>
</ArgumentTypeCoercion>
<InvalidReturnStatement>
<code><![CDATA[[
'version' => 1,
'app' => 'NG Backup',
'fingerprint' => $this->fingerprint(),
'kit_version' => $this->kitVersion(),
'confirmation_code' => $this->kitCode(),
'created' => gmdate('c'),
'wrapped_key' => $this->wrappedKey(),
'slots' => $this->slots(),
'instructions' => 'Keep this file safe, outside this server. Any one of the passphrases of the slots listed here opens it. '
. 'To restore on a new server: install NG Backup, add the backup location and enter this kit with one passphrase. '
. 'Without the kit (or the location) and a passphrase, the backups cannot be decrypted.',
]]]></code>
</InvalidReturnStatement>
<InvalidReturnType>
<code><![CDATA[array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string}]]></code>
</InvalidReturnType>
<LessSpecificReturnStatement>
<code><![CDATA[array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots'])]]></code>
</LessSpecificReturnStatement>
@@ -455,6 +450,18 @@
<code><![CDATA[array{kept: array<string, list<string>>, forgotten: list<string>, purged: list<string>, prune: array}]]></code>
</MoreSpecificReturnType>
</file>
<file src="lib/Service/RestoreFullService.php">
<EmptyArrayAccess>
<code><![CDATA[$backupConfig[$key]]]></code>
</EmptyArrayAccess>
<UndefinedInterfaceMethod>
<code><![CDATA[getInner]]></code>
<code><![CDATA[getInner]]></code>
</UndefinedInterfaceMethod>
<UnresolvableInclude>
<code><![CDATA[include $path]]></code>
</UnresolvableInclude>
</file>
<file src="lib/Service/RestoreService.php">
<LessSpecificReturnStatement>
<code><![CDATA[array_values($children)]]></code>
@@ -481,12 +488,6 @@
</RedundantCastGivenDocblockType>
</file>
<file src="lib/Service/TargetService.php">
<InvalidReturnStatement>
<code><![CDATA[$this->factory()->describeBackends()]]></code>
</InvalidReturnStatement>
<InvalidReturnType>
<code><![CDATA[list<array{id:string, name:string, auth:list<string>, parameters:array}>]]></code>
</InvalidReturnType>
<UndefinedDocblockClass>
<code><![CDATA[Server::get(\OCA\Files_External\Service\BackendService::class)]]></code>
</UndefinedDocblockClass>
+9
View File
@@ -31,8 +31,17 @@
<errorLevel type="suppress">
<!-- Provided by files_external at runtime; not a composer dependency (PLAN.md 3.5). -->
<referencedClass name="OCA\Files_External\**" />
<!-- Provided by user_migration at runtime, an optional dependency (PLAN.md 3.8). -->
<referencedClass name="OCA\UserMigration\**" />
</errorLevel>
</UndefinedClass>
<UndefinedDocblockClass>
<errorLevel type="suppress">
<!-- Server::get()'s templated return type resolving the users_migration class-string
constant below; same reason as the UndefinedClass suppression above. -->
<referencedClass name="OCA\UserMigration\**" />
</errorLevel>
</UndefinedDocblockClass>
<!-- The #[\Override] attribute convention (PHP 8.3+) adds no behaviour; this app supports
PHP 8.1+, and applying it to every interface-implementing method project-wide is pure
ceremony with no bug-catching value, so it's suppressed rather than papered over. -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+98 -8
View File
@@ -6,23 +6,48 @@
<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', 'We would like to hear how NG Backup 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>
<NcNoteCard v-if="status && !status.cron.ok" type="warning">
<p>
{{ status.cron.stopped
? t('ng_backup', 'Background jobs are set to system cron, but cron has not run since {time}.', { time: lastCron })
: t('ng_backup', 'Background jobs do not run on a system cron (they are set to "{mode}").', { mode: status.cron.mode === 'webcron' ? 'Webcron' : 'AJAX' }) }}
{{ t('ng_backup', 'Backups need a system cron that runs every 5 minutes, also the scheduled ones.') }}
<a :href="status.cron.docs" target="_blank" rel="noopener noreferrer">{{ t('ng_backup', 'How to set up cron ↗') }}</a>
</p>
<p>{{ t('ng_backup', 'Until then, a backup started with "Back up now" only moves on while this page is open.') }}</p>
</NcNoteCard>
</NcSettingsSection>
<template v-if="status">
<div class="ngb-tabs">
<NcButton :variant="tab === 'backup' ? 'primary' : 'secondary'" @click="tab = 'backup'">{{ t('ng_backup', 'Backup and restore') }}</NcButton>
<NcButton v-if="status.key.everConfirmed && status.targets.length" :variant="tab === 'bin' ? 'primary' : 'secondary'" @click="tab = 'bin'">{{ t('ng_backup', 'Bin') }}</NcButton>
<NcButton :variant="tab === 'settings' ? 'primary' : 'secondary'" @click="tab = 'settings'">{{ t('ng_backup', 'Settings') }}</NcButton>
<NcButton :variant="tab === 'docs' ? 'primary' : 'secondary'" @click="tab = 'docs'">{{ t('ng_backup', 'Docs') }}</NcButton>
</div>
<template v-if="tab === 'settings'">
<NcSettingsSection v-if="tab === 'docs'" :name="t('ng_backup', 'Documentation')">
<ul class="ngb-docs">
<li v-for="doc in docs" :key="doc.url">
<a :href="doc.url" target="_blank" rel="noopener noreferrer">{{ doc.name }} ↗</a>
<span>{{ doc.description }}</span>
</li>
</ul>
</NcSettingsSection>
<Bin v-else-if="tab === 'bin' && status.key.everConfirmed && status.targets.length" :status="status" @changed="refresh" />
<template v-else-if="tab === 'settings'">
<KeySetup :status="status" @changed="refresh" />
<Locations v-if="status.key.everConfirmed" :status="status" @changed="refresh" />
</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>
@@ -33,7 +58,7 @@
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
@@ -41,19 +66,48 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import api from './api.js'
import Backups from './components/Backups.vue'
import Bin from './components/Bin.vue'
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 repoUrl = 'https://github.com/dillardblom/ng_backup'
const docs = [
{
name: t('ng_backup', 'How-to'),
description: t('ng_backup', 'Step by step: from first install to a tested restore.'),
url: repoUrl + '/blob/main/docs/HOWTO.md',
},
{
name: t('ng_backup', 'Disaster recovery'),
description: t('ng_backup', 'Your server is gone: get Nextcloud back on a new server from a backup location.'),
url: repoUrl + '/blob/main/docs/DISASTER-RECOVERY.md',
},
{
name: t('ng_backup', 'Roadmap'),
description: t('ng_backup', 'What is planned for the next versions.'),
url: repoUrl + '#roadmap',
},
]
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.
@@ -64,24 +118,60 @@ async function refresh() {
} catch (e) {
error.value = e.message
}
// Poll while something runs, so progress shows up without reloading.
// Poll while something runs, so progress shows up without reloading. Without a working
// system cron, each poll also lets a running backup take a short step (see tick()).
clearTimeout(timer)
if (status.value?.runs.some(r => r.status === 'running')) {
timer = setTimeout(refresh, 4000)
timer = setTimeout(tickAndRefresh, 4000)
}
}
async function tickAndRefresh() {
if (status.value && !status.value.cron.ok && status.value.runs.some(r => r.status === 'running' && r.kind === 'full')) {
try {
await api.tick()
} catch (e) {
// The next poll tries again; the error of a failed step shows on its run.
}
}
await refresh()
}
const lastCron = computed(() => status.value?.cron.lastRun ? new Date(status.value.cron.lastRun * 1000).toLocaleString() : '')
onMounted(refresh)
onBeforeUnmount(() => clearTimeout(timer))
</script>
<style scoped>
/* NcSettingsSection sets its own width (at most 900px): the runs table needs more */
.ng-backup :deep(.settings-section) {
max-width: 900px;
width: min(1400px, 100% - var(--default-grid-baseline) * 7 * 2);
max-width: none;
}
.ngb-feedback {
color: var(--color-text-maxcontrast);
margin-bottom: 12px;
}
.ngb-feedback a {
color: var(--color-primary-element);
text-decoration: underline;
}
.ngb-docs li {
margin-bottom: 8px;
}
.ngb-docs a {
color: var(--color-primary-element);
text-decoration: underline;
margin-right: 8px;
}
.ngb-docs span {
color: var(--color-text-maxcontrast);
}
.ngb-tabs {
display: flex;
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>
+9 -3
View File
@@ -16,23 +16,29 @@ 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 }),
removeSlot: (slot) => call('delete', `/key/slots/${slot}`),
trash: (targetId) => call('get', `/targets/${targetId}/trash`),
trash: (targetId, after = '') => call('get', `/targets/${targetId}/trash`, { after }),
untrash: (targetId, snapshotId) => call('post', `/targets/${targetId}/trash/${snapshotId}`),
kitUrl: () => url('/key/kit'),
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
backends: () => call('get', '/backends'),
addTarget: (target) => call('post', '/targets', target),
targetSettings: (id) => call('get', `/targets/${id}/settings`),
updateTarget: (id, target) => call('put', `/targets/${id}`, target),
removeTarget: (id) => call('delete', `/targets/${id}`),
testTarget: (id) => call('post', `/targets/${id}/test`),
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
setSchedule: (time) => call('put', '/schedule', { time }),
tick: () => call('post', '/tick'),
setPauseHeavyJobs: (enabled) => call('put', '/settings/pause-heavy-jobs', { enabled }),
setTargetSchedule: (id, schedule, timezone) => call('put', `/targets/${id}/schedule`, { schedule, timezone }),
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
setSnapshotNote: (targetId, snapshotId, note) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/note`, { note }),
setSnapshotPinned: (targetId, snapshotId, pinned) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/pin`, { pinned }),
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }),
}
+87 -80
View File
@@ -5,117 +5,122 @@
<template>
<NcSettingsSection :name="t('ng_backup', 'Backups')"
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
<LocationScheduleRow v-for="target in status.targets" :key="target.id" :target="target" @changed="emit('changed')" @error="error = $event" />
<NcCheckboxRadioSwitch :model-value="status.heavyJobs.enabled" @update:model-value="savePause">
{{ t('ng_backup', 'Pause heavy background jobs (Memories, Recognize, Preview Generator, full text search, antivirus scan) while a backup runs, at most 6 hours in a row') }}
</NcCheckboxRadioSwitch>
<p v-if="status.heavyJobs.jobs.length" class="ngb-muted">{{ t('ng_backup', 'Paused now: {jobs}', { jobs: status.heavyJobs.jobs.join(', ') }) }}</p>
<div class="ngb-row">
<NcTextField v-model="schedule" class="ngb-time" :label="t('ng_backup', 'Daily at (HH:MM, empty = off)')" />
<NcButton :disabled="schedule === status.schedule" @click="saveSchedule">{{ t('ng_backup', 'Save') }}</NcButton>
</div>
<div class="ngb-row">
<NcTextField v-model="label" class="ngb-label" :maxlength="100" :label="t('ng_backup', 'Label (optional)')"
:placeholder="t('ng_backup', 'e.g. before the upgrade to Nextcloud 35')" />
<NcButton v-for="target in status.targets" :key="target.id" :disabled="isRunning(target.id)" @click="start(target)">
{{ isRunning(target.id) ? t('ng_backup', 'Backup to {name} running …', { name: target.name }) : t('ng_backup', 'Back up now to {name}', { name: target.name }) }}
</NcButton>
</div>
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
<template v-for="target in status.targets" :key="'trash' + target.id">
<div v-if="trash[target.id]?.length" class="ngb-trash">
<h3>{{ t('ng_backup', 'Trash of {name}', { name: target.name }) }}</h3>
<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>
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
</li>
</ul>
</div>
</template>
<table v-if="status.runs.length" class="ngb-table">
<thead>
<tr>
<th>{{ t('ng_backup', 'Started') }}</th>
<th>{{ t('ng_backup', 'Location') }}</th>
<th>{{ t('ng_backup', 'Kind') }}</th>
<th>{{ t('ng_backup', 'Restore point') }}</th>
<th>{{ t('ng_backup', 'Status') }}</th>
<th>{{ t('ng_backup', 'Files') }}</th>
<th>{{ t('ng_backup', 'Uploaded') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</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 :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
</tr>
</tbody>
</table>
<div v-if="status.runs.length" class="ngb-table-wrap">
<table class="ngb-table">
<thead>
<tr>
<th>{{ t('ng_backup', 'Started') }}</th>
<th>{{ t('ng_backup', 'Location') }}</th>
<th>{{ t('ng_backup', 'Kind') }}</th>
<th>{{ t('ng_backup', 'Restore point') }}</th>
<th>{{ t('ng_backup', 'Status') }}</th>
<th>{{ t('ng_backup', 'Files') }}</th>
<th :title="t('ng_backup', 'Total size of the files in this backup, unchanged files included')">{{ t('ng_backup', 'Size') }}</th>
<th>{{ t('ng_backup', 'Uploaded') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
<td>{{ formatDateTime(run.startedAt) }}</td>
<td>{{ targetName(run.targetId) }}</td>
<td>{{ run.kind }}</td>
<td class="ngb-point">{{ run.snapshot ? (run.snapshot.pinned ? '📌 ' : '') + formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') + (run.snapshot.note ? ' – ' + run.snapshot.note : '') : '' }}</td>
<td :class="'ngb-' + run.status">{{ runStatus(run) }}{{ run.error ? ': ' + run.error : '' }}</td>
<td class="ngb-num">{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
<td class="ngb-num">{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
<td class="ngb-num">{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
</tr>
</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 { ref } from 'vue'
import { n, t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import LocationScheduleRow from './LocationScheduleRow.vue'
import api from '../api.js'
import { formatSize } from '../format.js'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { reactive } from 'vue'
import { formatDateTime, formatSize } from '../format.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const schedule = ref(props.status.schedule)
const props = defineProps({ status: { type: Object, required: true }, allRuns: { type: Boolean, default: false } })
const emit = defineEmits(['changed', 'show-all-runs'])
const error = ref('')
watch(() => props.status.schedule, (s) => { schedule.value = s })
const trash = reactive({})
async function loadTrash() {
for (const target of props.status.targets) {
try {
trash[target.id] = await api.trash(target.id)
} catch (e) {
trash[target.id] = []
}
const label = ref('')
// ": 47/183 tables" in the database phase; " 68%", or " ~68%" when the total is estimated from
// Nextcloud's file cache (first backup)
const percent = (run) => {
if (!run.progress) {
return ''
}
}
watch(() => props.status.targets.map(tg => tg.id).join(','), loadTrash, { immediate: true })
async function untrash(target, item) {
error.value = ''
try {
await confirmPassword()
await api.untrash(target.id, item.id)
await loadTrash()
emit('changed')
} catch (e) {
error.value = e?.message || ''
if (run.progress.totalTables) {
return ': ' + t('ng_backup', '{done}/{total} tables', { done: run.progress.tables, total: run.progress.totalTables })
}
return ' ' + (run.progress.approximate ? '~' : '') + Math.floor(run.progress.fraction * 100) + '%'
}
// "running (db: 47/183 tables)", or "waiting for background jobs (…, last step 12 min ago)"
const runStatus = (run) => {
if (run.status !== 'running') {
return run.status
}
if (run.queued) {
return t('ng_backup', 'queued, after the backup that is running now')
}
const detail = run.phase + percent(run)
if (run.waitingFor) {
return t('ng_backup', 'waiting for background jobs ({detail}, last step {minutes} min ago)', { detail, minutes: Math.floor(run.waitingFor / 60) })
}
return run.status + ' (' + detail + ')'
}
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
async function start(target) {
async function savePause(enabled) {
error.value = ''
try {
await api.startBackup(target.id, '')
await api.setPauseHeavyJobs(enabled)
emit('changed')
} catch (e) {
error.value = e.message
}
}
async function saveSchedule() {
async function start(target) {
error.value = ''
try {
await api.setSchedule(schedule.value.trim())
await api.startBackup(target.id, label.value.trim())
label.value = ''
emit('changed')
} catch (e) {
error.value = e.message
@@ -125,12 +130,14 @@ async function saveSchedule() {
<style scoped>
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
.ngb-time { max-width: 260px; }
.ngb-label { max-width: 360px; }
.ngb-table-wrap { max-width: 90%; overflow-x: auto; } /* scrolls only on a really narrow screen */
.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); white-space: normal; overflow-wrap: break-word; }
/* Text wraps; numbers stay on one line, the restore point keeps room for its label and note */
.ngb-table td.ngb-num { white-space: nowrap; }
.ngb-table td.ngb-point { min-width: 9em; }
.ngb-failed { color: var(--color-error-text); }
.ngb-muted { color: var(--color-text-maxcontrast); }
.ngb-trash { margin: 12px 0; }
.ngb-trash h3 { font-weight: bold; }
.ngb-done { color: var(--color-success-text); }
</style>

Some files were not shown because too many files have changed in this diff Show More