diff --git a/appinfo/info.xml b/appinfo/info.xml index 6d80cdf..e64dad3 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -16,4 +16,13 @@ sodium + + OCA\NgBackup\Command\KeyInit + OCA\NgBackup\Command\KeyKit + OCA\NgBackup\Command\KeyConfirm + OCA\NgBackup\Command\TargetBackends + OCA\NgBackup\Command\TargetAdd + OCA\NgBackup\Command\TargetList + OCA\NgBackup\Command\TargetRemove + diff --git a/lib/Command/KeyConfirm.php b/lib/Command/KeyConfirm.php new file mode 100644 index 0000000..31ed327 --- /dev/null +++ b/lib/Command/KeyConfirm.php @@ -0,0 +1,52 @@ +setName('backup:key:confirm') + ->setDescription('Confirm that the recovery kit is stored safely (required before the first backup)') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Admin user id recorded with the confirmation', 'occ'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if (!$this->keys->isInitialized()) { + $output->writeln('No backup key yet: run occ backup:key:init first.'); + return 1; + } + $output->writeln('Key fingerprint: ' . $this->keys->fingerprint() . ''); + $output->writeln(''); + $output->writeln(wordwrap(self::STATEMENT, 78)); + $output->writeln(''); + $answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: ')); + if (trim($answer) !== self::CONFIRMATION) { + $output->writeln('Not confirmed.'); + return 1; + } + $this->keys->confirmRecoveryKit((string)$input->getOption('user')); + $output->writeln('Confirmed. Backups can now run.'); + return 0; + } +} diff --git a/lib/Command/KeyInit.php b/lib/Command/KeyInit.php new file mode 100644 index 0000000..8bec0f4 --- /dev/null +++ b/lib/Command/KeyInit.php @@ -0,0 +1,69 @@ +setName('backup:key:init') + ->setDescription('Create the backup key, protected by a passphrase you keep safe') + ->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking') + ->setHelp(<<<'HELP' +Creates the master key that encrypts all backups of this installation. + +The server keeps the key (encrypted with this instance's secret) so scheduled backups can run. +A copy protected by your passphrase goes into every backup location and into the recovery kit +(occ backup:key:kit). To restore on a new server you need the recovery kit and the passphrase. + +Example: + occ backup:key:init + occ backup:key:kit --output=/root/ng_backup-recovery-kit.json +HELP); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($this->keys->isInitialized()) { + $output->writeln('A backup key already exists (fingerprint ' . $this->keys->fingerprint() . ').'); + return 1; + } + $file = $input->getOption('passphrase-file'); + if ($file !== null) { + $pass = rtrim((string)@file_get_contents($file), "\r\n"); + } else { + $helper = $this->getHelper('question'); + $q = (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false); + $pass = (string)$helper->ask($input, $output, $q); + $again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false)); + if ($pass !== $again) { + $output->writeln('The passphrases do not match.'); + return 1; + } + } + try { + $this->keys->initialize($pass); + } catch (\InvalidArgumentException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Backup key created, fingerprint ' . $this->keys->fingerprint() . '.'); + $output->writeln('Next: save the recovery kit with occ backup:key:kit --output=FILE and confirm with occ backup:key:confirm.'); + return 0; + } +} diff --git a/lib/Command/KeyKit.php b/lib/Command/KeyKit.php new file mode 100644 index 0000000..a149523 --- /dev/null +++ b/lib/Command/KeyKit.php @@ -0,0 +1,48 @@ +setName('backup:key:kit') + ->setDescription('Write the recovery kit (passphrase-protected key) to a file or stdout') + ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'File to write (default: stdout)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if (!$this->keys->isInitialized()) { + $output->writeln('No backup key yet: run occ backup:key:init first.'); + return 1; + } + $json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; + $file = $input->getOption('output'); + if ($file === null) { + $output->write($json, false, OutputInterface::OUTPUT_RAW); + return 0; + } + if (file_put_contents($file, $json) === false) { + $output->writeln("Cannot write $file"); + return 1; + } + @chmod($file, 0600); + $output->writeln("Recovery kit written to $file. Copy it off this server, then run occ backup:key:confirm."); + return 0; + } +} diff --git a/lib/Command/TargetAdd.php b/lib/Command/TargetAdd.php new file mode 100644 index 0000000..3422628 --- /dev/null +++ b/lib/Command/TargetAdd.php @@ -0,0 +1,73 @@ +setName('backup:target:add') + ->setDescription('Add a backup location (S3, SFTP, WebDAV, ...) via files_external, without mounting it') + ->addArgument('name', InputArgument::REQUIRED, 'Name of the location') + ->addArgument('backend', InputArgument::REQUIRED, 'files_external backend id (see backup:target:backends)') + ->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)') + ->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)') + ->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup') + ->setHelp(<<<'HELP' +The location is reached with files_external's own connection code, but it is never added as an +external storage: it does not appear in Files for anyone. Credentials are stored encrypted. + +Examples: + occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud + + occ backup:target:add nas sftp -a password::password -o host=nas.example.lan -o root=/backups -o user=nextcloud -o password=... --path=ng_backup +HELP); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if (!$this->keys->isInitialized()) { + $output->writeln('No backup key yet: run occ backup:key:init first.'); + return 1; + } + $options = []; + foreach ($input->getOption('option') as $pair) { + if (!str_contains($pair, '=')) { + $output->writeln("Option '$pair' must be key=value"); + return 1; + } + [$k, $v] = explode('=', $pair, 2); + $options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v }; + } + if ($input->getOption('auth') === null) { + $output->writeln('--auth is required (see occ backup:target:backends)'); + return 1; + } + try { + $r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path')); + } catch (\Throwable $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln(sprintf('Location %s added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(), + $r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted')); + return 0; + } +} diff --git a/lib/Command/TargetBackends.php b/lib/Command/TargetBackends.php new file mode 100644 index 0000000..3ad3e10 --- /dev/null +++ b/lib/Command/TargetBackends.php @@ -0,0 +1,42 @@ +setName('backup:target:backends') + ->setDescription('List the storage backends (from files_external) that can be used as backup location'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $backends = $this->targets->availableBackends(); + if ($input->getOption('output') !== self::OUTPUT_FORMAT_PLAIN) { + $this->writeMixedInOutputFormat($input, $output, $backends); + return 0; + } + foreach ($backends as $b) { + $output->writeln(sprintf('%s (%s) auth schemes: %s', $b['id'], $b['name'], implode(', ', $b['auth']))); + foreach ($b['parameters'] as $key => $p) { + $output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : '')); + } + } + return 0; + } +} diff --git a/lib/Command/TargetList.php b/lib/Command/TargetList.php new file mode 100644 index 0000000..e36cdcc --- /dev/null +++ b/lib/Command/TargetList.php @@ -0,0 +1,57 @@ +setName('backup:target:list') + ->setDescription('List backup locations') + ->addOption('test', 't', InputOption::VALUE_NONE, 'Also connect to each location and check the repository'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $rows = []; + foreach ($this->targets->list() as $t) { + $row = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(), + 'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt())]; + if ($input->getOption('test')) { + try { + $repo = $this->targets->repository($t); + $row['status'] = 'ok, ' . count($repo->listSnapshots()) . ' snapshots'; + } catch (\Throwable $e) { + $row['status'] = 'error: ' . $e->getMessage(); + } + } + $rows[] = $row; + } + if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) { + if ($rows === []) { + $output->writeln('No backup locations yet (occ backup:target:add).'); + return 0; + } + $table = new \Symfony\Component\Console\Helper\Table($output); + $table->setHeaders(array_keys($rows[0]))->setRows(array_map('array_values', $rows))->render(); + } else { + $this->writeMixedInOutputFormat($input, $output, $rows); + } + return 0; + } +} diff --git a/lib/Command/TargetRemove.php b/lib/Command/TargetRemove.php new file mode 100644 index 0000000..800d27e --- /dev/null +++ b/lib/Command/TargetRemove.php @@ -0,0 +1,40 @@ +setName('backup:target:remove') + ->setDescription('Remove a backup location from NG Backup (the backups on it are kept)') + ->addArgument('target', InputArgument::REQUIRED, 'Name or id'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $t = $this->targets->get($input->getArgument('target')); + } catch (\Throwable) { + $output->writeln('No such location.'); + return 1; + } + $this->targets->remove($t); + $output->writeln('Removed ' . $t->getName() . '. The backups on the location itself were not touched; add it again to use them.'); + return 0; + } +} diff --git a/lib/Crypto/KeyRing.php b/lib/Crypto/KeyRing.php index a7a0019..6a7bce9 100644 --- a/lib/Crypto/KeyRing.php +++ b/lib/Crypto/KeyRing.php @@ -37,6 +37,11 @@ final class KeyRing { return new self(sodium_crypto_kdf_keygen()); } + /** Raw master key, only for storing it encrypted on the server (ICrypto). */ + public function exportMasterKey(): string { + return $this->masterKey; + } + public function dataKey(): string { return $this->dataKey; } diff --git a/lib/Db/Target.php b/lib/Db/Target.php new file mode 100644 index 0000000..71688c2 --- /dev/null +++ b/lib/Db/Target.php @@ -0,0 +1,40 @@ +addType('createdAt', 'integer'); + } +} diff --git a/lib/Db/TargetMapper.php b/lib/Db/TargetMapper.php new file mode 100644 index 0000000..d815c57 --- /dev/null +++ b/lib/Db/TargetMapper.php @@ -0,0 +1,38 @@ + */ +class TargetMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ngb_targets', Target::class); + } + + public function find(int $id): Target { + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + public function findByName(string $name): Target { + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('name', $qb->createNamedParameter($name))); + return $this->findEntity($qb); + } + + /** @return list */ + public function findAll(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*')->from($this->getTableName())->orderBy('name'); + return $this->findEntities($qb); + } +} diff --git a/lib/Migration/Version000001Date20261002000000.php b/lib/Migration/Version000001Date20261002000000.php new file mode 100644 index 0000000..a88ec99 --- /dev/null +++ b/lib/Migration/Version000001Date20261002000000.php @@ -0,0 +1,72 @@ +hasTable('ngb_targets')) { + $t = $schema->createTable('ngb_targets'); + $t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]); + $t->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 64]); + $t->addColumn('backend', Types::STRING, ['notnull' => true, 'length' => 64]); + $t->addColumn('auth', Types::STRING, ['notnull' => true, 'length' => 64]); + // Backend/auth options as JSON, encrypted with ICrypto (they contain credentials). + $t->addColumn('options', Types::TEXT, ['notnull' => true]); + $t->addColumn('base_path', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => '']); + $t->addColumn('repository_id', Types::STRING, ['notnull' => false, 'length' => 32]); + $t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]); + $t->setPrimaryKey(['id']); + $t->addUniqueIndex(['name'], 'ngb_targets_name'); + } + + if (!$schema->hasTable('ngb_runs')) { + $t = $schema->createTable('ngb_runs'); + $t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]); + $t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]); + $t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]); + $t->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16]); + $t->addColumn('phase', Types::STRING, ['notnull' => true, 'length' => 32]); + // Resumable state (JSON). Large lists live in the repository, not here. + $t->addColumn('state', Types::TEXT, ['notnull' => true]); + $t->addColumn('stats', Types::TEXT, ['notnull' => false]); + $t->addColumn('error', Types::TEXT, ['notnull' => false]); + $t->addColumn('snapshot_id', Types::STRING, ['notnull' => false, 'length' => 32]); + $t->addColumn('started_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]); + $t->addColumn('updated_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]); + $t->addColumn('finished_at', Types::BIGINT, ['notnull' => false, 'unsigned' => true]); + $t->setPrimaryKey(['id']); + $t->addIndex(['target_id', 'status'], 'ngb_runs_target_status'); + } + + if (!$schema->hasTable('ngb_snapshots')) { + // Cache of the snapshots in each repository, for listing without reading the target. + $t = $schema->createTable('ngb_snapshots'); + $t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]); + $t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]); + $t->addColumn('snapshot_id', Types::STRING, ['notnull' => true, 'length' => 32]); + $t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]); + $t->addColumn('label', Types::STRING, ['notnull' => false, 'length' => 255]); + $t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]); + $t->addColumn('files', Types::BIGINT, ['notnull' => false, 'unsigned' => true]); + $t->addColumn('bytes', Types::BIGINT, ['notnull' => false, 'unsigned' => true]); + $t->setPrimaryKey(['id']); + $t->addUniqueIndex(['target_id', 'snapshot_id'], 'ngb_snap_target_snap'); + } + + return $schema; + } +} diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index 8f21b97..3d9fe4d 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -43,17 +43,43 @@ final class Repository { } public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self { + $keys = KeyRing::generate(); + return self::initWithKey($backend, $keys, $keys->wrap($passphrase)); + } + + /** + * Create a repository with an existing master key. $wrappedKey is the passphrase-wrapped + * copy (KeyRing::wrap) stored in the repository config, so the repository can be opened + * with the passphrase from the recovery kit even when the server is gone. + */ + public static function initWithKey(IBackend $backend, KeyRing $keys, array $wrappedKey): self { if ($backend->exists('config')) { throw new RepositoryException('A repository already exists at this location'); } - $keys = KeyRing::generate(); $id = bin2hex(random_bytes(16)); - $config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $keys->wrap($passphrase)]; + $config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey, + 'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check'))]; self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); return new self($backend, $keys, $id); } - public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self { + /** Open with the master key the server holds (scheduled backups, no passphrase needed). */ + public static function openWithKey(IBackend $backend, KeyRing $keys): self { + $config = self::readConfig($backend); + try { + $ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id']; + } catch (\OCA\NgBackup\Crypto\CryptoException) { + $ok = false; + } + if (!$ok) { + throw new RepositoryException('This repository was created with a different key'); + } + $repo = new self($backend, $keys, $config['id']); + $repo->index->load(); + return $repo; + } + + private static function readConfig(IBackend $backend): array { try { $fh = $backend->get('config'); } catch (BackendException) { @@ -64,6 +90,15 @@ final class Repository { if (($config['format'] ?? 0) !== self::FORMAT) { throw new RepositoryException('Unsupported repository format'); } + return $config; + } + + public function id(): string { + return $this->repositoryId; + } + + public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self { + $config = self::readConfig($backend); $repo = new self($backend, KeyRing::unwrap($config['key'], $passphrase), $config['id']); $repo->index->load(); return $repo; diff --git a/lib/Service/KeyService.php b/lib/Service/KeyService.php new file mode 100644 index 0000000..d92cc68 --- /dev/null +++ b/lib/Service/KeyService.php @@ -0,0 +1,110 @@ +appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== ''; + } + + public function initialize(#[\SensitiveParameter] string $passphrase): void { + if ($this->isInitialized()) { + throw new \RuntimeException('A backup key already exists'); + } + if (strlen($passphrase) < 12) { + throw new \InvalidArgumentException('The passphrase must be at least 12 characters'); + } + $keys = KeyRing::generate(); + $this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER, + $this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true); + $this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, + json_encode($keys->wrap($passphrase), JSON_THROW_ON_ERROR), true, true); + $this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true); + $this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED); + $this->cached = $keys; + } + + public function keyRing(): KeyRing { + if ($this->cached === null) { + $enc = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true); + if ($enc === '') { + throw new \RuntimeException('No backup key yet: run occ backup:key:init'); + } + $this->cached = new KeyRing(base64_decode($this->crypto->decrypt($enc), true)); + } + return $this->cached; + } + + /** Passphrase-wrapped copy of the master key (for repository configs and the recovery kit). */ + public function wrappedKey(): array { + return json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR); + } + + /** Short identifier of the key, shown in the UI and the kit (not secret). */ + public function fingerprint(): string { + return $this->appConfig->getValueString(Application::APP_ID, self::KEY_FINGERPRINT, '', true); + } + + /** @return array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string} */ + public function recoveryKit(): array { + return [ + 'version' => 1, + 'app' => 'NG Backup', + 'fingerprint' => $this->fingerprint(), + 'created' => gmdate('c'), + 'wrapped_key' => $this->wrappedKey(), + 'instructions' => 'Keep this file and your passphrase safe, outside this server. To restore on a new server: install NG Backup, ' + . 'add the backup location, and enter this kit with your passphrase. Without both, the backups cannot be decrypted.', + ]; + } + + public function confirmRecoveryKit(string $uid): void { + $this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED, + json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint()], JSON_THROW_ON_ERROR), true); + } + + /** Confirmation for the current key, or null. */ + public function recoveryKitConfirmation(): ?array { + $raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true); + $data = $raw === '' ? null : json_decode($raw, true); + return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint()) ? $data : null; + } + + private function fingerprintOf(KeyRing $keys): string { + $id = $keys->blobId('ng_backup key fingerprint'); + return implode('-', str_split(substr($id, 0, 16), 4)); + } +} diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php new file mode 100644 index 0000000..8c0d4d5 --- /dev/null +++ b/lib/Service/TargetService.php @@ -0,0 +1,124 @@ +, parameters:array}> */ + public function availableBackends(): array { + return $this->factory()->describeBackends(); + } + + /** + * Add a target: connect, create the repository there (or adopt an existing one made with this + * installation's key), then save it. + * + * @param array $options + * @return array{target: Target, created: bool} + */ + public function add(string $name, string $backend, string $auth, array $options, string $basePath): array { + if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) { + throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)'); + } + try { + $this->mapper->findByName($name); + throw new \InvalidArgumentException("A target named '$name' already exists"); + } catch (\OCP\AppFramework\Db\DoesNotExistException) { + } + $keys = $this->keys->keyRing(); + $storageBackend = $this->buildBackend($backend, $auth, $options, $basePath); + if ($storageBackend->exists('config')) { + $repo = Repository::openWithKey($storageBackend, $keys); + $created = false; + } else { + $repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey()); + $created = true; + } + + foreach ($this->mapper->findAll() as $existing) { + if ($existing->getRepositoryId() === $repo->id()) { + throw new \InvalidArgumentException("This repository is already registered as '{$existing->getName()}'"); + } + } + + $target = new Target(); + $target->setName($name); + $target->setBackend($backend); + $target->setAuth($auth); + $target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR))); + $target->setBasePath(trim($basePath, '/')); + $target->setRepositoryId($repo->id()); + $target->setCreatedAt(time()); + return ['target' => $this->mapper->insert($target), 'created' => $created]; + } + + /** @return list */ + public function list(): array { + return $this->mapper->findAll(); + } + + public function get(string $nameOrId): Target { + return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId); + } + + /** Remove the target from NG Backup. The backups on the target itself are not touched. */ + public function remove(Target $target): void { + $this->mapper->delete($target); + } + + public function backend(Target $target): IBackend { + return $this->buildBackend($target->getBackend(), $target->getAuth(), $this->options($target), $target->getBasePath()); + } + + public function repository(Target $target): Repository { + $repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing()); + if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) { + throw new \RuntimeException('The target now contains a different repository than when it was added'); + } + return $repo; + } + + /** @return array */ + public function options(Target $target): array { + return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR); + } + + private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend { + return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath); + } + + private function factory(): ExternalStorageFactory { + // Load files_external's classes without enabling the app (its state stays as the admin set it). + if (!class_exists(\OCA\Files_External\Service\BackendService::class)) { + $this->appManager->loadApp('files_external'); + } + return new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class)); + } +} diff --git a/tests/.phpunit.result.cache b/tests/.phpunit.result.cache index 129766d..29ad6fc 100644 --- a/tests/.phpunit.result.cache +++ b/tests/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":[],"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.151,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.412,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.002,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.004,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.29,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#truncated":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#trailing data":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#cut at frame boundary":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#bad magic":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongAssociatedDataIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongKeyIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testRunInManyTinyStepsRestoresIdentically":4.049,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":6.114,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":2.122,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.127,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.183,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.042,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.248,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":0.645,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.289,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.188,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.529}} \ No newline at end of file +{"version":2,"defects":[],"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.115,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.074,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.003,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.262,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#truncated":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#trailing data":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#cut at frame boundary":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#bad magic":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongAssociatedDataIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongKeyIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testRunInManyTinyStepsRestoresIdentically":4.044,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":6.03,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":1.997,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.012,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.223,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.307,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.14,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":1.281,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.327,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.618,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.669}} \ No newline at end of file