Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b00d7ab94 | |||
| aa541d58b2 | |||
| e00d6579f7 | |||
| 101f954c5d | |||
| 6ee2b7e138 | |||
| b06ff0f89f | |||
| 5b0aded7b0 | |||
| 4fce893389 | |||
| fee189aec3 |
@@ -1,62 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
unit:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
php: ['8.2', '8.3', '8.4']
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: ${{ matrix.php }}
|
|
||||||
extensions: sodium, zip
|
|
||||||
coverage: none
|
|
||||||
- run: composer install --no-interaction --no-progress
|
|
||||||
- run: vendor/bin/phpunit -c tests/phpunit.xml
|
|
||||||
|
|
||||||
psalm:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.2'
|
|
||||||
extensions: sodium, zip
|
|
||||||
coverage: none
|
|
||||||
- run: composer install --no-interaction --no-progress
|
|
||||||
- run: composer psalm
|
|
||||||
|
|
||||||
integration:
|
|
||||||
# Full round trip (key, kit, location, backup, incremental, restore modes, database dump and
|
|
||||||
# restore, retention, trash) on a fresh Nextcloud per version and database.
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
nextcloud: ['32', '33', '34', '35']
|
|
||||||
database: ['sqlite', 'mariadb', 'pgsql']
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Round trip on Nextcloud ${{ matrix.nextcloud }} / ${{ matrix.database }}
|
|
||||||
run: tests/integration/matrix.sh "${{ matrix.nextcloud }}" "${{ matrix.database }}"
|
|
||||||
|
|
||||||
integration-targets:
|
|
||||||
# Same round trip, but through the real S3 (s3mock) and SFTP backends instead of a local
|
|
||||||
# path, so files_external's AmazonS3/SFTP backend and auth classes are actually exercised.
|
|
||||||
# Backend correctness does not depend on the Nextcloud version or database, so this runs once.
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Round trip against S3 and SFTP
|
|
||||||
run: tests/integration/targets-matrix.sh
|
|
||||||
+4
-9
@@ -1,10 +1,5 @@
|
|||||||
/build/
|
|
||||||
cred-*
|
|
||||||
*credentials*
|
|
||||||
/css/
|
|
||||||
/js/
|
|
||||||
/node_modules/
|
|
||||||
.phpunit.result.cache
|
|
||||||
*.secret
|
|
||||||
/spikes/out/
|
|
||||||
/vendor/
|
/vendor/
|
||||||
|
/node_modules/
|
||||||
|
/js/
|
||||||
|
/build/
|
||||||
|
/spikes/out/
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -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.
|
||||||
@@ -325,24 +325,6 @@ een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
|
|||||||
export, verwijderen, opnieuw aanmaken), óf terugzetten als `<gebruiker>-bak`, zie 3.8.
|
export, verwijderen, opnieuw aanmaken), óf terugzetten als `<gebruiker>-bak`, zie 3.8.
|
||||||
8. **Fase 0 afgerond (2026-10-01),** resultaten in `spikes/RESULTS.md`.
|
8. **Fase 0 afgerond (2026-10-01),** resultaten in `spikes/RESULTS.md`.
|
||||||
|
|
||||||
## 9. Releases en versies (besluit 2026-10-02)
|
|
||||||
|
|
||||||
| Versie | Betekenis |
|
|
||||||
|---|---|
|
|
||||||
| 0.1.0-alpha.N | publiek; meetesten gewaardeerd op een kopie van productie (dev, test, staging) |
|
|
||||||
| 0.9.0-beta.N | mag meedraaien op productie, naast de reguliere backup; volledige restore aanwezig en getest |
|
|
||||||
| 1.0.0-rc.N | releasekandidaten, alleen bugfixes |
|
|
||||||
| 1.0.0 | stabiel: vervanging van de reguliere backup, noodherstel bewezen |
|
|
||||||
| 1.2.0 e.v. | functies van fase 2 (restore per gebruiker, FTP/SMB, verificatie, ...) |
|
|
||||||
|
|
||||||
Volgorde: fase 1 grondig testen, reviewen en debuggen; dan nadenken over 0.1.0-alpha.1. Een
|
|
||||||
minimale volledige restore (`occ backup:restore:full` op een verse installatie) komt vóór de beta.
|
|
||||||
|
|
||||||
**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.
|
||||||
|
|||||||
@@ -1,131 +1,8 @@
|
|||||||
# NG Backup
|
# NG Backup
|
||||||
|
|
||||||
Encrypted, incremental backups of Nextcloud (database, configuration and data) with restore
|
Encrypted, incremental backups of Nextcloud with restore from the web interface.
|
||||||
from the web interface. Works without shell access: no external programs, no extra PHP
|
Works without shell access: no external programs, no extra PHP extensions beyond sodium.
|
||||||
extensions beyond sodium. Runs on Nextcloud All-in-One, regular installations and hosted
|
|
||||||
Nextcloud.
|
|
||||||
|
|
||||||
**Status: in development (phase 1). Not ready for production use.** See `PLAN.md` for the design.
|
**Status: phase 0 (prototype). Not usable yet.** See `PLAN.md` for the design.
|
||||||
|
|
||||||
License: AGPL-3.0-or-later.
|
License: AGPL-3.0-or-later.
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
- Backups go to a **location**: S3 (and S3-compatible storage), SFTP, WebDAV, FTP, Swift or a
|
|
||||||
local path. NG Backup uses the connection code of Nextcloud's External storage app, but never
|
|
||||||
mounts the location: it is only connected while a backup or restore runs, and it never
|
|
||||||
appears in Files, for anyone.
|
|
||||||
- Data is cut into blocks, deduplicated, compressed and encrypted (XChaCha20-Poly1305) before it
|
|
||||||
leaves the server, and streamed straight to the location: no local copy, no double disk space.
|
|
||||||
Unchanged files are not read again; only new data is uploaded.
|
|
||||||
- The database is dumped through Nextcloud's own connection, inside one consistent transaction.
|
|
||||||
- Restores go through Nextcloud, so overwritten files keep their old content as a version and
|
|
||||||
removed files go to the trash.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```
|
|
||||||
occ backup:key:init --label="Safe" # first passphrase, deletion delay (default 7 days)
|
|
||||||
occ backup:key:slot:add --label="CTO" # optional second and third passphrase
|
|
||||||
occ backup:key:slot:add --label="Head of IT"
|
|
||||||
occ backup:key:kit --output=/root/ng_backup-kit.json
|
|
||||||
occ backup:key:confirm
|
|
||||||
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=... -o key=... -o secret=...
|
|
||||||
occ backup:run
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use **Administration settings → NG Backup**.
|
|
||||||
|
|
||||||
## The backup key, passphrases and the recovery kit
|
|
||||||
|
|
||||||
All backups of an installation are encrypted with one master key.
|
|
||||||
|
|
||||||
- The **server** keeps the master key, encrypted with the instance secret, so scheduled backups
|
|
||||||
and restores work without anyone typing a passphrase. Any administrator can restore from the
|
|
||||||
web interface. Consequence: whoever fully controls the server can read the backups; whoever
|
|
||||||
only gets hold of the backup location cannot.
|
|
||||||
- For a **restore on a new server** (disaster recovery) you need the **recovery kit** and **one
|
|
||||||
passphrase**. The kit contains the master key, encrypted separately with each passphrase.
|
|
||||||
- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not
|
|
||||||
lock the company out. A suggested setup:
|
|
||||||
|
|
||||||
| Slot | Kept by |
|
|
||||||
|---|---|
|
|
||||||
| 1 | in the safe (sealed envelope) |
|
|
||||||
| 2 | the CTO |
|
|
||||||
| 3 | the head of IT |
|
|
||||||
|
|
||||||
Every holder keeps a copy of the same recovery kit and only knows their own passphrase. Using
|
|
||||||
a single slot is possible, but then that one passphrase is a single point of failure.
|
|
||||||
- When a holder leaves, give their slot a new passphrase (`occ backup:key:slot:replace`), download
|
|
||||||
the new kit and confirm it. Note: an old kit together with the old passphrase still opens the
|
|
||||||
same master key; to revoke it completely, start new locations with a new key.
|
|
||||||
- Downloading the kit, and every change to the slots, requires the administrator's password, is
|
|
||||||
written to the audit log and is announced to all administrators.
|
|
||||||
|
|
||||||
## Protection against ransomware and a compromised server
|
|
||||||
|
|
||||||
A backup is only useful if an attacker cannot destroy it. An attacker with administrator access
|
|
||||||
to Nextcloud could try to remove old restore points so that only infected data remains.
|
|
||||||
NG Backup makes this hard and visible, but **software alone can never guarantee it**: the
|
|
||||||
location must refuse deletions itself.
|
|
||||||
|
|
||||||
What NG Backup does:
|
|
||||||
|
|
||||||
- **Deletion delay.** Removed restore points first go to a trash on the location and are only
|
|
||||||
deleted after the delay chosen at installation (default 7 days, cannot be changed afterwards).
|
|
||||||
Within that time any administrator can bring them back (`occ backup:trash:restore`, or in the
|
|
||||||
web interface).
|
|
||||||
- **Notifications to all administrators** when restore points are removed, the retention is
|
|
||||||
changed, append-only is turned off, a location is removed or the recovery kit is downloaded,
|
|
||||||
and an entry in the audit log (admin_audit).
|
|
||||||
- **Append-only locations** (`occ backup:target:append-only NAME on`): NG Backup never deletes
|
|
||||||
anything there; old data is cleaned up by the location's own lifecycle rules.
|
|
||||||
- Removing a location in NG Backup only removes the connection. The backups on the location are
|
|
||||||
not touched and nothing keeps cleaning up in the background.
|
|
||||||
|
|
||||||
What you should do on the location (strongly recommended):
|
|
||||||
|
|
||||||
- **S3: enable Object Lock** on the bucket, in **compliance mode**, with a default retention of
|
|
||||||
for example **90 days**, plus versioning. Then nobody, not even with the server's credentials,
|
|
||||||
can delete or overwrite backups younger than 90 days. Combine with an append-only location in
|
|
||||||
NG Backup and a lifecycle rule that removes old versions after the lock expires.
|
|
||||||
With Object Lock, a deletion by NG Backup (or by an attacker through it) only adds a *delete
|
|
||||||
marker*: the locked data stays and can be brought back from the bucket's object versions until
|
|
||||||
the lock expires. Tested with an S3-compatible server; still to be verified on AWS itself, which
|
|
||||||
requires a checksum on uploads to locked buckets.
|
|
||||||
- Use **credentials without delete rights** for the backup location where the storage supports it.
|
|
||||||
- **SFTP / storage boxes:** enable the provider's own snapshots (e.g. daily, kept 30-90 days) and
|
|
||||||
give the backup user access to its own folder only, without a shell.
|
|
||||||
- Keep at least one location **off-site**, on a different network than the Nextcloud server.
|
|
||||||
|
|
||||||
## Restore
|
|
||||||
|
|
||||||
- **Files and folders** of a user: in the web interface or `occ backup:restore`, into a new folder
|
|
||||||
("Restored <date>"), merged into the original place, or replacing it.
|
|
||||||
- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`.
|
|
||||||
- **Whole server** (disaster recovery onto a fresh installation): 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
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `backup:key:init`, `backup:key:kit`, `backup:key:confirm` | create the key, recovery kit, confirmation |
|
|
||||||
| `backup:key:slots`, `backup:key:slot:add/replace/remove` | passphrase slots |
|
|
||||||
| `backup:target:backends/add/list/remove/append-only` | backup locations |
|
|
||||||
| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points |
|
|
||||||
| `backup:browse`, `backup:restore` | browse and restore |
|
|
||||||
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
|
|
||||||
|
|||||||
+2
-38
@@ -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.1</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>
|
||||||
@@ -20,36 +16,4 @@ relying on this for anything.]]></description>
|
|||||||
<lib>sodium</lib>
|
<lib>sodium</lib>
|
||||||
<nextcloud min-version="32" max-version="35"/>
|
<nextcloud min-version="32" max-version="35"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<background-jobs>
|
|
||||||
<job>OCA\NgBackup\BackgroundJob\BackupJob</job>
|
|
||||||
</background-jobs>
|
|
||||||
<commands>
|
|
||||||
<command>OCA\NgBackup\Command\KeyInit</command>
|
|
||||||
<command>OCA\NgBackup\Command\KeyKit</command>
|
|
||||||
<command>OCA\NgBackup\Command\KeyConfirm</command>
|
|
||||||
<command>OCA\NgBackup\Command\TargetBackends</command>
|
|
||||||
<command>OCA\NgBackup\Command\TargetAdd</command>
|
|
||||||
<command>OCA\NgBackup\Command\TargetList</command>
|
|
||||||
<command>OCA\NgBackup\Command\TargetRemove</command>
|
|
||||||
<command>OCA\NgBackup\Command\RunBackup</command>
|
|
||||||
<command>OCA\NgBackup\Command\Status</command>
|
|
||||||
<command>OCA\NgBackup\Command\ListSnapshots</command>
|
|
||||||
<command>OCA\NgBackup\Command\Browse</command>
|
|
||||||
<command>OCA\NgBackup\Command\Restore</command>
|
|
||||||
<command>OCA\NgBackup\Command\Prune</command>
|
|
||||||
<command>OCA\NgBackup\Command\Retention</command>
|
|
||||||
<command>OCA\NgBackup\Command\TargetAppendOnly</command>
|
|
||||||
<command>OCA\NgBackup\Command\TargetTrustCurrent</command>
|
|
||||||
<command>OCA\NgBackup\Command\TargetLimit</command>
|
|
||||||
<command>OCA\NgBackup\Command\Trash</command>
|
|
||||||
<command>OCA\NgBackup\Command\TrashRestore</command>
|
|
||||||
<command>OCA\NgBackup\Command\KeySlots</command>
|
|
||||||
<command>OCA\NgBackup\Command\KeySlotAdd</command>
|
|
||||||
<command>OCA\NgBackup\Command\KeySlotReplace</command>
|
|
||||||
<command>OCA\NgBackup\Command\KeySlotRemove</command>
|
|
||||||
</commands>
|
|
||||||
<settings>
|
|
||||||
<admin>OCA\NgBackup\Settings\Admin</admin>
|
|
||||||
<admin-section>OCA\NgBackup\Settings\AdminSection</admin-section>
|
|
||||||
</settings>
|
|
||||||
</info>
|
</info>
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
return [
|
|
||||||
'routes' => [
|
|
||||||
['name' => 'AdminApi#status', 'url' => '/api/status', 'verb' => 'GET'],
|
|
||||||
['name' => 'AdminApi#initKey', 'url' => '/api/key', 'verb' => 'POST'],
|
|
||||||
['name' => 'AdminApi#downloadKit', 'url' => '/api/key/kit', 'verb' => 'GET'],
|
|
||||||
['name' => 'AdminApi#confirmKit', 'url' => '/api/key/confirm', 'verb' => 'POST'],
|
|
||||||
['name' => 'AdminApi#addSlot', 'url' => '/api/key/slots', 'verb' => 'POST'],
|
|
||||||
['name' => 'AdminApi#replaceSlot', 'url' => '/api/key/slots/{slot}', 'verb' => 'PUT'],
|
|
||||||
['name' => 'AdminApi#removeSlot', 'url' => '/api/key/slots/{slot}', 'verb' => 'DELETE'],
|
|
||||||
['name' => 'AdminApi#trash', 'url' => '/api/targets/{targetId}/trash', 'verb' => 'GET'],
|
|
||||||
['name' => 'AdminApi#untrash', 'url' => '/api/targets/{targetId}/trash/{snapshotId}', 'verb' => 'POST'],
|
|
||||||
['name' => 'AdminApi#backends', 'url' => '/api/backends', 'verb' => 'GET'],
|
|
||||||
['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'],
|
|
||||||
['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'],
|
|
||||||
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
|
|
||||||
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
|
|
||||||
['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'],
|
|
||||||
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
|
|
||||||
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
|
|
||||||
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
|
|
||||||
['name' => 'AdminApi#startRestore', 'url' => '/api/restores', 'verb' => 'POST'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dillardblom/ng_backup",
|
|
||||||
"description": "NG Backup: encrypted, incremental Nextcloud backups",
|
|
||||||
"license": "AGPL-3.0-or-later",
|
|
||||||
"type": "project",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"OCA\\NgBackup\\": "lib/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"OCA\\NgBackup\\Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.1"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^10.5",
|
|
||||||
"nextcloud/ocp": "dev-stable32",
|
|
||||||
"vimeo/psalm": "^6.0"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"optimize-autoloader": true,
|
|
||||||
"platform": {
|
|
||||||
"php": "8.2.27"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test:unit": "phpunit -c tests/phpunit.xml --testsuite unit",
|
|
||||||
"psalm": "psalm --no-cache --threads=1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-5045
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#000" d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3Zm0 5a3 3 0 0 1 3 3v1h1v6H8v-6h1v-1a3 3 0 0 1 3-3Zm0 1.6c-.8 0-1.4.6-1.4 1.4v1h2.8v-1c0-.8-.6-1.4-1.4-1.4Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 272 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#fff" d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3Zm0 5a3 3 0 0 1 3 3v1h1v6H8v-6h1v-1a3 3 0 0 1 3-3Zm0 1.6c-.8 0-1.4.6-1.4 1.4v1h2.8v-1c0-.8-.6-1.4-1.4-1.4Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 272 B |
@@ -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() {
|
||||||
@@ -20,8 +20,6 @@ final class Application extends App implements IBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function register(IRegistrationContext $context): void {
|
public function register(IRegistrationContext $context): void {
|
||||||
$context->registerNotifierService(\OCA\NgBackup\Notification\Notifier::class);
|
|
||||||
$context->registerSetupCheck(\OCA\NgBackup\SetupCheck\BackupStatusCheck::class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(IBootContext $context): void {
|
public function boot(IBootContext $context): void {
|
||||||
|
|||||||
@@ -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 {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,32 +26,16 @@ final class ExternalStorageFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array<string, mixed>}>
|
* @return list<array{id:string, name:string, auth:list<string>, parameters:array<string, mixed>}>
|
||||||
* available backends, to build the settings form from
|
* available backends, to build the settings form from
|
||||||
*/
|
*/
|
||||||
public function describeBackends(): array {
|
public function describeBackends(): array {
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($this->backends->getAvailableBackends() as $backend) {
|
foreach ($this->backends->getAvailableBackends() as $backend) {
|
||||||
$auth = [];
|
|
||||||
foreach ($this->backends->getAuthMechanismsByScheme(array_keys($backend->getAuthSchemes())) as $mech) {
|
|
||||||
// Mechanisms that need the logged-in user's session cannot work for unattended backups.
|
|
||||||
if (in_array($mech->getScheme(), ['builtin', 'null'], true) && $mech->getIdentifier() !== 'null::null') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (str_starts_with($mech->getIdentifier(), 'password::session') || str_starts_with($mech->getIdentifier(), 'password::logincredentials')
|
|
||||||
|| str_starts_with($mech->getIdentifier(), 'password::userprovided') || str_starts_with($mech->getIdentifier(), 'password::global')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$auth[] = [
|
|
||||||
'id' => $mech->getIdentifier(),
|
|
||||||
'name' => $mech->getText(),
|
|
||||||
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $mech->getParameters()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'id' => $backend->getIdentifier(),
|
'id' => $backend->getIdentifier(),
|
||||||
'name' => $backend->getText(),
|
'name' => $backend->getText(),
|
||||||
'auth' => $auth,
|
'auth' => array_keys($backend->getAuthSchemes()),
|
||||||
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $backend->getParameters()),
|
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $backend->getParameters()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,111 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\BackgroundJob;
|
|
||||||
|
|
||||||
use OCA\NgBackup\AppInfo\Application;
|
|
||||||
use OCA\NgBackup\Db\RunMapper;
|
|
||||||
use OCA\NgBackup\Service\BackupService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use OCP\AppFramework\Utility\ITimeFactory;
|
|
||||||
use OCP\BackgroundJob\IJob;
|
|
||||||
use OCP\BackgroundJob\TimedJob;
|
|
||||||
use OCP\IAppConfig;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts scheduled backups and moves running ones forward, a time-boxed step per cron run.
|
|
||||||
* With system cron a step may take minutes; with webcron/AJAX cron it is kept short so it fits
|
|
||||||
* in a normal web request (hosted Nextcloud).
|
|
||||||
*/
|
|
||||||
final class BackupJob extends TimedJob {
|
|
||||||
public function __construct(
|
|
||||||
ITimeFactory $time,
|
|
||||||
private BackupService $backups,
|
|
||||||
private \OCA\NgBackup\Service\PruneService $prune,
|
|
||||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
|
||||||
private TargetService $targets,
|
|
||||||
private RunMapper $runs,
|
|
||||||
private IAppConfig $appConfig,
|
|
||||||
private LoggerInterface $logger,
|
|
||||||
) {
|
|
||||||
parent::__construct($time);
|
|
||||||
$this->setInterval(300);
|
|
||||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function run($argument): void {
|
|
||||||
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
|
||||||
$deadline = microtime(true) + $budget;
|
|
||||||
$this->startScheduled();
|
|
||||||
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
|
||||||
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
|
||||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->failStaleRestores();
|
|
||||||
$this->weeklyPrune();
|
|
||||||
foreach ($this->targets->list() as $t) {
|
|
||||||
$this->alerts->checkStale($t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A restore run that has not moved for 6 hours is considered dead (it would block cleanup forever). */
|
|
||||||
private function failStaleRestores(): void {
|
|
||||||
foreach ($this->runs->findRunning(null, 'restore') as $run) {
|
|
||||||
if (time() - $run->getUpdatedAt() > 6 * 3600) {
|
|
||||||
$run->setStatus(\OCA\NgBackup\Db\Run::FAILED);
|
|
||||||
$run->setError('No progress for 6 hours; marked as failed');
|
|
||||||
$run->setFinishedAt(time());
|
|
||||||
$this->runs->update($run);
|
|
||||||
$this->alerts->runFailed($run, '#' . $run->getTargetId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
|
|
||||||
private function weeklyPrune(): void {
|
|
||||||
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
|
|
||||||
if (date('N') !== '7' || $lastWeek === date('o-W') || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
foreach ($this->targets->list() as $t) {
|
|
||||||
if ($t->getAppendOnly()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$this->prune->apply($t, false, true);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', date('o-W'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
|
|
||||||
private function startScheduled(): void {
|
|
||||||
$at = $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', '');
|
|
||||||
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$todayAt = strtotime(date('Y-m-d') . ' ' . $at);
|
|
||||||
if (time() < $todayAt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
foreach ($this->targets->list() as $t) {
|
|
||||||
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
|
||||||
if ($this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$this->backups->start($t, 'scheduled');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->warning('NG Backup: scheduled backup to ' . $t->getName() . ' not started: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\BackgroundJob;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Db\Run;
|
|
||||||
use OCA\NgBackup\Db\RunMapper;
|
|
||||||
use OCA\NgBackup\Service\RestoreService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use OCP\AppFramework\Utility\ITimeFactory;
|
|
||||||
use OCP\BackgroundJob\QueuedJob;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
/** Runs a restore requested from the web interface, outside the web request. */
|
|
||||||
final class RestoreJob extends QueuedJob {
|
|
||||||
public function __construct(
|
|
||||||
ITimeFactory $time,
|
|
||||||
private RunMapper $runs,
|
|
||||||
private TargetService $targets,
|
|
||||||
private RestoreService $restore,
|
|
||||||
private LoggerInterface $logger,
|
|
||||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
|
||||||
) {
|
|
||||||
parent::__construct($time);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function run($argument): void {
|
|
||||||
$runId = (int)($argument['run'] ?? 0);
|
|
||||||
try {
|
|
||||||
$run = $this->runs->find($runId);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->error('NG Backup restore job: run ' . $runId . ' not found', ['exception' => $e]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ($run->getStatus() !== Run::RUNNING) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$req = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
$run->setPhase('restoring');
|
|
||||||
$run->setUpdatedAt(time());
|
|
||||||
$run = $this->runs->update($run);
|
|
||||||
@set_time_limit(0);
|
|
||||||
$lastBeat = time();
|
|
||||||
$result = $this->restore->restoreUserFiles($this->targets->get((string)$run->getTargetId()), $req['snapshot'], $req['path'], $req['mode'],
|
|
||||||
function (int $done, int $total) use (&$run, &$lastBeat): void {
|
|
||||||
// Heartbeat, so a long restore is not mistaken for a dead one.
|
|
||||||
if (time() - $lastBeat >= 60) {
|
|
||||||
$lastBeat = time();
|
|
||||||
$run->setPhase("restoring $done/$total");
|
|
||||||
$run->setUpdatedAt(time());
|
|
||||||
$run = $this->runs->update($run);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$run->setStatus(Run::DONE);
|
|
||||||
$run->setPhase('done');
|
|
||||||
$run->setStats(json_encode($result, JSON_THROW_ON_ERROR));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
|
||||||
$run->setStatus(Run::FAILED);
|
|
||||||
$run->setError($e->getMessage());
|
|
||||||
try {
|
|
||||||
$this->alerts->runFailed($run, '#' . $run->getTargetId());
|
|
||||||
} catch (\Throwable) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$run->setFinishedAt(time());
|
|
||||||
$run->setUpdatedAt(time());
|
|
||||||
$this->runs->update($run);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\RestoreService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Helper\Table;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class Browse extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private RestoreService $restore,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:browse')
|
|
||||||
->setDescription('Show folders and files in a snapshot')
|
|
||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
|
||||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
|
||||||
->addArgument('path', InputArgument::OPTIONAL, 'Path in the snapshot, e.g. data/alice/files/Documents', '')
|
|
||||||
->setHelp("Top level: config/ and data/. User files are under data/<user>/files/.\n\nExample:\n occ backup:browse offsite 537bf7e9... data/alice/files");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
try {
|
|
||||||
$items = $this->restore->browse($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), (string)$input->getArgument('path'));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if ($items === []) {
|
|
||||||
$output->writeln('Nothing here.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
$rows = array_map(fn ($i) => [$i['type'] === 'dir' ? $i['name'] . '/' : $i['name'], $this->size($i['size']),
|
|
||||||
$i['type'] === 'dir' ? $i['files'] . ' files' : date('Y-m-d H:i', (int)$i['mtime'])], $items);
|
|
||||||
(new Table($output))->setHeaders(['name', 'size', 'files / modified'])->setRows($rows)->render();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function size(int $b): string {
|
|
||||||
foreach (['B', 'KiB', 'MiB', 'GiB', 'TiB'] as $u) {
|
|
||||||
if ($b < 1024 || $u === 'TiB') {
|
|
||||||
return $u === 'B' ? "$b B" : sprintf('%.1f %s', $b, $u);
|
|
||||||
}
|
|
||||||
$b /= 1024;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Question\Question;
|
|
||||||
|
|
||||||
final class KeyConfirm extends Command {
|
|
||||||
public const STATEMENT = 'I have downloaded the recovery kit and stored it safely, outside this server. '
|
|
||||||
. 'I understand that keeping these keys safe is my own responsibility and that backups cannot be restored without them.';
|
|
||||||
public const CONFIRMATION = 'Yes, I confirm';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private KeyService $keys,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:key:confirm')
|
|
||||||
->setDescription('Confirm that the recovery kit is stored safely (required before the first backup)')
|
|
||||||
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Admin user id recorded with the confirmation', 'occ')
|
|
||||||
->addOption('code', null, InputOption::VALUE_REQUIRED, 'Confirmation code from the kit file (for scripts; asked otherwise)')
|
|
||||||
->addOption('confirm', null, InputOption::VALUE_REQUIRED, 'The confirmation phrase "' . self::CONFIRMATION . '" (for scripts; asked otherwise)');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
if (!$this->keys->isInitialized()) {
|
|
||||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$output->writeln('Key fingerprint: <info>' . $this->keys->fingerprint() . '</info>');
|
|
||||||
$output->writeln('');
|
|
||||||
$output->writeln(wordwrap(self::STATEMENT, 78));
|
|
||||||
$output->writeln('');
|
|
||||||
$code = $input->getOption('code') ?? (string)$this->getHelper('question')->ask($input, $output, new Question('Confirmation code from the recovery kit file ("confirmation_code"): '));
|
|
||||||
if (!$this->keys->checkKitCode($code)) {
|
|
||||||
$output->writeln('<error>That code does not match the current recovery kit (download it with occ backup:key:kit first).</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$answer = $input->getOption('confirm') ?? (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
|
|
||||||
if (trim($answer) !== self::CONFIRMATION) {
|
|
||||||
$output->writeln('<error>Not confirmed.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$this->keys->confirmRecoveryKit((string)$input->getOption('user'));
|
|
||||||
$output->writeln('Confirmed. Backups can now run.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Question\Question;
|
|
||||||
|
|
||||||
final class KeyInit extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private KeyService $keys,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:key:init')
|
|
||||||
->setDescription('Create the backup key, protected by a passphrase you keep safe')
|
|
||||||
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking')
|
|
||||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label of the first passphrase slot (who/where), e.g. "Safe"', 'Slot 1')
|
|
||||||
->addOption('delete-delay-days', null, InputOption::VALUE_REQUIRED, 'Days a removed snapshot stays in the trash before its data is deleted (1-365, fixed after this)', (string)KeyService::DEFAULT_DELETE_DELAY)
|
|
||||||
->setHelp(<<<'HELP'
|
|
||||||
Creates the master key that encrypts all backups of this installation.
|
|
||||||
|
|
||||||
The server keeps the key (encrypted with this instance's secret) so scheduled backups can run.
|
|
||||||
A copy protected by your passphrase goes into every backup location and into the recovery kit
|
|
||||||
(occ backup:key:kit). To restore on a new server you need the recovery kit and the passphrase.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
occ backup:key:init
|
|
||||||
occ backup:key:kit --output=/root/ng_backup-recovery-kit.json
|
|
||||||
HELP);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
if ($this->keys->isInitialized()) {
|
|
||||||
$output->writeln('<error>A backup key already exists (fingerprint ' . $this->keys->fingerprint() . ').</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$file = $input->getOption('passphrase-file');
|
|
||||||
if ($file !== null) {
|
|
||||||
$pass = rtrim((string)@file_get_contents($file), "\r\n");
|
|
||||||
} else {
|
|
||||||
$helper = $this->getHelper('question');
|
|
||||||
$q = (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false);
|
|
||||||
$pass = (string)$helper->ask($input, $output, $q);
|
|
||||||
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
|
|
||||||
if ($pass !== $again) {
|
|
||||||
$output->writeln('<error>The passphrases do not match.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$this->keys->initialize($pass, (int)$input->getOption('delete-delay-days'), (string)$input->getOption('label'));
|
|
||||||
} catch (\InvalidArgumentException $e) {
|
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$output->writeln('Backup key created, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
|
||||||
$output->writeln('Removed snapshots stay in the trash on the location for <info>' . $this->keys->deleteDelayDays() . ' days</info> (cannot be changed later).');
|
|
||||||
$output->writeln('Tip: add up to two more passphrases for other people with occ backup:key:slot:add (e.g. CTO, head of IT).');
|
|
||||||
$output->writeln('Next: save the recovery kit with <info>occ backup:key:kit --output=FILE</info> and confirm with <info>occ backup:key:confirm</info>.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class KeyKit extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private KeyService $keys,
|
|
||||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:key:kit')
|
|
||||||
->setDescription('Write the recovery kit (passphrase-protected key) to a file or stdout')
|
|
||||||
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'File to write (default: stdout)');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
if (!$this->keys->isInitialized()) {
|
|
||||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$this->alerts->securityEvent('kit_downloaded');
|
|
||||||
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
|
|
||||||
$file = $input->getOption('output');
|
|
||||||
if ($file === null) {
|
|
||||||
$output->write($json, false, OutputInterface::OUTPUT_RAW);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (file_put_contents($file, $json) === false) {
|
|
||||||
$output->writeln("<error>Cannot write $file</error>");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
@chmod($file, 0600);
|
|
||||||
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\KeySlotService;
|
|
||||||
|
|
||||||
final class KeySlotAdd extends KeySlots {
|
|
||||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
|
||||||
parent::__construct($keys, $slots, 'add');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\KeySlotService;
|
|
||||||
|
|
||||||
final class KeySlotRemove extends KeySlots {
|
|
||||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
|
||||||
parent::__construct($keys, $slots, 'remove');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\KeySlotService;
|
|
||||||
|
|
||||||
final class KeySlotReplace extends KeySlots {
|
|
||||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
|
||||||
parent::__construct($keys, $slots, 'replace');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\KeySlotService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Helper\Table;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Question\Question;
|
|
||||||
|
|
||||||
/** backup:key:slots, backup:key:slot:add, backup:key:slot:replace, backup:key:slot:remove */
|
|
||||||
class KeySlots extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private KeyService $keys,
|
|
||||||
private KeySlotService $slots,
|
|
||||||
private string $action = 'list',
|
|
||||||
) {
|
|
||||||
// list is the default; subclasses pick another action
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$help = <<<'HELP'
|
|
||||||
Up to three passphrases ("slots") can open the backup key, so that one person leaving or dying
|
|
||||||
does not lock you out. Example: one passphrase in the safe, one with the CTO, one with the head
|
|
||||||
of IT. Each holder keeps a copy of the same recovery kit and only knows their own passphrase.
|
|
||||||
After every change: occ backup:key:kit and occ backup:key:confirm.
|
|
||||||
HELP;
|
|
||||||
match ($this->action) {
|
|
||||||
'add' => $this->setName('backup:key:slot:add')->setDescription('Add a passphrase slot')
|
|
||||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Who or where, e.g. "CTO" or "Safe"', 'Slot'),
|
|
||||||
'replace' => $this->setName('backup:key:slot:replace')->setDescription('Set a new passphrase for a slot (e.g. its holder left)')
|
|
||||||
->addArgument('slot', InputArgument::REQUIRED, 'Slot number (see backup:key:slots)')
|
|
||||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'New label'),
|
|
||||||
'remove' => $this->setName('backup:key:slot:remove')->setDescription('Remove a passphrase slot (not the last one)')
|
|
||||||
->addArgument('slot', InputArgument::REQUIRED, 'Slot number (see backup:key:slots)'),
|
|
||||||
default => $this->setName('backup:key:slots')->setDescription('List the passphrase slots of the backup key'),
|
|
||||||
};
|
|
||||||
if ($this->action === 'add' || $this->action === 'replace') {
|
|
||||||
$this->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file');
|
|
||||||
}
|
|
||||||
$this->setHelp($help);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
if (!$this->keys->isInitialized()) {
|
|
||||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
switch ($this->action) {
|
|
||||||
case 'add':
|
|
||||||
$n = $this->slots->add($this->passphrase($input, $output), (string)$input->getOption('label'));
|
|
||||||
$output->writeln("Slot $n added.");
|
|
||||||
break;
|
|
||||||
case 'replace':
|
|
||||||
$this->slots->replace((int)$input->getArgument('slot'), $this->passphrase($input, $output), $input->getOption('label'));
|
|
||||||
$output->writeln('Slot ' . (int)$input->getArgument('slot') . ' has a new passphrase.');
|
|
||||||
break;
|
|
||||||
case 'remove':
|
|
||||||
$this->slots->remove((int)$input->getArgument('slot'));
|
|
||||||
$output->writeln('Slot ' . (int)$input->getArgument('slot') . ' removed.');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
(new Table($output))->setHeaders(['slot', 'label', 'set on'])
|
|
||||||
->setRows(array_map(fn ($s) => [$s['slot'], $s['label'], $s['created'] ? date('Y-m-d H:i', $s['created']) : ''], $this->keys->slots()))->render();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$output->writeln('Now download and confirm the new recovery kit: occ backup:key:kit -o FILE && occ backup:key:confirm');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function passphrase(InputInterface $input, OutputInterface $output): string {
|
|
||||||
if ($file = $input->getOption('passphrase-file')) {
|
|
||||||
return rtrim((string)@file_get_contents($file), "\r\n");
|
|
||||||
}
|
|
||||||
$h = $this->getHelper('question');
|
|
||||||
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false));
|
|
||||||
if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) {
|
|
||||||
throw new \InvalidArgumentException('The passphrases do not match');
|
|
||||||
}
|
|
||||||
return $p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Db\SnapshotMapper;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Helper\Table;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class ListSnapshots extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private SnapshotMapper $snapshots,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:list')
|
|
||||||
->setDescription('List the snapshots (restore points) on a location')
|
|
||||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$name = $input->getArgument('target');
|
|
||||||
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
|
||||||
$rows = [];
|
|
||||||
foreach ($targets as $t) {
|
|
||||||
foreach ($this->snapshots->findForTarget($t->getId()) as $s) {
|
|
||||||
$rows[] = [$t->getName(), $s->getSnapshotId(), date('Y-m-d H:i', $s->getCreatedAt()), $s->getKind(),
|
|
||||||
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? ''];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($rows === []) {
|
|
||||||
$output->writeln('No snapshots yet (occ backup:run).');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
(new Table($output))->setHeaders(['location', 'snapshot', 'date', 'kind', 'files', 'size', 'label'])->setRows($rows)->render();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\PruneService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class Prune extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private PruneService $prune,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:prune')
|
|
||||||
->setDescription('Apply the retention policy: remove old snapshots and the data only they used')
|
|
||||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all that are not append-only)')
|
|
||||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show what would be removed');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$name = $input->getArgument('target');
|
|
||||||
$targets = $name === null ? array_filter($this->targets->list(), fn ($t) => !$t->getAppendOnly()) : [$this->targets->get($name)];
|
|
||||||
$dry = (bool)$input->getOption('dry-run');
|
|
||||||
$output->writeln('Policy: ' . json_encode($this->prune->policy()->toArray()) . ($dry ? ' (dry run)' : ''));
|
|
||||||
$rc = 0;
|
|
||||||
foreach ($targets as $t) {
|
|
||||||
try {
|
|
||||||
$r = $this->prune->apply($t, $dry);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$output->writeln('<error>' . $t->getName() . ': ' . $e->getMessage() . '</error>');
|
|
||||||
$rc = 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$p = $r['prune'];
|
|
||||||
$output->writeln(sprintf('<info>%s</info>: keep %d, %s %d snapshots; packs: %d, %s %d, repack %d, frees %.1f MiB',
|
|
||||||
$t->getName(), count($r['kept']), $dry ? 'would forget' : 'forgot', count($r['forgotten']), $p['packs'],
|
|
||||||
$dry ? 'would delete' : 'deleted', $p['deleted'], $p['repacked'], $p['freedBytes'] / 1048576));
|
|
||||||
}
|
|
||||||
return $rc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\RestoreService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class Restore extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private RestoreService $restore,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:restore')
|
|
||||||
->setDescription('Restore files or folders from a snapshot')
|
|
||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
|
||||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
|
||||||
->addArgument('path', InputArgument::REQUIRED, 'Path in the snapshot (see backup:browse)')
|
|
||||||
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'new-folder, merge or replace (user files)', RestoreService::MODE_NEW_FOLDER)
|
|
||||||
->addOption('to-directory', 'd', InputOption::VALUE_REQUIRED, 'Write to this local directory instead (any path, raw files)')
|
|
||||||
->setHelp(<<<'HELP'
|
|
||||||
User files (data/<user>/files/...) are restored through Nextcloud, so versions, trash and the
|
|
||||||
file cache stay correct. Modes:
|
|
||||||
new-folder (default) into "Restored <date>/<same path>" in the user's files; nothing existing is touched
|
|
||||||
merge into the original place; overwritten files keep their old content as a version
|
|
||||||
replace like merge, and files that are not in the snapshot are moved to the trash
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
occ backup:restore offsite 537bf7e9... data/alice/files/Documents
|
|
||||||
occ backup:restore offsite 537bf7e9... data/alice/files/Documents/plan.odt --mode=merge
|
|
||||||
occ backup:restore offsite 537bf7e9... config --to-directory=/root/restored-config
|
|
||||||
HELP);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
try {
|
|
||||||
$target = $this->targets->get($input->getArgument('target'));
|
|
||||||
$snapshot = $input->getArgument('snapshot');
|
|
||||||
$path = trim((string)$input->getArgument('path'), '/'); // CLI convenience; the service itself only accepts canonical paths
|
|
||||||
if ($dir = $input->getOption('to-directory')) {
|
|
||||||
$n = $this->restore->restoreToDirectory($target, $snapshot, $path, $dir);
|
|
||||||
$output->writeln("Restored <info>$n</info> files to $dir.");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
$r = $this->restore->restoreUserFiles($target, $snapshot, $path, (string)$input->getOption('mode'),
|
|
||||||
function (int $done, int $total, string $name) use ($output) {
|
|
||||||
if ($output->isVerbose() || $done === $total || $done % 100 === 0) {
|
|
||||||
$output->writeln(" $done/$total $name");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$output->writeln(sprintf('Restored <info>%d</info> files to %s%s.', $r['restored'], $r['target'], $r['trashed'] ? ", moved {$r['trashed']} extra files to the trash" : ''));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\PruneService;
|
|
||||||
use OCA\NgBackup\Service\RetentionPolicy;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class Retention extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private PruneService $prune,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:retention')
|
|
||||||
->setDescription('Show or set how many snapshots are kept')
|
|
||||||
->addOption('last', null, InputOption::VALUE_REQUIRED, 'Always keep the newest N')
|
|
||||||
->addOption('daily', null, InputOption::VALUE_REQUIRED, 'Keep one per day for the last N days with a backup')
|
|
||||||
->addOption('weekly', null, InputOption::VALUE_REQUIRED, 'Keep one per week for N weeks')
|
|
||||||
->addOption('monthly', null, InputOption::VALUE_REQUIRED, 'Keep one per month for N months')
|
|
||||||
->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$p = $this->prune->policy()->toArray();
|
|
||||||
$changed = false;
|
|
||||||
foreach (['last', 'daily', 'weekly', 'monthly'] as $k) {
|
|
||||||
if ($input->getOption($k) !== null) {
|
|
||||||
$p[$k] = max(0, (int)$input->getOption($k));
|
|
||||||
$changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($changed) {
|
|
||||||
try {
|
|
||||||
$this->prune->setPolicy(RetentionPolicy::fromArray($p));
|
|
||||||
} catch (\InvalidArgumentException $e) {
|
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$output->writeln(sprintf('Keep the last %d, plus one per day for %d days, per week for %d weeks, per month for %d months.', $p['last'], $p['daily'], $p['weekly'], $p['monthly']));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Db\Run;
|
|
||||||
use OCA\NgBackup\Service\BackupService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class RunBackup extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private BackupService $backups,
|
|
||||||
private TargetService $targets,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:run')
|
|
||||||
->setDescription('Back up this Nextcloud (database, config, data) to a location now')
|
|
||||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all locations)')
|
|
||||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label for the snapshot', '')
|
|
||||||
->addOption('background', 'b', InputOption::VALUE_NONE, 'Only start the run; background jobs finish it')
|
|
||||||
->setHelp(<<<'HELP'
|
|
||||||
Starts a backup (or continues one that is already running for that location) and runs it to
|
|
||||||
the end in this process. Unchanged files are not read again; only new data is uploaded.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
occ backup:run back up to every location
|
|
||||||
occ backup:run offsite -l "before upgrade"
|
|
||||||
occ backup:run offsite --background
|
|
||||||
HELP);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$name = $input->getArgument('target');
|
|
||||||
try {
|
|
||||||
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
|
||||||
} catch (\Throwable) {
|
|
||||||
$output->writeln('<error>No such location.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if ($targets === []) {
|
|
||||||
$output->writeln('<error>No backup locations yet (occ backup:target:add).</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$failed = 0;
|
|
||||||
foreach ($targets as $t) {
|
|
||||||
try {
|
|
||||||
$run = $this->backups->start($t, (string)$input->getOption('label'));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$output->writeln('<error>' . $t->getName() . ': ' . $e->getMessage() . '</error>');
|
|
||||||
$failed++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$output->writeln(sprintf('<info>%s</info>: run %d, snapshot %s', $t->getName(), $run->getId(), $run->getSnapshotId()));
|
|
||||||
if ($input->getOption('background')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$start = microtime(true);
|
|
||||||
$run = $this->backups->runToCompletion($run, function (Run $r) use ($output, $start) {
|
|
||||||
$s = json_decode($r->getStats() ?? '{}', true);
|
|
||||||
$f = $s['files'] ?? [];
|
|
||||||
$output->writeln(sprintf(' %5.0fs %-6s files %d (read %d, unchanged %d), uploaded %.1f MiB',
|
|
||||||
microtime(true) - $start, $r->getPhase(), $f['files'] ?? 0, $f['read'] ?? 0, $f['reused'] ?? 0, ($f['uploaded'] ?? 0) / 1048576));
|
|
||||||
});
|
|
||||||
if ($run->getStatus() === Run::DONE) {
|
|
||||||
$output->writeln(sprintf(' done in %.0fs', microtime(true) - $start));
|
|
||||||
} else {
|
|
||||||
$output->writeln('<error> ' . $run->getStatus() . ': ' . $run->getError() . '</error>');
|
|
||||||
$failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $failed === 0 ? 0 : 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Db\RunMapper;
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Helper\Table;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class Status extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private KeyService $keys,
|
|
||||||
private TargetService $targets,
|
|
||||||
private RunMapper $runs,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
if (!$this->keys->isInitialized()) {
|
|
||||||
$output->writeln('Key: <error>not created</error> (occ backup:key:init)');
|
|
||||||
} else {
|
|
||||||
$c = $this->keys->recoveryKitConfirmation();
|
|
||||||
$output->writeln('Key: ' . $this->keys->fingerprint() . ', recovery kit '
|
|
||||||
. ($c ? 'confirmed by ' . $c['uid'] . ' on ' . date('Y-m-d H:i', $c['time']) : '<error>not confirmed</error> (occ backup:key:confirm)'));
|
|
||||||
}
|
|
||||||
$names = [];
|
|
||||||
foreach ($this->targets->list() as $t) {
|
|
||||||
$names[$t->getId()] = $t->getName();
|
|
||||||
}
|
|
||||||
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
|
|
||||||
$rows = [];
|
|
||||||
foreach ($this->runs->findRecent(10) as $r) {
|
|
||||||
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
|
|
||||||
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . ')' : ''),
|
|
||||||
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '',
|
|
||||||
$s['files'] ?? '', isset($s['uploaded']) ? sprintf('%.1f MiB', $s['uploaded'] / 1048576) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
|
|
||||||
}
|
|
||||||
if ($rows !== []) {
|
|
||||||
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'uploaded', 'error'])->setRows($rows)->render();
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class TargetAdd extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private KeyService $keys,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:target:add')
|
|
||||||
->setDescription('Add a backup location (S3, SFTP, WebDAV, ...) via files_external, without mounting it')
|
|
||||||
->addArgument('name', InputArgument::REQUIRED, 'Name of the location')
|
|
||||||
->addArgument('backend', InputArgument::REQUIRED, 'files_external backend id (see backup:target:backends)')
|
|
||||||
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)')
|
|
||||||
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)')
|
|
||||||
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup')
|
|
||||||
->setHelp(<<<'HELP'
|
|
||||||
The location is reached with files_external's own connection code, but it is never added as an
|
|
||||||
external storage: it does not appear in Files for anyone. Credentials are stored encrypted.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud
|
|
||||||
|
|
||||||
occ backup:target:add nas sftp -a password::password -o host=nas.example.lan -o root=/backups -o user=nextcloud -o password=... --path=ng_backup
|
|
||||||
HELP);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
if (!$this->keys->isInitialized()) {
|
|
||||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$options = [];
|
|
||||||
foreach ($input->getOption('option') as $pair) {
|
|
||||||
if (!str_contains($pair, '=')) {
|
|
||||||
$output->writeln("<error>Option '$pair' must be key=value</error>");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
[$k, $v] = explode('=', $pair, 2);
|
|
||||||
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
|
|
||||||
}
|
|
||||||
if ($input->getOption('auth') === null) {
|
|
||||||
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path'));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$output->writeln(sprintf('Location <info>%s</info> added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(),
|
|
||||||
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class TargetAppendOnly extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:target:append-only')
|
|
||||||
->setDescription('Make a location append-only (NG Backup never deletes there) or normal again')
|
|
||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
|
||||||
->addArgument('state', InputArgument::REQUIRED, 'on or off')
|
|
||||||
->setHelp("Append-only protects backups if this server is compromised: combine it with credentials\nwithout delete rights and a lifecycle rule or object lock on the location itself.");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$state = strtolower((string)$input->getArgument('state'));
|
|
||||||
if (!in_array($state, ['on', 'off'], true)) {
|
|
||||||
$output->writeln('<error>state must be on or off</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$t = $this->targets->setAppendOnly($this->targets->get($input->getArgument('target')), $state === 'on');
|
|
||||||
if ($state === 'off') {
|
|
||||||
$this->alerts->securityEvent('append_only_off', ['target' => $t->getName()]);
|
|
||||||
}
|
|
||||||
$output->writeln($t->getName() . ' is ' . ($t->getAppendOnly() ? 'append-only' : 'normal') . '.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OC\Core\Command\Base;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class TargetBackends extends Base {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
parent::configure();
|
|
||||||
$this->setName('backup:target:backends')
|
|
||||||
->setDescription('List the storage backends (from files_external) that can be used as backup location');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$backends = $this->targets->availableBackends();
|
|
||||||
if ($input->getOption('output') !== self::OUTPUT_FORMAT_PLAIN) {
|
|
||||||
$this->writeMixedInOutputFormat($input, $output, $backends);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
foreach ($backends as $b) {
|
|
||||||
$output->writeln(sprintf('<info>%s</info> (%s)', $b['id'], $b['name']));
|
|
||||||
foreach ($b['parameters'] as $key => $p) {
|
|
||||||
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
|
||||||
}
|
|
||||||
foreach ($b['auth'] as $a) {
|
|
||||||
$output->writeln(sprintf(' -a %s (%s)', $a['id'], $a['name']));
|
|
||||||
foreach ($a['parameters'] as $key => $p) {
|
|
||||||
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OC\Core\Command\Base;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class TargetList extends Base {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
parent::configure();
|
|
||||||
$this->setName('backup:target:list')
|
|
||||||
->setDescription('List backup locations')
|
|
||||||
->addOption('test', 't', InputOption::VALUE_NONE, 'Also connect to each location and check the repository');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$rows = [];
|
|
||||||
foreach ($this->targets->list() as $t) {
|
|
||||||
$row = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
|
||||||
'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt())];
|
|
||||||
if ($input->getOption('test')) {
|
|
||||||
try {
|
|
||||||
$repo = $this->targets->repository($t);
|
|
||||||
$row['status'] = 'ok, ' . count($repo->listSnapshots()) . ' snapshots';
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$row['status'] = 'error: ' . $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$rows[] = $row;
|
|
||||||
}
|
|
||||||
if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) {
|
|
||||||
if ($rows === []) {
|
|
||||||
$output->writeln('No backup locations yet (occ backup:target:add).');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
$table = new \Symfony\Component\Console\Helper\Table($output);
|
|
||||||
$table->setHeaders(array_keys($rows[0]))->setRows(array_map('array_values', $rows))->render();
|
|
||||||
} else {
|
|
||||||
$this->writeMixedInOutputFormat($input, $output, $rows);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class TargetRemove extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:target:remove')
|
|
||||||
->setDescription('Remove a backup location from NG Backup (the backups on it are kept)')
|
|
||||||
->addArgument('target', InputArgument::REQUIRED, 'Name or id');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
try {
|
|
||||||
$t = $this->targets->get($input->getArgument('target'));
|
|
||||||
} catch (\Throwable) {
|
|
||||||
$output->writeln('<error>No such location.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$this->targets->remove($t);
|
|
||||||
$this->alerts->securityEvent('target_removed', ['target' => $t->getName()]);
|
|
||||||
$output->writeln('Removed <info>' . $t->getName() . '</info>. The backups on the location itself were not touched; add it again to use them.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Db\AppConfigCatalogAnchor;
|
|
||||||
use OCA\NgBackup\Service\AlertService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use OCP\IAppConfig;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Question\Question;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept the state a location shows now, after NG Backup refused it as rolled back. Only for
|
|
||||||
* legitimate cases (e.g. the location was restored from the provider's own snapshot), only from
|
|
||||||
* occ, audited and announced to all administrators.
|
|
||||||
*/
|
|
||||||
final class TargetTrustCurrent extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private IAppConfig $appConfig,
|
|
||||||
private AlertService $alerts,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:target:trust-current')
|
|
||||||
->setDescription('Accept the current state of a location that was refused as rolled back')
|
|
||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
|
||||||
->setHelp("NG Backup refuses a location whose backup catalog is older than what this server saw\n"
|
|
||||||
. "before, because an attacker could have restored an old state. If you restored the location\n"
|
|
||||||
. "yourself (e.g. from the provider's snapshot), accept it with this command.");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$t = $this->targets->get($input->getArgument('target'));
|
|
||||||
$output->writeln('<comment>Only continue if you know why the location shows an older state.</comment>');
|
|
||||||
$answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type the location name to accept its current state: '));
|
|
||||||
if ($answer !== $t->getName()) {
|
|
||||||
$output->writeln('<error>Not confirmed.</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$this->appConfig->deleteKey('ng_backup', 'catalog_' . preg_replace('/[^a-f0-9]/', '', (string)$t->getRepositoryId()));
|
|
||||||
$repo = $this->targets->repository($t); // re-anchors on the newest valid catalog
|
|
||||||
$this->alerts->securityEvent('catalog_trusted', ['target' => $t->getName(), 'generation' => (string)$repo->catalogGeneration()]);
|
|
||||||
$output->writeln(sprintf('Accepted generation %d of %s (%d snapshots).', $repo->catalogGeneration(), $t->getName(), count($repo->listSnapshots())));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Helper\Table;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class Trash extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private KeyService $keys,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:trash')
|
|
||||||
->setDescription('List removed snapshots that can still be restored (backup:trash:restore)')
|
|
||||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
$name = $input->getArgument('target');
|
|
||||||
$delay = $this->keys->deleteDelayDays() * 86400;
|
|
||||||
$rows = [];
|
|
||||||
foreach ($name === null ? $this->targets->list() : [$this->targets->get($name)] as $t) {
|
|
||||||
foreach ($this->targets->repository($t)->trash() as $id => $info) {
|
|
||||||
$rows[] = [$t->getName(), $id, (string)($info['time'] ?? ''), $info['label'], date('Y-m-d H:i', $info['forgottenAt']), $info['by'],
|
|
||||||
date('Y-m-d H:i', $info['forgottenAt'] + $delay)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($rows === []) {
|
|
||||||
$output->writeln('The trash is empty.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
(new Table($output))->setHeaders(['location', 'snapshot', 'made', 'label', 'removed', 'by', 'deleted after'])->setRows($rows)->render();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Command;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Service\PruneService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
final class TrashRestore extends Command {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private PruneService $prune,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void {
|
|
||||||
$this->setName('backup:trash:restore')
|
|
||||||
->setDescription('Bring a removed snapshot back from the trash')
|
|
||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
|
||||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:trash)');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
||||||
try {
|
|
||||||
$this->prune->untrash($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
$output->writeln('Snapshot restored from the trash.');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Controller;
|
|
||||||
|
|
||||||
use OCA\NgBackup\AppInfo\Application;
|
|
||||||
use OCA\NgBackup\BackgroundJob\RestoreJob;
|
|
||||||
use OCA\NgBackup\Command\KeyConfirm;
|
|
||||||
use OCA\NgBackup\Db\Run;
|
|
||||||
use OCA\NgBackup\Db\RunMapper;
|
|
||||||
use OCA\NgBackup\Db\SnapshotMapper;
|
|
||||||
use OCA\NgBackup\Service\BackupService;
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\RestoreService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use OCP\AppFramework\Controller;
|
|
||||||
use OCP\AppFramework\Http;
|
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
||||||
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
|
|
||||||
use OCP\AppFramework\Http\DataDownloadResponse;
|
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
|
||||||
use OCP\BackgroundJob\IJobList;
|
|
||||||
use OCP\IAppConfig;
|
|
||||||
use OCP\IRequest;
|
|
||||||
use OCP\IUserSession;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON API for the admin settings page. Every method requires an admin (the AppFramework
|
|
||||||
* default for controllers without NoAdminRequired) and a valid CSRF token.
|
|
||||||
*/
|
|
||||||
final class AdminApiController extends Controller {
|
|
||||||
public function __construct(
|
|
||||||
IRequest $request,
|
|
||||||
private KeyService $keys,
|
|
||||||
private TargetService $targets,
|
|
||||||
private BackupService $backups,
|
|
||||||
private RestoreService $restore,
|
|
||||||
private RunMapper $runs,
|
|
||||||
private SnapshotMapper $snapshots,
|
|
||||||
private IAppConfig $appConfig,
|
|
||||||
private IJobList $jobs,
|
|
||||||
private IUserSession $session,
|
|
||||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
|
||||||
private \OCA\NgBackup\Service\KeySlotService $slots,
|
|
||||||
private \OCA\NgBackup\Service\PruneService $prune,
|
|
||||||
) {
|
|
||||||
parent::__construct(Application::APP_ID, $request);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function status(): JSONResponse {
|
|
||||||
$targets = [];
|
|
||||||
foreach ($this->targets->list() as $t) {
|
|
||||||
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
|
||||||
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(),
|
|
||||||
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)];
|
|
||||||
}
|
|
||||||
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
|
|
||||||
return new JSONResponse([
|
|
||||||
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
|
|
||||||
'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(),
|
|
||||||
'slots' => $this->keys->isInitialized() ? $this->keys->slots() : [], 'maxSlots' => \OCA\NgBackup\Service\KeyService::MAX_SLOTS,
|
|
||||||
'deleteDelayDays' => $this->keys->isInitialized() ? $this->keys->deleteDelayDays() : \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY],
|
|
||||||
'statement' => KeyConfirm::STATEMENT,
|
|
||||||
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
|
|
||||||
'targets' => $targets,
|
|
||||||
'runs' => $runs,
|
|
||||||
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse {
|
|
||||||
try {
|
|
||||||
$this->keys->initialize($passphrase, $deleteDelayDays, $label);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
return new JSONResponse(['fingerprint' => $this->keys->fingerprint()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plain download link (no CSRF token on a navigation), therefore guarded by a recent password
|
|
||||||
* confirmation instead, logged to the audit log and announced to all administrators. The kit
|
|
||||||
* only holds the passphrase-wrapped key: useless without the passphrase.
|
|
||||||
*/
|
|
||||||
#[NoCSRFRequired]
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function downloadKit(): DataDownloadResponse|JSONResponse {
|
|
||||||
if (!$this->keys->isInitialized()) {
|
|
||||||
return new JSONResponse(['error' => 'No backup key yet'], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
$this->alerts->securityEvent('kit_downloaded');
|
|
||||||
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
|
||||||
return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The admin must tick the statement, send the exact phrase and the code from the kit file. */
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function confirmKit(bool $accepted, string $phrase, string $code = ''): JSONResponse {
|
|
||||||
if (!$accepted || $phrase !== KeyConfirm::CONFIRMATION) {
|
|
||||||
return new JSONResponse(['error' => 'Not confirmed'], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
if (!$this->keys->checkKitCode($code)) {
|
|
||||||
return new JSONResponse(['error' => 'The confirmation code does not match the current recovery kit'], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
$this->keys->confirmRecoveryKit($this->session->getUser()?->getUID() ?? 'unknown');
|
|
||||||
return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function addSlot(string $passphrase, string $label): JSONResponse {
|
|
||||||
try {
|
|
||||||
return new JSONResponse(['slot' => $this->slots->add($passphrase, $label)]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function replaceSlot(int $slot, string $passphrase, ?string $label = null): JSONResponse {
|
|
||||||
try {
|
|
||||||
$this->slots->replace($slot, $passphrase, $label);
|
|
||||||
return new JSONResponse([]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function removeSlot(int $slot): JSONResponse {
|
|
||||||
try {
|
|
||||||
$this->slots->remove($slot);
|
|
||||||
return new JSONResponse([]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function trash(int $targetId): JSONResponse {
|
|
||||||
try {
|
|
||||||
$delay = $this->keys->deleteDelayDays() * 86400;
|
|
||||||
$out = [];
|
|
||||||
foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() as $id => $info) {
|
|
||||||
$out[] = ['id' => $id, 'label' => $info['label'], 'time' => $info['time'], 'removedAt' => $info['forgottenAt'],
|
|
||||||
'by' => $info['by'], 'deleteAfter' => $info['forgottenAt'] + $delay];
|
|
||||||
}
|
|
||||||
return new JSONResponse($out);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function untrash(int $targetId, string $snapshotId): JSONResponse {
|
|
||||||
try {
|
|
||||||
$this->prune->untrash($this->targets->get((string)$targetId), $snapshotId);
|
|
||||||
return new JSONResponse([]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function backends(): JSONResponse {
|
|
||||||
return new JSONResponse($this->targets->availableBackends());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse {
|
|
||||||
try {
|
|
||||||
$r = $this->targets->add($name, $backend, $auth, $options, $path);
|
|
||||||
if ($maxGb !== null && $maxGb > 0) {
|
|
||||||
$this->targets->setMaxBytes($r['target'], (int)round($maxGb * 1073741824));
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Size limit in GiB; null or 0 = unlimited. */
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse {
|
|
||||||
$t = $this->targets->setMaxBytes($this->targets->get((string)$id), ($maxGb !== null && $maxGb > 0) ? (int)round($maxGb * 1073741824) : null);
|
|
||||||
return new JSONResponse(['maxBytes' => $t->getMaxBytes()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function removeTarget(int $id): JSONResponse {
|
|
||||||
$target = $this->targets->get((string)$id);
|
|
||||||
$this->targets->remove($target);
|
|
||||||
$this->alerts->securityEvent('target_removed', ['target' => $target->getName()]);
|
|
||||||
return new JSONResponse([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testTarget(int $id): JSONResponse {
|
|
||||||
try {
|
|
||||||
$repo = $this->targets->repository($this->targets->get((string)$id));
|
|
||||||
return new JSONResponse(['ok' => true, 'snapshots' => count($repo->listSnapshots())]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['ok' => false, 'error' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function startBackup(int $targetId, string $label = ''): JSONResponse {
|
|
||||||
try {
|
|
||||||
$run = $this->backups->start($this->targets->get((string)$targetId), $label);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
return new JSONResponse($this->runJson($run));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSchedule(string $time): JSONResponse {
|
|
||||||
if ($time !== '' && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
|
||||||
return new JSONResponse(['error' => 'Use HH:MM or empty'], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, 'schedule_time', $time);
|
|
||||||
return new JSONResponse(['schedule' => $time]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function snapshots(int $targetId): JSONResponse {
|
|
||||||
$out = [];
|
|
||||||
foreach ($this->snapshots->findForTarget($targetId) as $s) {
|
|
||||||
$out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(),
|
|
||||||
'files' => $s->getFiles(), 'bytes' => $s->getBytes()];
|
|
||||||
}
|
|
||||||
return new JSONResponse($out);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse {
|
|
||||||
try {
|
|
||||||
return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queue a restore of user files; it runs as a background job and shows up in the runs. */
|
|
||||||
#[PasswordConfirmationRequired]
|
|
||||||
public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse {
|
|
||||||
if (!in_array($mode, [RestoreService::MODE_NEW_FOLDER, RestoreService::MODE_MERGE, RestoreService::MODE_REPLACE], true)
|
|
||||||
|| !\OCA\NgBackup\Repository\Repository::isSafePath($path) || !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) {
|
|
||||||
return new JSONResponse(['error' => 'Invalid path or mode'], Http::STATUS_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$target = $this->targets->get((string)$targetId);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return new JSONResponse(['error' => 'No such location'], Http::STATUS_NOT_FOUND);
|
|
||||||
}
|
|
||||||
$run = new Run();
|
|
||||||
$run->setTargetId($target->getId());
|
|
||||||
$run->setKind('restore');
|
|
||||||
$run->setStatus(Run::RUNNING);
|
|
||||||
$run->setPhase('queued');
|
|
||||||
$run->setState(json_encode(['snapshot' => $snapshotId, 'path' => $path, 'mode' => $mode,
|
|
||||||
'by' => $this->session->getUser()?->getUID()], JSON_THROW_ON_ERROR));
|
|
||||||
$run->setSnapshotId($snapshotId);
|
|
||||||
$run->setStartedAt(time());
|
|
||||||
$run->setUpdatedAt(time());
|
|
||||||
try {
|
|
||||||
$run = $this->prune->underStartLock($target, fn () => $this->runs->insert($run));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_CONFLICT);
|
|
||||||
}
|
|
||||||
$this->jobs->add(RestoreJob::class, ['run' => $run->getId()]);
|
|
||||||
return new JSONResponse($this->runJson($run));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function runJson(Run $r): array {
|
|
||||||
$snapshot = null;
|
|
||||||
if ($r->getSnapshotId() !== null) {
|
|
||||||
try {
|
|
||||||
$s = $this->snapshots->findOne($r->getTargetId(), $r->getSnapshotId());
|
|
||||||
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel()];
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Snapshot already pruned or trashed: the run keeps its id, just not the date.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
|
|
||||||
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
|
|
||||||
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
|
|
||||||
'stats' => json_decode($r->getStats() ?? 'null', true)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,6 @@ final class KeyRing {
|
|||||||
return new self(sodium_crypto_kdf_keygen());
|
return new self(sodium_crypto_kdf_keygen());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raw master key, only for storing it encrypted on the server (ICrypto). */
|
|
||||||
public function exportMasterKey(): string {
|
|
||||||
return $this->masterKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function dataKey(): string {
|
public function dataKey(): string {
|
||||||
return $this->dataKey;
|
return $this->dataKey;
|
||||||
}
|
}
|
||||||
@@ -76,23 +71,6 @@ final class KeyRing {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a key field that is either one wrapped key or {"slots": [{slot, label, key}, ...]}:
|
|
||||||
* every slot holds the same master key under its own passphrase.
|
|
||||||
*/
|
|
||||||
public static function unwrapAny(array $field, #[\SensitiveParameter] string $passphrase): self {
|
|
||||||
if (!isset($field['slots'])) {
|
|
||||||
return self::unwrap($field, $passphrase);
|
|
||||||
}
|
|
||||||
foreach ($field['slots'] as $slot) {
|
|
||||||
try {
|
|
||||||
return self::unwrap($slot['key'], $passphrase);
|
|
||||||
} catch (CryptoException) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new CryptoException('Wrong passphrase or damaged key');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
|
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
|
||||||
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
|
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
|
||||||
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
|
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Db;
|
|
||||||
|
|
||||||
use OCA\NgBackup\AppInfo\Application;
|
|
||||||
use OCA\NgBackup\Repository\ICatalogAnchor;
|
|
||||||
use OCP\IAppConfig;
|
|
||||||
|
|
||||||
/** Keeps the newest catalog generation seen per repository in the app config (outside the location). */
|
|
||||||
final class AppConfigCatalogAnchor implements ICatalogAnchor {
|
|
||||||
public function __construct(
|
|
||||||
private IAppConfig $appConfig,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(string $repositoryId): ?array {
|
|
||||||
$raw = $this->appConfig->getValueString(Application::APP_ID, $this->key($repositoryId), '', true);
|
|
||||||
$data = $raw === '' ? null : json_decode($raw, true);
|
|
||||||
return is_array($data) && isset($data['gen'], $data['hash']) ? ['gen' => (int)$data['gen'], 'hash' => (string)$data['hash']] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function set(string $repositoryId, int $gen, string $hash): void {
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, $this->key($repositoryId), json_encode(['gen' => $gen, 'hash' => $hash]), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function key(string $repositoryId): string {
|
|
||||||
return 'catalog_' . preg_replace('/[^a-f0-9]/', '', $repositoryId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+45
-121
@@ -31,8 +31,6 @@ use OCA\NgBackup\Repository\Repository;
|
|||||||
*/
|
*/
|
||||||
final class DbDumper {
|
final class DbDumper {
|
||||||
private const BATCH = 2000;
|
private const BATCH = 2000;
|
||||||
/** Rows per INSERT on restore; keeps the number of bound parameters well below database limits. */
|
|
||||||
private const INSERT_ROWS = 100;
|
|
||||||
/** Tables without a primary key are read in one query; refuse that above this size. */
|
/** Tables without a primary key are read in one query; refuse that above this size. */
|
||||||
private const MAX_ROWS_WITHOUT_PK = 50000;
|
private const MAX_ROWS_WITHOUT_PK = 50000;
|
||||||
|
|
||||||
@@ -43,87 +41,39 @@ final class DbDumper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dump everything in one go (one transaction, fully consistent).
|
* @return array{tables: array<string, array{blobs: list<string>, rows: int, bytes: int, sha256: string}>, provider: string}
|
||||||
*
|
|
||||||
* @return array{tables: array<string, array{blobs: list<string>, rows: int, bytes: int, sha256: string}>, sequences: array, provider: string, consistent: bool}
|
|
||||||
*/
|
*/
|
||||||
public function dump(Repository $repo, array &$stats): array {
|
public function dump(Repository $repo, array &$stats): array {
|
||||||
$state = $this->startDump();
|
|
||||||
while (!$state['done']) {
|
|
||||||
$state = $this->dumpStep($repo, $state, PHP_FLOAT_MAX, $stats);
|
|
||||||
}
|
|
||||||
return $this->manifest($state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** State for a resumable dump (see dumpStep()). */
|
|
||||||
public function startDump(): array {
|
|
||||||
$tables = array_values(array_filter($this->db->createSchemaManager()->listTableNames(), fn (string $t) => str_starts_with($t, $this->prefix)));
|
|
||||||
sort($tables);
|
|
||||||
return ['tables' => $tables, 'i' => 0, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null,
|
|
||||||
'result' => [], 'sequences' => [], 'steps' => 0, 'done' => false];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dump tables until $deadline, inside one REPEATABLE READ transaction for this step. A table
|
|
||||||
* that does not fit is resumed in the next step at the last primary key written. If the whole
|
|
||||||
* dump fits in one step it is fully consistent; otherwise each step is consistent on its own
|
|
||||||
* ('consistent' => false in the manifest).
|
|
||||||
*/
|
|
||||||
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
|
|
||||||
$state['steps']++;
|
|
||||||
$sm = $this->db->createSchemaManager();
|
$sm = $this->db->createSchemaManager();
|
||||||
|
$tables = array_values(array_filter($sm->listTableNames(), fn (string $t) => str_starts_with($t, $this->prefix)));
|
||||||
|
sort($tables);
|
||||||
|
|
||||||
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
||||||
$this->db->beginTransaction();
|
$this->db->beginTransaction();
|
||||||
try {
|
try {
|
||||||
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||||
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
||||||
}
|
}
|
||||||
while ($state['i'] < count($state['tables'])) {
|
$result = [];
|
||||||
$table = $state['tables'][$state['i']];
|
foreach ($tables as $table) {
|
||||||
$schema = $sm->introspectTable($table);
|
|
||||||
$writer = $repo->blobWriter($stats);
|
$writer = $repo->blobWriter($stats);
|
||||||
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
|
$hash = hash_init('sha256');
|
||||||
if ($state['after'] === null && $state['segments'] === []) {
|
$rows = 0;
|
||||||
$writer->write($this->header($schema) . "\n");
|
foreach ($this->tableLines($sm->introspectTable($table)) as $kind => $line) {
|
||||||
}
|
|
||||||
$complete = true;
|
|
||||||
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
|
|
||||||
$writer->write($line . "\n");
|
$writer->write($line . "\n");
|
||||||
hash_update($hash, $line . "\n");
|
if ($kind === 'row') {
|
||||||
$state['rows']++;
|
hash_update($hash, $line . "\n");
|
||||||
$state['after'] = $key;
|
$rows++;
|
||||||
if (microtime(true) >= $deadline) {
|
|
||||||
$complete = false;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$state['segments'][] = $writer->finish();
|
$result[$table] = ['blobs' => $writer->finish(), 'rows' => $rows, 'bytes' => $writer->bytes(), 'sha256' => hash_final($hash)];
|
||||||
$state['bytes'] += $writer->bytes();
|
|
||||||
if (!$complete) {
|
|
||||||
$state['hash'] = base64_encode(serialize($hash));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
|
||||||
'bytes' => $state['bytes'], 'sha256' => hash_final($hash)];
|
|
||||||
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
|
|
||||||
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($state['i'] >= count($state['tables'])) {
|
|
||||||
$state['sequences'] = $this->sequenceStates($state['tables']);
|
|
||||||
$state['done'] = true;
|
|
||||||
}
|
}
|
||||||
|
$sequences = $this->sequenceStates($tables);
|
||||||
} finally {
|
} finally {
|
||||||
$this->db->rollBack();
|
$this->db->rollBack();
|
||||||
}
|
}
|
||||||
$repo->flushPacks();
|
$repo->flushPacks();
|
||||||
return $state;
|
return ['tables' => $result, 'sequences' => $sequences, 'provider' => get_class($this->db->getDatabasePlatform())];
|
||||||
}
|
|
||||||
|
|
||||||
public function manifest(array $state): array {
|
|
||||||
return ['tables' => $state['result'], 'sequences' => $state['sequences'],
|
|
||||||
'provider' => get_class($this->db->getDatabasePlatform()), 'consistent' => $state['steps'] === 1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,23 +82,16 @@ final class DbDumper {
|
|||||||
*
|
*
|
||||||
* @param array{tables: array<string, array{blobs: list<string>, rows: int}>} $manifest
|
* @param array{tables: array<string, array{blobs: list<string>, rows: int}>} $manifest
|
||||||
*/
|
*/
|
||||||
public function restore(Repository $repo, array $manifest, ?array &$report = null): int {
|
public function restore(Repository $repo, array $manifest): int {
|
||||||
$sm = $this->db->createSchemaManager();
|
$sm = $this->db->createSchemaManager();
|
||||||
$existing = array_flip($sm->listTableNames());
|
$existing = array_flip($sm->listTableNames());
|
||||||
$total = 0;
|
$total = 0;
|
||||||
$report = ['skipped' => [], 'restored' => []];
|
|
||||||
$this->db->beginTransaction();
|
$this->db->beginTransaction();
|
||||||
try {
|
try {
|
||||||
foreach ($manifest['tables'] as $table => $info) {
|
foreach ($manifest['tables'] as $table => $info) {
|
||||||
if (!isset($existing[$table])) {
|
if (!isset($existing[$table])) {
|
||||||
throw new \RuntimeException("Table $table from the backup does not exist here (different app versions?)");
|
throw new \RuntimeException("Table $table from the backup does not exist here (different app versions?)");
|
||||||
}
|
}
|
||||||
// Unchanged since the backup: leave it alone (much faster than delete + insert).
|
|
||||||
$live = $this->checksum($table);
|
|
||||||
if ($live['rows'] === $info['rows'] && hash_equals($info['sha256'], $live['sha256'])) {
|
|
||||||
$report['skipped'][] = $table;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$this->db->executeStatement('DELETE FROM ' . $this->db->quoteIdentifier($table));
|
$this->db->executeStatement('DELETE FROM ' . $this->db->quoteIdentifier($table));
|
||||||
$columns = null;
|
$columns = null;
|
||||||
$binary = [];
|
$binary = [];
|
||||||
@@ -169,18 +112,17 @@ final class DbDumper {
|
|||||||
if (is_array($value) && isset($value['$b'])) {
|
if (is_array($value) && isset($value['$b'])) {
|
||||||
$value = base64_decode($value['$b'], true); // text that was not valid UTF-8
|
$value = base64_decode($value['$b'], true); // text that was not valid UTF-8
|
||||||
}
|
}
|
||||||
$row[] = ($value !== null && isset($binary[$col])) ? base64_decode($value, true) : $value;
|
$row[$col] = ($value !== null && isset($binary[$col])) ? base64_decode($value, true) : $value;
|
||||||
}
|
}
|
||||||
$batch[] = $row;
|
$batch[] = $row;
|
||||||
if (count($batch) >= self::INSERT_ROWS) {
|
if (count($batch) >= 500) {
|
||||||
$total += $this->insertBatch($table, $columns, $batch, $binary);
|
$total += $this->insertBatch($table, $batch, $binary);
|
||||||
$batch = [];
|
$batch = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($batch !== []) {
|
if ($batch !== []) {
|
||||||
$total += $this->insertBatch($table, $columns, $batch, $binary);
|
$total += $this->insertBatch($table, $batch, $binary);
|
||||||
}
|
}
|
||||||
$report['restored'][] = $table;
|
|
||||||
}
|
}
|
||||||
$this->restoreSequences($manifest['sequences'] ?? []);
|
$this->restoreSequences($manifest['sequences'] ?? []);
|
||||||
$this->db->commit();
|
$this->db->commit();
|
||||||
@@ -193,22 +135,20 @@ final class DbDumper {
|
|||||||
|
|
||||||
/** Same checksum as dump(): sha256 over the row lines in primary-key order. */
|
/** Same checksum as dump(): sha256 over the row lines in primary-key order. */
|
||||||
public function checksum(string $table): array {
|
public function checksum(string $table): array {
|
||||||
|
$sm = $this->db->createSchemaManager();
|
||||||
$hash = hash_init('sha256');
|
$hash = hash_init('sha256');
|
||||||
$rows = 0;
|
$rows = 0;
|
||||||
foreach ($this->rowLines($this->db->createSchemaManager()->introspectTable($table)) as [, $line]) {
|
foreach ($this->tableLines($sm->introspectTable($table)) as $kind => $line) {
|
||||||
hash_update($hash, $line . "\n");
|
if ($kind === 'row') {
|
||||||
$rows++;
|
hash_update($hash, $line . "\n");
|
||||||
|
$rows++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ['rows' => $rows, 'sha256' => hash_final($hash)];
|
return ['rows' => $rows, 'sha256' => hash_final($hash)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function header(\Doctrine\DBAL\Schema\Table $table): string {
|
/** @return \Generator<string, string> 'header' => line, then 'row' => line ... */
|
||||||
[$columns, $binary, $pk] = $this->describe($table);
|
private function tableLines(\Doctrine\DBAL\Schema\Table $table): \Generator {
|
||||||
return json_encode(['table' => $table->getName(), 'columns' => $columns, 'binary' => $binary, 'pk' => $pk], JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{0: list<string>, 1: list<string>, 2: list<string>} columns, binary columns, primary key */
|
|
||||||
private function describe(\Doctrine\DBAL\Schema\Table $table): array {
|
|
||||||
$columns = array_values(array_map(fn ($c) => $c->getName(), $table->getColumns()));
|
$columns = array_values(array_map(fn ($c) => $c->getName(), $table->getColumns()));
|
||||||
$binary = [];
|
$binary = [];
|
||||||
foreach ($table->getColumns() as $c) {
|
foreach ($table->getColumns() as $c) {
|
||||||
@@ -216,34 +156,25 @@ final class DbDumper {
|
|||||||
$binary[] = $c->getName();
|
$binary[] = $c->getName();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [$columns, $binary, $table->getPrimaryKey()?->getColumns() ?? []];
|
$pk = $table->getPrimaryKey()?->getColumns() ?? [];
|
||||||
}
|
yield 'header' => json_encode(['table' => $table->getName(), 'columns' => array_values($columns), 'binary' => $binary, 'pk' => $pk], JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
/**
|
|
||||||
* Rows in primary-key order, after the key $after (resume), as [key, JSON line].
|
|
||||||
*
|
|
||||||
* @return \Generator<array{0: list<mixed>|null, 1: string}>
|
|
||||||
*/
|
|
||||||
private function rowLines(\Doctrine\DBAL\Schema\Table $table, ?array $after = null): \Generator {
|
|
||||||
[$columns, $binary, $pk] = $this->describe($table);
|
|
||||||
$binaryFlip = array_flip($binary);
|
$binaryFlip = array_flip($binary);
|
||||||
$q = fn (string $c) => $this->db->quoteIdentifier($c);
|
$q = fn (string $c) => $this->db->quoteIdentifier($c);
|
||||||
$select = 'SELECT ' . implode(', ', array_map($q, $columns)) . ' FROM ' . $q($table->getName());
|
$select = 'SELECT ' . implode(', ', array_map($q, $columns)) . ' FROM ' . $q($table->getName());
|
||||||
|
|
||||||
if ($pk === []) {
|
if ($pk === []) {
|
||||||
// Nextcloud requires primary keys; small odd tables are read in one query (not resumable).
|
// Nextcloud requires primary keys; small odd tables are read in one query.
|
||||||
$count = (int)$this->db->fetchOne('SELECT COUNT(*) FROM ' . $q($table->getName()));
|
$count = (int)$this->db->fetchOne('SELECT COUNT(*) FROM ' . $q($table->getName()));
|
||||||
if ($count > self::MAX_ROWS_WITHOUT_PK) {
|
if ($count > self::MAX_ROWS_WITHOUT_PK) {
|
||||||
throw new \RuntimeException("Table {$table->getName()} has no primary key and $count rows; cannot dump it with bounded memory");
|
throw new \RuntimeException("Table {$table->getName()} has no primary key and $count rows; cannot dump it with bounded memory");
|
||||||
}
|
}
|
||||||
$batches = [$this->db->fetchAllNumeric($select)];
|
$batches = [$this->db->fetchAllNumeric($select)];
|
||||||
$pkIdx = [];
|
|
||||||
} else {
|
} else {
|
||||||
$batches = $this->keysetBatches($select, $pk, $columns, $after);
|
$batches = $this->keysetBatches($select, $pk, $columns);
|
||||||
$pkIdx = array_map(fn ($c) => array_search($c, $columns, true), $pk);
|
|
||||||
}
|
}
|
||||||
foreach ($batches as $rows) {
|
foreach ($batches as $rows) {
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$key = $pkIdx === [] ? null : array_map(fn ($i) => $row[$i], $pkIdx);
|
|
||||||
foreach ($columns as $i => $col) {
|
foreach ($columns as $i => $col) {
|
||||||
if (is_resource($row[$i])) {
|
if (is_resource($row[$i])) {
|
||||||
$row[$i] = stream_get_contents($row[$i]);
|
$row[$i] = stream_get_contents($row[$i]);
|
||||||
@@ -258,23 +189,23 @@ final class DbDumper {
|
|||||||
$row[$i] = (string)(is_bool($row[$i]) ? (int)$row[$i] : $row[$i]);
|
$row[$i] = (string)(is_bool($row[$i]) ? (int)$row[$i] : $row[$i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield [$key, json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)];
|
yield 'row' => json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return \Generator<list<list<mixed>>> */
|
/** @return \Generator<list<list<mixed>>> */
|
||||||
private function keysetBatches(string $select, array $pk, array $columns, ?array $after): \Generator {
|
private function keysetBatches(string $select, array $pk, array $columns): \Generator {
|
||||||
$q = fn (string $c) => $this->db->quoteIdentifier($c);
|
$q = fn (string $c) => $this->db->quoteIdentifier($c);
|
||||||
$order = ' ORDER BY ' . implode(', ', array_map($q, $pk));
|
$order = ' ORDER BY ' . implode(', ', array_map($q, $pk));
|
||||||
$cols = array_map(fn ($c) => $q($c), $pk);
|
$pkIdx = array_map(fn ($c) => array_search($c, $columns, true), $pk);
|
||||||
$last = $after;
|
$last = null;
|
||||||
while (true) {
|
while (true) {
|
||||||
$sql = $select;
|
$sql = $select;
|
||||||
$params = [];
|
$params = [];
|
||||||
if ($last !== null) {
|
if ($last !== null) {
|
||||||
// Row-value comparison (a, b) > (?, ?), supported by PostgreSQL, MySQL/MariaDB and SQLite.
|
// Row-value comparison (a, b) > (?, ?), supported by PostgreSQL, MySQL/MariaDB and SQLite.
|
||||||
$sql .= ' WHERE (' . implode(', ', $cols) . ') > (' . implode(', ', array_fill(0, count($pk), '?')) . ')';
|
$sql .= ' WHERE (' . implode(', ', array_map($q, $pk)) . ') > (' . implode(', ', array_fill(0, count($pk), '?')) . ')';
|
||||||
$params = $last;
|
$params = $last;
|
||||||
}
|
}
|
||||||
$rows = $this->db->fetchAllNumeric($sql . $order . ' LIMIT ' . self::BATCH, $params);
|
$rows = $this->db->fetchAllNumeric($sql . $order . ' LIMIT ' . self::BATCH, $params);
|
||||||
@@ -282,29 +213,22 @@ final class DbDumper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
yield $rows;
|
yield $rows;
|
||||||
|
$end = end($rows);
|
||||||
|
$last = array_map(fn ($i) => $end[$i], $pkIdx);
|
||||||
if (count($rows) < self::BATCH) {
|
if (count($rows) < self::BATCH) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$end = end($rows);
|
|
||||||
$last = array_map(fn ($c) => $end[array_search($c, $columns, true)], $pk);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One INSERT with several rows (portable multi-row VALUES). */
|
private function insertBatch(string $table, array $rows, array $binary): int {
|
||||||
private function insertBatch(string $table, array $columns, array $rows, array $binary): int {
|
|
||||||
$q = fn (string $c) => $this->db->quoteIdentifier($c);
|
|
||||||
$placeholders = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')';
|
|
||||||
$sql = 'INSERT INTO ' . $q($table) . ' (' . implode(', ', array_map($q, $columns)) . ') VALUES '
|
|
||||||
. implode(', ', array_fill(0, count($rows), $placeholders));
|
|
||||||
$params = [];
|
|
||||||
$types = [];
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
foreach ($columns as $i => $col) {
|
$types = [];
|
||||||
$params[] = $row[$i];
|
foreach ($row as $col => $_) {
|
||||||
$types[] = isset($binary[$col]) ? \Doctrine\DBAL\ParameterType::LARGE_OBJECT : ($row[$i] === null ? \Doctrine\DBAL\ParameterType::NULL : \Doctrine\DBAL\ParameterType::STRING);
|
$types[$col] = isset($binary[$col]) ? \Doctrine\DBAL\ParameterType::LARGE_OBJECT : \Doctrine\DBAL\ParameterType::STRING;
|
||||||
}
|
}
|
||||||
|
$this->db->insert($this->db->quoteIdentifier($table), array_combine(array_map(fn ($c) => $this->db->quoteIdentifier($c), array_keys($row)), $row), array_values($types));
|
||||||
}
|
}
|
||||||
$this->db->executeStatement($sql, $params, $types);
|
|
||||||
return count($rows);
|
return count($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Db;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Repository\IIndexCache;
|
|
||||||
use OCP\DB\Exception;
|
|
||||||
use OCP\IDBConnection;
|
|
||||||
|
|
||||||
final class DbIndexCache implements IIndexCache {
|
|
||||||
public function __construct(
|
|
||||||
private IDBConnection $db,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function all(string $repositoryId): array {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('pack_id', 'entries')->from('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
|
|
||||||
$result = $qb->executeQuery();
|
|
||||||
$out = [];
|
|
||||||
while ($row = $result->fetch()) {
|
|
||||||
$out[$row['pack_id']] = json_decode($row['entries'], true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
$result->closeCursor();
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function put(string $repositoryId, string $packId, array $entries): void {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->insert('ngb_index')->values([
|
|
||||||
'repository_id' => $qb->createNamedParameter($repositoryId),
|
|
||||||
'pack_id' => $qb->createNamedParameter($packId),
|
|
||||||
'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)),
|
|
||||||
]);
|
|
||||||
try {
|
|
||||||
$qb->executeStatement();
|
|
||||||
} catch (Exception $e) {
|
|
||||||
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function remove(string $repositoryId, array $packIds): void {
|
|
||||||
foreach (array_chunk($packIds, 500) as $chunk) {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->delete('ngb_index')
|
|
||||||
->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)))
|
|
||||||
->andWhere($qb->expr()->in('pack_id', $qb->createNamedParameter($chunk, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR_ARRAY)));
|
|
||||||
$qb->executeStatement();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Db;
|
|
||||||
|
|
||||||
use OCP\AppFramework\Db\Entity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @method int getTargetId()
|
|
||||||
* @method void setTargetId(int $v)
|
|
||||||
* @method string getKind()
|
|
||||||
* @method void setKind(string $v)
|
|
||||||
* @method string getStatus()
|
|
||||||
* @method void setStatus(string $v)
|
|
||||||
* @method string getPhase()
|
|
||||||
* @method void setPhase(string $v)
|
|
||||||
* @method string getState()
|
|
||||||
* @method void setState(string $v)
|
|
||||||
* @method string|null getStats()
|
|
||||||
* @method void setStats(?string $v)
|
|
||||||
* @method string|null getError()
|
|
||||||
* @method void setError(?string $v)
|
|
||||||
* @method string|null getSnapshotId()
|
|
||||||
* @method void setSnapshotId(?string $v)
|
|
||||||
* @method int getStartedAt()
|
|
||||||
* @method void setStartedAt(int $v)
|
|
||||||
* @method int getUpdatedAt()
|
|
||||||
* @method void setUpdatedAt(int $v)
|
|
||||||
* @method int|null getFinishedAt()
|
|
||||||
* @method void setFinishedAt(?int $v)
|
|
||||||
*/
|
|
||||||
final class Run extends Entity {
|
|
||||||
public const RUNNING = 'running';
|
|
||||||
public const DONE = 'done';
|
|
||||||
public const FAILED = 'failed';
|
|
||||||
public const CANCELLED = 'cancelled';
|
|
||||||
|
|
||||||
protected int $targetId = 0;
|
|
||||||
protected string $kind = '';
|
|
||||||
protected string $status = '';
|
|
||||||
protected string $phase = '';
|
|
||||||
protected string $state = '{}';
|
|
||||||
protected ?string $stats = null;
|
|
||||||
protected ?string $error = null;
|
|
||||||
protected ?string $snapshotId = null;
|
|
||||||
protected int $startedAt = 0;
|
|
||||||
protected int $updatedAt = 0;
|
|
||||||
protected ?int $finishedAt = null;
|
|
||||||
|
|
||||||
public function __construct() {
|
|
||||||
$this->addType('targetId', 'integer');
|
|
||||||
$this->addType('startedAt', 'integer');
|
|
||||||
$this->addType('updatedAt', 'integer');
|
|
||||||
$this->addType('finishedAt', 'integer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Db;
|
|
||||||
|
|
||||||
use OCP\AppFramework\Db\QBMapper;
|
|
||||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
|
||||||
use OCP\IDBConnection;
|
|
||||||
|
|
||||||
/** @template-extends QBMapper<Run> */
|
|
||||||
final class RunMapper extends QBMapper {
|
|
||||||
public function __construct(IDBConnection $db) {
|
|
||||||
parent::__construct($db, 'ngb_runs', Run::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): Run {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
|
||||||
return $this->findEntity($qb);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<Run> */
|
|
||||||
public function findRunning(?int $targetId = null, ?string $kind = null): array {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)));
|
|
||||||
if ($kind !== null) {
|
|
||||||
$qb->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)));
|
|
||||||
}
|
|
||||||
if ($targetId !== null) {
|
|
||||||
$qb->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)));
|
|
||||||
}
|
|
||||||
return $this->findEntities($qb->orderBy('id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<Run> */
|
|
||||||
public function findRecent(int $limit = 20): array {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())->orderBy('id', 'DESC')->setMaxResults($limit);
|
|
||||||
return $this->findEntities($qb);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lastFinished(int $targetId, string $kind): ?Run {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())
|
|
||||||
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
|
||||||
->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
|
|
||||||
->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Run::DONE)))
|
|
||||||
->orderBy('id', 'DESC')->setMaxResults(1);
|
|
||||||
$r = $this->findEntities($qb);
|
|
||||||
return $r[0] ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Db;
|
|
||||||
|
|
||||||
use OCP\AppFramework\Db\Entity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @method int getTargetId()
|
|
||||||
* @method void setTargetId(int $v)
|
|
||||||
* @method string getSnapshotId()
|
|
||||||
* @method void setSnapshotId(string $v)
|
|
||||||
* @method string getKind()
|
|
||||||
* @method void setKind(string $v)
|
|
||||||
* @method string|null getLabel()
|
|
||||||
* @method void setLabel(?string $v)
|
|
||||||
* @method int getCreatedAt()
|
|
||||||
* @method void setCreatedAt(int $v)
|
|
||||||
* @method int|null getFiles()
|
|
||||||
* @method void setFiles(?int $v)
|
|
||||||
* @method int|null getBytes()
|
|
||||||
* @method void setBytes(?int $v)
|
|
||||||
*/
|
|
||||||
final class Snapshot extends Entity {
|
|
||||||
protected int $targetId = 0;
|
|
||||||
protected string $snapshotId = '';
|
|
||||||
protected string $kind = '';
|
|
||||||
protected ?string $label = null;
|
|
||||||
protected int $createdAt = 0;
|
|
||||||
protected ?int $files = null;
|
|
||||||
protected ?int $bytes = null;
|
|
||||||
|
|
||||||
public function __construct() {
|
|
||||||
$this->addType('targetId', 'integer');
|
|
||||||
$this->addType('createdAt', 'integer');
|
|
||||||
$this->addType('files', 'integer');
|
|
||||||
$this->addType('bytes', 'integer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Db;
|
|
||||||
|
|
||||||
use OCP\AppFramework\Db\QBMapper;
|
|
||||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
|
||||||
use OCP\IDBConnection;
|
|
||||||
|
|
||||||
/** @template-extends QBMapper<Snapshot> */
|
|
||||||
final class SnapshotMapper extends QBMapper {
|
|
||||||
public function __construct(IDBConnection $db) {
|
|
||||||
parent::__construct($db, 'ngb_snapshots', Snapshot::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<Snapshot> newest first */
|
|
||||||
public function findForTarget(int $targetId): array {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())
|
|
||||||
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
|
||||||
->orderBy('created_at', 'DESC');
|
|
||||||
return $this->findEntities($qb);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findOne(int $targetId, string $snapshotId): Snapshot {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())
|
|
||||||
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
|
||||||
->andWhere($qb->expr()->eq('snapshot_id', $qb->createNamedParameter($snapshotId)));
|
|
||||||
return $this->findEntity($qb);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function latest(int $targetId, string $kind): ?Snapshot {
|
|
||||||
foreach ($this->findForTarget($targetId) as $s) {
|
|
||||||
if ($s->getKind() === $kind) {
|
|
||||||
return $s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Db;
|
|
||||||
|
|
||||||
use OCP\AppFramework\Db\Entity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @method string getName()
|
|
||||||
* @method void setName(string $v)
|
|
||||||
* @method string getBackend()
|
|
||||||
* @method void setBackend(string $v)
|
|
||||||
* @method string getAuth()
|
|
||||||
* @method void setAuth(string $v)
|
|
||||||
* @method string getOptions()
|
|
||||||
* @method void setOptions(string $v)
|
|
||||||
* @method string getBasePath()
|
|
||||||
* @method void setBasePath(string $v)
|
|
||||||
* @method string|null getRepositoryId()
|
|
||||||
* @method void setRepositoryId(?string $v)
|
|
||||||
* @method bool|null getAppendOnly()
|
|
||||||
* @method void setAppendOnly(bool $v)
|
|
||||||
* @method int|null getMaxBytes()
|
|
||||||
* @method void setMaxBytes(?int $v)
|
|
||||||
* @method int getCreatedAt()
|
|
||||||
* @method void setCreatedAt(int $v)
|
|
||||||
*/
|
|
||||||
final class Target extends Entity {
|
|
||||||
protected string $name = '';
|
|
||||||
protected string $backend = '';
|
|
||||||
protected string $auth = '';
|
|
||||||
protected string $options = '';
|
|
||||||
protected string $basePath = '';
|
|
||||||
protected ?string $repositoryId = null;
|
|
||||||
protected int $createdAt = 0;
|
|
||||||
protected ?bool $appendOnly = null;
|
|
||||||
protected ?int $maxBytes = null;
|
|
||||||
|
|
||||||
public function __construct() {
|
|
||||||
$this->addType('createdAt', 'integer');
|
|
||||||
$this->addType('appendOnly', 'boolean');
|
|
||||||
$this->addType('maxBytes', 'integer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Db;
|
|
||||||
|
|
||||||
use OCP\AppFramework\Db\QBMapper;
|
|
||||||
use OCP\IDBConnection;
|
|
||||||
|
|
||||||
/** @template-extends QBMapper<Target> */
|
|
||||||
final class TargetMapper extends QBMapper {
|
|
||||||
public function __construct(IDBConnection $db) {
|
|
||||||
parent::__construct($db, 'ngb_targets', Target::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): Target {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())
|
|
||||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
|
||||||
return $this->findEntity($qb);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByName(string $name): Target {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('name', $qb->createNamedParameter($name)));
|
|
||||||
return $this->findEntity($qb);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<Target> */
|
|
||||||
public function findAll(): array {
|
|
||||||
$qb = $this->db->getQueryBuilder();
|
|
||||||
$qb->select('*')->from($this->getTableName())->orderBy('name');
|
|
||||||
return $this->findEntities($qb);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+100
-84
@@ -10,142 +10,158 @@ namespace OCA\NgBackup\Job;
|
|||||||
use OCA\NgBackup\Repository\Repository;
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A file backup as a resumable state machine, for environments with short PHP time limits.
|
* A backup run as a resumable state machine, for environments with short PHP time limits
|
||||||
* Each step works until its deadline, uploads what it has (pack + tree segment) and returns
|
* (webcron/AJAX cron on hosted Nextcloud). Each step works until its deadline, then uploads
|
||||||
* the new state; a large file is resumed at the next blob.
|
* what it has (pack + tree segment) and returns the new state. Even a single large file is
|
||||||
|
* resumed at the blob where the previous step stopped.
|
||||||
*
|
*
|
||||||
* The state holds no file list: the position is a cursor in TreeWalker order, and the previous
|
* The state is a plain array (in the app: a database row). It is only saved after a step
|
||||||
* snapshot is merge-joined through ParentCursor, so memory and state size stay small however
|
* completes; if a process is killed mid-step, the next step redoes that work. Blobs uploaded by
|
||||||
* many files there are. The snapshot is written last; a killed step is simply redone.
|
* the killed step are not referenced and are removed later by pruning; nothing is corrupted,
|
||||||
|
* because the snapshot is written last.
|
||||||
*/
|
*/
|
||||||
final class BackupRun {
|
final class BackupRun {
|
||||||
/**
|
public static function start(string $source, ?string $parent = null, string $label = ''): array {
|
||||||
* @param string|array<string, string> $roots one directory, or name => directory (walked in order)
|
|
||||||
* @param list<string> $exclude logical path prefixes to skip
|
|
||||||
* @param array<string, mixed> $meta extra fields for the snapshot meta record
|
|
||||||
*/
|
|
||||||
public static function start(string|array $roots, ?string $parent = null, string $label = '', array $exclude = [], array $meta = []): array {
|
|
||||||
$roots = is_string($roots) ? ['' => rtrim($roots, '/')] : $roots;
|
|
||||||
uksort($roots, fn ($a, $b) => TreeWalker::compare((string)$a, (string)$b)); // walk order = compare order
|
|
||||||
return [
|
return [
|
||||||
'snapshot' => bin2hex(random_bytes(16)),
|
'snapshot' => bin2hex(random_bytes(16)),
|
||||||
'roots' => $roots,
|
'source' => rtrim($source, '/'),
|
||||||
'exclude' => $exclude,
|
|
||||||
'parent' => $parent,
|
'parent' => $parent,
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'meta' => $meta,
|
|
||||||
'started' => gmdate('c'),
|
'started' => gmdate('c'),
|
||||||
'phase' => 'files',
|
'phase' => 'scan',
|
||||||
'after' => null,
|
'files' => [],
|
||||||
'parentPos' => 0,
|
'pos' => 0,
|
||||||
'cur' => null,
|
'cur' => null,
|
||||||
'segments' => [],
|
'segments' => [],
|
||||||
'steps' => 0,
|
'steps' => 0,
|
||||||
'stats' => ['files' => 0, 'bytes' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0],
|
'stats' => ['files' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run one step until $deadline (microtime(true)); returns the new state. */
|
/** Run one step until $deadline (microtime(true)); returns the new state. */
|
||||||
public static function step(Repository $repo, array $state, float $deadline): array {
|
public static function step(Repository $repo, array $state, float $deadline): array {
|
||||||
$state['steps']++;
|
$state['steps']++;
|
||||||
|
if ($state['phase'] === 'scan') {
|
||||||
|
$state['files'] = self::scan($state['source']);
|
||||||
|
$state['phase'] = 'files';
|
||||||
|
// Scanning can take a while on big trees; continue in the same step if time is left.
|
||||||
|
}
|
||||||
|
|
||||||
if ($state['phase'] === 'files') {
|
if ($state['phase'] === 'files') {
|
||||||
$walker = new TreeWalker($state['roots'], $state['exclude']);
|
$previous = $state['parent'] !== null ? $repo->snapshotEntries($state['parent']) : [];
|
||||||
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
|
|
||||||
$stats = $state['stats'];
|
$stats = $state['stats'];
|
||||||
$lines = [];
|
$lines = [];
|
||||||
$finishedWalk = true;
|
$total = count($state['files']);
|
||||||
|
while ($state['pos'] < $total) {
|
||||||
foreach ($walker->walk($state['after']) as $logical => $path) {
|
$relative = $state['files'][$state['pos']];
|
||||||
$version = Repository::fileVersion($path);
|
$path = $state['source'] . '/' . $relative;
|
||||||
if ($version === 'missing') {
|
clearstatcache(true, $path);
|
||||||
continue; // deleted while walking
|
$size = @filesize($path);
|
||||||
}
|
$mtime = @filemtime($path);
|
||||||
[$size, $mtime] = array_map('intval', explode(':', $version, 3));
|
if ($size === false) {
|
||||||
$prev = $parent->find($logical);
|
// Deleted since the scan: skip.
|
||||||
|
$state['pos']++;
|
||||||
if ($state['cur'] === null && $prev !== null && $prev['s'] === $size && $prev['m'] === $mtime) {
|
|
||||||
$blobs = $prev['b'];
|
|
||||||
$stats['reused']++;
|
|
||||||
} else {
|
|
||||||
$cur = $state['cur'];
|
|
||||||
if ($cur === null || $cur['path'] !== $logical || $cur['version'] !== $version) {
|
|
||||||
if ($cur !== null && $cur['path'] === $logical) {
|
|
||||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1; // changed since the previous step
|
|
||||||
}
|
|
||||||
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
|
|
||||||
}
|
|
||||||
$fh = fopen($path, 'rb');
|
|
||||||
if ($fh === false) {
|
|
||||||
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
|
|
||||||
$state['cur'] = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
fseek($fh, $cur['offset']);
|
|
||||||
$complete = true;
|
|
||||||
while (($data = self::readFull($fh, Repository::BLOB_SIZE)) !== '') {
|
|
||||||
$cur['blobs'][] = $repo->storeData($data, $stats);
|
|
||||||
$cur['offset'] += strlen($data);
|
|
||||||
if (microtime(true) >= $deadline && !feof($fh)) {
|
|
||||||
$complete = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fclose($fh);
|
|
||||||
if (Repository::fileVersion($path) !== $cur['version']) {
|
|
||||||
// Changed while reading: read it again from the start (now or next step).
|
|
||||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
|
||||||
$state['cur'] = null;
|
|
||||||
$finishedWalk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!$complete) {
|
|
||||||
$state['cur'] = $cur;
|
|
||||||
$finishedWalk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$blobs = $cur['blobs'];
|
|
||||||
$state['cur'] = null;
|
$state['cur'] = null;
|
||||||
$stats['read']++;
|
continue;
|
||||||
|
}
|
||||||
|
$prev = $previous[$relative] ?? null;
|
||||||
|
if ($state['cur'] === null && $prev !== null && $prev['s'] === $size && $prev['m'] === $mtime) {
|
||||||
|
$lines[] = self::line($relative, $size, $mtime, $prev['b']);
|
||||||
|
$stats['files']++;
|
||||||
|
$stats['reused']++;
|
||||||
|
$state['pos']++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines[] = json_encode(['p' => $logical, 's' => $size, 'm' => $mtime, 'b' => $blobs], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
$version = Repository::fileVersion($path);
|
||||||
|
$cur = $state['cur'];
|
||||||
|
if ($cur === null || $cur['version'] !== $version) {
|
||||||
|
// New file, or it changed since the previous step: (re)start reading it.
|
||||||
|
if ($cur !== null) {
|
||||||
|
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
$cur = ['path' => $relative, 'offset' => 0, 'blobs' => [], 'version' => $version];
|
||||||
|
}
|
||||||
|
$fh = fopen($path, 'rb');
|
||||||
|
fseek($fh, $cur['offset']);
|
||||||
|
$finished = true;
|
||||||
|
while (($data = self::readFull($fh, Repository::BLOB_SIZE)) !== '') {
|
||||||
|
$cur['blobs'][] = $repo->storeData($data, $stats);
|
||||||
|
$cur['offset'] += strlen($data);
|
||||||
|
if (microtime(true) >= $deadline && !feof($fh)) {
|
||||||
|
$finished = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose($fh);
|
||||||
|
if (Repository::fileVersion($path) !== $cur['version']) {
|
||||||
|
// Changed while reading: start this file again (in this or the next step).
|
||||||
|
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
||||||
|
$state['cur'] = null;
|
||||||
|
if (microtime(true) >= $deadline) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$finished) {
|
||||||
|
$state['cur'] = $cur;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$lines[] = self::line($relative, $size, $mtime, $cur['blobs']);
|
||||||
$stats['files']++;
|
$stats['files']++;
|
||||||
$stats['bytes'] += $size;
|
$stats['read']++;
|
||||||
$state['after'] = $logical;
|
$state['cur'] = null;
|
||||||
|
$state['pos']++;
|
||||||
if (microtime(true) >= $deadline) {
|
if (microtime(true) >= $deadline) {
|
||||||
$finishedWalk = false;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist this step's tree lines as a blob stream segment and upload the open pack.
|
||||||
if ($lines !== []) {
|
if ($lines !== []) {
|
||||||
$writer = $repo->blobWriter($stats);
|
$writer = $repo->blobWriter($stats);
|
||||||
$writer->write(implode("\n", $lines) . "\n");
|
$writer->write(implode("\n", $lines) . "\n");
|
||||||
$state['segments'][] = $writer->finish();
|
$state['segments'][] = $writer->finish();
|
||||||
}
|
}
|
||||||
$repo->flushPacks();
|
$repo->flushPacks();
|
||||||
$state['parentPos'] = $parent->position();
|
|
||||||
$state['stats'] = $stats;
|
$state['stats'] = $stats;
|
||||||
if ($finishedWalk) {
|
if ($state['pos'] >= $total) {
|
||||||
$state['phase'] = 'finish';
|
$state['phase'] = 'finish';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($state['phase'] === 'finish') {
|
if ($state['phase'] === 'finish' && microtime(true) < $deadline + 5) {
|
||||||
$segments = $state['segments'];
|
$segments = $state['segments'];
|
||||||
$repo->writeSnapshot($state['snapshot'],
|
$repo->writeSnapshot($state['snapshot'],
|
||||||
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'], 'source' => $state['source']],
|
||||||
'roots' => array_keys($state['roots']), 'stats' => $state['stats']] + $state['meta'],
|
|
||||||
(function () use ($repo, $segments) {
|
(function () use ($repo, $segments) {
|
||||||
foreach ($segments as $blobs) {
|
foreach ($segments as $blobs) {
|
||||||
yield from $repo->readLines($blobs);
|
yield from $repo->readLines($blobs);
|
||||||
}
|
}
|
||||||
})());
|
})());
|
||||||
$state['phase'] = 'done';
|
$state['phase'] = 'done';
|
||||||
|
$state['files'] = [];
|
||||||
}
|
}
|
||||||
return $state;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private static function scan(string $root): array {
|
||||||
|
$files = [];
|
||||||
|
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
|
||||||
|
foreach ($it as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
$files[] = substr($file->getPathname(), strlen($root) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($files, SORT_STRING);
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function line(string $path, int $size, int $mtime, array $blobs): string {
|
||||||
|
return json_encode(['p' => $path, 's' => $size, 'm' => $mtime, 'b' => $blobs], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
private static function readFull($fh, int $length): string {
|
private static function readFull($fh, int $length): string {
|
||||||
$buffer = '';
|
$buffer = '';
|
||||||
while (strlen($buffer) < $length && !feof($fh)) {
|
while (strlen($buffer) < $length && !feof($fh)) {
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Job;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Repository\Repository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the previous snapshot's entries in walk order and answers "what did the parent have
|
|
||||||
* at this path?" for increasing paths (merge-join). Only the current entry is in memory; the
|
|
||||||
* number of entries consumed is part of the resumable state.
|
|
||||||
*/
|
|
||||||
final class ParentCursor {
|
|
||||||
private ?\Generator $entries = null;
|
|
||||||
private ?array $current = null;
|
|
||||||
private int $consumed = 0;
|
|
||||||
|
|
||||||
public function __construct(Repository $repo, ?string $snapshotId, int $skip = 0) {
|
|
||||||
if ($snapshotId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->entries = $repo->entries($snapshotId);
|
|
||||||
$this->current = $this->entries->valid() ? $this->entries->current() : null;
|
|
||||||
while ($this->consumed < $skip && $this->current !== null) {
|
|
||||||
$this->advance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{p:string, s:int, m:int, b:list<string>}|null */
|
|
||||||
public function find(string $path): ?array {
|
|
||||||
while ($this->current !== null) {
|
|
||||||
$c = TreeWalker::compare($this->current['p'], $path);
|
|
||||||
if ($c === 0) {
|
|
||||||
return $this->current;
|
|
||||||
}
|
|
||||||
if ($c > 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$this->advance();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function position(): int {
|
|
||||||
return $this->consumed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function advance(): void {
|
|
||||||
$this->entries->next();
|
|
||||||
$this->consumed++;
|
|
||||||
$this->current = $this->entries->valid() ? $this->entries->current() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Job;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walks one or more source roots in a stable depth-first order (entries of a directory sorted
|
|
||||||
* by name, children right after their directory), yielding "<root>/<relative path>" for files.
|
|
||||||
*
|
|
||||||
* The same order is used for comparing paths (compare()), so a walk can resume after a cursor
|
|
||||||
* and be merge-joined with the previous snapshot without holding any list in memory.
|
|
||||||
*/
|
|
||||||
final class TreeWalker {
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $roots name => absolute directory, walked in this order
|
|
||||||
* @param list<string> $exclude path prefixes ("<root>/<relative>") that are skipped entirely;
|
|
||||||
* a pattern with "*" matches one path segment (fnmatch, FNM_PATHNAME)
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private array $roots,
|
|
||||||
private array $exclude = [],
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return \Generator<string, string> logical path => absolute path, strictly after $after */
|
|
||||||
public function walk(?string $after = null): \Generator {
|
|
||||||
foreach ($this->roots as $name => $dir) {
|
|
||||||
$name = (string)$name;
|
|
||||||
if ($name !== '' && $after !== null && self::compare($name, self::firstSegment($after)) < 0) {
|
|
||||||
continue; // whole root lies before the cursor
|
|
||||||
}
|
|
||||||
yield from $this->walkDir(rtrim($dir, '/'), $name, $after);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Order used everywhere: segment by segment, byte-wise. */
|
|
||||||
public static function compare(string $a, string $b): int {
|
|
||||||
$sa = explode('/', $a);
|
|
||||||
$sb = explode('/', $b);
|
|
||||||
$n = min(count($sa), count($sb));
|
|
||||||
for ($i = 0; $i < $n; $i++) {
|
|
||||||
$c = strcmp($sa[$i], $sb[$i]);
|
|
||||||
if ($c !== 0) {
|
|
||||||
return $c < 0 ? -1 : 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count($sa) <=> count($sb);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function walkDir(string $abs, string $logical, ?string $after): \Generator {
|
|
||||||
if ($logical !== '' && $this->isExcluded($logical, true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$names = @scandir($abs);
|
|
||||||
if ($names === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$names = array_values(array_diff($names, ['.', '..']));
|
|
||||||
sort($names, SORT_STRING);
|
|
||||||
foreach ($names as $name) {
|
|
||||||
$childLogical = $logical === '' ? $name : $logical . '/' . $name;
|
|
||||||
$childAbs = $abs . '/' . $name;
|
|
||||||
if (is_link($childAbs)) {
|
|
||||||
continue; // never follow symlinks out of the data directory
|
|
||||||
}
|
|
||||||
if (is_dir($childAbs)) {
|
|
||||||
// Skip a directory entirely if everything in it sorts before the cursor.
|
|
||||||
if ($after !== null && self::compare($childLogical, $after) < 0 && !self::isPrefix($childLogical, $after)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
yield from $this->walkDir($childAbs, $childLogical, $after);
|
|
||||||
} elseif (is_file($childAbs)) {
|
|
||||||
if ($after !== null && self::compare($childLogical, $after) <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!$this->isExcluded($childLogical, false)) {
|
|
||||||
yield $childLogical => $childAbs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isExcluded(string $logical, bool $isDir): bool {
|
|
||||||
foreach ($this->exclude as $pattern) {
|
|
||||||
if (str_contains($pattern, '*')) {
|
|
||||||
if (fnmatch($pattern, $logical, FNM_PATHNAME)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} elseif ($logical === $pattern || ($isDir && str_starts_with($logical, $pattern . '/'))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function isPrefix(string $dir, string $path): bool {
|
|
||||||
return str_starts_with($path, $dir . '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function firstSegment(string $path): string {
|
|
||||||
return explode('/', $path, 2)[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Migration;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use OCP\DB\ISchemaWrapper;
|
|
||||||
use OCP\DB\Types;
|
|
||||||
use OCP\Migration\IOutput;
|
|
||||||
use OCP\Migration\SimpleMigrationStep;
|
|
||||||
|
|
||||||
final class Version000001Date20261002000000 extends SimpleMigrationStep {
|
|
||||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
|
||||||
/** @var ISchemaWrapper $schema */
|
|
||||||
$schema = $schemaClosure();
|
|
||||||
|
|
||||||
if (!$schema->hasTable('ngb_targets')) {
|
|
||||||
$t = $schema->createTable('ngb_targets');
|
|
||||||
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 64]);
|
|
||||||
$t->addColumn('backend', Types::STRING, ['notnull' => true, 'length' => 64]);
|
|
||||||
$t->addColumn('auth', Types::STRING, ['notnull' => true, 'length' => 64]);
|
|
||||||
// Backend/auth options as JSON, encrypted with ICrypto (they contain credentials).
|
|
||||||
$t->addColumn('options', Types::TEXT, ['notnull' => true]);
|
|
||||||
$t->addColumn('base_path', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => '']);
|
|
||||||
$t->addColumn('repository_id', Types::STRING, ['notnull' => false, 'length' => 32]);
|
|
||||||
$t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->setPrimaryKey(['id']);
|
|
||||||
$t->addUniqueIndex(['name'], 'ngb_targets_name');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$schema->hasTable('ngb_runs')) {
|
|
||||||
$t = $schema->createTable('ngb_runs');
|
|
||||||
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]);
|
|
||||||
$t->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16]);
|
|
||||||
$t->addColumn('phase', Types::STRING, ['notnull' => true, 'length' => 32]);
|
|
||||||
// Resumable state (JSON). Large lists live in the repository, not here.
|
|
||||||
$t->addColumn('state', Types::TEXT, ['notnull' => true]);
|
|
||||||
$t->addColumn('stats', Types::TEXT, ['notnull' => false]);
|
|
||||||
$t->addColumn('error', Types::TEXT, ['notnull' => false]);
|
|
||||||
$t->addColumn('snapshot_id', Types::STRING, ['notnull' => false, 'length' => 32]);
|
|
||||||
$t->addColumn('started_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('updated_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('finished_at', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
|
||||||
$t->setPrimaryKey(['id']);
|
|
||||||
$t->addIndex(['target_id', 'status'], 'ngb_runs_target_status');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$schema->hasTable('ngb_snapshots')) {
|
|
||||||
// Cache of the snapshots in each repository, for listing without reading the target.
|
|
||||||
$t = $schema->createTable('ngb_snapshots');
|
|
||||||
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('snapshot_id', Types::STRING, ['notnull' => true, 'length' => 32]);
|
|
||||||
$t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]);
|
|
||||||
$t->addColumn('label', Types::STRING, ['notnull' => false, 'length' => 255]);
|
|
||||||
$t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('files', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
|
||||||
$t->addColumn('bytes', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
|
||||||
$t->setPrimaryKey(['id']);
|
|
||||||
$t->addUniqueIndex(['target_id', 'snapshot_id'], 'ngb_snap_target_snap');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Migration;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use OCP\DB\ISchemaWrapper;
|
|
||||||
use OCP\DB\Types;
|
|
||||||
use OCP\Migration\IOutput;
|
|
||||||
use OCP\Migration\SimpleMigrationStep;
|
|
||||||
|
|
||||||
/** Local cache of the per-pack index files, so opening a repository does not download them all. */
|
|
||||||
final class Version000002Date20261002010000 extends SimpleMigrationStep {
|
|
||||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
|
||||||
/** @var ISchemaWrapper $schema */
|
|
||||||
$schema = $schemaClosure();
|
|
||||||
if (!$schema->hasTable('ngb_index')) {
|
|
||||||
$t = $schema->createTable('ngb_index');
|
|
||||||
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->addColumn('repository_id', Types::STRING, ['notnull' => true, 'length' => 32]);
|
|
||||||
$t->addColumn('pack_id', Types::STRING, ['notnull' => true, 'length' => 32]);
|
|
||||||
// Plain JSON list of blob entries. Blob ids are keyed hashes and reveal no content.
|
|
||||||
$t->addColumn('entries', Types::TEXT, ['notnull' => true]);
|
|
||||||
$t->setPrimaryKey(['id']);
|
|
||||||
$t->addUniqueIndex(['repository_id', 'pack_id'], 'ngb_index_repo_pack');
|
|
||||||
}
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Migration;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use OCP\DB\ISchemaWrapper;
|
|
||||||
use OCP\DB\Types;
|
|
||||||
use OCP\Migration\IOutput;
|
|
||||||
use OCP\Migration\SimpleMigrationStep;
|
|
||||||
|
|
||||||
/** Append-only locations: NG Backup never deletes anything there (cleanup by the location's own lifecycle rules). */
|
|
||||||
final class Version000003Date20261002020000 extends SimpleMigrationStep {
|
|
||||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
|
||||||
/** @var ISchemaWrapper $schema */
|
|
||||||
$schema = $schemaClosure();
|
|
||||||
$t = $schema->getTable('ngb_targets');
|
|
||||||
if (!$t->hasColumn('append_only')) {
|
|
||||||
$t->addColumn('append_only', Types::BOOLEAN, ['notnull' => false, 'default' => false]);
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Migration;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use OCP\DB\ISchemaWrapper;
|
|
||||||
use OCP\DB\Types;
|
|
||||||
use OCP\Migration\IOutput;
|
|
||||||
use OCP\Migration\SimpleMigrationStep;
|
|
||||||
|
|
||||||
/** Optional size limit per location (cost control; S3 has no bucket quota). */
|
|
||||||
final class Version000004Date20261002030000 extends SimpleMigrationStep {
|
|
||||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
|
||||||
/** @var ISchemaWrapper $schema */
|
|
||||||
$schema = $schemaClosure();
|
|
||||||
$t = $schema->getTable('ngb_targets');
|
|
||||||
if (!$t->hasColumn('max_bytes')) {
|
|
||||||
$t->addColumn('max_bytes', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Migration;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use OCP\DB\ISchemaWrapper;
|
|
||||||
use OCP\DB\Types;
|
|
||||||
use OCP\Migration\IOutput;
|
|
||||||
use OCP\Migration\SimpleMigrationStep;
|
|
||||||
|
|
||||||
/** Leases with an expiry: a killed process cannot block a location for longer than its lease. */
|
|
||||||
final class Version000005Date20261002040000 extends SimpleMigrationStep {
|
|
||||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
|
||||||
/** @var ISchemaWrapper $schema */
|
|
||||||
$schema = $schemaClosure();
|
|
||||||
if ($schema->hasTable('ngb_leases')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$t = $schema->createTable('ngb_leases');
|
|
||||||
$t->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 32]);
|
|
||||||
$t->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 128]);
|
|
||||||
$t->addColumn('mode', Types::STRING, ['notnull' => true, 'length' => 1]);
|
|
||||||
$t->addColumn('expires_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
|
||||||
$t->setPrimaryKey(['id']);
|
|
||||||
$t->addIndex(['name'], 'ngb_leases_name');
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Notification;
|
|
||||||
|
|
||||||
use OCA\NgBackup\AppInfo\Application;
|
|
||||||
use OCP\IURLGenerator;
|
|
||||||
use OCP\L10N\IFactory;
|
|
||||||
use OCP\Notification\INotification;
|
|
||||||
use OCP\Notification\INotifier;
|
|
||||||
use OCP\Notification\UnknownNotificationException;
|
|
||||||
|
|
||||||
final class Notifier implements INotifier {
|
|
||||||
public function __construct(
|
|
||||||
private IFactory $l10n,
|
|
||||||
private IURLGenerator $url,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getID(): string {
|
|
||||||
return Application::APP_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): string {
|
|
||||||
return $this->l10n->get(Application::APP_ID)->t('NG Backup');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function prepare(INotification $notification, string $languageCode): INotification {
|
|
||||||
if ($notification->getApp() !== Application::APP_ID) {
|
|
||||||
throw new UnknownNotificationException();
|
|
||||||
}
|
|
||||||
$l = $this->l10n->get(Application::APP_ID, $languageCode);
|
|
||||||
$p = $notification->getSubjectParameters();
|
|
||||||
switch ($notification->getSubject()) {
|
|
||||||
case 'run_failed':
|
|
||||||
$notification->setParsedSubject($p['kind'] === 'restore'
|
|
||||||
? $l->t('Restore from %s failed', [$p['target']])
|
|
||||||
: $l->t('Backup to %s failed', [$p['target']]));
|
|
||||||
$notification->setParsedMessage((string)($p['error'] ?? ''));
|
|
||||||
break;
|
|
||||||
case 'quota_warning':
|
|
||||||
$notification->setParsedSubject($l->t('Backup location %1$s is at %2$s%% of its limit (%3$s of %4$s)', [$p['target'], $p['percent'], $p['used'], $p['limit']]));
|
|
||||||
$notification->setParsedMessage($l->t('Backups stop when the limit is reached. Raise the limit, or let the retention policy remove older backups.'));
|
|
||||||
break;
|
|
||||||
case 'backup_stale':
|
|
||||||
$notification->setParsedSubject($l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
|
|
||||||
$notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
|
|
||||||
break;
|
|
||||||
case 'security_event':
|
|
||||||
$who = (string)($p['actor'] ?? '');
|
|
||||||
$notification->setParsedSubject(match ($p['event'] ?? '') {
|
|
||||||
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
|
|
||||||
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
|
|
||||||
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
|
|
||||||
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
|
|
||||||
'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']),
|
|
||||||
'snapshot_untrashed' => $l->t('%1$s restored snapshot %2$s of %3$s from the trash', [$who, $p['snapshot'] ?? '', $p['target'] ?? '']),
|
|
||||||
'catalog_trusted' => $l->t('%1$s accepted an older state of backup location %2$s (catalog generation %3$s)', [$who, $p['target'] ?? '', $p['generation'] ?? '']),
|
|
||||||
'slot_added' => $l->t('%1$s added backup passphrase slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
|
||||||
'slot_replaced' => $l->t('%1$s set a new passphrase for backup key slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
|
||||||
'slot_removed' => $l->t('%1$s removed backup passphrase slot %2$s; download and confirm the new recovery kit', [$who, $p['slot'] ?? '']),
|
|
||||||
default => $l->t('NG Backup security event'),
|
|
||||||
});
|
|
||||||
$notification->setParsedMessage($l->t('If this was not you or another administrator, check who has admin access.'));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new UnknownNotificationException();
|
|
||||||
}
|
|
||||||
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']));
|
|
||||||
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')));
|
|
||||||
return $notification;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,66 +24,22 @@ final class BlobIndex {
|
|||||||
/** @var array<string, true> blobs added in this run but whose pack is not uploaded yet */
|
/** @var array<string, true> blobs added in this run but whose pack is not uploaded yet */
|
||||||
private array $pending = [];
|
private array $pending = [];
|
||||||
|
|
||||||
private ?IIndexCache $cache = null;
|
|
||||||
private string $repositoryId = '';
|
|
||||||
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
|
|
||||||
public int $downloaded = 0;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private IBackend $backend,
|
private IBackend $backend,
|
||||||
private StreamCipher $cipher,
|
private StreamCipher $cipher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setCache(IIndexCache $cache, string $repositoryId): void {
|
public function load(): void {
|
||||||
$this->cache = $cache;
|
|
||||||
$this->repositoryId = $repositoryId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the index: one listing of index/, entries from the local cache where available,
|
|
||||||
* and only the index files that are not cached yet are downloaded. Cached packs that no
|
|
||||||
* longer exist on the backend are dropped from the cache.
|
|
||||||
*/
|
|
||||||
public function load(bool $verify = false): void {
|
|
||||||
$this->blobs = [];
|
|
||||||
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
|
|
||||||
$present = [];
|
|
||||||
$this->downloaded = 0;
|
|
||||||
foreach ($this->backend->list('index') as $path) {
|
foreach ($this->backend->list('index') as $path) {
|
||||||
$packId = basename($path);
|
$packId = basename($path);
|
||||||
$present[$packId] = true;
|
$fh = $this->backend->get($path);
|
||||||
if (isset($cached[$packId])) {
|
$entries = json_decode($this->cipher->decryptString((string)stream_get_contents($fh), 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
|
||||||
$entries = $cached[$packId];
|
fclose($fh);
|
||||||
} else {
|
|
||||||
$fh = $this->backend->get($path);
|
|
||||||
$entries = json_decode($this->cipher->decryptString((string)stream_get_contents($fh), 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
fclose($fh);
|
|
||||||
$this->downloaded++;
|
|
||||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
|
||||||
}
|
|
||||||
foreach ($entries as $e) {
|
foreach ($entries as $e) {
|
||||||
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($verify && $this->cache !== null) {
|
|
||||||
// Refresh the cache from what was just verified.
|
|
||||||
$this->cache->remove($this->repositoryId, array_keys($this->cache->all($this->repositoryId)));
|
|
||||||
foreach ($this->packs() as $packId => $_) {
|
|
||||||
$entries = [];
|
|
||||||
foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) {
|
|
||||||
if ($pack === $packId) {
|
|
||||||
$entries[] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->cache->put($this->repositoryId, $packId, $entries);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$stale = array_keys(array_diff_key($cached, $present));
|
|
||||||
if ($stale !== []) {
|
|
||||||
$this->cache?->remove($this->repositoryId, $stale);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function has(string $id): bool {
|
public function has(string $id): bool {
|
||||||
@@ -112,42 +68,12 @@ final class BlobIndex {
|
|||||||
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
|
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
|
||||||
rewind($stream);
|
rewind($stream);
|
||||||
$this->backend->put('index/' . $packId, $stream);
|
$this->backend->put('index/' . $packId, $stream);
|
||||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
|
||||||
foreach ($entries as $e) {
|
foreach ($entries as $e) {
|
||||||
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
||||||
unset($this->pending[$e['id']]);
|
unset($this->pending[$e['id']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, list<array{id:string, offset:int, length:int}>> packId => blobs currently located in that pack */
|
|
||||||
public function packs(): array {
|
|
||||||
$packs = [];
|
|
||||||
foreach ($this->blobs as $id => [$pack, $offset, $length]) {
|
|
||||||
$packs[$pack][] = ['id' => $id, 'offset' => $offset, 'length' => $length];
|
|
||||||
}
|
|
||||||
return $packs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
|
|
||||||
public function removePack(string $packId): void {
|
|
||||||
$this->backend->delete('index/' . $packId);
|
|
||||||
$this->cache?->remove($this->repositoryId, [$packId]);
|
|
||||||
foreach ($this->blobs as $id => $loc) {
|
|
||||||
if ($loc[0] === $packId) {
|
|
||||||
unset($this->blobs[$id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bytes of all blobs in the index (encrypted size, i.e. what is stored in packs). */
|
|
||||||
public function storedBytes(): int {
|
|
||||||
$total = 0;
|
|
||||||
foreach ($this->blobs as [, , $length]) {
|
|
||||||
$total += $length;
|
|
||||||
}
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function count(): int {
|
public function count(): int {
|
||||||
return count($this->blobs);
|
return count($this->blobs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Repository;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Backend\IBackend;
|
|
||||||
use OCA\NgBackup\Crypto\StreamCipher;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chained catalog of a repository's state, against rollback and hidden snapshots.
|
|
||||||
*
|
|
||||||
* Every change to the set of snapshots or trash entries writes catalog/<generation>: the
|
|
||||||
* encrypted list of snapshot and trash ids plus the hash of the previous generation. The server
|
|
||||||
* keeps the newest generation it has seen (ICatalogAnchor). On open, the newest catalog on the
|
|
||||||
* location must be at least the anchored generation and lead back to it through the chain;
|
|
||||||
* snapshots listed in the catalog must exist. Otherwise the location was rolled back or
|
|
||||||
* tampered with, and the repository is refused.
|
|
||||||
*/
|
|
||||||
final class Catalog {
|
|
||||||
private const KEEP = 200; // generations kept on the location (enough to verify long gaps)
|
|
||||||
|
|
||||||
public int $gen = 0;
|
|
||||||
/** @var array<string, true> */
|
|
||||||
public array $snapshots = [];
|
|
||||||
/** @var array<string, true> */
|
|
||||||
public array $trash = [];
|
|
||||||
public string $hash = '';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private IBackend $backend,
|
|
||||||
private StreamCipher $cipher,
|
|
||||||
private string $repositoryId,
|
|
||||||
private ?ICatalogAnchor $anchor,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load and verify the newest catalog; create the first one for repositories that have none. */
|
|
||||||
public function load(callable $listSnapshots, callable $listTrash): void {
|
|
||||||
$gens = $this->generations();
|
|
||||||
if ($gens === []) {
|
|
||||||
// First use (new or pre-catalog repository): adopt the current state as generation 1.
|
|
||||||
$anchored = $this->anchor?->get($this->repositoryId);
|
|
||||||
if ($anchored !== null) {
|
|
||||||
throw new RepositoryException('The catalog of this repository has disappeared from the location (possible tampering)');
|
|
||||||
}
|
|
||||||
$this->snapshots = array_fill_keys($listSnapshots(), true);
|
|
||||||
$this->trash = array_fill_keys($listTrash(), true);
|
|
||||||
$this->write();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$head = max($gens);
|
|
||||||
[$data, $hash] = $this->read($head);
|
|
||||||
$anchored = $this->anchor?->get($this->repositoryId);
|
|
||||||
if ($anchored !== null) {
|
|
||||||
if ($head < $anchored['gen']) {
|
|
||||||
throw new RepositoryException(sprintf('The location shows catalog generation %d, but this server has seen %d: the backups were rolled back', $head, $anchored['gen']));
|
|
||||||
}
|
|
||||||
// Walk back to the anchored generation, checking every link.
|
|
||||||
[$cur, $curHash] = [$data, $hash];
|
|
||||||
for ($g = $head; $g > $anchored['gen']; $g--) {
|
|
||||||
[$prev, $prevHash] = $this->read($g - 1);
|
|
||||||
if (!hash_equals($prevHash, (string)$cur['prev'])) {
|
|
||||||
throw new RepositoryException("Catalog chain broken at generation $g (possible tampering)");
|
|
||||||
}
|
|
||||||
[$cur, $curHash] = [$prev, $prevHash];
|
|
||||||
}
|
|
||||||
if (!hash_equals($anchored['hash'], $curHash)) {
|
|
||||||
throw new RepositoryException('The catalog does not match what this server saw before (possible tampering)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->gen = $head;
|
|
||||||
$this->hash = $hash;
|
|
||||||
$this->snapshots = array_fill_keys($data['snapshots'], true);
|
|
||||||
$this->trash = array_fill_keys($data['trash'], true);
|
|
||||||
$this->anchor?->set($this->repositoryId, $this->gen, $this->hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Write the next generation after the in-memory state was changed. */
|
|
||||||
public function write(): void {
|
|
||||||
$next = $this->gen + 1;
|
|
||||||
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
|
|
||||||
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash)], JSON_THROW_ON_ERROR);
|
|
||||||
$encrypted = $this->cipher->encryptString($plain, $this->ad($next));
|
|
||||||
$stream = fopen('php://memory', 'w+b');
|
|
||||||
fwrite($stream, $encrypted);
|
|
||||||
rewind($stream);
|
|
||||||
$this->backend->put($this->path($next), $stream);
|
|
||||||
$this->gen = $next;
|
|
||||||
$this->hash = hash('sha256', $encrypted);
|
|
||||||
$this->anchor?->set($this->repositoryId, $this->gen, $this->hash);
|
|
||||||
$old = $next - self::KEEP;
|
|
||||||
if ($old > 0) {
|
|
||||||
$this->backend->delete($this->path($old));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{0: array, 1: string} decoded catalog and the hash of its encrypted bytes */
|
|
||||||
private function read(int $gen): array {
|
|
||||||
try {
|
|
||||||
$fh = $this->backend->get($this->path($gen));
|
|
||||||
} catch (\Throwable) {
|
|
||||||
throw new RepositoryException("Catalog generation $gen is missing (possible tampering)");
|
|
||||||
}
|
|
||||||
$encrypted = (string)stream_get_contents($fh);
|
|
||||||
fclose($fh);
|
|
||||||
$data = json_decode($this->cipher->decryptString($encrypted, $this->ad($gen)), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
if (($data['repository'] ?? '') !== $this->repositoryId || ($data['gen'] ?? 0) !== $gen) {
|
|
||||||
throw new RepositoryException("Catalog generation $gen belongs to another repository or position");
|
|
||||||
}
|
|
||||||
return [$data, hash('sha256', $encrypted)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<int> */
|
|
||||||
private function generations(): array {
|
|
||||||
return array_map(fn ($p) => (int)basename($p), $this->backend->list('catalog'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function path(int $gen): string {
|
|
||||||
return sprintf('catalog/%010d', $gen);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ad(int $gen): string {
|
|
||||||
return 'catalog:' . $this->repositoryId . ':' . $gen;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Repository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the server remembers the newest catalog generation it has seen for a repository, outside
|
|
||||||
* the (untrusted) location. A location that presents an older generation, or a chain that does
|
|
||||||
* not lead back to this anchor, is rolled back or tampered with.
|
|
||||||
*/
|
|
||||||
interface ICatalogAnchor {
|
|
||||||
/** @return array{gen:int, hash:string}|null */
|
|
||||||
public function get(string $repositoryId): ?array;
|
|
||||||
|
|
||||||
public function set(string $repositoryId, int $gen, string $hash): void;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Repository;
|
|
||||||
|
|
||||||
/** Optional local cache of pack index entries (the repository's index/ files stay authoritative). */
|
|
||||||
interface IIndexCache {
|
|
||||||
/** @return array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> packId => entries */
|
|
||||||
public function all(string $repositoryId): array;
|
|
||||||
|
|
||||||
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
|
||||||
public function put(string $repositoryId, string $packId, array $entries): void;
|
|
||||||
|
|
||||||
/** @param list<string> $packIds */
|
|
||||||
public function remove(string $repositoryId, array $packIds): void;
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -50,27 +49,11 @@ final class PackWriter {
|
|||||||
return strlen($encrypted);
|
return strlen($encrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add an already encrypted blob as-is (repacking: no decryption needed, the blob is bound to its id, not to a pack). */
|
|
||||||
public function addRaw(string $id, string $encrypted, int $rawLength, int $flags): void {
|
|
||||||
fwrite($this->buffer, $encrypted);
|
|
||||||
$this->entries[] = ['id' => $id, 'offset' => $this->size, 'length' => strlen($encrypted), 'raw' => $rawLength, 'flags' => $flags];
|
|
||||||
$this->size += strlen($encrypted);
|
|
||||||
if ($this->size >= $this->targetSize) {
|
|
||||||
$this->flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upload the current pack (if any) and record it in the index. */
|
/** Upload the current pack (if any) and record it in the index. */
|
||||||
public function flush(): void {
|
public function flush(): void {
|
||||||
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)));
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
}
|
|
||||||
+18
-337
@@ -32,8 +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 function __construct(
|
private function __construct(
|
||||||
private IBackend $backend,
|
private IBackend $backend,
|
||||||
@@ -45,75 +43,17 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
||||||
$keys = KeyRing::generate();
|
|
||||||
return self::initWithKey($backend, $keys, $keys->wrap($passphrase));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a repository with an existing master key. $wrappedKey is the passphrase-wrapped
|
|
||||||
* copy (KeyRing::wrap) stored in the repository config, so the repository can be opened
|
|
||||||
* with the passphrase from the recovery kit even when the server is gone.
|
|
||||||
*/
|
|
||||||
public static function initWithKey(IBackend $backend, KeyRing $keys, array $wrappedKey, ?ICatalogAnchor $anchor = null): self {
|
|
||||||
if ($backend->exists('config')) {
|
if ($backend->exists('config')) {
|
||||||
throw new RepositoryException('A repository already exists at this location');
|
throw new RepositoryException('A repository already exists at this location');
|
||||||
}
|
}
|
||||||
|
$keys = KeyRing::generate();
|
||||||
$id = bin2hex(random_bytes(16));
|
$id = bin2hex(random_bytes(16));
|
||||||
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey,
|
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $keys->wrap($passphrase)];
|
||||||
'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check'))];
|
|
||||||
self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||||
$repo = new self($backend, $keys, $id);
|
return new self($backend, $keys, $id);
|
||||||
$repo->loadCatalog($anchor);
|
|
||||||
return $repo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open with the master key the server holds (scheduled backups, no passphrase needed). */
|
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
||||||
public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null, ?ICatalogAnchor $anchor = null): self {
|
|
||||||
$config = self::readConfig($backend);
|
|
||||||
try {
|
|
||||||
$ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id'];
|
|
||||||
} catch (\OCA\NgBackup\Crypto\CryptoException) {
|
|
||||||
$ok = false;
|
|
||||||
}
|
|
||||||
if (!$ok) {
|
|
||||||
throw new RepositoryException('This repository was created with a different key');
|
|
||||||
}
|
|
||||||
$repo = new self($backend, $keys, $config['id']);
|
|
||||||
if ($cache !== null) {
|
|
||||||
$repo->index->setCache($cache, $config['id']);
|
|
||||||
}
|
|
||||||
$repo->index->load();
|
|
||||||
$repo->loadCatalog($anchor);
|
|
||||||
return $repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loadCatalog(?ICatalogAnchor $anchor): void {
|
|
||||||
$this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor);
|
|
||||||
$this->catalog->load(
|
|
||||||
fn () => array_map('basename', $this->backend->list('snapshots')),
|
|
||||||
fn () => array_map('basename', $this->backend->list('trash/info')));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refuse uploads that would make the stored data larger than this (null = no limit). */
|
|
||||||
public function setMaxBytes(?int $maxBytes): void {
|
|
||||||
$this->maxBytes = $maxBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function storedBytes(): int {
|
|
||||||
return $this->index->storedBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current catalog generation (for status and tests). */
|
|
||||||
public function catalogGeneration(): int {
|
|
||||||
return $this->catalog->gen;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Index files downloaded when this repository was opened (0 when the local cache was complete). */
|
|
||||||
public function indexDownloads(): int {
|
|
||||||
return $this->index->downloaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function readConfig(IBackend $backend): array {
|
|
||||||
try {
|
try {
|
||||||
$fh = $backend->get('config');
|
$fh = $backend->get('config');
|
||||||
} catch (BackendException) {
|
} catch (BackendException) {
|
||||||
@@ -124,25 +64,8 @@ final class Repository {
|
|||||||
if (($config['format'] ?? 0) !== self::FORMAT) {
|
if (($config['format'] ?? 0) !== self::FORMAT) {
|
||||||
throw new RepositoryException('Unsupported repository format');
|
throw new RepositoryException('Unsupported repository format');
|
||||||
}
|
}
|
||||||
return $config;
|
$repo = new self($backend, KeyRing::unwrap($config['key'], $passphrase), $config['id']);
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace the passphrase-wrapped key slots in this repository's config (after a slot change). */
|
|
||||||
public function updateWrappedKey(array $wrappedKey): void {
|
|
||||||
$config = self::readConfig($this->backend);
|
|
||||||
$config['key'] = $wrappedKey;
|
|
||||||
self::putString($this->backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function id(): string {
|
|
||||||
return $this->repositoryId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
|
||||||
$config = self::readConfig($backend);
|
|
||||||
$repo = new self($backend, KeyRing::unwrapAny($config['key'], $passphrase), $config['id']);
|
|
||||||
$repo->index->load();
|
$repo->index->load();
|
||||||
$repo->loadCatalog(null); // disaster recovery on a new server: no anchor yet, trust the newest valid chain
|
|
||||||
return $repo;
|
return $repo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +78,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');
|
||||||
@@ -183,17 +106,12 @@ final class Repository {
|
|||||||
rewind($encrypted);
|
rewind($encrypted);
|
||||||
$this->backend->put('snapshots/' . $snapshotId, $encrypted);
|
$this->backend->put('snapshots/' . $snapshotId, $encrypted);
|
||||||
fclose($tree);
|
fclose($tree);
|
||||||
$this->catalog->snapshots[$snapshotId] = true;
|
|
||||||
$this->catalog->write();
|
|
||||||
|
|
||||||
return ['snapshot' => $snapshotId] + $stats;
|
return ['snapshot' => $snapshotId] + $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restore a snapshot (or only paths starting with $prefix) into a local directory. */
|
/** Restore a snapshot (or only paths starting with $prefix) into a local directory. */
|
||||||
public function restore(string $snapshotId, string $target, string $prefix = ''): int {
|
public function restore(string $snapshotId, string $target, string $prefix = ''): int {
|
||||||
if ($prefix !== '' && !self::isSafePath(rtrim($prefix, '/'))) {
|
|
||||||
throw new RepositoryException('Invalid path prefix');
|
|
||||||
}
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($this->streamEntries($snapshotId) as $entry) {
|
foreach ($this->streamEntries($snapshotId) as $entry) {
|
||||||
if ($prefix !== '' && !str_starts_with($entry['p'], $prefix)) {
|
if ($prefix !== '' && !str_starts_with($entry['p'], $prefix)) {
|
||||||
@@ -203,19 +121,10 @@ final class Repository {
|
|||||||
if (!is_dir(dirname($dest))) {
|
if (!is_dir(dirname($dest))) {
|
||||||
mkdir(dirname($dest), 0700, true);
|
mkdir(dirname($dest), 0700, true);
|
||||||
}
|
}
|
||||||
// Defence in depth: the resolved directory must stay below the target (no symlink escapes).
|
|
||||||
$base = realpath($target);
|
|
||||||
$dir = realpath(dirname($dest));
|
|
||||||
if ($base === false || $dir === false || ($dir !== $base && !str_starts_with($dir, $base . '/')) || is_link($dest)) {
|
|
||||||
throw new RepositoryException('Refusing to write outside the restore directory: ' . $entry['p']);
|
|
||||||
}
|
|
||||||
// Write next to the destination and rename after all blobs verified: at most one file
|
// Write next to the destination and rename after all blobs verified: at most one file
|
||||||
// is ever present twice.
|
// is ever present twice.
|
||||||
$tmp = $dest . '.ngb-restore-' . bin2hex(random_bytes(6));
|
$tmp = $dest . '.ngb-restore';
|
||||||
$out = @fopen($tmp, 'xb'); // fails if anything (including a symlink) already exists there
|
$out = fopen($tmp, 'wb');
|
||||||
if ($out === false) {
|
|
||||||
throw new RepositoryException('Cannot create a temporary file for ' . $entry['p']);
|
|
||||||
}
|
|
||||||
foreach ($entry['b'] as $id) {
|
foreach ($entry['b'] as $id) {
|
||||||
fwrite($out, $this->loadBlob($id));
|
fwrite($out, $this->loadBlob($id));
|
||||||
}
|
}
|
||||||
@@ -236,7 +145,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 +177,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,8 +201,6 @@ final class Repository {
|
|||||||
fclose($tree);
|
fclose($tree);
|
||||||
rewind($encrypted);
|
rewind($encrypted);
|
||||||
$this->backend->put('snapshots/' . $snapshotId, $encrypted);
|
$this->backend->put('snapshots/' . $snapshotId, $encrypted);
|
||||||
$this->catalog->snapshots[$snapshotId] = true;
|
|
||||||
$this->catalog->write();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, array{s:int, m:int, b:list<string>}> */
|
/** @return array<string, array{s:int, m:int, b:list<string>}> */
|
||||||
@@ -311,19 +218,6 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Relative path of non-empty segments, without ".", "..", NUL, backslash or control characters. */
|
|
||||||
public static function isSafePath(string $path): bool {
|
|
||||||
if ($path === '' || str_starts_with($path, '/') || preg_match('/[\x00-\x1f\\\\]/', $path)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
foreach (explode('/', $path) as $segment) {
|
|
||||||
if ($segment === '' || $segment === '.' || $segment === '..') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small encrypted objects (manifests). */
|
/** Small encrypted objects (manifests). */
|
||||||
public function putObject(string $path, string $data): void {
|
public function putObject(string $path, string $data): void {
|
||||||
self::putString($this->backend, $path, $this->cipher->encryptString($data, 'object:' . $path));
|
self::putString($this->backend, $path, $this->cipher->encryptString($data, 'object:' . $path));
|
||||||
@@ -336,200 +230,9 @@ final class Repository {
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return list<string> */
|
||||||
* Move a snapshot (and its database dump) to the trash on the location. Its data stays in
|
|
||||||
* use until purgeTrash() removes it after the deletion delay, so a malicious or mistaken
|
|
||||||
* cleanup can be undone with untrash() within that time.
|
|
||||||
*/
|
|
||||||
public function forget(string $snapshotId, string $by = ''): void {
|
|
||||||
$meta = $this->snapshotMeta($snapshotId);
|
|
||||||
$dbInTrash = null;
|
|
||||||
if (isset($meta['db']) && is_string($meta['db']) && $this->backend->exists($meta['db'])) {
|
|
||||||
// Its encryption is bound to its path (object:db/...), which changes on a move, so it
|
|
||||||
// must be decrypted and re-encrypted under the new path, not moved as raw bytes.
|
|
||||||
$dbInTrash = 'trash/' . $meta['db'];
|
|
||||||
$this->putObject($dbInTrash, $this->getObject($meta['db']));
|
|
||||||
$this->backend->delete($meta['db']);
|
|
||||||
}
|
|
||||||
// The snapshot object is bound to its id (not its path), so it can be moved as raw bytes.
|
|
||||||
$this->backend->move('snapshots/' . $snapshotId, 'trash/snapshots/' . $snapshotId);
|
|
||||||
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
|
|
||||||
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
|
|
||||||
unset($this->catalog->snapshots[$snapshotId]);
|
|
||||||
$this->catalog->trash[$snapshotId] = true;
|
|
||||||
$this->catalog->write();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Undo forget(): move a snapshot back from the trash. */
|
|
||||||
public function untrash(string $snapshotId): void {
|
|
||||||
$info = $this->trashInfo($snapshotId);
|
|
||||||
if ($info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash'])) {
|
|
||||||
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
|
|
||||||
$this->backend->delete($info['dbInTrash']);
|
|
||||||
}
|
|
||||||
$this->backend->move('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId);
|
|
||||||
$this->catalog->snapshots[$snapshotId] = true;
|
|
||||||
unset($this->catalog->trash[$snapshotId]);
|
|
||||||
$this->catalog->write();
|
|
||||||
$this->backend->delete('trash/info/' . $snapshotId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, array{forgottenAt:int, by:string, db:?string, dbInTrash:?string, time:?string, label:string}> */
|
|
||||||
public function trash(): array {
|
|
||||||
$out = [];
|
|
||||||
foreach (array_keys($this->catalog->trash) as $id) {
|
|
||||||
try {
|
|
||||||
$out[$id] = $this->trashInfo($id);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
throw new RepositoryException("Trash entry $id listed in the catalog is missing on the location (possible tampering)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Permanently delete trash entries older than $delaySeconds. @return list<string> purged snapshot ids */
|
|
||||||
public function purgeTrash(int $delaySeconds): array {
|
|
||||||
$purged = [];
|
|
||||||
foreach ($this->trash() as $id => $info) {
|
|
||||||
if (time() - $info['forgottenAt'] < $delaySeconds) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($info['dbInTrash'] !== null) {
|
|
||||||
$this->backend->delete($info['dbInTrash']);
|
|
||||||
}
|
|
||||||
$this->backend->delete('trash/snapshots/' . $id);
|
|
||||||
$this->backend->delete('trash/info/' . $id);
|
|
||||||
unset($this->catalog->trash[$id]);
|
|
||||||
$purged[] = $id;
|
|
||||||
}
|
|
||||||
if ($purged !== []) {
|
|
||||||
$this->catalog->write();
|
|
||||||
}
|
|
||||||
return $purged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function trashInfo(string $snapshotId): array {
|
|
||||||
return json_decode($this->getObject('trash/info/' . $snapshotId), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete packs no snapshot or user export uses any more, and repack packs where more than
|
|
||||||
* $repackThreshold of the bytes are unused. Must not run while a backup to this repository
|
|
||||||
* is in progress (its blobs are not referenced by a snapshot yet); callers hold the lock.
|
|
||||||
*
|
|
||||||
* @return array{packs:int, deleted:int, repacked:int, freedBytes:int, keptBlobs:int, unusedBlobs:int}
|
|
||||||
*/
|
|
||||||
public function prune(bool $dryRun = false, float $repackThreshold = 0.5, array $ignoreSnapshots = []): array {
|
|
||||||
// Deleting is decided on the authenticated index files on the location, never on the local cache.
|
|
||||||
$this->index->load(true);
|
|
||||||
$used = [];
|
|
||||||
$ignore = array_flip($ignoreSnapshots); // e.g. snapshots a dry run would forget first
|
|
||||||
foreach ($this->listSnapshots() as $sid) {
|
|
||||||
if (isset($ignore[$sid])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($this->streamEntries($sid) as $e) {
|
|
||||||
foreach ($e['b'] as $b) {
|
|
||||||
$used[$b] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$meta = $this->snapshotMeta($sid);
|
|
||||||
if (isset($meta['db']) && $this->backend->exists($meta['db'])) {
|
|
||||||
foreach (json_decode($this->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR)['tables'] as $t) {
|
|
||||||
foreach ($t['blobs'] as $b) {
|
|
||||||
$used[$b] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Snapshots in the trash keep their data until purgeTrash() removed them.
|
|
||||||
foreach ($this->trash() as $sid => $info) {
|
|
||||||
foreach ($this->streamEntries($sid, 'trash/snapshots/') as $e) {
|
|
||||||
foreach ($e['b'] as $b) {
|
|
||||||
$used[$b] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash'])) {
|
|
||||||
foreach (json_decode($this->getObject($info['dbInTrash']), true, 512, JSON_THROW_ON_ERROR)['tables'] as $t) {
|
|
||||||
foreach ($t['blobs'] as $b) {
|
|
||||||
$used[$b] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($this->backend->list('users') as $path) {
|
|
||||||
foreach (json_decode($this->getObject($path), true, 512, JSON_THROW_ON_ERROR)['entries'] as $e) {
|
|
||||||
foreach ($e['b'] ?? [] as $b) {
|
|
||||||
$used[$b] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stats = ['packs' => 0, 'deleted' => 0, 'repacked' => 0, 'freedBytes' => 0, 'keptBlobs' => count($used), 'unusedBlobs' => 0];
|
|
||||||
$packs = $this->index->packs();
|
|
||||||
$stats['packs'] = count($packs);
|
|
||||||
$writer = null;
|
|
||||||
$toRemove = [];
|
|
||||||
foreach ($packs as $packId => $blobs) {
|
|
||||||
$total = 0;
|
|
||||||
$unused = 0;
|
|
||||||
foreach ($blobs as $b) {
|
|
||||||
$total += $b['length'];
|
|
||||||
if (!isset($used[$b['id']])) {
|
|
||||||
$unused += $b['length'];
|
|
||||||
$stats['unusedBlobs']++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($unused === 0 || ($unused < $total && $unused / $total <= $repackThreshold)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$stats['freedBytes'] += $unused;
|
|
||||||
if ($unused === $total) {
|
|
||||||
$stats['deleted']++;
|
|
||||||
} else {
|
|
||||||
$stats['repacked']++;
|
|
||||||
if (!$dryRun) {
|
|
||||||
// No size limit here: repacking is how space is freed.
|
|
||||||
$writer ??= new PackWriter($this->backend, $this->cipher, $this->index);
|
|
||||||
$path = 'packs/' . substr($packId, 0, 2) . '/' . $packId;
|
|
||||||
foreach ($blobs as $b) {
|
|
||||||
if (isset($used[$b['id']])) {
|
|
||||||
[, , , $raw, $flags] = $this->index->get($b['id']);
|
|
||||||
$writer->addRaw($b['id'], $this->backend->getRange($path, $b['offset'], $b['length']), $raw, $flags);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$toRemove[] = $packId;
|
|
||||||
}
|
|
||||||
if ($dryRun) {
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
// New packs and their index entries are durable before any old pack is removed.
|
|
||||||
$writer?->flush();
|
|
||||||
foreach ($toRemove as $packId) {
|
|
||||||
$this->index->removePack($packId);
|
|
||||||
$this->backend->delete('packs/' . substr($packId, 0, 2) . '/' . $packId);
|
|
||||||
}
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshots according to the verified catalog. A snapshot the catalog lists but the location
|
|
||||||
* no longer has means it was deleted or hidden behind NG Backup's back.
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function listSnapshots(): array {
|
public function listSnapshots(): array {
|
||||||
$present = array_flip(array_map('basename', $this->backend->list('snapshots')));
|
return array_map('basename', $this->backend->list('snapshots'));
|
||||||
$listed = array_keys($this->catalog->snapshots);
|
|
||||||
foreach ($listed as $id) {
|
|
||||||
if (!isset($present[$id])) {
|
|
||||||
throw new RepositoryException("Snapshot $id is missing on the location (deleted or hidden outside NG Backup)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort($listed);
|
|
||||||
return $listed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function blobCount(): int {
|
public function blobCount(): int {
|
||||||
@@ -569,7 +272,7 @@ final class Repository {
|
|||||||
private function storeBlob(string $data, PackWriter $packs, array &$stats): string {
|
private function storeBlob(string $data, PackWriter $packs, array &$stats): string {
|
||||||
$id = $this->keys->blobId($data);
|
$id = $this->keys->blobId($data);
|
||||||
if ($this->index->has($id)) {
|
if ($this->index->has($id)) {
|
||||||
$stats['dupBlobs'] = ($stats['dupBlobs'] ?? 0) + 1;
|
$stats['dupBlobs']++;
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
$flags = 0;
|
$flags = 0;
|
||||||
@@ -579,8 +282,8 @@ final class Repository {
|
|||||||
$payload = $deflated;
|
$payload = $deflated;
|
||||||
$flags |= self::FLAG_DEFLATE;
|
$flags |= self::FLAG_DEFLATE;
|
||||||
}
|
}
|
||||||
$stats['uploaded'] = ($stats['uploaded'] ?? 0) + $packs->add($id, $this->cipher->encryptString($payload, 'blob:' . $id), strlen($data), $flags);
|
$stats['uploaded'] += $packs->add($id, $this->cipher->encryptString($payload, 'blob:' . $id), strlen($data), $flags);
|
||||||
$stats['newBlobs'] = ($stats['newBlobs'] ?? 0) + 1;
|
$stats['newBlobs']++;
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,38 +307,16 @@ final class Repository {
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> entries of a snapshot, in stored order */
|
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> */
|
||||||
public function entries(string $snapshotId): \Generator {
|
private function streamEntries(string $snapshotId): \Generator {
|
||||||
return $this->streamEntries($snapshotId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The meta record (first line) of a snapshot. */
|
|
||||||
public function snapshotMeta(string $snapshotId): array {
|
|
||||||
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
||||||
$fh = $this->backend->get('snapshots/' . $snapshotId);
|
$fh = $this->backend->get('snapshots/' . $snapshotId);
|
||||||
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
|
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
rewind($plain);
|
rewind($plain);
|
||||||
$meta = json_decode((string)fgets($plain), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
fclose($plain);
|
|
||||||
return $meta;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> */
|
|
||||||
private function streamEntries(string $snapshotId, string $dir = 'snapshots/'): \Generator {
|
|
||||||
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
|
||||||
$fh = $this->backend->get($dir . $snapshotId);
|
|
||||||
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
|
|
||||||
fclose($fh);
|
|
||||||
rewind($plain);
|
|
||||||
fgets($plain); // meta line
|
fgets($plain); // meta line
|
||||||
while (($line = fgets($plain)) !== false) {
|
while (($line = fgets($plain)) !== false) {
|
||||||
$entry = json_decode($line, true, 512, JSON_THROW_ON_ERROR);
|
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
|
||||||
if (!is_array($entry) || !is_string($entry['p'] ?? null) || !self::isSafePath($entry['p'])
|
|
||||||
|| !is_int($entry['s'] ?? null) || !is_array($entry['b'] ?? null)) {
|
|
||||||
throw new RepositoryException('Invalid entry in snapshot ' . $snapshotId);
|
|
||||||
}
|
|
||||||
yield $entry;
|
|
||||||
}
|
}
|
||||||
fclose($plain);
|
fclose($plain);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
|
||||||
|
|
||||||
use OCA\NgBackup\AppInfo\Application;
|
|
||||||
use OCA\NgBackup\Db\Run;
|
|
||||||
use OCA\NgBackup\Db\RunMapper;
|
|
||||||
use OCA\NgBackup\Db\Target;
|
|
||||||
use OCP\IGroupManager;
|
|
||||||
use OCP\Notification\IManager;
|
|
||||||
|
|
||||||
/** Notifies administrators about failed runs and locations without a recent successful backup. */
|
|
||||||
final class AlertService {
|
|
||||||
public const STALE_AFTER = 2 * 86400;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private IManager $notifications,
|
|
||||||
private IGroupManager $groups,
|
|
||||||
private RunMapper $runs,
|
|
||||||
private \OCP\IAppConfig $appConfig,
|
|
||||||
private \OCP\EventDispatcher\IEventDispatcher $events,
|
|
||||||
private \OCP\IUserSession $session,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security-relevant change (recovery kit downloaded, location removed, append-only off,
|
|
||||||
* retention changed, snapshots forgotten): written to the audit log (admin_audit) and sent
|
|
||||||
* to every administrator, so a single compromised admin account cannot do it unnoticed.
|
|
||||||
*
|
|
||||||
* @param array<string, string> $params
|
|
||||||
*/
|
|
||||||
public function actor(): string {
|
|
||||||
return $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function securityEvent(string $event, array $params = [], bool $notify = true): void {
|
|
||||||
$actor = $this->actor();
|
|
||||||
$params = ['actor' => $actor] + $params;
|
|
||||||
$this->events->dispatchTyped(new \OCP\Log\Audit\CriticalActionPerformedEvent(
|
|
||||||
'NG Backup: %s by %s ' . json_encode(array_diff_key($params, ['actor' => 1]), JSON_UNESCAPED_SLASHES), ['event' => $event, 'actor' => $actor]));
|
|
||||||
if ($notify) {
|
|
||||||
$this->notifyAdmins('security_event', $event . '-' . time(), ['event' => $event] + $params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function runFailed(Run $run, string $targetName): void {
|
|
||||||
$this->notifyAdmins('run_failed', 'run-' . $run->getId(),
|
|
||||||
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remember how much is stored on a location (for the settings page) and warn administrators
|
|
||||||
* once a day when it is at 80% of its limit or more.
|
|
||||||
*/
|
|
||||||
public function storedBytes(Target $target, int $bytes): void {
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, 'stored_' . $target->getId(), (string)$bytes);
|
|
||||||
$limit = $target->getMaxBytes();
|
|
||||||
if ($limit === null || $limit <= 0 || $bytes < 0.8 * $limit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$key = 'quota_notified_' . $target->getId();
|
|
||||||
if ($this->appConfig->getValueString(Application::APP_ID, $key, '') === date('Y-m-d')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
|
|
||||||
$this->notifyAdmins('quota_warning', 'quota-' . $target->getId() . '-' . date('Y-m-d'), [
|
|
||||||
'target' => $target->getName(), 'percent' => (string)round($bytes / $limit * 100),
|
|
||||||
'used' => self::gib($bytes), 'limit' => self::gib($limit)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function gib(int $bytes): string {
|
|
||||||
$g = $bytes / 1073741824;
|
|
||||||
return sprintf($g < 10 ? '%.2f GiB' : '%.1f GiB', $g);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lastStoredBytes(Target $target): ?int {
|
|
||||||
$v = $this->appConfig->getValueString(Application::APP_ID, 'stored_' . $target->getId(), '');
|
|
||||||
return $v === '' ? null : (int)$v;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** At most one notification per location per day. */
|
|
||||||
public function checkStale(Target $target): void {
|
|
||||||
$last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
|
|
||||||
$lastTime = $last?->getFinishedAt() ?? $target->getCreatedAt();
|
|
||||||
if (time() - $lastTime < self::STALE_AFTER || $this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$key = 'stale_notified_' . $target->getId();
|
|
||||||
if ($this->appConfig->getValueString(Application::APP_ID, $key, '') === date('Y-m-d')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
|
|
||||||
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
|
|
||||||
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : 'never']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function notifyAdmins(string $subject, string $objectId, array $params): void {
|
|
||||||
foreach ($this->groups->get('admin')?->getUsers() ?? [] as $admin) {
|
|
||||||
$n = $this->notifications->createNotification();
|
|
||||||
$n->setApp(Application::APP_ID)
|
|
||||||
->setUser($admin->getUID())
|
|
||||||
->setDateTime(new \DateTime())
|
|
||||||
->setObject($subject, $objectId)
|
|
||||||
->setSubject($subject, $params);
|
|
||||||
$this->notifications->notify($n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Db\DbDumper;
|
|
||||||
use OCA\NgBackup\Db\Run;
|
|
||||||
use OCA\NgBackup\Db\RunMapper;
|
|
||||||
use OCA\NgBackup\Db\Snapshot;
|
|
||||||
use OCA\NgBackup\Db\SnapshotMapper;
|
|
||||||
use OCA\NgBackup\Db\Target;
|
|
||||||
use OCA\NgBackup\Job\BackupRun;
|
|
||||||
use OCP\App\IAppManager;
|
|
||||||
use OCP\IConfig;
|
|
||||||
use OCP\IDBConnection;
|
|
||||||
use OCP\Lock\ILockingProvider;
|
|
||||||
use OCP\Lock\LockedException;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full backups of this installation: database (one consistent transaction), config and the
|
|
||||||
* data directory, as a resumable run whose state is stored in ngb_runs after every step.
|
|
||||||
*
|
|
||||||
* Excluded by default: previews, log files, the updater's backups and each user's cache and
|
|
||||||
* temporary uploads (all regenerable or temporary).
|
|
||||||
*/
|
|
||||||
final class BackupService {
|
|
||||||
public const KIND_FULL = 'full';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private KeyService $keys,
|
|
||||||
private RunMapper $runs,
|
|
||||||
private SnapshotMapper $snapshots,
|
|
||||||
private IConfig $config,
|
|
||||||
private IDBConnection $db,
|
|
||||||
private IAppManager $appManager,
|
|
||||||
private ILockingProvider $locking,
|
|
||||||
private LoggerInterface $logger,
|
|
||||||
private AlertService $alerts,
|
|
||||||
private LeaseService $leases,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function start(Target $target, string $label = ''): Run {
|
|
||||||
if (!$this->keys->everConfirmed()) {
|
|
||||||
throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)');
|
|
||||||
}
|
|
||||||
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
|
||||||
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
|
||||||
}
|
|
||||||
// Check and create the run under the location's exclusive lock, so a cleanup cannot start
|
|
||||||
// in between (it takes the same lock and refuses while a run exists).
|
|
||||||
// A run that is already in progress is simply continued; no lease needed for that.
|
|
||||||
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
|
||||||
if ($running !== []) {
|
|
||||||
return $running[0];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $this->createRun($target, $label));
|
|
||||||
} catch (LockedException) {
|
|
||||||
throw new \RuntimeException('A cleanup or another start for ' . $target->getName() . ' is running; try again later');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createRun(Target $target, string $label): Run {
|
|
||||||
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
|
||||||
if ($running !== []) {
|
|
||||||
return $running[0];
|
|
||||||
}
|
|
||||||
$dataDir = rtrim($this->config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'), '/');
|
|
||||||
$parent = $this->snapshots->latest($target->getId(), self::KIND_FULL);
|
|
||||||
|
|
||||||
$files = BackupRun::start(
|
|
||||||
['config' => rtrim(\OC::$configDir, '/'), 'data' => $dataDir],
|
|
||||||
$parent?->getSnapshotId(),
|
|
||||||
$label,
|
|
||||||
$this->excludes($dataDir),
|
|
||||||
['kind' => self::KIND_FULL, 'nextcloud' => $this->config->getSystemValueString('version'),
|
|
||||||
'instanceid' => $this->config->getSystemValueString('instanceid'),
|
|
||||||
'dbtype' => $this->config->getSystemValueString('dbtype'),
|
|
||||||
'apps' => $this->installedApps()],
|
|
||||||
);
|
|
||||||
|
|
||||||
$run = new Run();
|
|
||||||
$run->setTargetId($target->getId());
|
|
||||||
$run->setKind(self::KIND_FULL);
|
|
||||||
$run->setStatus(Run::RUNNING);
|
|
||||||
$run->setPhase('db');
|
|
||||||
$run->setState(json_encode(['files' => $files], JSON_THROW_ON_ERROR));
|
|
||||||
$run->setSnapshotId($files['snapshot']);
|
|
||||||
$run->setStartedAt(time());
|
|
||||||
$run->setUpdatedAt(time());
|
|
||||||
return $this->runs->insert($run);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do one step of a run until $deadline. Safe to call concurrently: a second caller finds the
|
|
||||||
* run locked and returns immediately.
|
|
||||||
*/
|
|
||||||
public function step(Run $run, float $deadline): Run {
|
|
||||||
// Leases expire on their own: a step killed by a time limit or OOM blocks nothing for long.
|
|
||||||
$ttl = (int)min(3600, max(120, $deadline - microtime(true) + 120));
|
|
||||||
try {
|
|
||||||
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
|
|
||||||
} catch (LockedException) {
|
|
||||||
return $run; // another process is doing this step
|
|
||||||
}
|
|
||||||
// Shared lease on the location while this step writes: a cleanup (exclusive) cannot run now.
|
|
||||||
try {
|
|
||||||
$targetLease = $this->leases->acquire('ng_backup/target/' . $run->getTargetId(), LeaseService::SHARED, $ttl);
|
|
||||||
} catch (LockedException) {
|
|
||||||
$this->leases->release($runLease);
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
|
||||||
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
$target = $this->targets->get((string)$run->getTargetId());
|
|
||||||
$repo = $this->targets->repository($target);
|
|
||||||
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
if ($run->getPhase() === 'db') {
|
|
||||||
$dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_'));
|
|
||||||
$dbStats = $state['dbStats'] ?? [];
|
|
||||||
// With system cron the whole dump usually fits in one step (one transaction, fully
|
|
||||||
// consistent). Under web cron it continues per step; the snapshot then says so.
|
|
||||||
$dump = $dumper->dumpStep($repo, $state['dump'] ?? $dumper->startDump(), $deadline, $dbStats);
|
|
||||||
$state['dbStats'] = $dbStats;
|
|
||||||
if ($dump['done']) {
|
|
||||||
$manifest = $dumper->manifest($dump);
|
|
||||||
$repo->putObject('db/' . $state['files']['snapshot'], json_encode($manifest, JSON_THROW_ON_ERROR));
|
|
||||||
$state['files']['meta']['db'] = 'db/' . $state['files']['snapshot'];
|
|
||||||
$state['files']['meta']['dbConsistent'] = $manifest['consistent'];
|
|
||||||
$state['dbStats'] += ['tables' => count($manifest['tables']), 'rows' => array_sum(array_column($manifest['tables'], 'rows')),
|
|
||||||
'steps' => $dump['steps'], 'consistent' => $manifest['consistent']];
|
|
||||||
unset($state['dump']);
|
|
||||||
$run->setPhase('files');
|
|
||||||
} else {
|
|
||||||
$state['dump'] = $dump;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$state['files'] = BackupRun::step($repo, $state['files'], $deadline);
|
|
||||||
if ($state['files']['phase'] === 'done') {
|
|
||||||
$this->recordSnapshot($run, $state['files']);
|
|
||||||
$this->alerts->storedBytes($target, $repo->storedBytes());
|
|
||||||
$run->setStatus(Run::DONE);
|
|
||||||
$run->setPhase('done');
|
|
||||||
$run->setFinishedAt(time());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
|
|
||||||
$run->setStats(json_encode(['db' => $state['dbStats'] ?? null, 'files' => $state['files']['stats']], JSON_THROW_ON_ERROR));
|
|
||||||
$run->setUpdatedAt(time());
|
|
||||||
return $this->runs->update($run);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
|
||||||
$run->setStatus(Run::FAILED);
|
|
||||||
$run->setError($e->getMessage());
|
|
||||||
$run->setFinishedAt(time());
|
|
||||||
$run->setUpdatedAt(time());
|
|
||||||
$run = $this->runs->update($run);
|
|
||||||
try {
|
|
||||||
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
|
|
||||||
} catch (\Throwable) {
|
|
||||||
}
|
|
||||||
return $run;
|
|
||||||
} finally {
|
|
||||||
$this->leases->release($targetLease);
|
|
||||||
$this->leases->release($runLease);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run to completion in this process (occ, system cron), reporting progress. */
|
|
||||||
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
|
|
||||||
while ($run->getStatus() === Run::RUNNING) {
|
|
||||||
$before = $run->getUpdatedAt();
|
|
||||||
$run = $this->step($run, microtime(true) + $stepSeconds);
|
|
||||||
if ($run->getStatus() === Run::RUNNING && $run->getUpdatedAt() === $before) {
|
|
||||||
// Step was not possible (another process holds the run, or a crashed step's lease has
|
|
||||||
// not expired yet): wait instead of spinning.
|
|
||||||
sleep(5);
|
|
||||||
$run = $this->runs->find($run->getId());
|
|
||||||
}
|
|
||||||
if ($progress !== null) {
|
|
||||||
$progress($run);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancel(Run $run): Run {
|
|
||||||
$run->setStatus(Run::CANCELLED);
|
|
||||||
$run->setFinishedAt(time());
|
|
||||||
$run->setUpdatedAt(time());
|
|
||||||
return $this->runs->update($run);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordSnapshot(Run $run, array $files): void {
|
|
||||||
$s = new Snapshot();
|
|
||||||
$s->setTargetId($run->getTargetId());
|
|
||||||
$s->setSnapshotId($files['snapshot']);
|
|
||||||
$s->setKind($run->getKind());
|
|
||||||
$s->setLabel($files['label'] !== '' ? $files['label'] : null);
|
|
||||||
$s->setCreatedAt(time());
|
|
||||||
$s->setFiles($files['stats']['files']);
|
|
||||||
$s->setBytes($files['stats']['bytes']);
|
|
||||||
$this->snapshots->insert($s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
private function excludes(string $dataDir): array {
|
|
||||||
$instanceId = $this->config->getSystemValueString('instanceid');
|
|
||||||
$exclude = [
|
|
||||||
"data/appdata_$instanceId/preview",
|
|
||||||
"data/updater-$instanceId",
|
|
||||||
'data/nextcloud.log',
|
|
||||||
'data/nextcloud.log.*',
|
|
||||||
'data/audit.log',
|
|
||||||
'data/*/cache',
|
|
||||||
'data/*/uploads',
|
|
||||||
];
|
|
||||||
// SQLite: the database file is in the data directory; it is backed up by the logical dump,
|
|
||||||
// and copying the live file would give an inconsistent copy.
|
|
||||||
if ($this->config->getSystemValueString('dbtype') === 'sqlite3') {
|
|
||||||
$dbName = $this->config->getSystemValueString('dbname', 'owncloud');
|
|
||||||
$exclude[] = "data/$dbName.db";
|
|
||||||
$exclude[] = "data/$dbName.db-journal";
|
|
||||||
$exclude[] = "data/$dbName.db-wal";
|
|
||||||
$exclude[] = "data/$dbName.db-shm";
|
|
||||||
}
|
|
||||||
$logFile = $this->config->getSystemValueString('logfile', '');
|
|
||||||
if ($logFile !== '' && str_starts_with($logFile, $dataDir . '/')) {
|
|
||||||
$exclude[] = 'data/' . substr($logFile, strlen($dataDir) + 1);
|
|
||||||
}
|
|
||||||
// Never back up a local backup location into itself.
|
|
||||||
foreach ($this->targets->list() as $t) {
|
|
||||||
$opts = $this->targets->options($t);
|
|
||||||
$dir = rtrim((string)($opts['datadir'] ?? ''), '/');
|
|
||||||
if ($t->getBackend() === 'local' && $dir !== '' && str_starts_with($dir . '/', $dataDir . '/')) {
|
|
||||||
$exclude[] = trim('data/' . substr($dir, strlen($dataDir) + 1) . '/' . $t->getBasePath(), '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return array_values(array_unique($exclude));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, string> app id => version */
|
|
||||||
private function installedApps(): array {
|
|
||||||
$apps = [];
|
|
||||||
foreach ($this->appManager->getEnabledApps() as $app) {
|
|
||||||
$apps[$app] = $this->appManager->getAppVersion($app);
|
|
||||||
}
|
|
||||||
ksort($apps);
|
|
||||||
return $apps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
|
||||||
|
|
||||||
use OCA\NgBackup\AppInfo\Application;
|
|
||||||
use OCA\NgBackup\Crypto\KeyRing;
|
|
||||||
use OCP\IAppConfig;
|
|
||||||
use OCP\Security\ICrypto;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The installation's backup master key.
|
|
||||||
*
|
|
||||||
* - The server keeps the master key encrypted with Nextcloud's own secret (ICrypto), so
|
|
||||||
* scheduled backups run without anyone typing a passphrase. Whoever fully controls the server
|
|
||||||
* can therefore read the backups; whoever only gets the backup location cannot.
|
|
||||||
* - A copy wrapped with the admin's passphrase (Argon2id) goes into every repository config and
|
|
||||||
* into the recovery kit, so backups can be restored on a fresh server with the passphrase.
|
|
||||||
* - The admin must confirm having stored the recovery kit before the first backup runs.
|
|
||||||
*/
|
|
||||||
final class KeyService {
|
|
||||||
private const KEY_MASTER = 'master_key';
|
|
||||||
private const KEY_WRAPPED = 'master_key_wrapped';
|
|
||||||
private const KEY_CONFIRMED = 'recovery_kit_confirmed';
|
|
||||||
private const KEY_FINGERPRINT = 'master_key_fingerprint';
|
|
||||||
private const KEY_DELETE_DELAY = 'delete_delay_days';
|
|
||||||
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
|
||||||
private const KEY_KIT_CODE = 'recovery_kit_code';
|
|
||||||
public const DEFAULT_DELETE_DELAY = 7;
|
|
||||||
|
|
||||||
private ?KeyRing $cached = null;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private IAppConfig $appConfig,
|
|
||||||
private ICrypto $crypto,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isInitialized(): bool {
|
|
||||||
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int $deleteDelayDays how long forgotten snapshots stay in the trash on the location
|
|
||||||
* before their data is really deleted. Fixed here, at installation:
|
|
||||||
* there is deliberately no way to change it later from NG Backup.
|
|
||||||
*/
|
|
||||||
public function initialize(#[\SensitiveParameter] string $passphrase, int $deleteDelayDays = self::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): void {
|
|
||||||
if ($deleteDelayDays < 1 || $deleteDelayDays > 365) {
|
|
||||||
throw new \InvalidArgumentException('The deletion delay must be between 1 and 365 days');
|
|
||||||
}
|
|
||||||
if ($this->isInitialized()) {
|
|
||||||
throw new \RuntimeException('A backup key already exists');
|
|
||||||
}
|
|
||||||
if (strlen($passphrase) < 12) {
|
|
||||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
|
||||||
}
|
|
||||||
$keys = KeyRing::generate();
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
|
|
||||||
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
|
|
||||||
$this->cached = $keys;
|
|
||||||
$this->saveSlots([['slot' => 1, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $keys->wrap($passphrase)]]);
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
|
|
||||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $deleteDelayDays, true);
|
|
||||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true);
|
|
||||||
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED);
|
|
||||||
$this->cached = $keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function keyRing(): KeyRing {
|
|
||||||
if ($this->cached === null) {
|
|
||||||
$enc = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true);
|
|
||||||
if ($enc === '') {
|
|
||||||
throw new \RuntimeException('No backup key yet: run occ backup:key:init');
|
|
||||||
}
|
|
||||||
$this->cached = new KeyRing(base64_decode($this->crypto->decrypt($enc), true));
|
|
||||||
}
|
|
||||||
return $this->cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletion delay in days, fixed at installation (installations from before this setting get the default once). */
|
|
||||||
public function deleteDelayDays(): int {
|
|
||||||
$days = $this->appConfig->getValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, 0, true);
|
|
||||||
if ($days === 0 && $this->isInitialized()) {
|
|
||||||
$days = self::DEFAULT_DELETE_DELAY;
|
|
||||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $days, true);
|
|
||||||
}
|
|
||||||
return $days;
|
|
||||||
}
|
|
||||||
|
|
||||||
public const MAX_SLOTS = 3;
|
|
||||||
|
|
||||||
/** @return list<array{slot:int, label:string, created:int}> passphrase slots, without key material */
|
|
||||||
public function slots(): array {
|
|
||||||
return array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add a passphrase slot (e.g. one per person who must be able to restore). Returns the slot number. */
|
|
||||||
public function addSlot(#[\SensitiveParameter] string $passphrase, string $label): int {
|
|
||||||
self::checkPassphrase($passphrase);
|
|
||||||
$slots = $this->wrappedKey()['slots'];
|
|
||||||
if (count($slots) >= self::MAX_SLOTS) {
|
|
||||||
throw new \InvalidArgumentException('At most ' . self::MAX_SLOTS . ' passphrase slots');
|
|
||||||
}
|
|
||||||
$used = array_column($slots, 'slot');
|
|
||||||
$n = min(array_diff(range(1, self::MAX_SLOTS), $used));
|
|
||||||
$slots[] = ['slot' => $n, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $this->keyRing()->wrap($passphrase)];
|
|
||||||
usort($slots, fn ($a, $b) => $a['slot'] <=> $b['slot']);
|
|
||||||
$this->saveSlots($slots);
|
|
||||||
return $n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** New passphrase for a slot (e.g. its holder left). The master key and all backups stay the same. */
|
|
||||||
public function replaceSlot(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void {
|
|
||||||
self::checkPassphrase($passphrase);
|
|
||||||
$slots = $this->wrappedKey()['slots'];
|
|
||||||
$found = false;
|
|
||||||
foreach ($slots as &$s) {
|
|
||||||
if ($s['slot'] === $slot) {
|
|
||||||
$s['key'] = $this->keyRing()->wrap($passphrase);
|
|
||||||
$s['created'] = time();
|
|
||||||
if ($label !== null) {
|
|
||||||
$s['label'] = self::cleanLabel($label);
|
|
||||||
}
|
|
||||||
$found = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($s);
|
|
||||||
if (!$found) {
|
|
||||||
throw new \InvalidArgumentException("There is no slot $slot");
|
|
||||||
}
|
|
||||||
$this->saveSlots($slots);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeSlot(int $slot): void {
|
|
||||||
$slots = array_values(array_filter($this->wrappedKey()['slots'], fn ($s) => $s['slot'] !== $slot));
|
|
||||||
if (count($slots) === count($this->wrappedKey()['slots'])) {
|
|
||||||
throw new \InvalidArgumentException("There is no slot $slot");
|
|
||||||
}
|
|
||||||
if ($slots === []) {
|
|
||||||
throw new \InvalidArgumentException('The last passphrase slot cannot be removed');
|
|
||||||
}
|
|
||||||
$this->saveSlots($slots);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every change to the slots asks for a new recovery kit (and its confirmation). */
|
|
||||||
private function saveSlots(array $slots): void {
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode(['slots' => $slots], JSON_THROW_ON_ERROR), true, true);
|
|
||||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, $this->kitVersion() + 1, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function checkPassphrase(string $passphrase): void {
|
|
||||||
if (strlen($passphrase) < 12) {
|
|
||||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function cleanLabel(string $label): string {
|
|
||||||
$label = trim(preg_replace('/[\x00-\x1f]/', '', $label) ?? '');
|
|
||||||
return mb_substr($label === '' ? 'Slot' : $label, 0, 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function kitVersion(): int {
|
|
||||||
return $this->appConfig->getValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Passphrase-wrapped copy of the master key (for repository configs and the recovery kit). */
|
|
||||||
public function wrappedKey(): array {
|
|
||||||
$data = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
if (!isset($data['slots'])) {
|
|
||||||
$data = ['slots' => $data === [] ? [] : [['slot' => 1, 'label' => 'Slot 1', 'created' => 0, 'key' => $data]]];
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Short identifier of the key, shown in the UI and the kit (not secret). */
|
|
||||||
public function fingerprint(): string {
|
|
||||||
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_FINGERPRINT, '', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string} */
|
|
||||||
public function recoveryKit(): array {
|
|
||||||
return [
|
|
||||||
'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.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Short code printed in the current kit file. Typing it back when confirming proves the
|
|
||||||
* admin actually has the downloaded kit. A new code per kit version; the server only keeps
|
|
||||||
* its own copy encrypted with the instance secret.
|
|
||||||
*/
|
|
||||||
private function kitCode(): string {
|
|
||||||
$stored = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_KIT_CODE, '{}', true), true) ?: [];
|
|
||||||
if (($stored['version'] ?? 0) === $this->kitVersion() && isset($stored['code'])) {
|
|
||||||
return $this->crypto->decrypt($stored['code']);
|
|
||||||
}
|
|
||||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
|
|
||||||
$code = '';
|
|
||||||
for ($i = 0; $i < 8; $i++) {
|
|
||||||
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
|
||||||
}
|
|
||||||
$code = substr($code, 0, 4) . '-' . substr($code, 4);
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_KIT_CODE,
|
|
||||||
json_encode(['version' => $this->kitVersion(), 'code' => $this->crypto->encrypt($code)], JSON_THROW_ON_ERROR), true, true);
|
|
||||||
return $code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function checkKitCode(string $code): bool {
|
|
||||||
$stored = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_KIT_CODE, '{}', true), true) ?: [];
|
|
||||||
if (($stored['version'] ?? 0) !== $this->kitVersion() || !isset($stored['code'])) {
|
|
||||||
return false; // no kit downloaded for this version yet
|
|
||||||
}
|
|
||||||
$normalise = static fn (string $c) => strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $c) ?? '');
|
|
||||||
return hash_equals($normalise($this->crypto->decrypt($stored['code'])), $normalise($code));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirmRecoveryKit(string $uid): void {
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED,
|
|
||||||
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint(), 'kit_version' => $this->kitVersion()], JSON_THROW_ON_ERROR), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a recovery kit for this key was ever confirmed (any passphrase version). Backups
|
|
||||||
* keep running after a passphrase change, so changing it cannot be used to stop backups; the
|
|
||||||
* missing re-confirmation is reported as an error in the admin overview instead.
|
|
||||||
*/
|
|
||||||
public function everConfirmed(): bool {
|
|
||||||
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
|
|
||||||
$data = $raw === '' ? null : json_decode($raw, true);
|
|
||||||
return $data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Confirmation for the current key and kit version, or null. */
|
|
||||||
public function recoveryKitConfirmation(): ?array {
|
|
||||||
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
|
|
||||||
$data = $raw === '' ? null : json_decode($raw, true);
|
|
||||||
return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint() && ($data['kit_version'] ?? 1) === $this->kitVersion()) ? $data : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function fingerprintOf(KeyRing $keys): string {
|
|
||||||
$id = $keys->blobId('ng_backup key fingerprint');
|
|
||||||
return implode('-', str_split(substr($id, 0, 16), 4));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passphrase slots (like LUKS): up to three passphrases, each opening the same master key, so
|
|
||||||
* that one person leaving or dying does not lock the company out of its backups. Every change
|
|
||||||
* is written to all locations and announced to all administrators; a new recovery kit must be
|
|
||||||
* downloaded and confirmed afterwards (backups keep running meanwhile).
|
|
||||||
*/
|
|
||||||
final class KeySlotService {
|
|
||||||
public function __construct(
|
|
||||||
private KeyService $keys,
|
|
||||||
private TargetService $targets,
|
|
||||||
private AlertService $alerts,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function add(#[\SensitiveParameter] string $passphrase, string $label): int {
|
|
||||||
$slot = $this->keys->addSlot($passphrase, $label);
|
|
||||||
$this->apply('slot_added', $slot);
|
|
||||||
return $slot;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function replace(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void {
|
|
||||||
$this->keys->replaceSlot($slot, $passphrase, $label);
|
|
||||||
$this->apply('slot_replaced', $slot);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function remove(int $slot): void {
|
|
||||||
$this->keys->removeSlot($slot);
|
|
||||||
$this->apply('slot_removed', $slot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function apply(string $event, int $slot): void {
|
|
||||||
$wrapped = $this->keys->wrappedKey();
|
|
||||||
$failed = [];
|
|
||||||
foreach ($this->targets->list() as $t) {
|
|
||||||
try {
|
|
||||||
$this->targets->repository($t)->updateWrappedKey($wrapped);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$failed[] = $t->getName();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$label = '';
|
|
||||||
foreach ($this->keys->slots() as $s) {
|
|
||||||
if ($s['slot'] === $slot) {
|
|
||||||
$label = $s['label'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]);
|
|
||||||
if ($failed !== []) {
|
|
||||||
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
|
||||||
|
|
||||||
use OCA\NgBackup\AppInfo\Application;
|
|
||||||
use OCA\NgBackup\Db\RunMapper;
|
|
||||||
use OCA\NgBackup\Db\SnapshotMapper;
|
|
||||||
use OCA\NgBackup\Db\Target;
|
|
||||||
use OCP\IAppConfig;
|
|
||||||
use OCP\Lock\ILockingProvider;
|
|
||||||
use OCP\Lock\LockedException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the retention policy to a location: forgets snapshots the policy does not keep and
|
|
||||||
* prunes data no remaining snapshot uses. Never runs on append-only locations, never while a
|
|
||||||
* backup to the same location is running (the location lock is shared with BackupService).
|
|
||||||
*/
|
|
||||||
final class PruneService {
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private RunMapper $runs,
|
|
||||||
private SnapshotMapper $snapshots,
|
|
||||||
private IAppConfig $appConfig,
|
|
||||||
private ILockingProvider $locking,
|
|
||||||
private AlertService $alerts,
|
|
||||||
private KeyService $keys,
|
|
||||||
private LeaseService $leases,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function untrash(Target $target, string $snapshotId): void {
|
|
||||||
$lease = $this->lockExclusive($target, 300);
|
|
||||||
try {
|
|
||||||
$this->doUntrash($target, $snapshotId);
|
|
||||||
} finally {
|
|
||||||
$this->leases->release($lease);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run $fn under the location's exclusive lock (to create a run atomically with respect to a
|
|
||||||
* cleanup, which takes the same lock and then refuses while a run exists).
|
|
||||||
*/
|
|
||||||
public function underStartLock(Target $target, callable $fn): mixed {
|
|
||||||
try {
|
|
||||||
return $this->leases->with(self::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $fn());
|
|
||||||
} catch (LockedException) {
|
|
||||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exclusive lock on the location, then make sure no run of any kind (backup, restore, export)
|
|
||||||
* is in progress: their data may not be referenced by a snapshot yet, or is being read.
|
|
||||||
*/
|
|
||||||
private function lockExclusive(Target $target, int $ttl): string {
|
|
||||||
try {
|
|
||||||
$lease = $this->leases->acquire(self::lockKey($target), LeaseService::EXCLUSIVE, $ttl);
|
|
||||||
} catch (LockedException) {
|
|
||||||
throw new \RuntimeException('A backup, restore or cleanup of ' . $target->getName() . ' is in progress; try again later');
|
|
||||||
}
|
|
||||||
if ($this->runs->findRunning($target->getId()) !== []) {
|
|
||||||
$this->leases->release($lease);
|
|
||||||
throw new \RuntimeException('A backup or restore of ' . $target->getName() . ' is running; try again later');
|
|
||||||
}
|
|
||||||
return $lease;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function doUntrash(Target $target, string $snapshotId): void {
|
|
||||||
$repo = $this->targets->repository($target);
|
|
||||||
if (!array_key_exists($snapshotId, $repo->trash())) {
|
|
||||||
throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)');
|
|
||||||
}
|
|
||||||
$repo->untrash($snapshotId);
|
|
||||||
// Put it back in the local snapshot list as well.
|
|
||||||
$meta = $repo->snapshotMeta($snapshotId);
|
|
||||||
$s = new \OCA\NgBackup\Db\Snapshot();
|
|
||||||
$s->setTargetId($target->getId());
|
|
||||||
$s->setSnapshotId($snapshotId);
|
|
||||||
$s->setKind((string)($meta['kind'] ?? 'full'));
|
|
||||||
$s->setLabel(($meta['label'] ?? '') !== '' ? $meta['label'] : null);
|
|
||||||
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
|
|
||||||
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
|
||||||
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
|
||||||
try {
|
|
||||||
$this->snapshots->insert($s);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
}
|
|
||||||
$this->alerts->securityEvent('snapshot_untrashed', ['target' => $target->getName(), 'snapshot' => $snapshotId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function lockKey(Target $target): string {
|
|
||||||
return 'ng_backup/target/' . $target->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function policy(): RetentionPolicy {
|
|
||||||
return RetentionPolicy::fromArray(json_decode($this->appConfig->getValueString(Application::APP_ID, 'retention', '{}'), true) ?: []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPolicy(RetentionPolicy $policy): void {
|
|
||||||
if ($policy->last < 1) {
|
|
||||||
throw new \InvalidArgumentException('Keep at least the last snapshot');
|
|
||||||
}
|
|
||||||
$old = $this->policy()->toArray();
|
|
||||||
$this->appConfig->setValueString(Application::APP_ID, 'retention', json_encode($policy->toArray(), JSON_THROW_ON_ERROR));
|
|
||||||
if ($old !== $policy->toArray()) {
|
|
||||||
$this->alerts->securityEvent('retention_changed', ['policy' => json_encode($policy->toArray())]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshots the policy drops go to the trash on the location; trash entries older than the
|
|
||||||
* deletion delay are purged, and only then is their data freed.
|
|
||||||
*
|
|
||||||
* @return array{kept: array<string, list<string>>, forgotten: list<string>, purged: list<string>, prune: array}
|
|
||||||
*/
|
|
||||||
public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array {
|
|
||||||
if ($target->getAppendOnly()) {
|
|
||||||
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
|
|
||||||
}
|
|
||||||
$lease = $this->lockExclusive($target, 1800);
|
|
||||||
try {
|
|
||||||
$repo = $this->targets->repository($target);
|
|
||||||
$times = [];
|
|
||||||
foreach ($repo->listSnapshots() as $id) {
|
|
||||||
$meta = $repo->snapshotMeta($id);
|
|
||||||
$times[$id] = (int)strtotime((string)($meta['time'] ?? '@0'));
|
|
||||||
}
|
|
||||||
$keep = $this->policy()->keep($times);
|
|
||||||
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
|
|
||||||
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
|
|
||||||
$purged = [];
|
|
||||||
if (!$dryRun) {
|
|
||||||
foreach ($forget as $id) {
|
|
||||||
$repo->forget($id, $actor);
|
|
||||||
try {
|
|
||||||
$this->snapshots->delete($this->snapshots->findOne($target->getId(), $id));
|
|
||||||
} catch (\Throwable) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone.
|
|
||||||
$delay = $this->keys->deleteDelayDays();
|
|
||||||
if (!$dryRun && $forget !== []) {
|
|
||||||
// Scheduled cleanup follows the policy: audit log only, no notification every week.
|
|
||||||
$this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget),
|
|
||||||
'until' => date('Y-m-d', time() + $delay * 86400)], !$scheduled);
|
|
||||||
}
|
|
||||||
if (!$dryRun) {
|
|
||||||
$purged = $repo->purgeTrash($delay * 86400);
|
|
||||||
}
|
|
||||||
$prune = $repo->prune($dryRun);
|
|
||||||
return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune];
|
|
||||||
} finally {
|
|
||||||
$this->leases->release($lease);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Db\Target;
|
|
||||||
use OCA\NgBackup\Repository\Repository;
|
|
||||||
use OCP\Files\File;
|
|
||||||
use OCP\Files\Folder;
|
|
||||||
use OCP\Files\IRootFolder;
|
|
||||||
use OCP\Files\NotFoundException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restoring from a snapshot.
|
|
||||||
*
|
|
||||||
* User files (data/<uid>/files/...) are written through the Nextcloud Files API, so the file
|
|
||||||
* cache, versions and trash stay consistent: an overwritten file becomes a version and a
|
|
||||||
* removed file goes to the trash, which makes a restore itself reversible. Every file is
|
|
||||||
* written to a temporary name first and only renamed into place after all its blobs were
|
|
||||||
* decrypted and verified.
|
|
||||||
*
|
|
||||||
* Modes for user files:
|
|
||||||
* - new-folder (default): into "Restored <date>/<same path>", nothing existing is touched
|
|
||||||
* - merge: into the original place, existing files are overwritten (old content kept as a version)
|
|
||||||
* - replace: like merge, and files that are not in the snapshot are moved to the trash
|
|
||||||
*/
|
|
||||||
final class RestoreService {
|
|
||||||
public const MODE_NEW_FOLDER = 'new-folder';
|
|
||||||
public const MODE_MERGE = 'merge';
|
|
||||||
public const MODE_REPLACE = 'replace';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private TargetService $targets,
|
|
||||||
private IRootFolder $root,
|
|
||||||
private LeaseService $leases,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shared lock on the location while restoring: a cleanup (exclusive) must not delete what is being read. */
|
|
||||||
private function withSharedLock(Target $target, callable $fn, int $ttl = 600): mixed {
|
|
||||||
try {
|
|
||||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, $fn);
|
|
||||||
} catch (\OCP\Lock\LockedException) {
|
|
||||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Direct children (files and folders) of a path in a snapshot.
|
|
||||||
*
|
|
||||||
* @return list<array{name:string, type:string, size:int, mtime:int|null, files:int}>
|
|
||||||
*/
|
|
||||||
public function browse(Target $target, string $snapshotId, string $path = ''): array {
|
|
||||||
$prefix = trim($path, '/');
|
|
||||||
$prefix = $prefix === '' ? '' : $prefix . '/';
|
|
||||||
$children = [];
|
|
||||||
foreach ($this->targets->repository($target)->entries($snapshotId) as $e) {
|
|
||||||
if ($prefix !== '' && !str_starts_with($e['p'], $prefix)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$rest = substr($e['p'], strlen($prefix));
|
|
||||||
[$name, $more] = array_pad(explode('/', $rest, 2), 2, null);
|
|
||||||
$c = $children[$name] ?? ['name' => $name, 'type' => $more === null ? 'file' : 'dir', 'size' => 0, 'mtime' => null, 'files' => 0];
|
|
||||||
$c['size'] += $e['s'];
|
|
||||||
$c['files']++;
|
|
||||||
if ($more === null) {
|
|
||||||
$c['mtime'] = $e['m'];
|
|
||||||
}
|
|
||||||
$children[$name] = $c;
|
|
||||||
}
|
|
||||||
ksort($children, SORT_STRING);
|
|
||||||
return array_values($children);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore user files from "data/<uid>/files[/<path>]" into that user's Nextcloud files.
|
|
||||||
*
|
|
||||||
* @return array{restored:int, trashed:int, target:string}
|
|
||||||
*/
|
|
||||||
public function restoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode = self::MODE_NEW_FOLDER, ?callable $progress = null): array {
|
|
||||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $logicalPath, $mode, $progress) {
|
|
||||||
$last = time();
|
|
||||||
// Keep the lease alive while files are being written.
|
|
||||||
$beat = function (int $done, int $total, string $name) use ($refresh, $progress, &$last): void {
|
|
||||||
if (time() - $last >= 60) {
|
|
||||||
$refresh();
|
|
||||||
$last = time();
|
|
||||||
}
|
|
||||||
if ($progress !== null) {
|
|
||||||
$progress($done, $total, $name);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return $this->doRestoreUserFiles($target, $snapshotId, $logicalPath, $mode, $beat);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress): array {
|
|
||||||
if (!Repository::isSafePath($logicalPath) || !preg_match('#^data/([^/]+)/files(?:/(.*))?$#', $logicalPath, $m)) {
|
|
||||||
throw new \InvalidArgumentException('Expected a path like data/<user>/files/<folder or file>');
|
|
||||||
}
|
|
||||||
[$uid, $rel] = [$m[1], trim($m[2] ?? '', '/')];
|
|
||||||
$userFolder = $this->root->getUserFolder($uid);
|
|
||||||
$repo = $this->targets->repository($target);
|
|
||||||
$source = 'data/' . $uid . '/files' . ($rel === '' ? '' : '/' . $rel);
|
|
||||||
|
|
||||||
$entries = [];
|
|
||||||
$single = false;
|
|
||||||
foreach ($repo->entries($snapshotId) as $e) {
|
|
||||||
if ($e['p'] === $source) {
|
|
||||||
$single = true;
|
|
||||||
$entries[''] = $e;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (str_starts_with($e['p'], $source . '/')) {
|
|
||||||
$entries[substr($e['p'], strlen($source) + 1)] = $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($entries === []) {
|
|
||||||
throw new NotFoundException("Nothing at $source in snapshot $snapshotId");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where to write. new-folder mirrors the path under "Restored <date>" in the user's root.
|
|
||||||
$parentRel = $single ? (dirname($rel) === '.' ? '' : dirname($rel)) : $rel;
|
|
||||||
if ($mode === self::MODE_NEW_FOLDER) {
|
|
||||||
$parentRel = trim('Restored ' . date('Y-m-d H.i') . '/' . $parentRel, '/');
|
|
||||||
} elseif ($mode !== self::MODE_MERGE && $mode !== self::MODE_REPLACE) {
|
|
||||||
throw new \InvalidArgumentException("Unknown mode $mode");
|
|
||||||
}
|
|
||||||
$dest = $this->ensureFolder($userFolder, $parentRel);
|
|
||||||
$names = $single ? [basename($rel) => $entries['']] : $entries;
|
|
||||||
|
|
||||||
$restored = 0;
|
|
||||||
foreach ($names as $relPath => $entry) {
|
|
||||||
$this->writeFile($repo, $dest, (string)$relPath, $entry);
|
|
||||||
$restored++;
|
|
||||||
if ($progress !== null) {
|
|
||||||
$progress($restored, count($names), (string)$relPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$trashed = 0;
|
|
||||||
if ($mode === self::MODE_REPLACE && !$single) {
|
|
||||||
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)));
|
|
||||||
}
|
|
||||||
return ['restored' => $restored, 'trashed' => $trashed, 'target' => $uid . ':' . $userFolder->getRelativePath($dest->getPath())];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raw restore of any snapshot path to a local directory (administrators, disaster recovery). */
|
|
||||||
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory): int {
|
|
||||||
if ($prefix !== '' && !Repository::isSafePath($prefix)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid path: ' . $prefix);
|
|
||||||
}
|
|
||||||
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
|
|
||||||
throw new \RuntimeException("Cannot create $directory");
|
|
||||||
}
|
|
||||||
return $this->withSharedLock($target, fn () => $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/'), 3600);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
|
|
||||||
$dir = dirname($relPath);
|
|
||||||
$folder = $dir === '.' ? $dest : $this->ensureFolder($dest, $dir);
|
|
||||||
$name = basename($relPath);
|
|
||||||
// Stage under a temporary name; only rename into place after every blob was verified.
|
|
||||||
$tmpName = '.' . $name . '.ngb-restore-' . bin2hex(random_bytes(3));
|
|
||||||
$tmp = $folder->newFile($tmpName);
|
|
||||||
try {
|
|
||||||
$out = $tmp->fopen('wb');
|
|
||||||
$written = 0;
|
|
||||||
foreach ($repo->readBlobs($entry['b']) as $data) { // each blob is verified on read
|
|
||||||
if (fwrite($out, $data) !== strlen($data)) {
|
|
||||||
throw new \RuntimeException('Write failed for ' . $relPath);
|
|
||||||
}
|
|
||||||
$written += strlen($data);
|
|
||||||
}
|
|
||||||
if (!fclose($out)) {
|
|
||||||
throw new \RuntimeException('Write failed for ' . $relPath);
|
|
||||||
}
|
|
||||||
if ($written !== $entry['s']) {
|
|
||||||
throw new \RuntimeException("Size mismatch restoring $relPath");
|
|
||||||
}
|
|
||||||
if ($folder->nodeExists($name)) {
|
|
||||||
$existing = $folder->get($name);
|
|
||||||
if (!$existing instanceof File) {
|
|
||||||
throw new \RuntimeException("$relPath exists and is not a file");
|
|
||||||
}
|
|
||||||
// Overwrite through the Files API: the current content becomes a version.
|
|
||||||
$in = $tmp->fopen('rb');
|
|
||||||
$existing->putContent($in);
|
|
||||||
if (is_resource($in)) {
|
|
||||||
fclose($in);
|
|
||||||
}
|
|
||||||
$tmp->delete();
|
|
||||||
$existing->touch($entry['m']);
|
|
||||||
} else {
|
|
||||||
$tmp->move($folder->getPath() . '/' . $name);
|
|
||||||
$folder->get($name)->touch($entry['m']);
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
try {
|
|
||||||
$tmp->delete();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
}
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function trashExtras(Folder $folder, string $prefix, array $keep): int {
|
|
||||||
$count = 0;
|
|
||||||
foreach ($folder->getDirectoryListing() as $node) {
|
|
||||||
$rel = $prefix === '' ? $node->getName() : $prefix . '/' . $node->getName();
|
|
||||||
if ($node instanceof Folder) {
|
|
||||||
$hasKept = false;
|
|
||||||
foreach (array_keys($keep) as $k) {
|
|
||||||
if (str_starts_with((string)$k, $rel . '/')) {
|
|
||||||
$hasKept = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($hasKept) {
|
|
||||||
$count += $this->trashExtras($node, $rel, $keep);
|
|
||||||
} else {
|
|
||||||
$node->delete(); // to the trash
|
|
||||||
$count++;
|
|
||||||
}
|
|
||||||
} elseif (!isset($keep[$rel])) {
|
|
||||||
$node->delete();
|
|
||||||
$count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $count;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureFolder(Folder $base, string $path): Folder {
|
|
||||||
$path = trim($path, '/');
|
|
||||||
if ($path === '' || $path === '.') {
|
|
||||||
return $base;
|
|
||||||
}
|
|
||||||
if ($base->nodeExists($path)) {
|
|
||||||
$node = $base->get($path);
|
|
||||||
if (!$node instanceof Folder) {
|
|
||||||
throw new \RuntimeException("$path exists and is not a folder");
|
|
||||||
}
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
return $base->newFolder($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which snapshots to keep: the newest $last, plus the newest snapshot of each of the most recent
|
|
||||||
* $daily days, $weekly ISO weeks and $monthly months that have a snapshot (like restic/borg).
|
|
||||||
*/
|
|
||||||
final class RetentionPolicy {
|
|
||||||
public function __construct(
|
|
||||||
public readonly int $last = 3,
|
|
||||||
public readonly int $daily = 7,
|
|
||||||
public readonly int $weekly = 4,
|
|
||||||
public readonly int $monthly = 6,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromArray(array $a): self {
|
|
||||||
return new self((int)($a['last'] ?? 3), (int)($a['daily'] ?? 7), (int)($a['weekly'] ?? 4), (int)($a['monthly'] ?? 6));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toArray(): array {
|
|
||||||
return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, int> $snapshots id => unix time
|
|
||||||
* @return array<string, list<string>> id => reasons it is kept (ids not in the result are removed)
|
|
||||||
*/
|
|
||||||
public function keep(array $snapshots): array {
|
|
||||||
arsort($snapshots);
|
|
||||||
$keep = [];
|
|
||||||
$i = 0;
|
|
||||||
foreach ($snapshots as $id => $time) {
|
|
||||||
if ($i++ < $this->last) {
|
|
||||||
$keep[$id][] = 'last';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach (['daily' => 'Y-m-d', 'weekly' => 'o-W', 'monthly' => 'Y-m'] as $kind => $format) {
|
|
||||||
$seen = [];
|
|
||||||
foreach ($snapshots as $id => $time) {
|
|
||||||
$bucket = date($format, $time);
|
|
||||||
if (isset($seen[$bucket])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (count($seen) >= $this->$kind) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$seen[$bucket] = true;
|
|
||||||
$keep[$id][] = $kind;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $keep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Backend\ExternalStorageFactory;
|
|
||||||
use OCA\NgBackup\Backend\IBackend;
|
|
||||||
use OCA\NgBackup\Backend\StorageBackend;
|
|
||||||
use OCA\NgBackup\Db\Target;
|
|
||||||
use OCA\NgBackup\Db\TargetMapper;
|
|
||||||
use OCA\NgBackup\Repository\Repository;
|
|
||||||
use OCP\App\IAppManager;
|
|
||||||
use OCP\Security\ICrypto;
|
|
||||||
use OCP\Server;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup targets: a files_external backend + auth mechanism + options + base path. The storage
|
|
||||||
* object is built only while it is used; nothing is mounted. files_external is loaded for its
|
|
||||||
* classes but its enabled/disabled state is left as the admin set it.
|
|
||||||
*/
|
|
||||||
final class TargetService {
|
|
||||||
public function __construct(
|
|
||||||
private TargetMapper $mapper,
|
|
||||||
private KeyService $keys,
|
|
||||||
private ICrypto $crypto,
|
|
||||||
private IAppManager $appManager,
|
|
||||||
private \OCA\NgBackup\Db\DbIndexCache $indexCache,
|
|
||||||
private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
|
|
||||||
public function availableBackends(): array {
|
|
||||||
return $this->factory()->describeBackends();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a target: connect, create the repository there (or adopt an existing one made with this
|
|
||||||
* installation's key), then save it.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $options
|
|
||||||
* @return array{target: Target, created: bool}
|
|
||||||
*/
|
|
||||||
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
|
|
||||||
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
|
||||||
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$this->mapper->findByName($name);
|
|
||||||
throw new \InvalidArgumentException("A target named '$name' already exists");
|
|
||||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
|
||||||
}
|
|
||||||
$keys = $this->keys->keyRing();
|
|
||||||
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
|
|
||||||
if ($storageBackend->exists('config')) {
|
|
||||||
$repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor);
|
|
||||||
$created = false;
|
|
||||||
} else {
|
|
||||||
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
|
|
||||||
$created = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->mapper->findAll() as $existing) {
|
|
||||||
if ($existing->getRepositoryId() === $repo->id()) {
|
|
||||||
throw new \InvalidArgumentException("This repository is already registered as '{$existing->getName()}'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$target = new Target();
|
|
||||||
$target->setName($name);
|
|
||||||
$target->setBackend($backend);
|
|
||||||
$target->setAuth($auth);
|
|
||||||
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
|
|
||||||
$target->setBasePath(trim($basePath, '/'));
|
|
||||||
$target->setRepositoryId($repo->id());
|
|
||||||
$target->setCreatedAt(time());
|
|
||||||
return ['target' => $this->mapper->insert($target), 'created' => $created];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<Target> */
|
|
||||||
public function list(): array {
|
|
||||||
return $this->mapper->findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(string $nameOrId): Target {
|
|
||||||
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 {
|
|
||||||
$target->setAppendOnly($appendOnly);
|
|
||||||
return $this->mapper->update($target);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
|
|
||||||
public function remove(Target $target): void {
|
|
||||||
$this->mapper->delete($target);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function backend(Target $target): IBackend {
|
|
||||||
return $this->buildBackend($target->getBackend(), $target->getAuth(), $this->options($target), $target->getBasePath());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function repository(Target $target): Repository {
|
|
||||||
$repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing(), $this->indexCache, $this->anchor);
|
|
||||||
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
|
|
||||||
throw new \RuntimeException('The target now contains a different repository than when it was added');
|
|
||||||
}
|
|
||||||
$repo->setMaxBytes($target->getMaxBytes());
|
|
||||||
return $repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function options(Target $target): array {
|
|
||||||
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend {
|
|
||||||
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ?ExternalStorageFactory $factory = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* files_external's connection code, without changing the app's state:
|
|
||||||
* - enabled: it is booted by Nextcloud; use its own BackendService;
|
|
||||||
* - disabled: do NOT boot it (booting registers its mount provider and listeners, which use
|
|
||||||
* its database tables; on installations where it was never enabled those do not exist).
|
|
||||||
* Only its classes are loaded, and a private BackendService gets its backends and auth
|
|
||||||
* mechanisms from its Application class.
|
|
||||||
*/
|
|
||||||
private function factory(): ExternalStorageFactory {
|
|
||||||
if ($this->factory !== null) {
|
|
||||||
return $this->factory;
|
|
||||||
}
|
|
||||||
if ($this->appManager->isEnabledForAnyone('files_external')) {
|
|
||||||
$this->appManager->loadApp('files_external');
|
|
||||||
return $this->factory = new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class));
|
|
||||||
}
|
|
||||||
if (!class_exists(\OCA\Files_External\AppInfo\Application::class)) {
|
|
||||||
$path = $this->appManager->getAppPath('files_external');
|
|
||||||
require_once $path . '/composer/autoload.php';
|
|
||||||
}
|
|
||||||
$service = new \OCA\Files_External\Service\BackendService(
|
|
||||||
Server::get(\OCP\IAppConfig::class), Server::get(\Psr\Log\LoggerInterface::class));
|
|
||||||
$app = new \OCA\Files_External\AppInfo\Application();
|
|
||||||
$service->registerBackendProvider($app);
|
|
||||||
$service->registerAuthMechanismProvider($app);
|
|
||||||
return $this->factory = new ExternalStorageFactory($service);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Settings;
|
|
||||||
|
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
|
||||||
use OCP\Settings\ISettings;
|
|
||||||
use OCP\Util;
|
|
||||||
|
|
||||||
final class Admin implements ISettings {
|
|
||||||
public function getForm(): TemplateResponse {
|
|
||||||
Util::addScript('ng_backup', 'ng_backup-admin');
|
|
||||||
Util::addStyle('ng_backup', 'ng_backup-admin');
|
|
||||||
return new TemplateResponse('ng_backup', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSection(): string {
|
|
||||||
return 'ng_backup';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPriority(): int {
|
|
||||||
return 10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Settings;
|
|
||||||
|
|
||||||
use OCP\IL10N;
|
|
||||||
use OCP\IURLGenerator;
|
|
||||||
use OCP\Settings\IIconSection;
|
|
||||||
|
|
||||||
final class AdminSection implements IIconSection {
|
|
||||||
public function __construct(
|
|
||||||
private IL10N $l,
|
|
||||||
private IURLGenerator $url,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getID(): string {
|
|
||||||
return 'ng_backup';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): string {
|
|
||||||
return $this->l->t('NG Backup');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPriority(): int {
|
|
||||||
return 80;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIcon(): string {
|
|
||||||
return $this->url->imagePath('ng_backup', 'app-dark.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\SetupCheck;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Db\Run;
|
|
||||||
use OCA\NgBackup\Db\RunMapper;
|
|
||||||
use OCA\NgBackup\Service\AlertService;
|
|
||||||
use OCA\NgBackup\Service\BackupService;
|
|
||||||
use OCA\NgBackup\Service\KeyService;
|
|
||||||
use OCA\NgBackup\Service\TargetService;
|
|
||||||
use OCP\IL10N;
|
|
||||||
use OCP\IURLGenerator;
|
|
||||||
use OCP\SetupCheck\ISetupCheck;
|
|
||||||
use OCP\SetupCheck\SetupResult;
|
|
||||||
|
|
||||||
/** Shows the backup state in the admin overview (Security & setup warnings). */
|
|
||||||
final class BackupStatusCheck implements ISetupCheck {
|
|
||||||
public function __construct(
|
|
||||||
private IL10N $l,
|
|
||||||
private IURLGenerator $url,
|
|
||||||
private KeyService $keys,
|
|
||||||
private TargetService $targets,
|
|
||||||
private RunMapper $runs,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCategory(): string {
|
|
||||||
return 'system';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): string {
|
|
||||||
return $this->l->t('NG Backup');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function run(): SetupResult {
|
|
||||||
$link = $this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']);
|
|
||||||
if (!$this->keys->isInitialized() || !$this->keys->everConfirmed()) {
|
|
||||||
return SetupResult::error($this->l->t('Backups are not set up: create the backup key and confirm the recovery kit.'), $link);
|
|
||||||
}
|
|
||||||
if ($this->keys->recoveryKitConfirmation() === null) {
|
|
||||||
return SetupResult::error($this->l->t('The backup passphrase was changed: download and confirm the new recovery kit.'), $link);
|
|
||||||
}
|
|
||||||
$targets = $this->targets->list();
|
|
||||||
if ($targets === []) {
|
|
||||||
return SetupResult::warning($this->l->t('No backup location is configured.'), $link);
|
|
||||||
}
|
|
||||||
$problems = [];
|
|
||||||
foreach ($targets as $t) {
|
|
||||||
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
|
||||||
if ($last === null) {
|
|
||||||
$problems[] = $this->l->t('%s: no successful backup yet', [$t->getName()]);
|
|
||||||
} elseif (time() - (int)$last->getFinishedAt() > AlertService::STALE_AFTER) {
|
|
||||||
$problems[] = $this->l->t('%1$s: last successful backup %2$s', [$t->getName(), date('Y-m-d H:i', (int)$last->getFinishedAt())]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($this->runs->findRecent(10) as $r) {
|
|
||||||
if ($r->getStatus() === Run::FAILED && $r->getStartedAt() > time() - AlertService::STALE_AFTER) {
|
|
||||||
$problems[] = $this->l->t('A %1$s run failed: %2$s', [$r->getKind(), mb_strimwidth((string)$r->getError(), 0, 120, '…')]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $problems === []
|
|
||||||
? SetupResult::success($this->l->t('Recent backups to all locations succeeded.'))
|
|
||||||
: SetupResult::warning(implode("\n", $problems), $link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)) {
|
$destination->get($name)->putContent($stream);
|
||||||
$existing = $destination->get($name);
|
} else {
|
||||||
if (!$existing instanceof File) {
|
$destination->newFile($name, $stream);
|
||||||
throw new UserMigrationException("$path exists and is not a file");
|
}
|
||||||
}
|
if (is_resource($stream)) {
|
||||||
$existing->putContent($stream);
|
|
||||||
} else {
|
|
||||||
$destination->newFile($name, $stream);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
fclose($stream);
|
fclose($stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-8381
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ng_backup",
|
|
||||||
"version": "0.0.5",
|
|
||||||
"private": true,
|
|
||||||
"license": "AGPL-3.0-or-later",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite --mode production build",
|
|
||||||
"dev": "vite --mode development build --watch"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nextcloud/axios": "^2.5.1",
|
|
||||||
"@nextcloud/dialogs": "^7.4.0",
|
|
||||||
"@nextcloud/l10n": "^3.4.0",
|
|
||||||
"@nextcloud/password-confirmation": "^6.1.0",
|
|
||||||
"@nextcloud/router": "^3.0.1",
|
|
||||||
"@nextcloud/vue": "^9.0.0",
|
|
||||||
"vue": "^3.5.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@nextcloud/vite-config": "^2.3.5",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.3.6"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^22.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
+6
-22
@@ -48,11 +48,9 @@ $seqStates = static function () use ($db, $prefix): array {
|
|||||||
return $out;
|
return $out;
|
||||||
};
|
};
|
||||||
// Let one sequence run ahead of the data (deleted rows, rollbacks): MAX+1 would be wrong.
|
// Let one sequence run ahead of the data (deleted rows, rollbacks): MAX+1 would be wrong.
|
||||||
if ($db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
$fcSeq = $db->fetchOne("SELECT pg_get_serial_sequence(?, 'fileid')", [$prefix . 'filecache']);
|
||||||
$fcSeq = $db->fetchOne("SELECT pg_get_serial_sequence(?, 'fileid')", [$prefix . 'filecache']);
|
for ($i = 0; $i < 50; $i++) {
|
||||||
for ($i = 0; $i < 50; $i++) {
|
$db->fetchOne('SELECT nextval(CAST(? AS regclass))', [$fcSeq]);
|
||||||
$db->fetchOne('SELECT nextval(CAST(? AS regclass))', [$fcSeq]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$seqBefore = $seqStates();
|
$seqBefore = $seqStates();
|
||||||
$before = [];
|
$before = [];
|
||||||
@@ -73,17 +71,6 @@ $dumpBytes = array_sum(array_column($manifest['tables'], 'bytes'));
|
|||||||
printf("dump size (plain JSON lines): %.1f MiB\n", $dumpBytes / 1048576);
|
printf("dump size (plain JSON lines): %.1f MiB\n", $dumpBytes / 1048576);
|
||||||
$check('checksums in dump match live tables', array_map(fn ($x) => $x['sha256'], $manifest['tables']) === array_map(fn ($x) => $x['sha256'], $before));
|
$check('checksums in dump match live tables', array_map(fn ($x) => $x['sha256'], $manifest['tables']) === array_map(fn ($x) => $x['sha256'], $before));
|
||||||
|
|
||||||
// The same dump in many tiny steps (as under web cron) must give identical table checksums.
|
|
||||||
$stepped = $dumper->startDump();
|
|
||||||
$stepStats = [];
|
|
||||||
while (!$stepped['done']) {
|
|
||||||
$stepped = $dumper->dumpStep($repo, $stepped, microtime(true) + 0.05, $stepStats);
|
|
||||||
}
|
|
||||||
$sm = $dumper->manifest($stepped);
|
|
||||||
printf("stepped dump: %d steps, consistent=%s\n", $stepped['steps'], json_encode($sm['consistent']));
|
|
||||||
$check('stepped dump: same checksums as the one-shot dump', array_map(fn ($x) => $x['sha256'], $sm['tables']) === array_map(fn ($x) => $x['sha256'], $manifest['tables']));
|
|
||||||
$check('stepped dump is marked as not fully consistent', $stepped['steps'] === 1 || $sm['consistent'] === false);
|
|
||||||
|
|
||||||
// Second dump without changes: blobs are reused.
|
// Second dump without changes: blobs are reused.
|
||||||
$stats2 = ['newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0];
|
$stats2 = ['newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0];
|
||||||
$dumper->dump($repo, $stats2);
|
$dumper->dump($repo, $stats2);
|
||||||
@@ -103,10 +90,8 @@ memory_reset_peak_usage();
|
|||||||
$t = microtime(true);
|
$t = microtime(true);
|
||||||
$repo2 = Repository::open(new LocalBackend($repoDir), 'spike passphrase');
|
$repo2 = Repository::open(new LocalBackend($repoDir), 'spike passphrase');
|
||||||
$m = json_decode($repo2->getObject('db/1'), true, 512, JSON_THROW_ON_ERROR);
|
$m = json_decode($repo2->getObject('db/1'), true, 512, JSON_THROW_ON_ERROR);
|
||||||
$report = [];
|
$rows = $dumper->restore($repo2, $m);
|
||||||
$rows = $dumper->restore($repo2, $m, $report);
|
printf("restore: %d rows in %.1fs, peak %.1f MiB\n", $rows, microtime(true) - $t, memory_get_peak_usage() / 1048576);
|
||||||
printf("restore: %d rows in %.1fs, peak %.1f MiB, %d tables restored, %d unchanged skipped\n", $rows, microtime(true) - $t, memory_get_peak_usage() / 1048576, count($report['restored']), count($report['skipped']));
|
|
||||||
$check('restore only rewrote the damaged tables', count($report['restored']) === count($changed));
|
|
||||||
|
|
||||||
$mismatch = [];
|
$mismatch = [];
|
||||||
foreach ($tables as $tb) {
|
foreach ($tables as $tb) {
|
||||||
@@ -124,8 +109,7 @@ if ($mismatch) {
|
|||||||
// Sequences must be restored exactly (PostgreSQL), including one that ran ahead of the data.
|
// Sequences must be restored exactly (PostgreSQL), including one that ran ahead of the data.
|
||||||
$seqAfter = $seqStates();
|
$seqAfter = $seqStates();
|
||||||
$diff = array_filter(array_keys($seqBefore), fn ($k) => ($seqAfter[$k] ?? null) !== $seqBefore[$k]);
|
$diff = array_filter(array_keys($seqBefore), fn ($k) => ($seqAfter[$k] ?? null) !== $seqBefore[$k]);
|
||||||
$isPg = $db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
$check(sprintf('all %d sequences restored exactly (filecache seq 50 ahead of data)', count($seqBefore)), $diff === [] && count($seqBefore) > 0);
|
||||||
$check(sprintf('all %d sequences restored exactly%s', count($seqBefore), $isPg ? ' (filecache seq 50 ahead of data)' : ' (n/a on this database)'), $diff === [] && (!$isPg || count($seqBefore) > 0));
|
|
||||||
if ($diff) {
|
if ($diff) {
|
||||||
foreach (array_slice($diff, 0, 5) as $k) {
|
foreach (array_slice($diff, 0, 5) as $k) {
|
||||||
echo "$k before=" . json_encode($seqBefore[$k]) . ' after=' . json_encode($seqAfter[$k] ?? null) . "\n";
|
echo "$k before=" . json_encode($seqBefore[$k]) . ' after=' . json_encode($seqAfter[$k] ?? null) . "\n";
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
<!--
|
|
||||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<div class="ng-backup">
|
|
||||||
<NcSettingsSection :name="t('ng_backup', 'NG Backup')"
|
|
||||||
:description="t('ng_backup', 'Encrypted, incremental backups of this Nextcloud (database, configuration and data). Backup locations are never mounted and are not visible in Files.')">
|
|
||||||
<NcLoadingIcon v-if="!status" :size="32" />
|
|
||||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
|
||||||
</NcSettingsSection>
|
|
||||||
|
|
||||||
<template v-if="status">
|
|
||||||
<div class="ngb-tabs">
|
|
||||||
<NcButton :variant="tab === 'backup' ? 'primary' : 'secondary'" @click="tab = 'backup'">{{ t('ng_backup', 'Backup and restore') }}</NcButton>
|
|
||||||
<NcButton :variant="tab === 'settings' ? 'primary' : 'secondary'" @click="tab = 'settings'">{{ t('ng_backup', 'Settings') }}</NcButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="tab === 'settings'">
|
|
||||||
<KeySetup :status="status" @changed="refresh" />
|
|
||||||
<Locations v-if="status.key.everConfirmed" :status="status" @changed="refresh" />
|
|
||||||
</template>
|
|
||||||
<template v-else-if="status.key.everConfirmed">
|
|
||||||
<template v-if="status.targets.length">
|
|
||||||
<Backups :status="status" @changed="refresh" />
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
|
||||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
|
||||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
|
||||||
import api from './api.js'
|
|
||||||
import Backups from './components/Backups.vue'
|
|
||||||
import KeySetup from './components/KeySetup.vue'
|
|
||||||
import Locations from './components/Locations.vue'
|
|
||||||
import RestoreBrowser from './components/RestoreBrowser.vue'
|
|
||||||
|
|
||||||
const status = ref(null)
|
|
||||||
const error = ref('')
|
|
||||||
const tab = ref('settings')
|
|
||||||
let tabInitialized = false
|
|
||||||
let timer = null
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
try {
|
|
||||||
status.value = await api.status()
|
|
||||||
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) {
|
|
||||||
error.value = e.message
|
|
||||||
}
|
|
||||||
// Poll while something runs, so progress shows up without reloading.
|
|
||||||
clearTimeout(timer)
|
|
||||||
if (status.value?.runs.some(r => r.status === 'running')) {
|
|
||||||
timer = setTimeout(refresh, 4000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refresh)
|
|
||||||
onBeforeUnmount(() => clearTimeout(timer))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ng-backup :deep(.settings-section) {
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
.ngb-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import AdminSettings from './AdminSettings.vue'
|
|
||||||
|
|
||||||
createApp(AdminSettings).mount('#ng-backup-admin')
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
|
|
||||||
const url = (path) => generateUrl('/apps/ng_backup/api' + path)
|
|
||||||
|
|
||||||
/** Unwraps {error} responses into thrown Errors with the server's message. */
|
|
||||||
async function call(method, path, data) {
|
|
||||||
try {
|
|
||||||
const response = await axios({ method, url: url(path), data, params: method === 'get' ? data : undefined })
|
|
||||||
return response.data
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(e.response?.data?.error || e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
status: () => call('get', '/status'),
|
|
||||||
initKey: (passphrase, deleteDelayDays, label) => call('post', '/key', { passphrase, deleteDelayDays, label }),
|
|
||||||
addSlot: (passphrase, label) => call('post', '/key/slots', { passphrase, label }),
|
|
||||||
replaceSlot: (slot, passphrase, label) => call('put', `/key/slots/${slot}`, { passphrase, label }),
|
|
||||||
removeSlot: (slot) => call('delete', `/key/slots/${slot}`),
|
|
||||||
trash: (targetId) => call('get', `/targets/${targetId}/trash`),
|
|
||||||
untrash: (targetId, snapshotId) => call('post', `/targets/${targetId}/trash/${snapshotId}`),
|
|
||||||
kitUrl: () => url('/key/kit'),
|
|
||||||
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
|
|
||||||
backends: () => call('get', '/backends'),
|
|
||||||
addTarget: (target) => call('post', '/targets', target),
|
|
||||||
removeTarget: (id) => call('delete', `/targets/${id}`),
|
|
||||||
testTarget: (id) => call('post', `/targets/${id}/test`),
|
|
||||||
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
|
|
||||||
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
|
|
||||||
setSchedule: (time) => call('put', '/schedule', { time }),
|
|
||||||
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
|
|
||||||
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
|
|
||||||
startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }),
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<!--
|
|
||||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<NcSettingsSection :name="t('ng_backup', 'Backups')"
|
|
||||||
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
|
|
||||||
<div class="ngb-row">
|
|
||||||
<NcTextField v-model="schedule" class="ngb-time" :label="t('ng_backup', 'Daily at (HH:MM, empty = off)')" />
|
|
||||||
<NcButton :disabled="schedule === status.schedule" @click="saveSchedule">{{ t('ng_backup', 'Save') }}</NcButton>
|
|
||||||
</div>
|
|
||||||
<div class="ngb-row">
|
|
||||||
<NcButton v-for="target in status.targets" :key="target.id" :disabled="isRunning(target.id)" @click="start(target)">
|
|
||||||
{{ isRunning(target.id) ? t('ng_backup', 'Backup to {name} running …', { name: target.name }) : t('ng_backup', 'Back up now to {name}', { name: target.name }) }}
|
|
||||||
</NcButton>
|
|
||||||
</div>
|
|
||||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
|
||||||
|
|
||||||
<template v-for="target in status.targets" :key="'trash' + target.id">
|
|
||||||
<div v-if="trash[target.id]?.length" class="ngb-trash">
|
|
||||||
<h3>{{ t('ng_backup', 'Trash of {name}', { name: target.name }) }}</h3>
|
|
||||||
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
|
|
||||||
<ul>
|
|
||||||
<li v-for="item in trash[target.id]" :key="item.id">
|
|
||||||
{{ item.time ? new Date(item.time).toLocaleString() : item.id }}{{ item.label ? ' – ' + item.label : '' }}
|
|
||||||
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: new Date(item.deleteAfter * 1000).toLocaleDateString() }) }}</span>
|
|
||||||
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<table v-if="status.runs.length" class="ngb-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ t('ng_backup', 'Started') }}</th>
|
|
||||||
<th>{{ t('ng_backup', 'Location') }}</th>
|
|
||||||
<th>{{ t('ng_backup', 'Kind') }}</th>
|
|
||||||
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
|
||||||
<th>{{ t('ng_backup', 'Status') }}</th>
|
|
||||||
<th>{{ t('ng_backup', 'Files') }}</th>
|
|
||||||
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
|
||||||
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
|
|
||||||
<td>{{ targetName(run.targetId) }}</td>
|
|
||||||
<td>{{ run.kind }}</td>
|
|
||||||
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
|
||||||
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
|
||||||
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
|
||||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</NcSettingsSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
|
||||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
|
||||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
|
||||||
import api from '../api.js'
|
|
||||||
import { formatSize } from '../format.js'
|
|
||||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
|
||||||
import { reactive } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({ status: { type: Object, required: true } })
|
|
||||||
const emit = defineEmits(['changed'])
|
|
||||||
const schedule = ref(props.status.schedule)
|
|
||||||
const error = ref('')
|
|
||||||
watch(() => props.status.schedule, (s) => { schedule.value = s })
|
|
||||||
|
|
||||||
const trash = reactive({})
|
|
||||||
async function loadTrash() {
|
|
||||||
for (const target of props.status.targets) {
|
|
||||||
try {
|
|
||||||
trash[target.id] = await api.trash(target.id)
|
|
||||||
} catch (e) {
|
|
||||||
trash[target.id] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
watch(() => props.status.targets.map(tg => tg.id).join(','), loadTrash, { immediate: true })
|
|
||||||
|
|
||||||
async function untrash(target, item) {
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
await confirmPassword()
|
|
||||||
await api.untrash(target.id, item.id)
|
|
||||||
await loadTrash()
|
|
||||||
emit('changed')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e?.message || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
|
|
||||||
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
|
|
||||||
|
|
||||||
async function start(target) {
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
await api.startBackup(target.id, '')
|
|
||||||
emit('changed')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSchedule() {
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
await api.setSchedule(schedule.value.trim())
|
|
||||||
emit('changed')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
|
||||||
.ngb-time { max-width: 260px; }
|
|
||||||
.ngb-table { width: 100%; border-collapse: collapse; }
|
|
||||||
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
|
|
||||||
.ngb-failed { color: var(--color-error-text); }
|
|
||||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
|
||||||
.ngb-trash { margin: 12px 0; }
|
|
||||||
.ngb-trash h3 { font-weight: bold; }
|
|
||||||
.ngb-done { color: var(--color-success-text); }
|
|
||||||
</style>
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
<!--
|
|
||||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<NcSettingsSection :name="t('ng_backup', 'Backup key and recovery kit')"
|
|
||||||
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups. To restore on a new server you need the recovery kit and one of the passphrases.')">
|
|
||||||
<!-- 1. Create the key -->
|
|
||||||
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
|
|
||||||
<NcTextField v-model="label" :label="t('ng_backup', 'Who keeps this passphrase (e.g. Safe, CTO)')" />
|
|
||||||
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
|
|
||||||
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
|
|
||||||
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
|
|
||||||
<NcTextField v-model="delay" type="number" min="1" max="365"
|
|
||||||
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
|
|
||||||
:helper-text="t('ng_backup', 'Protects against a malicious or mistaken cleanup. Cannot be changed later.')" />
|
|
||||||
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
|
|
||||||
{{ t('ng_backup', 'Create backup key') }}
|
|
||||||
</NcButton>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<p>
|
|
||||||
{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }} ·
|
|
||||||
{{ t('ng_backup', 'Removed backups are kept in the trash for {days} days', { days: status.key.deleteDelayDays }) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Passphrase slots -->
|
|
||||||
<h3>{{ t('ng_backup', 'Passphrases') }}</h3>
|
|
||||||
<p class="ngb-muted">
|
|
||||||
{{ t('ng_backup', 'Up to {max} passphrases can open the key, so one person leaving does not lock you out. For example: one in the safe, one with the CTO, one with the head of IT. Everyone keeps the same recovery kit and only knows their own passphrase.', { max: status.key.maxSlots }) }}
|
|
||||||
</p>
|
|
||||||
<table class="ngb-table">
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="slot in status.key.slots" :key="slot.slot">
|
|
||||||
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
|
|
||||||
<td><strong>{{ slot.label }}</strong></td>
|
|
||||||
<td class="ngb-muted">{{ slot.created ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
|
|
||||||
<td>
|
|
||||||
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
|
|
||||||
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<NcButton v-if="!slotForm && status.key.slots.length < status.key.maxSlots" @click="openSlotForm(null)">{{ t('ng_backup', 'Add passphrase') }}</NcButton>
|
|
||||||
<form v-if="slotForm" class="ngb-form" @submit.prevent="saveSlot">
|
|
||||||
<NcTextField v-model="slotForm.label" :label="t('ng_backup', 'Who keeps this passphrase')" />
|
|
||||||
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
|
|
||||||
<NcPasswordField v-model="slotForm.pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password" :error="slotForm.pass2 !== '' && slotForm.pass1 !== slotForm.pass2" />
|
|
||||||
<div class="ngb-row">
|
|
||||||
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 12 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
|
|
||||||
<NcButton @click="slotForm = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Recovery kit -->
|
|
||||||
<h3>{{ t('ng_backup', 'Recovery kit') }}</h3>
|
|
||||||
<div v-if="!status.key.confirmation" class="ngb-confirm">
|
|
||||||
<NcNoteCard :type="status.key.everConfirmed ? 'error' : 'warning'">
|
|
||||||
{{ status.key.everConfirmed
|
|
||||||
? t('ng_backup', 'The passphrases changed: download the new recovery kit, give a copy to every passphrase holder and confirm. Backups keep running meanwhile.')
|
|
||||||
: t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with the passphrase, somewhere safe outside this server.') }}
|
|
||||||
</NcNoteCard>
|
|
||||||
<NcButton @click="downloadKit">{{ t('ng_backup', 'Download recovery kit') }}</NcButton>
|
|
||||||
<NcTextField v-model="code" :disabled="!downloaded" :label="t('ng_backup', 'Confirmation code from the kit file')"
|
|
||||||
:helper-text="t('ng_backup', 'Open the downloaded file and type the value of confirmation_code, e.g. ABCD-2345.')" />
|
|
||||||
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">{{ status.statement }}</NcCheckboxRadioSwitch>
|
|
||||||
<NcButton variant="primary" :disabled="!downloaded || !accepted || code.length < 8 || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
|
|
||||||
</div>
|
|
||||||
<p v-else>
|
|
||||||
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
|
|
||||||
<NcButton variant="tertiary" @click="downloadKit">{{ t('ng_backup', 'Download the kit again') }}</NcButton>
|
|
||||||
<span class="ngb-muted">{{ t('ng_backup', 'Requires your password; all administrators are notified.') }}</span>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
|
||||||
</NcSettingsSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
|
||||||
import '@nextcloud/password-confirmation/style.css'
|
|
||||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
|
||||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
|
||||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
|
||||||
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
|
||||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
|
||||||
import api from '../api.js'
|
|
||||||
|
|
||||||
const props = defineProps({ status: { type: Object, required: true } })
|
|
||||||
const emit = defineEmits(['changed'])
|
|
||||||
|
|
||||||
const label = ref('')
|
|
||||||
const pass1 = ref('')
|
|
||||||
const pass2 = ref('')
|
|
||||||
const delay = ref(props.status.key.deleteDelayDays)
|
|
||||||
const slotForm = ref(null)
|
|
||||||
const downloaded = ref(false)
|
|
||||||
const accepted = ref(false)
|
|
||||||
const code = ref('')
|
|
||||||
const busy = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const kitUrl = api.kitUrl()
|
|
||||||
|
|
||||||
/** Run fn after a password confirmation; report errors in the card. */
|
|
||||||
async function guarded(fn) {
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
await confirmPassword()
|
|
||||||
await fn()
|
|
||||||
emit('changed')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e?.message || ''
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createKey = () => guarded(async () => {
|
|
||||||
await api.initKey(pass1.value, Number(delay.value), label.value || 'Slot 1')
|
|
||||||
pass1.value = pass2.value = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const downloadKit = () => guarded(async () => {
|
|
||||||
window.location.href = kitUrl
|
|
||||||
downloaded.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
function openSlotForm(slot) {
|
|
||||||
slotForm.value = { slot: slot?.slot ?? null, label: slot?.label ?? '', pass1: '', pass2: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveSlot = () => guarded(async () => {
|
|
||||||
const f = slotForm.value
|
|
||||||
if (f.slot === null) {
|
|
||||||
await api.addSlot(f.pass1, f.label || 'Slot')
|
|
||||||
} else {
|
|
||||||
await api.replaceSlot(f.slot, f.pass1, f.label || null)
|
|
||||||
}
|
|
||||||
slotForm.value = null
|
|
||||||
downloaded.value = accepted.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeSlot = (slot) => guarded(async () => {
|
|
||||||
await api.removeSlot(slot.slot)
|
|
||||||
downloaded.value = accepted.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
const confirm = () => guarded(async () => {
|
|
||||||
await api.confirmKit(accepted.value, props.status.confirmationPhrase, code.value)
|
|
||||||
code.value = ''
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
|
||||||
.ngb-form, .ngb-confirm { display: flex; flex-direction: column; gap: 12px; max-width: 600px; margin: 8px 0; }
|
|
||||||
.ngb-row { display: flex; gap: 8px; }
|
|
||||||
.ngb-table td { padding: 2px 12px 2px 0; }
|
|
||||||
h3 { margin-top: 16px; font-weight: bold; }
|
|
||||||
</style>
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
<!--
|
|
||||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<NcSettingsSection :name="t('ng_backup', 'Backup locations')"
|
|
||||||
:description="t('ng_backup', 'Uses the connection types of External storage (S3, SFTP, WebDAV, ...), but never mounts them: the location is only connected while a backup or restore runs. Credentials are stored encrypted.')">
|
|
||||||
<ul v-if="status.targets.length" class="ngb-list">
|
|
||||||
<li v-for="target in status.targets" :key="target.id">
|
|
||||||
<strong>{{ target.name }}</strong>
|
|
||||||
<span class="ngb-muted">{{ target.backend }} · {{ target.path }}{{ target.appendOnly ? ' · ' + t('ng_backup', 'append-only') : '' }}</span>
|
|
||||||
<span :class="usageClass(target)">{{ usageText(target) }}</span>
|
|
||||||
<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 }}
|
|
||||||
</span>
|
|
||||||
<NcButton variant="tertiary" @click="test(target)">{{ t('ng_backup', 'Test') }}</NcButton>
|
|
||||||
<NcButton variant="tertiary" @click="remove(target)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<NcButton v-if="!adding" @click="startAdd">{{ t('ng_backup', 'Add location') }}</NcButton>
|
|
||||||
<form v-else class="ngb-form" @submit.prevent="add">
|
|
||||||
<NcTextField v-model="form.name" :label="t('ng_backup', 'Name')" />
|
|
||||||
<NcSelect v-model="backend" :options="backends" label="name" :input-label="t('ng_backup', 'Type')" :clearable="false" />
|
|
||||||
<NcSelect v-if="backend" v-model="auth" :options="backend.auth" label="name" :input-label="t('ng_backup', 'Authentication')" :clearable="false" />
|
|
||||||
<template v-for="field in fields" :key="field.key">
|
|
||||||
<NcCheckboxRadioSwitch v-if="field.type === 1" v-model="form.options[field.key]">{{ field.label }}</NcCheckboxRadioSwitch>
|
|
||||||
<NcPasswordField v-else-if="field.type === 2" v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" autocomplete="off" />
|
|
||||||
<NcTextField v-else v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" />
|
|
||||||
</template>
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
|
||||||
</NcSettingsSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
|
||||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
|
||||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
|
||||||
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
|
||||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
|
||||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
|
||||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
|
||||||
import '@nextcloud/password-confirmation/style.css'
|
|
||||||
import api from '../api.js'
|
|
||||||
|
|
||||||
defineProps({ status: { type: Object, required: true } })
|
|
||||||
const emit = defineEmits(['changed'])
|
|
||||||
|
|
||||||
const adding = ref(false)
|
|
||||||
const busy = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const backends = ref([])
|
|
||||||
const backend = ref(null)
|
|
||||||
const auth = ref(null)
|
|
||||||
const tests = reactive({})
|
|
||||||
const form = reactive({ name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
|
||||||
|
|
||||||
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
|
|
||||||
const toFields = (params) => Object.entries(params || {})
|
|
||||||
.filter(([, p]) => !(p.flags & 4))
|
|
||||||
.map(([key, p]) => ({ key, label: p.value, type: p.type, optional: !!(p.flags & 1) }))
|
|
||||||
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
|
|
||||||
|
|
||||||
watch(backend, (b) => {
|
|
||||||
auth.value = b?.auth?.[0] ?? null
|
|
||||||
form.options = {}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function startAdd() {
|
|
||||||
adding.value = true
|
|
||||||
error.value = ''
|
|
||||||
if (!backends.value.length) {
|
|
||||||
try {
|
|
||||||
backends.value = await api.backends()
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add() {
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
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
|
|
||||||
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
|
||||||
emit('changed')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function test(target) {
|
|
||||||
tests[target.id] = await api.testTarget(target.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(target) {
|
|
||||||
if (!window.confirm(t('ng_backup', 'Remove "{name}" from NG Backup? The backups on the location itself are kept.', { name: target.name }))) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await confirmPassword()
|
|
||||||
await api.removeTarget(target.id)
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e?.message || ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
emit('changed')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ngb-list li { display: flex; align-items: center; gap: 12px; padding: 4px 0; }
|
|
||||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
|
||||||
.ngb-ok { color: var(--color-success-text); }
|
|
||||||
.ngb-err { color: var(--color-error-text); }
|
|
||||||
.ngb-form { display: flex; flex-direction: column; gap: 10px; max-width: 600px; margin-top: 12px; }
|
|
||||||
.ngb-row { display: flex; gap: 8px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<!--
|
|
||||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<NcSettingsSection :name="t('ng_backup', 'Restore')"
|
|
||||||
: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">
|
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="snapshot">
|
|
||||||
<div class="ngb-crumbs">
|
|
||||||
<NcButton variant="tertiary" @click="open('data')">data</NcButton>
|
|
||||||
<template v-for="(crumb, i) in crumbs" :key="crumb.path">
|
|
||||||
<span>/</span>
|
|
||||||
<NcButton variant="tertiary" :disabled="i === crumbs.length - 1" @click="open(crumb.path)">{{ crumb.name }}</NcButton>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<NcLoadingIcon v-if="loading" />
|
|
||||||
<ul v-else class="ngb-entries">
|
|
||||||
<li v-for="item in items" :key="item.name" :class="{ selected: selected === join(item.name) }">
|
|
||||||
<NcButton v-if="item.type === 'dir'" variant="tertiary" @click="open(join(item.name))">📁 {{ item.name }}/</NcButton>
|
|
||||||
<span v-else class="ngb-file">📄 {{ item.name }}</span>
|
|
||||||
<span class="ngb-muted">{{ formatSize(item.size) }}{{ item.type === 'dir' ? ' · ' + item.files + ' files' : '' }}</span>
|
|
||||||
<NcButton v-if="restorable(join(item.name))" variant="tertiary" @click="selected = join(item.name)">{{ t('ng_backup', 'Select') }}</NcButton>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<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">
|
|
||||||
<p>{{ t('ng_backup', 'Selected: {path}, from the restore point of {date}', { path: selected, date: snapshotLabel(snapshot) }) }}</p>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<NcNoteCard v-if="message" type="success">{{ message }}</NcNoteCard>
|
|
||||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
|
||||||
</NcSettingsSection>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { t } from '@nextcloud/l10n'
|
|
||||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
|
||||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
|
||||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
|
||||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
|
||||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
|
||||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
|
||||||
import '@nextcloud/password-confirmation/style.css'
|
|
||||||
import api from '../api.js'
|
|
||||||
import { formatSize } from '../format.js'
|
|
||||||
|
|
||||||
const props = defineProps({ status: { type: Object, required: true } })
|
|
||||||
const emit = defineEmits(['changed'])
|
|
||||||
|
|
||||||
const target = ref(props.status.targets[0] ?? null)
|
|
||||||
const snapshots = ref([])
|
|
||||||
const snapshot = ref(null)
|
|
||||||
const path = ref('data')
|
|
||||||
const items = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const selected = ref('')
|
|
||||||
const mode = ref('new-folder')
|
|
||||||
const busy = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const message = ref('')
|
|
||||||
const modes = [
|
|
||||||
{ id: 'new-folder', label: t('ng_backup', 'Into a new folder "Restored <date>" (nothing existing is changed)') },
|
|
||||||
{ id: 'merge', label: t('ng_backup', 'Into the original place, overwrite (old content kept as a version)') },
|
|
||||||
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
|
|
||||||
]
|
|
||||||
|
|
||||||
const snapshotLabel = (s) => new Date(s.createdAt * 1000).toLocaleString() + (s.label ? ' – ' + s.label : '')
|
|
||||||
const join = (name) => path.value + '/' + name
|
|
||||||
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
|
|
||||||
const crumbs = computed(() => {
|
|
||||||
const parts = path.value.split('/').slice(1)
|
|
||||||
return parts.map((name, i) => ({ name, path: ['data', ...parts.slice(0, i + 1)].join('/') }))
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(target, async (tg) => {
|
|
||||||
snapshot.value = null
|
|
||||||
const list = tg ? await api.snapshots(tg.id) : []
|
|
||||||
snapshots.value = list.map(s => ({ ...s, displayLabel: snapshotLabel(s) }))
|
|
||||||
snapshot.value = snapshots.value[0] ?? null
|
|
||||||
}, { immediate: true })
|
|
||||||
watch(snapshot, () => { selected.value = ''; open('data') })
|
|
||||||
|
|
||||||
async function open(p) {
|
|
||||||
if (!snapshot.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path.value = p
|
|
||||||
loading.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
items.value = await api.browse(target.value.id, snapshot.value.id, p)
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restore() {
|
|
||||||
busy.value = true
|
|
||||||
error.value = message.value = ''
|
|
||||||
try {
|
|
||||||
await confirmPassword()
|
|
||||||
await api.startRestore(target.value.id, snapshot.value.id, selected.value, mode.value)
|
|
||||||
message.value = t('ng_backup', 'Restore queued. It runs with the next cron job; progress is shown under Backups.')
|
|
||||||
emit('changed')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
||||||
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
|
|
||||||
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
|
|
||||||
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
|
|
||||||
.ngb-file { padding-inline-start: 12px; }
|
|
||||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
|
||||||
.ngb-restore { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; max-width: 700px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
export function formatSize(bytes) {
|
|
||||||
if (bytes === null || bytes === undefined) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
|
||||||
let i = 0
|
|
||||||
let b = bytes
|
|
||||||
while (b >= 1024 && i < units.length - 1) {
|
|
||||||
b /= 1024
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return i === 0 ? `${b} B` : `${b.toFixed(1)} ${units[i]}`
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
?>
|
|
||||||
<div id="ng-backup-admin"></div>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Tests\Unit\Crypto;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Crypto\CryptoException;
|
|
||||||
use OCA\NgBackup\Crypto\KeyRing;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class KeyRingTest extends TestCase {
|
|
||||||
public function testWrapUnwrapKeepsKeys(): void {
|
|
||||||
$keys = KeyRing::generate();
|
|
||||||
$restored = KeyRing::unwrap($keys->wrap('pass phrase'), 'pass phrase');
|
|
||||||
$this->assertSame($keys->blobId('data'), $restored->blobId('data'));
|
|
||||||
$this->assertSame($keys->dataKey(), $restored->dataKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWrongPassphrase(): void {
|
|
||||||
$wrapped = KeyRing::generate()->wrap('right');
|
|
||||||
$this->expectException(CryptoException::class);
|
|
||||||
KeyRing::unwrap($wrapped, 'wrong');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBlobIdsAreKeyed(): void {
|
|
||||||
$this->assertNotSame(KeyRing::generate()->blobId('same'), KeyRing::generate()->blobId('same'));
|
|
||||||
$k = KeyRing::generate();
|
|
||||||
$this->assertSame($k->blobId('same'), $k->blobId('same'));
|
|
||||||
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $k->blobId('x'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRejectsShortMasterKey(): void {
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
new KeyRing('short');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAnySlotOpensTheSameKey(): void {
|
|
||||||
$keys = KeyRing::generate();
|
|
||||||
$field = ['slots' => [
|
|
||||||
['slot' => 1, 'label' => 'Safe', 'key' => $keys->wrap('passphrase in the safe')],
|
|
||||||
['slot' => 2, 'label' => 'CTO', 'key' => $keys->wrap('passphrase of the cto')],
|
|
||||||
['slot' => 3, 'label' => 'Head of IT', 'key' => $keys->wrap('passphrase head of it')],
|
|
||||||
]];
|
|
||||||
foreach (['passphrase in the safe', 'passphrase of the cto', 'passphrase head of it'] as $p) {
|
|
||||||
$this->assertSame($keys->blobId('x'), KeyRing::unwrapAny($field, $p)->blobId('x'));
|
|
||||||
}
|
|
||||||
$this->assertSame($keys->blobId('x'), KeyRing::unwrapAny($keys->wrap('legacy single'), 'legacy single')->blobId('x'));
|
|
||||||
$this->expectException(CryptoException::class);
|
|
||||||
KeyRing::unwrapAny($field, 'nobody knows this');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Tests\Unit\Crypto;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Crypto\CryptoException;
|
|
||||||
use OCA\NgBackup\Crypto\KeyRing;
|
|
||||||
use OCA\NgBackup\Crypto\StreamCipher;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class StreamCipherTest extends TestCase {
|
|
||||||
private StreamCipher $cipher;
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
$this->cipher = new StreamCipher(KeyRing::generate());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function sizes(): array {
|
|
||||||
$f = StreamCipher::FRAME_SIZE;
|
|
||||||
return ['empty' => [0], 'small' => [100], 'one frame' => [$f], 'frame+1' => [$f + 1], 'several' => [3 * $f + 17]];
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('sizes')]
|
|
||||||
public function testRoundTrip(int $size): void {
|
|
||||||
$plain = random_bytes(max(1, $size));
|
|
||||||
$plain = substr($plain, 0, $size);
|
|
||||||
$this->assertSame($plain, $this->cipher->decryptString($this->cipher->encryptString($plain, 'ad'), 'ad'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testStreamingKeepsMemoryBounded(): void {
|
|
||||||
$in = fopen('php://temp', 'w+b');
|
|
||||||
for ($i = 0; $i < 32; $i++) {
|
|
||||||
fwrite($in, random_bytes(1048576));
|
|
||||||
}
|
|
||||||
rewind($in);
|
|
||||||
$enc = fopen('php://temp', 'w+b');
|
|
||||||
memory_reset_peak_usage();
|
|
||||||
$base = memory_get_usage();
|
|
||||||
$this->cipher->encrypt($in, $enc);
|
|
||||||
$this->assertLessThan(4 * 1048576, memory_get_peak_usage() - $base);
|
|
||||||
rewind($enc);
|
|
||||||
$out = fopen('php://temp', 'w+b');
|
|
||||||
$this->assertSame(32 * 1048576, $this->cipher->decrypt($enc, $out));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function corruptions(): array {
|
|
||||||
return [
|
|
||||||
'flipped byte' => [static function (string $c): string { $c[60] = chr(ord($c[60]) ^ 1); return $c; }],
|
|
||||||
'truncated' => [static fn (string $c): string => substr($c, 0, -10)],
|
|
||||||
'trailing data' => [static fn (string $c): string => $c . 'x'],
|
|
||||||
'cut at frame boundary' => [static fn (string $c): string => substr($c, 0, 4 + 24 + 4 + StreamCipher::FRAME_SIZE + 17)],
|
|
||||||
'bad magic' => [static fn (string $c): string => 'XXXX' . substr($c, 4)],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('corruptions')]
|
|
||||||
public function testCorruptionIsRejected(callable $corrupt): void {
|
|
||||||
$cipher = $this->cipher->encryptString(str_repeat('a', StreamCipher::FRAME_SIZE + 500), 'ad');
|
|
||||||
$this->expectException(CryptoException::class);
|
|
||||||
$this->cipher->decryptString($corrupt($cipher), 'ad');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWrongAssociatedDataIsRejected(): void {
|
|
||||||
$this->expectException(CryptoException::class);
|
|
||||||
$this->cipher->decryptString($this->cipher->encryptString('x', 'blob:1'), 'blob:2');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWrongKeyIsRejected(): void {
|
|
||||||
$this->expectException(CryptoException::class);
|
|
||||||
(new StreamCipher(KeyRing::generate()))->decryptString($this->cipher->encryptString('x'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace OCA\NgBackup\Tests\Unit\Job;
|
|
||||||
|
|
||||||
use OCA\NgBackup\Backend\LocalBackend;
|
|
||||||
use OCA\NgBackup\Crypto\KeyRing;
|
|
||||||
use OCA\NgBackup\Job\BackupRun;
|
|
||||||
use OCA\NgBackup\Repository\Repository;
|
|
||||||
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class BackupRunTest extends TestCase {
|
|
||||||
use TempDirTrait;
|
|
||||||
|
|
||||||
public function testRunInManyTinyStepsRestoresIdentically(): void {
|
|
||||||
$src = $this->tempDir();
|
|
||||||
mkdir("$src/a", 0700, true);
|
|
||||||
for ($i = 0; $i < 50; $i++) {
|
|
||||||
file_put_contents("$src/a/f$i.txt", random_bytes(20000));
|
|
||||||
}
|
|
||||||
file_put_contents("$src/big.bin", random_bytes(13 * 1048576));
|
|
||||||
$backend = new LocalBackend($this->tempDir());
|
|
||||||
$keys = KeyRing::generate();
|
|
||||||
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
|
||||||
|
|
||||||
$state = BackupRun::start($src);
|
|
||||||
$steps = 0;
|
|
||||||
while ($state['phase'] !== 'done' && $steps < 500) {
|
|
||||||
// Fresh repository object per step, state through JSON: as separate processes would.
|
|
||||||
$repo = Repository::openWithKey($backend, $keys);
|
|
||||||
$state = json_decode(json_encode(BackupRun::step($repo, $state, microtime(true) + 0.05)), true);
|
|
||||||
$steps++;
|
|
||||||
}
|
|
||||||
$this->assertSame('done', $state['phase']);
|
|
||||||
$this->assertGreaterThan(2, $steps, 'the run was actually split into steps');
|
|
||||||
|
|
||||||
$target = $this->tempDir();
|
|
||||||
Repository::openWithKey($backend, $keys)->restore($state['snapshot'], $target);
|
|
||||||
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFileChangedBetweenStepsIsReadAgain(): void {
|
|
||||||
$src = $this->tempDir();
|
|
||||||
file_put_contents("$src/big.bin", random_bytes(20 * 1048576));
|
|
||||||
$backend = new LocalBackend($this->tempDir());
|
|
||||||
$keys = KeyRing::generate();
|
|
||||||
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
|
||||||
|
|
||||||
$state = BackupRun::start($src);
|
|
||||||
$mutated = false;
|
|
||||||
for ($i = 0; $i < 500 && $state['phase'] !== 'done'; $i++) {
|
|
||||||
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.01);
|
|
||||||
if (!$mutated && $state['cur'] !== null && $state['cur']['offset'] > 0) {
|
|
||||||
$fh = fopen("$src/big.bin", 'r+b');
|
|
||||||
fwrite($fh, random_bytes(1024)); // same size, new content at the start
|
|
||||||
fclose($fh);
|
|
||||||
touch("$src/big.bin", time() + 10);
|
|
||||||
$mutated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->assertTrue($mutated);
|
|
||||||
$this->assertGreaterThanOrEqual(1, $state['stats']['retried'] ?? 0);
|
|
||||||
$target = $this->tempDir();
|
|
||||||
Repository::openWithKey($backend, $keys)->restore($state['snapshot'], $target);
|
|
||||||
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIncrementalRunInTinyStepsReadsNothing(): void {
|
|
||||||
$src = $this->tempDir();
|
|
||||||
for ($i = 0; $i < 200; $i++) {
|
|
||||||
@mkdir("$src/d" . ($i % 7), 0700, true);
|
|
||||||
file_put_contents("$src/d" . ($i % 7) . "/f$i", random_bytes(3000));
|
|
||||||
}
|
|
||||||
$backend = new LocalBackend($this->tempDir());
|
|
||||||
$keys = KeyRing::generate();
|
|
||||||
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
|
||||||
$run = function (?string $parent) use ($backend, $src, $keys): array {
|
|
||||||
$state = BackupRun::start(['data' => $src], $parent);
|
|
||||||
for ($i = 0; $i < 2000 && $state['phase'] !== 'done'; $i++) {
|
|
||||||
$state = json_decode(json_encode(BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true))), true);
|
|
||||||
}
|
|
||||||
return $state;
|
|
||||||
};
|
|
||||||
$first = $run(null);
|
|
||||||
$second = $run($first['snapshot']);
|
|
||||||
$this->assertSame('done', $second['phase']);
|
|
||||||
$this->assertGreaterThan(150, $second["steps"], "one file per step");
|
|
||||||
$this->assertSame(0, $second['stats']['read'], 'merge-join with the parent across steps reuses every file');
|
|
||||||
$this->assertSame(200, $second['stats']['reused']);
|
|
||||||
$target = $this->tempDir();
|
|
||||||
Repository::openWithKey($backend, $keys)->restore($second['snapshot'], $target);
|
|
||||||
$this->assertSame(self::hashTree($src), self::hashTree("$target/data"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user