Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71a3984cf9 | |||
| bd850b137e | |||
| f98487a060 | |||
| 62e365350f | |||
| 8b822a73ac | |||
| 8ef9dc0f35 | |||
| 075f2fac6f | |||
| 2933608747 | |||
| 450e1a5ee6 | |||
| 113e8fe6ef | |||
| 0309f2e44e | |||
| ebd9737f51 | |||
| 05e2c9fe45 | |||
| 446e317786 | |||
| bc5a589ed8 | |||
| c3d9ad6041 | |||
| bf3437ae10 | |||
| 08e4ed52d8 | |||
| 1be00ba1a0 | |||
| 155f657886 | |||
| 0b4246b1b3 | |||
| ab3f0ea948 | |||
| 74de572160 | |||
| f30074c4ae | |||
| b6a51e6764 |
@@ -25,18 +25,6 @@ jobs:
|
||||
- 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.
|
||||
@@ -50,13 +38,3 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/build/
|
||||
cred-*
|
||||
*credentials*
|
||||
/css/
|
||||
/js/
|
||||
/node_modules/
|
||||
.phpunit.result.cache
|
||||
*.secret
|
||||
/spikes/out/
|
||||
/vendor/
|
||||
|
||||
@@ -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
|
||||
|
||||
In plaats van zelf te bepalen welke gegevens van een gebruiker meegaan, hergebruiken we
|
||||
**user_migration** (officiële Nextcloud-app, actief onderhouden, NC29–36; draait ook op onze eigen productie-instantie).
|
||||
Die app werkt met *migrators* die andere apps zelf aanleveren. Op onze productie-instantie zijn dat er al:
|
||||
**user_migration** (officiële Nextcloud-app, actief onderhouden, NC29–36; staat ook op nc01).
|
||||
Die app werkt met *migrators* die andere apps zelf aanleveren. Op nc01 hebben die er al:
|
||||
calendar, dav (agenda's en contactpersonen), deck, files_trashbin, mail, settings (profiel,
|
||||
account, app-instellingen) en de bestanden zelf. Elke app die later een migrator toevoegt, gaat
|
||||
automatisch mee.
|
||||
@@ -308,14 +308,14 @@ niet met de hunne, maar bij installatie waarschuwen als `backup` ook actief is.
|
||||
| 2 | Backup/restore per gebruiker via user_migration, volledige restore op verse installatie, FTP/SMB, verificatie | 3–4 weken |
|
||||
| 3 | In-place volledige restore (restore-modus), zelfbediening voor gebruikers, extra doelen | open |
|
||||
|
||||
Release in de app store pas na fase 1 en een periode van eigen gebruik op onze productie-instantie (net als occweb):
|
||||
Release in de app store pas na fase 1 en een periode van eigen gebruik op nc01 (net als occweb):
|
||||
een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
|
||||
|
||||
## 8. Besluiten (2026-10-01)
|
||||
|
||||
1. **Doelgroep:** alle varianten: AIO, losse installaties én gehoste Nextcloud zonder SSH.
|
||||
2. **Licentie:** AGPL-3.0-or-later.
|
||||
3. **Repository:** eigen Gitea + GitHub (dillardblom), zoals occweb.
|
||||
3. **Repository:** Ensembia op Gitea + GitHub (dillardblom), zoals occweb.
|
||||
4. **Naam:** "NG Backup", app-id `ng_backup`, occ-voorvoegsel `backup:`.
|
||||
5. **Herstelkit:** verplicht downloaden + expliciete bevestiging door de beheerder (vinkje +
|
||||
"Yes, I confirm"), zie 3.2; versleuteld per e-mail als optionele extra kopie.
|
||||
@@ -338,11 +338,6 @@ een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
|
||||
Volgorde: fase 1 grondig testen, reviewen en debuggen; dan nadenken over 0.1.0-alpha.1. Een
|
||||
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)
|
||||
|
||||
- Externe programma's (`pg_dump`) en extensies (`pgsql`, `mysqli`) → onbruikbaar op standaard/hosted.
|
||||
|
||||
@@ -106,19 +106,6 @@ What you should do on the location (strongly recommended):
|
||||
- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`.
|
||||
- **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 |
|
||||
|
||||
+2
-7
@@ -4,12 +4,8 @@
|
||||
<id>ng_backup</id>
|
||||
<name>NG Backup</name>
|
||||
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
||||
<description><![CDATA[Alpha: feature-complete for scheduled, encrypted, incremental backups to
|
||||
S3/SFTP/WebDAV/local, with restore of files and folders from the web interface and occ. Meant for
|
||||
testing on a copy of production (dev, test, staging) -- not yet for production use, and full
|
||||
disaster recovery is not implemented yet. See the recovery kit section of the README before
|
||||
relying on this for anything.]]></description>
|
||||
<version>0.1.0</version>
|
||||
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
||||
<version>0.0.15</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Dillard Blom</author>
|
||||
<namespace>NgBackup</namespace>
|
||||
@@ -40,7 +36,6 @@ relying on this for anything.]]></description>
|
||||
<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>
|
||||
|
||||
@@ -20,7 +20,6 @@ return [
|
||||
['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'],
|
||||
|
||||
@@ -12,7 +12,7 @@ use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
|
||||
final class Application extends App implements IBootstrap {
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'ng_backup';
|
||||
|
||||
public function __construct() {
|
||||
|
||||
@@ -7,5 +7,5 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Backend;
|
||||
|
||||
final class BackendException extends \RuntimeException {
|
||||
class BackendException extends \RuntimeException {
|
||||
}
|
||||
|
||||
@@ -27,13 +27,6 @@ interface IBackend {
|
||||
|
||||
public function exists(string $path): bool;
|
||||
|
||||
/**
|
||||
* Move an object within the same location (used for the trash). Backends must use a native
|
||||
* rename, not a read+write copy: some storages (e.g. SFTP) cannot reliably read and write on
|
||||
* the same connection at once, since the read side is a lazily-pulled stream.
|
||||
*/
|
||||
public function move(string $from, string $to): void;
|
||||
|
||||
/** @return list<string> object paths below $prefix (recursive) */
|
||||
public function list(string $prefix): array;
|
||||
|
||||
|
||||
@@ -106,17 +106,6 @@ final class LocalBackend implements IBackend {
|
||||
@unlink($this->abs($path));
|
||||
}
|
||||
|
||||
public function move(string $from, string $to): void {
|
||||
$target = $this->abs($to);
|
||||
$dir = dirname($target);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
|
||||
throw new BackendException('Cannot create ' . $dir);
|
||||
}
|
||||
if (!rename($this->abs($from), $target)) {
|
||||
throw new BackendException("Cannot move $from to $to");
|
||||
}
|
||||
}
|
||||
|
||||
private function abs(string $path): string {
|
||||
if (str_contains($path, '..') || str_starts_with($path, '/')) {
|
||||
throw new BackendException('Invalid path: ' . $path);
|
||||
|
||||
@@ -115,14 +115,6 @@ final class StorageBackend implements IBackend {
|
||||
$this->storage->unlink($this->abs($path));
|
||||
}
|
||||
|
||||
public function move(string $from, string $to): void {
|
||||
$target = $this->abs($to);
|
||||
$this->mkdirs(dirname($target));
|
||||
if (!$this->storage->rename($this->abs($from), $target)) {
|
||||
throw new BackendException("Cannot move $from to $to");
|
||||
}
|
||||
}
|
||||
|
||||
private function listInto(string $dir, array &$result): void {
|
||||
if (!$this->storage->is_dir($dir)) {
|
||||
return;
|
||||
|
||||
@@ -22,7 +22,7 @@ use Psr\Log\LoggerInterface;
|
||||
* With system cron a step may take minutes; with webcron/AJAX cron it is kept short so it fits
|
||||
* in a normal web request (hosted Nextcloud).
|
||||
*/
|
||||
final class BackupJob extends TimedJob {
|
||||
class BackupJob extends TimedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private BackupService $backups,
|
||||
|
||||
@@ -16,7 +16,7 @@ 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 {
|
||||
class RestoreJob extends QueuedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private RunMapper $runs,
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class Browse extends Command {
|
||||
class Browse extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private RestoreService $restore,
|
||||
|
||||
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
final class KeyConfirm extends Command {
|
||||
class KeyConfirm extends Command {
|
||||
public const STATEMENT = 'I have downloaded the recovery kit and stored it safely, outside this server. '
|
||||
. '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';
|
||||
|
||||
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
final class KeyInit extends Command {
|
||||
class KeyInit extends Command {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
) {
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class KeyKit extends Command {
|
||||
class KeyKit extends Command {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\KeySlotService;
|
||||
|
||||
final class KeySlotAdd extends KeySlots {
|
||||
class KeySlotAdd extends KeySlots {
|
||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
||||
parent::__construct($keys, $slots, 'add');
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\KeySlotService;
|
||||
|
||||
final class KeySlotRemove extends KeySlots {
|
||||
class KeySlotRemove extends KeySlots {
|
||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
||||
parent::__construct($keys, $slots, 'remove');
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\KeySlotService;
|
||||
|
||||
final class KeySlotReplace extends KeySlots {
|
||||
class KeySlotReplace extends KeySlots {
|
||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
||||
parent::__construct($keys, $slots, 'replace');
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class ListSnapshots extends Command {
|
||||
class ListSnapshots extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private SnapshotMapper $snapshots,
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class Prune extends Command {
|
||||
class Prune extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private PruneService $prune,
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class Restore extends Command {
|
||||
class Restore extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private RestoreService $restore,
|
||||
|
||||
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class Retention extends Command {
|
||||
class Retention extends Command {
|
||||
public function __construct(
|
||||
private PruneService $prune,
|
||||
) {
|
||||
|
||||
@@ -16,7 +16,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class RunBackup extends Command {
|
||||
class RunBackup extends Command {
|
||||
public function __construct(
|
||||
private BackupService $backups,
|
||||
private TargetService $targets,
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class Status extends Command {
|
||||
class Status extends Command {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
private TargetService $targets,
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class TargetAdd extends Command {
|
||||
class TargetAdd extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private KeyService $keys,
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class TargetAppendOnly extends Command {
|
||||
class TargetAppendOnly extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||
|
||||
@@ -12,7 +12,7 @@ use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class TargetBackends extends Base {
|
||||
class TargetBackends extends Base {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class TargetList extends Base {
|
||||
class TargetList extends Base {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
) {
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class TargetRemove extends Command {
|
||||
class TargetRemove extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||
|
||||
@@ -22,7 +22,7 @@ use Symfony\Component\Console\Question\Question;
|
||||
* legitimate cases (e.g. the location was restored from the provider's own snapshot), only from
|
||||
* occ, audited and announced to all administrators.
|
||||
*/
|
||||
final class TargetTrustCurrent extends Command {
|
||||
class TargetTrustCurrent extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private IAppConfig $appConfig,
|
||||
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class Trash extends Command {
|
||||
class Trash extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private KeyService $keys,
|
||||
|
||||
@@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class TrashRestore extends Command {
|
||||
class TrashRestore extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private PruneService $prune,
|
||||
|
||||
@@ -32,7 +32,7 @@ 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 {
|
||||
class AdminApiController extends Controller {
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private KeyService $keys,
|
||||
@@ -55,8 +55,7 @@ final class AdminApiController extends Controller {
|
||||
$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)];
|
||||
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt()];
|
||||
}
|
||||
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
|
||||
return new JSONResponse([
|
||||
@@ -168,25 +167,15 @@ final class AdminApiController extends Controller {
|
||||
return new JSONResponse($this->targets->availableBackends());
|
||||
}
|
||||
|
||||
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse {
|
||||
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse {
|
||||
try {
|
||||
$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);
|
||||
@@ -270,17 +259,8 @@ final class AdminApiController extends Controller {
|
||||
}
|
||||
|
||||
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(),
|
||||
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), '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;
|
||||
|
||||
final class CryptoException extends \RuntimeException {
|
||||
class CryptoException extends \RuntimeException {
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 {
|
||||
class AppConfigCatalogAnchor implements ICatalogAnchor {
|
||||
public function __construct(
|
||||
private IAppConfig $appConfig,
|
||||
) {
|
||||
|
||||
@@ -11,7 +11,7 @@ use OCA\NgBackup\Repository\IIndexCache;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
final class DbIndexCache implements IIndexCache {
|
||||
class DbIndexCache implements IIndexCache {
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method int|null getFinishedAt()
|
||||
* @method void setFinishedAt(?int $v)
|
||||
*/
|
||||
final class Run extends Entity {
|
||||
class Run extends Entity {
|
||||
public const RUNNING = 'running';
|
||||
public const DONE = 'done';
|
||||
public const FAILED = 'failed';
|
||||
|
||||
@@ -12,7 +12,7 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/** @template-extends QBMapper<Run> */
|
||||
final class RunMapper extends QBMapper {
|
||||
class RunMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'ngb_runs', Run::class);
|
||||
}
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method int|null getBytes()
|
||||
* @method void setBytes(?int $v)
|
||||
*/
|
||||
final class Snapshot extends Entity {
|
||||
class Snapshot extends Entity {
|
||||
protected int $targetId = 0;
|
||||
protected string $snapshotId = '';
|
||||
protected string $kind = '';
|
||||
|
||||
@@ -12,7 +12,7 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/** @template-extends QBMapper<Snapshot> */
|
||||
final class SnapshotMapper extends QBMapper {
|
||||
class SnapshotMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'ngb_snapshots', Snapshot::class);
|
||||
}
|
||||
|
||||
+1
-5
@@ -24,12 +24,10 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @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 {
|
||||
class Target extends Entity {
|
||||
protected string $name = '';
|
||||
protected string $backend = '';
|
||||
protected string $auth = '';
|
||||
@@ -38,11 +36,9 @@ final class Target extends Entity {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/** @template-extends QBMapper<Target> */
|
||||
final class TargetMapper extends QBMapper {
|
||||
class TargetMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'ngb_targets', Target::class);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
final class Version000001Date20261002000000 extends SimpleMigrationStep {
|
||||
class Version000001Date20261002000000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 {
|
||||
class Version000002Date20261002010000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 {
|
||||
class Version000003Date20261002020000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/** Optional size limit per location (cost control; S3 has no bucket quota). */
|
||||
final class Version000004Date20261002030000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
$t = $schema->getTable('ngb_targets');
|
||||
if (!$t->hasColumn('max_bytes')) {
|
||||
$t->addColumn('max_bytes', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
||||
return $schema;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/** Leases with an expiry: a killed process cannot block a location for longer than its lease. */
|
||||
final class Version000005Date20261002040000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
if ($schema->hasTable('ngb_leases')) {
|
||||
return null;
|
||||
}
|
||||
$t = $schema->createTable('ngb_leases');
|
||||
$t->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 32]);
|
||||
$t->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 128]);
|
||||
$t->addColumn('mode', Types::STRING, ['notnull' => true, 'length' => 1]);
|
||||
$t->addColumn('expires_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
||||
$t->setPrimaryKey(['id']);
|
||||
$t->addIndex(['name'], 'ngb_leases_name');
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use OCP\Notification\INotification;
|
||||
use OCP\Notification\INotifier;
|
||||
use OCP\Notification\UnknownNotificationException;
|
||||
|
||||
final class Notifier implements INotifier {
|
||||
class Notifier implements INotifier {
|
||||
public function __construct(
|
||||
private IFactory $l10n,
|
||||
private IURLGenerator $url,
|
||||
@@ -42,10 +42,6 @@ final class Notifier implements INotifier {
|
||||
: $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.'));
|
||||
|
||||
@@ -139,15 +139,6 @@ final class BlobIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes of all blobs in the index (encrypted size, i.e. what is stored in packs). */
|
||||
public function storedBytes(): int {
|
||||
$total = 0;
|
||||
foreach ($this->blobs as [, , $length]) {
|
||||
$total += $length;
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function count(): int {
|
||||
return count($this->blobs);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ final class PackWriter {
|
||||
private StreamCipher $cipher,
|
||||
private BlobIndex $index,
|
||||
private int $targetSize = 32 * 1048576,
|
||||
private ?int $maxBytes = null,
|
||||
) {
|
||||
$this->reset();
|
||||
}
|
||||
@@ -65,12 +64,6 @@ final class PackWriter {
|
||||
if ($this->entries === []) {
|
||||
return;
|
||||
}
|
||||
if ($this->maxBytes !== null && $this->index->storedBytes() + $this->size > $this->maxBytes) {
|
||||
$this->index->dropPending(array_column($this->entries, 'id'));
|
||||
$this->reset();
|
||||
throw new QuotaExceededException(sprintf('Storage limit of this location reached (%.1f GiB): backup stopped',
|
||||
$this->maxBytes / 1073741824));
|
||||
}
|
||||
$packId = bin2hex(random_bytes(16));
|
||||
$header = $this->cipher->encryptString(json_encode($this->entries, JSON_THROW_ON_ERROR), 'pack-header:' . $packId);
|
||||
fwrite($this->buffer, $header . pack('N', strlen($header)));
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -32,7 +32,6 @@ final class Repository {
|
||||
private StreamCipher $cipher;
|
||||
private BlobIndex $index;
|
||||
private ?PackWriter $runPacks = null;
|
||||
private ?int $maxBytes = null;
|
||||
private Catalog $catalog;
|
||||
|
||||
private function __construct(
|
||||
@@ -94,15 +93,6 @@ final class Repository {
|
||||
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;
|
||||
@@ -155,7 +145,7 @@ final class Repository {
|
||||
public function backupDirectory(string $source, ?string $parent = null, string $label = ''): array {
|
||||
$source = rtrim($source, '/');
|
||||
$previous = $parent !== null ? $this->loadEntries($parent) : [];
|
||||
$packs = new PackWriter($this->backend, $this->cipher, $this->index, 32 * 1048576, $this->maxBytes);
|
||||
$packs = new PackWriter($this->backend, $this->cipher, $this->index);
|
||||
$stats = ['files' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0];
|
||||
|
||||
$tree = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
||||
@@ -236,7 +226,7 @@ final class Repository {
|
||||
* run is done, so the last pack is uploaded before anything references it.
|
||||
*/
|
||||
public function blobWriter(array &$stats): BlobWriter {
|
||||
$this->runPacks ??= new PackWriter($this->backend, $this->cipher, $this->index, 32 * 1048576, $this->maxBytes);
|
||||
$this->runPacks ??= new PackWriter($this->backend, $this->cipher, $this->index);
|
||||
$packs = $this->runPacks;
|
||||
return new BlobWriter(function (string $data) use ($packs, &$stats): string {
|
||||
return $this->storeBlob($data, $packs, $stats);
|
||||
@@ -268,7 +258,7 @@ final class Repository {
|
||||
|
||||
/** Store one blob of file data in the current run (deduplicated); returns its id. */
|
||||
public function storeData(string $data, array &$stats): string {
|
||||
$this->runPacks ??= new PackWriter($this->backend, $this->cipher, $this->index, 32 * 1048576, $this->maxBytes);
|
||||
$this->runPacks ??= new PackWriter($this->backend, $this->cipher, $this->index);
|
||||
return $this->storeBlob($data, $this->runPacks, $stats);
|
||||
}
|
||||
|
||||
@@ -345,19 +335,20 @@ final class Repository {
|
||||
$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);
|
||||
// The snapshot object is bound to its id (not its path), so it can be copied as-is.
|
||||
$this->backend->put('trash/snapshots/' . $snapshotId, $this->backend->get('snapshots/' . $snapshotId));
|
||||
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
|
||||
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
|
||||
unset($this->catalog->snapshots[$snapshotId]);
|
||||
$this->catalog->trash[$snapshotId] = true;
|
||||
$this->catalog->write();
|
||||
if ($dbInTrash !== null) {
|
||||
$this->backend->delete($meta['db']);
|
||||
}
|
||||
$this->backend->delete('snapshots/' . $snapshotId);
|
||||
}
|
||||
|
||||
/** Undo forget(): move a snapshot back from the trash. */
|
||||
@@ -367,10 +358,11 @@ final class Repository {
|
||||
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
|
||||
$this->backend->delete($info['dbInTrash']);
|
||||
}
|
||||
$this->backend->move('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId);
|
||||
$this->backend->put('snapshots/' . $snapshotId, $this->backend->get('trash/snapshots/' . $snapshotId));
|
||||
$this->catalog->snapshots[$snapshotId] = true;
|
||||
unset($this->catalog->trash[$snapshotId]);
|
||||
$this->catalog->write();
|
||||
$this->backend->delete('trash/snapshots/' . $snapshotId);
|
||||
$this->backend->delete('trash/info/' . $snapshotId);
|
||||
}
|
||||
|
||||
@@ -489,7 +481,6 @@ final class Repository {
|
||||
} 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) {
|
||||
|
||||
@@ -15,7 +15,7 @@ use OCP\IGroupManager;
|
||||
use OCP\Notification\IManager;
|
||||
|
||||
/** Notifies administrators about failed runs and locations without a recent successful backup. */
|
||||
final class AlertService {
|
||||
class AlertService {
|
||||
public const STALE_AFTER = 2 * 86400;
|
||||
|
||||
public function __construct(
|
||||
@@ -54,36 +54,6 @@ final class AlertService {
|
||||
['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);
|
||||
|
||||
@@ -28,7 +28,7 @@ use Psr\Log\LoggerInterface;
|
||||
* 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 {
|
||||
class BackupService {
|
||||
public const KIND_FULL = 'full';
|
||||
|
||||
public function __construct(
|
||||
@@ -42,7 +42,6 @@ final class BackupService {
|
||||
private ILockingProvider $locking,
|
||||
private LoggerInterface $logger,
|
||||
private AlertService $alerts,
|
||||
private LeaseService $leases,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -55,16 +54,17 @@ final class BackupService {
|
||||
}
|
||||
// Check and create the run under the location's exclusive lock, so a cleanup cannot start
|
||||
// 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];
|
||||
}
|
||||
$lock = PruneService::lockKey($target);
|
||||
try {
|
||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $this->createRun($target, $label));
|
||||
$this->locking->acquireLock($lock, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('A cleanup or another start for ' . $target->getName() . ' is running; try again later');
|
||||
}
|
||||
try {
|
||||
return $this->createRun($target, $label);
|
||||
} finally {
|
||||
$this->locking->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
private function createRun(Target $target, string $label): Run {
|
||||
@@ -103,18 +103,18 @@ final class BackupService {
|
||||
* 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));
|
||||
$lockKey = 'ng_backup/run/' . $run->getId();
|
||||
try {
|
||||
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
|
||||
$this->locking->acquireLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
} catch (LockedException) {
|
||||
return $run; // another process is doing this step
|
||||
return $run;
|
||||
}
|
||||
// Shared lease on the location while this step writes: a cleanup (exclusive) cannot run now.
|
||||
// Shared lock on the location while this step writes: a cleanup (exclusive) cannot run now.
|
||||
$targetLock = 'ng_backup/target/' . $run->getTargetId();
|
||||
try {
|
||||
$targetLease = $this->leases->acquire('ng_backup/target/' . $run->getTargetId(), LeaseService::SHARED, $ttl);
|
||||
$this->locking->acquireLock($targetLock, ILockingProvider::LOCK_SHARED);
|
||||
} catch (LockedException) {
|
||||
$this->leases->release($runLease);
|
||||
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
return $run;
|
||||
}
|
||||
try {
|
||||
@@ -149,7 +149,6 @@ final class BackupService {
|
||||
$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());
|
||||
@@ -172,22 +171,15 @@ final class BackupService {
|
||||
}
|
||||
return $run;
|
||||
} finally {
|
||||
$this->leases->release($targetLease);
|
||||
$this->leases->release($runLease);
|
||||
$this->locking->releaseLock($targetLock, ILockingProvider::LOCK_SHARED);
|
||||
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use OCP\Security\ICrypto;
|
||||
* 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 {
|
||||
class KeyService {
|
||||
private const KEY_MASTER = 'master_key';
|
||||
private const KEY_WRAPPED = 'master_key_wrapped';
|
||||
private const KEY_CONFIRMED = 'recovery_kit_confirmed';
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace OCA\NgBackup\Service;
|
||||
* 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 {
|
||||
class KeySlotService {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
private TargetService $targets,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use OCP\Lock\LockedException;
|
||||
* 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 {
|
||||
class PruneService {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private RunMapper $runs,
|
||||
@@ -29,16 +29,15 @@ final class PruneService {
|
||||
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);
|
||||
$this->lockExclusive($target);
|
||||
try {
|
||||
$this->doUntrash($target, $snapshotId);
|
||||
} finally {
|
||||
$this->leases->release($lease);
|
||||
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,27 +47,31 @@ final class PruneService {
|
||||
*/
|
||||
public function underStartLock(Target $target, callable $fn): mixed {
|
||||
try {
|
||||
return $this->leases->with(self::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $fn());
|
||||
$this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
|
||||
}
|
||||
try {
|
||||
return $fn();
|
||||
} finally {
|
||||
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusive lock on the location, then make sure no run of any kind (backup, restore, export)
|
||||
* is in progress: their data may not be referenced by a snapshot yet, or is being read.
|
||||
*/
|
||||
private function lockExclusive(Target $target, int $ttl): string {
|
||||
private function lockExclusive(Target $target): void {
|
||||
try {
|
||||
$lease = $this->leases->acquire(self::lockKey($target), LeaseService::EXCLUSIVE, $ttl);
|
||||
$this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
} 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);
|
||||
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
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 {
|
||||
@@ -123,7 +126,7 @@ final class PruneService {
|
||||
if ($target->getAppendOnly()) {
|
||||
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
|
||||
}
|
||||
$lease = $this->lockExclusive($target, 1800);
|
||||
$this->lockExclusive($target);
|
||||
try {
|
||||
$repo = $this->targets->repository($target);
|
||||
$times = [];
|
||||
@@ -157,7 +160,7 @@ final class PruneService {
|
||||
$prune = $repo->prune($dryRun);
|
||||
return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune];
|
||||
} finally {
|
||||
$this->leases->release($lease);
|
||||
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use OCP\Files\NotFoundException;
|
||||
* - 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 {
|
||||
class RestoreService {
|
||||
public const MODE_NEW_FOLDER = 'new-folder';
|
||||
public const MODE_MERGE = 'merge';
|
||||
public const MODE_REPLACE = 'replace';
|
||||
@@ -36,17 +36,23 @@ final class RestoreService {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private IRootFolder $root,
|
||||
private LeaseService $leases,
|
||||
private \OCP\Lock\ILockingProvider $locking,
|
||||
) {
|
||||
}
|
||||
|
||||
/** Shared lock on the location while restoring: a cleanup (exclusive) must not delete what is being read. */
|
||||
private function withSharedLock(Target $target, callable $fn, int $ttl = 600): mixed {
|
||||
private function withSharedLock(Target $target, callable $fn): mixed {
|
||||
$key = PruneService::lockKey($target);
|
||||
try {
|
||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, $fn);
|
||||
$this->locking->acquireLock($key, \OCP\Lock\ILockingProvider::LOCK_SHARED);
|
||||
} catch (\OCP\Lock\LockedException) {
|
||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later');
|
||||
}
|
||||
try {
|
||||
return $fn();
|
||||
} finally {
|
||||
$this->locking->releaseLock($key, \OCP\Lock\ILockingProvider::LOCK_SHARED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,20 +88,7 @@ final class RestoreService {
|
||||
* @return array{restored:int, trashed:int, target:string}
|
||||
*/
|
||||
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);
|
||||
});
|
||||
return $this->withSharedLock($target, fn () => $this->doRestoreUserFiles($target, $snapshotId, $logicalPath, $mode, $progress));
|
||||
}
|
||||
|
||||
private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress): array {
|
||||
@@ -157,7 +150,7 @@ final class RestoreService {
|
||||
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException("Cannot create $directory");
|
||||
}
|
||||
return $this->withSharedLock($target, fn () => $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/'), 3600);
|
||||
return $this->withSharedLock($target, fn () => $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/'));
|
||||
}
|
||||
|
||||
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
|
||||
|
||||
@@ -22,7 +22,7 @@ use OCP\Server;
|
||||
* 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 {
|
||||
class TargetService {
|
||||
public function __construct(
|
||||
private TargetMapper $mapper,
|
||||
private KeyService $keys,
|
||||
@@ -90,12 +90,6 @@ final class TargetService {
|
||||
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);
|
||||
@@ -115,7 +109,6 @@ final class TargetService {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\Settings\ISettings;
|
||||
use OCP\Util;
|
||||
|
||||
final class Admin implements ISettings {
|
||||
class Admin implements ISettings {
|
||||
public function getForm(): TemplateResponse {
|
||||
Util::addScript('ng_backup', 'ng_backup-admin');
|
||||
Util::addStyle('ng_backup', 'ng_backup-admin');
|
||||
|
||||
@@ -11,7 +11,7 @@ use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
final class AdminSection implements IIconSection {
|
||||
class AdminSection implements IIconSection {
|
||||
public function __construct(
|
||||
private IL10N $l,
|
||||
private IURLGenerator $url,
|
||||
|
||||
@@ -19,7 +19,7 @@ use OCP\SetupCheck\ISetupCheck;
|
||||
use OCP\SetupCheck\SetupResult;
|
||||
|
||||
/** Shows the backup state in the admin overview (Security & setup warnings). */
|
||||
final class BackupStatusCheck implements ISetupCheck {
|
||||
class BackupStatusCheck implements ISetupCheck {
|
||||
public function __construct(
|
||||
private IL10N $l,
|
||||
private IURLGenerator $url,
|
||||
|
||||
@@ -8,7 +8,6 @@ declare(strict_types=1);
|
||||
namespace OCA\NgBackup\UserMigration;
|
||||
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\UserMigration\IImportSource;
|
||||
use OCP\UserMigration\UserMigrationException;
|
||||
@@ -31,14 +30,10 @@ final class RepositoryImportSource implements IImportSource {
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @return resource */
|
||||
public function getFileAsStream(string $path) {
|
||||
$entry = $this->entry($path, 'f');
|
||||
// Phase 0: buffer in php://temp (memory up to 8 MiB, then a temp file for this one file).
|
||||
$stream = fopen('php://temp/maxmemory:' . (8 * 1048576), 'w+b');
|
||||
if ($stream === false) {
|
||||
throw new UserMigrationException('Cannot buffer ' . $path);
|
||||
}
|
||||
foreach ($this->repo->readBlobs($entry['b']) as $data) {
|
||||
for ($done = 0, $len = strlen($data); $done < $len; $done += $n) {
|
||||
$n = fwrite($stream, $done === 0 ? $data : substr($data, $done));
|
||||
@@ -56,7 +51,6 @@ final class RepositoryImportSource implements IImportSource {
|
||||
$prefix = trim($path, '/');
|
||||
$prefix = $prefix === '' ? '' : $prefix . '/';
|
||||
$names = [];
|
||||
/** @var string $p */
|
||||
foreach (array_keys($this->manifest['entries']) as $p) {
|
||||
if ($prefix === '' || str_starts_with($p, $prefix)) {
|
||||
$rest = substr($p, strlen($prefix));
|
||||
@@ -76,7 +70,6 @@ final class RepositoryImportSource implements IImportSource {
|
||||
if (isset($this->manifest['entries'][$p])) {
|
||||
return true;
|
||||
}
|
||||
/** @var string $k */
|
||||
foreach (array_keys($this->manifest['entries']) as $k) {
|
||||
if (str_starts_with($k, $p . '/')) {
|
||||
return true;
|
||||
@@ -98,17 +91,12 @@ final class RepositoryImportSource implements IImportSource {
|
||||
$this->copyToFolder($sub, $path);
|
||||
} else {
|
||||
$stream = $this->getFileAsStream($path);
|
||||
try {
|
||||
if ($destination->nodeExists($name)) {
|
||||
$existing = $destination->get($name);
|
||||
if (!$existing instanceof File) {
|
||||
throw new UserMigrationException("$path exists and is not a file");
|
||||
}
|
||||
$existing->putContent($stream);
|
||||
} else {
|
||||
$destination->newFile($name, $stream);
|
||||
}
|
||||
} finally {
|
||||
if ($destination->nodeExists($name)) {
|
||||
$destination->get($name)->putContent($stream);
|
||||
} else {
|
||||
$destination->newFile($name, $stream);
|
||||
}
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+5
-30
@@ -11,23 +11,12 @@
|
||||
</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" />
|
||||
<KeySetup :status="status" @changed="refresh" />
|
||||
<template v-if="status.key.everConfirmed">
|
||||
<Locations :status="status" @changed="refresh" />
|
||||
<Backups v-if="status.targets.length" :status="status" @changed="refresh" />
|
||||
<RestoreBrowser v-if="status.targets.length" :status="status" @changed="refresh" />
|
||||
</template>
|
||||
<template 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>
|
||||
@@ -35,7 +24,6 @@
|
||||
<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'
|
||||
@@ -47,20 +35,12 @@ 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
|
||||
}
|
||||
@@ -79,9 +59,4 @@ onBeforeUnmount(() => clearTimeout(timer))
|
||||
.ng-backup :deep(.settings-section) {
|
||||
max-width: 900px;
|
||||
}
|
||||
.ngb-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,6 @@ export default {
|
||||
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`),
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
<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>
|
||||
@@ -47,7 +46,6 @@
|
||||
<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>
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
<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 class="ngb-muted">{{ target.backend }} · {{ target.path }}</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>
|
||||
@@ -29,8 +28,6 @@
|
||||
<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>
|
||||
@@ -64,18 +61,7 @@ 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'
|
||||
const form = reactive({ name: '', path: 'ng_backup', options: {} })
|
||||
|
||||
// files_external DefinitionParameter: type 0 text, 1 boolean, 2 password; flag 1 = optional, 4 = hidden
|
||||
const toFields = (params) => Object.entries(params || {})
|
||||
@@ -104,10 +90,9 @@ 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) })
|
||||
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path })
|
||||
adding.value = false
|
||||
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
||||
Object.assign(form, { name: '', path: 'ng_backup', options: {} })
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')">
|
||||
<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" />
|
||||
<NcSelect v-if="target" v-model="snapshot" :options="snapshots" :get-option-label="snapshotLabel" :input-label="t('ng_backup', 'Restore point')" :clearable="false" />
|
||||
</div>
|
||||
|
||||
<template v-if="snapshot">
|
||||
@@ -30,7 +30,7 @@
|
||||
<NcButton v-if="restorable(path) && path !== selected" variant="tertiary" @click="selected = path">{{ t('ng_backup', 'Select this folder') }}</NcButton>
|
||||
|
||||
<div v-if="selected" class="ngb-restore">
|
||||
<p>{{ t('ng_backup', 'Selected: {path}, from the restore point of {date}', { path: selected, date: snapshotLabel(snapshot) }) }}</p>
|
||||
<p>{{ t('ng_backup', 'Selected: {path}', { path: selected }) }}</p>
|
||||
<NcCheckboxRadioSwitch v-for="m in modes" :key="m.id" v-model="mode" :value="m.id" type="radio" name="ngb-mode">{{ m.label }}</NcCheckboxRadioSwitch>
|
||||
<NcButton variant="primary" :disabled="busy" @click="restore">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||
</div>
|
||||
@@ -84,8 +84,7 @@ const crumbs = computed(() => {
|
||||
|
||||
watch(target, async (tg) => {
|
||||
snapshot.value = null
|
||||
const list = tg ? await api.snapshots(tg.id) : []
|
||||
snapshots.value = list.map(s => ({ ...s, displayLabel: snapshotLabel(s) }))
|
||||
snapshots.value = tg ? await api.snapshots(tg.id) : []
|
||||
snapshot.value = snapshots.value[0] ?? null
|
||||
}, { immediate: true })
|
||||
watch(snapshot, () => { selected.value = ''; open('data') })
|
||||
|
||||
@@ -146,16 +146,4 @@ class RepositoryTest extends TestCase {
|
||||
$this->assertFileDoesNotExist(dirname($target, 2) . '/escaped.txt');
|
||||
$this->assertFileDoesNotExist(dirname($target) . '/escaped.txt');
|
||||
}
|
||||
|
||||
public function testStorageLimitStopsTheBackup(): void {
|
||||
$repo = Repository::init($this->backend, 'pw');
|
||||
$repo->setMaxBytes(6 * 1048576); // less than the test tree
|
||||
try {
|
||||
$repo->backupDirectory($this->src);
|
||||
$this->fail('backup should hit the limit');
|
||||
} catch (\OCA\NgBackup\Repository\QuotaExceededException) {
|
||||
}
|
||||
$this->assertLessThanOrEqual(6 * 1048576, $repo->storedBytes());
|
||||
$this->assertSame([], $repo->listSnapshots(), 'no snapshot from an incomplete backup');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
#!/bin/bash
|
||||
# End-to-end round trip of NG Backup inside a Nextcloud installation (CI and local matrix).
|
||||
# Usage: roundtrip.sh "<command prefix to run occ/php as the web user in the instance>" <datadir> <apps-path> [target-args]
|
||||
# Usage: roundtrip.sh "<command prefix to run occ/php as the web user in the instance>" <datadir> <apps-path>
|
||||
# e.g. roundtrip.sh "docker exec -u www-data ngb-nc35-pgsql" /var/www/html/data /var/www/html/custom_apps
|
||||
# target-args defaults to a local files_external location; pass backend/auth/options to exercise
|
||||
# a different backend (e.g. amazons3 or sftp), see tests/integration/targets-matrix.sh.
|
||||
set -euo pipefail
|
||||
X="$1"; DATA="$2"; APPS="$3"
|
||||
TARGET_ARGS="${4:-local -a null::null -o datadir=/tmp/ngb-rt-target}"
|
||||
occ() { $X php occ "$@"; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
ok() { echo "ok $*"; }
|
||||
@@ -20,8 +17,8 @@ CODE=$(occ backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN),
|
||||
occ backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm"
|
||||
ok "key, kit code and confirmation"
|
||||
|
||||
occ backup:target:add ci $TARGET_ARGS --path=repo >/dev/null || fail "target add"
|
||||
ok "location added"
|
||||
occ backup:target:add ci local -a null::null -o datadir=/tmp/ngb-rt-target --path=repo >/dev/null || fail "target add"
|
||||
ok "local location added"
|
||||
|
||||
# Test data: a user with files.
|
||||
$X sh -c "OC_PASS='Roundtrip-pw-2026' php occ user:add --password-from-env $U >/dev/null"
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Round trip of NG Backup against real S3 (s3mock) and SFTP backends, through files_external's own
|
||||
# backend/auth classes, same as a production location would use. Backend correctness does not
|
||||
# depend on the Nextcloud version or database, so this runs once (not matrixed across NC/DB),
|
||||
# on a recent Nextcloud with sqlite. Each backend gets its own fresh Nextcloud container, since
|
||||
# NG Backup's key can only be initialised once per instance.
|
||||
# Usage: tests/integration/targets-matrix.sh [nextcloud-version]
|
||||
set -euo pipefail
|
||||
V=${1:-35}
|
||||
APP=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
NET=ngb-targets-net
|
||||
RESULTS=()
|
||||
|
||||
cleanup() { docker rm -fv ngb-targets-nc-s3 ngb-targets-nc-sftp ngb-targets-s3 ngb-targets-sftp >/dev/null 2>&1; docker network rm "$NET" >/dev/null 2>&1; }
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_tcp() { # host port
|
||||
docker run --rm --network "$NET" alpine:3 sh -c "for i in \$(seq 1 30); do nc -z $1 $2 2>/dev/null && exit 0; sleep 1; done; exit 1" \
|
||||
|| { echo "FAIL: $1:$2 never came up"; exit 1; }
|
||||
}
|
||||
|
||||
start_nc() { # container-name
|
||||
docker run -d --name "$1" --network "$NET" \
|
||||
-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin-targets-pw -e NEXTCLOUD_TRUSTED_DOMAINS=localhost \
|
||||
-e SQLITE_DATABASE=nextcloud \
|
||||
-v "$APP:/var/www/html/custom_apps/ng_backup:ro" nextcloud:$V-apache >/dev/null
|
||||
for i in $(seq 1 90); do docker exec -u www-data "$1" php occ status 2>/dev/null | grep -q "installed: true" && break; sleep 4; done
|
||||
docker exec "$1" chown www-data:www-data /var/www/html/custom_apps
|
||||
echo "=== NC $V / sqlite ($1): $(docker exec -u www-data "$1" php occ -V 2>/dev/null)"
|
||||
}
|
||||
|
||||
run() { # label, nc-container, target-args
|
||||
echo "=== $1"
|
||||
if bash ${NGB_TRACE:+-x} "$APP/tests/integration/roundtrip.sh" "docker exec -u www-data $2" /var/www/html/data /var/www/html/custom_apps "$3"; then
|
||||
RESULTS+=("$1: PASS")
|
||||
else
|
||||
RESULTS+=("$1: FAIL")
|
||||
docker exec "$2" tail -c 2000 /var/www/html/data/nextcloud.log | tr ',' '\n' | grep -E '"message"' | tail -3
|
||||
fi
|
||||
}
|
||||
|
||||
docker network create "$NET" >/dev/null
|
||||
|
||||
# adobe/s3mock: an S3-compatible test server (MinIO's Docker Hub image now needs a paid login;
|
||||
# this one is freely pullable and pre-creates a bucket via INITIAL_BUCKETS, no auth to configure).
|
||||
docker run -d --name ngb-targets-s3 --network "$NET" \
|
||||
-e INITIAL_BUCKETS=ngbackups \
|
||||
adobe/s3mock >/dev/null
|
||||
|
||||
# atmoz/sftp: "user:pass:::dir" creates /home/user/dir as the upload root.
|
||||
docker run -d --name ngb-targets-sftp --network "$NET" \
|
||||
atmoz/sftp ngbackup:ngbackuppw:::upload >/dev/null
|
||||
|
||||
wait_tcp ngb-targets-s3 9090
|
||||
wait_tcp ngb-targets-sftp 22
|
||||
|
||||
start_nc ngb-targets-nc-s3
|
||||
run "S3 (s3mock)" ngb-targets-nc-s3 "amazons3 -a amazons3::accesskey -o bucket=ngbackups -o hostname=ngb-targets-s3 -o port=9090 -o region=us-east-1 -o use_ssl=false -o use_path_style=true -o key=test -o secret=test"
|
||||
|
||||
start_nc ngb-targets-nc-sftp
|
||||
run "SFTP" ngb-targets-nc-sftp "sftp -a password::password -o host=ngb-targets-sftp -o root=/upload -o user=ngbackup -o password=ngbackuppw"
|
||||
|
||||
printf '%s\n' "${RESULTS[@]}"
|
||||
printf '%s\n' "${RESULTS[@]}" | grep -q FAIL && exit 1
|
||||
exit 0
|
||||
Reference in New Issue
Block a user