mirror of
https://github.com/Sebclem/hassio-nextcloud-backup.git
synced 2024-11-25 10:32:58 +01:00
🔨 Migrate ha to service
This commit is contained in:
parent
168de9c5e6
commit
50f478cd48
@ -15,14 +15,11 @@
|
||||
"watch": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"cyan.bold,green.bold\" \"pnpm run watch-ts\" \"pnpm run watch-node\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "6.1.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.38.0",
|
||||
"app-root-path": "3.0.0",
|
||||
"bootstrap": "5.2.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
"cron": "2.1.0",
|
||||
"debug": "4.3.4",
|
||||
"ejs": "3.1.8",
|
||||
"errorhandler": "^1.5.1",
|
||||
"express": "4.18.1",
|
||||
"form-data": "4.0.0",
|
||||
@ -31,6 +28,7 @@
|
||||
"jquery": "3.6.0",
|
||||
"luxon": "3.0.1",
|
||||
"morgan": "1.10.0",
|
||||
"swagger-ui-express": "^4.5.0",
|
||||
"webdav": "4.10.0",
|
||||
"winston": "3.8.1"
|
||||
},
|
||||
|
@ -30,6 +30,7 @@ specifiers:
|
||||
luxon: 3.0.1
|
||||
morgan: 1.10.0
|
||||
nodemon: ^2.0.7
|
||||
swagger-ui-express: ^4.5.0
|
||||
ts-node: ^10.9.1
|
||||
typescript: ^4.8.3
|
||||
webdav: 4.10.0
|
||||
@ -52,6 +53,7 @@ dependencies:
|
||||
jquery: 3.6.0
|
||||
luxon: 3.0.1
|
||||
morgan: 1.10.0
|
||||
swagger-ui-express: 4.5.0_express@4.18.1
|
||||
webdav: 4.10.0_debug@4.3.4
|
||||
winston: 3.8.1
|
||||
|
||||
@ -2222,6 +2224,20 @@ packages:
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: true
|
||||
|
||||
/swagger-ui-dist/4.14.0:
|
||||
resolution: {integrity: sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw==}
|
||||
dev: false
|
||||
|
||||
/swagger-ui-express/4.5.0_express@4.18.1:
|
||||
resolution: {integrity: sha512-DHk3zFvsxrkcnurGvQlAcLuTDacAVN1JHKDgcba/gr2NFRE4HGwP1YeHIXMiGznkWR4AeS7X5vEblNn4QljuNA==}
|
||||
engines: {node: '>= v0.10.32'}
|
||||
peerDependencies:
|
||||
express: '>=4.0.0'
|
||||
dependencies:
|
||||
express: 4.18.1
|
||||
swagger-ui-dist: 4.14.0
|
||||
dev: false
|
||||
|
||||
/table/6.8.0:
|
||||
resolution: {integrity: sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
@ -5,10 +5,9 @@ import cookieParser from "cookie-parser";
|
||||
import fs from "fs"
|
||||
import newlog from "./config/winston.js"
|
||||
import * as statusTools from "./tools/status.js"
|
||||
import * as hassioApiTools from "./tools/hassioApiTools.js"
|
||||
import * as settingsTools from "./tools/settingsTools.js"
|
||||
import cronTools from "./tools/cronTools.js"
|
||||
import webdav from "./tools/webdavTools.js";
|
||||
import webdav from "./services/webdavService.js";
|
||||
|
||||
import apiRouter from "./routes/api.js"
|
||||
import { fileURLToPath } from "url";
|
||||
@ -71,15 +70,17 @@ if (!fs.existsSync("/data")) fs.mkdirSync("/data");
|
||||
statusTools.init();
|
||||
newlog.info("Satus : \x1b[32mGo !\x1b[0m");
|
||||
|
||||
hassioApiTools.getSnapshots().then(
|
||||
() => {
|
||||
newlog.info("Hassio API : \x1b[32mGo !\x1b[0m");
|
||||
},
|
||||
(err) => {
|
||||
newlog.error("Hassio API : \x1b[31;1mFAIL !\x1b[0m");
|
||||
newlog.error("... " + err);
|
||||
}
|
||||
);
|
||||
|
||||
// TODO Change this
|
||||
// hassioApiTools.getSnapshots().then(
|
||||
// () => {
|
||||
// newlog.info("Hassio API : \x1b[32mGo !\x1b[0m");
|
||||
// },
|
||||
// (err) => {
|
||||
// newlog.error("Hassio API : \x1b[31;1mFAIL !\x1b[0m");
|
||||
// newlog.error("... " + err);
|
||||
// }
|
||||
// );
|
||||
|
||||
webdav.confIsValid().then(
|
||||
() => {
|
||||
|
503
nextcloud_backup/backend/src/services/haOsService.ts
Normal file
503
nextcloud_backup/backend/src/services/haOsService.ts
Normal file
@ -0,0 +1,503 @@
|
||||
import fs from "fs";
|
||||
|
||||
import FormData from "form-data";
|
||||
import got, { OptionsOfJSONResponseBody, Response } from "got";
|
||||
import stream from "stream";
|
||||
import { promisify } from "util";
|
||||
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 { NewPartialBackupPayload } from "../types/services/ha_os_payload.js";
|
||||
import {
|
||||
AddonModel,
|
||||
BackupDetailModel,
|
||||
BackupModel,
|
||||
CoreInfoBody
|
||||
} from "../types/services/ha_os_response.js";
|
||||
import { Status } from "../types/status.js";
|
||||
|
||||
const pipeline = promisify(stream.pipeline);
|
||||
|
||||
const token = process.env.SUPERVISOR_TOKEN;
|
||||
|
||||
// Default timeout to 90min
|
||||
const create_snap_timeout = process.env.CREATE_BACKUP_TIMEOUT
|
||||
? parseInt(process.env.CREATE_BACKUP_TIMEOUT)
|
||||
: 90 * 60 * 1000;
|
||||
|
||||
function getVersion(): Promise<Response<CoreInfoBody>> {
|
||||
return got<CoreInfoBody>("http://hassio/core/info", {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
}).then(
|
||||
(result) => {
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
messageManager.error(
|
||||
"Fail to fetch Home Assistant version",
|
||||
error?.message
|
||||
);
|
||||
logger.error(`Fail to fetch Home Assistant version`);
|
||||
logger.error(error);
|
||||
return error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getAddonList(): Promise<Response<{ addons: AddonModel[] }>> {
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
return got<{ addons: AddonModel[] }>("http://hassio/addons", option).then(
|
||||
(result) => {
|
||||
result.body.addons.sort((a, b) => {
|
||||
const textA = a.name.toUpperCase();
|
||||
const textB = b.name.toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
messageManager.error("Fail to fetch addons list", error?.message);
|
||||
logger.error(`Fail to fetch addons list (${error?.message})`);
|
||||
logger.error(error);
|
||||
return error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getAddonToBackup() {
|
||||
const excluded_addon = settingsTools.getSettings().exclude_addon;
|
||||
return getAddonList().then((response) => {
|
||||
const slugs: string[] = [];
|
||||
for (const addon of response.body.addons) {
|
||||
if (!excluded_addon.includes(addon.slug)) slugs.push(addon.slug);
|
||||
}
|
||||
logger.debug("Addon to backup:");
|
||||
logger.debug(slugs);
|
||||
return slugs;
|
||||
});
|
||||
}
|
||||
|
||||
function getFolderList() {
|
||||
return [
|
||||
{
|
||||
name: "Home Assistant configuration",
|
||||
slug: "homeassistant",
|
||||
},
|
||||
{
|
||||
name: "SSL",
|
||||
slug: "ssl",
|
||||
},
|
||||
{
|
||||
name: "Share",
|
||||
slug: "share",
|
||||
},
|
||||
{
|
||||
name: "Media",
|
||||
slug: "media",
|
||||
},
|
||||
{
|
||||
name: "Local add-ons",
|
||||
slug: "addons/local",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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<Response<{ backups: BackupModel[] }>> {
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
return got<{ backups: BackupModel[] }>("http://hassio/backups", option).then(
|
||||
(result) => {
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
messageManager.error("Fail to fetch Hassio backups", error?.message);
|
||||
return error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function downloadSnapshot(id: string): Promise<string> {
|
||||
logger.info(`Downloading snapshot ${id}...`);
|
||||
if (!fs.existsSync("./temp/")) {
|
||||
fs.mkdirSync("./temp/");
|
||||
}
|
||||
const tmp_file = `./temp/${id}.tar`;
|
||||
const stream = fs.createWriteStream(tmp_file);
|
||||
const status = statusTools.getStatus();
|
||||
status.status = "download";
|
||||
status.progress = 0;
|
||||
statusTools.setStatus(status);
|
||||
const option = {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
};
|
||||
|
||||
return pipeline(
|
||||
got.stream
|
||||
.get(`http://hassio/backups/${id}/download`, option)
|
||||
.on("downloadProgress", (e) => {
|
||||
const percent = Math.round(e.percent * 100) / 100;
|
||||
if (status.progress !== percent) {
|
||||
status.progress = percent;
|
||||
statusTools.setStatus(status);
|
||||
}
|
||||
}),
|
||||
stream
|
||||
).then(
|
||||
() => {
|
||||
logger.info("Download success !");
|
||||
status.progress = 1;
|
||||
statusTools.setStatus(status);
|
||||
logger.debug(
|
||||
"Snapshot dl size : " + fs.statSync(tmp_file).size / 1024 / 1024
|
||||
);
|
||||
return tmp_file;
|
||||
},
|
||||
(reason) => {
|
||||
fs.unlinkSync(tmp_file);
|
||||
messageManager.error(
|
||||
"Fail to download Home Assistant backup",
|
||||
reason.message
|
||||
);
|
||||
return reason;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function delSnap(id: string): Promise<Response<string>> {
|
||||
const option = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
};
|
||||
return got.delete(`http://hassio/backups/${id}`, option).then(
|
||||
(result) => {
|
||||
return result;
|
||||
},
|
||||
(reason) => {
|
||||
messageManager.error(
|
||||
"Fail to delete Homme assistant backup detail.",
|
||||
reason.message
|
||||
);
|
||||
logger.error("Fail to retrive Homme assistant backup detail.");
|
||||
logger.error(reason);
|
||||
return reason;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function checkSnap(id: string): Promise<Response<BackupDetailModel>> {
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
return got<BackupDetailModel>(
|
||||
`http://hassio/backups/${id}/info`,
|
||||
option
|
||||
).then(
|
||||
(result) => {
|
||||
logger.info("Backup found !");
|
||||
logger.debug(`Backup size: ${result.body.size}`);
|
||||
return result;
|
||||
},
|
||||
(reason) => {
|
||||
messageManager.error(
|
||||
"Fail to retrive Homme assistant backup detail.",
|
||||
reason.message
|
||||
);
|
||||
logger.error("Fail to retrive Homme assistant backup detail");
|
||||
logger.error(reason);
|
||||
return reason;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createNewBackup(
|
||||
name: string,
|
||||
addonSlugs: string[],
|
||||
folders: string[]
|
||||
): Promise<Response<{ slug: string }>> {
|
||||
const status = statusTools.getStatus();
|
||||
status.status = "creating";
|
||||
status.progress = -1;
|
||||
statusTools.setStatus(status);
|
||||
logger.info("Creating new snapshot...");
|
||||
const body: NewPartialBackupPayload = {
|
||||
name: name,
|
||||
addons: addonSlugs,
|
||||
folders: folders,
|
||||
};
|
||||
|
||||
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",
|
||||
timeout: {
|
||||
response: create_snap_timeout,
|
||||
},
|
||||
json: body,
|
||||
};
|
||||
return got
|
||||
.post<{ slug: string }>(`http://hassio/backups/new/partial`, option)
|
||||
.then(
|
||||
(result) => {
|
||||
logger.info(`Snapshot created with id ${result.body.slug}`);
|
||||
return result;
|
||||
},
|
||||
(reason) => {
|
||||
messageManager.error("Fail to create new backup.", reason.message);
|
||||
logger.error("Fail to create new backup");
|
||||
logger.error(reason);
|
||||
return reason;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function clean(backups: BackupModel[]) {
|
||||
const promises = [];
|
||||
let limit = settingsTools.getSettings().auto_clean_local_keep;
|
||||
if (limit == null) {
|
||||
limit = 5;
|
||||
}
|
||||
if (backups.length < limit) {
|
||||
return;
|
||||
}
|
||||
backups.sort((a, b) => {
|
||||
return a.date < b.date ? 1 : -1;
|
||||
});
|
||||
const toDel = backups.slice(limit);
|
||||
for (const i of toDel) {
|
||||
promises.push(delSnap(i.slug));
|
||||
}
|
||||
logger.info("Local clean done.");
|
||||
return Promise.allSettled(promises).then((values) => {
|
||||
let errors = false;
|
||||
for (const val of values) {
|
||||
if (val.status == "rejected") {
|
||||
messageManager.error("Fail to delete backup", val.reason);
|
||||
logger.error("Fail to delete backup");
|
||||
logger.error(val.reason);
|
||||
errors = true;
|
||||
}
|
||||
}
|
||||
if (errors) {
|
||||
messageManager.error("Fail to clean backups in Home Assistant");
|
||||
logger.error("Fail to clean backups in Home Assistant");
|
||||
return Promise.reject();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function uploadSnapshot(path: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const status = statusTools.getStatus();
|
||||
status.progress = 0;
|
||||
statusTools.setStatus(status);
|
||||
logger.info("Uploading backup...");
|
||||
const stream = fs.createReadStream(path);
|
||||
const form = new FormData();
|
||||
form.append("file", stream);
|
||||
|
||||
const options = {
|
||||
body: form,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
};
|
||||
got.stream
|
||||
.post(`http://hassio/backups/new/upload`, options)
|
||||
.on("uploadProgress", (e) => {
|
||||
const percent = e.percent;
|
||||
if (status.progress !== percent) {
|
||||
status.progress = percent;
|
||||
statusTools.setStatus(status);
|
||||
}
|
||||
if (percent >= 1) {
|
||||
logger.info("Upload done...");
|
||||
}
|
||||
})
|
||||
.on("response", (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
logger.error(status.message);
|
||||
fs.unlinkSync(path);
|
||||
reject(status.message);
|
||||
} else {
|
||||
logger.info(`...Upload finish ! (status: ${res.statusCode})`);
|
||||
fs.unlinkSync(path);
|
||||
resolve(res);
|
||||
}
|
||||
})
|
||||
.on("error", (err) => {
|
||||
fs.unlinkSync(path);
|
||||
messageManager.error(
|
||||
"Fail to upload backup to Home Assistant",
|
||||
err?.message
|
||||
);
|
||||
logger.error("Fail to upload backup to Home Assistant");
|
||||
logger.error(err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopAddons(addonSlugs: string[]) {
|
||||
logger.info("Stopping addons...");
|
||||
const promises = [];
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
for (const addon of addonSlugs) {
|
||||
if (addon !== "") {
|
||||
logger.debug(`... Stopping addon ${addon}`);
|
||||
promises.push(got.post(`http://hassio/addons/${addon}/stop`, option));
|
||||
}
|
||||
}
|
||||
return Promise.allSettled(promises).then((values) => {
|
||||
let errors = false;
|
||||
for (const val of values) {
|
||||
if (val.status == "rejected") {
|
||||
messageManager.error("Fail to stop addon", val.reason);
|
||||
logger.error("Fail to stop addon");
|
||||
logger.error(val.reason);
|
||||
errors = true;
|
||||
}
|
||||
}
|
||||
if (errors) {
|
||||
messageManager.error("Fail to stop addon");
|
||||
logger.error("Fail to stop addon");
|
||||
return Promise.reject();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startAddons(addonSlugs: string[]) {
|
||||
logger.info("Starting addons...");
|
||||
const status = statusTools.getStatus();
|
||||
status.status = "starting";
|
||||
status.progress = -1;
|
||||
statusTools.setStatus(status);
|
||||
const promises = [];
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
for (const addon of addonSlugs) {
|
||||
if (addon !== "") {
|
||||
logger.debug(`... Starting addon ${addon}`);
|
||||
promises.push(got.post(`http://hassio/addons/${addon}/start`, option));
|
||||
}
|
||||
}
|
||||
return Promise.allSettled(promises).then((values) => {
|
||||
let errors = false;
|
||||
for (const val of values) {
|
||||
if (val.status == "rejected") {
|
||||
messageManager.error("Fail to start addon", val.reason);
|
||||
logger.error("Fail to start addon");
|
||||
logger.error(val.reason);
|
||||
errors = true;
|
||||
}
|
||||
}
|
||||
if (errors) {
|
||||
messageManager.error("Fail to start addon");
|
||||
logger.error("Fail to start addon");
|
||||
return Promise.reject();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function publish_state(state: Status) {
|
||||
// let data_error_sensor = {
|
||||
// state: state.status == "error" ? "on" : "off",
|
||||
// attributes: {
|
||||
// friendly_name: "Nexcloud Backup Error",
|
||||
// device_class: "problem",
|
||||
// error_code: state.error_code,
|
||||
// message: state.message,
|
||||
// icon: state.status == "error" ? "mdi:cloud-alert" : "mdi:cloud-check"
|
||||
// },
|
||||
// }
|
||||
// let option = {
|
||||
// headers: { "authorization": `Bearer ${token}` },
|
||||
// responseType: "json",
|
||||
// json: data_error_sensor
|
||||
// };
|
||||
// got.post(`http://hassio/core/api/states/binary_sensor.nextcloud_backup_error`, option)
|
||||
// .then((result) => {
|
||||
// logger.debug('Home assistant sensor updated (error status)');
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// logger.error(error);
|
||||
// });
|
||||
// let icon = ""
|
||||
// switch(state.status){
|
||||
// case "error":
|
||||
// icon = "mdi:cloud-alert";
|
||||
// break;
|
||||
// case "download":
|
||||
// case "download-b":
|
||||
// icon = "mdi:cloud-download";
|
||||
// break;
|
||||
// case "upload":
|
||||
// case "upload-b":
|
||||
// icon = "mdi:cloud-upload";
|
||||
// break;
|
||||
// case "idle":
|
||||
// icon = "mdi:cloud-check";
|
||||
// break;
|
||||
// default:
|
||||
// icon = "mdi:cloud-sync";
|
||||
// break;
|
||||
// }
|
||||
// let data_state_sensor = {
|
||||
// state: state.status,
|
||||
// attributes: {
|
||||
// friendly_name: "Nexcloud Backup Status",
|
||||
// error_code: state.error_code,
|
||||
// message: state.message,
|
||||
// icon: icon,
|
||||
// last_backup: state.last_backup == null || state.last_backup == "" ? "" : new Date(state.last_backup).toISOString(),
|
||||
// next_backup: state.next_backup == null || state.next_backup == "" ? "" : new Date(state.next_backup).toISOString()
|
||||
// },
|
||||
// }
|
||||
// option.json = data_state_sensor
|
||||
// got.post(`http://hassio/core/api/states/sensor.nextcloud_backup_status`, option)
|
||||
// .then((result) => {
|
||||
// logger.debug('Home assistant sensor updated (status)');
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// logger.error(error);
|
||||
// });
|
||||
}
|
||||
|
||||
export {
|
||||
getVersion,
|
||||
getAddonList,
|
||||
getFolderList,
|
||||
getBackups,
|
||||
downloadSnapshot,
|
||||
createNewBackup,
|
||||
uploadSnapshot,
|
||||
stopAddons,
|
||||
startAddons,
|
||||
clean,
|
||||
publish_state,
|
||||
};
|
@ -3,7 +3,7 @@ import * as settingsTools from "./settingsTools.js";
|
||||
import * as hassioApiTools from "./hassioApiTools.js";
|
||||
import * as statusTools from "./status.js";
|
||||
import * as pathTools from "./pathTools.js";
|
||||
import webdav from "./webdavTools.js";
|
||||
import webdav from "../services/webdavService.js";
|
||||
|
||||
import logger from "../config/winston.js";
|
||||
|
||||
|
@ -1,545 +0,0 @@
|
||||
import fs from "fs";
|
||||
|
||||
import stream from "stream";
|
||||
import { promisify } from "util";
|
||||
import got, { OptionsOfJSONResponseBody } from "got";
|
||||
import FormData from "form-data";
|
||||
import * as statusTools from "./status.js";
|
||||
import * as settingsTools from "./settingsTools.js";
|
||||
|
||||
import logger from "../config/winston.js";
|
||||
import { Status } from "../types/status.js";
|
||||
import { AddonModel, BackupDetailModel, BackupModel, CoreInfoBody } from "../types/services/ha_os_response.js";
|
||||
|
||||
const pipeline = promisify(stream.pipeline);
|
||||
|
||||
const token = process.env.SUPERVISOR_TOKEN;
|
||||
|
||||
// Default timeout to 90min
|
||||
const create_snap_timeout = process.env.CREATE_BACKUP_TIMEOUT
|
||||
? parseInt(process.env.CREATE_BACKUP_TIMEOUT)
|
||||
: 90 * 60 * 1000;
|
||||
|
||||
function getVersion() {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const status = statusTools.getStatus();
|
||||
got<CoreInfoBody>("http://hassio/core/info", {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
})
|
||||
.then((result) => {
|
||||
if (status.error_code == 1) {
|
||||
status.status = "idle";
|
||||
status.message = undefined;
|
||||
status.error_code = undefined;
|
||||
statusTools.setStatus(status);
|
||||
}
|
||||
const version = result.body.version;
|
||||
resolve(version);
|
||||
})
|
||||
.catch((error) => {
|
||||
statusTools.setError(`Fail to fetch HA Version (${error.message})`, 1);
|
||||
reject(`Fail to fetch HA Version (${error.message})`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getAddonList() {
|
||||
return new Promise<AddonModel[]>((resolve, reject) => {
|
||||
const status = statusTools.getStatus();
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
|
||||
got<{ addons: AddonModel[] }>("http://hassio/addons", option)
|
||||
.then((result) => {
|
||||
if (status.error_code === 1) {
|
||||
status.status = "idle";
|
||||
status.message = undefined;
|
||||
status.error_code = undefined;
|
||||
statusTools.setStatus(status);
|
||||
}
|
||||
const addons = result.body.addons;
|
||||
addons.sort((a, b) => {
|
||||
const textA = a.name.toUpperCase();
|
||||
const textB = b.name.toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||
});
|
||||
resolve(addons);
|
||||
})
|
||||
.catch((error) => {
|
||||
statusTools.setError(`Fail to fetch addons list (${error.message})`, 1);
|
||||
reject(`Fail to fetch addons list (${error.message})`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getAddonToBackup() {
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
const excluded_addon = settingsTools.getSettings().exclude_addon;
|
||||
getAddonList()
|
||||
.then((all_addon) => {
|
||||
const slugs = [];
|
||||
for (const addon of all_addon) {
|
||||
if (!excluded_addon.includes(addon.slug)) slugs.push(addon.slug);
|
||||
}
|
||||
logger.debug("Addon to backup:");
|
||||
logger.debug(slugs);
|
||||
resolve(slugs);
|
||||
})
|
||||
.catch(() => reject());
|
||||
});
|
||||
}
|
||||
|
||||
function getFolderList() {
|
||||
return [
|
||||
{
|
||||
name: "Home Assistant configuration",
|
||||
slug: "homeassistant",
|
||||
},
|
||||
{
|
||||
name: "SSL",
|
||||
slug: "ssl",
|
||||
},
|
||||
{
|
||||
name: "Share",
|
||||
slug: "share",
|
||||
},
|
||||
{
|
||||
name: "Media",
|
||||
slug: "media",
|
||||
},
|
||||
{
|
||||
name: "Local add-ons",
|
||||
slug: "addons/local",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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 getSnapshots() {
|
||||
return new Promise<BackupModel[]>((resolve, reject) => {
|
||||
const status = statusTools.getStatus();
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
|
||||
got<{ backups: BackupModel[] }>("http://hassio/backups", option)
|
||||
.then((result) => {
|
||||
if (status.error_code === 1) {
|
||||
status.status = "idle";
|
||||
status.message = undefined;
|
||||
status.error_code = undefined;
|
||||
statusTools.setStatus(status);
|
||||
}
|
||||
const snaps = result.body.backups;
|
||||
resolve(snaps);
|
||||
})
|
||||
.catch((error) => {
|
||||
statusTools.setError(
|
||||
`Fail to fetch Hassio backups (${error.message})`,
|
||||
1
|
||||
);
|
||||
reject(`Fail to fetch Hassio backups (${error.message})`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function downloadSnapshot(id: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.info(`Downloading snapshot ${id}...`);
|
||||
if (!fs.existsSync("./temp/")) fs.mkdirSync("./temp/");
|
||||
const tmp_file = `./temp/${id}.tar`;
|
||||
const stream = fs.createWriteStream(tmp_file);
|
||||
const status = statusTools.getStatus();
|
||||
checkSnap(id)
|
||||
.then(() => {
|
||||
status.status = "download";
|
||||
status.progress = 0;
|
||||
statusTools.setStatus(status);
|
||||
const option = {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
};
|
||||
|
||||
pipeline(
|
||||
got.stream
|
||||
.get(`http://hassio/backups/${id}/download`, option)
|
||||
.on("downloadProgress", (e) => {
|
||||
const percent = Math.round(e.percent * 100) / 100;
|
||||
if (status.progress !== percent) {
|
||||
status.progress = percent;
|
||||
statusTools.setStatus(status);
|
||||
}
|
||||
}),
|
||||
stream
|
||||
)
|
||||
.then(() => {
|
||||
logger.info("Download success !");
|
||||
status.progress = 1;
|
||||
statusTools.setStatus(status);
|
||||
logger.debug(
|
||||
"Snapshot dl size : " + fs.statSync(tmp_file).size / 1024 / 1024
|
||||
);
|
||||
resolve(undefined);
|
||||
})
|
||||
.catch((error) => {
|
||||
fs.unlinkSync(tmp_file);
|
||||
statusTools.setError(
|
||||
`Fail to download Hassio backup (${error.message})`,
|
||||
7
|
||||
);
|
||||
reject(`Fail to download Hassio backup (${error.message})`);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
statusTools.setError("Fail to download Hassio backup. Not found ?", 7);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dellSnap(id: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
checkSnap(id)
|
||||
.then(() => {
|
||||
const option = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
};
|
||||
|
||||
got
|
||||
.delete(`http://hassio/backups/${id}`, option)
|
||||
.then(() => resolve(undefined))
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
reject();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkSnap(id: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
|
||||
got<BackupDetailModel>(`http://hassio/backups/${id}/info`, option)
|
||||
.then((result) => {
|
||||
logger.debug(`Snapshot size: ${result.body.size}`);
|
||||
resolve(undefined);
|
||||
})
|
||||
.catch(() => reject());
|
||||
});
|
||||
}
|
||||
|
||||
function createNewBackup(name: string) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const status = statusTools.getStatus();
|
||||
status.status = "creating";
|
||||
status.progress = -1;
|
||||
statusTools.setStatus(status);
|
||||
logger.info("Creating new snapshot...");
|
||||
getAddonToBackup()
|
||||
.then((addons) => {
|
||||
const folders = getFolderToBackup();
|
||||
|
||||
const body: NewPartialBackupPayload = {
|
||||
name: name,
|
||||
addons: addons,
|
||||
folders: folders,
|
||||
}
|
||||
|
||||
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",
|
||||
timeout: {
|
||||
response: create_snap_timeout,
|
||||
},
|
||||
json: body
|
||||
};
|
||||
|
||||
got
|
||||
.post<{ slug: string }>(`http://hassio/backups/new/partial`, option)
|
||||
.then((result) => {
|
||||
logger.info(`Snapshot created with id ${result.body.slug}`);
|
||||
resolve(result.body.slug);
|
||||
})
|
||||
.catch((error) => {
|
||||
statusTools.setError(
|
||||
`Can't create new snapshot (${error.message})`,
|
||||
5
|
||||
);
|
||||
reject(`Can't create new snapshot (${error.message})`);
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
function clean() {
|
||||
let limit = settingsTools.getSettings().auto_clean_local_keep;
|
||||
if (limit == null) limit = 5;
|
||||
return new Promise((resolve, reject) => {
|
||||
getSnapshots()
|
||||
.then(async (snaps) => {
|
||||
if (snaps.length < limit) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
snaps.sort((a, b) => {
|
||||
return a.date < b.date ? 1 : -1;
|
||||
});
|
||||
const toDel = snaps.slice(limit);
|
||||
for (const i of toDel) {
|
||||
await dellSnap(i.slug);
|
||||
}
|
||||
logger.info("Local clean done.");
|
||||
resolve(undefined);
|
||||
})
|
||||
.catch((e) => {
|
||||
statusTools.setError(`Fail to clean backups (${e}) !`, 6);
|
||||
reject(`Fail to clean backups (${e}) !`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function uploadSnapshot(path: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const status = statusTools.getStatus();
|
||||
status.status = "upload-b";
|
||||
status.progress = 0;
|
||||
status.message = undefined;
|
||||
status.error_code = undefined;
|
||||
statusTools.setStatus(status);
|
||||
logger.info("Uploading backup...");
|
||||
const stream = fs.createReadStream(path);
|
||||
const form = new FormData();
|
||||
form.append("file", stream);
|
||||
|
||||
const options = {
|
||||
body: form,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
};
|
||||
|
||||
got.stream
|
||||
.post(`http://hassio/backups/new/upload`, options)
|
||||
.on("uploadProgress", (e) => {
|
||||
const percent = e.percent;
|
||||
if (status.progress !== percent) {
|
||||
status.progress = percent;
|
||||
statusTools.setStatus(status);
|
||||
}
|
||||
if (percent >= 1) {
|
||||
logger.info("Upload done...");
|
||||
}
|
||||
})
|
||||
.on("response", (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
status.status = "error";
|
||||
status.error_code = 4;
|
||||
status.message = `Fail to upload backup to home assistant (Status code: ${res.statusCode})!`;
|
||||
statusTools.setStatus(status);
|
||||
logger.error(status.message);
|
||||
fs.unlinkSync(path);
|
||||
reject(status.message);
|
||||
} else {
|
||||
logger.info(`...Upload finish ! (status: ${res.statusCode})`);
|
||||
status.status = "idle";
|
||||
status.progress = -1;
|
||||
status.message = undefined;
|
||||
status.error_code = undefined;
|
||||
statusTools.setStatus(status);
|
||||
fs.unlinkSync(path);
|
||||
resolve(undefined);
|
||||
}
|
||||
})
|
||||
.on("error", (err) => {
|
||||
fs.unlinkSync(path);
|
||||
statusTools.setError(
|
||||
`Fail to upload backup to home assistant (${err}) !`,
|
||||
4
|
||||
);
|
||||
reject(`Fail to upload backup to home assistant (${err}) !`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopAddons() {
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.info("Stopping addons...");
|
||||
const status = statusTools.getStatus();
|
||||
status.status = "stopping";
|
||||
status.progress = -1;
|
||||
status.message = undefined;
|
||||
status.error_code = undefined;
|
||||
statusTools.setStatus(status);
|
||||
const promises = [];
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
const addons_slug = settingsTools.getSettings().auto_stop_addon;
|
||||
for (const addon of addons_slug) {
|
||||
if (addon !== "") {
|
||||
logger.debug(`... Stopping addon ${addon}`);
|
||||
promises.push(got.post(`http://hassio/addons/${addon}/stop`, option));
|
||||
}
|
||||
}
|
||||
Promise.allSettled(promises).then((values) => {
|
||||
let error = null;
|
||||
for (const val of values) if (val.status === "rejected") error = val.reason;
|
||||
|
||||
if (error) {
|
||||
statusTools.setError(`Fail to stop addons(${error}) !`, 8);
|
||||
logger.error(status.message);
|
||||
reject(status.message);
|
||||
} else {
|
||||
logger.info("... Ok");
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startAddons() {
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.info("Starting addons...");
|
||||
const status = statusTools.getStatus();
|
||||
status.status = "starting";
|
||||
status.progress = -1;
|
||||
status.message = undefined;
|
||||
status.error_code = undefined;
|
||||
statusTools.setStatus(status);
|
||||
const promises = [];
|
||||
const option: OptionsOfJSONResponseBody = {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
responseType: "json",
|
||||
};
|
||||
const addons_slug = settingsTools.getSettings().auto_stop_addon;
|
||||
for (const addon of addons_slug) {
|
||||
if (addon !== "") {
|
||||
logger.debug(`... Starting addon ${addon}`);
|
||||
promises.push(got.post(`http://hassio/addons/${addon}/start`, option));
|
||||
}
|
||||
}
|
||||
Promise.allSettled(promises).then((values) => {
|
||||
let error = null;
|
||||
for (const val of values) if (val.status === "rejected") error = val.reason;
|
||||
|
||||
if (error) {
|
||||
statusTools.setError(`Fail to start addons (${error}) !`, 9);
|
||||
reject(status.message);
|
||||
} else {
|
||||
logger.info("... Ok");
|
||||
status.status = "idle";
|
||||
status.progress = -1;
|
||||
status.message = undefined;
|
||||
status.error_code = undefined;
|
||||
statusTools.setStatus(status);
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function publish_state(state: Status) {
|
||||
// let data_error_sensor = {
|
||||
// state: state.status == "error" ? "on" : "off",
|
||||
// attributes: {
|
||||
// friendly_name: "Nexcloud Backup Error",
|
||||
// device_class: "problem",
|
||||
// error_code: state.error_code,
|
||||
// message: state.message,
|
||||
// icon: state.status == "error" ? "mdi:cloud-alert" : "mdi:cloud-check"
|
||||
// },
|
||||
// }
|
||||
// let option = {
|
||||
// headers: { "authorization": `Bearer ${token}` },
|
||||
// responseType: "json",
|
||||
// json: data_error_sensor
|
||||
// };
|
||||
// got.post(`http://hassio/core/api/states/binary_sensor.nextcloud_backup_error`, option)
|
||||
// .then((result) => {
|
||||
// logger.debug('Home assistant sensor updated (error status)');
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// logger.error(error);
|
||||
// });
|
||||
// let icon = ""
|
||||
// switch(state.status){
|
||||
// case "error":
|
||||
// icon = "mdi:cloud-alert";
|
||||
// break;
|
||||
// case "download":
|
||||
// case "download-b":
|
||||
// icon = "mdi:cloud-download";
|
||||
// break;
|
||||
// case "upload":
|
||||
// case "upload-b":
|
||||
// icon = "mdi:cloud-upload";
|
||||
// break;
|
||||
// case "idle":
|
||||
// icon = "mdi:cloud-check";
|
||||
// break;
|
||||
// default:
|
||||
// icon = "mdi:cloud-sync";
|
||||
// break;
|
||||
// }
|
||||
// let data_state_sensor = {
|
||||
// state: state.status,
|
||||
// attributes: {
|
||||
// friendly_name: "Nexcloud Backup Status",
|
||||
// error_code: state.error_code,
|
||||
// message: state.message,
|
||||
// icon: icon,
|
||||
// last_backup: state.last_backup == null || state.last_backup == "" ? "" : new Date(state.last_backup).toISOString(),
|
||||
// next_backup: state.next_backup == null || state.next_backup == "" ? "" : new Date(state.next_backup).toISOString()
|
||||
// },
|
||||
// }
|
||||
// option.json = data_state_sensor
|
||||
// got.post(`http://hassio/core/api/states/sensor.nextcloud_backup_status`, option)
|
||||
// .then((result) => {
|
||||
// logger.debug('Home assistant sensor updated (status)');
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// logger.error(error);
|
||||
// });
|
||||
}
|
||||
|
||||
export {
|
||||
getVersion,
|
||||
getAddonList,
|
||||
getFolderList,
|
||||
getSnapshots,
|
||||
downloadSnapshot,
|
||||
createNewBackup,
|
||||
uploadSnapshot,
|
||||
stopAddons,
|
||||
startAddons,
|
||||
clean,
|
||||
publish_state,
|
||||
};
|
40
nextcloud_backup/backend/src/tools/messageManager.ts
Normal file
40
nextcloud_backup/backend/src/tools/messageManager.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { Message, MessageType } from "../types/message.js";
|
||||
|
||||
const maxMessageLength = 255;
|
||||
|
||||
class MessageManager {
|
||||
private messages: Message[] = [];
|
||||
|
||||
public addMessage(type: MessageType, message: string, detail?: string) {
|
||||
this.messages.push({
|
||||
message: message,
|
||||
type: type,
|
||||
time: DateTime.now(),
|
||||
detail: detail
|
||||
});
|
||||
if (this.messages.length > maxMessageLength) {
|
||||
this.messages.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public error(message: string, detail?: string) {
|
||||
this.addMessage(MessageType.ERROR, message, detail);
|
||||
}
|
||||
|
||||
public warn(message: string, detail?: string) {
|
||||
this.addMessage(MessageType.WARN, message, detail);
|
||||
}
|
||||
|
||||
public info(message: string, detail?: string) {
|
||||
this.addMessage(MessageType.INFO, message, detail);
|
||||
}
|
||||
|
||||
public success(message: string, detail?: string) {
|
||||
this.addMessage(MessageType.SUCCESS, message, detail);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const messageManager = new MessageManager();
|
||||
export default messageManager;
|
16
nextcloud_backup/backend/src/types/message.ts
Normal file
16
nextcloud_backup/backend/src/types/message.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export enum MessageType {
|
||||
ERROR,
|
||||
WARN,
|
||||
INFO,
|
||||
SUCCESS
|
||||
}
|
||||
|
||||
|
||||
export interface Message {
|
||||
time: DateTime;
|
||||
type: MessageType;
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
interface NewPartialBackupPayload {
|
||||
export interface NewPartialBackupPayload {
|
||||
name?: string;
|
||||
password?: string;
|
||||
homeassistant?: boolean;
|
||||
|
Loading…
Reference in New Issue
Block a user