mirror of
https://github.com/Sebclem/hassio-nextcloud-backup.git
synced 2024-11-13 21:12:59 +01:00
Compare commits
1 Commits
fa55746e4a
...
cb3380dd1d
Author | SHA1 | Date | |
---|---|---|---|
|
cb3380dd1d |
@ -88,7 +88,7 @@ check [the contributor's page][contributors].
|
|||||||
|
|
||||||
[aarch64-shield]: https://img.shields.io/badge/aarch64-yes-green.svg
|
[aarch64-shield]: https://img.shields.io/badge/aarch64-yes-green.svg
|
||||||
[amd64-shield]: https://img.shields.io/badge/amd64-yes-green.svg
|
[amd64-shield]: https://img.shields.io/badge/amd64-yes-green.svg
|
||||||
[armhf-shield]: https://img.shields.io/badge/armhf-no-red.svg
|
[armhf-shield]: https://img.shields.io/badge/armhf-yes-green.svg
|
||||||
[armv7-shield]: https://img.shields.io/badge/armv7-yes-green.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-shield]: https://www.buymeacoffee.com/assets/img/guidelines/download-assets-sm-2.svg
|
||||||
[buymeacoffee]: https://www.buymeacoffee.com/seb6596
|
[buymeacoffee]: https://www.buymeacoffee.com/seb6596
|
||||||
@ -96,10 +96,10 @@ check [the contributor's page][contributors].
|
|||||||
[discord-ha]: https://discord.gg/c5DvZ4e
|
[discord-ha]: https://discord.gg/c5DvZ4e
|
||||||
[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg
|
[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg
|
||||||
[forum]: https://community.home-assistant.io/
|
[forum]: https://community.home-assistant.io/
|
||||||
[i386-shield]: https://img.shields.io/badge/i386-no-red.svg
|
[i386-shield]: https://img.shields.io/badge/i386-yes-green.svg
|
||||||
[issue]: https://github.com/Sebclem/hassio-nextcloud-backup/issues
|
[issue]: https://github.com/Sebclem/hassio-nextcloud-backup/issues
|
||||||
[license-shield]: https://img.shields.io/github/license/Sebclem/hassio-nextcloud-backup.svg
|
[license-shield]: https://img.shields.io/github/license/Sebclem/hassio-nextcloud-backup.svg
|
||||||
[maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg
|
[maintenance-shield]: https://img.shields.io/maintenance/yes/2022.svg
|
||||||
[project-stage-shield]: https://img.shields.io/badge/project%20stage-Beta-red.svg
|
[project-stage-shield]: https://img.shields.io/badge/project%20stage-Beta-red.svg
|
||||||
[reddit]: https://reddit.com/r/homeassistant
|
[reddit]: https://reddit.com/r/homeassistant
|
||||||
[releases-shield]: https://img.shields.io/github/release/Sebclem/hassio-nextcloud-backup.svg?include_prereleases
|
[releases-shield]: https://img.shields.io/github/release/Sebclem/hassio-nextcloud-backup.svg?include_prereleases
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
"webdav": "5.3.2",
|
"webdav": "5.3.2",
|
||||||
"winston": "3.11.0"
|
"winston": "3.11.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.7.0",
|
"packageManager": "pnpm@8.15.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.6.0",
|
"@eslint/js": "^9.6.0",
|
||||||
"@tsconfig/recommended": "^1.0.3",
|
"@tsconfig/recommended": "^1.0.3",
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,6 @@ import { getChunkEndpoint, getEndpoint } from "./webdavConfigService.js";
|
|||||||
import { pipeline } from "stream/promises";
|
import { pipeline } from "stream/promises";
|
||||||
import { humanFileSize } from "../tools/toolbox.js";
|
import { humanFileSize } from "../tools/toolbox.js";
|
||||||
import type { BackupConfig } from "../types/services/backupConfig.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_SIZE = 5 * 1024 * 1024; // 5MiB Same as desktop client
|
||||||
const CHUNK_NUMBER_SIZE = 5; // To add landing "0"
|
const CHUNK_NUMBER_SIZE = 5; // To add landing "0"
|
||||||
@ -80,16 +79,16 @@ export function checkWebdavLogin(
|
|||||||
|
|
||||||
export async function createBackupFolder(conf: WebdavConfig) {
|
export async function createBackupFolder(conf: WebdavConfig) {
|
||||||
const root_splited = conf.backupDir.split("/").splice(1);
|
const root_splited = conf.backupDir.split("/").splice(1);
|
||||||
let thiPath = "/";
|
let path = "/";
|
||||||
for (const elem of root_splited) {
|
for (const elem of root_splited) {
|
||||||
if (elem != "") {
|
if (elem != "") {
|
||||||
thiPath = path.join(thiPath, elem);
|
path = path + elem + "/";
|
||||||
try {
|
try {
|
||||||
await createDirectory(thiPath, conf);
|
await createDirectory(path, conf);
|
||||||
logger.debug(`Path ${thiPath} created.`);
|
logger.debug(`Path ${path} created.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HTTPError && error.response.statusCode == 405)
|
if (error instanceof HTTPError && error.response.statusCode == 405)
|
||||||
logger.debug(`Path ${thiPath} already exist.`);
|
logger.debug(`Path ${path} already exist.`);
|
||||||
else {
|
else {
|
||||||
messageManager.error("Fail to create webdav root folder");
|
messageManager.error("Fail to create webdav root folder");
|
||||||
logger.error("Fail to create webdav root folder");
|
logger.error("Fail to create webdav root folder");
|
||||||
@ -105,11 +104,11 @@ export async function createBackupFolder(conf: WebdavConfig) {
|
|||||||
}
|
}
|
||||||
for (const elem of [pathTools.auto, pathTools.manual]) {
|
for (const elem of [pathTools.auto, pathTools.manual]) {
|
||||||
try {
|
try {
|
||||||
await createDirectory(path.join(conf.backupDir, elem), conf);
|
await createDirectory(conf.backupDir + elem, conf);
|
||||||
logger.debug(`Path ${path.join(conf.backupDir, elem)} created.`);
|
logger.debug(`Path ${conf.backupDir + elem} created.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HTTPError && error.response.statusCode == 405) {
|
if (error instanceof HTTPError && error.response.statusCode == 405) {
|
||||||
logger.debug(`Path ${path.join(conf.backupDir, elem)} already exist.`);
|
logger.debug(`Path ${conf.backupDir + elem} already exist.`);
|
||||||
} else {
|
} else {
|
||||||
messageManager.error("Fail to create webdav root folder");
|
messageManager.error("Fail to create webdav root folder");
|
||||||
logger.error("Fail to create webdav root folder");
|
logger.error("Fail to create webdav root folder");
|
||||||
@ -128,9 +127,9 @@ export async function createBackupFolder(conf: WebdavConfig) {
|
|||||||
statusTools.setStatus(status);
|
statusTools.setStatus(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDirectory(pathToCreate: string, config: WebdavConfig) {
|
function createDirectory(path: string, config: WebdavConfig) {
|
||||||
const endpoint = getEndpoint(config);
|
const endpoint = getEndpoint(config);
|
||||||
return got(path.join(config.url, endpoint, pathToCreate), {
|
return got(config.url + endpoint + path, {
|
||||||
method: "MKCOL" as Method,
|
method: "MKCOL" as Method,
|
||||||
headers: {
|
headers: {
|
||||||
authorization:
|
authorization:
|
||||||
@ -150,7 +149,7 @@ export function getBackups(
|
|||||||
return Promise.reject(new Error("Not logged in"));
|
return Promise.reject(new Error("Not logged in"));
|
||||||
}
|
}
|
||||||
const endpoint = getEndpoint(config);
|
const endpoint = getEndpoint(config);
|
||||||
return got(path.join(config.url, endpoint, config.backupDir, folder), {
|
return got(config.url + endpoint + config.backupDir + folder, {
|
||||||
method: "PROPFIND" as Method,
|
method: "PROPFIND" as Method,
|
||||||
headers: {
|
headers: {
|
||||||
authorization:
|
authorization:
|
||||||
@ -202,11 +201,11 @@ function extractBackupInfo(backups: WebdavBackup[], template: string) {
|
|||||||
return backups;
|
return backups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBackup(pathToDelete: string, config: WebdavConfig) {
|
export function deleteBackup(path: string, config: WebdavConfig) {
|
||||||
logger.debug(`Deleting Cloud backup ${pathToDelete}`);
|
logger.debug(`Deleting Cloud backup ${path}`);
|
||||||
const endpoint = getEndpoint(config);
|
const endpoint = getEndpoint(config);
|
||||||
return got
|
return got
|
||||||
.delete(path.join(config.url, endpoint, pathToDelete), {
|
.delete(config.url + endpoint + path, {
|
||||||
headers: {
|
headers: {
|
||||||
authorization:
|
authorization:
|
||||||
"Basic " +
|
"Basic " +
|
||||||
@ -280,7 +279,7 @@ export function webdavUploadFile(
|
|||||||
},
|
},
|
||||||
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
};
|
};
|
||||||
const url = path.join(config.url, getEndpoint(config), webdavPath);
|
const url = config.url + getEndpoint(config) + webdavPath;
|
||||||
|
|
||||||
logger.debug(`...URI: ${encodeURI(url)}`);
|
logger.debug(`...URI: ${encodeURI(url)}`);
|
||||||
logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`);
|
logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`);
|
||||||
@ -344,12 +343,8 @@ export async function chunkedUpload(
|
|||||||
const fileSize = fs.statSync(localPath).size;
|
const fileSize = fs.statSync(localPath).size;
|
||||||
|
|
||||||
const chunkEndpoint = getChunkEndpoint(config);
|
const chunkEndpoint = getChunkEndpoint(config);
|
||||||
const chunkedUrl = path.join(config.url, chunkEndpoint, uuid);
|
const chunkedUrl = config.url + chunkEndpoint + uuid;
|
||||||
const finalDestination = path.join(
|
const finalDestination = config.url + getEndpoint(config) + webdavPath;
|
||||||
config.url,
|
|
||||||
getEndpoint(config),
|
|
||||||
webdavPath
|
|
||||||
);
|
|
||||||
const status = statusTools.getStatus();
|
const status = statusTools.getStatus();
|
||||||
status.status = States.BKUP_UPLOAD_CLOUD;
|
status.status = States.BKUP_UPLOAD_CLOUD;
|
||||||
status.progress = -1;
|
status.progress = -1;
|
||||||
@ -393,7 +388,7 @@ export async function chunkedUpload(
|
|||||||
try {
|
try {
|
||||||
const chunckNumber = i.toString().padStart(CHUNK_NUMBER_SIZE, "0");
|
const chunckNumber = i.toString().padStart(CHUNK_NUMBER_SIZE, "0");
|
||||||
await uploadChunk(
|
await uploadChunk(
|
||||||
path.join(chunkedUrl, chunckNumber),
|
chunkedUrl + `/${chunckNumber}`,
|
||||||
finalDestination,
|
finalDestination,
|
||||||
chunk,
|
chunk,
|
||||||
current_size,
|
current_size,
|
||||||
@ -532,7 +527,7 @@ function assembleChunkedUpload(
|
|||||||
totalLength: number,
|
totalLength: number,
|
||||||
config: WebdavConfig
|
config: WebdavConfig
|
||||||
) {
|
) {
|
||||||
const chunckFile = path.join(url, ".file");
|
const chunckFile = `${url}/.file`;
|
||||||
logger.info(`Assemble chuncked upload.`);
|
logger.info(`Assemble chuncked upload.`);
|
||||||
logger.debug(`...URI: ${encodeURI(chunckFile)}`);
|
logger.debug(`...URI: ${encodeURI(chunckFile)}`);
|
||||||
logger.debug(`...Final destination: ${encodeURI(finalDestination)}`);
|
logger.debug(`...Final destination: ${encodeURI(finalDestination)}`);
|
||||||
@ -568,7 +563,7 @@ export function downloadFile(
|
|||||||
},
|
},
|
||||||
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
https: { rejectUnauthorized: !config.allowSelfSignedCerts },
|
||||||
};
|
};
|
||||||
const url = path.join(config.url, getEndpoint(config), webdavPath);
|
const url = config.url + getEndpoint(config) + webdavPath;
|
||||||
logger.debug(`...URI: ${encodeURI(url)}`);
|
logger.debug(`...URI: ${encodeURI(url)}`);
|
||||||
logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`);
|
logger.debug(`...rejectUnauthorized: ${options.https?.rejectUnauthorized}`);
|
||||||
const status = statusTools.getStatus();
|
const status = statusTools.getStatus();
|
||||||
|
@ -48,5 +48,5 @@
|
|||||||
"vue-router": "^4.2.0",
|
"vue-router": "^4.2.0",
|
||||||
"vue-tsc": "^1.8.0"
|
"vue-tsc": "^1.8.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.7.0"
|
"packageManager": "pnpm@8.15.3"
|
||||||
}
|
}
|
File diff suppressed because it is too large
Load Diff
28
nextcloud_backup/rootfs/opt/nextcloud_backup/package.json
Normal file
28
nextcloud_backup/rootfs/opt/nextcloud_backup/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
229
nextcloud_backup/rootfs/opt/nextcloud_backup/routes/api.js
Normal file
229
nextcloud_backup/rootfs/opt/nextcloud_backup/routes/api.js
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
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;
|
||||||
|
|
@ -0,0 +1,536 @@
|
|||||||
|
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
|
||||||
|
}
|
@ -0,0 +1,427 @@
|
|||||||
|
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;
|
@ -0,0 +1,73 @@
|
|||||||
|
<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>
|
1561
nextcloud_backup/rootfs/opt/nextcloud_backup/yarn.lock
Normal file
1561
nextcloud_backup/rootfs/opt/nextcloud_backup/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hassio-nextcloud-backup",
|
"name": "hassio-nextcloud-backup",
|
||||||
"packageManager": "pnpm@9.7.0",
|
"packageManager": "pnpm@7.13.4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"auto-changelog": "2.4.0",
|
"auto-changelog": "2.4.0",
|
||||||
"release-it": "15.2.0"
|
"release-it": "15.2.0"
|
||||||
|
Loading…
Reference in New Issue
Block a user