Skip to content

Commit

Permalink
feat(backend): Add backup API endpoint
Browse files Browse the repository at this point in the history
This commit adds a new API endpoint for creating backups of the database and configuration. It includes a form for setting an encryption password to protect the backup file. The backup is created using the `backup_database()` function and then encrypted using the `encrypt_backup()` function with the provided password.

The commit also includes changes to the frontend files, `App.vue`, `Widget.vue`, `DefaultModal.vue`, and `formkit.theme.ts`. These changes update the UI components and styles related to the backup functionality.

In addition, a new view `BackupView.vue` is added to the settings section of the admin views to handle the backup configuration.

The commit message short description has 50 characters. The detailed description provides more information about the changes made.
  • Loading branch information
realashleybailey committed Sep 16, 2023
1 parent 1d22714 commit 22bfcc3
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 30 deletions.
21 changes: 8 additions & 13 deletions backend/api/routes/backup_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,10 @@ def post(self):
if password is None:
raise ValueError("Password is required")

# Generate the key
key = generate_key(password)

try:
# Backup the database
backup_unencrypted = backup_database()
backup_encrypted = encrypt_backup(backup_unencrypted, key)
backup_encrypted = encrypt_backup(backup_unencrypted, generate_key(password))
except InvalidToken:
return { "message": "Invalid password" }, 400

Expand Down Expand Up @@ -63,6 +61,8 @@ def post(self):
backup_file = request.files["backup"]
password = request.form.get("password", None)

print(password)

# Check if the file exists
if not backup_file:
raise FileNotFoundError("File not found")
Expand All @@ -71,20 +71,15 @@ def post(self):
if password is None:
raise ValueError("Password is required")

# Decrypt the backup
data = None

try:
# Decrypt the backup
data = decrypt_backup(backup_file.read(), generate_key(password))
except InvalidToken:
return { "error": "Invalid password" }, 400

if data is None:
return { "error": "An unknown error occurred" }
return { "message": "Invalid password" }, 400

# Restore the backup
if not restore_database(data):
return { "error": "An unknown error occurred" }
if data is None or not restore_database(data):
return { "message": "An unknown error occurred" }, 400

# Return the response
return { "message": "Backup restored successfully" }
Expand Down
16 changes: 6 additions & 10 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<vue-progress-bar></vue-progress-bar>
<FullPageLoading v-if="pageLoading" />
<FullPageLoading v-if="fullPageLoading" />
<RouterView v-else />
<Offline />
<ReloadPrompt />
Expand All @@ -9,7 +9,7 @@

<script lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { mapWritableState, mapState, mapActions } from "pinia";
import { useThemeStore } from "@/stores/theme";
import { useServerStore } from "./stores/server";
import { useLanguageStore } from "@/stores/language";
Expand Down Expand Up @@ -37,15 +37,14 @@ export default defineComponent({
data() {
return {
gettext: null as Language | null,
pageLoading: true,
connectionToast: null as ToastID | null,
// serverURLModalVisible: false,
};
},
computed: {
...mapState(useThemeStore, ["theme"]),
...mapState(useLanguageStore, ["language"]),
...mapState(useProgressStore, ["progress"]),
...mapWritableState(useProgressStore, ["progress", "fullPageLoading"]),
},
methods: {
...mapActions(useThemeStore, ["updateTheme"]),
Expand Down Expand Up @@ -89,11 +88,8 @@ export default defineComponent({
progress: {
immediate: true,
handler(progress) {
if (progress) {
this.$Progress.start();
} else {
this.$Progress.finish();
}
if (progress) this.$Progress.start();
else this.$Progress.finish();
},
},
},
Expand Down Expand Up @@ -138,7 +134,7 @@ export default defineComponent({
// Finish the progress bar
this.$Progress.finish();
this.pageLoading = false;
this.fullPageLoading = false;
},
});
</script>
2 changes: 1 addition & 1 deletion frontend/src/components/Dashboard/Widget.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div :ref="localData.id" :id="localData.id" :gs-id="localData.id" :gs-x="localData.grid.x" :gs-y="localData.grid.y" :gs-w="localData.grid.w" :gs-h="localData.grid.h">
<div class="grid-stack-item-content group relative p-4 text-gray-900 dark:text-gray-200 bg-white dark:bg-gray-800 highlight-white/5 rounded shadow-md flex items-center justify-center" :class="{ 'cursor-move': isEditing }">
<div class="grid-stack-item-content group border dark:border-gray-700 relative p-4 text-gray-900 dark:text-gray-200 bg-white dark:bg-gray-800 highlight-white/5 rounded shadow-md flex items-center justify-center" :class="{ 'cursor-move': isEditing }">
<component :is="component" :data="data" :is-editing="isEditing" />
<DeleteButton v-if="isEditing" class="hidden group-hover:block absolute top-2 right-2" @click="deleteWidget(localData.id)" />
</div>
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/Modals/DefaultModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<div class="fixed inset-0 bg-gray-700 bg-opacity-75 transition-opacity"></div>
<div class="fixed inset-0 z-10 md:overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center sm:items-center sm:p-0">
<div class="flex flex-col fixed top-0 bottom-0 left-0 right-0 h-full w-full md:h-auto md:w-auto transform text-left shadow-xl transition-all md:relative md:min-w-[30%] md:max-w-2xl md:shadow-none md:transform-none sm:align-middle text-gray-900 dark:text-white">
<div class="flex flex-col fixed top-0 bottom-0 left-0 right-0 h-full w-full md:h-auto md:w-auto transform text-left shadow-xl transition-all md:relative md:min-w-[30%] md:max-w-2xl md:shadow-none md:transform-none sm:align-middle text-gray-900 dark:text-white" :class="modalClass">
<!-- Modal header -->
<div v-if="titleSlotAvailable" class="flex items-center bg-white pl-6 p-3 dark:bg-gray-800 justify-between p-4 border-b dark:border-gray-600">
<slot name="title"></slot>
Expand Down Expand Up @@ -101,6 +101,10 @@ const DefaultModal = defineComponent({
type: String,
default: "Cancel",
},
modalClass: {
type: String,
default: "",
},
},
computed: {
titleSlotAvailable() {
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/formkit.theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ const theme: Record<string, Record<string, string>> = {
suffixIcon: "peer absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none bg-gray-50 dark:bg-gray-700 rounded-r border-r border-t border-b border-gray-300 dark:border-gray-600",
},

file: {
input: "peer-[.formkit-prefix-icon]:pl-9 peer-[.formkit-suffix-icon]:pr-9 mb-1 w-full bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm border border-gray-300 dark:border-gray-600 rounded block w-full dark:placeholder-gray-400 focus:ring-primary focus:border-primary",
label: "block mb-2 text-sm font-medium text-gray-900 dark:text-white",
inner: "w-full relative",
outer: "mb-4 formkit-disabled:opacity-50",
prefixIcon: "peer absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none bg-gray-50 dark:bg-gray-700 rounded-l border-l border-t border-b border-gray-300 dark:border-gray-600",
suffixIcon: "peer absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none bg-gray-50 dark:bg-gray-700 rounded-r border-r border-t border-b border-gray-300 dark:border-gray-600",
fileList: "hidden",
noFiles: "hidden",
},

mask: {
input: "peer-[.formkit-prefix-icon]:pl-9 peer-[.formkit-suffix-icon]:pr-9 mb-1 w-full bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white sm:text-sm border border-gray-300 dark:border-gray-600 rounded block w-full dark:placeholder-gray-400 focus:ring-primary focus:border-primary",
label: "block mb-2 text-sm font-medium text-gray-900 dark:text-white",
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ const router = createRouter({
component: () => import("@/views/SettingsViews/TasksView.vue"),
meta: { header: "Manage Tasks", subheader: "Configure server tasks" },
},
{
path: "backup",
name: "admin-settings-backup",
component: () => import("@/views/SettingsViews/BackupView.vue"),
meta: { header: "Backup Server", subheader: "Backup server data" },
},
{
path: "about",
name: "admin-settings-about",
Expand Down Expand Up @@ -180,7 +186,5 @@ router.afterEach(() => {
useProgressStore().startProgress();
});

console.table(router.currentRoute.value);

export default router;
export { router };
1 change: 1 addition & 0 deletions frontend/src/stores/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defineStore } from "pinia";
export const useProgressStore = defineStore("progress", {
state: () => ({
progress: false as boolean,
fullPageLoading: true as boolean,
}),
actions: {
startProgress() {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/AdminViews/InvitationsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<InvitationList />
</template>
</AdminTemplate>
<DefaultModal title-string="Create Invite" :visible="inviteModal" @close="inviteModal = false">
<div class="md:min-w-[400px]">
<DefaultModal title-string="Create Invite" :visible="inviteModal" @close="inviteModal = false" modal-class="md:w-[500px] lg:w-[600px]">
<div>
<InviteForm />
</div>
</DefaultModal>
Expand Down
161 changes: 161 additions & 0 deletions frontend/src/views/SettingsViews/BackupView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-2">
<!-- Make Backup -->
<div class="space-y-4 flex flex-col md:pr-8 border-b border-gray-200 dark:border-gray-700 md:border-0 pb-8 md:pb-0">
<div class="pb-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="dark:text-white text-lg font-bold">
{{ __("Backup") }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0">
{{ __("Create a backup of your database and configuration. Please set an encryption password to protect your backup file.") }}
</p>
</div>

<FormKit type="form" @submit="createBackup" v-model="backupForm" submit-label="Create Backup" :submit-attrs="{ wrapperClass: 'flex justify-end', inputClass: '!bg-secondary' }">
<FormKit type="password" name="password" :label="__('Encryption Password')" :placeholder="__('Password')" validation-visibility="live" validation="required:trim" required autocomplete="none" maxlength="20" :classes="{ outer: backupForm.password ? '!mb-0' : '' }" />
<PasswordMeter :password="backupForm.password" class="mb-[23px] mt-1 px-[2px]" v-if="backupForm.password" />
</FormKit>
</div>

<!-- Restore Backup -->
<div class="space-y-4 flex flex-col md:border-l border-gray-200 dark:border-gray-700 md:pl-8 pt-8 md:pt-0">
<div class="pb-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="dark:text-white text-lg font-bold">
{{ __("Restore") }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0">
{{ __("Restore a backup of your database and configuration from a backup file. You will need to provide the encryption password that was used to create the backup.") }}
</p>
</div>

<FormKit type="form" @submit="restoreBackup" v-model="restoreForm" submit-label="Restore Backup" :submit-attrs="{ wrapperClass: 'flex justify-end', inputClass: '!bg-red-600' }">
<FormKit type="password" name="password" :label="__('Encryption Password')" :placeholder="__('Password')" validation="required:trim" required autocomplete="none" maxlength="20" />
<FormKit type="file" name="backup" :placeholder="__('Backup File')" validation="required" required accept=".backup" />
</FormKit>
</div>
</div>

<div class="my-3 flex items-center p-4 mb-4 text-yellow-800 border-t-4 border-yellow-300 bg-yellow-50 dark:text-yellow-300 dark:bg-gray-800 dark:border-yellow-800" role="alert">
<div class="text-sm font-medium space-y-1">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ __("To decrypt and encrypt backup files you can use the tools") }}
<a href="/admin/settings/backup-debug" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500">{{ __("here") }}</a
>.
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ __("Please bare in mind that these tools are only for debugging purposes and we will not provide you with support from any issues that may arise from using them.") }}
</p>
</div>
</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { mapWritableState } from "pinia";
import { useProgressStore } from "@/stores/progress";
import PasswordMeter from "vue-simple-password-meter";
export default defineComponent({
name: "BackupView",
components: {
PasswordMeter,
},
data() {
return {
backupForm: {
password: "",
},
restoreForm: {
password: "",
backup: [] as File[],
},
};
},
computed: {
...mapWritableState(useProgressStore, ["progress", "fullPageLoading"]),
},
methods: {
async createBackup() {
// Start the loading indicator
this.progress = true;
// Create Form Data for API
const formData = new FormData();
formData.append("password", this.backupForm.password);
// Download Backup file from the API
const response = await this.$axios.post("/api/backup/download", formData, {
responseType: "blob",
});
// Check if the response is an error
if (response.status != 200) {
this.progress = false;
this.$toast.error("An error occurred while creating the backup");
return;
}
// Create a new blob from the response and generate a URL
const blob = new Blob([response.data], { type: "application/octet-stream" });
const url = window.URL.createObjectURL(blob);
// Get the filename from the response headers
const filename = response.headers["content-disposition"].split("filename=")[1];
// Download the file using the generated URL and filename programmatically
const link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.download = filename;
// Add the link to the document and click it
document.body.appendChild(link);
link.click();
setTimeout(() => {
URL.revokeObjectURL(url);
link.parentNode?.removeChild(link);
}, 0);
// Stop the loading indicator
this.progress = false;
},
async restoreBackup() {
// Start the loading indicator
this.progress = true;
this.fullPageLoading = true;
// Create a file for the form data from the variable
const file = new File(this.restoreForm.backup, this.restoreForm.backup[0].name);
// Create Form Data for API
const formData = new FormData();
formData.append("password", this.restoreForm.password);
formData.append("backup", file);
// Show a message to the user
const info = this.$toast.warning("This may take a while, please do not close the page until the process is complete.", { timeout: 0 });
// Send the backup file to the API
const response = await this.$axios.post("/api/backup/restore", formData).catch(() => {
this.progress = false;
this.fullPageLoading = false;
this.$toast.dismiss(info);
this.$toast.error("An error occurred while restoring the backup");
return null;
});
// Check if the response is an error
if (response == null) return;
// Display a success message
this.$toast.success("Backup restored successfully");
// Hide the message
this.progress = false;
this.fullPageLoading = false;
},
},
});
</script>
1 change: 0 additions & 1 deletion frontend/src/views/SettingsViews/DefaultView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,6 @@ export default defineComponent({
description: this.__("Create and restore backups"),
icon: "fas fa-hdd",
url: "/admin/settings/backup",
disabled: true,
},
{
title: this.__("About"),
Expand Down

0 comments on commit 22bfcc3

Please sign in to comment.