Compare commits

..

6 Commits

Author SHA1 Message Date
renovate[bot]
fa55746e4a
⬆️ Update dependency express to v4.19.2 [SECURITY] 2024-08-14 08:32:06 +00:00
cebd6fe0a6
Merge pull request #254 from Sebclem/renovate/pnpm-9.x
⬆️ Update pnpm to v9
2024-08-14 10:31:06 +02:00
b91d68dee5
Cleanup 2024-08-14 10:20:30 +02:00
696d8e694d
Use path.join in webdav service 2024-08-14 10:17:30 +02:00
renovate[bot]
932716a7e8
⬆️ Update pnpm to v9 2024-08-13 15:16:52 +00:00
726b889b60
Update readme 2024-08-13 17:12:31 +02:00
13 changed files with 4045 additions and 6089 deletions

View File

@ -88,7 +88,7 @@ check [the contributor's page][contributors].
[aarch64-shield]: https://img.shields.io/badge/aarch64-yes-green.svg
[amd64-shield]: https://img.shields.io/badge/amd64-yes-green.svg
[armhf-shield]: https://img.shields.io/badge/armhf-yes-green.svg
[armhf-shield]: https://img.shields.io/badge/armhf-no-red.svg
[armv7-shield]: https://img.shields.io/badge/armv7-yes-green.svg
[buymeacoffee-shield]: https://www.buymeacoffee.com/assets/img/guidelines/download-assets-sm-2.svg
[buymeacoffee]: https://www.buymeacoffee.com/seb6596
@ -96,10 +96,10 @@ check [the contributor's page][contributors].
[discord-ha]: https://discord.gg/c5DvZ4e
[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg
[forum]: https://community.home-assistant.io/
[i386-shield]: https://img.shields.io/badge/i386-yes-green.svg
[i386-shield]: https://img.shields.io/badge/i386-no-red.svg
[issue]: https://github.com/Sebclem/hassio-nextcloud-backup/issues
[license-shield]: https://img.shields.io/github/license/Sebclem/hassio-nextcloud-backup.svg
[maintenance-shield]: https://img.shields.io/maintenance/yes/2022.svg
[maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg
[project-stage-shield]: https://img.shields.io/badge/project%20stage-Beta-red.svg
[reddit]: https://reddit.com/r/homeassistant
[releases-shield]: https://img.shields.io/github/release/Sebclem/hassio-nextcloud-backup.svg?include_prereleases

View File

@ -32,7 +32,7 @@
"webdav": "5.3.2",
"winston": "3.11.0"
},
"packageManager": "pnpm@8.15.3",
"packageManager": "pnpm@9.7.0",
"devDependencies": {
"@eslint/js": "^9.6.0",
"@tsconfig/recommended": "^1.0.3",

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ import { getChunkEndpoint, getEndpoint } from "./webdavConfigService.js";
import { pipeline } from "stream/promises";
import { humanFileSize } from "../tools/toolbox.js";
import type { BackupConfig } from "../types/services/backupConfig.js";
import path from "path";
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MiB Same as desktop client
const CHUNK_NUMBER_SIZE = 5; // To add landing "0"
@ -79,16 +80,16 @@ export function checkWebdavLogin(
export async function createBackupFolder(conf: WebdavConfig) {
const root_splited = conf.backupDir.split("/").splice(1);
let path = "/";
let thiPath = "/";
for (const elem of root_splited) {
if (elem != "") {
path = path + elem + "/";
thiPath = path.join(thiPath, elem);
try {
await createDirectory(path, conf);
logger.debug(`Path ${path} created.`);
await createDirectory(thiPath, conf);
logger.debug(`Path ${thiPath} created.`);
} catch (error) {
if (error instanceof HTTPError && error.response.statusCode == 405)
logger.debug(`Path ${path} already exist.`);
logger.debug(`Path ${thiPath} already exist.`);
else {
messageManager.error("Fail to create webdav root folder");
logger.error("Fail to create webdav root folder");
@ -104,11 +105,11 @@ export async function createBackupFolder(conf: WebdavConfig) {
}
for (const elem of [pathTools.auto, pathTools.manual]) {
try {
await createDirectory(conf.backupDir + elem, conf);
logger.debug(`Path ${conf.backupDir + elem} created.`);
await createDirectory(path.join(conf.backupDir, elem), conf);
logger.debug(`Path ${path.join(conf.backupDir, elem)} created.`);
} catch (error) {
if (error instanceof HTTPError && error.response.statusCode == 405) {
logger.debug(`Path ${conf.backupDir + elem} already exist.`);
logger.debug(`Path ${path.join(conf.backupDir, elem)} already exist.`);
} else {
messageManager.error("Fail to create webdav root folder");
logger.error("Fail to create webdav root folder");
@ -127,9 +128,9 @@ export async function createBackupFolder(conf: WebdavConfig) {
statusTools.setStatus(status);
}
function createDirectory(path: string, config: WebdavConfig) {
function createDirectory(pathToCreate: string, config: WebdavConfig) {
const endpoint = getEndpoint(config);
return got(config.url + endpoint + path, {
return got(path.join(config.url, endpoint, pathToCreate), {
method: "MKCOL" as Method,
headers: {
authorization:
@ -149,7 +150,7 @@ export function getBackups(
return Promise.reject(new Error("Not logged in"));
}
const endpoint = getEndpoint(config);
return got(config.url + endpoint + config.backupDir + folder, {
return got(path.join(config.url, endpoint, config.backupDir, folder), {
method: "PROPFIND" as Method,
headers: {
authorization:
@ -201,11 +202,11 @@ function extractBackupInfo(backups: WebdavBackup[], template: string) {
return backups;
}
export function deleteBackup(path: string, config: WebdavConfig) {
logger.debug(`Deleting Cloud backup ${path}`);
export function deleteBackup(pathToDelete: string, config: WebdavConfig) {
logger.debug(`Deleting Cloud backup ${pathToDelete}`);
const endpoint = getEndpoint(config);
return got
.delete(config.url + endpoint + path, {
.delete(path.join(config.url, endpoint, pathToDelete), {
headers: {
authorization:
"Basic " +
@ -279,7 +280,7 @@ export function webdavUploadFile(
},
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
};
const url = config.url + getEndpoint(config) + webdavPath;
const url = path.join(config.url, getEndpoint(config), webdavPath);
logger.debug(`...URI: ${encodeURI(url)}`);
logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`);
@ -343,8 +344,12 @@ export async function chunkedUpload(
const fileSize = fs.statSync(localPath).size;
const chunkEndpoint = getChunkEndpoint(config);
const chunkedUrl = config.url + chunkEndpoint + uuid;
const finalDestination = config.url + getEndpoint(config) + webdavPath;
const chunkedUrl = path.join(config.url, chunkEndpoint, uuid);
const finalDestination = path.join(
config.url,
getEndpoint(config),
webdavPath
);
const status = statusTools.getStatus();
status.status = States.BKUP_UPLOAD_CLOUD;
status.progress = -1;
@ -388,7 +393,7 @@ export async function chunkedUpload(
try {
const chunckNumber = i.toString().padStart(CHUNK_NUMBER_SIZE, "0");
await uploadChunk(
chunkedUrl + `/${chunckNumber}`,
path.join(chunkedUrl, chunckNumber),
finalDestination,
chunk,
current_size,
@ -527,7 +532,7 @@ function assembleChunkedUpload(
totalLength: number,
config: WebdavConfig
) {
const chunckFile = `${url}/.file`;
const chunckFile = path.join(url, ".file");
logger.info(`Assemble chuncked upload.`);
logger.debug(`...URI: ${encodeURI(chunckFile)}`);
logger.debug(`...Final destination: ${encodeURI(finalDestination)}`);
@ -563,7 +568,7 @@ export function downloadFile(
},
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
};
const url = config.url + getEndpoint(config) + webdavPath;
const url = path.join(config.url, getEndpoint(config), webdavPath);
logger.debug(`...URI: ${encodeURI(url)}`);
logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`);
const status = statusTools.getStatus();

View File

@ -48,5 +48,5 @@
"vue-router": "^4.2.0",
"vue-tsc": "^1.8.0"
},
"packageManager": "pnpm@8.15.3"
"packageManager": "pnpm@9.7.0"
}

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +0,0 @@
{
"name": "nexcloud-backup",
"version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
"start": "node ./bin/www.js "
},
"dependencies": {
"@fortawesome/fontawesome-free": "6.1.2",
"app-root-path": "3.0.0",
"bootstrap": "5.1.3",
"cookie-parser": "1.4.6",
"cron": "2.1.0",
"debug": "4.3.4",
"ejs": "3.1.8",
"express": "4.19.2",
"form-data": "4.0.0",
"got": "12.3.0",
"http-errors": "2.0.0",
"jquery": "3.6.0",
"luxon": "3.0.1",
"morgan": "1.10.0",
"webdav": "4.10.0",
"winston": "3.8.1"
},
"packageManager": "yarn@3.2.2"
}

View File

@ -1,229 +0,0 @@
import express from 'express';
import * as statusTools from "../tools/status.js"
import webdav from "../tools/webdavTools.js"
import * as settingsTools from "../tools/settingsTools.js"
import * as pathTools from "../tools/pathTools.js"
import * as hassioApiTools from "../tools/hassioApiTools.js"
import { humanFileSize } from "../tools/toolbox.js";
import cronTools from "../tools/cronTools.js"
import logger from "../config/winston.js"
import {DateTime} from "luxon";
var router = express.Router();
router.get("/status", (req, res, next) => {
cronTools.updateNextDate();
let status = statusTools.getStatus();
res.json(status);
});
router.get("/formated-local-snap", function (req, res, next) {
hassioApiTools.getSnapshots()
.then((snaps) => {
snaps.sort((a, b) => {
return Date.parse(b.date) - Date.parse(a.date);
});
res.render("localSnaps", { snaps: snaps, DateTime: DateTime });
})
.catch((err) => {
logger.error(err);
res.status(500);
res.send("");
}
);
});
router.get("/formated-backup-manual", function (req, res, next) {
if (webdav.getConf() == null) {
res.send("");
return;
}
webdav
.getFolderContent(webdav.getConf().back_dir + pathTools.manual)
.then((contents) => {
contents.sort((a, b) => {
return Date.parse(b.lastmod) - Date.parse(a.lastmod)
});
//TODO Remove this when bug is fixed, etag contain '"' at start and end ?
for (let backup of contents) {
backup.etag = backup.etag.replace(/"/g, '');
}
res.render("backupSnaps", { backups: contents, DateTime: DateTime, humanFileSize: humanFileSize });
})
.catch((err) => {
res.status(500)
res.send(err);
});
});
router.get("/formated-backup-auto", function (req, res, next) {
if (webdav.getConf() == null) {
res.send("");
return;
}
let url = webdav.getConf().back_dir + pathTools.auto;
webdav
.getFolderContent(url)
.then((contents) => {
contents.sort((a, b) => {
return Date.parse(b.lastmod) - Date.parse(a.lastmod)
});
//TODO Remove this when bug is fixed, etag contain '"' at start and end ?
for (let backup of contents) {
backup.etag = backup.etag.replace(/"/g, '');
}
res.render("backupSnaps", { backups: contents, DateTime: DateTime, humanFileSize: humanFileSize });
})
.catch((err) => {
res.status(500)
res.send(err);
});
});
router.post("/nextcloud-settings", function (req, res, next) {
let settings = req.body;
if (settings.ssl != null && settings.host != null && settings.host !== "" && settings.username != null && settings.password != null) {
webdav.setConf(settings);
webdav
.confIsValid()
.then(() => {
res.status(201);
res.send();
})
.catch((err) => {
res.status(406);
res.json({ message: err });
});
} else {
res.status(400);
res.send();
}
});
router.get("/nextcloud-settings", function (req, res, next) {
let conf = webdav.getConf();
if (conf == null) {
res.status(404);
res.send();
} else {
res.json(conf);
}
});
router.post("/manual-backup", function (req, res, next) {
let id = req.query.id;
let name = req.query.name;
let status = statusTools.getStatus();
if (status.status === "creating" && status.status === "upload" && status.status === "download") {
res.status(503);
res.send();
return;
}
hassioApiTools
.downloadSnapshot(id)
.then(() => {
webdav.uploadFile(id, webdav.getConf().back_dir + pathTools.manual + name + ".tar").then(()=>{
res.status(201);
res.send();
}).catch(()=>{
res.status(500);
res.send();
}
);
})
.catch(() => {
res.status(500);
res.send();
});
});
router.post("/new-backup", function (req, res, next) {
let status = statusTools.getStatus();
if (status.status === "creating" || status.status === "upload" || status.status === "download" || status.status === "stopping" || status.status === "starting") {
res.status(503);
res.send();
return;
}
hassioApiTools.stopAddons()
.then(() => {
hassioApiTools.getVersion()
.then((version) => {
let name = settingsTools.getFormatedName(true, version);
hassioApiTools.createNewBackup(name)
.then((id) => {
hassioApiTools
.downloadSnapshot(id)
.then(() => {
webdav.uploadFile(id, webdav.getConf().back_dir + pathTools.manual + name + ".tar")
.then(() => {
hassioApiTools.startAddons().catch(() => {
})
}).catch(()=>{});
}).catch(()=>{});
}).catch(()=>{});
}).catch(()=>{});
})
.catch(() => {
hassioApiTools.startAddons().catch(() => {
});
});
res.status(201);
res.send();
});
router.get("/backup-settings", function (req, res, next) {
hassioApiTools.getAddonList().then((addonList) => {
let data = {};
data['folders'] = hassioApiTools.getFolderList();
data['addonList'] = addonList;
data['settings'] = settingsTools.getSettings();
res.send(data);
})
});
router.post("/backup-settings", function (req, res, next) {
let [result, message] = settingsTools.check(req.body)
if (result) {
settingsTools.setSettings(req.body);
cronTools.init();
res.send();
} else {
res.status(400);
res.send(message);
}
});
router.post("/clean-now", function (req, res, next) {
webdav
.clean()
.then(() => {
hassioApiTools.clean().catch();
})
.catch(() => {
hassioApiTools.clean().catch();
});
res.status(201);
res.send();
});
router.post("/restore", function (req, res, next) {
if (req.body["path"] != null) {
webdav.downloadFile(req.body["path"]).then((path) => {
hassioApiTools.uploadSnapshot(path).catch();
});
res.status(200);
res.send();
} else {
res.status(400);
res.send();
}
});
export default router;

View File

@ -1,536 +0,0 @@
import fs from "fs"
import stream from "stream"
import { promisify } from "util";
import got from "got";
import FormData from "form-data";
import * as statusTools from "../tools/status.js"
import * as settingsTools from "../tools/settingsTools.js"
import logger from "../config/winston.js"
import {DateTime} from "luxon";
const pipeline = promisify(stream.pipeline);
const token = process.env.SUPERVISOR_TOKEN;
// Default timeout to 90min
const create_snap_timeout = parseInt(process.env.CREATE_BACKUP_TIMEOUT) || (90 * 60 * 1000);
function getVersion() {
return new Promise((resolve, reject) => {
let status = statusTools.getStatus();
let option = {
headers: { "authorization": `Bearer ${token}` },
responseType: "json",
};
got("http://hassio/core/info", option)
.then((result) => {
if (status.error_code === 1) {
status.status = "idle";
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
}
let version = result.body.data.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((resolve, reject) => {
let status = statusTools.getStatus();
let option = {
headers: { "authorization": `Bearer ${token}` },
responseType: "json",
};
got("http://hassio/addons", option)
.then((result) => {
if (status.error_code === 1) {
status.status = "idle";
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
}
let addons = result.body.data.addons;
addons.sort((a, b) => {
let textA = a.name.toUpperCase();
let 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((resolve, reject) => {
let excluded_addon = settingsTools.getSettings().exclude_addon;
getAddonList()
.then((all_addon) => {
let slugs = [];
for (let 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() {
let excluded_folder = settingsTools.getSettings().exclude_folder;
let all_folder = getFolderList()
let slugs = [];
for (let 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((resolve, reject) => {
let status = statusTools.getStatus();
let option = {
headers: { "authorization": `Bearer ${token}` },
responseType: "json",
};
got("http://hassio/backups", option)
.then((result) => {
if (status.error_code === 1) {
status.status = "idle";
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
}
let snaps = result.body.data.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) {
return new Promise((resolve, reject) => {
logger.info(`Downloading snapshot ${id}...`);
if (!fs.existsSync("./temp/")) fs.mkdirSync("./temp/");
let tmp_file = `./temp/${id}.tar`;
let stream = fs.createWriteStream(tmp_file);
let status = statusTools.getStatus();
checkSnap(id)
.then(() => {
status.status = "download";
status.progress = 0;
statusTools.setStatus(status);
let option = {
headers: { "Authorization": `Bearer ${token}` },
};
pipeline(
got.stream.get(`http://hassio/backups/${id}/download`, option)
.on("downloadProgress", (e) => {
let 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();
})
.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) {
return new Promise((resolve, reject) => {
checkSnap(id)
.then(() => {
let option = {
headers: { "authorization": `Bearer ${token}` },
responseType: "json",
};
got.delete(`http://hassio/backups/${id}`, option)
.then(() => resolve())
.catch((e) => {
logger.error(e)
reject();
});
})
.catch(() => {
reject();
});
});
}
function checkSnap(id) {
return new Promise((resolve, reject) => {
let option = {
headers: { "authorization": `Bearer ${token}` },
responseType: "json",
};
got(`http://hassio/backups/${id}/info`, option)
.then((result) => {
logger.debug(`Snapshot size: ${result.body.data.size}`);
resolve();
})
.catch(() => reject());
});
}
function createNewBackup(name) {
return new Promise((resolve, reject) => {
let status = statusTools.getStatus();
status.status = "creating";
status.progress = -1;
statusTools.setStatus(status);
logger.info("Creating new snapshot...");
getAddonToBackup().then((addons) => {
let folders = getFolderToBackup();
let option = {
headers: { "authorization": `Bearer ${token}` },
responseType: "json",
timeout: {
response: create_snap_timeout
},
json: {
name: name,
addons: addons,
folders: folders
},
};
let password_protected = settingsTools.getSettings().password_protected;
logger.debug(`Is password protected ? ${password_protected}`)
if ( password_protected === "true") {
option.json.password = settingsTools.getSettings().password_protect_value
}
got.post(`http://hassio/backups/new/partial`, option)
.then((result) => {
logger.info(`Snapshot created with id ${result.body.data.slug}`);
resolve(result.body.data.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();
return;
}
snaps.sort((a, b) => {
return Date.parse(b.date) - Date.parse(a.date);
});
let toDel = snaps.slice(limit);
for (let i of toDel) {
await dellSnap(i.slug);
}
logger.info("Local clean done.");
resolve();
})
.catch((e) => {
statusTools.setError(`Fail to clean backups (${e}) !`, 6);
reject(`Fail to clean backups (${e}) !`);
});
});
}
function uploadSnapshot(path) {
return new Promise((resolve, reject) => {
let status = statusTools.getStatus();
status.status = "upload-b";
status.progress = 0;
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
logger.info("Uploading backup...");
let stream = fs.createReadStream(path);
let form = new FormData();
form.append("file", stream);
let options = {
body: form,
headers: { "authorization": `Bearer ${token}` },
};
got.stream
.post(`http://hassio/backups/new/upload`, options)
.on("uploadProgress", (e) => {
let 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 = null;
status.error_code = null;
statusTools.setStatus(status);
fs.unlinkSync(path);
resolve();
}
})
.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...');
let status = statusTools.getStatus();
status.status = "stopping";
status.progress = -1;
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
let promises = [];
let option = {
headers: { "authorization": `Bearer ${token}` },
responseType: "json",
};
let addons_slug = settingsTools.getSettings().auto_stop_addon
for (let 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 (let 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();
}
});
}));
}
function startAddons() {
return new Promise(((resolve, reject) => {
logger.info('Starting addons...');
let status = statusTools.getStatus();
status.status = "starting";
status.progress = -1;
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
let promises = [];
let option = {
headers: { "authorization": `Bearer ${token}` },
responseType: "json",
};
let addons_slug = settingsTools.getSettings().auto_stop_addon
for (let 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 (let 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 = null;
status.error_code = null;
statusTools.setStatus(status);
resolve();
}
});
}));
}
function publish_state(state) {
// 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
}

View File

@ -1,427 +0,0 @@
import { createClient } from "webdav";
import fs from "fs"
import https from "https"
import path from "path";
import got from "got";
import stream from "stream";
import { promisify } from "util";
import * as statusTools from "../tools/status.js"
import * as settingsTools from "../tools/settingsTools.js"
import * as pathTools from "../tools/pathTools.js"
import * as hassioApiTools from "../tools/hassioApiTools.js"
import logger from "../config/winston.js"
import {DateTime} from "luxon";
const endpoint = "/remote.php/webdav";
const configPath = "/data/webdav_conf.json";
const pipeline = promisify(stream.pipeline);
class WebdavTools {
constructor() {
this.host = null;
this.client = null;
this.baseUrl = null;
this.username = null;
this.password = null;
}
init(ssl, host, username, password, accept_selfsigned_cert) {
return new Promise((resolve, reject) => {
this.host = host;
let status = statusTools.getStatus();
logger.info("Initializing and checking webdav client...");
this.baseUrl = (ssl === "true" ? "https" : "http") + "://" + host + endpoint;
this.username = username;
this.password = password;
let agent_option = ssl === "true" ? { rejectUnauthorized: accept_selfsigned_cert === "false" } : {};
try {
this.client = createClient(this.baseUrl, {
username: username,
password: password,
httpsAgent: new https.Agent(agent_option)
});
this.client
.getDirectoryContents("/")
.then(() => {
if (status.error_code === 3) {
status.status = "idle";
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
}
logger.debug("Nextcloud connection: \x1b[32mSuccess !\x1b[0m");
this.initFolder().then(() => {
resolve();
});
})
.catch((error) => {
this.__cant_connect_status(error);
this.client = null;
reject("Can't connect to Nextcloud (" + error + ") !");
});
} catch (err) {
this.__cant_connect_status(err);
this.client = null;
reject("Can't connect to Nextcloud (" + err + ") !");
}
});
}
__cant_connect_status(err){
statusTools.setError(`Can't connect to Nextcloud (${err})`, 3);
}
async __createRoot() {
let root_splited = this.getConf().back_dir.split("/").splice(1);
let path = "/";
for (let elem of root_splited) {
if (elem !== "") {
path = path + elem + "/";
try {
await this.client.createDirectory(path);
logger.debug(`Path ${path} created.`);
} catch (error) {
if (error.status === 405) logger.debug(`Path ${path} already exist.`);
else logger.error(error);
}
}
}
}
initFolder() {
return new Promise((resolve) => {
this.__createRoot().catch((err) => {
logger.error(err);
}).then(() => {
this.client.createDirectory(this.getConf().back_dir + pathTools.auto)
.catch(() => {
})
.then(() => {
this.client
.createDirectory(this.getConf().back_dir + pathTools.manual)
.catch(() => {
})
.then(() => {
resolve();
});
});
});
});
}
/**
* Check if theh webdav config is valid, if yes, start init of webdav client
*/
confIsValid() {
return new Promise((resolve, reject) => {
let status = statusTools.getStatus();
let conf = this.getConf();
if (conf !== null) {
if (conf.ssl !== null && conf.host !== null && conf.username !== null && conf.password !== null) {
if (status.error_code === 2) {
status.status = "idle";
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
}
// Check if self_signed option exist
if (conf.self_signed == null || conf.self_signed === "") {
conf.self_signed = "false";
this.setConf(conf);
}
this.init(conf.ssl, conf.host, conf.username, conf.password, conf.self_signed)
.then(() => {
resolve();
})
.catch((err) => {
reject(err);
});
} else {
status.status = "error";
status.error_code = 2;
status.message = "Nextcloud config invalid !";
statusTools.setStatus(status);
logger.error(status.message);
reject("Nextcloud config invalid !");
}
if (conf.back_dir == null || conf.back_dir === "") {
logger.info("Backup dir is null, initializing it.");
conf.back_dir = pathTools.default_root;
this.setConf(conf);
} else {
if (!conf.back_dir.startsWith("/")) {
logger.warn("Backup dir not starting with '/', fixing this...");
conf.back_dir = `/${conf.back_dir}`;
this.setConf(conf);
}
if (!conf.back_dir.endsWith("/")) {
logger.warn("Backup dir not ending with '/', fixing this...");
conf.back_dir = `${conf.back_dir}/`;
this.setConf(conf);
}
}
} else {
status.status = "error";
status.error_code = 2;
status.message = "Nextcloud config not found !";
statusTools.setStatus(status);
logger.error(status.message);
reject("Nextcloud config not found !");
}
});
}
getConf() {
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath).toString());
} else
return null;
}
setConf(conf) {
fs.writeFileSync(configPath, JSON.stringify(conf));
}
uploadFile(id, path) {
return new Promise((resolve, reject) => {
if (this.client == null) {
this.confIsValid()
.then(() => {
this._startUpload(id, path)
.then(()=> resolve())
.catch((err) => reject(err));
})
.catch((err) => {
reject(err);
});
} else
this._startUpload(id, path)
.then(()=> resolve())
.catch((err) => reject(err));
});
}
_startUpload(id, path) {
return new Promise((resolve, reject) => {
let status = statusTools.getStatus();
status.status = "upload";
status.progress = 0;
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
logger.info("Uploading snap...");
let tmpFile = `./temp/${id}.tar`;
let stats = fs.statSync(tmpFile);
let stream = fs.createReadStream(tmpFile);
let conf = this.getConf();
let options = {
body: stream,
// username: this.username,
// password: encodeURIComponent(this.password),
headers: {
'authorization': 'Basic ' + Buffer.from(this.username + ':' + this.password).toString('base64'),
'content-length': String(stats.size)
}
};
if (conf.ssl === "true") {
options["https"] = { rejectUnauthorized: conf.self_signed === "false" };
}
logger.debug(`...URI: ${encodeURI(this.baseUrl.replace(this.host, 'host.hiden') + path)}`);
if (conf.ssl === "true")
logger.debug(`...rejectUnauthorized: ${options["https"]["rejectUnauthorized"]}`);
got.stream
.put(encodeURI(this.baseUrl + path), options)
.on("uploadProgress", (e) => {
let 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 !== 201 && res.statusCode !== 204) {
status.status = "error";
status.error_code = 4;
status.message = `Fail to upload snapshot to nextcloud (Status code: ${res.statusCode})!`;
statusTools.setStatus(status);
logger.error(status.message);
fs.unlinkSync(tmpFile);
reject(status.message);
} else {
logger.info(`...Upload finish ! (status: ${res.statusCode})`);
status.status = "idle";
status.progress = -1;
status.message = null;
status.error_code = null;
status.last_backup = DateTime.now().toFormat("dd MMM yyyy, HH:mm")
statusTools.setStatus(status);
cleanTempFolder();
let autoCleanCloud = settingsTools.getSettings().auto_clean_backup;
if (autoCleanCloud != null && autoCleanCloud === "true") {
this.clean().catch();
}
let autoCleanlocal = settingsTools.getSettings().auto_clean_local;
if (autoCleanlocal != null && autoCleanlocal === "true") {
hassioApiTools.clean().catch();
}
resolve();
}
})
.on("error", (err) => {
fs.unlinkSync(tmpFile);
status.status = "error";
status.error_code = 4;
status.message = `Fail to upload snapshot to nextcloud (${err}) !`;
statusTools.setStatus(status);
logger.error(status.message);
logger.error(err.stack);
reject(status.message);
});
});
}
downloadFile(path) {
return new Promise((resolve, reject) => {
if (this.client == null) {
this.confIsValid()
.then(() => {
this._startDownload(path)
.then((path) => resolve(path))
.catch(() => reject());
})
.catch((err) => {
reject(err);
});
} else
this._startDownload(path)
.then((path) => resolve(path))
.catch(() => reject());
});
}
_startDownload(path) {
return new Promise((resolve, reject) => {
let status = statusTools.getStatus();
status.status = "download-b";
status.progress = 0;
status.message = null;
status.error_code = null;
statusTools.setStatus(status);
logger.info("Downloading backup...");
if (!fs.existsSync("./temp/"))
fs.mkdirSync("./temp/");
let tmpFile = `./temp/restore_${DateTime.now().toFormat("MMM-dd-yyyy_HH_mm")}.tar`;
let stream = fs.createWriteStream(tmpFile);
let conf = this.getConf();
let options = {
headers: {
'authorization': 'Basic ' + Buffer.from(this.username + ':' + this.password).toString('base64')
}
};
if (conf.ssl === "true") {
options["https"] = { rejectUnauthorized: conf.self_signed === "false" };
}
logger.debug(`...URI: ${encodeURI(this.baseUrl.replace(this.host, 'host.hiden') + path)}`);
if (conf.ssl === "true")
logger.debug(`...rejectUnauthorized: ${options["https"]["rejectUnauthorized"]}`);
pipeline(
got.stream.get(encodeURI(this.baseUrl + path), options)
.on("downloadProgress", (e) => {
let percent = Math.round(e.percent * 100) / 100;
if (status.progress !== percent) {
status.progress = percent;
statusTools.setStatus(status);
}
}),
stream
).then((res) => {
logger.info("Download success !");
status.progress = 1;
statusTools.setStatus(status);
logger.debug("Backup dl size : " + fs.statSync(tmpFile).size / 1024 / 1024);
resolve(tmpFile);
}).catch((err) => {
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
status.status = "error";
status.message = "Fail to download Hassio snapshot (" + err.message + ")";
status.error_code = 7;
statusTools.setStatus(status);
logger.error(status.message);
logger.error(err.stack);
reject(err.message);
});
});
}
getFolderContent(path) {
return new Promise((resolve, reject) => {
if (this.client == null) {
reject();
return;
}
this.client
.getDirectoryContents(path)
.then((contents) => resolve(contents))
.catch((error) => reject(error));
});
}
clean() {
let limit = settingsTools.getSettings().auto_clean_backup_keep;
if (limit == null) limit = 5;
return new Promise((resolve, reject) => {
this.getFolderContent(this.getConf().back_dir + pathTools.auto)
.then(async (contents) => {
if (contents.length < limit) {
resolve();
return;
}
contents.sort((a, b) => {
return Date.parse(b.lastmod) - Date.parse(a.lastmod)
});
let toDel = contents.slice(limit);
for (let i in toDel) {
await this.client.deleteFile(toDel[i].filename);
}
logger.info("Cloud clean done.");
resolve();
})
.catch((error) => {
let status = statusTools.getStatus();
status.status = "error";
status.error_code = 6;
status.message = "Fail to clean Nexcloud (" + error + ") !";
statusTools.setStatus(status);
logger.error(status.message);
reject(status.message);
});
});
}
}
function cleanTempFolder() {
fs.readdir("./temp/", (err, files) => {
if (err)
throw err;
for (const file of files) {
fs.unlink(path.join("./temp/", file), (err) => {
if (err)
throw err;
});
}
});
}
const INSTANCE = new WebdavTools();
export default INSTANCE;

View File

@ -1,73 +0,0 @@
<div id="modal-settings-nextcloud" class="modal fade">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="exampleModalLabel">Nextcloud Settings</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row d-none">
<div class="col-12 col-md-10 offset-md-1 text-center alert alert-danger" role="alert" id="nextcloud_settings_message">
</div>
</div>
<div class="row">
<div class="col-sm-12 col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" id="ssl" type="checkbox">
<label class="form-check-label" for="ssl">SSL</label>
</div>
</div>
<div class="col-sm-12 col-md-8 invisible">
<div class="form-check form-switch">
<input class="form-check-input" id="self_signed" type="checkbox">
<label class="form-check-label" for="ssl">Accept Self-signed certificate</label>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<label for="hostname" class="form-label">Hostname</label>
<input id="hostname" type="text" class="form-control" aria-describedby="hostname-help">
<div id="hostname-help" class="form-text">example.com:8080</span>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label for="username" class="form-label">Username</label>
<input id="username" type="text" class="form-control">
</div>
<div class="col-12 col-md-6">
<label for="password" class="form-label">Password</label>
<input id="password" type="password" class="form-control" aria-describedby="password-help">
<div id="password-help" class="form-text">
!!! Use App Password !!! See
<a target="_blank"
href="https://github.com/Sebclem/hassio-nextcloud-backup#nextcloud-config">
doc
</a>
for more information.
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<label for="back-dir" class="form-label">Backup Directory</label>
<input id="back-dir" type="text" class="form-control" aria-describedby="dir-help" placeholder="/Hassio Backup/">
<div id="dir-help" class="form-text">Default: /Hassio Backup/</div>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button data-bs-dismiss="modal" class="btn btn-danger"><b>Cancel</b></button>
<button class="btn btn-success" id="save-nextcloud-settings"><b>Save</b></button>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "hassio-nextcloud-backup",
"packageManager": "pnpm@7.13.4",
"packageManager": "pnpm@9.7.0",
"devDependencies": {
"auto-changelog": "2.4.0",
"release-it": "15.2.0"