diff --git a/nextcloud_backup/backend/src/routes/action.ts b/nextcloud_backup/backend/src/routes/action.ts new file mode 100644 index 0000000..7fce3b5 --- /dev/null +++ b/nextcloud_backup/backend/src/routes/action.ts @@ -0,0 +1,18 @@ +import express from "express"; +import { doBackupWorkflow } from "../services/orchestrator.js"; +import { WorkflowType } from "../types/services/orchecstrator.js"; +import logger from "../config/winston.js"; + +const actionRouter = express.Router(); + +actionRouter.post("/backup", (req, res) => { + doBackupWorkflow(WorkflowType.MANUAL) + .then(() => { + logger.info("All good !"); + }) + .catch((reason) => { + logger.error("Something wrong !"); + }); +}); + +export default actionRouter; diff --git a/nextcloud_backup/backend/src/routes/apiV2.ts b/nextcloud_backup/backend/src/routes/apiV2.ts index b82da05..52a5997 100644 --- a/nextcloud_backup/backend/src/routes/apiV2.ts +++ b/nextcloud_backup/backend/src/routes/apiV2.ts @@ -1,17 +1,18 @@ -import express from "express" +import express from "express"; import configRouter from "./config.js"; -import homeAssistant from "./homeAssistant.js" +import homeAssistant from "./homeAssistant.js"; import messageRouter from "./messages.js"; import webdavRouter from "./webdav.js"; import statusRouter from "./status.js"; - +import actionRouter from "./action.js"; const router = express.Router(); -router.use("/homeAssistant", homeAssistant) +router.use("/homeAssistant", homeAssistant); router.use("/config", configRouter); router.use("/webdav", webdavRouter); router.use("/messages", messageRouter); -router.use('/status', statusRouter); +router.use("/status", statusRouter); +router.use("/action", actionRouter); -export default router; \ No newline at end of file +export default router; diff --git a/nextcloud_backup/backend/src/services/backupConfigService.ts b/nextcloud_backup/backend/src/services/backupConfigService.ts index 03c7e80..8f3a97a 100644 --- a/nextcloud_backup/backend/src/services/backupConfigService.ts +++ b/nextcloud_backup/backend/src/services/backupConfigService.ts @@ -1,8 +1,13 @@ import fs from "fs"; import Joi from "joi"; import logger from "../config/winston.js"; -import { type BackupConfig, BackupType } from "../types/services/backupConfig.js"; +import { + type BackupConfig, + BackupType, +} from "../types/services/backupConfig.js"; import backupConfigValidation from "../types/services/backupConfigValidation.js"; +import { DateTime } from "luxon"; +import { WorkflowType } from "../types/services/orchecstrator.js"; const backupConfigPath = "/data/backupConfigV2.json"; @@ -52,10 +57,32 @@ export function templateToRegexp(template: string) { let regexp = template.replace("{date}", "(?\\d{4}-\\d{2}-\\d{2})"); regexp = regexp.replace("{hour}", "(?\\d{4})"); regexp = regexp.replace("{hour_12}", "(?\\d{4}(AM|PM))"); - regexp = regexp.replace("{type}", "(?Auto|Manual|)") - regexp = regexp.replace("{type_low}", "(?auto|manual|)") + regexp = regexp.replace("{type}", "(?Auto|Manual|)"); + regexp = regexp.replace("{type_low}", "(?auto|manual|)"); return regexp.replace( "{ha_version}", "(?\\d+\\.\\d+\\.\\d+(b\\d+)?)" ); } + +export function getFormatedName( + workflowType: WorkflowType, + ha_version: string +) { + const setting = getBackupConfig(); + let template = setting.nameTemplate; + template = template.replace( + "{type_low}", + workflowType == WorkflowType.MANUAL ? "manual" : "auto" + ); + template = template.replace( + "{type}", + workflowType == WorkflowType.MANUAL ? "Manual" : "Auto" + ); + template = template.replace("{ha_version}", ha_version); + const now = DateTime.now().setLocale("en"); + template = template.replace("{hour_12}", now.toFormat("hhmma")); + template = template.replace("{hour}", now.toFormat("HHmm")); + template = template.replace("{date}", now.toFormat("yyyy-MM-dd")); + return template; +} diff --git a/nextcloud_backup/backend/src/services/homeAssistantService.ts b/nextcloud_backup/backend/src/services/homeAssistantService.ts index 1158253..d7205dd 100644 --- a/nextcloud_backup/backend/src/services/homeAssistantService.ts +++ b/nextcloud_backup/backend/src/services/homeAssistantService.ts @@ -13,7 +13,7 @@ import logger from "../config/winston.js"; import messageManager from "../tools/messageManager.js"; import * as settingsTools from "../tools/settingsTools.js"; import * as statusTools from "../tools/status.js"; -import type { NewPartialBackupPayload } from "../types/services/ha_os_payload.js"; +import type { NewBackupPayload } from "../types/services/ha_os_payload.js"; import type { AddonData, AddonModel, @@ -25,6 +25,7 @@ import type { } from "../types/services/ha_os_response.js"; import { States, type Status } from "../types/status.js"; import { DateTime } from "luxon"; +import { BackupType } from "../types/services/backupConfig.js"; const pipeline = promisify(stream.pipeline); @@ -81,29 +82,6 @@ function getAddonList(): Promise>> { ); } -function getAddonToBackup(addons: AddonModel[]) { - const excluded_addon = settingsTools.getSettings().exclude_addon; - const slugs: string[] = []; - for (const addon of addons) { - if (!excluded_addon.includes(addon.slug)) slugs.push(addon.slug); - } - logger.debug("Addon to backup:"); - logger.debug(slugs); - return slugs; -} - -function getFolderToBackup() { - const excluded_folder = settingsTools.getSettings().exclude_folder; - const all_folder = getFolderList(); - const slugs = []; - for (const folder of all_folder) { - if (!excluded_folder.includes(folder.slug)) slugs.push(folder.slug); - } - logger.debug("Folders to backup:"); - logger.debug(slugs); - return slugs; -} - function getBackups(): Promise>> { const option: OptionsOfJSONResponseBody = { headers: { authorization: `Bearer ${token}` }, @@ -232,25 +210,24 @@ function getBackupInfo(id: string) { function createNewBackup( name: string, - addonSlugs: string[], - folders: string[] + type: BackupType, + passwordEnable: boolean, + password?: string, + addonSlugs?: string[], + folders?: string[] ) { const status = statusTools.getStatus(); status.status = States.BKUP_CREATION; status.progress = -1; statusTools.setStatus(status); logger.info("Creating new snapshot..."); - const body: NewPartialBackupPayload = { + const body: NewBackupPayload = { name: name, - addons: addonSlugs, - folders: folders, + password: passwordEnable ? password : undefined, + addons: type == BackupType.PARTIAL ? addonSlugs : undefined, + folders: type == BackupType.PARTIAL ? folders : undefined, }; - const password_protected = settingsTools.getSettings().password_protected; - logger.debug(`Is password protected ? ${password_protected}`); - if (password_protected === "true") { - body.password = settingsTools.getSettings().password_protect_value; - } const option: OptionsOfJSONResponseBody = { headers: { authorization: `Bearer ${token}` }, responseType: "json", @@ -259,31 +236,31 @@ function createNewBackup( }, json: body, }; - return got - .post>( - `http://hassio/backups/new/partial`, - option - ) - .then( - (result) => { - logger.info(`Snapshot created with id ${result.body.data.slug}`); - const status = statusTools.getStatus(); - status.status = States.IDLE; - status.progress = undefined; - statusTools.setStatus(status); - return result; - }, - (reason) => { - messageManager.error("Fail to create new backup.", reason.message); - logger.error("Fail to create new backup"); - logger.error(reason); - const status = statusTools.getStatus(); - status.status = States.IDLE; - status.progress = undefined; - statusTools.setStatus(status); - return Promise.reject(reason); - } - ); + + const url = + type == BackupType.PARTIAL + ? "http://hassio/backups/new/partial" + : "http://hassio/backups/new/full"; + return got.post>(url, option).then( + (result) => { + logger.info(`Snapshot created with id ${result.body.data.slug}`); + const status = statusTools.getStatus(); + status.status = States.IDLE; + status.progress = undefined; + statusTools.setStatus(status); + return result; + }, + (reason) => { + messageManager.error("Fail to create new backup.", reason.message); + logger.error("Fail to create new backup"); + logger.error(reason); + const status = statusTools.getStatus(); + status.status = States.IDLE; + status.progress = undefined; + statusTools.setStatus(status); + return Promise.reject(reason); + } + ); } function clean(backups: BackupModel[]) { diff --git a/nextcloud_backup/backend/src/services/orchestrator.ts b/nextcloud_backup/backend/src/services/orchestrator.ts new file mode 100644 index 0000000..df550a9 --- /dev/null +++ b/nextcloud_backup/backend/src/services/orchestrator.ts @@ -0,0 +1,133 @@ +import type { AddonModel } from "../types/services/ha_os_response.js"; +import { WorkflowType } from "../types/services/orchecstrator.js"; +import * as backupConfigService from "./backupConfigService.js"; +import * as homeAssistantService from "./homeAssistantService.js"; +import { getBackupFolder, getWebdavConfig } from "./webdavConfigService.js"; +import * as webDavService from "./webdavService.js"; +import * as statusTools from "../tools/status.js"; +import { stat, unlinkSync } from "fs"; +import logger from "../config/winston.js"; +import { BackupType } from "../types/services/backupConfig.js"; +import { DateTime } from "luxon"; +import messageManager from "../tools/messageManager.js"; + +export function doBackupWorkflow(type: WorkflowType) { + let name = ""; + let addonsToStartStop = [] as string[]; + let addonInHa = [] as AddonModel[]; + let tmpBackupFile = ""; + + const backupConfig = backupConfigService.getBackupConfig(); + const webdavConfig = getWebdavConfig(); + + return homeAssistantService + .getVersion() + .then((value) => { + const version = value.body.data.version; + name = backupConfigService.getFormatedName(type, version); + return homeAssistantService.getAddonList(); + }) + .then((response) => { + addonInHa = response.body.data.addons; + addonsToStartStop = sanitizeAddonList( + backupConfig.autoStopAddon, + response.body.data.addons + ); + return webDavService.checkWebdavLogin(webdavConfig); + }) + .then(() => { + return homeAssistantService.stopAddons(addonsToStartStop); + }) + .then((response) => { + if (backupConfig.backupType == BackupType.FULL) { + return homeAssistantService.createNewBackup( + name, + backupConfig.backupType, + backupConfig.password.enabled, + backupConfig.password.value + ); + } else { + const addons = getAddonToBackup( + backupConfig.exclude?.addon as string[], + addonInHa + ); + const folders = getFolderToBackup( + backupConfig.exclude?.folder as string[], + homeAssistantService.getFolderList() + ); + return homeAssistantService.createNewBackup( + name, + backupConfig.backupType, + backupConfig.password.enabled, + backupConfig.password.value, + addons, + folders + ); + } + }) + .then((response) => { + response.body.data.slug; + return homeAssistantService.downloadSnapshot(response.body.data.slug); + }) + .then((tmpFile) => { + tmpBackupFile = tmpFile; + return webDavService.webdavUploadFile( + tmpFile, + getBackupFolder(type, webdavConfig) + name, + webdavConfig + ); + }) + .then(() => { + logger.info("Backup workflow finished successfully !"); + messageManager.info("Backup workflow finished successfully !"); + const status = statusTools.getStatus(); + status.last_backup.success = true; + status.last_backup.last_try = DateTime.now(); + status.last_backup.last_success = DateTime.now(); + statusTools.setStatus(status); + }) + .catch(() => { + backupFail(); + if (tmpBackupFile != "") { + unlinkSync(tmpBackupFile); + } + return Promise.reject(); + }); +} + +// This methods remove addon that are no installed in HA from the conf array +function sanitizeAddonList(addonInConf: string[], addonInHA: AddonModel[]) { + addonInConf.filter((value) => addonInHA.some((v) => v.slug == value)); + return addonInConf; +} + +function getAddonToBackup(excludedAddon: string[], addonInHA: AddonModel[]) { + const slugs: string[] = []; + for (const addon of addonInHA) { + if (!excludedAddon.includes(addon.slug)) slugs.push(addon.slug); + } + logger.debug("Addon to backup:"); + logger.debug(slugs); + return slugs; +} + +function getFolderToBackup( + excludedFolder: string[], + folderInHA: { name: string; slug: string }[] +) { + const slugs = []; + for (const folder of folderInHA) { + if (!excludedFolder.includes(folder.slug)) slugs.push(folder.slug); + } + logger.debug("Folders to backup:"); + logger.debug(slugs); + return slugs; +} + +function backupFail() { + const status = statusTools.getStatus(); + status.last_backup.success = false; + status.last_backup.last_try = DateTime.now(); + statusTools.setStatus(status); + messageManager.error("Last backup as failed !"); +} diff --git a/nextcloud_backup/backend/src/services/webdavConfigService.ts b/nextcloud_backup/backend/src/services/webdavConfigService.ts index 3609e6d..44ccbec 100644 --- a/nextcloud_backup/backend/src/services/webdavConfigService.ts +++ b/nextcloud_backup/backend/src/services/webdavConfigService.ts @@ -4,9 +4,13 @@ import logger from "../config/winston.js"; import { default_root } from "../tools/pathTools.js"; import { type WebdavConfig, - WebdavEndpointType + WebdavEndpointType, } from "../types/services/webdavConfig.js"; import WebdavConfigValidation from "../types/services/webdavConfigValidation.js"; +import { BackupType } from "../types/services/backupConfig.js"; +import * as pathTools from "../tools/pathTools.js"; +import { WorkflowType } from "../types/services/orchecstrator.js"; +import e from "express"; const webdavConfigPath = "/data/webdavConfigV2.json"; const NEXTCLOUD_ENDPOINT = "/remote.php/dav/files/$username"; @@ -46,12 +50,24 @@ export function getEndpoint(config: WebdavConfig) { } else { return ""; } - if (endpoint.endsWith("/")) { - return endpoint.slice(0, -1); + if (!endpoint.startsWith("/")) { + endpoint = "/" + endpoint; } + + if (!endpoint.endsWith("/")) { + return endpoint + "/"; + } + return endpoint; } +export function getBackupFolder(type: WorkflowType, config: WebdavConfig) { + const end = type == WorkflowType.AUTO ? pathTools.auto : pathTools.manual; + return config.backupDir.endsWith("/") + ? config.backupDir + end + : config.backupDir + "/" + end; +} + export function getWebdavDefaultConfig(): WebdavConfig { return { url: "", diff --git a/nextcloud_backup/backend/src/services/webdavService.ts b/nextcloud_backup/backend/src/services/webdavService.ts index 06f7bce..c3c7340 100644 --- a/nextcloud_backup/backend/src/services/webdavService.ts +++ b/nextcloud_backup/backend/src/services/webdavService.ts @@ -268,8 +268,7 @@ export function webdavUploadFile( }, https: { rejectUnauthorized: !config.allowSelfSignedCerts }, }; - const url = - config.url + getEndpoint(config) + config.backupDir + webdavPath; + const url = config.url + getEndpoint(config) + webdavPath; logger.debug(`...URI: ${encodeURI(url)}`); logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`); diff --git a/nextcloud_backup/backend/src/tools/settingsTools.ts b/nextcloud_backup/backend/src/tools/settingsTools.ts index f0718f3..bdfe78b 100644 --- a/nextcloud_backup/backend/src/tools/settingsTools.ts +++ b/nextcloud_backup/backend/src/tools/settingsTools.ts @@ -39,7 +39,7 @@ function check_cron(conf: Settings) { if (conf.cron_custom != null) { try { // TODO Need to be destroy - new CronJob(conf.cron_custom, () => { + new CronJob(conf.cron_custom, () => { //Do nothing }); return true; diff --git a/nextcloud_backup/backend/src/types/services/ha_os_payload.ts b/nextcloud_backup/backend/src/types/services/ha_os_payload.ts index 72b3dfa..05476ba 100644 --- a/nextcloud_backup/backend/src/types/services/ha_os_payload.ts +++ b/nextcloud_backup/backend/src/types/services/ha_os_payload.ts @@ -1,8 +1,8 @@ -export interface NewPartialBackupPayload { - name?: string; +export interface NewBackupPayload { + name?: string; password?: string; homeassistant?: boolean; addons?: string[]; folders?: string[]; compressed?: boolean; -} \ No newline at end of file +} diff --git a/nextcloud_backup/backend/src/types/services/orchecstrator.ts b/nextcloud_backup/backend/src/types/services/orchecstrator.ts new file mode 100644 index 0000000..c614e5a --- /dev/null +++ b/nextcloud_backup/backend/src/types/services/orchecstrator.ts @@ -0,0 +1,4 @@ +export enum WorkflowType { + AUTO, + MANUAL, +} diff --git a/nextcloud_backup/backend/src/types/status.ts b/nextcloud_backup/backend/src/types/status.ts index bc07af3..e00c8ab 100644 --- a/nextcloud_backup/backend/src/types/status.ts +++ b/nextcloud_backup/backend/src/types/status.ts @@ -17,7 +17,7 @@ export interface Status { last_backup: { success?: boolean; last_success?: DateTime; - message?: string; + last_try?: DateTime; }; next_backup?: string; webdav: { diff --git a/nextcloud_backup/frontend/components.d.ts b/nextcloud_backup/frontend/components.d.ts index dfaebcb..fa62983 100644 --- a/nextcloud_backup/frontend/components.d.ts +++ b/nextcloud_backup/frontend/components.d.ts @@ -7,6 +7,7 @@ export {} declare module 'vue' { export interface GlobalComponents { + ActionComponent: typeof import('./src/components/statusBar/ActionComponent.vue')['default'] AlertManager: typeof import('./src/components/AlertManager.vue')['default'] BackupConfigAddon: typeof import('./src/components/settings/BackupConfig/BackupConfigAddon.vue')['default'] BackupConfigAutoBackup: typeof import('./src/components/settings/BackupConfig/BackupConfigAutoBackup.vue')['default'] @@ -16,9 +17,11 @@ declare module 'vue' { BackupConfigForm: typeof import('./src/components/settings/BackupConfigForm.vue')['default'] BackupConfigMenu: typeof import('./src/components/settings/BackupConfigMenu.vue')['default'] BackupConfigSecurity: typeof import('./src/components/settings/BackupConfig/BackupConfigSecurity.vue')['default'] + BackupStatus: typeof import('./src/components/statusBar/BackupStatus.vue')['default'] CloudDeleteDialog: typeof import('./src/components/cloud/CloudDeleteDialog.vue')['default'] CloudList: typeof import('./src/components/cloud/CloudList.vue')['default'] CloudListItem: typeof import('./src/components/cloud/CloudListItem.vue')['default'] + ConnectionStatus: typeof import('./src/components/statusBar/ConnectionStatus.vue')['default'] HaList: typeof import('./src/components/homeAssistant/HaList.vue')['default'] HaListItem: typeof import('./src/components/homeAssistant/HaListItem.vue')['default'] HaListItemContent: typeof import('./src/components/homeAssistant/HaListItemContent.vue')['default'] @@ -26,7 +29,8 @@ declare module 'vue' { NavbarComponent: typeof import('./src/components/NavbarComponent.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] - StatusComponent: typeof import('./src/components/StatusComponent.vue')['default'] + StatusBar: typeof import('./src/components/statusBar/StatusBar.vue')['default'] + StatusComponent: typeof import('./src/components/statusBar/StatusComponent.vue')['default'] WebdavConfigForm: typeof import('./src/components/settings/WebdavConfigForm.vue')['default'] WebdavConfigMenu: typeof import('./src/components/settings/WebdavConfigMenu.vue')['default'] } diff --git a/nextcloud_backup/frontend/src/App.vue b/nextcloud_backup/frontend/src/App.vue index 8e19274..4bfec05 100644 --- a/nextcloud_backup/frontend/src/App.vue +++ b/nextcloud_backup/frontend/src/App.vue @@ -5,17 +5,13 @@ - + + - - + + - - - - - - + @@ -32,8 +28,14 @@ import MessageBar from "./components/MessageBar.vue"; import NavbarComponent from "./components/NavbarComponent.vue"; import BackupConfigMenu from "./components/settings/BackupConfigMenu.vue"; import WebdavConfigMenu from "./components/settings/WebdavConfigMenu.vue"; -import StatusComponent from "./components/StatusComponent.vue"; +import StatusBar from "./components/statusBar/StatusBar.vue"; const cloudList = ref | null>(null); +const haList = ref | null>(null); + +function refreshLists() { + cloudList.value?.refreshBackup(); + haList.value?.refreshBackup(); +} diff --git a/nextcloud_backup/frontend/src/components/StatusComponent.vue b/nextcloud_backup/frontend/src/components/StatusComponent.vue deleted file mode 100644 index c853201..0000000 --- a/nextcloud_backup/frontend/src/components/StatusComponent.vue +++ /dev/null @@ -1,130 +0,0 @@ - - - diff --git a/nextcloud_backup/frontend/src/components/cloud/CloudList.vue b/nextcloud_backup/frontend/src/components/cloud/CloudList.vue index 41f66ec..f4e8a00 100644 --- a/nextcloud_backup/frontend/src/components/cloud/CloudList.vue +++ b/nextcloud_backup/frontend/src/components/cloud/CloudList.vue @@ -15,7 +15,7 @@ > - + diff --git a/nextcloud_backup/frontend/src/components/cloud/CloudListItem.vue b/nextcloud_backup/frontend/src/components/cloud/CloudListItem.vue index 78c81fd..cc3fc26 100644 --- a/nextcloud_backup/frontend/src/components/cloud/CloudListItem.vue +++ b/nextcloud_backup/frontend/src/components/cloud/CloudListItem.vue @@ -90,7 +90,7 @@ - + - +