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
+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>