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:
2026-10-02 01:09:47 +02:00
parent c601a02b2b
commit 36e6a3fde7
29 changed files with 9333 additions and 13 deletions
+91
View File
@@ -0,0 +1,91 @@
<!--
- SPDX-FileCopyrightText: 2026 Dillard Blom
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSettingsSection :name="t('ng_backup', 'Backups')"
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
<div class="ngb-row">
<NcTextField v-model="schedule" class="ngb-time" :label="t('ng_backup', 'Daily at (HH:MM, empty = off)')" />
<NcButton :disabled="schedule === status.schedule" @click="saveSchedule">{{ t('ng_backup', 'Save') }}</NcButton>
</div>
<div class="ngb-row">
<NcButton v-for="target in status.targets" :key="target.id" :disabled="isRunning(target.id)" @click="start(target)">
{{ isRunning(target.id) ? t('ng_backup', 'Backup to {name} running …', { name: target.name }) : t('ng_backup', 'Back up now to {name}', { name: target.name }) }}
</NcButton>
</div>
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
<table v-if="status.runs.length" class="ngb-table">
<thead>
<tr>
<th>{{ t('ng_backup', 'Started') }}</th>
<th>{{ t('ng_backup', 'Location') }}</th>
<th>{{ t('ng_backup', 'Kind') }}</th>
<th>{{ t('ng_backup', 'Status') }}</th>
<th>{{ t('ng_backup', 'Files') }}</th>
<th>{{ t('ng_backup', 'Uploaded') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
<td>{{ targetName(run.targetId) }}</td>
<td>{{ run.kind }}</td>
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
</tr>
</tbody>
</table>
</NcSettingsSection>
</template>
<script setup>
import { ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import api from '../api.js'
import { formatSize } from '../format.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const schedule = ref(props.status.schedule)
const error = ref('')
watch(() => props.status.schedule, (s) => { schedule.value = s })
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
async function start(target) {
error.value = ''
try {
await api.startBackup(target.id, '')
emit('changed')
} catch (e) {
error.value = e.message
}
}
async function saveSchedule() {
error.value = ''
try {
await api.setSchedule(schedule.value.trim())
emit('changed')
} catch (e) {
error.value = e.message
}
}
</script>
<style scoped>
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
.ngb-time { max-width: 260px; }
.ngb-table { width: 100%; border-collapse: collapse; }
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
.ngb-failed { color: var(--color-error-text); }
.ngb-done { color: var(--color-success-text); }
</style>
+101
View File
@@ -0,0 +1,101 @@
<!--
- SPDX-FileCopyrightText: 2026 Dillard Blom
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSettingsSection :name="t('ng_backup', 'Backup key and recovery kit')"
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups; the recovery kit, protected by your passphrase, is what you need to restore on a new server.')">
<!-- 1. Create the key -->
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2">
{{ t('ng_backup', 'Create backup key') }}
</NcButton>
</form>
<template v-else>
<p>{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }}</p>
<!-- 2. Download the kit and confirm -->
<div v-if="!status.key.confirmation" class="ngb-confirm">
<NcNoteCard type="warning">
{{ t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with your passphrase, somewhere safe outside this server.') }}
</NcNoteCard>
<NcButton :href="kitUrl" download @click="downloaded = true">
{{ t('ng_backup', 'Download recovery kit') }}
</NcButton>
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">
{{ status.statement }}
</NcCheckboxRadioSwitch>
<NcButton variant="primary" :disabled="!downloaded || !accepted || busy" @click="confirm">
{{ status.confirmationPhrase }}
</NcButton>
</div>
<p v-else>
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
<a :href="kitUrl" download>{{ t('ng_backup', 'Download the kit again') }}</a>
</p>
</template>
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
</NcSettingsSection>
</template>
<script setup>
import { ref } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import api from '../api.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const pass1 = ref('')
const pass2 = ref('')
const downloaded = ref(false)
const accepted = ref(false)
const busy = ref(false)
const error = ref('')
const kitUrl = api.kitUrl()
async function createKey() {
busy.value = true
error.value = ''
try {
await api.initKey(pass1.value)
pass1.value = pass2.value = ''
emit('changed')
} catch (e) {
error.value = e.message
} finally {
busy.value = false
}
}
async function confirm() {
busy.value = true
error.value = ''
try {
await api.confirmKit(accepted.value, props.status.confirmationPhrase)
emit('changed')
} catch (e) {
error.value = e.message
} finally {
busy.value = false
}
}
</script>
<style scoped>
.ngb-form, .ngb-confirm {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 600px;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<!--
- SPDX-FileCopyrightText: 2026 Dillard Blom
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSettingsSection :name="t('ng_backup', 'Backup locations')"
:description="t('ng_backup', 'Uses the connection types of External storage (S3, SFTP, WebDAV, ...), but never mounts them: the location is only connected while a backup or restore runs. Credentials are stored encrypted.')">
<ul v-if="status.targets.length" class="ngb-list">
<li v-for="target in status.targets" :key="target.id">
<strong>{{ target.name }}</strong>
<span class="ngb-muted">{{ target.backend }} · {{ target.path }}</span>
<span v-if="tests[target.id]" :class="tests[target.id].ok ? 'ngb-ok' : 'ngb-err'">
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
</span>
<NcButton variant="tertiary" @click="test(target)">{{ t('ng_backup', 'Test') }}</NcButton>
<NcButton variant="tertiary" @click="remove(target)">{{ t('ng_backup', 'Remove') }}</NcButton>
</li>
</ul>
<NcButton v-if="!adding" @click="startAdd">{{ t('ng_backup', 'Add location') }}</NcButton>
<form v-else class="ngb-form" @submit.prevent="add">
<NcTextField v-model="form.name" :label="t('ng_backup', 'Name')" />
<NcSelect v-model="backend" :options="backends" label="name" :input-label="t('ng_backup', 'Type')" :clearable="false" />
<NcSelect v-if="backend" v-model="auth" :options="backend.auth" label="name" :input-label="t('ng_backup', 'Authentication')" :clearable="false" />
<template v-for="field in fields" :key="field.key">
<NcCheckboxRadioSwitch v-if="field.type === 1" v-model="form.options[field.key]">{{ field.label }}</NcCheckboxRadioSwitch>
<NcPasswordField v-else-if="field.type === 2" v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" autocomplete="off" />
<NcTextField v-else v-model="form.options[field.key]" :label="field.label + (field.optional ? '' : ' *')" />
</template>
<NcTextField v-model="form.path" :label="t('ng_backup', 'Folder on the location for the backups')" />
<div class="ngb-row">
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton>
<NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</NcButton>
</div>
</form>
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
</NcSettingsSection>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import api from '../api.js'
defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const adding = ref(false)
const busy = ref(false)
const error = ref('')
const backends = ref([])
const backend = ref(null)
const auth = ref(null)
const tests = reactive({})
const form = reactive({ name: '', path: 'ng_backup', options: {} })
// files_external DefinitionParameter: type 0 text, 1 boolean, 2 password; flag 1 = optional, 4 = hidden
const toFields = (params) => Object.entries(params || {})
.filter(([, p]) => !(p.flags & 4))
.map(([key, p]) => ({ key, label: p.value, type: p.type, optional: !!(p.flags & 1) }))
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
watch(backend, (b) => {
auth.value = b?.auth?.[0] ?? null
form.options = {}
})
async function startAdd() {
adding.value = true
error.value = ''
if (!backends.value.length) {
try {
backends.value = await api.backends()
} catch (e) {
error.value = e.message
}
}
}
async function add() {
busy.value = true
error.value = ''
try {
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path })
adding.value = false
Object.assign(form, { name: '', path: 'ng_backup', options: {} })
emit('changed')
} catch (e) {
error.value = e.message
} finally {
busy.value = false
}
}
async function test(target) {
tests[target.id] = await api.testTarget(target.id)
}
async function remove(target) {
if (!window.confirm(t('ng_backup', 'Remove "{name}" from NG Backup? The backups on the location itself are kept.', { name: target.name }))) {
return
}
await api.removeTarget(target.id)
emit('changed')
}
</script>
<style scoped>
.ngb-list li { display: flex; align-items: center; gap: 12px; padding: 4px 0; }
.ngb-muted { color: var(--color-text-maxcontrast); }
.ngb-ok { color: var(--color-success-text); }
.ngb-err { color: var(--color-error-text); }
.ngb-form { display: flex; flex-direction: column; gap: 10px; max-width: 600px; margin-top: 12px; }
.ngb-row { display: flex; gap: 8px; }
</style>
+129
View File
@@ -0,0 +1,129 @@
<!--
- SPDX-FileCopyrightText: 2026 Dillard Blom
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSettingsSection :name="t('ng_backup', 'Restore')"
:description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')">
<div class="ngb-row">
<NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" />
<NcSelect v-if="target" v-model="snapshot" :options="snapshots" :get-option-label="snapshotLabel" :input-label="t('ng_backup', 'Restore point')" :clearable="false" />
</div>
<template v-if="snapshot">
<div class="ngb-crumbs">
<NcButton variant="tertiary" @click="open('data')">data</NcButton>
<template v-for="(crumb, i) in crumbs" :key="crumb.path">
<span>/</span>
<NcButton variant="tertiary" :disabled="i === crumbs.length - 1" @click="open(crumb.path)">{{ crumb.name }}</NcButton>
</template>
</div>
<NcLoadingIcon v-if="loading" />
<ul v-else class="ngb-entries">
<li v-for="item in items" :key="item.name" :class="{ selected: selected === join(item.name) }">
<NcButton v-if="item.type === 'dir'" variant="tertiary" @click="open(join(item.name))">📁 {{ item.name }}/</NcButton>
<span v-else class="ngb-file">📄 {{ item.name }}</span>
<span class="ngb-muted">{{ formatSize(item.size) }}{{ item.type === 'dir' ? ' · ' + item.files + ' files' : '' }}</span>
<NcButton v-if="restorable(join(item.name))" variant="tertiary" @click="selected = join(item.name)">{{ t('ng_backup', 'Select') }}</NcButton>
</li>
</ul>
<NcButton v-if="restorable(path) && path !== selected" variant="tertiary" @click="selected = path">{{ t('ng_backup', 'Select this folder') }}</NcButton>
<div v-if="selected" class="ngb-restore">
<p>{{ t('ng_backup', 'Selected: {path}', { 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>
</template>
<NcNoteCard v-if="message" type="success">{{ message }}</NcNoteCard>
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
</NcSettingsSection>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import api from '../api.js'
import { formatSize } from '../format.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const target = ref(props.status.targets[0] ?? null)
const snapshots = ref([])
const snapshot = ref(null)
const path = ref('data')
const items = ref([])
const loading = ref(false)
const selected = ref('')
const mode = ref('new-folder')
const busy = ref(false)
const error = ref('')
const message = ref('')
const modes = [
{ id: 'new-folder', label: t('ng_backup', 'Into a new folder "Restored <date>" (nothing existing is changed)') },
{ id: 'merge', label: t('ng_backup', 'Into the original place, overwrite (old content kept as a version)') },
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
]
const snapshotLabel = (s) => new Date(s.createdAt * 1000).toLocaleString() + (s.label ? ' – ' + s.label : '')
const join = (name) => path.value + '/' + name
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
const crumbs = computed(() => {
const parts = path.value.split('/').slice(1)
return parts.map((name, i) => ({ name, path: ['data', ...parts.slice(0, i + 1)].join('/') }))
})
watch(target, async (tg) => {
snapshot.value = null
snapshots.value = tg ? await api.snapshots(tg.id) : []
snapshot.value = snapshots.value[0] ?? null
}, { immediate: true })
watch(snapshot, () => { selected.value = ''; open('data') })
async function open(p) {
if (!snapshot.value) {
return
}
path.value = p
loading.value = true
error.value = ''
try {
items.value = await api.browse(target.value.id, snapshot.value.id, p)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function restore() {
busy.value = true
error.value = message.value = ''
try {
await api.startRestore(target.value.id, snapshot.value.id, selected.value, mode.value)
message.value = t('ng_backup', 'Restore queued. It runs with the next cron job; progress is shown under Backups.')
emit('changed')
} catch (e) {
error.value = e.message
} finally {
busy.value = false
}
}
</script>
<style scoped>
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
.ngb-file { padding-inline-start: 12px; }
.ngb-muted { color: var(--color-text-maxcontrast); }
.ngb-restore { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; max-width: 700px; }
</style>