Compare commits

..

26 Commits

Author SHA1 Message Date
dillard fe052a1b96 Plan: release stages and version numbers
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
2026-10-02 13:16:54 +02:00
dillard 54ce5fa034 M5: end-to-end test matrix and CI
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
- tests/integration/roundtrip.sh: key, kit code and confirmation,
  location, full and unchanged incremental backup, restore new-folder and
  replace (files identical, extra file in the trash), database dump and
  restore, retention, trash and restore from trash, no files_external
  errors in the log
- tests/integration/matrix.sh: fresh Nextcloud per version and database
  (SQLite, MariaDB, PostgreSQL), removes only its own containers and
  their volumes; locally green on NC32 and NC35 with all three databases
- GitHub Actions: unit tests on PHP 8.2-8.4, round trip on NC32-35 x
  SQLite/MariaDB/PostgreSQL

Fixes found by the matrix:
- files_external is no longer booted when it is disabled: only its
  classes are loaded and a private BackendService gets its backends from
  its Application class (booting registered its mount provider, which
  failed on installations where its tables were never created)
- SQLite: the database file is excluded from the file backup (it is
  covered by the logical dump and copying the live file is inconsistent)
- occ backup:key:confirm accepts --code and --confirm for scripts
2026-10-02 11:56:57 +02:00
dillard 56b14a15b0 M5: resumable database dump and faster database restore
- DbDumper::dumpStep(): tables are dumped until the step's deadline in one
  REPEATABLE READ transaction and resumed at the last primary key in the
  next step; the running table checksum is carried over. A dump that fits
  in one step is fully consistent, otherwise the snapshot records
  dbConsistent=false. BackupService uses it, so the database phase no
  longer needs one long request (hosted Nextcloud, web cron)
- restore inserts 100 rows per statement and leaves tables whose checksum
  already matches the dump untouched
- spike C: 30-step dump gives the same checksums as the one-shot dump;
  restore rewrote only the 3 damaged tables of 147; 300k-row table
  restored in 14.6 s instead of 46 s
2026-10-02 11:34:59 +02:00
dillard 08b916cac8 M5: chained catalog against rollback and hidden snapshots
Every change to the set of snapshots or trash entries writes an
encrypted catalog generation bound to the repository and its position,
with the hash of the previous generation. The server anchors the newest
generation it has seen in its app config; on open, the location must
show at least that generation and chain back to it, and every snapshot
listed must exist. A rolled-back or tampered location is refused (the
run fails and administrators are notified). Opening with the passphrase
on a new server accepts the newest valid chain.

occ backup:target:trust-current accepts a legitimately older state
(e.g. restored from the provider's snapshot) after typing the location
name; audited and announced to all administrators.

Tests: chain advances, rollback, hidden snapshot, swapped generation,
disaster recovery without anchor. Tested on NC34 by deleting the newest
catalog generation on the SFTP location.
2026-10-02 11:30:42 +02:00
dillard 2c10dd2227 Close remaining review gaps
- restore runs from the web are created under the location's exclusive
  lock, like backup runs
- raw restore writes to an unpredictable temporary name opened with
  exclusive create (never follows a planted symlink) and validates the
  path prefix; the restore service rejects non-canonical paths instead
  of trimming them (occ normalises user input before calling it)
- restore jobs report progress every minute, so a long restore is not
  taken for a dead one
- README: Object Lock turns deletions into delete markers
2026-10-02 11:25:22 +02:00
dillard 7bde1cfdf0 Fixes from the phase-1 code review
- one locking protocol per location: backup steps and restores hold a
  shared lock, prune and untrash an exclusive one and refuse while any
  run (backup, restore, export) exists; starting a backup checks and
  creates the run under the exclusive lock, closing the race with prune
- snapshot entries are validated when read (relative path without '.',
  '..', NUL, backslash or control characters); raw restore also checks
  that the resolved directory stays below the target; the restore API
  and service accept canonical paths only
- recovery kit confirmation requires a recent password confirmation and
  the confirmation code printed in the current kit file (new code per
  kit version), proving the kit was actually downloaded
- prune decides on the authenticated index files on the location and
  refreshes the local index cache from them
- restore job marks the run failed on any error; restore runs without
  progress for 6 hours are failed so they cannot block cleanup forever
- tests for safe paths and a malicious snapshot path
2026-10-02 11:21:40 +02:00
dillard 9b03f98d0d M5: deletion delay with trash, passphrase slots, ransomware documentation
- removed snapshots go to a trash on the location and their data is only
  freed after the deletion delay, which is chosen when the key is created
  (default 7 days, 1-365) and cannot be changed afterwards; any admin can
  bring them back (occ backup:trash, backup:trash:restore, web UI)
- up to three passphrase slots open the same master key (like LUKS), so
  one person leaving or dying does not lock the company out; slot changes
  are written to every location, announced to all admins and require a
  new recovery kit confirmation, while backups keep running
- occ backup:key:slots, backup:key:slot:add|replace|remove; key init takes
  --label and --delete-delay-days
- web UI: deletion delay and first slot at key creation, slot management,
  trash per location with restore; all behind password confirmation
- README: key, slots (safe / CTO / head of IT), and protection against
  ransomware: object lock in compliance mode (e.g. 90 days), credentials
  without delete rights, provider snapshots, off-site copy
2026-10-02 03:01:44 +02:00
dillard 7b154e322b M5: notifications, setup check, audit log and password confirmation
- notifications to administrators for failed backup/restore runs and
  locations without a successful backup for two days (once a day)
- setup check in the admin overview: error when the key/kit is not set
  up, warning for missing, old or failed backups
- security events (recovery kit downloaded, location removed,
  append-only turned off, retention changed, snapshots forgotten) go to
  the audit log and are announced to all administrators, from the web
  and from occ; the scheduled weekly cleanup is logged only
- fix: the recovery-kit download link failed the CSRF check (412); it
  now requires a recent password confirmation instead, like creating the
  key, removing a location and restoring
2026-10-02 02:29:46 +02:00
dillard 7b3c9cabf6 M5: retention policy, prune with repacking, append-only locations
- RetentionPolicy (last/daily/weekly/monthly) and PruneService: forget
  snapshots the policy drops, delete packs nothing uses and repack mostly
  unused ones by copying encrypted blobs as-is; new packs are indexed
  before old ones are removed
- a location lock keeps prune and backups apart; append-only locations
  are never pruned (cleanup via the location's lifecycle rules)
- weekly prune from the background job; occ backup:prune [--dry-run],
  backup:retention, backup:target:append-only
- tests: forget+prune frees space and the remaining snapshot restores
  identically, repack of mixed packs, dry-run preview, retention buckets
2026-10-02 02:21:48 +02:00
dillard e5eaf6df14 Ignore built CSS and the PHPUnit result cache 2026-10-02 01:10:03 +02:00
dillard dad60140bd M4: admin settings page and JSON API
- AdminApiController (admin only, CSRF-protected): status, create key,
  download recovery kit, confirm (statement accepted + exact phrase),
  backends with their usable auth mechanisms, add/test/remove locations,
  start backups, schedule, snapshots, browse, queue restores
- RestoreJob runs web-initiated restores in the background; backup and
  restore runs are kept apart in the run table
- admin section 'NG Backup' with a Vue 3 / @nextcloud/vue 9 page: key and
  recovery-kit flow (download, tick statement, 'Yes, I confirm'), location
  form built from files_external's parameter definitions, schedule, run
  list with polling, snapshot browser and restore with the three modes
- auth mechanisms that depend on a user session or on files_external's
  own stored credentials are not offered

API tested with curl on NC34 (status, backends, schedule validation,
confirmation refusal, browse, queued restore and backup completed by
cron); non-admins get 403, anonymous 401.
2026-10-02 01:09:47 +02:00
dillard b144f20453 M3: restore files and folders (occ backup:browse, backup:restore)
User files are restored through the Files API so the file cache,
versions and trash stay consistent: modes new-folder (default, into
'Restored <date>/<path>'), merge (overwritten files keep their old
content as a version) and replace (extra files go to the trash). Each
file is staged under a temporary name and moved into place only after
all its blobs were verified. Any snapshot path can also be restored raw
to a local directory (disaster recovery).

Tested on NC34: all three modes restore checksum-identical files, the
overwritten content is kept as a version, the extra file is in the
trash, no temporary files remain.
2026-10-02 00:58:18 +02:00
dillard 50acd5dc0e M2: local cache of pack indexes (ngb_index)
Opening a repository lists index/ once and downloads only index files
that are not cached; packs written by this server are cached on write,
stale cache rows are dropped. Each resumable step therefore no longer
downloads every index file from the location.
2026-10-02 00:55:46 +02:00
dillard 2ab4b2e7d5 M2: full backup runs (database, config, data) with occ and cron
BackupService starts a run per location (database dump in one
transaction, then config + data directory as a resumable file run),
stores the state in ngb_runs after every step, locks a run while a step
executes and records the snapshot. Previews, logs, updater backups and
per-user cache/uploads are excluded (TreeWalker now supports patterns).
BackupJob (every 5 min) starts the daily schedule and steps running runs
with a short budget under web cron and a long one under system cron.
occ: backup:run, backup:status, backup:list.

Tested on NC34/PostgreSQL: first run 128 files / 1 GiB in 64 s (443 MiB
uploaded to S3), unchanged second run 2 s / 0 bytes; a background run to
SFTP completed through cron.php.
2026-10-02 00:53:50 +02:00
dillard 9cefea019d M2: cursor-based resumable runs with merge-join against the parent
TreeWalker walks one or more roots in a stable segment-wise order with
exclusions and resumes after a cursor; ParentCursor merge-joins the
previous snapshot in the same order. BackupRun no longer keeps a file
list or the parent's entries in memory or in its state.
2026-10-02 00:46:31 +02:00
dillard b42acb41c5 M1: key management, backup locations and occ commands
- migration: ngb_targets, ngb_runs, ngb_snapshots
- KeyService: master key stored encrypted with the instance secret
  (unattended backups), passphrase-wrapped copy in every repository and
  in the recovery kit, recorded admin confirmation per key fingerprint
- TargetService: locations built from files_external backends without a
  mount; files_external is loaded through IAppManager::loadApp() and its
  enabled state is left alone; credentials encrypted with ICrypto;
  existing repositories are adopted only if made with this key, and a
  repository cannot be registered twice
- Repository: initWithKey/openWithKey with an encrypted key check
- occ: backup:key:init|kit|confirm, backup:target:backends|add|list|remove
2026-10-02 00:37:18 +02:00
dillard 4a13f8e791 M1: PHPUnit test suite for crypto, repository and resumable runs
Dev tooling via composer (phpunit, psalm, nextcloud/ocp); vendor/ is
mounted from outside the repository by a helper script. 28 unit tests
ported from the phase-0 spikes (round trips, corruption, wrong keys,
dedup, incremental runs, partial restore, tampering, object binding,
time-boxed steps, file changed between steps).
2026-10-02 00:32:56 +02:00
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
78 changed files with 130 additions and 1241 deletions
-22
View File
@@ -25,18 +25,6 @@ jobs:
- run: composer install --no-interaction --no-progress - run: composer install --no-interaction --no-progress
- run: vendor/bin/phpunit -c tests/phpunit.xml - run: vendor/bin/phpunit -c tests/phpunit.xml
psalm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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: integration:
# Full round trip (key, kit, location, backup, incremental, restore modes, database dump and # Full round trip (key, kit, location, backup, incremental, restore modes, database dump and
# restore, retention, trash) on a fresh Nextcloud per version and database. # restore, retention, trash) on a fresh Nextcloud per version and database.
@@ -50,13 +38,3 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Round trip on Nextcloud ${{ matrix.nextcloud }} / ${{ matrix.database }} - name: Round trip on Nextcloud ${{ matrix.nextcloud }} / ${{ matrix.database }}
run: tests/integration/matrix.sh "${{ 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
- name: Round trip against S3 and SFTP
run: tests/integration/targets-matrix.sh
-3
View File
@@ -1,10 +1,7 @@
/build/ /build/
cred-*
*credentials*
/css/ /css/
/js/ /js/
/node_modules/ /node_modules/
.phpunit.result.cache .phpunit.result.cache
*.secret
/spikes/out/ /spikes/out/
/vendor/ /vendor/
-44
View File
@@ -1,44 +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.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).
+4 -9
View File
@@ -214,8 +214,8 @@ Daarom:
### 3.8 Backup en restore per gebruiker: via user_migration ### 3.8 Backup en restore per gebruiker: via user_migration
In plaats van zelf te bepalen welke gegevens van een gebruiker meegaan, hergebruiken we 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). **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 onze productie-instantie zijn dat er al: 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, 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 account, app-instellingen) en de bestanden zelf. Elke app die later een migrator toevoegt, gaat
automatisch mee. automatisch mee.
@@ -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 | | 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 | | 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. een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
## 8. Besluiten (2026-10-01) ## 8. Besluiten (2026-10-01)
1. **Doelgroep:** alle varianten: AIO, losse installaties én gehoste Nextcloud zonder SSH. 1. **Doelgroep:** alle varianten: AIO, losse installaties én gehoste Nextcloud zonder SSH.
2. **Licentie:** AGPL-3.0-or-later. 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:`. 4. **Naam:** "NG Backup", app-id `ng_backup`, occ-voorvoegsel `backup:`.
5. **Herstelkit:** verplicht downloaden + expliciete bevestiging door de beheerder (vinkje + 5. **Herstelkit:** verplicht downloaden + expliciete bevestiging door de beheerder (vinkje +
"Yes, I confirm"), zie 3.2; versleuteld per e-mail als optionele extra kopie. "Yes, I confirm"), zie 3.2; versleuteld per e-mail als optionele extra kopie.
@@ -338,11 +338,6 @@ een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
Volgorde: fase 1 grondig testen, reviewen en debuggen; dan nadenken over 0.1.0-alpha.1. Een 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. minimale volledige restore (`occ backup:restore:full` op een verse installatie) komt vóór de beta.
**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) ## Bijlage: lessen uit nextcloud/backup (test NC34, 2026-10-01)
- Externe programma's (`pg_dump`) en extensies (`pgsql`, `mysqli`) → onbruikbaar op standaard/hosted. - Externe programma's (`pg_dump`) en extensies (`pgsql`, `mysqli`) → onbruikbaar op standaard/hosted.
-13
View File
@@ -106,19 +106,6 @@ What you should do on the location (strongly recommended):
- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`. - **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`.
- **Whole server** (disaster recovery onto a fresh installation): planned for phase 2. - **Whole server** (disaster recovery onto a fresh installation): planned for phase 2.
## 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.
## Commands ## Commands
| Command | Purpose | | Command | Purpose |
+2 -7
View File
@@ -4,12 +4,8 @@
<id>ng_backup</id> <id>ng_backup</id>
<name>NG Backup</name> <name>NG Backup</name>
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary> <summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
<description><![CDATA[Alpha: feature-complete for scheduled, encrypted, incremental backups to <description><![CDATA[Work in progress, not ready for use.]]></description>
S3/SFTP/WebDAV/local, with restore of files and folders from the web interface and occ. Meant for <version>0.0.15</version>
testing on a copy of production (dev, test, staging) -- not yet for production use, and full
disaster recovery is not implemented yet. See the recovery kit section of the README before
relying on this for anything.]]></description>
<version>0.1.0</version>
<licence>agpl</licence> <licence>agpl</licence>
<author>Dillard Blom</author> <author>Dillard Blom</author>
<namespace>NgBackup</namespace> <namespace>NgBackup</namespace>
@@ -40,7 +36,6 @@ relying on this for anything.]]></description>
<command>OCA\NgBackup\Command\Retention</command> <command>OCA\NgBackup\Command\Retention</command>
<command>OCA\NgBackup\Command\TargetAppendOnly</command> <command>OCA\NgBackup\Command\TargetAppendOnly</command>
<command>OCA\NgBackup\Command\TargetTrustCurrent</command> <command>OCA\NgBackup\Command\TargetTrustCurrent</command>
<command>OCA\NgBackup\Command\TargetLimit</command>
<command>OCA\NgBackup\Command\Trash</command> <command>OCA\NgBackup\Command\Trash</command>
<command>OCA\NgBackup\Command\TrashRestore</command> <command>OCA\NgBackup\Command\TrashRestore</command>
<command>OCA\NgBackup\Command\KeySlots</command> <command>OCA\NgBackup\Command\KeySlots</command>
-1
View File
@@ -20,7 +20,6 @@ return [
['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'], ['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'],
['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'], ['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'],
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'], ['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#startBackup', 'url' => '/api/backups', 'verb' => 'POST'],
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'], ['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'], ['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
+1 -1
View File
@@ -12,7 +12,7 @@ use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext; 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 const APP_ID = 'ng_backup';
public function __construct() { public function __construct() {
+1 -1
View File
@@ -7,5 +7,5 @@ declare(strict_types=1);
namespace OCA\NgBackup\Backend; namespace OCA\NgBackup\Backend;
final class BackendException extends \RuntimeException { class BackendException extends \RuntimeException {
} }
-7
View File
@@ -27,13 +27,6 @@ interface IBackend {
public function exists(string $path): bool; 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) */ /** @return list<string> object paths below $prefix (recursive) */
public function list(string $prefix): array; public function list(string $prefix): array;
-11
View File
@@ -106,17 +106,6 @@ final class LocalBackend implements IBackend {
@unlink($this->abs($path)); @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 { private function abs(string $path): string {
if (str_contains($path, '..') || str_starts_with($path, '/')) { if (str_contains($path, '..') || str_starts_with($path, '/')) {
throw new BackendException('Invalid path: ' . $path); throw new BackendException('Invalid path: ' . $path);
-8
View File
@@ -115,14 +115,6 @@ final class StorageBackend implements IBackend {
$this->storage->unlink($this->abs($path)); $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 { private function listInto(string $dir, array &$result): void {
if (!$this->storage->is_dir($dir)) { if (!$this->storage->is_dir($dir)) {
return; return;
+1 -1
View File
@@ -22,7 +22,7 @@ use Psr\Log\LoggerInterface;
* With system cron a step may take minutes; with webcron/AJAX cron it is kept short so it fits * 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). * in a normal web request (hosted Nextcloud).
*/ */
final class BackupJob extends TimedJob { class BackupJob extends TimedJob {
public function __construct( public function __construct(
ITimeFactory $time, ITimeFactory $time,
private BackupService $backups, private BackupService $backups,
+1 -1
View File
@@ -16,7 +16,7 @@ use OCP\BackgroundJob\QueuedJob;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/** Runs a restore requested from the web interface, outside the web request. */ /** Runs a restore requested from the web interface, outside the web request. */
final class RestoreJob extends QueuedJob { class RestoreJob extends QueuedJob {
public function __construct( public function __construct(
ITimeFactory $time, ITimeFactory $time,
private RunMapper $runs, private RunMapper $runs,
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class Browse extends Command { class Browse extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private RestoreService $restore, private RestoreService $restore,
+1 -1
View File
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Question\Question;
final class KeyConfirm extends Command { class KeyConfirm extends Command {
public const STATEMENT = 'I have downloaded the recovery kit and stored it safely, outside this server. ' 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.'; . '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 const CONFIRMATION = 'Yes, I confirm';
+1 -1
View File
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Question\Question;
final class KeyInit extends Command { class KeyInit extends Command {
public function __construct( public function __construct(
private KeyService $keys, private KeyService $keys,
) { ) {
+1 -1
View File
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class KeyKit extends Command { class KeyKit extends Command {
public function __construct( public function __construct(
private KeyService $keys, private KeyService $keys,
private \OCA\NgBackup\Service\AlertService $alerts, private \OCA\NgBackup\Service\AlertService $alerts,
+1 -1
View File
@@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService; use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\KeySlotService; use OCA\NgBackup\Service\KeySlotService;
final class KeySlotAdd extends KeySlots { class KeySlotAdd extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) { public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'add'); parent::__construct($keys, $slots, 'add');
} }
+1 -1
View File
@@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService; use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\KeySlotService; use OCA\NgBackup\Service\KeySlotService;
final class KeySlotRemove extends KeySlots { class KeySlotRemove extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) { public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'remove'); parent::__construct($keys, $slots, 'remove');
} }
+1 -1
View File
@@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService; use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\KeySlotService; use OCA\NgBackup\Service\KeySlotService;
final class KeySlotReplace extends KeySlots { class KeySlotReplace extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) { public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'replace'); parent::__construct($keys, $slots, 'replace');
} }
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class ListSnapshots extends Command { class ListSnapshots extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private SnapshotMapper $snapshots, private SnapshotMapper $snapshots,
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class Prune extends Command { class Prune extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private PruneService $prune, private PruneService $prune,
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class Restore extends Command { class Restore extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private RestoreService $restore, private RestoreService $restore,
+1 -1
View File
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class Retention extends Command { class Retention extends Command {
public function __construct( public function __construct(
private PruneService $prune, private PruneService $prune,
) { ) {
+1 -1
View File
@@ -16,7 +16,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class RunBackup extends Command { class RunBackup extends Command {
public function __construct( public function __construct(
private BackupService $backups, private BackupService $backups,
private TargetService $targets, private TargetService $targets,
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class Status extends Command { class Status extends Command {
public function __construct( public function __construct(
private KeyService $keys, private KeyService $keys,
private TargetService $targets, private TargetService $targets,
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class TargetAdd extends Command { class TargetAdd extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private KeyService $keys, private KeyService $keys,
+1 -1
View File
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class TargetAppendOnly extends Command { class TargetAppendOnly extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private \OCA\NgBackup\Service\AlertService $alerts, private \OCA\NgBackup\Service\AlertService $alerts,
+1 -1
View File
@@ -12,7 +12,7 @@ use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class TargetBackends extends Base { class TargetBackends extends Base {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
) { ) {
-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;
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class TargetList extends Base { class TargetList extends Base {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
) { ) {
+1 -1
View File
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class TargetRemove extends Command { class TargetRemove extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private \OCA\NgBackup\Service\AlertService $alerts, private \OCA\NgBackup\Service\AlertService $alerts,
+1 -1
View File
@@ -22,7 +22,7 @@ use Symfony\Component\Console\Question\Question;
* legitimate cases (e.g. the location was restored from the provider's own snapshot), only from * legitimate cases (e.g. the location was restored from the provider's own snapshot), only from
* occ, audited and announced to all administrators. * occ, audited and announced to all administrators.
*/ */
final class TargetTrustCurrent extends Command { class TargetTrustCurrent extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private IAppConfig $appConfig, private IAppConfig $appConfig,
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class Trash extends Command { class Trash extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private KeyService $keys, private KeyService $keys,
+1 -1
View File
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
final class TrashRestore extends Command { class TrashRestore extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private PruneService $prune, private PruneService $prune,
+4 -24
View File
@@ -32,7 +32,7 @@ use OCP\IUserSession;
* JSON API for the admin settings page. Every method requires an admin (the AppFramework * JSON API for the admin settings page. Every method requires an admin (the AppFramework
* default for controllers without NoAdminRequired) and a valid CSRF token. * default for controllers without NoAdminRequired) and a valid CSRF token.
*/ */
final class AdminApiController extends Controller { class AdminApiController extends Controller {
public function __construct( public function __construct(
IRequest $request, IRequest $request,
private KeyService $keys, private KeyService $keys,
@@ -55,8 +55,7 @@ final class AdminApiController extends Controller {
$targets = []; $targets = [];
foreach ($this->targets->list() as $t) { foreach ($this->targets->list() as $t) {
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(), $targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(), 'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt()];
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)];
} }
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15)); $runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
return new JSONResponse([ return new JSONResponse([
@@ -168,25 +167,15 @@ final class AdminApiController extends Controller {
return new JSONResponse($this->targets->availableBackends()); return new JSONResponse($this->targets->availableBackends());
} }
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse { public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse {
try { try {
$r = $this->targets->add($name, $backend, $auth, $options, $path); $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) { } catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} }
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]); 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] #[PasswordConfirmationRequired]
public function removeTarget(int $id): JSONResponse { public function removeTarget(int $id): JSONResponse {
$target = $this->targets->get((string)$id); $target = $this->targets->get((string)$id);
@@ -270,17 +259,8 @@ final class AdminApiController extends Controller {
} }
private function runJson(Run $r): array { 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(), return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(), 'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'startedAt' => $r->getStartedAt(),
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(), 'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
'stats' => json_decode($r->getStats() ?? 'null', true)]; 'stats' => json_decode($r->getStats() ?? 'null', true)];
} }
+1 -1
View File
@@ -7,5 +7,5 @@ declare(strict_types=1);
namespace OCA\NgBackup\Crypto; namespace OCA\NgBackup\Crypto;
final class CryptoException extends \RuntimeException { class CryptoException extends \RuntimeException {
} }
+1 -1
View File
@@ -12,7 +12,7 @@ use OCA\NgBackup\Repository\ICatalogAnchor;
use OCP\IAppConfig; use OCP\IAppConfig;
/** Keeps the newest catalog generation seen per repository in the app config (outside the location). */ /** Keeps the newest catalog generation seen per repository in the app config (outside the location). */
final class AppConfigCatalogAnchor implements ICatalogAnchor { class AppConfigCatalogAnchor implements ICatalogAnchor {
public function __construct( public function __construct(
private IAppConfig $appConfig, private IAppConfig $appConfig,
) { ) {
+1 -1
View File
@@ -11,7 +11,7 @@ use OCA\NgBackup\Repository\IIndexCache;
use OCP\DB\Exception; use OCP\DB\Exception;
use OCP\IDBConnection; use OCP\IDBConnection;
final class DbIndexCache implements IIndexCache { class DbIndexCache implements IIndexCache {
public function __construct( public function __construct(
private IDBConnection $db, private IDBConnection $db,
) { ) {
+1 -1
View File
@@ -33,7 +33,7 @@ use OCP\AppFramework\Db\Entity;
* @method int|null getFinishedAt() * @method int|null getFinishedAt()
* @method void setFinishedAt(?int $v) * @method void setFinishedAt(?int $v)
*/ */
final class Run extends Entity { class Run extends Entity {
public const RUNNING = 'running'; public const RUNNING = 'running';
public const DONE = 'done'; public const DONE = 'done';
public const FAILED = 'failed'; public const FAILED = 'failed';
+1 -1
View File
@@ -12,7 +12,7 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection; use OCP\IDBConnection;
/** @template-extends QBMapper<Run> */ /** @template-extends QBMapper<Run> */
final class RunMapper extends QBMapper { class RunMapper extends QBMapper {
public function __construct(IDBConnection $db) { public function __construct(IDBConnection $db) {
parent::__construct($db, 'ngb_runs', Run::class); parent::__construct($db, 'ngb_runs', Run::class);
} }
+1 -1
View File
@@ -25,7 +25,7 @@ use OCP\AppFramework\Db\Entity;
* @method int|null getBytes() * @method int|null getBytes()
* @method void setBytes(?int $v) * @method void setBytes(?int $v)
*/ */
final class Snapshot extends Entity { class Snapshot extends Entity {
protected int $targetId = 0; protected int $targetId = 0;
protected string $snapshotId = ''; protected string $snapshotId = '';
protected string $kind = ''; protected string $kind = '';
+1 -1
View File
@@ -12,7 +12,7 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection; use OCP\IDBConnection;
/** @template-extends QBMapper<Snapshot> */ /** @template-extends QBMapper<Snapshot> */
final class SnapshotMapper extends QBMapper { class SnapshotMapper extends QBMapper {
public function __construct(IDBConnection $db) { public function __construct(IDBConnection $db) {
parent::__construct($db, 'ngb_snapshots', Snapshot::class); parent::__construct($db, 'ngb_snapshots', Snapshot::class);
} }
+1 -5
View File
@@ -24,12 +24,10 @@ use OCP\AppFramework\Db\Entity;
* @method void setRepositoryId(?string $v) * @method void setRepositoryId(?string $v)
* @method bool|null getAppendOnly() * @method bool|null getAppendOnly()
* @method void setAppendOnly(bool $v) * @method void setAppendOnly(bool $v)
* @method int|null getMaxBytes()
* @method void setMaxBytes(?int $v)
* @method int getCreatedAt() * @method int getCreatedAt()
* @method void setCreatedAt(int $v) * @method void setCreatedAt(int $v)
*/ */
final class Target extends Entity { class Target extends Entity {
protected string $name = ''; protected string $name = '';
protected string $backend = ''; protected string $backend = '';
protected string $auth = ''; protected string $auth = '';
@@ -38,11 +36,9 @@ final class Target extends Entity {
protected ?string $repositoryId = null; protected ?string $repositoryId = null;
protected int $createdAt = 0; protected int $createdAt = 0;
protected ?bool $appendOnly = null; protected ?bool $appendOnly = null;
protected ?int $maxBytes = null;
public function __construct() { public function __construct() {
$this->addType('createdAt', 'integer'); $this->addType('createdAt', 'integer');
$this->addType('appendOnly', 'boolean'); $this->addType('appendOnly', 'boolean');
$this->addType('maxBytes', 'integer');
} }
} }
+1 -1
View File
@@ -11,7 +11,7 @@ use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection; use OCP\IDBConnection;
/** @template-extends QBMapper<Target> */ /** @template-extends QBMapper<Target> */
final class TargetMapper extends QBMapper { class TargetMapper extends QBMapper {
public function __construct(IDBConnection $db) { public function __construct(IDBConnection $db) {
parent::__construct($db, 'ngb_targets', Target::class); parent::__construct($db, 'ngb_targets', Target::class);
} }
@@ -13,7 +13,7 @@ use OCP\DB\Types;
use OCP\Migration\IOutput; use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep; use OCP\Migration\SimpleMigrationStep;
final class Version000001Date20261002000000 extends SimpleMigrationStep { class Version000001Date20261002000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */ /** @var ISchemaWrapper $schema */
$schema = $schemaClosure(); $schema = $schemaClosure();
@@ -14,7 +14,7 @@ use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep; use OCP\Migration\SimpleMigrationStep;
/** Local cache of the per-pack index files, so opening a repository does not download them all. */ /** Local cache of the per-pack index files, so opening a repository does not download them all. */
final class Version000002Date20261002010000 extends SimpleMigrationStep { class Version000002Date20261002010000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */ /** @var ISchemaWrapper $schema */
$schema = $schemaClosure(); $schema = $schemaClosure();
@@ -14,7 +14,7 @@ use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep; use OCP\Migration\SimpleMigrationStep;
/** Append-only locations: NG Backup never deletes anything there (cleanup by the location's own lifecycle rules). */ /** Append-only locations: NG Backup never deletes anything there (cleanup by the location's own lifecycle rules). */
final class Version000003Date20261002020000 extends SimpleMigrationStep { class Version000003Date20261002020000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */ /** @var ISchemaWrapper $schema */
$schema = $schemaClosure(); $schema = $schemaClosure();
@@ -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;
}
}
+1 -5
View File
@@ -14,7 +14,7 @@ use OCP\Notification\INotification;
use OCP\Notification\INotifier; use OCP\Notification\INotifier;
use OCP\Notification\UnknownNotificationException; use OCP\Notification\UnknownNotificationException;
final class Notifier implements INotifier { class Notifier implements INotifier {
public function __construct( public function __construct(
private IFactory $l10n, private IFactory $l10n,
private IURLGenerator $url, private IURLGenerator $url,
@@ -42,10 +42,6 @@ final class Notifier implements INotifier {
: $l->t('Backup to %s failed', [$p['target']])); : $l->t('Backup to %s failed', [$p['target']]));
$notification->setParsedMessage((string)($p['error'] ?? '')); $notification->setParsedMessage((string)($p['error'] ?? ''));
break; 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': case 'backup_stale':
$notification->setParsedSubject($l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']])); $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.')); $notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
-9
View File
@@ -139,15 +139,6 @@ final class BlobIndex {
} }
} }
/** 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 { public function count(): int {
return count($this->blobs); return count($this->blobs);
} }
-7
View File
@@ -32,7 +32,6 @@ final class PackWriter {
private StreamCipher $cipher, private StreamCipher $cipher,
private BlobIndex $index, private BlobIndex $index,
private int $targetSize = 32 * 1048576, private int $targetSize = 32 * 1048576,
private ?int $maxBytes = null,
) { ) {
$this->reset(); $this->reset();
} }
@@ -65,12 +64,6 @@ final class PackWriter {
if ($this->entries === []) { if ($this->entries === []) {
return; 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)); $packId = bin2hex(random_bytes(16));
$header = $this->cipher->encryptString(json_encode($this->entries, JSON_THROW_ON_ERROR), 'pack-header:' . $packId); $header = $this->cipher->encryptString(json_encode($this->entries, JSON_THROW_ON_ERROR), 'pack-header:' . $packId);
fwrite($this->buffer, $header . pack('N', strlen($header))); 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 {
}
+11 -20
View File
@@ -32,7 +32,6 @@ final class Repository {
private StreamCipher $cipher; private StreamCipher $cipher;
private BlobIndex $index; private BlobIndex $index;
private ?PackWriter $runPacks = null; private ?PackWriter $runPacks = null;
private ?int $maxBytes = null;
private Catalog $catalog; private Catalog $catalog;
private function __construct( private function __construct(
@@ -94,15 +93,6 @@ final class Repository {
fn () => array_map('basename', $this->backend->list('trash/info'))); fn () => array_map('basename', $this->backend->list('trash/info')));
} }
/** 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). */ /** Current catalog generation (for status and tests). */
public function catalogGeneration(): int { public function catalogGeneration(): int {
return $this->catalog->gen; return $this->catalog->gen;
@@ -155,7 +145,7 @@ final class Repository {
public function backupDirectory(string $source, ?string $parent = null, string $label = ''): array { public function backupDirectory(string $source, ?string $parent = null, string $label = ''): array {
$source = rtrim($source, '/'); $source = rtrim($source, '/');
$previous = $parent !== null ? $this->loadEntries($parent) : []; $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]; $stats = ['files' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0];
$tree = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b'); $tree = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
@@ -236,7 +226,7 @@ final class Repository {
* run is done, so the last pack is uploaded before anything references it. * run is done, so the last pack is uploaded before anything references it.
*/ */
public function blobWriter(array &$stats): BlobWriter { 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; $packs = $this->runPacks;
return new BlobWriter(function (string $data) use ($packs, &$stats): string { return new BlobWriter(function (string $data) use ($packs, &$stats): string {
return $this->storeBlob($data, $packs, $stats); return $this->storeBlob($data, $packs, $stats);
@@ -268,7 +258,7 @@ final class Repository {
/** Store one blob of file data in the current run (deduplicated); returns its id. */ /** Store one blob of file data in the current run (deduplicated); returns its id. */
public function storeData(string $data, array &$stats): string { 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); return $this->storeBlob($data, $this->runPacks, $stats);
} }
@@ -345,19 +335,20 @@ final class Repository {
$meta = $this->snapshotMeta($snapshotId); $meta = $this->snapshotMeta($snapshotId);
$dbInTrash = null; $dbInTrash = null;
if (isset($meta['db']) && is_string($meta['db']) && $this->backend->exists($meta['db'])) { 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']; $dbInTrash = 'trash/' . $meta['db'];
$this->putObject($dbInTrash, $this->getObject($meta['db'])); $this->putObject($dbInTrash, $this->getObject($meta['db']));
$this->backend->delete($meta['db']);
} }
// The snapshot object is bound to its id (not its path), so it can be moved as raw bytes. // The snapshot object is bound to its id (not its path), so it can be copied as-is.
$this->backend->move('snapshots/' . $snapshotId, 'trash/snapshots/' . $snapshotId); $this->backend->put('trash/snapshots/' . $snapshotId, $this->backend->get('snapshots/' . $snapshotId));
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null, $this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR)); 'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
unset($this->catalog->snapshots[$snapshotId]); unset($this->catalog->snapshots[$snapshotId]);
$this->catalog->trash[$snapshotId] = true; $this->catalog->trash[$snapshotId] = true;
$this->catalog->write(); $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. */ /** Undo forget(): move a snapshot back from the trash. */
@@ -367,10 +358,11 @@ final class Repository {
$this->putObject($info['db'], $this->getObject($info['dbInTrash'])); $this->putObject($info['db'], $this->getObject($info['dbInTrash']));
$this->backend->delete($info['dbInTrash']); $this->backend->delete($info['dbInTrash']);
} }
$this->backend->move('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId); $this->backend->put('snapshots/' . $snapshotId, $this->backend->get('trash/snapshots/' . $snapshotId));
$this->catalog->snapshots[$snapshotId] = true; $this->catalog->snapshots[$snapshotId] = true;
unset($this->catalog->trash[$snapshotId]); unset($this->catalog->trash[$snapshotId]);
$this->catalog->write(); $this->catalog->write();
$this->backend->delete('trash/snapshots/' . $snapshotId);
$this->backend->delete('trash/info/' . $snapshotId); $this->backend->delete('trash/info/' . $snapshotId);
} }
@@ -489,7 +481,6 @@ final class Repository {
} else { } else {
$stats['repacked']++; $stats['repacked']++;
if (!$dryRun) { if (!$dryRun) {
// No size limit here: repacking is how space is freed.
$writer ??= new PackWriter($this->backend, $this->cipher, $this->index); $writer ??= new PackWriter($this->backend, $this->cipher, $this->index);
$path = 'packs/' . substr($packId, 0, 2) . '/' . $packId; $path = 'packs/' . substr($packId, 0, 2) . '/' . $packId;
foreach ($blobs as $b) { foreach ($blobs as $b) {
+1 -31
View File
@@ -15,7 +15,7 @@ use OCP\IGroupManager;
use OCP\Notification\IManager; use OCP\Notification\IManager;
/** Notifies administrators about failed runs and locations without a recent successful backup. */ /** Notifies administrators about failed runs and locations without a recent successful backup. */
final class AlertService { class AlertService {
public const STALE_AFTER = 2 * 86400; public const STALE_AFTER = 2 * 86400;
public function __construct( public function __construct(
@@ -54,36 +54,6 @@ final class AlertService {
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]); ['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 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. */ /** At most one notification per location per day. */
public function checkStale(Target $target): void { public function checkStale(Target $target): void {
$last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL); $last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
+17 -25
View File
@@ -28,7 +28,7 @@ use Psr\Log\LoggerInterface;
* Excluded by default: previews, log files, the updater's backups and each user's cache and * Excluded by default: previews, log files, the updater's backups and each user's cache and
* temporary uploads (all regenerable or temporary). * temporary uploads (all regenerable or temporary).
*/ */
final class BackupService { class BackupService {
public const KIND_FULL = 'full'; public const KIND_FULL = 'full';
public function __construct( public function __construct(
@@ -42,7 +42,6 @@ final class BackupService {
private ILockingProvider $locking, private ILockingProvider $locking,
private LoggerInterface $logger, private LoggerInterface $logger,
private AlertService $alerts, private AlertService $alerts,
private LeaseService $leases,
) { ) {
} }
@@ -55,16 +54,17 @@ final class BackupService {
} }
// Check and create the run under the location's exclusive lock, so a cleanup cannot start // 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). // 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. $lock = PruneService::lockKey($target);
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
if ($running !== []) {
return $running[0];
}
try { try {
return $this->leases->with(PruneService::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $this->createRun($target, $label)); $this->locking->acquireLock($lock, ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) { } catch (LockedException) {
throw new \RuntimeException('A cleanup or another start for ' . $target->getName() . ' is running; try again later'); throw new \RuntimeException('A cleanup or another start for ' . $target->getName() . ' is running; try again later');
} }
try {
return $this->createRun($target, $label);
} finally {
$this->locking->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE);
}
} }
private function createRun(Target $target, string $label): Run { private function createRun(Target $target, string $label): Run {
@@ -103,18 +103,18 @@ final class BackupService {
* run locked and returns immediately. * run locked and returns immediately.
*/ */
public function step(Run $run, float $deadline): Run { 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. $lockKey = 'ng_backup/run/' . $run->getId();
$ttl = (int)min(3600, max(120, $deadline - microtime(true) + 120));
try { try {
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl); $this->locking->acquireLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) { } catch (LockedException) {
return $run; // another process is doing this step return $run;
} }
// Shared lease on the location while this step writes: a cleanup (exclusive) cannot run now. // Shared lock on the location while this step writes: a cleanup (exclusive) cannot run now.
$targetLock = 'ng_backup/target/' . $run->getTargetId();
try { try {
$targetLease = $this->leases->acquire('ng_backup/target/' . $run->getTargetId(), LeaseService::SHARED, $ttl); $this->locking->acquireLock($targetLock, ILockingProvider::LOCK_SHARED);
} catch (LockedException) { } catch (LockedException) {
$this->leases->release($runLease); $this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
return $run; return $run;
} }
try { try {
@@ -149,7 +149,6 @@ final class BackupService {
$state['files'] = BackupRun::step($repo, $state['files'], $deadline); $state['files'] = BackupRun::step($repo, $state['files'], $deadline);
if ($state['files']['phase'] === 'done') { if ($state['files']['phase'] === 'done') {
$this->recordSnapshot($run, $state['files']); $this->recordSnapshot($run, $state['files']);
$this->alerts->storedBytes($target, $repo->storedBytes());
$run->setStatus(Run::DONE); $run->setStatus(Run::DONE);
$run->setPhase('done'); $run->setPhase('done');
$run->setFinishedAt(time()); $run->setFinishedAt(time());
@@ -172,22 +171,15 @@ final class BackupService {
} }
return $run; return $run;
} finally { } finally {
$this->leases->release($targetLease); $this->locking->releaseLock($targetLock, ILockingProvider::LOCK_SHARED);
$this->leases->release($runLease); $this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
} }
} }
/** Run to completion in this process (occ, system cron), reporting progress. */ /** Run to completion in this process (occ, system cron), reporting progress. */
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run { public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
while ($run->getStatus() === Run::RUNNING) { while ($run->getStatus() === Run::RUNNING) {
$before = $run->getUpdatedAt();
$run = $this->step($run, microtime(true) + $stepSeconds); $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) { if ($progress !== null) {
$progress($run); $progress($run);
} }
+1 -1
View File
@@ -22,7 +22,7 @@ use OCP\Security\ICrypto;
* into the recovery kit, so backups can be restored on a fresh server with the passphrase. * 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. * - The admin must confirm having stored the recovery kit before the first backup runs.
*/ */
final class KeyService { class KeyService {
private const KEY_MASTER = 'master_key'; private const KEY_MASTER = 'master_key';
private const KEY_WRAPPED = 'master_key_wrapped'; private const KEY_WRAPPED = 'master_key_wrapped';
private const KEY_CONFIRMED = 'recovery_kit_confirmed'; private const KEY_CONFIRMED = 'recovery_kit_confirmed';
+1 -1
View File
@@ -13,7 +13,7 @@ namespace OCA\NgBackup\Service;
* is written to all locations and announced to all administrators; a new recovery kit must be * is written to all locations and announced to all administrators; a new recovery kit must be
* downloaded and confirmed afterwards (backups keep running meanwhile). * downloaded and confirmed afterwards (backups keep running meanwhile).
*/ */
final class KeySlotService { class KeySlotService {
public function __construct( public function __construct(
private KeyService $keys, private KeyService $keys,
private TargetService $targets, private TargetService $targets,
-88
View File
@@ -1,88 +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();
}
/** 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);
}
}
}
+14 -11
View File
@@ -20,7 +20,7 @@ use OCP\Lock\LockedException;
* prunes data no remaining snapshot uses. Never runs on append-only locations, never while a * 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). * backup to the same location is running (the location lock is shared with BackupService).
*/ */
final class PruneService { class PruneService {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private RunMapper $runs, private RunMapper $runs,
@@ -29,16 +29,15 @@ final class PruneService {
private ILockingProvider $locking, private ILockingProvider $locking,
private AlertService $alerts, private AlertService $alerts,
private KeyService $keys, private KeyService $keys,
private LeaseService $leases,
) { ) {
} }
public function untrash(Target $target, string $snapshotId): void { public function untrash(Target $target, string $snapshotId): void {
$lease = $this->lockExclusive($target, 300); $this->lockExclusive($target);
try { try {
$this->doUntrash($target, $snapshotId); $this->doUntrash($target, $snapshotId);
} finally { } finally {
$this->leases->release($lease); $this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
} }
} }
@@ -48,27 +47,31 @@ final class PruneService {
*/ */
public function underStartLock(Target $target, callable $fn): mixed { public function underStartLock(Target $target, callable $fn): mixed {
try { try {
return $this->leases->with(self::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $fn()); $this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) { } catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later'); throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
} }
try {
return $fn();
} finally {
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
}
} }
/** /**
* Exclusive lock on the location, then make sure no run of any kind (backup, restore, export) * 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. * 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 { private function lockExclusive(Target $target): void {
try { try {
$lease = $this->leases->acquire(self::lockKey($target), LeaseService::EXCLUSIVE, $ttl); $this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) { } catch (LockedException) {
throw new \RuntimeException('A backup, restore or cleanup of ' . $target->getName() . ' is in progress; try again later'); throw new \RuntimeException('A backup, restore or cleanup of ' . $target->getName() . ' is in progress; try again later');
} }
if ($this->runs->findRunning($target->getId()) !== []) { if ($this->runs->findRunning($target->getId()) !== []) {
$this->leases->release($lease); $this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
throw new \RuntimeException('A backup or restore of ' . $target->getName() . ' is running; try again later'); 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 { private function doUntrash(Target $target, string $snapshotId): void {
@@ -123,7 +126,7 @@ final class PruneService {
if ($target->getAppendOnly()) { if ($target->getAppendOnly()) {
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there'); throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
} }
$lease = $this->lockExclusive($target, 1800); $this->lockExclusive($target);
try { try {
$repo = $this->targets->repository($target); $repo = $this->targets->repository($target);
$times = []; $times = [];
@@ -157,7 +160,7 @@ final class PruneService {
$prune = $repo->prune($dryRun); $prune = $repo->prune($dryRun);
return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune]; return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune];
} finally { } finally {
$this->leases->release($lease); $this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
} }
} }
} }
+12 -19
View File
@@ -28,7 +28,7 @@ use OCP\Files\NotFoundException;
* - merge: into the original place, existing files are overwritten (old content kept as a version) * - 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 * - replace: like merge, and files that are not in the snapshot are moved to the trash
*/ */
final class RestoreService { class RestoreService {
public const MODE_NEW_FOLDER = 'new-folder'; public const MODE_NEW_FOLDER = 'new-folder';
public const MODE_MERGE = 'merge'; public const MODE_MERGE = 'merge';
public const MODE_REPLACE = 'replace'; public const MODE_REPLACE = 'replace';
@@ -36,17 +36,23 @@ final class RestoreService {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private IRootFolder $root, private IRootFolder $root,
private LeaseService $leases, private \OCP\Lock\ILockingProvider $locking,
) { ) {
} }
/** Shared lock on the location while restoring: a cleanup (exclusive) must not delete what is being read. */ /** 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 { private function withSharedLock(Target $target, callable $fn): mixed {
$key = PruneService::lockKey($target);
try { try {
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, $fn); $this->locking->acquireLock($key, \OCP\Lock\ILockingProvider::LOCK_SHARED);
} catch (\OCP\Lock\LockedException) { } catch (\OCP\Lock\LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later'); throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later');
} }
try {
return $fn();
} finally {
$this->locking->releaseLock($key, \OCP\Lock\ILockingProvider::LOCK_SHARED);
}
} }
/** /**
@@ -82,20 +88,7 @@ final class RestoreService {
* @return array{restored:int, trashed:int, target:string} * @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 { 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) { return $this->withSharedLock($target, fn () => $this->doRestoreUserFiles($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 { private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress): array {
@@ -157,7 +150,7 @@ final class RestoreService {
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create $directory"); throw new \RuntimeException("Cannot create $directory");
} }
return $this->withSharedLock($target, fn () => $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/'), 3600); return $this->withSharedLock($target, fn () => $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/'));
} }
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void { private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
+1 -8
View File
@@ -22,7 +22,7 @@ use OCP\Server;
* object is built only while it is used; nothing is mounted. files_external is loaded for its * 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. * classes but its enabled/disabled state is left as the admin set it.
*/ */
final class TargetService { class TargetService {
public function __construct( public function __construct(
private TargetMapper $mapper, private TargetMapper $mapper,
private KeyService $keys, private KeyService $keys,
@@ -90,12 +90,6 @@ final class TargetService {
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId); return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($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 { public function setAppendOnly(Target $target, bool $appendOnly): Target {
$target->setAppendOnly($appendOnly); $target->setAppendOnly($appendOnly);
return $this->mapper->update($target); return $this->mapper->update($target);
@@ -115,7 +109,6 @@ final class TargetService {
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) { if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
throw new \RuntimeException('The target now contains a different repository than when it was added'); throw new \RuntimeException('The target now contains a different repository than when it was added');
} }
$repo->setMaxBytes($target->getMaxBytes());
return $repo; return $repo;
} }
+1 -1
View File
@@ -11,7 +11,7 @@ use OCP\AppFramework\Http\TemplateResponse;
use OCP\Settings\ISettings; use OCP\Settings\ISettings;
use OCP\Util; use OCP\Util;
final class Admin implements ISettings { class Admin implements ISettings {
public function getForm(): TemplateResponse { public function getForm(): TemplateResponse {
Util::addScript('ng_backup', 'ng_backup-admin'); Util::addScript('ng_backup', 'ng_backup-admin');
Util::addStyle('ng_backup', 'ng_backup-admin'); Util::addStyle('ng_backup', 'ng_backup-admin');
+1 -1
View File
@@ -11,7 +11,7 @@ use OCP\IL10N;
use OCP\IURLGenerator; use OCP\IURLGenerator;
use OCP\Settings\IIconSection; use OCP\Settings\IIconSection;
final class AdminSection implements IIconSection { class AdminSection implements IIconSection {
public function __construct( public function __construct(
private IL10N $l, private IL10N $l,
private IURLGenerator $url, private IURLGenerator $url,
+1 -1
View File
@@ -19,7 +19,7 @@ use OCP\SetupCheck\ISetupCheck;
use OCP\SetupCheck\SetupResult; use OCP\SetupCheck\SetupResult;
/** Shows the backup state in the admin overview (Security & setup warnings). */ /** Shows the backup state in the admin overview (Security & setup warnings). */
final class BackupStatusCheck implements ISetupCheck { class BackupStatusCheck implements ISetupCheck {
public function __construct( public function __construct(
private IL10N $l, private IL10N $l,
private IURLGenerator $url, private IURLGenerator $url,
+2 -14
View File
@@ -8,7 +8,6 @@ declare(strict_types=1);
namespace OCA\NgBackup\UserMigration; namespace OCA\NgBackup\UserMigration;
use OCA\NgBackup\Repository\Repository; use OCA\NgBackup\Repository\Repository;
use OCP\Files\File;
use OCP\Files\Folder; use OCP\Files\Folder;
use OCP\UserMigration\IImportSource; use OCP\UserMigration\IImportSource;
use OCP\UserMigration\UserMigrationException; use OCP\UserMigration\UserMigrationException;
@@ -31,14 +30,10 @@ final class RepositoryImportSource implements IImportSource {
return $data; return $data;
} }
/** @return resource */
public function getFileAsStream(string $path) { public function getFileAsStream(string $path) {
$entry = $this->entry($path, 'f'); $entry = $this->entry($path, 'f');
// Phase 0: buffer in php://temp (memory up to 8 MiB, then a temp file for this one file). // 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'); $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) { foreach ($this->repo->readBlobs($entry['b']) as $data) {
for ($done = 0, $len = strlen($data); $done < $len; $done += $n) { for ($done = 0, $len = strlen($data); $done < $len; $done += $n) {
$n = fwrite($stream, $done === 0 ? $data : substr($data, $done)); $n = fwrite($stream, $done === 0 ? $data : substr($data, $done));
@@ -56,7 +51,6 @@ final class RepositoryImportSource implements IImportSource {
$prefix = trim($path, '/'); $prefix = trim($path, '/');
$prefix = $prefix === '' ? '' : $prefix . '/'; $prefix = $prefix === '' ? '' : $prefix . '/';
$names = []; $names = [];
/** @var string $p */
foreach (array_keys($this->manifest['entries']) as $p) { foreach (array_keys($this->manifest['entries']) as $p) {
if ($prefix === '' || str_starts_with($p, $prefix)) { if ($prefix === '' || str_starts_with($p, $prefix)) {
$rest = substr($p, strlen($prefix)); $rest = substr($p, strlen($prefix));
@@ -76,7 +70,6 @@ final class RepositoryImportSource implements IImportSource {
if (isset($this->manifest['entries'][$p])) { if (isset($this->manifest['entries'][$p])) {
return true; return true;
} }
/** @var string $k */
foreach (array_keys($this->manifest['entries']) as $k) { foreach (array_keys($this->manifest['entries']) as $k) {
if (str_starts_with($k, $p . '/')) { if (str_starts_with($k, $p . '/')) {
return true; return true;
@@ -98,17 +91,12 @@ final class RepositoryImportSource implements IImportSource {
$this->copyToFolder($sub, $path); $this->copyToFolder($sub, $path);
} else { } else {
$stream = $this->getFileAsStream($path); $stream = $this->getFileAsStream($path);
try {
if ($destination->nodeExists($name)) { if ($destination->nodeExists($name)) {
$existing = $destination->get($name); $destination->get($name)->putContent($stream);
if (!$existing instanceof File) {
throw new UserMigrationException("$path exists and is not a file");
}
$existing->putContent($stream);
} else { } else {
$destination->newFile($name, $stream); $destination->newFile($name, $stream);
} }
} finally { if (is_resource($stream)) {
fclose($stream); fclose($stream);
} }
} }
-497
View File
@@ -1,497 +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/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>
<PossiblyNullIterator>
<code><![CDATA[$segments]]></code>
</PossiblyNullIterator>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$mtime]]></code>
<code><![CDATA[$state['cur']]]></code>
<code><![CDATA[$state['phase']]]></code>
<code><![CDATA[$state['segments']]]></code>
<code><![CDATA[$state['stats']]]></code>
</PossiblyUndefinedArrayOffset>
</file>
<file src="lib/Job/ParentCursor.php">
<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>
<InvalidReturnStatement>
<code><![CDATA[[
'version' => 1,
'app' => 'NG Backup',
'fingerprint' => $this->fingerprint(),
'kit_version' => $this->kitVersion(),
'confirmation_code' => $this->kitCode(),
'created' => gmdate('c'),
'wrapped_key' => $this->wrappedKey(),
'slots' => $this->slots(),
'instructions' => 'Keep this file safe, outside this server. Any one of the passphrases of the slots listed here opens it. '
. 'To restore on a new server: install NG Backup, add the backup location and enter this kit with one passphrase. '
. 'Without the kit (or the location) and a passphrase, the backups cannot be decrypted.',
]]]></code>
</InvalidReturnStatement>
<InvalidReturnType>
<code><![CDATA[array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string}]]></code>
</InvalidReturnType>
<LessSpecificReturnStatement>
<code><![CDATA[array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots'])]]></code>
</LessSpecificReturnStatement>
<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/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>
-45
View File
@@ -1,45 +0,0 @@
<?xml version="1.0"?>
<!-- SPDX-FileCopyrightText: 2026 Dillard Blom -->
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<psalm
errorLevel="2"
resolveFromConfigFile="true"
findUnusedBaselineEntry="true"
findUnusedCode="false"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
errorBaseline="psalm-baseline.xml"
>
<projectFiles>
<directory name="lib" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
<!-- Interface-only stubs for Nextcloud's public API, not autoloaded at runtime: scanned for
type resolution only, never reported on. lib/ must only use OCP\, never internal OC\
classes, since those differ between the supported Nextcloud versions (PLAN.md section 6). -->
<extraFiles>
<directory name="vendor/nextcloud/ocp/OCP" />
<directory name="vendor/nextcloud/ocp/NCU" />
</extraFiles>
<issueHandlers>
<UndefinedClass>
<errorLevel type="suppress">
<!-- Provided by files_external at runtime; not a composer dependency (PLAN.md 3.5). -->
<referencedClass name="OCA\Files_External\**" />
</errorLevel>
</UndefinedClass>
<!-- The #[\Override] attribute convention (PHP 8.3+) adds no behaviour; this app supports
PHP 8.1+, and applying it to every interface-implementing method project-wide is pure
ceremony with no bug-catching value, so it's suppressed rather than papered over. -->
<MissingOverrideAttribute>
<errorLevel type="suppress">
<directory name="lib" />
</errorLevel>
</MissingOverrideAttribute>
</issueHandlers>
</psalm>
+4 -29
View File
@@ -11,23 +11,12 @@
</NcSettingsSection> </NcSettingsSection>
<template v-if="status"> <template v-if="status">
<div class="ngb-tabs">
<NcButton :variant="tab === 'backup' ? 'primary' : 'secondary'" @click="tab = 'backup'">{{ t('ng_backup', 'Backup and restore') }}</NcButton>
<NcButton :variant="tab === 'settings' ? 'primary' : 'secondary'" @click="tab = 'settings'">{{ t('ng_backup', 'Settings') }}</NcButton>
</div>
<template v-if="tab === 'settings'">
<KeySetup :status="status" @changed="refresh" /> <KeySetup :status="status" @changed="refresh" />
<Locations v-if="status.key.everConfirmed" :status="status" @changed="refresh" /> <template v-if="status.key.everConfirmed">
<Locations :status="status" @changed="refresh" />
<Backups v-if="status.targets.length" :status="status" @changed="refresh" />
<RestoreBrowser v-if="status.targets.length" :status="status" @changed="refresh" />
</template> </template>
<template v-else-if="status.key.everConfirmed">
<template v-if="status.targets.length">
<Backups :status="status" @changed="refresh" />
<RestoreBrowser :status="status" @changed="refresh" />
</template>
<NcNoteCard v-else type="info">{{ t('ng_backup', 'Add a backup location under "Settings" first.') }}</NcNoteCard>
</template>
<NcNoteCard v-else type="warning">{{ t('ng_backup', 'Set up the backup key under "Settings" first.') }}</NcNoteCard>
</template> </template>
</div> </div>
</template> </template>
@@ -35,7 +24,6 @@
<script setup> <script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { t } from '@nextcloud/l10n' import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
@@ -47,20 +35,12 @@ import RestoreBrowser from './components/RestoreBrowser.vue'
const status = ref(null) const status = ref(null)
const error = ref('') const error = ref('')
const tab = ref('settings')
let tabInitialized = false
let timer = null let timer = null
async function refresh() { async function refresh() {
try { try {
status.value = await api.status() status.value = await api.status()
error.value = '' error.value = ''
// Default to the daily-use tab once the key is set up; stay on Settings otherwise.
// Only decided on first load, so switching tabs ourselves isn't undone by polling.
if (!tabInitialized) {
tab.value = status.value.key.everConfirmed ? 'backup' : 'settings'
tabInitialized = true
}
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
} }
@@ -79,9 +59,4 @@ onBeforeUnmount(() => clearTimeout(timer))
.ng-backup :deep(.settings-section) { .ng-backup :deep(.settings-section) {
max-width: 900px; max-width: 900px;
} }
.ngb-tabs {
display: flex;
gap: 8px;
margin: 0 0 12px;
}
</style> </style>
-1
View File
@@ -29,7 +29,6 @@ export default {
addTarget: (target) => call('post', '/targets', target), addTarget: (target) => call('post', '/targets', target),
removeTarget: (id) => call('delete', `/targets/${id}`), removeTarget: (id) => call('delete', `/targets/${id}`),
testTarget: (id) => call('post', `/targets/${id}/test`), testTarget: (id) => call('post', `/targets/${id}/test`),
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }), startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
setSchedule: (time) => call('put', '/schedule', { time }), setSchedule: (time) => call('put', '/schedule', { time }),
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`), snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
-2
View File
@@ -36,7 +36,6 @@
<th>{{ t('ng_backup', 'Started') }}</th> <th>{{ t('ng_backup', 'Started') }}</th>
<th>{{ t('ng_backup', 'Location') }}</th> <th>{{ t('ng_backup', 'Location') }}</th>
<th>{{ t('ng_backup', 'Kind') }}</th> <th>{{ t('ng_backup', 'Kind') }}</th>
<th>{{ t('ng_backup', 'Restore point') }}</th>
<th>{{ t('ng_backup', 'Status') }}</th> <th>{{ t('ng_backup', 'Status') }}</th>
<th>{{ t('ng_backup', 'Files') }}</th> <th>{{ t('ng_backup', 'Files') }}</th>
<th>{{ t('ng_backup', 'Uploaded') }}</th> <th>{{ t('ng_backup', 'Uploaded') }}</th>
@@ -47,7 +46,6 @@
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td> <td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
<td>{{ targetName(run.targetId) }}</td> <td>{{ targetName(run.targetId) }}</td>
<td>{{ run.kind }}</td> <td>{{ run.kind }}</td>
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td> <td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td> <td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td> <td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
+4 -19
View File
@@ -8,8 +8,7 @@
<ul v-if="status.targets.length" class="ngb-list"> <ul v-if="status.targets.length" class="ngb-list">
<li v-for="target in status.targets" :key="target.id"> <li v-for="target in status.targets" :key="target.id">
<strong>{{ target.name }}</strong> <strong>{{ target.name }}</strong>
<span class="ngb-muted">{{ target.backend }} · {{ target.path }}{{ target.appendOnly ? ' · ' + t('ng_backup', 'append-only') : '' }}</span> <span class="ngb-muted">{{ target.backend }} · {{ target.path }}</span>
<span :class="usageClass(target)">{{ usageText(target) }}</span>
<span v-if="tests[target.id]" :class="tests[target.id].ok ? 'ngb-ok' : 'ngb-err'"> <span v-if="tests[target.id]" :class="tests[target.id].ok ? 'ngb-ok' : 'ngb-err'">
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }} {{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
</span> </span>
@@ -29,8 +28,6 @@
<NcTextField v-else v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" /> <NcTextField v-else v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" />
</template> </template>
<NcTextField v-model="form.path" :label="t('ng_backup', 'Folder on the location for the backups')" /> <NcTextField v-model="form.path" :label="t('ng_backup', 'Folder on the location for the backups')" />
<NcTextField v-model="form.maxGb" type="number" min="0" :label="t('ng_backup', 'Maximum size in GiB')"
:helper-text="form.maxGb ? t('ng_backup', 'Backups stop at this size; administrators are warned at 80%.') : t('ng_backup', 'Empty = unlimited: everything stored is billed by your provider, and nothing stops a backup that keeps growing.')" />
<div class="ngb-row"> <div class="ngb-row">
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton> <NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton>
<NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</NcButton> <NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</NcButton>
@@ -64,18 +61,7 @@ const backends = ref([])
const backend = ref(null) const backend = ref(null)
const auth = ref(null) const auth = ref(null)
const tests = reactive({}) const tests = reactive({})
const form = reactive({ name: '', path: 'ng_backup', options: {}, maxGb: '' }) const form = reactive({ name: '', path: 'ng_backup', options: {} })
const gib = (b) => (b / 1073741824).toFixed(1) + ' GiB'
function usageText(target) {
if (target.storedBytes === null || target.storedBytes === undefined) {
return target.maxBytes ? t('ng_backup', 'limit {limit}', { limit: gib(target.maxBytes) }) : t('ng_backup', 'no limit')
}
return target.maxBytes
? t('ng_backup', '{used} of {limit}', { used: gib(target.storedBytes), limit: gib(target.maxBytes) })
: t('ng_backup', '{used} stored, no limit', { used: gib(target.storedBytes) })
}
const usageClass = (target) => (target.maxBytes && target.storedBytes >= 0.8 * target.maxBytes) ? 'ngb-err' : 'ngb-muted'
// files_external DefinitionParameter: type 0 text, 1 boolean, 2 password; flag 1 = optional, 4 = hidden // files_external DefinitionParameter: type 0 text, 1 boolean, 2 password; flag 1 = optional, 4 = hidden
const toFields = (params) => Object.entries(params || {}) const toFields = (params) => Object.entries(params || {})
@@ -104,10 +90,9 @@ async function add() {
busy.value = true busy.value = true
error.value = '' error.value = ''
try { try {
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path, await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path })
maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
adding.value = false adding.value = false
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' }) Object.assign(form, { name: '', path: 'ng_backup', options: {} })
emit('changed') emit('changed')
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
+3 -4
View File
@@ -7,7 +7,7 @@
:description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')"> :description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')">
<div class="ngb-row"> <div class="ngb-row">
<NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" /> <NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" />
<NcSelect v-if="target" v-model="snapshot" :options="snapshots" label="displayLabel" :input-label="t('ng_backup', 'Restore point')" :clearable="false" /> <NcSelect v-if="target" v-model="snapshot" :options="snapshots" :get-option-label="snapshotLabel" :input-label="t('ng_backup', 'Restore point')" :clearable="false" />
</div> </div>
<template v-if="snapshot"> <template v-if="snapshot">
@@ -30,7 +30,7 @@
<NcButton v-if="restorable(path) && path !== selected" variant="tertiary" @click="selected = path">{{ t('ng_backup', 'Select this folder') }}</NcButton> <NcButton v-if="restorable(path) && path !== selected" variant="tertiary" @click="selected = path">{{ t('ng_backup', 'Select this folder') }}</NcButton>
<div v-if="selected" class="ngb-restore"> <div v-if="selected" class="ngb-restore">
<p>{{ t('ng_backup', 'Selected: {path}, from the restore point of {date}', { path: selected, date: snapshotLabel(snapshot) }) }}</p> <p>{{ t('ng_backup', 'Selected: {path}', { path: selected }) }}</p>
<NcCheckboxRadioSwitch v-for="m in modes" :key="m.id" v-model="mode" :value="m.id" type="radio" name="ngb-mode">{{ m.label }}</NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch v-for="m in modes" :key="m.id" v-model="mode" :value="m.id" type="radio" name="ngb-mode">{{ m.label }}</NcCheckboxRadioSwitch>
<NcButton variant="primary" :disabled="busy" @click="restore">{{ t('ng_backup', 'Restore') }}</NcButton> <NcButton variant="primary" :disabled="busy" @click="restore">{{ t('ng_backup', 'Restore') }}</NcButton>
</div> </div>
@@ -84,8 +84,7 @@ const crumbs = computed(() => {
watch(target, async (tg) => { watch(target, async (tg) => {
snapshot.value = null snapshot.value = null
const list = tg ? await api.snapshots(tg.id) : [] snapshots.value = tg ? await api.snapshots(tg.id) : []
snapshots.value = list.map(s => ({ ...s, displayLabel: snapshotLabel(s) }))
snapshot.value = snapshots.value[0] ?? null snapshot.value = snapshots.value[0] ?? null
}, { immediate: true }) }, { immediate: true })
watch(snapshot, () => { selected.value = ''; open('data') }) watch(snapshot, () => { selected.value = ''; open('data') })
-12
View File
@@ -146,16 +146,4 @@ class RepositoryTest extends TestCase {
$this->assertFileDoesNotExist(dirname($target, 2) . '/escaped.txt'); $this->assertFileDoesNotExist(dirname($target, 2) . '/escaped.txt');
$this->assertFileDoesNotExist(dirname($target) . '/escaped.txt'); $this->assertFileDoesNotExist(dirname($target) . '/escaped.txt');
} }
public function testStorageLimitStopsTheBackup(): void {
$repo = Repository::init($this->backend, 'pw');
$repo->setMaxBytes(6 * 1048576); // less than the test tree
try {
$repo->backupDirectory($this->src);
$this->fail('backup should hit the limit');
} catch (\OCA\NgBackup\Repository\QuotaExceededException) {
}
$this->assertLessThanOrEqual(6 * 1048576, $repo->storedBytes());
$this->assertSame([], $repo->listSnapshots(), 'no snapshot from an incomplete backup');
}
} }
+3 -6
View File
@@ -1,12 +1,9 @@
#!/bin/bash #!/bin/bash
# End-to-end round trip of NG Backup inside a Nextcloud installation (CI and local matrix). # End-to-end round trip of NG Backup inside a Nextcloud installation (CI and local matrix).
# Usage: roundtrip.sh "<command prefix to run occ/php as the web user in the instance>" <datadir> <apps-path> [target-args] # Usage: roundtrip.sh "<command prefix to run occ/php as the web user in the instance>" <datadir> <apps-path>
# e.g. roundtrip.sh "docker exec -u www-data ngb-nc35-pgsql" /var/www/html/data /var/www/html/custom_apps # e.g. roundtrip.sh "docker exec -u www-data ngb-nc35-pgsql" /var/www/html/data /var/www/html/custom_apps
# target-args defaults to a local files_external location; pass backend/auth/options to exercise
# a different backend (e.g. amazons3 or sftp), see tests/integration/targets-matrix.sh.
set -euo pipefail set -euo pipefail
X="$1"; DATA="$2"; APPS="$3" X="$1"; DATA="$2"; APPS="$3"
TARGET_ARGS="${4:-local -a null::null -o datadir=/tmp/ngb-rt-target}"
occ() { $X php occ "$@"; } occ() { $X php occ "$@"; }
fail() { echo "FAIL: $*"; exit 1; } fail() { echo "FAIL: $*"; exit 1; }
ok() { echo "ok $*"; } ok() { echo "ok $*"; }
@@ -20,8 +17,8 @@ CODE=$(occ backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN),
occ backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm" occ backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm"
ok "key, kit code and confirmation" ok "key, kit code and confirmation"
occ backup:target:add ci $TARGET_ARGS --path=repo >/dev/null || fail "target add" occ backup:target:add ci local -a null::null -o datadir=/tmp/ngb-rt-target --path=repo >/dev/null || fail "target add"
ok "location added" ok "local location added"
# Test data: a user with files. # Test data: a user with files.
$X sh -c "OC_PASS='Roundtrip-pw-2026' php occ user:add --password-from-env $U >/dev/null" $X sh -c "OC_PASS='Roundtrip-pw-2026' php occ user:add --password-from-env $U >/dev/null"
-65
View File
@@ -1,65 +0,0 @@
#!/bin/bash
# Round trip of NG Backup against real S3 (s3mock) and SFTP backends, through files_external's own
# backend/auth classes, same as a production location would use. Backend correctness does not
# depend on the Nextcloud version or database, so this runs once (not matrixed across NC/DB),
# on a recent Nextcloud with sqlite. Each backend gets its own fresh Nextcloud container, since
# NG Backup's key can only be initialised once per instance.
# Usage: tests/integration/targets-matrix.sh [nextcloud-version]
set -euo pipefail
V=${1:-35}
APP=$(cd "$(dirname "$0")/../.." && pwd)
NET=ngb-targets-net
RESULTS=()
cleanup() { docker rm -fv ngb-targets-nc-s3 ngb-targets-nc-sftp ngb-targets-s3 ngb-targets-sftp >/dev/null 2>&1; docker network rm "$NET" >/dev/null 2>&1; }
trap cleanup EXIT
wait_tcp() { # host port
docker run --rm --network "$NET" alpine:3 sh -c "for i in \$(seq 1 30); do nc -z $1 $2 2>/dev/null && exit 0; sleep 1; done; exit 1" \
|| { echo "FAIL: $1:$2 never came up"; exit 1; }
}
start_nc() { # container-name
docker run -d --name "$1" --network "$NET" \
-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin-targets-pw -e NEXTCLOUD_TRUSTED_DOMAINS=localhost \
-e SQLITE_DATABASE=nextcloud \
-v "$APP:/var/www/html/custom_apps/ng_backup:ro" nextcloud:$V-apache >/dev/null
for i in $(seq 1 90); do docker exec -u www-data "$1" php occ status 2>/dev/null | grep -q "installed: true" && break; sleep 4; done
docker exec "$1" chown www-data:www-data /var/www/html/custom_apps
echo "=== NC $V / sqlite ($1): $(docker exec -u www-data "$1" php occ -V 2>/dev/null)"
}
run() { # label, nc-container, target-args
echo "=== $1"
if bash ${NGB_TRACE:+-x} "$APP/tests/integration/roundtrip.sh" "docker exec -u www-data $2" /var/www/html/data /var/www/html/custom_apps "$3"; then
RESULTS+=("$1: PASS")
else
RESULTS+=("$1: FAIL")
docker exec "$2" tail -c 2000 /var/www/html/data/nextcloud.log | tr ',' '\n' | grep -E '"message"' | tail -3
fi
}
docker network create "$NET" >/dev/null
# adobe/s3mock: an S3-compatible test server (MinIO's Docker Hub image now needs a paid login;
# this one is freely pullable and pre-creates a bucket via INITIAL_BUCKETS, no auth to configure).
docker run -d --name ngb-targets-s3 --network "$NET" \
-e INITIAL_BUCKETS=ngbackups \
adobe/s3mock >/dev/null
# atmoz/sftp: "user:pass:::dir" creates /home/user/dir as the upload root.
docker run -d --name ngb-targets-sftp --network "$NET" \
atmoz/sftp ngbackup:ngbackuppw:::upload >/dev/null
wait_tcp ngb-targets-s3 9090
wait_tcp ngb-targets-sftp 22
start_nc ngb-targets-nc-s3
run "S3 (s3mock)" ngb-targets-nc-s3 "amazons3 -a amazons3::accesskey -o bucket=ngbackups -o hostname=ngb-targets-s3 -o port=9090 -o region=us-east-1 -o use_ssl=false -o use_path_style=true -o key=test -o secret=test"
start_nc ngb-targets-nc-sftp
run "SFTP" ngb-targets-nc-sftp "sftp -a password::password -o host=ngb-targets-sftp -o root=/upload -o user=ngbackup -o password=ngbackuppw"
printf '%s\n' "${RESULTS[@]}"
printf '%s\n' "${RESULTS[@]}" | grep -q FAIL && exit 1
exit 0