M4: admin settings page and JSON API
- AdminApiController (admin only, CSRF-protected): status, create key, download recovery kit, confirm (statement accepted + exact phrase), backends with their usable auth mechanisms, add/test/remove locations, start backups, schedule, snapshots, browse, queue restores - RestoreJob runs web-initiated restores in the background; backup and restore runs are kept apart in the run table - admin section 'NG Backup' with a Vue 3 / @nextcloud/vue 9 page: key and recovery-kit flow (download, tick statement, 'Yes, I confirm'), location form built from files_external's parameter definitions, schedule, run list with polling, snapshot browser and restore with the three modes - auth mechanisms that depend on a user session or on files_external's own stored credentials are not offered API tested with curl on NC34 (status, backends, schedule validation, confirmation refusal, browse, queued restore and backup completed by cron); non-admins get 403, anonymous 401.
This commit is contained in:
@@ -26,16 +26,32 @@ final class ExternalStorageFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:string, name:string, auth:list<string>, parameters:array<string, mixed>}>
|
||||
* @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array<string, mixed>}>
|
||||
* available backends, to build the settings form from
|
||||
*/
|
||||
public function describeBackends(): array {
|
||||
$result = [];
|
||||
foreach ($this->backends->getAvailableBackends() as $backend) {
|
||||
$auth = [];
|
||||
foreach ($this->backends->getAuthMechanismsByScheme(array_keys($backend->getAuthSchemes())) as $mech) {
|
||||
// Mechanisms that need the logged-in user's session cannot work for unattended backups.
|
||||
if (in_array($mech->getScheme(), ['builtin', 'null'], true) && $mech->getIdentifier() !== 'null::null') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($mech->getIdentifier(), 'password::session') || str_starts_with($mech->getIdentifier(), 'password::logincredentials')
|
||||
|| str_starts_with($mech->getIdentifier(), 'password::userprovided') || str_starts_with($mech->getIdentifier(), 'password::global')) {
|
||||
continue;
|
||||
}
|
||||
$auth[] = [
|
||||
'id' => $mech->getIdentifier(),
|
||||
'name' => $mech->getText(),
|
||||
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $mech->getParameters()),
|
||||
];
|
||||
}
|
||||
$result[] = [
|
||||
'id' => $backend->getIdentifier(),
|
||||
'name' => $backend->getText(),
|
||||
'auth' => array_keys($backend->getAuthSchemes()),
|
||||
'auth' => $auth,
|
||||
'parameters' => array_map(fn ($p) => $p->jsonSerialize(), $backend->getParameters()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class BackupJob extends TimedJob {
|
||||
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
||||
$deadline = microtime(true) + $budget;
|
||||
$this->startScheduled();
|
||||
foreach ($this->runs->findRunning() as $run) {
|
||||
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
||||
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class BackupJob extends TimedJob {
|
||||
}
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
||||
if ($this->runs->findRunning($t->getId()) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
||||
if ($this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\BackgroundJob;
|
||||
|
||||
use OCA\NgBackup\Db\Run;
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Service\RestoreService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\QueuedJob;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/** Runs a restore requested from the web interface, outside the web request. */
|
||||
class RestoreJob extends QueuedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private RunMapper $runs,
|
||||
private TargetService $targets,
|
||||
private RestoreService $restore,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
}
|
||||
|
||||
protected function run($argument): void {
|
||||
$run = $this->runs->find((int)$argument['run']);
|
||||
if ($run->getStatus() !== Run::RUNNING) {
|
||||
return;
|
||||
}
|
||||
$req = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
||||
$run->setPhase('restoring');
|
||||
$run->setUpdatedAt(time());
|
||||
$this->runs->update($run);
|
||||
@set_time_limit(0);
|
||||
try {
|
||||
$result = $this->restore->restoreUserFiles($this->targets->get((string)$run->getTargetId()), $req['snapshot'], $req['path'], $req['mode']);
|
||||
$run->setStatus(Run::DONE);
|
||||
$run->setPhase('done');
|
||||
$run->setStats(json_encode($result, JSON_THROW_ON_ERROR));
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
$run->setStatus(Run::FAILED);
|
||||
$run->setError($e->getMessage());
|
||||
}
|
||||
$run->setFinishedAt(time());
|
||||
$run->setUpdatedAt(time());
|
||||
$this->runs->update($run);
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,16 @@ class TargetBackends extends Base {
|
||||
return 0;
|
||||
}
|
||||
foreach ($backends as $b) {
|
||||
$output->writeln(sprintf('<info>%s</info> (%s) auth schemes: %s', $b['id'], $b['name'], implode(', ', $b['auth'])));
|
||||
$output->writeln(sprintf('<info>%s</info> (%s)', $b['id'], $b['name']));
|
||||
foreach ($b['parameters'] as $key => $p) {
|
||||
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
||||
}
|
||||
foreach ($b['auth'] as $a) {
|
||||
$output->writeln(sprintf(' -a %s (%s)', $a['id'], $a['name']));
|
||||
foreach ($a['parameters'] as $key => $p) {
|
||||
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Controller;
|
||||
|
||||
use OCA\NgBackup\AppInfo\Application;
|
||||
use OCA\NgBackup\BackgroundJob\RestoreJob;
|
||||
use OCA\NgBackup\Command\KeyConfirm;
|
||||
use OCA\NgBackup\Db\Run;
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Service\BackupService;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\RestoreService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataDownloadResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
|
||||
/**
|
||||
* JSON API for the admin settings page. Every method requires an admin (the AppFramework
|
||||
* default for controllers without NoAdminRequired) and a valid CSRF token.
|
||||
*/
|
||||
class AdminApiController extends Controller {
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private KeyService $keys,
|
||||
private TargetService $targets,
|
||||
private BackupService $backups,
|
||||
private RestoreService $restore,
|
||||
private RunMapper $runs,
|
||||
private SnapshotMapper $snapshots,
|
||||
private IAppConfig $appConfig,
|
||||
private IJobList $jobs,
|
||||
private IUserSession $session,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
public function status(): JSONResponse {
|
||||
$targets = [];
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
||||
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt()];
|
||||
}
|
||||
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
|
||||
return new JSONResponse([
|
||||
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
|
||||
'confirmation' => $this->keys->recoveryKitConfirmation()],
|
||||
'statement' => KeyConfirm::STATEMENT,
|
||||
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
|
||||
'targets' => $targets,
|
||||
'runs' => $runs,
|
||||
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function initKey(string $passphrase): JSONResponse {
|
||||
try {
|
||||
$this->keys->initialize($passphrase);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new JSONResponse(['fingerprint' => $this->keys->fingerprint()]);
|
||||
}
|
||||
|
||||
public function downloadKit(): DataDownloadResponse|JSONResponse {
|
||||
if (!$this->keys->isInitialized()) {
|
||||
return new JSONResponse(['error' => 'No backup key yet'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json');
|
||||
}
|
||||
|
||||
/** The admin must tick the statement and send the exact confirmation phrase. */
|
||||
public function confirmKit(bool $accepted, string $phrase): JSONResponse {
|
||||
if (!$accepted || $phrase !== KeyConfirm::CONFIRMATION) {
|
||||
return new JSONResponse(['error' => 'Not confirmed'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$this->keys->confirmRecoveryKit($this->session->getUser()?->getUID() ?? 'unknown');
|
||||
return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]);
|
||||
}
|
||||
|
||||
public function backends(): JSONResponse {
|
||||
return new JSONResponse($this->targets->availableBackends());
|
||||
}
|
||||
|
||||
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse {
|
||||
try {
|
||||
$r = $this->targets->add($name, $backend, $auth, $options, $path);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
|
||||
}
|
||||
|
||||
public function removeTarget(int $id): JSONResponse {
|
||||
$this->targets->remove($this->targets->get((string)$id));
|
||||
return new JSONResponse([]);
|
||||
}
|
||||
|
||||
public function testTarget(int $id): JSONResponse {
|
||||
try {
|
||||
$repo = $this->targets->repository($this->targets->get((string)$id));
|
||||
return new JSONResponse(['ok' => true, 'snapshots' => count($repo->listSnapshots())]);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function startBackup(int $targetId, string $label = ''): JSONResponse {
|
||||
try {
|
||||
$run = $this->backups->start($this->targets->get((string)$targetId), $label);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new JSONResponse($this->runJson($run));
|
||||
}
|
||||
|
||||
public function setSchedule(string $time): JSONResponse {
|
||||
if ($time !== '' && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
||||
return new JSONResponse(['error' => 'Use HH:MM or empty'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$this->appConfig->setValueString(Application::APP_ID, 'schedule_time', $time);
|
||||
return new JSONResponse(['schedule' => $time]);
|
||||
}
|
||||
|
||||
public function snapshots(int $targetId): JSONResponse {
|
||||
$out = [];
|
||||
foreach ($this->snapshots->findForTarget($targetId) as $s) {
|
||||
$out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(),
|
||||
'files' => $s->getFiles(), 'bytes' => $s->getBytes()];
|
||||
}
|
||||
return new JSONResponse($out);
|
||||
}
|
||||
|
||||
public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse {
|
||||
try {
|
||||
return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path));
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue a restore of user files; it runs as a background job and shows up in the runs. */
|
||||
public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse {
|
||||
if (!in_array($mode, [RestoreService::MODE_NEW_FOLDER, RestoreService::MODE_MERGE, RestoreService::MODE_REPLACE], true)
|
||||
|| !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) {
|
||||
return new JSONResponse(['error' => 'Invalid path or mode'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$run = new Run();
|
||||
$run->setTargetId($targetId);
|
||||
$run->setKind('restore');
|
||||
$run->setStatus(Run::RUNNING);
|
||||
$run->setPhase('queued');
|
||||
$run->setState(json_encode(['snapshot' => $snapshotId, 'path' => $path, 'mode' => $mode,
|
||||
'by' => $this->session->getUser()?->getUID()], JSON_THROW_ON_ERROR));
|
||||
$run->setSnapshotId($snapshotId);
|
||||
$run->setStartedAt(time());
|
||||
$run->setUpdatedAt(time());
|
||||
$run = $this->runs->insert($run);
|
||||
$this->jobs->add(RestoreJob::class, ['run' => $run->getId()]);
|
||||
return new JSONResponse($this->runJson($run));
|
||||
}
|
||||
|
||||
private function runJson(Run $r): array {
|
||||
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
|
||||
'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)];
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,12 @@ class RunMapper extends QBMapper {
|
||||
}
|
||||
|
||||
/** @return list<Run> */
|
||||
public function findRunning(?int $targetId = null): array {
|
||||
public function findRunning(?int $targetId = null, ?string $kind = null): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)));
|
||||
if ($kind !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)));
|
||||
}
|
||||
if ($targetId !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)));
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class BackupService {
|
||||
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
||||
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
||||
}
|
||||
$running = $this->runs->findRunning($target->getId());
|
||||
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
||||
if ($running !== []) {
|
||||
return $running[0];
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class BackupService {
|
||||
}
|
||||
try {
|
||||
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
||||
if ($run->getStatus() !== Run::RUNNING) {
|
||||
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
||||
return $run;
|
||||
}
|
||||
$target = $this->targets->get((string)$run->getTargetId());
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Settings;
|
||||
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\Settings\ISettings;
|
||||
use OCP\Util;
|
||||
|
||||
class Admin implements ISettings {
|
||||
public function getForm(): TemplateResponse {
|
||||
Util::addScript('ng_backup', 'ng_backup-admin');
|
||||
Util::addStyle('ng_backup', 'ng_backup-admin');
|
||||
return new TemplateResponse('ng_backup', 'admin');
|
||||
}
|
||||
|
||||
public function getSection(): string {
|
||||
return 'ng_backup';
|
||||
}
|
||||
|
||||
public function getPriority(): int {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Settings;
|
||||
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
class AdminSection implements IIconSection {
|
||||
public function __construct(
|
||||
private IL10N $l,
|
||||
private IURLGenerator $url,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getID(): string {
|
||||
return 'ng_backup';
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->l->t('NG Backup');
|
||||
}
|
||||
|
||||
public function getPriority(): int {
|
||||
return 80;
|
||||
}
|
||||
|
||||
public function getIcon(): string {
|
||||
return $this->url->imagePath('ng_backup', 'app-dark.svg');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user