Compare commits

..

9 Commits

Author SHA1 Message Date
dillard 7b00d7ab94 Docs: neutral wording for code review 2026-10-01 18:59:07 +02:00
dillard aa541d58b2 Phase 0: fixes from review (torn files, pending blobs, exact sequences, UTF-8, storage checks)
- detect files changing during reading or between resumable steps and
  re-read them (size/mtime/ctime/inode), spike F overwrites mid-run
- drop pending blob ids when a pack or index upload fails
- dump and restore PostgreSQL sequence state exactly, bound parameters,
  validated catalog names; spike C checks all sequences exactly
- keep invalid UTF-8 byte-exact (tagged base64)
- refuse large tables without a primary key
- StorageBackend: check write/flush/close results, validate base path
- user_migration import/export: root paths, intermediate folders,
  partial writes, read errors
- spikes: never touch an existing account, own fixtures, 512M note
2026-10-01 18:58:51 +02:00
dillard e00d6579f7 Plan: decisions after phase 0 (files_external state, per-user restore, versions) 2026-10-01 18:50:57 +02:00
dillard 101f954c5d Phase 0: resumable time-boxed backup steps (spike F) and results
BackupRun is a resumable state machine: each step works until a deadline,
uploads its pack and tree segment, and returns the state; large files are
resumed at the next blob. Snapshot is written last.

Spike F: 200 MiB file resumed across steps in separate processes; with 6
random kill -9 the run completes and restores bit-identically.
spikes/RESULTS.md summarises phase 0 and the findings for the plan.
2026-10-01 18:39:26 +02:00
dillard 6ee2b7e138 Phase 0: per-user backup/restore through user_migration (spike E)
RepositoryExportDestination / RepositoryImportSource implement the
public OCP\UserMigration interfaces on top of the repository, so
user_migration's migrators (calendar, contacts, trashbin, account,
files) export straight into the encrypted repository.

Spike E (NC34): export 0.8s, unchanged second export stores nothing;
after deleting the user, import restores account, files (bit-identical),
calendar event and contact. Findings: calendars/address books come back
as 'migrated-*' with new object URIs; importing over an existing user
duplicates contacts and adds a migrated birthday calendar; creating the
user on import needs >128 MB (password_policy word list).
2026-10-01 18:36:57 +02:00
dillard b06ff0f89f Phase 0: repository on files_external storages without a mount (spike D)
ExternalStorageFactory builds storages with files_external's own
backends and auth mechanisms from an unsaved StorageConfig (as its
ConfigAdapter does), and StorageBackend adapts any IStorage to IBackend
(writeStream, seekable range reads, temp name + rename except on S3).
IBackend::put() now owns and always closes the stream, because
files_external's S3 writeStream closes it.

Spike D (NC34): S3 (SeaweedFS) and SFTP: 120 MiB backup ~6s, unchanged
run uploads nothing, restore bit-identical, no files_external mount
created, no temp files (max 12 KiB in /tmp). Works with files_external
disabled when its classes are loaded. Backup peak memory 85-89 MiB on
these targets (to investigate).
2026-10-01 18:33:15 +02:00
dillard 5b0aded7b0 Phase 0: logical DB dump/restore without external tools (spike C)
DbDumper reads every prefixed table through the Nextcloud connection in
one REPEATABLE READ (read-only) transaction, with keyset pagination on
the primary key, and streams each table as JSON lines into its own blob
stream (BlobWriter), so unchanged tables are deduplicated. Restore
replaces table contents in one transaction and resets PostgreSQL
sequences.

Spike C (NC34, PostgreSQL, standard image without pg_dump/pgsql ext):
300k-row table with bytea: dump 6.3s / 50 MiB peak, 94 MiB JSON -> 20 MiB
stored; unchanged second dump stores nothing; restore makes every table
checksum-identical and all 110 sequences continue above the maximum.
2026-10-01 18:26:27 +02:00
dillard 4fce893389 Phase 0: repository format with packs, dedup and incremental runs (spike B)
Content-addressed repository on an IBackend (local implementation):
4 MiB blobs, deflate when it helps, each blob encrypted with its id as
associated data, ~32 MiB packs with an encrypted header, one index file
per pack, snapshots as encrypted JSON lines written last.

Spike B: 390 MiB tree, peak 54 MiB; unchanged run reads nothing; a 1 MiB
change in a 300 MiB file uploads one blob; full and partial restore are
bit-identical; tampering is detected.
2026-10-01 18:18:38 +02:00
dillard fee189aec3 Phase 0: skeleton, streaming encryption (spike A)
Repository skeleton (app id ng_backup, AGPL-3.0-or-later), KeyRing with
KDF subkeys, keyed blob ids and Argon2id key wrapping, and StreamCipher
(secretstream XChaCha20-Poly1305, framed, bounded memory).

Spike A: 1 GiB round trip with memory_limit=64M, peak < 2 MiB, ~160/122
MiB/s; tampering, truncation, wrong key/passphrase are rejected.
2026-10-01 18:15:24 +02:00
133 changed files with 204 additions and 23705 deletions
-106
View File
@@ -1,106 +0,0 @@
# SPDX-FileCopyrightText: 2026 Dillard Blom
# SPDX-License-Identifier: AGPL-3.0-or-later
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-latest
strategy:
matrix:
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 }}
extensions: sodium, zip
coverage: none
- run: composer install --no-interaction --no-progress
- run: vendor/bin/phpunit -c tests/phpunit.xml
psalm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: sodium, zip
coverage: none
- run: composer install --no-interaction --no-progress
- run: composer psalm
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.
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
nextcloud: ['32', '33', '34', '35']
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 }}"
integration-targets:
# Same round trip, but through the real S3 (s3mock) and SFTP backends instead of a local
# path, so files_external's AmazonS3/SFTP backend and auth classes are actually exercised.
# Backend correctness does not depend on the Nextcloud version or database, so this runs once.
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
+4 -9
View File
@@ -1,10 +1,5 @@
/build/
cred-*
*credentials*
/css/
/js/
/node_modules/
.phpunit.result.cache
*.secret
/spikes/out/
/vendor/
/node_modules/
/js/
/build/
/spikes/out/
-92
View File
@@ -1,92 +0,0 @@
# Changelog
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.
## [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
First public alpha. Feature-complete for phase 1: scheduled, encrypted, incremental backups, and
restore of files and folders. **Not for production use** — test on a copy of production (dev,
test, staging). Full disaster recovery (`backup:restore:full` on a fresh installation) is phase 2
and not implemented yet.
### Added
- Encrypted (XChaCha20-Poly1305), content-addressed, deduplicated, incremental backups, streamed
from source to destination without a local copy of the data.
- Backup locations via files_external's own backend/auth classes, without ever mounting the
location: S3 (and compatible services), SFTP, WebDAV, FTP, Swift, or a local path.
- A recovery kit with up to three passphrase slots, mandatory download-and-confirm before the
first backup runs, and an optional encrypted copy by email.
- Resumable, time-boxed background jobs (database dump, file walk, upload), so a run survives
short PHP time limits and a crash is resumed, never restarted, without leaving the instance in
maintenance mode.
- Database dump and restore without external tools, via Nextcloud's own `IDBConnection`/DBAL,
streamed through the same pipeline as file data.
- Restore of files and folders via the web interface and `occ`, into a new folder, merged, or
replacing the original (with the previous content going to the trash).
- Retention policy (last/daily/weekly/monthly), prune with pack repacking, and a snapshot trash
with a configurable deletion delay.
- Append-only locations and documented recommendations against ransomware and a compromised
server (object lock, delete-less credentials, off-site copies).
- Admin settings page (locations, schedule, retention, key management) and a JSON API.
- Notifications and an admin setup check for a failed or overdue backup.
- CI: unit tests on PHP 8.2–8.4, and an end-to-end backup→restore round trip on Nextcloud 32–35 ×
SQLite/MariaDB/PostgreSQL, and against real S3 and SFTP backends (not just a local path).
### Known limitations
- No full/disaster-recovery restore yet (phase 2).
- No per-user backup/restore via user_migration yet (phase 2).
- 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).
[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
+5 -66
View File
@@ -214,8 +214,8 @@ Daarom:
### 3.8 Backup en restore per gebruiker: via user_migration
In plaats van zelf te bepalen welke gegevens van een gebruiker meegaan, hergebruiken we
**user_migration** (officiële Nextcloud-app, actief onderhouden, NC29–36; draait ook op onze eigen productie-instantie).
Die app werkt met *migrators* die andere apps zelf aanleveren. Op onze productie-instantie zijn dat er al:
**user_migration** (officiële Nextcloud-app, actief onderhouden, NC29–36; staat ook op nc01).
Die app werkt met *migrators* die andere apps zelf aanleveren. Op nc01 hebben die er al:
calendar, dav (agenda's en contactpersonen), deck, files_trashbin, mail, settings (profiel,
account, app-instellingen) en de bestanden zelf. Elke app die later een migrator toevoegt, gaat
automatisch mee.
@@ -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:user:restore <snapshot> <user> [--to-user=]` | gebruiker terugzetten via user_migration (fase 2) |
| `backup:restore:user <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 |
@@ -308,14 +308,14 @@ niet met de hunne, maar bij installatie waarschuwen als `backup` ook actief is.
| 2 | Backup/restore per gebruiker via user_migration, volledige restore op verse installatie, FTP/SMB, verificatie | 3–4 weken |
| 3 | In-place volledige restore (restore-modus), zelfbediening voor gebruikers, extra doelen | open |
Release in de app store pas na fase 1 en een periode van eigen gebruik op onze productie-instantie (net als occweb):
Release in de app store pas na fase 1 en een periode van eigen gebruik op nc01 (net als occweb):
een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
## 8. Besluiten (2026-10-01)
1. **Doelgroep:** alle varianten: AIO, losse installaties én gehoste Nextcloud zonder SSH.
2. **Licentie:** AGPL-3.0-or-later.
3. **Repository:** eigen Gitea + GitHub (dillardblom), zoals occweb.
3. **Repository:** Ensembia op Gitea + GitHub (dillardblom), zoals occweb.
4. **Naam:** "NG Backup", app-id `ng_backup`, occ-voorvoegsel `backup:`.
5. **Herstelkit:** verplicht downloaden + expliciete bevestiging door de beheerder (vinkje +
"Yes, I confirm"), zie 3.2; versleuteld per e-mail als optionele extra kopie.
@@ -325,67 +325,6 @@ een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
export, verwijderen, opnieuw aanmaken), óf terugzetten als `<gebruiker>-bak`, zie 3.8.
8. **Fase 0 afgerond (2026-10-01),** resultaten in `spikes/RESULTS.md`.
## 9. Releases en versies (besluit 2026-10-02)
| Versie | Betekenis |
|---|---|
| 0.1.0-alpha.N | publiek; meetesten gewaardeerd op een kopie van productie (dev, test, staging) |
| 0.9.0-beta.N | mag meedraaien op productie, naast de reguliere backup; volledige restore aanwezig en getest |
| 1.0.0-rc.N | releasekandidaten, alleen bugfixes |
| 1.0.0 | stabiel: vervanging van de reguliere backup, noodherstel bewezen |
| 1.2.0 e.v. | functies van fase 2 (restore per gebruiker, FTP/SMB, verificatie, ...) |
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.
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
git-tag (`v0.1.0-alpha.1`), de GitHub/Gitea release (gemarkeerd als pre-release) en de CHANGELOG.
## Bijlage: lessen uit nextcloud/backup (test NC34, 2026-10-01)
- Externe programma's (`pg_dump`) en extensies (`pgsql`, `mysqli`) → onbruikbaar op standaard/hosted.
+3 -182
View File
@@ -1,187 +1,8 @@
# NG Backup
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 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.)
Encrypted, incremental backups of Nextcloud with restore from the web interface.
Works without shell access: no external programs, no extra PHP extensions beyond sodium.
**Status: beta (0.9.0).** Tested thoroughly, usable alongside your current backup, but not yet a
drop-in replacement for it. See "Known limitations" below, `PLAN.md` for the design and
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore.
**Status: phase 0 (prototype). Not usable yet.** See `PLAN.md` for the design.
License: AGPL-3.0-or-later.
## How it works
- Backups go to a **location**: S3 (and S3-compatible storage), SFTP, WebDAV, FTP, Swift or a
local path. NG Backup uses the connection code of Nextcloud's External storage app, but never
mounts the location: it is only connected while a backup or restore runs, and it never
appears in Files, for anyone.
- Data is cut into blocks, deduplicated, compressed and encrypted (XChaCha20-Poly1305) before it
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.
- Restores go through Nextcloud, so overwritten files keep their old content as a version and
removed files go to the trash.
## 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=/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
```
Or use **Administration settings → NG Backup**.
## The backup key, passphrases and the recovery kit
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.
- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not
lock the company out. A suggested setup:
| Slot | Kept by |
|---|---|
| 1 | in the safe (sealed envelope) |
| 2 | the CTO |
| 3 | the head of IT |
Every holder keeps a copy of the same recovery kit and only knows their own passphrase. Using
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.
- 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.
## Protection against ransomware and a compromised server
A backup is only useful if an attacker cannot destroy it. An attacker with administrator access
to Nextcloud could try to remove old restore points so that only infected data remains.
NG Backup makes this hard and visible, but **software alone can never guarantee it**: the
location must refuse deletions itself.
What NG Backup does:
- **Deletion delay.** Removed restore points first go to a trash on the location and are only
deleted after the delay chosen at installation (default 7 days, cannot be changed afterwards).
Within that time any administrator can bring them back (`occ backup:trash:restore`, or in the
web interface).
- **Notifications to all administrators** when restore points are removed, the retention is
changed, append-only is turned off, a location is removed or the recovery kit is downloaded,
and an entry in the audit log (admin_audit).
- **Append-only locations** (`occ backup:target:append-only NAME on`): NG Backup never deletes
anything there; old data is cleaned up by the location's own lifecycle rules.
- Removing a location in NG Backup only removes the connection. The backups on the location are
not touched and nothing keeps cleaning up in the background.
What you should do on the location (strongly recommended):
- **S3: enable Object Lock** on the bucket, in **compliance mode**, with a default retention of
for example **90 days**, plus versioning. Then nobody, not even with the server's credentials,
can delete or overwrite backups younger than 90 days. Combine with an append-only location in
NG Backup and a lifecycle rule that removes old versions after the lock expires.
With Object Lock, a deletion by NG Backup (or by an attacker through it) only adds a *delete
marker*: the locked data stays and can be brought back from the bucket's object versions until
the lock expires. Tested with an S3-compatible server; still to be verified on AWS itself, which
requires a checksum on uploads to locked buckets.
- Use **credentials without delete rights** for the backup location where the storage supports it.
- **SFTP / storage boxes:** enable the provider's own snapshots (e.g. daily, kept 30-90 days) and
give the backup user access to its own folder only, without a shell.
- Keep at least one location **off-site**, on a different network than the Nextcloud server.
## Restore
- **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): `occ backup:restore:full`, see the how-to.
- **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.
## Storage backend experiences
Notes from actually running a backend, beyond what's in files_external's own documentation.
Contributions welcome via PR — add what you ran into with your own provider.
- **Hetzner Storage Box, sub-account:** the SFTP backend's `root` option must be `home`, not `/`.
A sub-account's SFTP login lands in `/home` (confirmed via `pwd()` after login); writing to the
literal chroot root (`/`) fails with a generic `NET_SFTP_STATUS_FAILURE` from the SFTP server,
while the same write under `home` (or a path below it) succeeds. This affects any backend option
form that lets you set a path relative to the account's root, not just ng_backup. Not yet
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.
- **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.
- **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.
## Known limitations
On the backlog; none of these block the beta or 1.0.0, but know them before you rely on it:
- **Very large instances** (well over 1 TB, millions of files): the blob index, cleanup, browsing a
snapshot in the web interface and per-user export manifests are held in memory. Expect high
memory use there; reports from such instances are very welcome.
- **One location, one server.** Never let two Nextcloud servers write to the same location (for
example a disaster recovery test server pointed at the production location): they can break each
other's catalog, and the location is then refused as possibly tampered with. Test a disaster
recovery against a copy of the location, or only read from it.
- **Removing a passphrase slot does not revoke access.** The master key stays the same, so someone
with an old recovery kit and that slot's passphrase can still decrypt the location if they can
read it. Key rotation is not available yet.
- **Rollback before a disaster recovery is not detected.** A fresh server has no record of the
newest catalog generation, so a location rolled back to an older state looks valid there.
- Location passwords passed with `-o password=...` are visible in the process list and shell
history while the command runs.
- Temporary files of a restore into a user's files end up in that user's trash.
- Restoring the database does not order tables by foreign keys; apps with cascading foreign keys
could lose rows (Nextcloud core does not use them).
- A pack whose index upload failed stays on the location until removed by hand.
## Commands
| 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:browse`, `backup:restore` | browse and restore |
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
| `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:restore` | one user via user_migration (asks before installing it) |
+2 -43
View File
@@ -4,12 +4,8 @@
<id>ng_backup</id>
<name>NG Backup</name>
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
<description><![CDATA[Beta: scheduled, encrypted, incremental backups of the database, config and data
directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files and folders, of
single users (via user_migration) and of the whole server onto a fresh installation. Tested
thoroughly and usable alongside your current backup, but not yet a drop-in replacement for it.
See the README for known limitations and the recovery kit before relying on it.]]></description>
<version>0.9.0</version>
<description><![CDATA[Work in progress, not ready for use.]]></description>
<version>0.0.1</version>
<licence>agpl</licence>
<author>Dillard Blom</author>
<namespace>NgBackup</namespace>
@@ -20,41 +16,4 @@ See the README for known limitations and the recovery kit before relying on it.]
<lib>sodium</lib>
<nextcloud min-version="32" max-version="35"/>
</dependencies>
<background-jobs>
<job>OCA\NgBackup\BackgroundJob\BackupJob</job>
</background-jobs>
<commands>
<command>OCA\NgBackup\Command\KeyInit</command>
<command>OCA\NgBackup\Command\KeyKit</command>
<command>OCA\NgBackup\Command\KeyConfirm</command>
<command>OCA\NgBackup\Command\TargetBackends</command>
<command>OCA\NgBackup\Command\TargetAdd</command>
<command>OCA\NgBackup\Command\TargetList</command>
<command>OCA\NgBackup\Command\TargetRemove</command>
<command>OCA\NgBackup\Command\RunBackup</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\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\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\KeyImport</command>
<command>OCA\NgBackup\Command\RestoreFull</command>
<command>OCA\NgBackup\Command\Verify</command>
</commands>
<settings>
<admin>OCA\NgBackup\Settings\Admin</admin>
<admin-section>OCA\NgBackup\Settings\AdminSection</admin-section>
</settings>
</info>
-30
View File
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
return [
'routes' => [
['name' => 'AdminApi#status', 'url' => '/api/status', 'verb' => 'GET'],
['name' => 'AdminApi#initKey', 'url' => '/api/key', 'verb' => 'POST'],
['name' => 'AdminApi#downloadKit', 'url' => '/api/key/kit', 'verb' => 'GET'],
['name' => 'AdminApi#confirmKit', 'url' => '/api/key/confirm', 'verb' => 'POST'],
['name' => 'AdminApi#addSlot', 'url' => '/api/key/slots', 'verb' => 'POST'],
['name' => 'AdminApi#replaceSlot', 'url' => '/api/key/slots/{slot}', 'verb' => 'PUT'],
['name' => 'AdminApi#removeSlot', 'url' => '/api/key/slots/{slot}', 'verb' => 'DELETE'],
['name' => 'AdminApi#trash', 'url' => '/api/targets/{targetId}/trash', 'verb' => 'GET'],
['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#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#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
['name' => 'AdminApi#startRestore', 'url' => '/api/restores', 'verb' => 'POST'],
],
];
-34
View File
@@ -1,34 +0,0 @@
{
"name": "dillardblom/ng_backup",
"description": "NG Backup: encrypted, incremental Nextcloud backups",
"license": "AGPL-3.0-or-later",
"type": "project",
"autoload": {
"psr-4": {
"OCA\\NgBackup\\": "lib/"
}
},
"autoload-dev": {
"psr-4": {
"OCA\\NgBackup\\Tests\\": "tests/"
}
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"nextcloud/ocp": "dev-stable32",
"vimeo/psalm": "^6.0"
},
"config": {
"optimize-autoloader": true,
"platform": {
"php": "8.2.27"
}
},
"scripts": {
"test:unit": "phpunit -c tests/phpunit.xml --testsuite unit",
"psalm": "psalm --no-cache --threads=1"
}
}
Generated
-5045
View File
File diff suppressed because it is too large Load Diff
-158
View File
@@ -1,158 +0,0 @@
# 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. Keep it somewhere other than this server.
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
Any one passphrase is enough to restore. Removing a slot later does not revoke access for someone
who kept an old kit and that passphrase (see "Known limitations" in the README).
- The recovery kit (`/var/tmp/ng_backup-kit.json` above) is also required for disaster recovery. The web
user writes it there; then move it off the server as root, next to the passphrase, and delete the
copy in `/var/tmp`. Without the kit and a passphrase, the backup cannot be read.
## 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 sftp::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
```
## 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.
Schedule `occ backup:run` from system cron (or use the Administration settings, if you prefer).
## 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:restore offsite users/<uid>/<exportId> # replace (default)
occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
```
- 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
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>
```
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.
## 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>
```
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)
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#000" d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3Zm0 5a3 3 0 0 1 3 3v1h1v6H8v-6h1v-1a3 3 0 0 1 3-3Zm0 1.6c-.8 0-1.4.6-1.4 1.4v1h2.8v-1c0-.8-.6-1.4-1.4-1.4Z"/></svg>

Before

Width:  |  Height:  |  Size: 272 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#fff" d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3Zm0 5a3 3 0 0 1 3 3v1h1v6H8v-6h1v-1a3 3 0 0 1 3-3Zm0 1.6c-.8 0-1.4.6-1.4 1.4v1h2.8v-1c0-.8-.6-1.4-1.4-1.4Z"/></svg>

Before

Width:  |  Height:  |  Size: 272 B

+1 -3
View File
@@ -12,7 +12,7 @@ use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
final class Application extends App implements IBootstrap {
class Application extends App implements IBootstrap {
public const APP_ID = 'ng_backup';
public function __construct() {
@@ -20,8 +20,6 @@ final class Application extends App implements IBootstrap {
}
public function register(IRegistrationContext $context): void {
$context->registerNotifierService(\OCA\NgBackup\Notification\Notifier::class);
$context->registerSetupCheck(\OCA\NgBackup\SetupCheck\BackupStatusCheck::class);
}
public function boot(IBootContext $context): void {
+1 -1
View File
@@ -7,5 +7,5 @@ declare(strict_types=1);
namespace OCA\NgBackup\Backend;
final class BackendException extends \RuntimeException {
class BackendException extends \RuntimeException {
}
+2 -18
View File
@@ -26,32 +26,16 @@ final class ExternalStorageFactory {
}
/**
* @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array<string, mixed>}>
* @return list<array{id:string, name:string, auth:list<string>, parameters:array<string, mixed>}>
* available backends, to build the settings form from
*/
public function describeBackends(): array {
$result = [];
foreach ($this->backends->getAvailableBackends() as $backend) {
$auth = [];
foreach ($this->backends->getAuthMechanismsByScheme(array_keys($backend->getAuthSchemes())) as $mech) {
// Mechanisms that need the logged-in user's session cannot work for unattended backups.
if (in_array($mech->getScheme(), ['builtin', 'null'], true) && $mech->getIdentifier() !== 'null::null') {
continue;
}
if (str_starts_with($mech->getIdentifier(), 'password::session') || str_starts_with($mech->getIdentifier(), 'password::logincredentials')
|| str_starts_with($mech->getIdentifier(), 'password::userprovided') || str_starts_with($mech->getIdentifier(), 'password::global')) {
continue;
}
$auth[] = [
'id' => $mech->getIdentifier(),
'name' => $mech->getText(),
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $mech->getParameters()),
];
}
$result[] = [
'id' => $backend->getIdentifier(),
'name' => $backend->getText(),
'auth' => $auth,
'auth' => array_keys($backend->getAuthSchemes()),
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $backend->getParameters()),
];
}
-7
View File
@@ -27,13 +27,6 @@ interface IBackend {
public function exists(string $path): bool;
/**
* Move an object within the same location (used for the trash). Backends must use a native
* rename, not a read+write copy: some storages (e.g. SFTP) cannot reliably read and write on
* the same connection at once, since the read side is a lazily-pulled stream.
*/
public function move(string $from, string $to): void;
/** @return list<string> object paths below $prefix (recursive) */
public function list(string $prefix): array;
+1 -17
View File
@@ -33,16 +33,11 @@ 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);
}
@@ -99,7 +94,7 @@ final class LocalBackend implements IBackend {
$result = [];
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if ($file->isFile() && !StorageBackend::isTemporary($file->getFilename())) {
if ($file->isFile() && !str_contains($file->getFilename(), '.part-')) {
$result[] = ltrim(substr($file->getPathname(), strlen($this->root)), '/');
}
}
@@ -111,17 +106,6 @@ final class LocalBackend implements IBackend {
@unlink($this->abs($path));
}
public function move(string $from, string $to): void {
$target = $this->abs($to);
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
throw new BackendException('Cannot create ' . $dir);
}
if (!rename($this->abs($from), $target)) {
throw new BackendException("Cannot move $from to $to");
}
}
private function abs(string $path): string {
if (str_contains($path, '..') || str_starts_with($path, '/')) {
throw new BackendException('Invalid path: ' . $path);
+2 -40
View File
@@ -30,13 +30,7 @@ final class StorageBackend implements IBackend {
}
}
/** 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 {
$expected = self::remaining($stream);
$target = $this->abs($path);
$this->mkdirs(dirname($target));
$writeTo = $this->atomicPut ? $target : $target . '.part-' . bin2hex(random_bytes(4));
@@ -60,10 +54,6 @@ 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);
}
@@ -125,14 +115,6 @@ final class StorageBackend implements IBackend {
$this->storage->unlink($this->abs($path));
}
public function move(string $from, string $to): void {
$target = $this->abs($to);
$this->mkdirs(dirname($target));
if (!$this->storage->rename($this->abs($from), $target)) {
throw new BackendException("Cannot move $from to $to");
}
}
private function listInto(string $dir, array &$result): void {
if (!$this->storage->is_dir($dir)) {
return;
@@ -142,39 +124,19 @@ final class StorageBackend implements IBackend {
return;
}
while (($name = readdir($dh)) !== false) {
if ($name === '.' || $name === '..') {
if ($name === '.' || $name === '..' || str_contains($name, '.part-')) {
continue;
}
$child = $dir . '/' . $name;
if ($this->storage->is_dir($child)) {
$this->listInto($child, $result);
} elseif (!self::isTemporary($name)) {
} else {
$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;
-128
View File
@@ -1,128 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\BackgroundJob;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Service\BackupService;
use OCA\NgBackup\Service\TargetService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJob;
use OCP\BackgroundJob\TimedJob;
use OCP\IAppConfig;
use Psr\Log\LoggerInterface;
/**
* Starts scheduled backups and moves running ones forward, a time-boxed step per cron run.
* With system cron a step may take minutes; with webcron/AJAX cron it is kept short so it fits
* in a normal web request (hosted Nextcloud).
*/
final class BackupJob extends TimedJob {
public function __construct(
ITimeFactory $time,
private BackupService $backups,
private \OCA\NgBackup\Service\PruneService $prune,
private \OCA\NgBackup\Service\AlertService $alerts,
private TargetService $targets,
private RunMapper $runs,
private IAppConfig $appConfig,
private LoggerInterface $logger,
) {
parent::__construct($time);
$this->setInterval(300);
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
}
private const MAX_DAILY_ATTEMPTS = 3;
protected function run($argument): void {
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
$deadline = microtime(true) + $budget;
$this->startScheduled();
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
$next = $this->backups->step($run, min($deadline, microtime(true) + $budget));
if ($next === $run) {
break; // 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) {
$this->alerts->checkStale($t);
}
}
/** A restore run that has not moved for 6 hours is considered dead (it would block cleanup forever). */
private function failStaleRestores(): void {
foreach ($this->runs->findRunning(null, 'restore') as $run) {
if (time() - $run->getUpdatedAt() > 6 * 3600) {
$run->setStatus(\OCA\NgBackup\Db\Run::FAILED);
$run->setError('No progress for 6 hours; marked as failed');
$run->setFinishedAt(time());
$this->runs->update($run);
$this->alerts->runFailed($run, '#' . $run->getTargetId());
}
}
}
/**
* 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', '');
$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) {
if ($t->getAppendOnly()) {
continue;
}
try {
$this->prune->apply($t, false, true);
} catch (\Throwable $e) {
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
}
}
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', $due);
}
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
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 = (int)strtotime(date('Y-m-d') . ' ' . $at);
if (time() < $todayAt) {
return;
}
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)) {
continue;
}
// After a failure: retry at most MAX_DAILY_ATTEMPTS times a day, an hour apart, instead
// of starting (and notifying about) a new full run every five minutes all day.
$today = $this->runs->startedSince($t->getId(), BackupService::KIND_FULL, $todayAt);
if (count($today) >= self::MAX_DAILY_ATTEMPTS || ($today !== [] && time() - (int)$today[0]->getFinishedAt() < 3600)) {
continue;
}
try {
$this->backups->start($t, 'scheduled');
} catch (\Throwable $e) {
$this->logger->warning('NG Backup: scheduled backup to ' . $t->getName() . ' not started: ' . $e->getMessage());
}
}
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\BackgroundJob;
use OCA\NgBackup\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Service\RestoreService;
use OCA\NgBackup\Service\TargetService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use Psr\Log\LoggerInterface;
/** Runs a restore requested from the web interface, outside the web request. */
final class RestoreJob extends QueuedJob {
public function __construct(
ITimeFactory $time,
private RunMapper $runs,
private TargetService $targets,
private RestoreService $restore,
private LoggerInterface $logger,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct($time);
}
protected function run($argument): void {
$runId = (int)($argument['run'] ?? 0);
try {
$run = $this->runs->find($runId);
} catch (\Throwable $e) {
$this->logger->error('NG Backup restore job: run ' . $runId . ' not found', ['exception' => $e]);
return;
}
if ($run->getStatus() !== Run::RUNNING) {
return;
}
try {
$req = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
$run->setPhase('restoring');
$run->setUpdatedAt(time());
$run = $this->runs->update($run);
@set_time_limit(0);
$lastBeat = time();
$result = $this->restore->restoreUserFiles($this->targets->get((string)$run->getTargetId()), $req['snapshot'], $req['path'], $req['mode'],
function (int $done, int $total) use (&$run, &$lastBeat): void {
// Heartbeat, so a long restore is not mistaken for a dead one.
if (time() - $lastBeat >= 60) {
$lastBeat = time();
$run->setPhase("restoring $done/$total");
$run->setUpdatedAt(time());
$run = $this->runs->update($run);
}
});
$run->setStatus(Run::DONE);
$run->setPhase('done');
$run->setStats(json_encode($result, JSON_THROW_ON_ERROR));
} catch (\Throwable $e) {
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
$run->setStatus(Run::FAILED);
$run->setError($e->getMessage());
try {
$this->alerts->runFailed($run, '#' . $run->getTargetId());
} catch (\Throwable) {
}
}
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
$this->runs->update($run);
}
}
-64
View File
@@ -1,64 +0,0 @@
<?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;
}
}
-61
View File
@@ -1,61 +0,0 @@
<?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\RestoreService;
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 Browse extends Command {
public function __construct(
private TargetService $targets,
private RestoreService $restore,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:browse')
->setDescription('Show folders and files in a snapshot')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addArgument('path', InputArgument::OPTIONAL, 'Path in the snapshot, e.g. data/alice/files/Documents', '')
->setHelp("Top level: config/ and data/. User files are under data/<user>/files/.\n\nExample:\n occ backup:browse offsite 537bf7e9... data/alice/files");
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$items = $this->restore->browse($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), (string)$input->getArgument('path'));
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
if ($items === []) {
$output->writeln('Nothing here.');
return 0;
}
$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();
return 0;
}
private function size(int $b): string {
foreach (['B', 'KiB', 'MiB', 'GiB', 'TiB'] as $u) {
if ($b < 1024 || $u === 'TiB') {
return $u === 'B' ? "$b B" : sprintf('%.1f %s', $b, $u);
}
$b /= 1024;
}
return '';
}
}
-59
View File
@@ -1,59 +0,0 @@
<?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 Symfony\Component\Console\Command\Command;
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 KeyConfirm extends Command {
public const STATEMENT = 'I have downloaded the recovery kit and stored it safely, outside this server. '
. 'I understand that keeping these keys safe is my own responsibility and that backups cannot be restored without them.';
public const CONFIRMATION = 'Yes, I confirm';
public function __construct(
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:key:confirm')
->setDescription('Confirm that the recovery kit is stored safely (required before the first backup)')
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Admin user id recorded with the confirmation', 'occ')
->addOption('code', null, InputOption::VALUE_REQUIRED, 'Confirmation code from the kit file (for scripts; asked otherwise)')
->addOption('confirm', null, InputOption::VALUE_REQUIRED, 'The confirmation phrase "' . self::CONFIRMATION . '" (for scripts; asked otherwise)');
}
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;
}
$output->writeln('Key fingerprint: <info>' . $this->keys->fingerprint() . '</info>');
$output->writeln('');
$output->writeln(wordwrap(self::STATEMENT, 78));
$output->writeln('');
$code = $input->getOption('code') ?? (string)$this->getHelper('question')->ask($input, $output, new Question('Confirmation code from the recovery kit file ("confirmation_code"): '));
if (!$this->keys->checkKitCode($code)) {
$output->writeln('<error>That code does not match the current recovery kit (download it with occ backup:key:kit first).</error>');
return 1;
}
$answer = $input->getOption('confirm') ?? (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
if (trim($answer) !== self::CONFIRMATION) {
$output->writeln('<error>Not confirmed.</error>');
return 1;
}
$this->keys->confirmRecoveryKit((string)$input->getOption('user'));
$output->writeln('Confirmed. Backups can now run.');
return 0;
}
}
-85
View File
@@ -1,85 +0,0 @@
<?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 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,
) {
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')
->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.
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 {
if ($this->keys->isInitialized()) {
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '). occ backup:key:import is only for a fresh installation.</error>');
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 {
$this->keys->importRecoveryKit($kit, $pass);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('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;
}
}
-73
View File
@@ -1,73 +0,0 @@
<?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 Symfony\Component\Console\Command\Command;
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 KeyInit extends Command {
public function __construct(
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:key:init')
->setDescription('Create the backup key, protected by a passphrase you keep safe')
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label of the first passphrase slot (who/where), e.g. "Safe"', 'Slot 1')
->addOption('delete-delay-days', null, InputOption::VALUE_REQUIRED, 'Days a removed snapshot stays in the trash before its data is deleted (1-365, fixed after this)', (string)KeyService::DEFAULT_DELETE_DELAY)
->setHelp(<<<'HELP'
Creates the master key that encrypts all backups of this installation.
The server keeps the key (encrypted with this instance's secret) so scheduled backups can run.
A copy protected by your passphrase goes into every backup location and into the recovery kit
(occ backup:key:kit). To restore on a new server you need the recovery kit and the passphrase.
Example:
occ backup:key:init
occ backup:key:kit --output=/root/ng_backup-recovery-kit.json
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if ($this->keys->isInitialized()) {
$output->writeln('<error>A backup key already exists (fingerprint ' . $this->keys->fingerprint() . ').</error>');
return 1;
}
$file = $input->getOption('passphrase-file');
if ($file !== null) {
$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);
$pass = (string)$helper->ask($input, $output, $q);
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
if ($pass !== $again) {
$output->writeln('<error>The passphrases do not match.</error>');
return 1;
}
}
try {
$this->keys->initialize($pass, (int)$input->getOption('delete-delay-days'), (string)$input->getOption('label'));
} catch (\InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('Backup key created, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
$output->writeln('Removed snapshots stay in the trash on the location for <info>' . $this->keys->deleteDelayDays() . ' days</info> (cannot be changed later).');
$output->writeln('Tip: add up to two more passphrases for other people with occ backup:key:slot:add (e.g. CTO, head of IT).');
$output->writeln('Next: save the recovery kit with <info>occ backup:key:kit --output=FILE</info> and confirm with <info>occ backup:key:confirm</info>.');
return 0;
}
}
-63
View File
@@ -1,63 +0,0 @@
<?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 Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class KeyKit extends Command {
public function __construct(
private KeyService $keys,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:key:kit')
->setDescription('Write the recovery kit (passphrase-protected key) to a file or stdout')
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'File to write (default: stdout)');
}
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;
}
$this->alerts->securityEvent('kit_downloaded');
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
$file = $input->getOption('output');
if ($file === null) {
$output->write($json, false, OutputInterface::OUTPUT_RAW);
return 0;
}
// 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;
}
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
return 0;
}
}
-17
View File
@@ -1,17 +0,0 @@
<?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\KeySlotService;
final class KeySlotAdd extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'add');
}
}
-17
View File
@@ -1,17 +0,0 @@
<?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\KeySlotService;
final class KeySlotRemove extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'remove');
}
}
-17
View File
@@ -1,17 +0,0 @@
<?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\KeySlotService;
final class KeySlotReplace extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'replace');
}
}
-97
View File
@@ -1,97 +0,0 @@
<?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\KeySlotService;
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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
/** backup:key:slots, backup:key:slot:add, backup:key:slot:replace, backup:key:slot:remove */
class KeySlots extends Command {
public function __construct(
private KeyService $keys,
private KeySlotService $slots,
private string $action = 'list',
) {
// list is the default; subclasses pick another action
parent::__construct();
}
protected function configure(): void {
$help = <<<'HELP'
Up to three passphrases ("slots") can open the backup key, so that one person leaving or dying
does not lock you out. Example: one passphrase in the safe, one with the CTO, one with the head
of IT. Each holder keeps a copy of the same recovery kit and only knows their own passphrase.
After every change: occ backup:key:kit and occ backup:key:confirm.
HELP;
match ($this->action) {
'add' => $this->setName('backup:key:slot:add')->setDescription('Add a passphrase slot')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Who or where, e.g. "CTO" or "Safe"', 'Slot'),
'replace' => $this->setName('backup:key:slot:replace')->setDescription('Set a new passphrase for a slot (e.g. its holder left)')
->addArgument('slot', InputArgument::REQUIRED, 'Slot number (see backup:key:slots)')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'New label'),
'remove' => $this->setName('backup:key:slot:remove')->setDescription('Remove a passphrase slot (not the last one)')
->addArgument('slot', InputArgument::REQUIRED, 'Slot number (see backup:key:slots)'),
default => $this->setName('backup:key:slots')->setDescription('List the passphrase slots of the backup key'),
};
if ($this->action === 'add' || $this->action === 'replace') {
$this->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file');
}
$this->setHelp($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;
}
try {
switch ($this->action) {
case 'add':
$n = $this->slots->add($this->passphrase($input, $output), (string)$input->getOption('label'));
$output->writeln("Slot $n added.");
break;
case 'replace':
$this->slots->replace((int)$input->getArgument('slot'), $this->passphrase($input, $output), $input->getOption('label'));
$output->writeln('Slot ' . (int)$input->getArgument('slot') . ' has a new passphrase.');
break;
case 'remove':
$this->slots->remove((int)$input->getArgument('slot'));
$output->writeln('Slot ' . (int)$input->getArgument('slot') . ' removed.');
break;
default:
(new Table($output))->setHeaders(['slot', 'label', 'set on'])
->setRows(array_map(fn ($s) => [$s['slot'], $s['label'], $s['created'] ? date('Y-m-d H:i', $s['created']) : ''], $this->keys->slots()))->render();
return 0;
}
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('Now download and confirm the new recovery kit: occ backup:key:kit -o FILE && occ backup:key:confirm');
return 0;
}
private function passphrase(InputInterface $input, OutputInterface $output): string {
if ($file = $input->getOption('passphrase-file')) {
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));
if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) {
throw new \InvalidArgumentException('The passphrases do not match');
}
return $p;
}
}
-49
View File
@@ -1,49 +0,0 @@
<?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\SnapshotMapper;
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 ListSnapshots extends Command {
public function __construct(
private TargetService $targets,
private SnapshotMapper $snapshots,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:list')
->setDescription('List the snapshots (restore points) on a location')
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('target');
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
$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() ?? ''];
}
}
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();
return 0;
}
}
-70
View File
@@ -1,70 +0,0 @@
<?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 Prune extends Command {
public function __construct(
private TargetService $targets,
private PruneService $prune,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:prune')
->setDescription('Apply the retention policy: remove old snapshots and the data only they used')
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all that are not append-only)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show what would be removed');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('target');
$targets = $name === null ? array_filter($this->targets->list(), fn ($t) => !$t->getAppendOnly()) : [$this->targets->get($name)];
$dry = (bool)$input->getOption('dry-run');
$output->writeln('Policy: ' . json_encode($this->prune->policy()->toArray()) . ($dry ? ' (dry run)' : ''));
$rc = 0;
foreach ($targets as $t) {
// 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) {
$output->writeln('<error>' . $t->getName() . ': ' . $e->getMessage() . '</error>');
$rc = 1;
continue;
}
$p = $r['prune'];
$output->writeln(sprintf('<info>%s</info>: keep %d, %s %d snapshots; packs: %d, %s %d, repack %d, frees %.1f MiB',
$t->getName(), count($r['kept']), $dry ? 'would forget' : 'forgot', count($r['forgotten']), $p['packs'],
$dry ? 'would delete' : 'deleted', $p['deleted'], $p['repacked'], $p['freedBytes'] / 1048576));
}
return $rc;
}
}
-71
View File
@@ -1,71 +0,0 @@
<?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\RestoreService;
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 Restore extends Command {
public function __construct(
private TargetService $targets,
private RestoreService $restore,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:restore')
->setDescription('Restore files or folders from a snapshot')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->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)')
->setHelp(<<<'HELP'
User files (data/<user>/files/...) are restored through Nextcloud, so versions, trash and the
file cache stay correct. Modes:
new-folder (default) into "Restored <date>/<same path>" in the user's files; nothing existing is touched
merge into the original place; overwritten files keep their old content as a version
replace like merge, and files that are not in the snapshot are moved to the trash
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
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
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);
$output->writeln("Restored <info>$n</info> files to $dir.");
return 0;
}
$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");
}
});
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
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;
}
}
-97
View File
@@ -1,97 +0,0 @@
<?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,
) {
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')
->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.
Every app that was enabled when the snapshot was taken must already be installed here (its
database tables must exist before the restore can write into them); the command lists what is
missing and refuses to continue rather than guess.
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.
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');
$result = $this->restoreFull->restoreFull($target, $snapshot);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
$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.');
return 1;
}
$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['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>');
}
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
return 0;
}
}
-82
View File
@@ -1,82 +0,0 @@
<?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>)')
->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
Example:
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34
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');
$uid = explode('/', (string)$input->getArgument('manifest'))[1] ?? '';
if ($mode === UserRestoreService::MODE_REPLACE && $uid !== '' && $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>');
$target = $this->targets->get($input->getArgument('target'));
$this->installer->ensureEnabled($input, $output);
$user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), $mode);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('Restored <info>' . $user->getUID() . '</info>.');
return 0;
}
}
-68
View File
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\PruneService;
use OCA\NgBackup\Service\RetentionPolicy;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class Retention extends Command {
public function __construct(
private PruneService $prune,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:retention')
->setDescription('Show or set how many snapshots are kept')
->addOption('last', null, InputOption::VALUE_REQUIRED, 'Always keep the newest N')
->addOption('daily', null, InputOption::VALUE_REQUIRED, 'Keep one per day for the last N days with a backup')
->addOption('weekly', null, InputOption::VALUE_REQUIRED, 'Keep one per week for N weeks')
->addOption('monthly', null, InputOption::VALUE_REQUIRED, 'Keep one per month for N months')
->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) {
if ($input->getOption($k) !== null) {
$p[$k] = max(0, (int)$input->getOption($k));
$changed = true;
}
}
if ($changed) {
try {
$this->prune->setPolicy(RetentionPolicy::fromArray($p));
} catch (\InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
}
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;
}
}
-92
View File
@@ -1,92 +0,0 @@
<?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\Service\BackupService;
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 RunBackup extends Command {
public function __construct(
private BackupService $backups,
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:run')
->setDescription('Back up this Nextcloud (database, config, data) to a location now')
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all locations)')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label for the snapshot', '')
->addOption('background', 'b', InputOption::VALUE_NONE, 'Only start the run; background jobs finish it')
->setHelp(<<<'HELP'
Starts a backup (or continues one that is already running for that location) and runs it to
the end in this process. Unchanged files are not read again; only new data is uploaded.
Examples:
occ backup:run back up to every location
occ backup:run offsite -l "before upgrade"
occ backup:run offsite --background
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('target');
try {
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
} catch (\Throwable) {
$output->writeln('<error>No such location.</error>');
return 1;
}
if ($targets === []) {
$output->writeln('<error>No backup locations yet (occ backup:target:add).</error>');
return 1;
}
$failed = 0;
foreach ($targets as $t) {
try {
$run = $this->backups->start($t, (string)$input->getOption('label'));
} catch (\Throwable $e) {
$output->writeln('<error>' . $t->getName() . ': ' . $e->getMessage() . '</error>');
$failed++;
continue;
}
$output->writeln(sprintf('<info>%s</info>: run %d, snapshot %s', $t->getName(), $run->getId(), $run->getSnapshotId()));
if ($input->getOption('background')) {
continue;
}
$start = microtime(true);
$run = $this->backups->runToCompletion($run, function (Run $r) use ($output, $start) {
$s = json_decode($r->getStats() ?? '{}', true);
$f = $s['files'] ?? [];
$output->writeln(sprintf(' %5.0fs %-6s files %d (read %d, unchanged %d), uploaded %.1f MiB',
microtime(true) - $start, $r->getPhase(), $f['files'] ?? 0, $f['read'] ?? 0, $f['reused'] ?? 0, ($f['uploaded'] ?? 0) / 1048576));
});
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['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++;
}
}
return $failed === 0 ? 0 : 1;
}
}
-56
View File
@@ -1,56 +0,0 @@
<?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\RunMapper;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class Status extends Command {
public function __construct(
private KeyService $keys,
private TargetService $targets,
private RunMapper $runs,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
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)'));
}
$names = [];
foreach ($this->targets->list() as $t) {
$names[$t->getId()] = $t->getName();
}
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
$rows = [];
foreach ($this->runs->findRecent(10) 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, '…')];
}
if ($rows !== []) {
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'uploaded', 'error'])->setRows($rows)->render();
}
return 0;
}
}
-73
View File
@@ -1,73 +0,0 @@
<?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;
final class TargetAdd extends Command {
public function __construct(
private TargetService $targets,
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:add')
->setDescription('Add a backup location (S3, SFTP, WebDAV, ...) via files_external, without mounting it')
->addArgument('name', InputArgument::REQUIRED, 'Name of the location')
->addArgument('backend', InputArgument::REQUIRED, 'files_external backend id (see backup:target:backends)')
->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')
->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.
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
occ backup:target:add nas sftp -a password::password -o host=nas.example.lan -o root=/backups -o user=nextcloud -o password=... --path=ng_backup
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, '=')) {
$output->writeln("<error>Option '$pair' must be key=value</error>");
return 1;
}
[$k, $v] = explode('=', $pair, 2);
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
}
if ($input->getOption('auth') === null) {
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
return 1;
}
try {
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path'));
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$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'));
return 0;
}
}
-45
View File
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class TargetAppendOnly extends Command {
public function __construct(
private TargetService $targets,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:append-only')
->setDescription('Make a location append-only (NG Backup never deletes there) or normal again')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('state', InputArgument::REQUIRED, 'on or off')
->setHelp("Append-only protects backups if this server is compromised: combine it with credentials\nwithout delete rights and a lifecycle rule or object lock on the location itself.");
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$state = strtolower((string)$input->getArgument('state'));
if (!in_array($state, ['on', 'off'], true)) {
$output->writeln('<error>state must be on or off</error>');
return 1;
}
$t = $this->targets->setAppendOnly($this->targets->get($input->getArgument('target')), $state === 'on');
if ($state === 'off') {
$this->alerts->securityEvent('append_only_off', ['target' => $t->getName()]);
}
$output->writeln($t->getName() . ' is ' . ($t->getAppendOnly() ? 'append-only' : 'normal') . '.');
return 0;
}
}
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OC\Core\Command\Base;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class TargetBackends extends Base {
public function __construct(
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('backup:target:backends')
->setDescription('List the storage backends (from files_external) that can be used as backup location');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$backends = $this->targets->availableBackends();
if ($input->getOption('output') !== self::OUTPUT_FORMAT_PLAIN) {
$this->writeMixedInOutputFormat($input, $output, $backends);
return 0;
}
foreach ($backends as $b) {
$output->writeln(sprintf('<info>%s</info> (%s)', $b['id'], $b['name']));
foreach ($b['parameters'] as $key => $p) {
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
}
foreach ($b['auth'] as $a) {
$output->writeln(sprintf(' -a %s (%s)', $a['id'], $a['name']));
foreach ($a['parameters'] as $key => $p) {
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
}
}
}
return 0;
}
}
-52
View File
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class TargetLimit extends Command {
public function __construct(
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:limit')
->setDescription('Set a maximum size for the backups stored on a location (e.g. 100G), or "none"')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('size', InputArgument::OPTIONAL, 'Size like 500M, 20G, 2T, or "none"; omit to show the current limit')
->setHelp("A backup that would exceed the limit stops with an error and administrators are notified.\n"
. "Note: with versioning or Object Lock on the location, removed data keeps using space there\n"
. "until it expires; keep the limit below what you are willing to pay for.");
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$t = $this->targets->get($input->getArgument('target'));
$size = $input->getArgument('size');
if ($size !== null) {
if (strtolower($size) === 'none') {
$t = $this->targets->setMaxBytes($t, null);
} elseif (preg_match('/^(\d+(?:\.\d+)?)\s*([KMGT]?)i?B?$/i', $size, $m)) {
$factor = ['' => 1, 'K' => 1024, 'M' => 1024 ** 2, 'G' => 1024 ** 3, 'T' => 1024 ** 4][strtoupper($m[2])];
$t = $this->targets->setMaxBytes($t, (int)round((float)$m[1] * $factor));
} else {
$output->writeln('<error>Use a size like 500M, 20G or 2T, or "none".</error>');
return 1;
}
}
$used = $this->targets->repository($t)->storedBytes();
$output->writeln(sprintf('%s: %.2f GiB stored, limit %s', $t->getName(), $used / 1073741824,
$t->getMaxBytes() === null ? 'none' : sprintf('%.2f GiB', $t->getMaxBytes() / 1073741824)));
return 0;
}
}
-57
View File
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OC\Core\Command\Base;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class TargetList extends Base {
public function __construct(
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('backup:target:list')
->setDescription('List backup locations')
->addOption('test', 't', InputOption::VALUE_NONE, 'Also connect to each location and check the repository');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$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())];
if ($input->getOption('test')) {
try {
$repo = $this->targets->repository($t);
$row['status'] = 'ok, ' . count($repo->listSnapshots()) . ' snapshots';
} catch (\Throwable $e) {
$row['status'] = 'error: ' . $e->getMessage();
}
}
$rows[] = $row;
}
if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) {
if ($rows === []) {
$output->writeln('No backup locations yet (occ backup:target:add).');
return 0;
}
$table = new \Symfony\Component\Console\Helper\Table($output);
$table->setHeaders(array_keys($rows[0]))->setRows(array_map('array_values', $rows))->render();
} else {
$this->writeMixedInOutputFormat($input, $output, $rows);
}
return 0;
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class TargetRemove extends Command {
public function __construct(
private TargetService $targets,
private \OCA\NgBackup\Service\AlertService $alerts,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:remove')
->setDescription('Remove a backup location from NG Backup (the backups on it are kept)')
->addArgument('target', InputArgument::REQUIRED, 'Name or id');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$t = $this->targets->get($input->getArgument('target'));
} catch (\Throwable) {
$output->writeln('<error>No such location.</error>');
return 1;
}
$this->targets->remove($t);
$this->alerts->securityEvent('target_removed', ['target' => $t->getName()]);
$output->writeln('Removed <info>' . $t->getName() . '</info>. The backups on the location itself were not touched; add it again to use them.');
return 0;
}
}
-57
View File
@@ -1,57 +0,0 @@
<?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\AppConfigCatalogAnchor;
use OCA\NgBackup\Service\AlertService;
use OCA\NgBackup\Service\TargetService;
use OCP\IAppConfig;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
/**
* Accept the state a location shows now, after NG Backup refused it as rolled back. Only for
* legitimate cases (e.g. the location was restored from the provider's own snapshot), only from
* occ, audited and announced to all administrators.
*/
final class TargetTrustCurrent extends Command {
public function __construct(
private TargetService $targets,
private IAppConfig $appConfig,
private AlertService $alerts,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:trust-current')
->setDescription('Accept the current state of a location that was refused as rolled back')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->setHelp("NG Backup refuses a location whose backup catalog is older than what this server saw\n"
. "before, because an attacker could have restored an old state. If you restored the location\n"
. "yourself (e.g. from the provider's snapshot), accept it with this command.");
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$t = $this->targets->get($input->getArgument('target'));
$output->writeln('<comment>Only continue if you know why the location shows an older state.</comment>');
$answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type the location name to accept its current state: '));
if ($answer !== $t->getName()) {
$output->writeln('<error>Not confirmed.</error>');
return 1;
}
$this->appConfig->deleteKey('ng_backup', 'catalog_' . preg_replace('/[^a-f0-9]/', '', (string)$t->getRepositoryId()));
$repo = $this->targets->repository($t); // re-anchors on the newest valid catalog
$this->alerts->securityEvent('catalog_trusted', ['target' => $t->getName(), 'generation' => (string)$repo->catalogGeneration()]);
$output->writeln(sprintf('Accepted generation %d of %s (%d snapshots).', $repo->catalogGeneration(), $t->getName(), count($repo->listSnapshots())));
return 0;
}
}
-49
View File
@@ -1,49 +0,0 @@
<?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\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class Trash extends Command {
public function __construct(
private TargetService $targets,
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:trash')
->setDescription('List removed snapshots that can still be restored (backup:trash:restore)')
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('target');
$delay = $this->keys->deleteDelayDays() * 86400;
$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)];
}
}
if ($rows === []) {
$output->writeln('The trash is empty.');
return 0;
}
(new Table($output))->setHeaders(['location', 'snapshot', 'made', 'label', 'removed', 'by', 'deleted after'])->setRows($rows)->render();
return 0;
}
}
-42
View File
@@ -1,42 +0,0 @@
<?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 TrashRestore extends Command {
public function __construct(
private TargetService $targets,
private PruneService $prune,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:trash:restore')
->setDescription('Bring a removed snapshot back from the trash')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:trash)');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$this->prune->untrash($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'));
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('Snapshot restored from the trash.');
return 0;
}
}
-75
View File
@@ -1,75 +0,0 @@
<?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.
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');
$result = str_starts_with($ref, 'users/')
? $this->verify->verifyUserExport($target, $ref, $deep)
: $this->verify->verify($target, $ref, $deep);
} 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['missing'] === [] && $result['failed'] === []) {
$output->writeln('<info>OK</info>');
return 0;
}
return 1;
}
}
-288
View File
@@ -1,288 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Controller;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\BackgroundJob\RestoreJob;
use OCA\NgBackup\Command\KeyConfirm;
use OCA\NgBackup\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Service\BackupService;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\RestoreService;
use OCA\NgBackup\Service\TargetService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\BackgroundJob\IJobList;
use OCP\IAppConfig;
use OCP\IRequest;
use OCP\IUserSession;
/**
* JSON API for the admin settings page. Every method requires an admin (the AppFramework
* default for controllers without NoAdminRequired) and a valid CSRF token.
*/
final class AdminApiController extends Controller {
public function __construct(
IRequest $request,
private KeyService $keys,
private TargetService $targets,
private BackupService $backups,
private RestoreService $restore,
private RunMapper $runs,
private SnapshotMapper $snapshots,
private IAppConfig $appConfig,
private IJobList $jobs,
private IUserSession $session,
private \OCA\NgBackup\Service\AlertService $alerts,
private \OCA\NgBackup\Service\KeySlotService $slots,
private \OCA\NgBackup\Service\PruneService $prune,
) {
parent::__construct(Application::APP_ID, $request);
}
public function status(): JSONResponse {
$targets = [];
foreach ($this->targets->list() as $t) {
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(),
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)];
}
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
return new JSONResponse([
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(),
'slots' => $this->keys->isInitialized() ? $this->keys->slots() : [], 'maxSlots' => \OCA\NgBackup\Service\KeyService::MAX_SLOTS,
'deleteDelayDays' => $this->keys->isInitialized() ? $this->keys->deleteDelayDays() : \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY],
'statement' => KeyConfirm::STATEMENT,
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
'targets' => $targets,
'runs' => $runs,
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
]);
}
#[PasswordConfirmationRequired]
public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse {
try {
$this->keys->initialize($passphrase, $deleteDelayDays, $label);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new JSONResponse(['fingerprint' => $this->keys->fingerprint()]);
}
/**
* Plain download link (no CSRF token on a navigation), therefore guarded by a recent password
* confirmation instead, logged to the audit log and announced to all administrators. The kit
* only holds the passphrase-wrapped key: useless without the passphrase.
*/
#[NoCSRFRequired]
#[PasswordConfirmationRequired]
public function downloadKit(): DataDownloadResponse|JSONResponse {
if (!$this->keys->isInitialized()) {
return new JSONResponse(['error' => 'No backup key yet'], Http::STATUS_BAD_REQUEST);
}
$this->alerts->securityEvent('kit_downloaded');
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json');
}
/** The admin must tick the statement, send the exact phrase and the code from the kit file. */
#[PasswordConfirmationRequired]
public function confirmKit(bool $accepted, string $phrase, string $code = ''): JSONResponse {
if (!$accepted || $phrase !== KeyConfirm::CONFIRMATION) {
return new JSONResponse(['error' => 'Not confirmed'], Http::STATUS_BAD_REQUEST);
}
if (!$this->keys->checkKitCode($code)) {
return new JSONResponse(['error' => 'The confirmation code does not match the current recovery kit'], Http::STATUS_BAD_REQUEST);
}
$this->keys->confirmRecoveryKit($this->session->getUser()?->getUID() ?? 'unknown');
return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]);
}
#[PasswordConfirmationRequired]
public function addSlot(string $passphrase, string $label): JSONResponse {
try {
return new JSONResponse(['slot' => $this->slots->add($passphrase, $label)]);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
#[PasswordConfirmationRequired]
public function replaceSlot(int $slot, string $passphrase, ?string $label = null): JSONResponse {
try {
$this->slots->replace($slot, $passphrase, $label);
return new JSONResponse([]);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
#[PasswordConfirmationRequired]
public function removeSlot(int $slot): JSONResponse {
try {
$this->slots->remove($slot);
return new JSONResponse([]);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
public function trash(int $targetId): JSONResponse {
try {
$delay = $this->keys->deleteDelayDays() * 86400;
$out = [];
foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() 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);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
#[PasswordConfirmationRequired]
public function untrash(int $targetId, string $snapshotId): JSONResponse {
try {
$this->prune->untrash($this->targets->get((string)$targetId), $snapshotId);
return new JSONResponse([]);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
public function backends(): JSONResponse {
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);
if ($maxGb !== null && $maxGb > 0) {
$this->targets->setMaxBytes($r['target'], (int)round($maxGb * 1073741824));
}
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
}
/** Size limit in GiB; null or 0 = unlimited. */
#[PasswordConfirmationRequired]
public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse {
$t = $this->targets->setMaxBytes($this->targets->get((string)$id), ($maxGb !== null && $maxGb > 0) ? (int)round($maxGb * 1073741824) : null);
return new JSONResponse(['maxBytes' => $t->getMaxBytes()]);
}
#[PasswordConfirmationRequired]
public function removeTarget(int $id): JSONResponse {
$target = $this->targets->get((string)$id);
$this->targets->remove($target);
$this->alerts->securityEvent('target_removed', ['target' => $target->getName()]);
return new JSONResponse([]);
}
public function testTarget(int $id): JSONResponse {
try {
$repo = $this->targets->repository($this->targets->get((string)$id));
return new JSONResponse(['ok' => true, 'snapshots' => count($repo->listSnapshots())]);
} catch (\Throwable $e) {
return new JSONResponse(['ok' => false, 'error' => $e->getMessage()]);
}
}
public function startBackup(int $targetId, string $label = ''): JSONResponse {
try {
$run = $this->backups->start($this->targets->get((string)$targetId), $label);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
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);
}
$this->appConfig->setValueString(Application::APP_ID, 'schedule_time', $time);
return new JSONResponse(['schedule' => $time]);
}
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()];
}
return new JSONResponse($out);
}
public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse {
try {
return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path));
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
/** Queue a restore of user files; it runs as a background job and shows up in the runs. */
#[PasswordConfirmationRequired]
public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse {
if (!in_array($mode, [RestoreService::MODE_NEW_FOLDER, RestoreService::MODE_MERGE, RestoreService::MODE_REPLACE], true)
|| !\OCA\NgBackup\Repository\Repository::isSafePath($path) || !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) {
return new JSONResponse(['error' => 'Invalid path or mode'], Http::STATUS_BAD_REQUEST);
}
try {
$target = $this->targets->get((string)$targetId);
} catch (\Throwable) {
return new JSONResponse(['error' => 'No such location'], Http::STATUS_NOT_FOUND);
}
$run = new Run();
$run->setTargetId($target->getId());
$run->setKind('restore');
$run->setStatus(Run::RUNNING);
$run->setPhase('queued');
$run->setState(json_encode(['snapshot' => $snapshotId, 'path' => $path, 'mode' => $mode,
'by' => $this->session->getUser()?->getUID()], JSON_THROW_ON_ERROR));
$run->setSnapshotId($snapshotId);
$run->setStartedAt(time());
$run->setUpdatedAt(time());
try {
$run = $this->prune->underStartLock($target, fn () => $this->runs->insert($run));
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_CONFLICT);
}
$this->jobs->add(RestoreJob::class, ['run' => $run->getId()]);
return new JSONResponse($this->runJson($run));
}
private function runJson(Run $r): array {
$snapshot = null;
if ($r->getSnapshotId() !== null) {
try {
$s = $this->snapshots->findOne($r->getTargetId(), $r->getSnapshotId());
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel()];
} catch (\Throwable) {
// Snapshot already pruned or trashed: the run keeps its id, just not the date.
}
}
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)];
}
}
+1 -1
View File
@@ -7,5 +7,5 @@ declare(strict_types=1);
namespace OCA\NgBackup\Crypto;
final class CryptoException extends \RuntimeException {
class CryptoException extends \RuntimeException {
}
-28
View File
@@ -37,11 +37,6 @@ final class KeyRing {
return new self(sodium_crypto_kdf_keygen());
}
/** Raw master key, only for storing it encrypted on the server (ICrypto). */
public function exportMasterKey(): string {
return $this->masterKey;
}
public function dataKey(): string {
return $this->dataKey;
}
@@ -76,32 +71,9 @@ final class KeyRing {
];
}
/**
* Open a key field that is either one wrapped key or {"slots": [{slot, label, key}, ...]}:
* every slot holds the same master key under its own passphrase.
*/
public static function unwrapAny(array $field, #[\SensitiveParameter] string $passphrase): self {
if (!isset($field['slots'])) {
return self::unwrap($field, $passphrase);
}
foreach ($field['slots'] as $slot) {
try {
return self::unwrap($slot['key'], $passphrase);
} catch (CryptoException) {
}
}
throw new CryptoException('Wrong passphrase or damaged key');
}
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
// 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 = 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);
-8
View File
@@ -122,7 +122,6 @@ 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) {
@@ -133,15 +132,8 @@ 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;
-34
View File
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Repository\ICatalogAnchor;
use OCP\IAppConfig;
/** Keeps the newest catalog generation seen per repository in the app config (outside the location). */
final class AppConfigCatalogAnchor implements ICatalogAnchor {
public function __construct(
private IAppConfig $appConfig,
) {
}
public function get(string $repositoryId): ?array {
$raw = $this->appConfig->getValueString(Application::APP_ID, $this->key($repositoryId), '', true);
$data = $raw === '' ? null : json_decode($raw, true);
return is_array($data) && isset($data['gen'], $data['hash']) ? ['gen' => (int)$data['gen'], 'hash' => (string)$data['hash']] : null;
}
public function set(string $repositoryId, int $gen, string $hash): void {
$this->appConfig->setValueString(Application::APP_ID, $this->key($repositoryId), json_encode(['gen' => $gen, 'hash' => $hash]), true);
}
private function key(string $repositoryId): string {
return 'catalog_' . preg_replace('/[^a-f0-9]/', '', $repositoryId);
}
}
+46 -129
View File
@@ -31,8 +31,6 @@ use OCA\NgBackup\Repository\Repository;
*/
final class DbDumper {
private const BATCH = 2000;
/** Rows per INSERT on restore; keeps the number of bound parameters well below database limits. */
private const INSERT_ROWS = 100;
/** Tables without a primary key are read in one query; refuse that above this size. */
private const MAX_ROWS_WITHOUT_PK = 50000;
@@ -43,87 +41,39 @@ final class DbDumper {
}
/**
* Dump everything in one go (one transaction, fully consistent).
*
* @return array{tables: array<string, array{blobs: list<string>, rows: int, bytes: int, sha256: string}>, sequences: array, provider: string, consistent: bool}
* @return array{tables: array<string, array{blobs: list<string>, rows: int, bytes: int, sha256: string}>, provider: string}
*/
public function dump(Repository $repo, array &$stats): array {
$state = $this->startDump();
while (!$state['done']) {
$state = $this->dumpStep($repo, $state, PHP_FLOAT_MAX, $stats);
}
return $this->manifest($state);
}
/** State for a resumable dump (see dumpStep()). */
public function startDump(): array {
$tables = array_values(array_filter($this->db->createSchemaManager()->listTableNames(), fn (string $t) => str_starts_with($t, $this->prefix)));
sort($tables);
return ['tables' => $tables, 'i' => 0, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null,
'result' => [], 'sequences' => [], 'steps' => 0, 'done' => false];
}
/**
* Dump tables until $deadline, inside one REPEATABLE READ transaction for this step. A table
* that does not fit is resumed in the next step at the last primary key written. If the whole
* dump fits in one step it is fully consistent; otherwise each step is consistent on its own
* ('consistent' => false in the manifest).
*/
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
$state['steps']++;
$sm = $this->db->createSchemaManager();
$tables = array_values(array_filter($sm->listTableNames(), fn (string $t) => str_starts_with($t, $this->prefix)));
sort($tables);
$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');
}
while ($state['i'] < count($state['tables'])) {
$table = $state['tables'][$state['i']];
$schema = $sm->introspectTable($table);
$result = [];
foreach ($tables as $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]) {
$hash = hash_init('sha256');
$rows = 0;
foreach ($this->tableLines($sm->introspectTable($table)) as $kind => $line) {
$writer->write($line . "\n");
hash_update($hash, $line . "\n");
$state['rows']++;
$state['after'] = $key;
if (microtime(true) >= $deadline) {
$complete = false;
break;
if ($kind === 'row') {
hash_update($hash, $line . "\n");
$rows++;
}
}
$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)];
$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;
$result[$table] = ['blobs' => $writer->finish(), 'rows' => $rows, 'bytes' => $writer->bytes(), 'sha256' => hash_final($hash)];
}
$sequences = $this->sequenceStates($tables);
} finally {
$this->db->rollBack();
}
$repo->flushPacks();
return $state;
}
public function manifest(array $state): array {
return ['tables' => $state['result'], 'sequences' => $state['sequences'],
'provider' => get_class($this->db->getDatabasePlatform()), 'consistent' => $state['steps'] === 1];
return ['tables' => $result, 'sequences' => $sequences, 'provider' => get_class($this->db->getDatabasePlatform())];
}
/**
@@ -132,23 +82,16 @@ final class DbDumper {
*
* @param array{tables: array<string, array{blobs: list<string>, rows: int}>} $manifest
*/
public function restore(Repository $repo, array $manifest, ?array &$report = null, ?callable $heartbeat = null): int {
public function restore(Repository $repo, array $manifest): int {
$sm = $this->db->createSchemaManager();
$existing = array_flip($sm->listTableNames());
$total = 0;
$report = ['skipped' => [], 'restored' => []];
$this->db->beginTransaction();
try {
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?)");
}
// Unchanged since the backup: leave it alone (much faster than delete + insert).
$live = $this->checksum($table);
if ($live['rows'] === $info['rows'] && hash_equals($info['sha256'], $live['sha256'])) {
$report['skipped'][] = $table;
continue;
}
$this->db->executeStatement('DELETE FROM ' . $this->db->quoteIdentifier($table));
$columns = null;
$binary = [];
@@ -169,21 +112,17 @@ final class DbDumper {
if (is_array($value) && isset($value['$b'])) {
$value = base64_decode($value['$b'], true); // text that was not valid UTF-8
}
$row[] = ($value !== null && isset($binary[$col])) ? base64_decode($value, true) : $value;
$row[$col] = ($value !== null && isset($binary[$col])) ? base64_decode($value, true) : $value;
}
$batch[] = $row;
if (count($batch) >= self::INSERT_ROWS) {
$total += $this->insertBatch($table, $columns, $batch, $binary);
if (count($batch) >= 500) {
$total += $this->insertBatch($table, $batch, $binary);
$batch = [];
if ($heartbeat !== null) {
$heartbeat();
}
}
}
if ($batch !== []) {
$total += $this->insertBatch($table, $columns, $batch, $binary);
$total += $this->insertBatch($table, $batch, $binary);
}
$report['restored'][] = $table;
}
$this->restoreSequences($manifest['sequences'] ?? []);
$this->db->commit();
@@ -196,22 +135,20 @@ final class DbDumper {
/** Same checksum as dump(): sha256 over the row lines in primary-key order. */
public function checksum(string $table): array {
$sm = $this->db->createSchemaManager();
$hash = hash_init('sha256');
$rows = 0;
foreach ($this->rowLines($this->db->createSchemaManager()->introspectTable($table)) as [, $line]) {
hash_update($hash, $line . "\n");
$rows++;
foreach ($this->tableLines($sm->introspectTable($table)) as $kind => $line) {
if ($kind === 'row') {
hash_update($hash, $line . "\n");
$rows++;
}
}
return ['rows' => $rows, 'sha256' => hash_final($hash)];
}
private function header(\Doctrine\DBAL\Schema\Table $table): string {
[$columns, $binary, $pk] = $this->describe($table);
return json_encode(['table' => $table->getName(), 'columns' => $columns, 'binary' => $binary, 'pk' => $pk], JSON_THROW_ON_ERROR);
}
/** @return array{0: list<string>, 1: list<string>, 2: list<string>} columns, binary columns, primary key */
private function describe(\Doctrine\DBAL\Schema\Table $table): array {
/** @return \Generator<string, string> 'header' => line, then 'row' => line ... */
private function tableLines(\Doctrine\DBAL\Schema\Table $table): \Generator {
$columns = array_values(array_map(fn ($c) => $c->getName(), $table->getColumns()));
$binary = [];
foreach ($table->getColumns() as $c) {
@@ -219,34 +156,25 @@ final class DbDumper {
$binary[] = $c->getName();
}
}
return [$columns, $binary, $table->getPrimaryKey()?->getColumns() ?? []];
}
$pk = $table->getPrimaryKey()?->getColumns() ?? [];
yield 'header' => json_encode(['table' => $table->getName(), 'columns' => array_values($columns), 'binary' => $binary, 'pk' => $pk], JSON_THROW_ON_ERROR);
/**
* Rows in primary-key order, after the key $after (resume), as [key, JSON line].
*
* @return \Generator<array{0: list<mixed>|null, 1: string}>
*/
private function rowLines(\Doctrine\DBAL\Schema\Table $table, ?array $after = null): \Generator {
[$columns, $binary, $pk] = $this->describe($table);
$binaryFlip = array_flip($binary);
$q = fn (string $c) => $this->db->quoteIdentifier($c);
$select = 'SELECT ' . implode(', ', array_map($q, $columns)) . ' FROM ' . $q($table->getName());
if ($pk === []) {
// Nextcloud requires primary keys; small odd tables are read in one query (not resumable).
// Nextcloud requires primary keys; small odd tables are read in one query.
$count = (int)$this->db->fetchOne('SELECT COUNT(*) FROM ' . $q($table->getName()));
if ($count > self::MAX_ROWS_WITHOUT_PK) {
throw new \RuntimeException("Table {$table->getName()} has no primary key and $count rows; cannot dump it with bounded memory");
}
$batches = [$this->db->fetchAllNumeric($select)];
$pkIdx = [];
} else {
$batches = $this->keysetBatches($select, $pk, $columns, $after);
$pkIdx = array_map(fn ($c) => array_search($c, $columns, true), $pk);
$batches = $this->keysetBatches($select, $pk, $columns);
}
foreach ($batches as $rows) {
foreach ($rows as $row) {
$key = $pkIdx === [] ? null : array_map(fn ($i) => $row[$i], $pkIdx);
foreach ($columns as $i => $col) {
if (is_resource($row[$i])) {
$row[$i] = stream_get_contents($row[$i]);
@@ -256,32 +184,28 @@ 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_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])) {
} elseif (is_int($row[$i]) || is_float($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]);
}
}
yield [$key, json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)];
yield 'row' => json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
}
}
/** @return \Generator<list<list<mixed>>> */
private function keysetBatches(string $select, array $pk, array $columns, ?array $after): \Generator {
private function keysetBatches(string $select, array $pk, array $columns): \Generator {
$q = fn (string $c) => $this->db->quoteIdentifier($c);
$order = ' ORDER BY ' . implode(', ', array_map($q, $pk));
$cols = array_map(fn ($c) => $q($c), $pk);
$last = $after;
$pkIdx = array_map(fn ($c) => array_search($c, $columns, true), $pk);
$last = null;
while (true) {
$sql = $select;
$params = [];
if ($last !== null) {
// Row-value comparison (a, b) > (?, ?), supported by PostgreSQL, MySQL/MariaDB and SQLite.
$sql .= ' WHERE (' . implode(', ', $cols) . ') > (' . implode(', ', array_fill(0, count($pk), '?')) . ')';
$sql .= ' WHERE (' . implode(', ', array_map($q, $pk)) . ') > (' . implode(', ', array_fill(0, count($pk), '?')) . ')';
$params = $last;
}
$rows = $this->db->fetchAllNumeric($sql . $order . ' LIMIT ' . self::BATCH, $params);
@@ -289,29 +213,22 @@ final class DbDumper {
return;
}
yield $rows;
$end = end($rows);
$last = array_map(fn ($i) => $end[$i], $pkIdx);
if (count($rows) < self::BATCH) {
return;
}
$end = end($rows);
$last = array_map(fn ($c) => $end[array_search($c, $columns, true)], $pk);
}
}
/** One INSERT with several rows (portable multi-row VALUES). */
private function insertBatch(string $table, array $columns, array $rows, array $binary): int {
$q = fn (string $c) => $this->db->quoteIdentifier($c);
$placeholders = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')';
$sql = 'INSERT INTO ' . $q($table) . ' (' . implode(', ', array_map($q, $columns)) . ') VALUES '
. implode(', ', array_fill(0, count($rows), $placeholders));
$params = [];
$types = [];
private function insertBatch(string $table, array $rows, array $binary): int {
foreach ($rows as $row) {
foreach ($columns as $i => $col) {
$params[] = $row[$i];
$types[] = isset($binary[$col]) ? \Doctrine\DBAL\ParameterType::LARGE_OBJECT : ($row[$i] === null ? \Doctrine\DBAL\ParameterType::NULL : \Doctrine\DBAL\ParameterType::STRING);
$types = [];
foreach ($row as $col => $_) {
$types[$col] = isset($binary[$col]) ? \Doctrine\DBAL\ParameterType::LARGE_OBJECT : \Doctrine\DBAL\ParameterType::STRING;
}
$this->db->insert($this->db->quoteIdentifier($table), array_combine(array_map(fn ($c) => $this->db->quoteIdentifier($c), array_keys($row)), $row), array_values($types));
}
$this->db->executeStatement($sql, $params, $types);
return count($rows);
}
-57
View File
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCA\NgBackup\Repository\IIndexCache;
use OCP\DB\Exception;
use OCP\IDBConnection;
final class DbIndexCache implements IIndexCache {
public function __construct(
private IDBConnection $db,
) {
}
public function all(string $repositoryId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('pack_id', 'entries')->from('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
$result = $qb->executeQuery();
$out = [];
while ($row = $result->fetch()) {
$out[$row['pack_id']] = json_decode($row['entries'], true, 512, JSON_THROW_ON_ERROR);
}
$result->closeCursor();
return $out;
}
public function put(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;
}
}
}
public function remove(string $repositoryId, array $packIds): void {
foreach (array_chunk($packIds, 500) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->delete('ngb_index')
->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)))
->andWhere($qb->expr()->in('pack_id', $qb->createNamedParameter($chunk, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR_ARRAY)));
$qb->executeStatement();
}
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method int getTargetId()
* @method void setTargetId(int $v)
* @method string getKind()
* @method void setKind(string $v)
* @method string getStatus()
* @method void setStatus(string $v)
* @method string getPhase()
* @method void setPhase(string $v)
* @method string getState()
* @method void setState(string $v)
* @method string|null getStats()
* @method void setStats(?string $v)
* @method string|null getError()
* @method void setError(?string $v)
* @method string|null getSnapshotId()
* @method void setSnapshotId(?string $v)
* @method int getStartedAt()
* @method void setStartedAt(int $v)
* @method int getUpdatedAt()
* @method void setUpdatedAt(int $v)
* @method int|null getFinishedAt()
* @method void setFinishedAt(?int $v)
*/
final class Run extends Entity {
public const RUNNING = 'running';
public const DONE = 'done';
public const FAILED = 'failed';
public const CANCELLED = 'cancelled';
protected int $targetId = 0;
protected string $kind = '';
protected string $status = '';
protected string $phase = '';
protected string $state = '{}';
protected ?string $stats = null;
protected ?string $error = null;
protected ?string $snapshotId = null;
protected int $startedAt = 0;
protected int $updatedAt = 0;
protected ?int $finishedAt = null;
public function __construct() {
$this->addType('targetId', 'integer');
$this->addType('startedAt', 'integer');
$this->addType('updatedAt', 'integer');
$this->addType('finishedAt', 'integer');
}
}
-67
View File
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/** @template-extends QBMapper<Run> */
final class RunMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'ngb_runs', Run::class);
}
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)));
return $this->findEntity($qb);
}
/** @return list<Run> */
public function findRunning(?int $targetId = null, ?string $kind = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)));
if ($kind !== null) {
$qb->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)));
}
if ($targetId !== null) {
$qb->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)));
}
return $this->findEntities($qb->orderBy('id'));
}
/** @return list<Run> */
public function findRecent(int $limit = 20): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->orderBy('id', 'DESC')->setMaxResults($limit);
return $this->findEntities($qb);
}
/** @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())
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Run::DONE)))
->orderBy('id', 'DESC')->setMaxResults(1);
$r = $this->findEntities($qb);
return $r[0] ?? null;
}
}
-43
View File
@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method int getTargetId()
* @method void setTargetId(int $v)
* @method string getSnapshotId()
* @method void setSnapshotId(string $v)
* @method string getKind()
* @method void setKind(string $v)
* @method string|null getLabel()
* @method void setLabel(?string $v)
* @method int getCreatedAt()
* @method void setCreatedAt(int $v)
* @method int|null getFiles()
* @method void setFiles(?int $v)
* @method int|null getBytes()
* @method void setBytes(?int $v)
*/
final class Snapshot extends Entity {
protected int $targetId = 0;
protected string $snapshotId = '';
protected string $kind = '';
protected ?string $label = null;
protected int $createdAt = 0;
protected ?int $files = null;
protected ?int $bytes = null;
public function __construct() {
$this->addType('targetId', 'integer');
$this->addType('createdAt', 'integer');
$this->addType('files', 'integer');
$this->addType('bytes', 'integer');
}
}
-45
View File
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/** @template-extends QBMapper<Snapshot> */
final class SnapshotMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'ngb_snapshots', Snapshot::class);
}
/** @return list<Snapshot> newest first */
public function findForTarget(int $targetId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
->orderBy('created_at', 'DESC');
return $this->findEntities($qb);
}
public function findOne(int $targetId, string $snapshotId): Snapshot {
$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('snapshot_id', $qb->createNamedParameter($snapshotId)));
return $this->findEntity($qb);
}
public function latest(int $targetId, string $kind): ?Snapshot {
foreach ($this->findForTarget($targetId) as $s) {
if ($s->getKind() === $kind) {
return $s;
}
}
return null;
}
}
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method string getName()
* @method void setName(string $v)
* @method string getBackend()
* @method void setBackend(string $v)
* @method string getAuth()
* @method void setAuth(string $v)
* @method string getOptions()
* @method void setOptions(string $v)
* @method string getBasePath()
* @method void setBasePath(string $v)
* @method string|null getRepositoryId()
* @method void setRepositoryId(?string $v)
* @method bool|null getAppendOnly()
* @method void setAppendOnly(bool $v)
* @method int|null getMaxBytes()
* @method void setMaxBytes(?int $v)
* @method int getCreatedAt()
* @method void setCreatedAt(int $v)
*/
final class Target extends Entity {
protected string $name = '';
protected string $backend = '';
protected string $auth = '';
protected string $options = '';
protected string $basePath = '';
protected ?string $repositoryId = null;
protected int $createdAt = 0;
protected ?bool $appendOnly = null;
protected ?int $maxBytes = null;
public function __construct() {
$this->addType('createdAt', 'integer');
$this->addType('appendOnly', 'boolean');
$this->addType('maxBytes', 'integer');
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
/** @template-extends QBMapper<Target> */
final class TargetMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'ngb_targets', Target::class);
}
public function find(int $id): Target {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
public function findByName(string $name): Target {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('name', $qb->createNamedParameter($name)));
return $this->findEntity($qb);
}
/** @return list<Target> */
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->orderBy('name');
return $this->findEntities($qb);
}
}
+94 -138
View File
@@ -10,200 +10,156 @@ namespace OCA\NgBackup\Job;
use OCA\NgBackup\Repository\Repository;
/**
* A file backup as a resumable state machine, for environments with short PHP time limits.
* Each step works until its deadline, uploads what it has (pack + tree segment) and returns
* the new state; a large file is resumed at the next blob.
* A backup run as a resumable state machine, for environments with short PHP time limits
* (webcron/AJAX cron on hosted Nextcloud). Each step works until its deadline, then uploads
* what it has (pack + tree segment) and returns the new state. Even a single large file is
* resumed at the blob where the previous step stopped.
*
* The state holds no file list: the position is a cursor in TreeWalker order, and the previous
* snapshot is merge-joined through ParentCursor, so memory and state size stay small however
* many files there are. The snapshot is written last; a killed step is simply redone.
* The state is a plain array (in the app: a database row). It is only saved after a step
* completes; if a process is killed mid-step, the next step redoes that work. Blobs uploaded by
* the killed step are not referenced and are removed later by pruning; nothing is corrupted,
* because the snapshot is written last.
*/
final class BackupRun {
/**
* @param string|array<string, string> $roots one directory, or name => directory (walked in order)
* @param list<string> $exclude logical path prefixes to skip
* @param array<string, mixed> $meta extra fields for the snapshot meta record
*/
public static function start(string|array $roots, ?string $parent = null, string $label = '', array $exclude = [], array $meta = []): array {
$roots = is_string($roots) ? ['' => rtrim($roots, '/')] : $roots;
uksort($roots, fn ($a, $b) => TreeWalker::compare((string)$a, (string)$b)); // walk order = compare order
public static function start(string $source, ?string $parent = null, string $label = ''): array {
return [
'snapshot' => bin2hex(random_bytes(16)),
'roots' => $roots,
'exclude' => $exclude,
'source' => rtrim($source, '/'),
'parent' => $parent,
'label' => $label,
'meta' => $meta,
'started' => gmdate('c'),
'phase' => 'files',
'after' => null,
'parentPos' => 0,
'phase' => 'scan',
'files' => [],
'pos' => 0,
'cur' => null,
'segments' => [],
'steps' => 0,
'stats' => ['files' => 0, 'bytes' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0],
'stats' => ['files' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0],
];
}
/** Run one step until $deadline (microtime(true)); returns the new state. */
public static function step(Repository $repo, array $state, float $deadline): array {
$state['steps']++;
if ($state['phase'] === 'scan') {
$state['files'] = self::scan($state['source']);
$state['phase'] = 'files';
// Scanning can take a while on big trees; continue in the same step if time is left.
}
if ($state['phase'] === 'files') {
$previous = $state['parent'] !== null ? $repo->snapshotEntries($state['parent']) : [];
$stats = $state['stats'];
$walker = new TreeWalker($state['roots'], $state['exclude'], function (string $logical) use (&$state, &$stats): void {
self::recordUnreadable($state, $stats, $logical);
});
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
$lines = [];
$finishedWalk = true;
foreach ($walker->walk($state['after']) as $logical => $path) {
$version = Repository::fileVersion($path);
if ($version === 'missing') {
continue; // deleted while walking
$total = count($state['files']);
while ($state['pos'] < $total) {
$relative = $state['files'][$state['pos']];
$path = $state['source'] . '/' . $relative;
clearstatcache(true, $path);
$size = @filesize($path);
$mtime = @filemtime($path);
if ($size === false) {
// Deleted since the scan: skip.
$state['pos']++;
$state['cur'] = null;
continue;
}
[$size, $mtime] = array_map('intval', explode(':', $version, 3));
$prev = $parent->find($logical);
$prev = $previous[$relative] ?? null;
if ($state['cur'] === null && $prev !== null && $prev['s'] === $size && $prev['m'] === $mtime) {
$blobs = $prev['b'];
$lines[] = self::line($relative, $size, $mtime, $prev['b']);
$stats['files']++;
$stats['reused']++;
} else {
$cur = $state['cur'];
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;
}
$state['pos']++;
continue;
}
$version = Repository::fileVersion($path);
$cur = $state['cur'];
if ($cur === null || $cur['version'] !== $version) {
// New file, or it changed since the previous step: (re)start reading it.
if ($cur !== null) {
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
}
if ($cur === null || $cur['path'] !== $logical) {
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
}
$fh = @fopen($path, 'rb');
if ($fh === false) {
self::recordUnreadable($state, $stats, $logical);
$state['cur'] = null;
continue;
}
fseek($fh, $cur['offset']);
$complete = true;
while (($data = self::readFull($fh, Repository::BLOB_SIZE)) !== '') {
$cur['blobs'][] = $repo->storeData($data, $stats);
$cur['offset'] += strlen($data);
if (microtime(true) >= $deadline && !feof($fh)) {
$complete = false;
break;
}
}
fclose($fh);
$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;
$finishedWalk = false;
$cur = ['path' => $relative, 'offset' => 0, 'blobs' => [], 'version' => $version];
}
$fh = fopen($path, 'rb');
fseek($fh, $cur['offset']);
$finished = true;
while (($data = self::readFull($fh, Repository::BLOB_SIZE)) !== '') {
$cur['blobs'][] = $repo->storeData($data, $stats);
$cur['offset'] += strlen($data);
if (microtime(true) >= $deadline && !feof($fh)) {
$finished = false;
break;
}
$blobs = $cur['blobs'];
$size = $cur['offset'];
$state['cur'] = null;
$stats['read']++;
}
$lines[] = json_encode(['p' => $logical, 's' => $size, 'm' => $mtime, 'b' => $blobs], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
fclose($fh);
if (Repository::fileVersion($path) !== $cur['version']) {
// Changed while reading: start this file again (in this or the next step).
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
$state['cur'] = null;
if (microtime(true) >= $deadline) {
break;
}
continue;
}
if (!$finished) {
$state['cur'] = $cur;
break;
}
$lines[] = self::line($relative, $size, $mtime, $cur['blobs']);
$stats['files']++;
$stats['bytes'] += $size;
$state['after'] = $logical;
$stats['read']++;
$state['cur'] = null;
$state['pos']++;
if (microtime(true) >= $deadline) {
$finishedWalk = false;
break;
}
}
// Persist this step's tree lines as a blob stream segment and upload the open pack.
if ($lines !== []) {
$writer = $repo->blobWriter($stats);
$writer->write(implode("\n", $lines) . "\n");
$state['segments'][] = $writer->finish();
}
$repo->flushPacks();
$state['parentPos'] = $parent->position();
$state['stats'] = $stats;
if ($finishedWalk) {
if ($state['pos'] >= $total) {
$state['phase'] = 'finish';
}
}
if ($state['phase'] === 'finish') {
if ($state['phase'] === 'finish' && microtime(true) < $deadline + 5) {
$segments = $state['segments'];
$repo->putSnapshot($state['snapshot'],
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
'warnings' => self::warnings($state)] + $state['meta'],
$repo->writeSnapshot($state['snapshot'],
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'], 'source' => $state['source']],
(function () use ($repo, $segments) {
foreach ($segments as $blobs) {
yield from $repo->readLines($blobs);
}
})());
$state['phase'] = 'commit';
$state['phase'] = 'done';
$state['files'] = [];
}
return $state;
}
/**
* 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;
}
/** @return array{unreadable: 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),
'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 list<string> */
private static function scan(string $root): array {
$files = [];
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if ($file->isFile()) {
$files[] = substr($file->getPathname(), strlen($root) + 1);
}
}
return true;
sort($files, SORT_STRING);
return $files;
}
private static function line(string $path, int $size, int $mtime, array $blobs): string {
return json_encode(['p' => $path, 's' => $size, 'm' => $mtime, 'b' => $blobs], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
private static function readFull($fh, int $length): string {
-57
View File
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Job;
use OCA\NgBackup\Repository\Repository;
/**
* Reads the previous snapshot's entries in walk order and answers "what did the parent have
* at this path?" for increasing paths (merge-join). Only the current entry is in memory; the
* number of entries consumed is part of the resumable state.
*/
final class ParentCursor {
private ?\Generator $entries = null;
private ?array $current = null;
private int $consumed = 0;
public function __construct(Repository $repo, ?string $snapshotId, int $skip = 0) {
if ($snapshotId === null) {
return;
}
$this->entries = $repo->entries($snapshotId);
$this->current = $this->entries->valid() ? $this->entries->current() : null;
while ($this->consumed < $skip && $this->current !== null) {
$this->advance();
}
}
/** @return array{p:string, s:int, m:int, b:list<string>}|null */
public function find(string $path): ?array {
while ($this->current !== null) {
$c = TreeWalker::compare($this->current['p'], $path);
if ($c === 0) {
return $this->current;
}
if ($c > 0) {
return null;
}
$this->advance();
}
return null;
}
public function position(): int {
return $this->consumed;
}
private function advance(): void {
$this->entries->next();
$this->consumed++;
$this->current = $this->entries->valid() ? $this->entries->current() : null;
}
}
-112
View File
@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
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.
*
* 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 {
/**
* @param array<string, string> $roots name => absolute directory, walked in this order
* @param list<string> $exclude path prefixes ("<root>/<relative>") that are skipped entirely;
* a pattern with "*" matches one path segment (fnmatch, FNM_PATHNAME)
*/
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,
) {
}
/** @return \Generator<string, string> logical path => absolute path, strictly after $after */
public function walk(?string $after = null): \Generator {
foreach ($this->roots as $name => $dir) {
$name = (string)$name;
if ($name !== '' && $after !== null && self::compare($name, self::firstSegment($after)) < 0) {
continue; // whole root lies before the cursor
}
yield from $this->walkDir(rtrim($dir, '/'), $name, $after);
}
}
/** Order used everywhere: segment by segment, byte-wise. */
public static function compare(string $a, string $b): int {
$sa = explode('/', $a);
$sb = explode('/', $b);
$n = min(count($sa), count($sb));
for ($i = 0; $i < $n; $i++) {
$c = strcmp($sa[$i], $sb[$i]);
if ($c !== 0) {
return $c < 0 ? -1 : 1;
}
}
return count($sa) <=> count($sb);
}
private function walkDir(string $abs, string $logical, ?string $after): \Generator {
if ($logical !== '' && $this->isExcluded($logical, true)) {
return;
}
$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);
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)) {
// 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)) {
if ($after !== null && self::compare($childLogical, $after) <= 0) {
continue;
}
if (!$this->isExcluded($childLogical, false)) {
yield $childLogical => $childAbs;
}
}
}
}
private function isExcluded(string $logical, bool $isDir): bool {
foreach ($this->exclude as $pattern) {
if (str_contains($pattern, '*')) {
if (fnmatch($pattern, $logical, FNM_PATHNAME)) {
return true;
}
} elseif ($logical === $pattern || ($isDir && str_starts_with($logical, $pattern . '/'))) {
return true;
}
}
return false;
}
private static function isPrefix(string $dir, string $path): bool {
return str_starts_with($path, $dir . '/');
}
private static function firstSegment(string $path): string {
return explode('/', $path, 2)[0];
}
}
@@ -1,72 +0,0 @@
<?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;
final class Version000001Date20261002000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('ngb_targets')) {
$t = $schema->createTable('ngb_targets');
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
$t->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 64]);
$t->addColumn('backend', Types::STRING, ['notnull' => true, 'length' => 64]);
$t->addColumn('auth', Types::STRING, ['notnull' => true, 'length' => 64]);
// Backend/auth options as JSON, encrypted with ICrypto (they contain credentials).
$t->addColumn('options', Types::TEXT, ['notnull' => true]);
$t->addColumn('base_path', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => '']);
$t->addColumn('repository_id', Types::STRING, ['notnull' => false, 'length' => 32]);
$t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->setPrimaryKey(['id']);
$t->addUniqueIndex(['name'], 'ngb_targets_name');
}
if (!$schema->hasTable('ngb_runs')) {
$t = $schema->createTable('ngb_runs');
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
$t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]);
$t->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16]);
$t->addColumn('phase', Types::STRING, ['notnull' => true, 'length' => 32]);
// Resumable state (JSON). Large lists live in the repository, not here.
$t->addColumn('state', Types::TEXT, ['notnull' => true]);
$t->addColumn('stats', Types::TEXT, ['notnull' => false]);
$t->addColumn('error', Types::TEXT, ['notnull' => false]);
$t->addColumn('snapshot_id', Types::STRING, ['notnull' => false, 'length' => 32]);
$t->addColumn('started_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('updated_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('finished_at', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
$t->setPrimaryKey(['id']);
$t->addIndex(['target_id', 'status'], 'ngb_runs_target_status');
}
if (!$schema->hasTable('ngb_snapshots')) {
// Cache of the snapshots in each repository, for listing without reading the target.
$t = $schema->createTable('ngb_snapshots');
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
$t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('snapshot_id', Types::STRING, ['notnull' => true, 'length' => 32]);
$t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]);
$t->addColumn('label', Types::STRING, ['notnull' => false, 'length' => 255]);
$t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('files', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
$t->addColumn('bytes', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
$t->setPrimaryKey(['id']);
$t->addUniqueIndex(['target_id', 'snapshot_id'], 'ngb_snap_target_snap');
}
return $schema;
}
}
@@ -1,33 +0,0 @@
<?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 cache of the per-pack index files, so opening a repository does not download them all. */
final class Version000002Date20261002010000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('ngb_index')) {
$t = $schema->createTable('ngb_index');
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
$t->addColumn('repository_id', Types::STRING, ['notnull' => true, 'length' => 32]);
$t->addColumn('pack_id', Types::STRING, ['notnull' => true, 'length' => 32]);
// Plain JSON list of blob entries. Blob ids are keyed hashes and reveal no content.
$t->addColumn('entries', Types::TEXT, ['notnull' => true]);
$t->setPrimaryKey(['id']);
$t->addUniqueIndex(['repository_id', 'pack_id'], 'ngb_index_repo_pack');
}
return $schema;
}
}
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/** Append-only locations: NG Backup never deletes anything there (cleanup by the location's own lifecycle rules). */
final class Version000003Date20261002020000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$t = $schema->getTable('ngb_targets');
if (!$t->hasColumn('append_only')) {
$t->addColumn('append_only', Types::BOOLEAN, ['notnull' => false, 'default' => false]);
return $schema;
}
return null;
}
}
@@ -1,28 +0,0 @@
<?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;
/** Optional size limit per location (cost control; S3 has no bucket quota). */
final class Version000004Date20261002030000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$t = $schema->getTable('ngb_targets');
if (!$t->hasColumn('max_bytes')) {
$t->addColumn('max_bytes', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
return $schema;
}
return null;
}
}
@@ -1,33 +0,0 @@
<?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;
/** Leases with an expiry: a killed process cannot block a location for longer than its lease. */
final class Version000005Date20261002040000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('ngb_leases')) {
return null;
}
$t = $schema->createTable('ngb_leases');
$t->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 32]);
$t->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 128]);
$t->addColumn('mode', Types::STRING, ['notnull' => true, 'length' => 1]);
$t->addColumn('expires_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->setPrimaryKey(['id']);
$t->addIndex(['name'], 'ngb_leases_name');
return $schema;
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Notification;
use OCA\NgBackup\AppInfo\Application;
use OCP\IURLGenerator;
use OCP\L10N\IFactory;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
use OCP\Notification\UnknownNotificationException;
final class Notifier implements INotifier {
public function __construct(
private IFactory $l10n,
private IURLGenerator $url,
) {
}
public function getID(): string {
return Application::APP_ID;
}
public function getName(): string {
return $this->l10n->get(Application::APP_ID)->t('NG Backup');
}
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== Application::APP_ID) {
throw new UnknownNotificationException();
}
$l = $this->l10n->get(Application::APP_ID, $languageCode);
$p = $notification->getSubjectParameters();
switch ($notification->getSubject()) {
case 'run_failed':
$notification->setParsedSubject($p['kind'] === 'restore'
? $l->t('Restore from %s failed', [$p['target']])
: $l->t('Backup to %s failed', [$p['target']]));
$notification->setParsedMessage((string)($p['error'] ?? ''));
break;
case '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 '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']]));
$notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
break;
case 'security_event':
$who = (string)($p['actor'] ?? '');
$notification->setParsedSubject(match ($p['event'] ?? '') {
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
'snapshots_forgotten' => $l->t('%1$s 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'),
});
$notification->setParsedMessage($l->t('If this was not you or another administrator, check who has admin access.'));
break;
default:
throw new UnknownNotificationException();
}
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']));
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')));
return $notification;
}
}
+4 -78
View File
@@ -24,66 +24,22 @@ final class BlobIndex {
/** @var array<string, true> blobs added in this run but whose pack is not uploaded yet */
private array $pending = [];
private ?IIndexCache $cache = null;
private string $repositoryId = '';
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
public int $downloaded = 0;
public function __construct(
private IBackend $backend,
private StreamCipher $cipher,
) {
}
public function setCache(IIndexCache $cache, string $repositoryId): void {
$this->cache = $cache;
$this->repositoryId = $repositoryId;
}
/**
* 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 = [];
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
$present = [];
$this->downloaded = 0;
public function load(): void {
foreach ($this->backend->list('index') 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);
}
$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);
foreach ($entries as $e) {
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
}
}
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];
}
}
$this->cache->put($this->repositoryId, $packId, $entries);
}
return;
}
$stale = array_keys(array_diff_key($cached, $present));
if ($stale !== []) {
$this->cache?->remove($this->repositoryId, $stale);
}
}
public function has(string $id): bool {
@@ -112,42 +68,12 @@ 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);
foreach ($entries as $e) {
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
unset($this->pending[$e['id']]);
}
}
/** @return array<string, list<array{id:string, offset:int, length:int}>> packId => blobs currently located in that pack */
public function packs(): array {
$packs = [];
foreach ($this->blobs as $id => [$pack, $offset, $length]) {
$packs[$pack][] = ['id' => $id, 'offset' => $offset, 'length' => $length];
}
return $packs;
}
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
public function removePack(string $packId): void {
$this->backend->delete('index/' . $packId);
$this->cache?->remove($this->repositoryId, [$packId]);
foreach ($this->blobs as $id => $loc) {
if ($loc[0] === $packId) {
unset($this->blobs[$id]);
}
}
}
/** Bytes of all blobs in the index (encrypted size, i.e. what is stored in packs). */
public function storedBytes(): int {
$total = 0;
foreach ($this->blobs as [, , $length]) {
$total += $length;
}
return $total;
}
public function count(): int {
return count($this->blobs);
}
-141
View File
@@ -1,141 +0,0 @@
<?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\Crypto\StreamCipher;
/**
* Chained catalog of a repository's state, against rollback and hidden snapshots.
*
* Every change to the set of snapshots or trash entries writes catalog/<generation>: the
* encrypted list of snapshot and trash ids plus the hash of the previous generation. The server
* keeps the newest generation it has seen (ICatalogAnchor). On open, the newest catalog on the
* location must be at least the anchored generation and lead back to it through the chain;
* snapshots listed in the catalog must exist. Otherwise the location was rolled back or
* tampered with, and the repository is refused.
*/
final class Catalog {
private const KEEP = 200; // generations kept on the location (enough to verify long gaps)
public int $gen = 0;
/** @var array<string, true> */
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 = [];
public string $hash = '';
public function __construct(
private IBackend $backend,
private StreamCipher $cipher,
private string $repositoryId,
private ?ICatalogAnchor $anchor,
) {
}
/** Load and verify the newest catalog; create the first one for repositories that have none. */
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.
$anchored = $this->anchor?->get($this->repositoryId);
if ($anchored !== null) {
throw new RepositoryException('The catalog of this repository has disappeared from the location (possible tampering)');
}
$this->snapshots = array_fill_keys($listSnapshots(), true);
$this->trash = array_fill_keys($listTrash(), true);
$this->userExports = array_fill_keys($listUserExports(), true);
$this->write();
return;
}
$head = max($gens);
[$data, $hash] = $this->read($head);
$anchored = $this->anchor?->get($this->repositoryId);
if ($anchored !== null) {
if ($head < $anchored['gen']) {
throw new RepositoryException(sprintf('The location shows catalog generation %d, but this server has seen %d: the backups were rolled back', $head, $anchored['gen']));
}
// Walk back to the anchored generation, checking every link.
[$cur, $curHash] = [$data, $hash];
for ($g = $head; $g > $anchored['gen']; $g--) {
[$prev, $prevHash] = $this->read($g - 1);
if (!hash_equals($prevHash, (string)$cur['prev'])) {
throw new RepositoryException("Catalog chain broken at generation $g (possible tampering)");
}
[$cur, $curHash] = [$prev, $prevHash];
}
if (!hash_equals($anchored['hash'], $curHash)) {
throw new RepositoryException('The catalog does not match what this server saw before (possible tampering)');
}
}
$this->gen = $head;
$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->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");
}
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash),
'userExports' => array_keys($this->userExports)], JSON_THROW_ON_ERROR);
$encrypted = $this->cipher->encryptString($plain, $this->ad($next));
$stream = fopen('php://memory', 'w+b');
fwrite($stream, $encrypted);
rewind($stream);
$this->backend->put($this->path($next), $stream);
$this->gen = $next;
$this->hash = hash('sha256', $encrypted);
$this->anchor?->set($this->repositoryId, $this->gen, $this->hash);
$old = $next - self::KEEP;
if ($old > 0) {
$this->backend->delete($this->path($old));
}
}
/** @return array{0: array, 1: string} decoded catalog and the hash of its encrypted bytes */
private function read(int $gen): array {
try {
$fh = $this->backend->get($this->path($gen));
} catch (\Throwable) {
throw new RepositoryException("Catalog generation $gen is missing (possible tampering)");
}
$encrypted = (string)stream_get_contents($fh);
fclose($fh);
$data = json_decode($this->cipher->decryptString($encrypted, $this->ad($gen)), true, 512, JSON_THROW_ON_ERROR);
if (($data['repository'] ?? '') !== $this->repositoryId || ($data['gen'] ?? 0) !== $gen) {
throw new RepositoryException("Catalog generation $gen belongs to another repository or position");
}
return [$data, hash('sha256', $encrypted)];
}
/** @return list<int> */
private function generations(): array {
return array_map(fn ($p) => (int)basename($p), $this->backend->list('catalog'));
}
private function path(int $gen): string {
return sprintf('catalog/%010d', $gen);
}
private function ad(int $gen): string {
return 'catalog:' . $this->repositoryId . ':' . $gen;
}
}
-20
View File
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Repository;
/**
* Where the server remembers the newest catalog generation it has seen for a repository, outside
* the (untrusted) location. A location that presents an older generation, or a chain that does
* not lead back to this anchor, is rolled back or tampered with.
*/
interface ICatalogAnchor {
/** @return array{gen:int, hash:string}|null */
public function get(string $repositoryId): ?array;
public function set(string $repositoryId, int $gen, string $hash): void;
}
-20
View File
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Repository;
/** Optional local cache of pack index entries (the repository's index/ files stay authoritative). */
interface IIndexCache {
/** @return array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> packId => entries */
public function all(string $repositoryId): array;
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
public function put(string $repositoryId, string $packId, array $entries): void;
/** @param list<string> $packIds */
public function remove(string $repositoryId, array $packIds): void;
}
-17
View File
@@ -32,7 +32,6 @@ final class PackWriter {
private StreamCipher $cipher,
private BlobIndex $index,
private int $targetSize = 32 * 1048576,
private ?int $maxBytes = null,
) {
$this->reset();
}
@@ -50,27 +49,11 @@ final class PackWriter {
return strlen($encrypted);
}
/** Add an already encrypted blob as-is (repacking: no decryption needed, the blob is bound to its id, not to a pack). */
public function addRaw(string $id, string $encrypted, int $rawLength, int $flags): void {
fwrite($this->buffer, $encrypted);
$this->entries[] = ['id' => $id, 'offset' => $this->size, 'length' => strlen($encrypted), 'raw' => $rawLength, 'flags' => $flags];
$this->size += strlen($encrypted);
if ($this->size >= $this->targetSize) {
$this->flush();
}
}
/** Upload the current pack (if any) and record it in the index. */
public function flush(): void {
if ($this->entries === []) {
return;
}
if ($this->maxBytes !== null && $this->index->storedBytes() + $this->size > $this->maxBytes) {
$this->index->dropPending(array_column($this->entries, 'id'));
$this->reset();
throw new QuotaExceededException(sprintf('Storage limit of this location reached (%.1f GiB): backup stopped',
$this->maxBytes / 1073741824));
}
$packId = bin2hex(random_bytes(16));
$header = $this->cipher->encryptString(json_encode($this->entries, JSON_THROW_ON_ERROR), 'pack-header:' . $packId);
fwrite($this->buffer, $header . pack('N', strlen($header)));
-11
View File
@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Repository;
final class QuotaExceededException extends RepositoryException {
}
+23 -564
View File
@@ -32,8 +32,6 @@ final class Repository {
private StreamCipher $cipher;
private BlobIndex $index;
private ?PackWriter $runPacks = null;
private ?int $maxBytes = null;
private Catalog $catalog;
private function __construct(
private IBackend $backend,
@@ -45,76 +43,17 @@ final class Repository {
}
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
$keys = KeyRing::generate();
return self::initWithKey($backend, $keys, $keys->wrap($passphrase));
}
/**
* Create a repository with an existing master key. $wrappedKey is the passphrase-wrapped
* copy (KeyRing::wrap) stored in the repository config, so the repository can be opened
* with the passphrase from the recovery kit even when the server is gone.
*/
public static function initWithKey(IBackend $backend, KeyRing $keys, array $wrappedKey, ?ICatalogAnchor $anchor = null): self {
if ($backend->exists('config')) {
throw new RepositoryException('A repository already exists at this location');
}
$keys = KeyRing::generate();
$id = bin2hex(random_bytes(16));
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey,
'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check'))];
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $keys->wrap($passphrase)];
self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
$repo = new self($backend, $keys, $id);
$repo->loadCatalog($anchor);
return $repo;
return new self($backend, $keys, $id);
}
/** Open with the master key the server holds (scheduled backups, no passphrase needed). */
public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null, ?ICatalogAnchor $anchor = null): self {
$config = self::readConfig($backend);
try {
$ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id'];
} catch (\OCA\NgBackup\Crypto\CryptoException) {
$ok = false;
}
if (!$ok) {
throw new RepositoryException('This repository was created with a different key');
}
$repo = new self($backend, $keys, $config['id']);
if ($cache !== null) {
$repo->index->setCache($cache, $config['id']);
}
$repo->index->load();
$repo->loadCatalog($anchor);
return $repo;
}
private function loadCatalog(?ICatalogAnchor $anchor): void {
$this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor);
$this->catalog->load(
fn () => array_map('basename', $this->backend->list('snapshots')),
fn () => array_map('basename', $this->backend->list('trash/info')),
fn () => $this->backend->list('users'));
}
/** Refuse uploads that would make the stored data larger than this (null = no limit). */
public function setMaxBytes(?int $maxBytes): void {
$this->maxBytes = $maxBytes;
}
public function storedBytes(): int {
return $this->index->storedBytes();
}
/** Current catalog generation (for status and tests). */
public function catalogGeneration(): int {
return $this->catalog->gen;
}
/** Index files downloaded when this repository was opened (0 when the local cache was complete). */
public function indexDownloads(): int {
return $this->index->downloaded;
}
private static function readConfig(IBackend $backend): array {
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
try {
$fh = $backend->get('config');
} catch (BackendException) {
@@ -125,25 +64,8 @@ final class Repository {
if (($config['format'] ?? 0) !== self::FORMAT) {
throw new RepositoryException('Unsupported repository format');
}
return $config;
}
/** Replace the passphrase-wrapped key slots in this repository's config (after a slot change). */
public function updateWrappedKey(array $wrappedKey): void {
$config = self::readConfig($this->backend);
$config['key'] = $wrappedKey;
self::putString($this->backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
}
public function id(): string {
return $this->repositoryId;
}
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
$config = self::readConfig($backend);
$repo = new self($backend, KeyRing::unwrapAny($config['key'], $passphrase), $config['id']);
$repo = new self($backend, KeyRing::unwrap($config['key'], $passphrase), $config['id']);
$repo->index->load();
$repo->loadCatalog(null); // disaster recovery on a new server: no anchor yet, trust the newest valid chain
return $repo;
}
@@ -156,7 +78,7 @@ final class Repository {
public function backupDirectory(string $source, ?string $parent = null, string $label = ''): array {
$source = rtrim($source, '/');
$previous = $parent !== null ? $this->loadEntries($parent) : [];
$packs = new PackWriter($this->backend, $this->cipher, $this->index, 32 * 1048576, $this->maxBytes);
$packs = new PackWriter($this->backend, $this->cipher, $this->index);
$stats = ['files' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0];
$tree = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
@@ -184,65 +106,35 @@ final class Repository {
rewind($encrypted);
$this->backend->put('snapshots/' . $snapshotId, $encrypted);
fclose($tree);
$this->catalog->snapshots[$snapshotId] = true;
$this->catalog->write();
return ['snapshot' => $snapshotId] + $stats;
}
/**
* Restore a snapshot (or only paths starting with $prefix) into a local directory.
*
* @param bool $stripPrefix write "$target/<path without $prefix>" instead of
* "$target/<full logical path>" (disaster recovery: restoring e.g. "data/" or
* "config/" directly into the real data/config directory, not a subdirectory
* named after the prefix). Ignored when $prefix is '' (nothing to strip).
*/
public function restore(string $snapshotId, string $target, string $prefix = '', bool $stripPrefix = false, ?callable $heartbeat = null): int {
if ($prefix !== '' && !self::isSafePath(rtrim($prefix, '/'))) {
throw new RepositoryException('Invalid path prefix');
}
/** Restore a snapshot (or only paths starting with $prefix) into a local directory. */
public function restore(string $snapshotId, string $target, string $prefix = ''): int {
$count = 0;
foreach ($this->streamEntries($snapshotId) as $entry) {
if ($prefix !== '' && !str_starts_with($entry['p'], $prefix)) {
continue;
}
$relPath = ($stripPrefix && $prefix !== '') ? substr($entry['p'], strlen(rtrim($prefix, '/')) + 1) : $entry['p'];
$dest = rtrim($target, '/') . '/' . $relPath;
$dest = rtrim($target, '/') . '/' . $entry['p'];
if (!is_dir(dirname($dest))) {
mkdir(dirname($dest), 0700, true);
}
// Defence in depth: the resolved directory must stay below the target (no symlink escapes).
$base = realpath($target);
$dir = realpath(dirname($dest));
if ($base === false || $dir === false || ($dir !== $base && !str_starts_with($dir, $base . '/')) || is_link($dest)) {
throw new RepositoryException('Refusing to write outside the restore directory: ' . $entry['p']);
}
// Write next to the destination and rename after all blobs verified: at most one file
// is ever present twice.
$tmp = $dest . '.ngb-restore-' . bin2hex(random_bytes(6));
$out = @fopen($tmp, 'xb'); // fails if anything (including a symlink) already exists there
if ($out === false) {
throw new RepositoryException('Cannot create a temporary file for ' . $entry['p']);
}
$tmp = $dest . '.ngb-restore';
$out = fopen($tmp, 'wb');
foreach ($entry['b'] as $id) {
fwrite($out, $this->loadBlob($id));
if ($heartbeat !== null) {
$heartbeat();
}
}
fclose($out);
if (filesize($tmp) !== $entry['s']) {
unlink($tmp);
throw new RepositoryException('Size mismatch restoring ' . $entry['p']);
}
if (!rename($tmp, $dest)) {
@unlink($tmp);
throw new RepositoryException('Cannot put the restored file in place: ' . $entry['p']);
}
if (!touch($dest, $entry['m'])) {
throw new RepositoryException('Cannot set the modification time of ' . $entry['p']);
}
rename($tmp, $dest);
touch($dest, $entry['m']);
$count++;
}
return $count;
@@ -253,7 +145,7 @@ final class Repository {
* run is done, so the last pack is uploaded before anything references it.
*/
public function blobWriter(array &$stats): BlobWriter {
$this->runPacks ??= new PackWriter($this->backend, $this->cipher, $this->index, 32 * 1048576, $this->maxBytes);
$this->runPacks ??= new PackWriter($this->backend, $this->cipher, $this->index);
$packs = $this->runPacks;
return new BlobWriter(function (string $data) use ($packs, &$stats): string {
return $this->storeBlob($data, $packs, $stats);
@@ -285,7 +177,7 @@ final class Repository {
/** Store one blob of file data in the current run (deduplicated); returns its id. */
public function storeData(string $data, array &$stats): string {
$this->runPacks ??= new PackWriter($this->backend, $this->cipher, $this->index, 32 * 1048576, $this->maxBytes);
$this->runPacks ??= new PackWriter($this->backend, $this->cipher, $this->index);
return $this->storeBlob($data, $this->runPacks, $stats);
}
@@ -296,17 +188,6 @@ final class Repository {
* @param iterable<string> $lines
*/
public function writeSnapshot(string $snapshotId, array $meta, iterable $lines): void {
$this->putSnapshot($snapshotId, $meta, $lines);
$this->recordSnapshot($snapshotId);
}
/**
* Store a snapshot object without touching the catalog. Use recordSnapshot() afterwards, from a
* repository opened inside the catalog lock, so the catalog write builds on the latest generation.
*
* @param iterable<string> $lines
*/
public function putSnapshot(string $snapshotId, array $meta, iterable $lines): void {
$tree = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
fwrite($tree, json_encode(['snapshot' => $snapshotId] + $meta, JSON_THROW_ON_ERROR) . "\n");
foreach ($lines as $line) {
@@ -322,11 +203,6 @@ final class Repository {
$this->backend->put('snapshots/' . $snapshotId, $encrypted);
}
public function recordSnapshot(string $snapshotId): void {
$this->catalog->snapshots[$snapshotId] = true;
$this->catalog->write();
}
/** @return array<string, array{s:int, m:int, b:list<string>}> */
public function snapshotEntries(string $snapshotId): array {
return $this->loadEntries($snapshotId);
@@ -342,19 +218,6 @@ final class Repository {
}
}
/** Relative path of non-empty segments, without ".", "..", NUL, backslash or control characters. */
public static function isSafePath(string $path): bool {
if ($path === '' || str_starts_with($path, '/') || preg_match('/[\x00-\x1f\\\\]/', $path)) {
return false;
}
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
/** Small encrypted objects (manifests). */
public function putObject(string $path, string $data): void {
self::putString($this->backend, $path, $this->cipher->encryptString($data, 'object:' . $path));
@@ -367,280 +230,9 @@ final class Repository {
return $data;
}
/**
* Move a snapshot (and its database dump) to the trash on the location. Its data stays in
* use until purgeTrash() removes it after the deletion delay, so a malicious or mistaken
* cleanup can be undone with untrash() within that time.
*/
public function forget(string $snapshotId, string $by = ''): void {
$meta = $this->snapshotMeta($snapshotId);
$dbInTrash = null;
if (isset($meta['db']) && is_string($meta['db']) && $this->backend->exists($meta['db'])) {
// Its encryption is bound to its path (object:db/...), which changes on a move, so it
// must be decrypted and re-encrypted under the new path, not moved as raw bytes.
$dbInTrash = 'trash/' . $meta['db'];
$this->putObject($dbInTrash, $this->getObject($meta['db']));
}
// Copy first, switch the catalog, delete the originals last: a crash at any point leaves
// either the old state or the new one plus unreferenced leftovers, never a catalog entry
// pointing at a missing object. The snapshot object is bound to its id (not its path),
// so it can be copied as raw bytes.
$this->copyRaw('snapshots/' . $snapshotId, 'trash/snapshots/' . $snapshotId);
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
unset($this->catalog->snapshots[$snapshotId]);
$this->catalog->trash[$snapshotId] = true;
$this->catalog->write();
if ($dbInTrash !== null) {
$this->backend->delete($meta['db']);
}
$this->backend->delete('snapshots/' . $snapshotId);
}
/** Undo forget(): move a snapshot back from the trash. */
public function untrash(string $snapshotId): void {
$info = $this->trashInfo($snapshotId);
$restoreDb = $info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash']);
if ($restoreDb) {
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
}
// Same order as forget(): copy, switch the catalog, then delete the trash copies.
$this->copyRaw('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId);
$this->catalog->snapshots[$snapshotId] = true;
unset($this->catalog->trash[$snapshotId]);
$this->catalog->write();
if ($restoreDb) {
$this->backend->delete($info['dbInTrash']);
}
$this->backend->delete('trash/snapshots/' . $snapshotId);
$this->backend->delete('trash/info/' . $snapshotId);
}
/** Copy an object as raw bytes, buffered locally (remote read streams cannot be uploaded directly everywhere). */
private function copyRaw(string $from, string $to): void {
$in = $this->backend->get($from);
$buffer = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
if ($buffer === false) {
fclose($in);
throw new RepositoryException("Cannot buffer $from");
}
try {
if (stream_copy_to_stream($in, $buffer) === false) {
throw new RepositoryException("Cannot read $from");
}
} finally {
fclose($in);
}
rewind($buffer);
$this->backend->put($to, $buffer);
}
/** Record a user_migration export's manifest in the catalog (rollback-protected like a snapshot). */
public function recordUserExport(string $manifestPath): void {
$this->catalog->userExports[$manifestPath] = true;
$this->catalog->write();
}
/**
* User export manifests according to the verified catalog, newest first. A manifest the
* catalog lists but the location no longer has means it was deleted or hidden behind NG
* Backup's back, same as a missing snapshot (see listSnapshots()).
*
* @return list<string>
*/
public function listUserExports(?string $uid = null): array {
$prefix = $uid !== null ? 'users/' . $uid . '/' : 'users/';
$out = [];
foreach (array_keys($this->catalog->userExports) as $path) {
if (!str_starts_with($path, $prefix)) {
continue;
}
if (!$this->backend->exists($path)) {
throw new RepositoryException("User export $path is missing on the location (deleted or hidden outside NG Backup)");
}
$out[] = $path;
}
sort($out, SORT_STRING);
return array_reverse($out);
}
/** Whether $manifestPath is a user export the verified catalog actually recorded. */
public function isRecordedUserExport(string $manifestPath): bool {
return isset($this->catalog->userExports[$manifestPath]);
}
/** Permanently delete a user export. Its blobs are freed by the next prune() like any other. */
public function forgetUserExport(string $manifestPath): void {
if (!isset($this->catalog->userExports[$manifestPath])) {
throw new RepositoryException("$manifestPath is not a recorded user export");
}
unset($this->catalog->userExports[$manifestPath]);
$this->catalog->write();
$this->backend->delete($manifestPath);
}
/** @return array<string, array{forgottenAt:int, by:string, db:?string, dbInTrash:?string, time:?string, label:string}> */
public function trash(): array {
$out = [];
foreach (array_keys($this->catalog->trash) as $id) {
try {
$out[$id] = $this->trashInfo($id);
} catch (\Throwable) {
throw new RepositoryException("Trash entry $id listed in the catalog is missing on the location (possible tampering)");
}
}
return $out;
}
/** Permanently delete trash entries older than $delaySeconds. @return list<string> purged snapshot ids */
public function purgeTrash(int $delaySeconds): array {
$doomed = [];
foreach ($this->trash() as $id => $info) {
if (time() - $info['forgottenAt'] < $delaySeconds) {
continue;
}
$doomed[$id] = $info;
unset($this->catalog->trash[$id]);
}
if ($doomed === []) {
return [];
}
$this->catalog->write();
foreach ($doomed as $id => $info) {
if ($info['dbInTrash'] !== null) {
$this->backend->delete($info['dbInTrash']);
}
$this->backend->delete('trash/snapshots/' . $id);
$this->backend->delete('trash/info/' . $id);
}
return array_keys($doomed);
}
private function trashInfo(string $snapshotId): array {
return json_decode($this->getObject('trash/info/' . $snapshotId), true, 512, JSON_THROW_ON_ERROR);
}
/**
* Delete packs no snapshot or user export uses any more, and repack packs where more than
* $repackThreshold of the bytes are unused. Must not run while a backup to this repository
* is in progress (its blobs are not referenced by a snapshot yet); callers hold the lock.
*
* @return array{packs:int, deleted:int, repacked:int, freedBytes:int, keptBlobs:int, unusedBlobs:int}
*/
public function prune(bool $dryRun = false, float $repackThreshold = 0.5, array $ignoreSnapshots = []): array {
// Deleting is decided on the authenticated index files on the location, never on the local cache.
$this->index->load(true);
$used = [];
$ignore = array_flip($ignoreSnapshots); // e.g. snapshots a dry run would forget first
foreach ($this->listSnapshots() as $sid) {
if (isset($ignore[$sid])) {
continue;
}
foreach ($this->streamEntries($sid) as $e) {
foreach ($e['b'] as $b) {
$used[$b] = true;
}
}
$meta = $this->snapshotMeta($sid);
if (isset($meta['db']) && $this->backend->exists($meta['db'])) {
foreach (json_decode($this->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR)['tables'] as $t) {
foreach ($t['blobs'] as $b) {
$used[$b] = true;
}
}
}
}
// Snapshots in the trash keep their data until purgeTrash() removed them.
foreach ($this->trash() as $sid => $info) {
foreach ($this->streamEntries($sid, 'trash/snapshots/') as $e) {
foreach ($e['b'] as $b) {
$used[$b] = true;
}
}
if ($info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash'])) {
foreach (json_decode($this->getObject($info['dbInTrash']), true, 512, JSON_THROW_ON_ERROR)['tables'] as $t) {
foreach ($t['blobs'] as $b) {
$used[$b] = true;
}
}
}
}
// listUserExports() throws when a recorded export's manifest is missing: then stop, since
// freeing its blobs would turn a tampering or storage alarm into irreversible deletion.
$exports = array_unique(array_merge($this->listUserExports(), $this->backend->list('users')));
foreach ($exports as $path) {
foreach (json_decode($this->getObject($path), true, 512, JSON_THROW_ON_ERROR)['entries'] as $e) {
foreach ($e['b'] ?? [] as $b) {
$used[$b] = true;
}
}
}
$stats = ['packs' => 0, 'deleted' => 0, 'repacked' => 0, 'freedBytes' => 0, 'keptBlobs' => count($used), 'unusedBlobs' => 0];
$packs = $this->index->packs();
$stats['packs'] = count($packs);
$writer = null;
$toRemove = [];
foreach ($packs as $packId => $blobs) {
$total = 0;
$unused = 0;
foreach ($blobs as $b) {
$total += $b['length'];
if (!isset($used[$b['id']])) {
$unused += $b['length'];
$stats['unusedBlobs']++;
}
}
if ($unused === 0 || ($unused < $total && $unused / $total <= $repackThreshold)) {
continue;
}
$stats['freedBytes'] += $unused;
if ($unused === $total) {
$stats['deleted']++;
} else {
$stats['repacked']++;
if (!$dryRun) {
// No size limit here: repacking is how space is freed.
$writer ??= new PackWriter($this->backend, $this->cipher, $this->index);
$path = 'packs/' . substr($packId, 0, 2) . '/' . $packId;
foreach ($blobs as $b) {
if (isset($used[$b['id']])) {
[, , , $raw, $flags] = $this->index->get($b['id']);
$writer->addRaw($b['id'], $this->backend->getRange($path, $b['offset'], $b['length']), $raw, $flags);
}
}
}
}
$toRemove[] = $packId;
}
if ($dryRun) {
return $stats;
}
// New packs and their index entries are durable before any old pack is removed.
$writer?->flush();
foreach ($toRemove as $packId) {
$this->index->removePack($packId);
$this->backend->delete('packs/' . substr($packId, 0, 2) . '/' . $packId);
}
return $stats;
}
/**
* Snapshots according to the verified catalog. A snapshot the catalog lists but the location
* no longer has means it was deleted or hidden behind NG Backup's back.
*
* @return list<string>
*/
/** @return list<string> */
public function listSnapshots(): array {
$present = array_flip(array_map('basename', $this->backend->list('snapshots')));
$listed = array_keys($this->catalog->snapshots);
foreach ($listed as $id) {
if (!isset($present[$id])) {
throw new RepositoryException("Snapshot $id is missing on the location (deleted or hidden outside NG Backup)");
}
}
sort($listed);
return $listed;
return array_map('basename', $this->backend->list('snapshots'));
}
public function blobCount(): int {
@@ -680,7 +272,7 @@ final class Repository {
private function storeBlob(string $data, PackWriter $packs, array &$stats): string {
$id = $this->keys->blobId($data);
if ($this->index->has($id)) {
$stats['dupBlobs'] = ($stats['dupBlobs'] ?? 0) + 1;
$stats['dupBlobs']++;
return $id;
}
$flags = 0;
@@ -690,8 +282,8 @@ final class Repository {
$payload = $deflated;
$flags |= self::FLAG_DEFLATE;
}
$stats['uploaded'] = ($stats['uploaded'] ?? 0) + $packs->add($id, $this->cipher->encryptString($payload, 'blob:' . $id), strlen($data), $flags);
$stats['newBlobs'] = ($stats['newBlobs'] ?? 0) + 1;
$stats['uploaded'] += $packs->add($id, $this->cipher->encryptString($payload, 'blob:' . $id), strlen($data), $flags);
$stats['newBlobs']++;
return $id;
}
@@ -715,149 +307,16 @@ final class Repository {
return $map;
}
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> entries of a snapshot, in stored order */
public function entries(string $snapshotId): \Generator {
return $this->streamEntries($snapshotId);
}
/**
* Checksum audit of a snapshot: every blob it references (files and the database dump, when
* the snapshot has one) must be in the index, freshly re-downloaded and re-authenticated from
* the location rather than trusted from the local cache (same reasoning as prune()), and its
* pack must still be present. With $deep, each blob is also downloaded and decrypted, which
* verifies its AEAD authentication tag and its content hash against the blob id (loadBlob()
* already does both on every real read -- this just does it for everything in the snapshot,
* deliberately, rather than relying on it to come up during a later restore). A blob
* referenced by more than one file is only checked once; a missing database manifest is a
* failure, not something to silently skip over.
*
* @param ?callable $heartbeat called after every blob (shallow) or every blob read (deep);
* callers use it to keep a time-boxed lease alive during a large audit
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>}
*/
public function verify(string $snapshotId, bool $deep = false, ?callable $heartbeat = null): array {
$heartbeat ??= static function (): void {
};
$this->index->load(true);
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => []];
$seen = [];
$packSeen = []; // pack id => exists on the backend; avoids one exists() call per blob
foreach ($this->streamEntries($snapshotId) as $entry) {
$stats['filesChecked']++;
foreach ($entry['b'] as $id) {
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$this->verifyBlob($id, $deep, $stats, $packSeen);
$heartbeat();
}
}
$meta = $this->snapshotMeta($snapshotId);
if (isset($meta['db']) && is_string($meta['db'])) {
if (!$this->backend->exists($meta['db'])) {
$stats['missing'][] = $meta['db'];
} else {
$dbManifest = json_decode($this->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
foreach ($dbManifest['tables'] as $info) {
foreach ($info['blobs'] as $id) {
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$this->verifyBlob($id, $deep, $stats, $packSeen);
$heartbeat();
}
}
}
}
return $stats;
}
/**
* Same checks as verify(), for a user export: every blob its manifest references must be in
* the freshly re-authenticated index with its pack present; $deep also decrypts each.
*
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>}
*/
public function verifyUserExport(string $manifestPath, bool $deep = false, ?callable $heartbeat = null): array {
if (!$this->isRecordedUserExport($manifestPath)) {
throw new RepositoryException("$manifestPath is not a recorded user export for this location");
}
$heartbeat ??= static function (): void {
};
$this->index->load(true);
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => []];
$seen = [];
$packSeen = [];
$manifest = json_decode($this->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
foreach ($manifest['entries'] as $entry) {
if (($entry['t'] ?? '') !== 'f') {
continue;
}
$stats['filesChecked']++;
foreach ($entry['b'] ?? [] as $id) {
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$this->verifyBlob($id, $deep, $stats, $packSeen);
$heartbeat();
}
}
return $stats;
}
/** @param array<string, bool> $packSeen */
private function verifyBlob(string $id, bool $deep, array &$stats, array &$packSeen): void {
if (!$this->index->has($id)) {
$stats['missing'][] = $id;
return;
}
[$pack] = $this->index->get($id);
$packExists = $packSeen[$pack] ??= $this->backend->exists('packs/' . substr($pack, 0, 2) . '/' . $pack);
if (!$packExists) {
$stats['missing'][] = $id;
return;
}
$stats['blobsChecked']++;
if (!$deep) {
return;
}
try {
$stats['bytesChecked'] += strlen($this->loadBlob($id));
} catch (\Throwable) {
$stats['failed'][] = $id;
}
}
/** The meta record (first line) of a snapshot. */
public function snapshotMeta(string $snapshotId): array {
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> */
private function streamEntries(string $snapshotId): \Generator {
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
$fh = $this->backend->get('snapshots/' . $snapshotId);
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
fclose($fh);
rewind($plain);
$meta = json_decode((string)fgets($plain), true, 512, JSON_THROW_ON_ERROR);
fclose($plain);
return $meta;
}
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> */
private function streamEntries(string $snapshotId, string $dir = 'snapshots/'): \Generator {
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
$fh = $this->backend->get($dir . $snapshotId);
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
fclose($fh);
rewind($plain);
fgets($plain); // meta line
while (($line = fgets($plain)) !== false) {
$entry = json_decode($line, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($entry) || !is_string($entry['p'] ?? null) || !self::isSafePath($entry['p'])
|| !is_int($entry['s'] ?? null) || !is_array($entry['b'] ?? null)) {
throw new RepositoryException('Invalid entry in snapshot ' . $snapshotId);
}
yield $entry;
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
}
fclose($plain);
}
-121
View File
@@ -1,121 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\Target;
use OCP\IGroupManager;
use OCP\Notification\IManager;
/** Notifies administrators about failed runs and locations without a recent successful backup. */
final class AlertService {
public const STALE_AFTER = 2 * 86400;
public function __construct(
private IManager $notifications,
private IGroupManager $groups,
private RunMapper $runs,
private \OCP\IAppConfig $appConfig,
private \OCP\EventDispatcher\IEventDispatcher $events,
private \OCP\IUserSession $session,
) {
}
/**
* Security-relevant change (recovery kit downloaded, location removed, append-only off,
* retention changed, snapshots forgotten): written to the audit log (admin_audit) and sent
* to every administrator, so a single compromised admin account cannot do it unnoticed.
*
* @param array<string, string> $params
*/
public function actor(): string {
return $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
}
public function securityEvent(string $event, array $params = [], bool $notify = true): void {
$actor = $this->actor();
$params = ['actor' => $actor] + $params;
$this->events->dispatchTyped(new \OCP\Log\Audit\CriticalActionPerformedEvent(
'NG Backup: %s by %s ' . json_encode(array_diff_key($params, ['actor' => 1]), JSON_UNESCAPED_SLASHES), ['event' => $event, 'actor' => $actor]));
if ($notify) {
$this->notifyAdmins('security_event', $event . '-' . time(), ['event' => $event] + $params);
}
}
public function runFailed(Run $run, string $targetName): void {
$this->notifyAdmins('run_failed', 'run-' . $run->getId(),
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
}
/** 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, '…')]);
}
/**
* 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.
*/
public function storedBytes(Target $target, int $bytes): void {
$this->appConfig->setValueString(Application::APP_ID, 'stored_' . $target->getId(), (string)$bytes);
$limit = $target->getMaxBytes();
if ($limit === null || $limit <= 0 || $bytes < 0.8 * $limit) {
return;
}
$key = 'quota_notified_' . $target->getId();
if ($this->appConfig->getValueString(Application::APP_ID, $key, '') === date('Y-m-d')) {
return;
}
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
$this->notifyAdmins('quota_warning', 'quota-' . $target->getId() . '-' . date('Y-m-d'), [
'target' => $target->getName(), 'percent' => (string)round($bytes / $limit * 100),
'used' => self::gib($bytes), 'limit' => self::gib($limit)]);
}
private static function gib(int $bytes): string {
$g = $bytes / 1073741824;
return sprintf($g < 10 ? '%.2f GiB' : '%.1f GiB', $g);
}
public function lastStoredBytes(Target $target): ?int {
$v = $this->appConfig->getValueString(Application::APP_ID, 'stored_' . $target->getId(), '');
return $v === '' ? null : (int)$v;
}
/** At most one notification per location per day. */
public function checkStale(Target $target): void {
$last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
$lastTime = $last?->getFinishedAt() ?? $target->getCreatedAt();
if (time() - $lastTime < self::STALE_AFTER || $this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
return;
}
$key = 'stale_notified_' . $target->getId();
if ($this->appConfig->getValueString(Application::APP_ID, $key, '') === date('Y-m-d')) {
return;
}
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : 'never']);
}
private function notifyAdmins(string $subject, string $objectId, array $params): void {
foreach ($this->groups->get('admin')?->getUsers() ?? [] as $admin) {
$n = $this->notifications->createNotification();
$n->setApp(Application::APP_ID)
->setUser($admin->getUID())
->setDateTime(new \DateTime())
->setObject($subject, $objectId)
->setSubject($subject, $params);
$this->notifications->notify($n);
}
}
}
-283
View File
@@ -1,283 +0,0 @@
<?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\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\Snapshot;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\Job\BackupRun;
use OCA\NgBackup\Repository\RepositoryException;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;
/**
* Full backups of this installation: database (one consistent transaction), config and the
* data directory, as a resumable run whose state is stored in ngb_runs after every step.
*
* Excluded by default: previews, log files, the updater's backups and each user's cache and
* temporary uploads (all regenerable or temporary).
*/
final class BackupService {
public const KIND_FULL = 'full';
public function __construct(
private TargetService $targets,
private KeyService $keys,
private RunMapper $runs,
private SnapshotMapper $snapshots,
private IConfig $config,
private IDBConnection $db,
private IAppManager $appManager,
private ILockingProvider $locking,
private LoggerInterface $logger,
private AlertService $alerts,
private LeaseService $leases,
) {
}
public function start(Target $target, string $label = ''): Run {
if (!$this->keys->everConfirmed()) {
throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)');
}
if ($this->config->getSystemValue('objectstore', null) !== null) {
throw new \RuntimeException('Object storage as primary storage is not supported yet');
}
// Check and create the run under the location's exclusive lock, so a cleanup cannot start
// in between (it takes the same lock and refuses while a run exists).
// A run that is already in progress is simply continued; no lease needed for that.
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
if ($running !== []) {
return $running[0];
}
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');
}
}
private function createRun(Target $target, string $label): Run {
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
if ($running !== []) {
return $running[0];
}
$dataDir = rtrim($this->config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'), '/');
$parent = $this->snapshots->latest($target->getId(), self::KIND_FULL);
$files = BackupRun::start(
['config' => rtrim(\OC::$configDir, '/'), 'data' => $dataDir],
$parent?->getSnapshotId(),
$label,
$this->excludes($dataDir),
['kind' => self::KIND_FULL, 'nextcloud' => $this->config->getSystemValueString('version'),
'instanceid' => $this->config->getSystemValueString('instanceid'),
'dbtype' => $this->config->getSystemValueString('dbtype'),
'apps' => $this->installedApps()],
);
$run = new Run();
$run->setTargetId($target->getId());
$run->setKind(self::KIND_FULL);
$run->setStatus(Run::RUNNING);
$run->setPhase('db');
$run->setState(json_encode(['files' => $files], JSON_THROW_ON_ERROR));
$run->setSnapshotId($files['snapshot']);
$run->setStartedAt(time());
$run->setUpdatedAt(time());
return $this->runs->insert($run);
}
/**
* Do one step of a run until $deadline. Safe to call concurrently: a second caller finds the
* run locked and returns immediately.
*/
public function step(Run $run, float $deadline): 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));
try {
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
} catch (LockedException) {
return $run; // another process is doing this step
}
// Shared lease on the location while this step writes: a cleanup (exclusive) cannot run now.
try {
$targetLease = $this->leases->acquire('ng_backup/target/' . $run->getTargetId(), LeaseService::SHARED, $ttl);
} catch (LockedException) {
$this->leases->release($runLease);
return $run;
}
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;
}
$target = $this->targets->get((string)$run->getTargetId());
$repo = $this->targets->repository($target);
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
if ($run->getPhase() === 'db') {
$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
// consistent). Under web cron it continues per step; the snapshot then says so.
$dump = $dumper->dumpStep($repo, $state['dump'] ?? $dumper->startDump(), $deadline, $dbStats);
$state['dbStats'] = $dbStats;
if ($dump['done']) {
$manifest = $dumper->manifest($dump);
$repo->putObject('db/' . $state['files']['snapshot'], json_encode($manifest, JSON_THROW_ON_ERROR));
$state['files']['meta']['db'] = 'db/' . $state['files']['snapshot'];
$state['files']['meta']['dbConsistent'] = $manifest['consistent'];
$state['dbStats'] += ['tables' => count($manifest['tables']), 'rows' => array_sum(array_column($manifest['tables'], 'rows')),
'steps' => $dump['steps'], 'consistent' => $manifest['consistent']];
unset($state['dump']);
$run->setPhase('files');
} else {
$state['dump'] = $dump;
}
} else {
$state['files'] = BackupRun::step($repo, $state['files'], $deadline);
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());
$stats = $state['files']['stats'];
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
$warnings = BackupRun::warnings($state['files']);
$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);
}
$run->setStatus(Run::DONE);
$run->setPhase('done');
$run->setFinishedAt(time());
}
}
$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);
} catch (\Throwable $e) {
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
$run->setStatus(Run::FAILED);
$run->setError($e->getMessage());
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
$run = $this->runs->update($run);
try {
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
} catch (\Throwable) {
}
return $run;
} finally {
$this->leases->release($targetLease);
$this->leases->release($runLease);
}
}
/** Run to completion in this process (occ, system cron), reporting progress. */
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
while ($run->getStatus() === Run::RUNNING) {
$before = $run->getUpdatedAt();
$run = $this->step($run, microtime(true) + $stepSeconds);
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());
}
if ($progress !== null) {
$progress($run);
}
}
return $run;
}
public function cancel(Run $run): Run {
$run->setStatus(Run::CANCELLED);
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
return $this->runs->update($run);
}
private function recordSnapshot(Run $run, array $files): void {
$s = new Snapshot();
$s->setTargetId($run->getTargetId());
$s->setSnapshotId($files['snapshot']);
$s->setKind($run->getKind());
$s->setLabel($files['label'] !== '' ? $files['label'] : null);
$s->setCreatedAt(time());
$s->setFiles($files['stats']['files']);
$s->setBytes($files['stats']['bytes']);
$this->snapshots->insert($s);
}
/** @return list<string> */
private function excludes(string $dataDir): array {
$instanceId = $this->config->getSystemValueString('instanceid');
$exclude = [
"data/appdata_$instanceId/preview",
"data/updater-$instanceId",
'data/nextcloud.log',
'data/nextcloud.log.*',
'data/audit.log',
'data/*/cache',
'data/*/uploads',
];
// SQLite: the database file is in the data directory; it is backed up by the logical dump,
// and copying the live file would give an inconsistent copy.
if ($this->config->getSystemValueString('dbtype') === 'sqlite3') {
$dbName = $this->config->getSystemValueString('dbname', 'owncloud');
$exclude[] = "data/$dbName.db";
$exclude[] = "data/$dbName.db-journal";
$exclude[] = "data/$dbName.db-wal";
$exclude[] = "data/$dbName.db-shm";
}
$logFile = $this->config->getSystemValueString('logfile', '');
if ($logFile !== '' && str_starts_with($logFile, $dataDir . '/')) {
$exclude[] = 'data/' . substr($logFile, strlen($dataDir) + 1);
}
// Never back up a local backup location into itself.
foreach ($this->targets->list() as $t) {
$opts = $this->targets->options($t);
$dir = rtrim((string)($opts['datadir'] ?? ''), '/');
if ($t->getBackend() === 'local' && $dir !== '' && str_starts_with($dir . '/', $dataDir . '/')) {
$exclude[] = trim('data/' . substr($dir, strlen($dataDir) + 1) . '/' . $t->getBasePath(), '/');
}
}
return array_values(array_unique($exclude));
}
/** @return array<string, string> app id => version */
private function installedApps(): array {
$apps = [];
foreach ($this->appManager->getEnabledApps() as $app) {
$apps[$app] = $this->appManager->getAppVersion($app);
}
ksort($apps);
return $apps;
}
}
-340
View File
@@ -1,340 +0,0 @@
<?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\Crypto\KeyRing;
use OCP\IAppConfig;
use OCP\Security\ICrypto;
/**
* The installation's backup master key.
*
* - The server keeps the master key encrypted with Nextcloud's own secret (ICrypto), so
* scheduled backups run without anyone typing a passphrase. Whoever fully controls the server
* can therefore read the backups; whoever only gets the backup location cannot.
* - A copy wrapped with the admin's passphrase (Argon2id) goes into every repository config and
* into the recovery kit, so backups can be restored on a fresh server with the passphrase.
* - The admin must confirm having stored the recovery kit before the first backup runs.
*/
final class KeyService {
private const KEY_MASTER = 'master_key';
private const KEY_WRAPPED = 'master_key_wrapped';
private const KEY_CONFIRMED = 'recovery_kit_confirmed';
private const KEY_FINGERPRINT = 'master_key_fingerprint';
private const KEY_DELETE_DELAY = 'delete_delay_days';
private const KEY_KIT_VERSION = 'recovery_kit_version';
private const KEY_KIT_CODE = 'recovery_kit_code';
public const DEFAULT_DELETE_DELAY = 7;
private ?KeyRing $cached = null;
public function __construct(
private IAppConfig $appConfig,
private ICrypto $crypto,
) {
}
public function isInitialized(): bool {
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== '';
}
/**
* @param int $deleteDelayDays how long forgotten snapshots stay in the trash on the location
* before their data is really deleted. Fixed here, at installation:
* there is deliberately no way to change it later from NG Backup.
*/
public function initialize(#[\SensitiveParameter] string $passphrase, int $deleteDelayDays = self::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): void {
if ($deleteDelayDays < 1 || $deleteDelayDays > 365) {
throw new \InvalidArgumentException('The deletion delay must be between 1 and 365 days');
}
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');
}
$keys = KeyRing::generate();
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
$this->cached = $keys;
$this->saveSlots([['slot' => 1, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $keys->wrap($passphrase)]]);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $deleteDelayDays, true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true);
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED);
$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): void {
if ($this->isInitialized()) {
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->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($kit['wrapped_key'], JSON_THROW_ON_ERROR), true, true);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
// Kits from before this field existed fall back to the default.
$delay = (int)($kit['delete_delay_days'] ?? self::DEFAULT_DELETE_DELAY);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, ($delay >= 1 && $delay <= 365) ? $delay : self::DEFAULT_DELETE_DELAY, true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, (int)($kit['kit_version'] ?? 1), true);
// Having and successfully using a real kit file is stronger proof than the normal
// download-and-confirm flow it replaces.
$this->confirmRecoveryKit('cli-import');
}
/**
* 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);
if ($enc === '') {
throw new \RuntimeException('No backup key yet: run occ backup:key:init');
}
$this->cached = new KeyRing(base64_decode($this->crypto->decrypt($enc), true));
}
return $this->cached;
}
/** Deletion delay in days, fixed at installation (installations from before this setting get the default once). */
public function deleteDelayDays(): int {
$days = $this->appConfig->getValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, 0, true);
if ($days === 0 && $this->isInitialized()) {
$days = self::DEFAULT_DELETE_DELAY;
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $days, true);
}
return $days;
}
public const MAX_SLOTS = 3;
/** @return list<array{slot:int, label:string, created:int}> passphrase slots, without key material */
public function slots(): array {
return array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots']);
}
/** Add a passphrase slot (e.g. one per person who must be able to restore). Returns the slot number. */
public function addSlot(#[\SensitiveParameter] string $passphrase, string $label): int {
self::checkPassphrase($passphrase);
$slots = $this->wrappedKey()['slots'];
if (count($slots) >= self::MAX_SLOTS) {
throw new \InvalidArgumentException('At most ' . self::MAX_SLOTS . ' passphrase slots');
}
$used = array_column($slots, 'slot');
$n = min(array_diff(range(1, self::MAX_SLOTS), $used));
$slots[] = ['slot' => $n, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $this->keyRing()->wrap($passphrase)];
usort($slots, fn ($a, $b) => $a['slot'] <=> $b['slot']);
$this->saveSlots($slots);
return $n;
}
/** New passphrase for a slot (e.g. its holder left). The master key and all backups stay the same. */
public function replaceSlot(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void {
self::checkPassphrase($passphrase);
$slots = $this->wrappedKey()['slots'];
$found = false;
foreach ($slots as &$s) {
if ($s['slot'] === $slot) {
$s['key'] = $this->keyRing()->wrap($passphrase);
$s['created'] = time();
if ($label !== null) {
$s['label'] = self::cleanLabel($label);
}
$found = true;
}
}
unset($s);
if (!$found) {
throw new \InvalidArgumentException("There is no slot $slot");
}
$this->saveSlots($slots);
}
public function removeSlot(int $slot): void {
$slots = array_values(array_filter($this->wrappedKey()['slots'], fn ($s) => $s['slot'] !== $slot));
if (count($slots) === count($this->wrappedKey()['slots'])) {
throw new \InvalidArgumentException("There is no slot $slot");
}
if ($slots === []) {
throw new \InvalidArgumentException('The last passphrase slot cannot be removed');
}
$this->saveSlots($slots);
}
/** Every change to the slots asks for a new recovery kit (and its confirmation). */
private function saveSlots(array $slots): void {
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode(['slots' => $slots], JSON_THROW_ON_ERROR), true, true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, $this->kitVersion() + 1, true);
}
private static function checkPassphrase(string $passphrase): void {
if (strlen($passphrase) < 12) {
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
}
}
private static function cleanLabel(string $label): string {
$label = trim(preg_replace('/[\x00-\x1f]/', '', $label) ?? '');
return mb_substr($label === '' ? 'Slot' : $label, 0, 64);
}
public function kitVersion(): int {
return $this->appConfig->getValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true);
}
/** Passphrase-wrapped copy of the master key (for repository configs and the recovery kit). */
public function wrappedKey(): array {
$data = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR);
if (!isset($data['slots'])) {
$data = ['slots' => $data === [] ? [] : [['slot' => 1, 'label' => 'Slot 1', 'created' => 0, 'key' => $data]]];
}
return $data;
}
/** Short identifier of the key, shown in the UI and the kit (not secret). */
public function fingerprint(): string {
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_FINGERPRINT, '', true);
}
/** @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(),
'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.',
];
}
/**
* Short code printed in the current kit file. Typing it back when confirming proves the
* admin actually has the downloaded kit. A new code per kit version; the server only keeps
* its own copy encrypted with the instance secret.
*/
private function kitCode(): string {
$stored = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_KIT_CODE, '{}', true), true) ?: [];
if (($stored['version'] ?? 0) === $this->kitVersion() && isset($stored['code'])) {
return $this->crypto->decrypt($stored['code']);
}
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
$code = substr($code, 0, 4) . '-' . substr($code, 4);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_KIT_CODE,
json_encode(['version' => $this->kitVersion(), 'code' => $this->crypto->encrypt($code)], JSON_THROW_ON_ERROR), true, true);
return $code;
}
public function checkKitCode(string $code): bool {
$stored = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_KIT_CODE, '{}', true), true) ?: [];
if (($stored['version'] ?? 0) !== $this->kitVersion() || !isset($stored['code'])) {
return false; // no kit downloaded for this version yet
}
$normalise = static fn (string $c) => strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $c) ?? '');
return hash_equals($normalise($this->crypto->decrypt($stored['code'])), $normalise($code));
}
public function confirmRecoveryKit(string $uid): void {
$this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED,
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint(), 'kit_version' => $this->kitVersion()], JSON_THROW_ON_ERROR), true);
}
/**
* Whether a recovery kit for this key was ever confirmed (any passphrase version). Backups
* keep running after a passphrase change, so changing it cannot be used to stop backups; the
* missing re-confirmation is reported as an error in the admin overview instead.
*/
public function everConfirmed(): bool {
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
$data = $raw === '' ? null : json_decode($raw, true);
return $data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint();
}
/** Confirmation for the current key and kit version, or null. */
public function recoveryKitConfirmation(): ?array {
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
$data = $raw === '' ? null : json_decode($raw, true);
return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint() && ($data['kit_version'] ?? 1) === $this->kitVersion()) ? $data : null;
}
private function fingerprintOf(KeyRing $keys): string {
$id = $keys->blobId('ng_backup key fingerprint');
return implode('-', str_split(substr($id, 0, 16), 4));
}
}
-61
View File
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
/**
* Passphrase slots (like LUKS): up to three passphrases, each opening the same master key, so
* that one person leaving or dying does not lock the company out of its backups. Every change
* is written to all locations and announced to all administrators; a new recovery kit must be
* downloaded and confirmed afterwards (backups keep running meanwhile).
*/
final class KeySlotService {
public function __construct(
private KeyService $keys,
private TargetService $targets,
private AlertService $alerts,
) {
}
public function add(#[\SensitiveParameter] string $passphrase, string $label): int {
$slot = $this->keys->addSlot($passphrase, $label);
$this->apply('slot_added', $slot);
return $slot;
}
public function replace(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void {
$this->keys->replaceSlot($slot, $passphrase, $label);
$this->apply('slot_replaced', $slot);
}
public function remove(int $slot): void {
$this->keys->removeSlot($slot);
$this->apply('slot_removed', $slot);
}
private function apply(string $event, int $slot): void {
$wrapped = $this->keys->wrappedKey();
$failed = [];
foreach ($this->targets->list() as $t) {
try {
$this->targets->repository($t)->updateWrappedKey($wrapped);
} catch (\Throwable $e) {
$failed[] = $t->getName();
}
}
$label = '';
foreach ($this->keys->slots() as $s) {
if ($s['slot'] === $slot) {
$label = $s['label'];
}
}
$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));
}
}
}
-99
View File
@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCP\IDBConnection;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
/**
* Shared/exclusive leases with an expiry, stored in ngb_leases.
*
* Nextcloud's locks live up to an hour; a PHP process killed while holding one (web cron time
* limit, OOM, kill -9) would block a location that long. A lease expires on its own unless its
* holder keeps refreshing it. Nextcloud's lock is only held for the few milliseconds in which a
* lease is checked and written.
*/
final class LeaseService {
public const SHARED = 's';
public const EXCLUSIVE = 'x';
public function __construct(
private IDBConnection $db,
private ILockingProvider $locking,
) {
}
/** @return string lease id; throws LockedException when it conflicts with a live lease */
public function acquire(string $name, string $mode, int $ttl): string {
$guard = 'ng_backup/lease/' . $name;
$this->locking->acquireLock($guard, ILockingProvider::LOCK_EXCLUSIVE);
try {
$now = time();
$qb = $this->db->getQueryBuilder();
$qb->delete('ngb_leases')
->where($qb->expr()->eq('name', $qb->createNamedParameter($name)))
->andWhere($qb->expr()->lt('expires_at', $qb->createNamedParameter($now)));
$qb->executeStatement();
$qb = $this->db->getQueryBuilder();
$qb->select('mode')->from('ngb_leases')->where($qb->expr()->eq('name', $qb->createNamedParameter($name)));
$modes = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
if ($modes !== [] && ($mode === self::EXCLUSIVE || in_array(self::EXCLUSIVE, $modes, true))) {
throw new LockedException($name);
}
$id = bin2hex(random_bytes(16));
$qb = $this->db->getQueryBuilder();
$qb->insert('ngb_leases')->values([
'id' => $qb->createNamedParameter($id),
'name' => $qb->createNamedParameter($name),
'mode' => $qb->createNamedParameter($mode),
'expires_at' => $qb->createNamedParameter($now + $ttl),
]);
$qb->executeStatement();
return $id;
} finally {
$this->locking->releaseLock($guard, ILockingProvider::LOCK_EXCLUSIVE);
}
}
/** Extend a lease while its holder is still working. */
public function refresh(string $id, int $ttl): void {
$qb = $this->db->getQueryBuilder();
$qb->update('ngb_leases')->set('expires_at', $qb->createNamedParameter(time() + $ttl))
->where($qb->expr()->eq('id', $qb->createNamedParameter($id)));
$qb->executeStatement();
}
public function release(string $id): void {
$qb = $this->db->getQueryBuilder();
$qb->delete('ngb_leases')->where($qb->expr()->eq('id', $qb->createNamedParameter($id)));
$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);
try {
return $fn(fn () => $this->refresh($id, $ttl));
} finally {
$this->release($id);
}
}
}
-240
View File
@@ -1,240 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Db\Target;
use OCP\IAppConfig;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
/**
* Applies the retention policy to a location: forgets snapshots the policy does not keep and
* prunes data no remaining snapshot uses. Never runs on append-only locations, never while a
* backup to the same location is running (the location lock is shared with BackupService).
*/
final class PruneService {
public function __construct(
private TargetService $targets,
private RunMapper $runs,
private SnapshotMapper $snapshots,
private IAppConfig $appConfig,
private ILockingProvider $locking,
private AlertService $alerts,
private KeyService $keys,
private LeaseService $leases,
) {
}
public function untrash(Target $target, string $snapshotId): void {
$lease = $this->lockExclusive($target, 300);
try {
$this->doUntrash($target, $snapshotId);
} finally {
$this->leases->release($lease);
}
}
/**
* Run $fn under the location's exclusive lock (to create a run atomically with respect to a
* cleanup, which takes the same lock and then refuses while a run exists).
*/
public function underStartLock(Target $target, callable $fn): mixed {
try {
return $this->leases->with(self::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $fn());
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
}
}
/**
* Exclusive lock on the location, then make sure no run of any kind (backup, restore, export)
* is in progress: their data may not be referenced by a snapshot yet, or is being read.
*/
private function lockExclusive(Target $target, int $ttl): string {
try {
$lease = $this->leases->acquire(self::lockKey($target), LeaseService::EXCLUSIVE, $ttl);
} catch (LockedException) {
throw new \RuntimeException('A backup, restore or cleanup of ' . $target->getName() . ' is in progress; try again later');
}
if ($this->runs->findRunning($target->getId()) !== []) {
$this->leases->release($lease);
throw new \RuntimeException('A backup or restore of ' . $target->getName() . ' is running; try again later');
}
return $lease;
}
private function doUntrash(Target $target, string $snapshotId): void {
$repo = $this->targets->repository($target);
if (!array_key_exists($snapshotId, $repo->trash())) {
throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)');
}
$repo->untrash($snapshotId);
// Put it back in the local snapshot list as well.
$meta = $repo->snapshotMeta($snapshotId);
$s = new \OCA\NgBackup\Db\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));
try {
$this->snapshots->insert($s);
} catch (\Throwable) {
}
$this->alerts->securityEvent('snapshot_untrashed', ['target' => $target->getName(), 'snapshot' => $snapshotId]);
}
public static function lockKey(Target $target): string {
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) ?: []);
}
public function setPolicy(RetentionPolicy $policy): void {
if ($policy->last < 1) {
throw new \InvalidArgumentException('Keep at least the last snapshot');
}
$old = $this->policy()->toArray();
$this->appConfig->setValueString(Application::APP_ID, 'retention', json_encode($policy->toArray(), JSON_THROW_ON_ERROR));
if ($old !== $policy->toArray()) {
$this->alerts->securityEvent('retention_changed', ['policy' => json_encode($policy->toArray())]);
}
}
/**
* Snapshots the policy drops go to the trash on the location; trash entries older than the
* deletion delay are purged, and only then is their data freed.
*
* @return array{kept: array<string, list<string>>, forgotten: list<string>, purged: list<string>, prune: array}
*/
public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array {
if ($target->getAppendOnly()) {
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
}
$lease = $this->lockExclusive($target, 1800);
try {
$repo = $this->targets->repository($target);
$times = [];
foreach ($repo->listSnapshots() as $id) {
$meta = $repo->snapshotMeta($id);
$times[$id] = (int)strtotime((string)($meta['time'] ?? '@0'));
}
$keep = $this->policy()->keep($times);
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
$purged = [];
if (!$dryRun) {
foreach ($forget as $id) {
$repo->forget($id, $actor);
try {
$this->snapshots->delete($this->snapshots->findOne($target->getId(), $id));
} catch (\Throwable) {
}
}
}
// Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone.
$delay = $this->keys->deleteDelayDays();
if (!$dryRun && $forget !== []) {
// Scheduled cleanup follows the policy: audit log only, no notification every week.
$this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget),
'until' => date('Y-m-d', time() + $delay * 86400)], !$scheduled);
}
if (!$dryRun) {
$purged = $repo->purgeTrash($delay * 86400);
}
$prune = $repo->prune($dryRun);
return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune];
} finally {
$this->leases->release($lease);
}
}
}
-228
View File
@@ -1,228 +0,0 @@
<?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.
*/
private const CONNECTION_KEYS = [
'dbtype', 'dbhost', 'dbport', 'dbname', 'dbuser', 'dbpassword', 'dbpersistent',
'dbtableprefix', 'dbdriveroptions', 'datadirectory',
];
public function __construct(
private TargetService $targets,
private RestoreService $restore,
private LeaseService $leases,
private IConfig $config,
private IDBConnection $db,
private IAppManager $appManager,
private KeyService $keys,
) {
}
/**
* Whether this installation can receive $snapshotId: same Nextcloud major version, and every
* app that was enabled at backup time already installed here (its tables must exist before
* the database restore can write into them).
*
* @return array{ok:bool, currentMajor:string, snapshotMajor:string, missingApps:list<string>}
*/
public function checkCompatibility(Target $target, string $snapshotId): array {
$meta = $this->targets->repository($target)->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)));
return [
'ok' => $currentMajor === $snapshotMajor && $missingApps === [],
'currentMajor' => $currentMajor,
'snapshotMajor' => $snapshotMajor,
'missingApps' => $missingApps,
];
}
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool} */
public function restoreFull(Target $target, string $snapshotId): array {
$check = $this->checkCompatibility($target, $snapshotId);
if (!$check['ok']) {
throw new \RuntimeException($this->describeBlockers($check));
}
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId) {
$beat = LeaseService::throttled($refresh);
$repo = $this->targets->repository($target);
$meta = $repo->snapshotMeta($snapshotId);
$dbReport = null;
$manifest = [];
if (isset($meta['db'])) {
$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);
} finally {
$this->restoreOwnAppConfig($ownAppConfig);
}
}
// 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.
/** @var array<string, mixed> $backupConfig */
$backupConfig = $this->fetchBackupConfig($target, $snapshotId);
$dataDir = rtrim($this->config->getSystemValueString('datadirectory'), '/');
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $dataDir, stripPrefix: true, heartbeat: $beat);
// 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' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
'dbConsistent' => (bool)($manifest['consistent'] ?? true)];
});
}
/** @return array<string, mixed> config.php's $CONFIG from the backup, minus the connection keys */
private function fetchBackupConfig(Target $target, string $snapshotId): array {
$tmp = sys_get_temp_dir() . '/ngb-restore-config-' . bin2hex(random_bytes(6));
try {
$this->restore->restoreToDirectory($target, $snapshotId, 'config', $tmp, stripPrefix: true);
$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['missingApps'] !== []) {
$parts[] = 'install these apps first (their tables must exist before the database restore can use them): '
. implode(', ', array_map(fn (string $a) => "occ app:install $a", $check['missingApps']));
}
return implode('; ', $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');
}
}
}
-266
View File
@@ -1,266 +0,0 @@
<?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\Repository\Repository;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
/**
* Restoring from a snapshot.
*
* User files (data/<uid>/files/...) are written through the Nextcloud Files API, so the file
* cache, versions and trash stay consistent: an overwritten file becomes a version and a
* removed file goes to the trash, which makes a restore itself reversible. Every file is
* written to a temporary name first and only renamed into place after all its blobs were
* decrypted and verified.
*
* Modes for user files:
* - new-folder (default): into "Restored <date>/<same path>", nothing existing is touched
* - merge: into the original place, existing files are overwritten (old content kept as a version)
* - replace: like merge, and files that are not in the snapshot are moved to the trash
*/
final class RestoreService {
public const MODE_NEW_FOLDER = 'new-folder';
public const MODE_MERGE = 'merge';
public const MODE_REPLACE = 'replace';
public function __construct(
private TargetService $targets,
private IRootFolder $root,
private LeaseService $leases,
) {
}
/** Shared lock on the location while restoring: a cleanup (exclusive) must not delete what is being read. */
private function withSharedLock(Target $target, callable $fn, int $ttl = 600): mixed {
try {
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, $fn);
} catch (\OCP\Lock\LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later');
}
}
/**
* Direct children (files and folders) of a path in a snapshot.
*
* @return list<array{name:string, type:string, size:int, mtime:int|null, files:int}>
*/
public function browse(Target $target, string $snapshotId, string $path = ''): array {
$prefix = trim($path, '/');
$prefix = $prefix === '' ? '' : $prefix . '/';
$children = [];
foreach ($this->targets->repository($target)->entries($snapshotId) as $e) {
if ($prefix !== '' && !str_starts_with($e['p'], $prefix)) {
continue;
}
$rest = substr($e['p'], strlen($prefix));
[$name, $more] = array_pad(explode('/', $rest, 2), 2, null);
$c = $children[$name] ?? ['name' => $name, 'type' => $more === null ? 'file' : 'dir', 'size' => 0, 'mtime' => null, 'files' => 0];
$c['size'] += $e['s'];
$c['files']++;
if ($more === null) {
$c['mtime'] = $e['m'];
}
$children[$name] = $c;
}
ksort($children, SORT_STRING);
return array_values($children);
}
/**
* Restore user files from "data/<uid>/files[/<path>]" into that user's Nextcloud files.
*
* @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) {
$last = time();
// Keep the lease alive while files are being written.
$beat = function (int $done, int $total, string $name) use ($refresh, $progress, &$last): void {
if (time() - $last >= 60) {
$refresh();
$last = time();
}
if ($progress !== null) {
$progress($done, $total, $name);
}
};
return $this->doRestoreUserFiles($target, $snapshotId, $logicalPath, $mode, $beat);
});
}
private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress): array {
if (!Repository::isSafePath($logicalPath) || !preg_match('#^data/([^/]+)/files(?:/(.*))?$#', $logicalPath, $m)) {
throw new \InvalidArgumentException('Expected a path like data/<user>/files/<folder or file>');
}
[$uid, $rel] = [$m[1], trim($m[2] ?? '', '/')];
$userFolder = $this->root->getUserFolder($uid);
$repo = $this->targets->repository($target);
$source = 'data/' . $uid . '/files' . ($rel === '' ? '' : '/' . $rel);
$entries = [];
$single = false;
foreach ($repo->entries($snapshotId) as $e) {
if ($e['p'] === $source) {
$single = true;
$entries[''] = $e;
break;
}
if (str_starts_with($e['p'], $source . '/')) {
$entries[substr($e['p'], strlen($source) + 1)] = $e;
}
}
if ($entries === []) {
throw new NotFoundException("Nothing at $source in snapshot $snapshotId");
}
// Where to write. new-folder mirrors the path under "Restored <date>" in the user's root.
$parentRel = $single ? (dirname($rel) === '.' ? '' : dirname($rel)) : $rel;
if ($mode === self::MODE_NEW_FOLDER) {
$parentRel = trim('Restored ' . date('Y-m-d H.i') . '/' . $parentRel, '/');
} elseif ($mode !== self::MODE_MERGE && $mode !== self::MODE_REPLACE) {
throw new \InvalidArgumentException("Unknown mode $mode");
}
$dest = $this->ensureFolder($userFolder, $parentRel);
$names = $single ? [basename($rel) => $entries['']] : $entries;
$restored = 0;
foreach ($names as $relPath => $entry) {
$this->writeFile($repo, $dest, (string)$relPath, $entry);
$restored++;
if ($progress !== null) {
$progress($restored, count($names), (string)$relPath);
}
}
$trashed = 0;
if ($mode === self::MODE_REPLACE && !$single) {
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)));
}
return ['restored' => $restored, 'trashed' => $trashed, 'target' => $uid . ':' . $userFolder->getRelativePath($dest->getPath())];
}
/**
* 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.
*/
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory, bool $stripPrefix = false, ?callable $heartbeat = null): 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");
}
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $prefix, $directory, $stripPrefix, $heartbeat): 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);
}, 3600);
}
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
$dir = dirname($relPath);
$folder = $dir === '.' ? $dest : $this->ensureFolder($dest, $dir);
$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));
$tmp = $folder->newFile($tmpName);
try {
$out = $tmp->fopen('wb');
$written = 0;
foreach ($repo->readBlobs($entry['b']) as $data) { // each blob is verified on read
if (fwrite($out, $data) !== strlen($data)) {
throw new \RuntimeException('Write failed for ' . $relPath);
}
$written += strlen($data);
}
if (!fclose($out)) {
throw new \RuntimeException('Write failed for ' . $relPath);
}
if ($written !== $entry['s']) {
throw new \RuntimeException("Size mismatch restoring $relPath");
}
if ($folder->nodeExists($name)) {
$existing = $folder->get($name);
if (!$existing instanceof File) {
throw new \RuntimeException("$relPath exists and is not a file");
}
// Overwrite through the Files API: the current content becomes a version.
$in = $tmp->fopen('rb');
$existing->putContent($in);
if (is_resource($in)) {
fclose($in);
}
$tmp->delete();
$existing->touch($entry['m']);
} else {
$tmp->move($folder->getPath() . '/' . $name);
$folder->get($name)->touch($entry['m']);
}
} catch (\Throwable $e) {
try {
$tmp->delete();
} catch (\Throwable) {
}
throw $e;
}
}
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;
foreach (array_keys($keep) as $k) {
if (str_starts_with((string)$k, $rel . '/')) {
$hasKept = true;
break;
}
}
if ($hasKept) {
$count += $this->trashExtras($node, $rel, $keep);
} else {
$node->delete(); // to the trash
$count++;
}
} elseif (!isset($keep[$rel])) {
$node->delete();
$count++;
}
}
return $count;
}
private function ensureFolder(Folder $base, string $path): Folder {
$path = trim($path, '/');
if ($path === '' || $path === '.') {
return $base;
}
if ($base->nodeExists($path)) {
$node = $base->get($path);
if (!$node instanceof Folder) {
throw new \RuntimeException("$path exists and is not a folder");
}
return $node;
}
return $base->newFolder($path);
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
/**
* Which snapshots to keep: the newest $last, plus the newest snapshot of each of the most recent
* $daily days, $weekly ISO weeks and $monthly months that have a snapshot (like restic/borg).
*/
final class RetentionPolicy {
public function __construct(
public readonly int $last = 3,
public readonly int $daily = 7,
public readonly int $weekly = 4,
public readonly int $monthly = 6,
) {
}
public static function fromArray(array $a): self {
return new self((int)($a['last'] ?? 3), (int)($a['daily'] ?? 7), (int)($a['weekly'] ?? 4), (int)($a['monthly'] ?? 6));
}
public function toArray(): array {
return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly];
}
/**
* @param array<string, int> $snapshots id => unix time
* @return array<string, list<string>> id => reasons it is kept (ids not in the result are removed)
*/
public function keep(array $snapshots): array {
arsort($snapshots);
$keep = [];
$i = 0;
foreach ($snapshots as $id => $time) {
if ($i++ < $this->last) {
$keep[$id][] = 'last';
}
}
foreach (['daily' => 'Y-m-d', 'weekly' => 'o-W', 'monthly' => 'Y-m'] as $kind => $format) {
$seen = [];
foreach ($snapshots as $id => $time) {
$bucket = date($format, $time);
if (isset($seen[$bucket])) {
continue;
}
if (count($seen) >= $this->$kind) {
break;
}
$seen[$bucket] = true;
$keep[$id][] = $kind;
}
}
return $keep;
}
}
-229
View File
@@ -1,229 +0,0 @@
<?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\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\Security\ICrypto;
use OCP\Server;
/**
* Backup targets: a files_external backend + auth mechanism + options + base path. The storage
* object is built only while it is used; nothing is mounted. files_external is loaded for its
* classes but its enabled/disabled state is left as the admin set it.
*/
final class TargetService {
public function __construct(
private TargetMapper $mapper,
private KeyService $keys,
private ICrypto $crypto,
private IAppManager $appManager,
private \OCA\NgBackup\Db\DbIndexCache $indexCache,
private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor,
private SnapshotMapper $snapshots,
) {
}
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
public function availableBackends(): array {
return $this->factory()->describeBackends();
}
/**
* Add a target: connect, create the repository there (or adopt an existing one made with this
* installation's key), then save it.
*
* @param array<string, mixed> $options
* @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)');
}
// 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');
}
try {
$this->mapper->findByName($name);
throw new \InvalidArgumentException("A target named '$name' already exists");
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
}
$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;
}
foreach ($this->mapper->findAll() as $existing) {
if ($existing->getRepositoryId() === $repo->id()) {
throw new \InvalidArgumentException("This repository is already registered as '{$existing->getName()}'");
}
}
$target = new Target();
$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, '/'));
$target->setRepositoryId($repo->id());
$target->setCreatedAt(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];
}
/**
* 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;
}
private function adoptSnapshots(Target $target, Repository $repo): void {
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));
$this->snapshots->insert($s);
} catch (\Throwable) {
}
}
}
/** @return list<Target> */
public function list(): array {
return $this->mapper->findAll();
}
public function get(string $nameOrId): Target {
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). */
public function setMaxBytes(Target $target, ?int $maxBytes): Target {
$target->setMaxBytes($maxBytes);
return $this->mapper->update($target);
}
public function setAppendOnly(Target $target, bool $appendOnly): Target {
$target->setAppendOnly($appendOnly);
return $this->mapper->update($target);
}
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
public function remove(Target $target): void {
$this->mapper->delete($target);
}
public function backend(Target $target): IBackend {
return $this->buildBackend($target->getBackend(), $target->getAuth(), $this->options($target), $target->getBasePath());
}
public function repository(Target $target): Repository {
$repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing(), $this->indexCache, $this->anchor);
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
throw new \RuntimeException('The target now contains a different repository than when it was added');
}
$repo->setMaxBytes($target->getMaxBytes());
return $repo;
}
/** @return array<string, mixed> */
public function options(Target $target): array {
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 ?ExternalStorageFactory $factory = null;
/**
* files_external's connection code, without changing the app's state:
* - enabled: it is booted by Nextcloud; use its own BackendService;
* - disabled: do NOT boot it (booting registers its mount provider and listeners, which use
* its database tables; on installations where it was never enabled those do not exist).
* Only its classes are loaded, and a private BackendService gets its backends and auth
* mechanisms from its Application class.
*/
private function factory(): ExternalStorageFactory {
if ($this->factory !== null) {
return $this->factory;
}
if ($this->appManager->isEnabledForAnyone('files_external')) {
$this->appManager->loadApp('files_external');
return $this->factory = new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class));
}
if (!class_exists(\OCA\Files_External\AppInfo\Application::class)) {
$path = $this->appManager->getAppPath('files_external');
require_once $path . '/composer/autoload.php';
}
$service = new \OCA\Files_External\Service\BackendService(
Server::get(\OCP\IAppConfig::class), Server::get(\Psr\Log\LoggerInterface::class));
$app = new \OCA\Files_External\AppInfo\Application();
$service->registerBackendProvider($app);
$service->registerAuthMechanismProvider($app);
return $this->factory = new ExternalStorageFactory($service);
}
}
-66
View File
@@ -1,66 +0,0 @@
<?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
@@ -1,137 +0,0 @@
<?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');
}
}
}
-48
View File
@@ -1,48 +0,0 @@
<?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 OCP\Lock\LockedException;
/** Checksum audit of a snapshot (occ backup:verify), see Repository::verify(). */
final class VerifyService {
public function __construct(
private TargetService $targets,
private LeaseService $leases,
) {
}
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>} */
public function verify(Target $target, string $snapshotId, bool $deep = false): array {
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
}
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>} */
public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false): array {
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
}
private function run(Target $target, bool $deep, callable $fn): array {
$ttl = $deep ? 600 : 300;
try {
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $deep, $ttl, $fn): array {
$last = time();
$heartbeat = function () use ($refresh, $ttl, &$last): void {
if (time() - $last >= intdiv($ttl, 2)) {
$refresh();
$last = time();
}
};
return $fn($this->targets->repository($target), $heartbeat);
});
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
}
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Settings;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Settings\ISettings;
use OCP\Util;
final class Admin implements ISettings {
public function getForm(): TemplateResponse {
Util::addScript('ng_backup', 'ng_backup-admin');
Util::addStyle('ng_backup', 'ng_backup-admin');
return new TemplateResponse('ng_backup', 'admin');
}
public function getSection(): string {
return 'ng_backup';
}
public function getPriority(): int {
return 10;
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Settings;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
final class AdminSection implements IIconSection {
public function __construct(
private IL10N $l,
private IURLGenerator $url,
) {
}
public function getID(): string {
return 'ng_backup';
}
public function getName(): string {
return $this->l->t('NG Backup');
}
public function getPriority(): int {
return 80;
}
public function getIcon(): string {
return $this->url->imagePath('ng_backup', 'app-dark.svg');
}
}
-71
View File
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\SetupCheck;
use OCA\NgBackup\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Service\AlertService;
use OCA\NgBackup\Service\BackupService;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\TargetService;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\SetupCheck\ISetupCheck;
use OCP\SetupCheck\SetupResult;
/** Shows the backup state in the admin overview (Security & setup warnings). */
final class BackupStatusCheck implements ISetupCheck {
public function __construct(
private IL10N $l,
private IURLGenerator $url,
private KeyService $keys,
private TargetService $targets,
private RunMapper $runs,
) {
}
public function getCategory(): string {
return 'system';
}
public function getName(): string {
return $this->l->t('NG Backup');
}
public function run(): SetupResult {
$link = $this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']);
if (!$this->keys->isInitialized() || !$this->keys->everConfirmed()) {
return SetupResult::error($this->l->t('Backups are not set up: create the backup key and confirm the recovery kit.'), $link);
}
if ($this->keys->recoveryKitConfirmation() === null) {
return SetupResult::error($this->l->t('The backup passphrase was changed: download and confirm the new recovery kit.'), $link);
}
$targets = $this->targets->list();
if ($targets === []) {
return SetupResult::warning($this->l->t('No backup location is configured.'), $link);
}
$problems = [];
foreach ($targets as $t) {
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
if ($last === null) {
$problems[] = $this->l->t('%s: no successful backup yet', [$t->getName()]);
} elseif (time() - (int)$last->getFinishedAt() > AlertService::STALE_AFTER) {
$problems[] = $this->l->t('%1$s: last successful backup %2$s', [$t->getName(), date('Y-m-d H:i', (int)$last->getFinishedAt())]);
}
}
foreach ($this->runs->findRecent(10) as $r) {
if ($r->getStatus() === Run::FAILED && $r->getStartedAt() > time() - AlertService::STALE_AFTER) {
$problems[] = $this->l->t('A %1$s run failed: %2$s', [$r->getKind(), mb_strimwidth((string)$r->getError(), 0, 120, '…')]);
break;
}
}
return $problems === []
? SetupResult::success($this->l->t('Recent backups to all locations succeeded.'))
: SetupResult::warning(implode("\n", $problems), $link);
}
}
@@ -28,8 +28,6 @@ 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));
}
@@ -38,7 +36,6 @@ 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 {
@@ -49,7 +46,6 @@ 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()];
}
@@ -69,11 +65,8 @@ final class RepositoryExportDestination implements IExportDestination {
if ($stream === false) {
throw new \RuntimeException('Cannot read ' . $node->getPath());
}
try {
$this->addFileAsStream($target, $stream);
} finally {
fclose($stream);
}
$this->addFileAsStream($target, $stream);
fclose($stream);
}
}
}
@@ -96,12 +89,6 @@ 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, '/');
}
+7 -31
View File
@@ -8,7 +8,6 @@ declare(strict_types=1);
namespace OCA\NgBackup\UserMigration;
use OCA\NgBackup\Repository\Repository;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\UserMigration\IImportSource;
use OCP\UserMigration\UserMigrationException;
@@ -20,8 +19,6 @@ 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);
}
@@ -33,18 +30,11 @@ final class RepositoryImportSource implements IImportSource {
return $data;
}
/** @return resource */
public function getFileAsStream(string $path) {
$entry = $this->entry($path, 'f');
// Phase 0: buffer in php://temp (memory up to 8 MiB, then a temp file for this one file).
$stream = fopen('php://temp/maxmemory:' . (8 * 1048576), 'w+b');
if ($stream === false) {
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) {
@@ -61,7 +51,6 @@ final class RepositoryImportSource implements IImportSource {
$prefix = trim($path, '/');
$prefix = $prefix === '' ? '' : $prefix . '/';
$names = [];
/** @var string $p */
foreach (array_keys($this->manifest['entries']) as $p) {
if ($prefix === '' || str_starts_with($p, $prefix)) {
$rest = substr($p, strlen($prefix));
@@ -81,7 +70,6 @@ final class RepositoryImportSource implements IImportSource {
if (isset($this->manifest['entries'][$p])) {
return true;
}
/** @var string $k */
foreach (array_keys($this->manifest['entries']) as $k) {
if (str_starts_with($k, $p . '/')) {
return true;
@@ -103,25 +91,13 @@ final class RepositoryImportSource implements IImportSource {
$this->copyToFolder($sub, $path);
} else {
$stream = $this->getFileAsStream($path);
try {
if ($destination->nodeExists($name)) {
$existing = $destination->get($name);
if (!$existing instanceof File) {
throw new UserMigrationException("$path exists and is not a file");
}
$existing->putContent($stream);
} else {
$destination->newFile($name, $stream);
}
} finally {
// 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);
}
if ($destination->nodeExists($name)) {
$destination->get($name)->putContent($stream);
} else {
$destination->newFile($name, $stream);
}
if (is_resource($stream)) {
fclose($stream);
}
}
}
@@ -1,77 +0,0 @@
<?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');
}
}
@@ -1,35 +0,0 @@
<?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);
}
}
-8381
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
{
"name": "ng_backup",
"version": "0.0.5",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"build": "vite --mode production build",
"dev": "vite --mode development build --watch"
},
"dependencies": {
"@nextcloud/axios": "^2.5.1",
"@nextcloud/dialogs": "^7.4.0",
"@nextcloud/l10n": "^3.4.0",
"@nextcloud/password-confirmation": "^6.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^9.0.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@nextcloud/vite-config": "^2.3.5",
"typescript": "^5.9.3",
"vite": "^7.3.6"
},
"engines": {
"node": "^22.0.0"
}
}
-490
View File
@@ -1,490 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="6.19.1@e2ca44251c1f1aa2e35452d426895746b47de64e">
<file src="lib/Backend/ExternalStorageFactory.php">
<MissingClosureParamType>
<code><![CDATA[$p]]></code>
<code><![CDATA[$p]]></code>
</MissingClosureParamType>
<MissingClosureReturnType>
<code><![CDATA[fn ($p) => $p->jsonSerialize()]]></code>
<code><![CDATA[fn ($p) => $p->jsonSerialize()]]></code>
</MissingClosureReturnType>
</file>
<file src="lib/Backend/IBackend.php">
<MissingParamType>
<code><![CDATA[$stream]]></code>
</MissingParamType>
</file>
<file src="lib/Backend/LocalBackend.php">
<MissingParamType>
<code><![CDATA[$stream]]></code>
</MissingParamType>
<TypeDoesNotContainType>
<code><![CDATA[$out !== false]]></code>
</TypeDoesNotContainType>
</file>
<file src="lib/Backend/StorageBackend.php">
<MissingParamType>
<code><![CDATA[$stream]]></code>
</MissingParamType>
</file>
<file src="lib/BackgroundJob/BackupJob.php">
<MissingParamType>
<code><![CDATA[$argument]]></code>
</MissingParamType>
</file>
<file src="lib/BackgroundJob/RestoreJob.php">
<MissingParamType>
<code><![CDATA[$argument]]></code>
</MissingParamType>
</file>
<file src="lib/Command/Browse.php">
<InvalidOperand>
<code><![CDATA[$b /= 1024]]></code>
</InvalidOperand>
</file>
<file src="lib/Command/KeyConfirm.php">
<UndefinedInterfaceMethod>
<code><![CDATA[ask]]></code>
<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>
<code><![CDATA[ask]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="lib/Command/KeySlots.php">
<UndefinedInterfaceMethod>
<code><![CDATA[ask]]></code>
<code><![CDATA[ask]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="lib/Command/Prune.php">
<PossiblyFalseOperand>
<code><![CDATA[json_encode($this->prune->policy()->toArray())]]></code>
</PossiblyFalseOperand>
</file>
<file src="lib/Command/RunBackup.php">
<PossiblyNullArgument>
<code><![CDATA[$run->getSnapshotId()]]></code>
</PossiblyNullArgument>
</file>
<file src="lib/Command/Status.php">
<RiskyTruthyFalsyComparison>
<code><![CDATA[$c]]></code>
<code><![CDATA[$r->getFinishedAt()]]></code>
</RiskyTruthyFalsyComparison>
</file>
<file src="lib/Command/TargetAdd.php">
<PossiblyNullArgument>
<code><![CDATA[$r['target']->getRepositoryId()]]></code>
</PossiblyNullArgument>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$v]]></code>
</PossiblyUndefinedArrayOffset>
</file>
<file src="lib/Command/TargetBackends.php">
<UndefinedClass>
<code><![CDATA[Base]]></code>
</UndefinedClass>
</file>
<file src="lib/Command/TargetLimit.php">
<InvalidOperand>
<code><![CDATA[(float)$m[1] * $factor]]></code>
</InvalidOperand>
</file>
<file src="lib/Command/TargetList.php">
<UndefinedClass>
<code><![CDATA[Base]]></code>
</UndefinedClass>
</file>
<file src="lib/Command/TargetTrustCurrent.php">
<UndefinedInterfaceMethod>
<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>
<code><![CDATA[$maxGb * 1073741824]]></code>
</InvalidOperand>
<PossiblyNullArgument>
<code><![CDATA[$r->getSnapshotId()]]></code>
</PossiblyNullArgument>
</file>
<file src="lib/Crypto/StreamCipher.php">
<MissingParamType>
<code><![CDATA[$in]]></code>
<code><![CDATA[$out]]></code>
</MissingParamType>
<PossiblyFalseArgument>
<code><![CDATA[$in]]></code>
<code><![CDATA[$in]]></code>
<code><![CDATA[$in]]></code>
<code><![CDATA[$in]]></code>
<code><![CDATA[$in]]></code>
<code><![CDATA[$in]]></code>
<code><![CDATA[$out]]></code>
<code><![CDATA[$out]]></code>
<code><![CDATA[$out]]></code>
<code><![CDATA[$out]]></code>
<code><![CDATA[$out]]></code>
<code><![CDATA[$out]]></code>
</PossiblyFalseArgument>
<PossiblyInvalidArrayAccess>
<code><![CDATA[unpack('N', $len)[1]]]></code>
</PossiblyInvalidArrayAccess>
</file>
<file src="lib/Db/AppConfigCatalogAnchor.php">
<PossiblyFalseArgument>
<code><![CDATA[json_encode(['gen' => $gen, 'hash' => $hash])]]></code>
</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>
<code><![CDATA[fn (string $c) => $this->db->quoteIdentifier($c)]]></code>
<code><![CDATA[fn (string $c) => $this->db->quoteIdentifier($c)]]></code>
</MissingClosureReturnType>
<PossiblyFalseArgument>
<code><![CDATA[$value]]></code>
</PossiblyFalseArgument>
<PossiblyNullArgument>
<code><![CDATA[$columns]]></code>
</PossiblyNullArgument>
<UndefinedClass>
<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[$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>
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></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\Table]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
<code><![CDATA[private]]></code>
</UndefinedClass>
</file>
<file src="lib/Db/Run.php">
<PropertyNotSetInConstructor>
<code><![CDATA[Run]]></code>
</PropertyNotSetInConstructor>
</file>
<file src="lib/Db/Snapshot.php">
<PropertyNotSetInConstructor>
<code><![CDATA[Snapshot]]></code>
</PropertyNotSetInConstructor>
</file>
<file src="lib/Db/Target.php">
<PropertyNotSetInConstructor>
<code><![CDATA[Target]]></code>
</PropertyNotSetInConstructor>
</file>
<file src="lib/Job/BackupRun.php">
<ArgumentTypeCoercion>
<code><![CDATA[$blobs]]></code>
</ArgumentTypeCoercion>
<MissingParamType>
<code><![CDATA[$fh]]></code>
</MissingParamType>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$mtime]]></code>
<code><![CDATA[$state['phase']]]></code>
<code><![CDATA[$state['stats']]]></code>
</PossiblyUndefinedArrayOffset>
</file>
<file src="lib/Job/ParentCursor.php">
<PossiblyNullReference>
<code><![CDATA[next]]></code>
</PossiblyNullReference>
</file>
<file src="lib/Job/TreeWalker.php">
<RedundantCastGivenDocblockType>
<code><![CDATA[(string)$name]]></code>
</RedundantCastGivenDocblockType>
</file>
<file src="lib/Migration/Version000001Date20261002000000.php">
<UndefinedDocblockClass>
<code><![CDATA[$schema->createTable('ngb_targets')]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000002Date20261002010000.php">
<UndefinedDocblockClass>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000003Date20261002020000.php">
<UndefinedDocblockClass>
<code><![CDATA[$schema->getTable('ngb_targets')]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000004Date20261002030000.php">
<UndefinedDocblockClass>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Migration/Version000005Date20261002040000.php">
<UndefinedDocblockClass>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
<code><![CDATA[$t]]></code>
</UndefinedDocblockClass>
</file>
<file src="lib/Repository/BlobIndex.php">
<PossiblyFalseArgument>
<code><![CDATA[$stream]]></code>
<code><![CDATA[$stream]]></code>
</PossiblyFalseArgument>
</file>
<file src="lib/Repository/Catalog.php">
<PossiblyFalseArgument>
<code><![CDATA[$stream]]></code>
<code><![CDATA[$stream]]></code>
</PossiblyFalseArgument>
</file>
<file src="lib/Repository/PackWriter.php">
<InvalidPropertyAssignmentValue>
<code><![CDATA[$this->buffer]]></code>
</InvalidPropertyAssignmentValue>
<PossiblyFalsePropertyAssignmentValue>
<code><![CDATA[fopen('php://temp/maxmemory:' . ($this->targetSize + 8 * 1048576), 'w+b')]]></code>
</PossiblyFalsePropertyAssignmentValue>
<RedundantPropertyInitializationCheck>
<code><![CDATA[isset($this->buffer)]]></code>
</RedundantPropertyInitializationCheck>
</file>
<file src="lib/Repository/Repository.php">
<InvalidOperand>
<code><![CDATA[strlen($data) * 0.95]]></code>
</InvalidOperand>
<InvalidReturnStatement>
<code><![CDATA[['snapshot' => $snapshotId] + $stats]]></code>
</InvalidReturnStatement>
<InvalidReturnType>
<code><![CDATA[array{snapshot:string, files:int, reused:int, read:int, newBlobs:int, dupBlobs:int, uploaded:int}]]></code>
</InvalidReturnType>
<MissingParamType>
<code><![CDATA[$fh]]></code>
</MissingParamType>
<PossiblyFalseArgument>
<code><![CDATA[$encrypted]]></code>
<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>
<code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code>
<code><![CDATA[$s]]></code>
<code><![CDATA[$s]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
<code><![CDATA[$tree]]></code>
</PossiblyFalseArgument>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$stats['files']]]></code>
<code><![CDATA[$stats['reused']]]></code>
</PossiblyUndefinedArrayOffset>
<PropertyNotSetInConstructor>
<code><![CDATA[$catalog]]></code>
</PropertyNotSetInConstructor>
</file>
<file src="lib/Service/AlertService.php">
<InvalidOperand>
<code><![CDATA[$bytes / $limit * 100]]></code>
<code><![CDATA[0.8 * $limit]]></code>
</InvalidOperand>
<PossiblyFalseOperand>
<code><![CDATA[json_encode(array_diff_key($params, ['actor' => 1]), JSON_UNESCAPED_SLASHES)]]></code>
</PossiblyFalseOperand>
</file>
<file src="lib/Service/BackupService.php">
<InvalidOperand>
<code><![CDATA[$deadline - microtime(true) + 120]]></code>
</InvalidOperand>
<UndefinedClass>
<code><![CDATA[\OC]]></code>
<code><![CDATA[\OC]]></code>
</UndefinedClass>
<UndefinedInterfaceMethod>
<code><![CDATA[getInner]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="lib/Service/KeyService.php">
<ArgumentTypeCoercion>
<code><![CDATA[array_diff(range(1, self::MAX_SLOTS), $used)]]></code>
</ArgumentTypeCoercion>
<LessSpecificReturnStatement>
<code><![CDATA[array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots'])]]></code>
</LessSpecificReturnStatement>
<MissingClosureParamType>
<code><![CDATA[$s]]></code>
<code><![CDATA[$s]]></code>
</MissingClosureParamType>
<MissingClosureReturnType>
<code><![CDATA[static fn (string $c) => strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $c) ?? '')]]></code>
</MissingClosureReturnType>
<MoreSpecificReturnType>
<code><![CDATA[list<array{slot:int, label:string, created:int}>]]></code>
</MoreSpecificReturnType>
<PossiblyFalseArgument>
<code><![CDATA[base64_decode($this->crypto->decrypt($enc), true)]]></code>
</PossiblyFalseArgument>
</file>
<file src="lib/Service/PruneService.php">
<LessSpecificReturnStatement>
<code><![CDATA[['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune]]]></code>
</LessSpecificReturnStatement>
<MissingClosureReturnType>
<code><![CDATA[fn () => $fn()]]></code>
</MissingClosureReturnType>
<MoreSpecificReturnType>
<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>
</UndefinedInterfaceMethod>
<UnresolvableInclude>
<code><![CDATA[include $path]]></code>
</UnresolvableInclude>
</file>
<file src="lib/Service/RestoreService.php">
<LessSpecificReturnStatement>
<code><![CDATA[array_values($children)]]></code>
</LessSpecificReturnStatement>
<MissingDependency>
<code><![CDATA[$this->root]]></code>
<code><![CDATA[private]]></code>
</MissingDependency>
<MoreSpecificReturnType>
<code><![CDATA[list<array{name:string, type:string, size:int, mtime:int|null, files:int}>]]></code>
</MoreSpecificReturnType>
<PossiblyFalseArgument>
<code><![CDATA[$in]]></code>
<code><![CDATA[$out]]></code>
<code><![CDATA[$out]]></code>
</PossiblyFalseArgument>
<PossiblyNullArrayOffset>
<code><![CDATA[$children]]></code>
<code><![CDATA[$children]]></code>
</PossiblyNullArrayOffset>
<RedundantCastGivenDocblockType>
<code><![CDATA[(string)$relPath]]></code>
<code><![CDATA[(string)$relPath]]></code>
</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>
<UnresolvableInclude>
<code><![CDATA[require_once $path . '/composer/autoload.php']]></code>
</UnresolvableInclude>
</file>
</files>

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