From f8e9aca1950a7f596f5f4294b4cde7d71c7b0102 Mon Sep 17 00:00:00 2001
From: Alexander Sabino <32822107+asabino2@users.noreply.github.com>
Date: Mon, 4 May 2026 17:15:51 +0100
Subject: [PATCH] first commit
---
.dockerignore | 6 +
.gitignore | 2 +
Dockerfile | 18 +
README.md | 39 +
docker-compose.example.yml | 15 +
docker-compose.yml | 15 +
package-lock.json | 1573 ++++++++++++++++++++++++++++++++++++
package.json | 18 +
public/app.js | 596 ++++++++++++++
public/index.html | 113 +++
public/styles.css | 548 +++++++++++++
src/backupService.js | 850 +++++++++++++++++++
src/config.js | 11 +
src/dockerService.js | 486 +++++++++++
src/server.js | 271 +++++++
src/store.js | 157 ++++
16 files changed, 4718 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 README.md
create mode 100644 docker-compose.example.yml
create mode 100644 docker-compose.yml
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 public/app.js
create mode 100644 public/index.html
create mode 100644 public/styles.css
create mode 100644 src/backupService.js
create mode 100644 src/config.js
create mode 100644 src/dockerService.js
create mode 100644 src/server.js
create mode 100644 src/store.js
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a8274b6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+npm-debug.log
+data/*.json
+data/tmpnode_modules
+npm-debug.log
+data/store.json
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0c54eff
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+data/
+node_modules/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ce071b6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,18 @@
+FROM node:20-bookworm-slim
+
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+RUN npm ci --omit=dev
+
+COPY public ./public
+COPY src ./src
+#COPY data ./data
+
+ENV PORT=3000
+ENV DATA_DIR=/app/data
+ENV HELPER_IMAGE=dockerbackup-app
+
+EXPOSE 3000
+
+CMD ["npm", "start"]
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5cffc2e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,39 @@
+# Docker Backup App
+
+⚠️ **AVISO CRÍTICO:** Esta é uma aplicação em estágio inicial de desenvolvimento, não use em produção de forma alguma, há risco de perda de dados ⚠️
+
+Aplicacao web para cadastrar profiles de backup de containers Docker, executar backup full ou incremental e restaurar snapshots de volumes e bind mounts.
+
+## Como funciona
+
+- O app lista os containers via Docker socket.
+- Cada backup processa um container por vez: para, executa o backup dos mounts elegiveis e sobe novamente se ele estava rodando.
+- O backup usa um container auxiliar com GNU tar e `--listed-incremental` para gerar arquivos compactados `.tar.gz`.
+- Quando o app roda dentro de Docker, backup e restore sao feitos via Docker API (`getArchive`/`putArchive`) sem criar helper e sem mapear o root do host.
+- Ha dois escopos por profile: `somente volumes` (comportamento tradicional) e `container inteiro` (tar unico por container a partir de `/`).
+- O restore aplica a cadeia full + incrementais sobre os mounts atuais do container, limpando o conteudo antes de reconstituir o snapshot escolhido.
+- Ao restaurar um backup, e possivel escolher quais containers do backup serao restaurados.
+
+## Requisitos
+
+- Docker Engine com acesso ao socket em `/var/run/docker.sock`.
+- O diretorio de backup informado no profile precisa ser visivel para o Docker daemon.
+- Em Docker Desktop no Windows, quando o app roda fora de container, paths como `C:\backups` sao convertidos automaticamente para `/run/desktop/mnt/host/c/backups`.
+- Quando o app roda dentro de container, use um caminho absoluto interno do container (ex.: `/app/data/backups`).
+- O escopo `container inteiro` exige que o app esteja rodando em Docker para usar backup/restore nativos sem helper.
+
+## Executando com Docker Compose
+
+```bash
+docker compose up --build
+```
+
+Abra `http://localhost:3000`.
+
+O arquivo `docker-compose.example.yml` foi mantido como referencia equivalente ao compose principal.
+
+## Observacoes
+
+- O restore valida se o conjunto de mounts do container continua igual ao do backup selecionado.
+- O catalogo de profiles e historico de backups fica salvo em `./data/store.json`.
+- Os arquivos `.tar.gz` sao gravados no diretorio de backup configurado em cada profile.
\ No newline at end of file
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
new file mode 100644
index 0000000..e3b855f
--- /dev/null
+++ b/docker-compose.example.yml
@@ -0,0 +1,15 @@
+services:
+ dockerbackup:
+ image: dockerbackup-app
+ build:
+ context: .
+ ports:
+ - "3000:3000"
+ environment:
+ PORT: 3000
+ DATA_DIR: /app/data
+ HELPER_IMAGE: dockerbackup-app
+ volumes:
+ - ./data:/app/data
+ - /var/run/docker.sock:/var/run/docker.sock
+ restart: unless-stopped
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..bc360a0
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,15 @@
+services:
+ dockerbackup:
+ image: dockerbackup-app
+ build:
+ context: .
+ ports:
+ - "33000:3000"
+ environment:
+ PORT: 3000
+ DATA_DIR: /app/data
+ HELPER_IMAGE: dockerbackup-app
+ volumes:
+ - /mnt/f/ChromeDownloads/dockerbackup/data:/app/data
+ - /var/run/docker.sock:/var/run/docker.sock
+ # restart: unless-stopped
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..28ce46c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1573 @@
+{
+ "name": "dockerbackup-app",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dockerbackup-app",
+ "version": "1.0.0",
+ "dependencies": {
+ "dockerode": "^4.0.7",
+ "express": "^4.21.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@balena/dockerignore": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
+ "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@grpc/grpc-js": {
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
+ "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@grpc/proto-loader": "^0.8.0",
+ "@js-sdsl/ordered-map": "^4.4.2"
+ },
+ "engines": {
+ "node": ">=12.10.0"
+ }
+ },
+ "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
+ "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "lodash.camelcase": "^4.3.0",
+ "long": "^5.0.0",
+ "protobufjs": "^7.5.3",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@grpc/proto-loader": {
+ "version": "0.7.15",
+ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
+ "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "lodash.camelcase": "^4.3.0",
+ "long": "^5.0.0",
+ "protobufjs": "^7.2.5",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@js-sdsl/ordered-map": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
+ "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/js-sdsl"
+ }
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
+ "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@types/node": {
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.19.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/asn1": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+ "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": "~2.1.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bcrypt-pbkdf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+ "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tweetnacl": "^0.14.3"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/body-parser/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buildcheck": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
+ "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
+ "optional": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/cpu-features": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
+ "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
+ "hasInstallScript": true,
+ "optional": true,
+ "dependencies": {
+ "buildcheck": "~0.0.6",
+ "nan": "^2.19.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/docker-modem": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz",
+ "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "readable-stream": "^3.5.0",
+ "split-ca": "^1.0.1",
+ "ssh2": "^1.15.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/dockerode": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz",
+ "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@balena/dockerignore": "^1.0.2",
+ "@grpc/grpc-js": "^1.11.1",
+ "@grpc/proto-loader": "^0.7.13",
+ "docker-modem": "^5.0.7",
+ "protobufjs": "^7.3.2",
+ "tar-fs": "^2.1.4",
+ "uuid": "^10.0.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/finalhandler/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/finalhandler/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lodash.camelcase": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+ "license": "MIT"
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nan": {
+ "version": "2.26.2",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
+ "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/protobufjs": {
+ "version": "7.5.6",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
+ "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.1",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/send/node_modules/debug/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split-ca": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
+ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
+ "license": "ISC"
+ },
+ "node_modules/ssh2": {
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
+ "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "asn1": "^0.2.6",
+ "bcrypt-pbkdf": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ },
+ "optionalDependencies": {
+ "cpu-features": "~0.0.10",
+ "nan": "^2.23.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tweetnacl": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+ "license": "Unlicense"
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4267c84
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "dockerbackup-app",
+ "version": "1.0.0",
+ "description": "Aplicacao web para backup e restauracao de volumes Docker",
+ "main": "src/server.js",
+ "scripts": {
+ "start": "node src/server.js",
+ "dev": "node --watch src/server.js",
+ "check": "node --check src/server.js && node --check src/config.js && node --check src/dockerService.js && node --check src/store.js && node --check src/backupService.js && node --check public/app.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "dependencies": {
+ "dockerode": "^4.0.7",
+ "express": "^4.21.2"
+ }
+}
\ No newline at end of file
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..7abc256
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,596 @@
+const state = {
+ containers: [],
+ profiles: [],
+ activeRuns: new Map(),
+};
+
+const elements = {
+ containerCount: document.querySelector('#containerCount'),
+ profileCount: document.querySelector('#profileCount'),
+ profileForm: document.querySelector('#profileForm'),
+ profileId: document.querySelector('#profileId'),
+ profileName: document.querySelector('#profileName'),
+ backupDir: document.querySelector('#backupDir'),
+ containerOptions: document.querySelector('#containerOptions'),
+ profilesList: document.querySelector('#profilesList'),
+ formModeBadge: document.querySelector('#formModeBadge'),
+ toast: document.querySelector('#toast'),
+ restoreModal: document.querySelector('#restoreModal'),
+ restoreModalSubtitle: document.querySelector('#restoreModalSubtitle'),
+ restoreContainerOptions: document.querySelector('#restoreContainerOptions'),
+ restoreModalConfirm: document.querySelector('#restoreModalConfirm'),
+ restoreModalClose: document.querySelector('#restoreModalClose'),
+ restoreModalSelectAll: document.querySelector('#restoreModalSelectAll'),
+};
+
+async function api(path, options = {}) {
+ const response = await fetch(path, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(options.headers || {}),
+ },
+ ...options,
+ });
+
+ if (response.status === 204) {
+ return null;
+ }
+
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.error || 'Falha na requisicao');
+ }
+
+ return payload;
+}
+
+function escapeHtml(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function showToast(message, isError = false) {
+ elements.toast.textContent = message;
+ elements.toast.classList.remove('hidden', 'error');
+ if (isError) {
+ elements.toast.classList.add('error');
+ }
+
+ window.clearTimeout(showToast.timer);
+ showToast.timer = window.setTimeout(() => {
+ elements.toast.classList.add('hidden');
+ }, 3200);
+}
+
+function getSelectedContainerIds() {
+ return [...document.querySelectorAll('input[name="containerIds"]:checked')].map((input) => input.value);
+}
+
+function renderContainers() {
+ elements.containerCount.textContent = String(state.containers.length);
+
+ if (!state.containers.length) {
+ elements.containerOptions.innerHTML = '
Nenhum container encontrado.
';
+ return;
+ }
+
+ const selected = new Set(getSelectedContainerIds());
+ elements.containerOptions.innerHTML = state.containers.map((container) => `
+
+ `).join('');
+}
+
+function backupButtons(profile) {
+ const isRunning = state.activeRuns.has(profile.id);
+ return `
+
+
+
+
+
+
+
+
+ `;
+}
+
+function getRunMode(profileId) {
+ const selector = document.querySelector(`select[data-run-mode="${profileId}"]`);
+ const value = selector?.value;
+ return value === 'incremental' ? 'incremental' : 'full';
+}
+
+function getProfileScopeLabel(scope) {
+ return scope === 'container' ? 'container inteiro' : 'somente volumes';
+}
+
+function askRestoreContainerSelection(profile, backup) {
+ const restorable = (backup.containers || []).filter((item) => item.status === 'ok');
+ if (!restorable.length) {
+ throw new Error('Nao ha containers validos neste backup para restaurar.');
+ }
+
+ elements.restoreModalSubtitle.textContent = `${profile.name} - ${new Date(backup.createdAt).toLocaleString('pt-BR')}`;
+ elements.restoreContainerOptions.innerHTML = restorable.map((item) => `
+
+ `).join('');
+
+ elements.restoreModal.classList.remove('hidden');
+ elements.restoreModal.setAttribute('aria-hidden', 'false');
+
+ return new Promise((resolve, reject) => {
+ const closeModal = () => {
+ elements.restoreModal.classList.add('hidden');
+ elements.restoreModal.setAttribute('aria-hidden', 'true');
+ elements.restoreContainerOptions.innerHTML = '';
+ };
+
+ const cleanup = () => {
+ elements.restoreModalConfirm.removeEventListener('click', onConfirm);
+ elements.restoreModalClose.removeEventListener('click', onCancel);
+ elements.restoreModalSelectAll.removeEventListener('click', onSelectAll);
+ elements.restoreModal.removeEventListener('click', onBackdropClick);
+ };
+
+ const onConfirm = () => {
+ const selected = [...elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]:checked')]
+ .map((input) => input.value);
+
+ if (!selected.length) {
+ showToast('Selecione ao menos um container para restaurar.', true);
+ return;
+ }
+
+ cleanup();
+ closeModal();
+ resolve(selected);
+ };
+
+ const onCancel = () => {
+ cleanup();
+ closeModal();
+ resolve(null);
+ };
+
+ const onSelectAll = () => {
+ for (const input of elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]')) {
+ input.checked = true;
+ }
+ };
+
+ const onBackdropClick = (event) => {
+ const closeTrigger = event.target.closest('[data-action="close-restore-modal"]');
+ if (closeTrigger) {
+ onCancel();
+ }
+ };
+
+ elements.restoreModalConfirm.addEventListener('click', onConfirm);
+ elements.restoreModalClose.addEventListener('click', onCancel);
+ elements.restoreModalSelectAll.addEventListener('click', onSelectAll);
+ elements.restoreModal.addEventListener('click', onBackdropClick);
+ });
+}
+
+function formatBackupFailures(backup) {
+ const failures = (backup.containers || []).filter((item) => item.status === 'error');
+ if (!failures.length) {
+ return '';
+ }
+
+ return `
+
+ Falhas: ${failures.map((item) => `${escapeHtml(item.containerName || item.containerId || 'container')}: ${escapeHtml(item.error || 'erro desconhecido')}`).join(' | ')}
+
+ `;
+}
+
+function progressBar(percent) {
+ const normalized = Math.max(0, Math.min(100, Number(percent) || 0));
+ return `
+
+
+
+ `;
+}
+
+function renderRunProgress(profileId) {
+ const run = state.activeRuns.get(profileId);
+ const host = document.querySelector(`[data-run-progress="${profileId}"]`);
+ if (!host) {
+ return;
+ }
+
+ if (!run || !run.progress) {
+ host.innerHTML = '';
+ host.classList.add('hidden');
+ return;
+ }
+
+ const overall = run.progress.overall || { total: 0, completed: 0, pending: 0, percent: 0 };
+ const currentContainer = run.progress.currentContainer;
+ const file = currentContainer?.file || { current: 0, total: 0, percent: 0, currentFile: null };
+ const containerPercent = Number.isFinite(currentContainer?.percent) ? currentContainer.percent : 0;
+ const stepLabel = currentContainer?.step || 'aguardando';
+ const stepMessage = currentContainer?.message || 'Aguardando processamento de arquivo...';
+ const logs = Array.isArray(currentContainer?.logs) ? currentContainer.logs.slice(-8) : [];
+ const operation = run?.kind === 'restore' || run?.progress?.operation === 'restore' ? 'restore' : 'backup';
+ const operationTitle = operation === 'restore' ? 'Progresso do restore' : 'Progresso do backup';
+
+ host.classList.remove('hidden');
+ host.innerHTML = `
+
+
+
+
+
+ Containers: ${escapeHtml(String(overall.completed))}/${escapeHtml(String(overall.total))} concluido(s)
+ Faltam ${escapeHtml(String(overall.pending))}
+
+ ${progressBar(overall.percent)}
+
+
+
+
+ Container atual: ${escapeHtml(currentContainer?.containerName || currentContainer?.containerId || '-')}
+ ${escapeHtml(String(Math.round(containerPercent)))}%
+
+ ${progressBar(containerPercent)}
+
+
+
+
+ Arquivos: ${escapeHtml(String(file.current || 0))}/${escapeHtml(String(file.total || 0))}
+ ${escapeHtml(String(Math.round(file.percent || 0)))}%
+
+ ${progressBar(file.percent || 0)}
+
Etapa: ${escapeHtml(stepLabel)} · ${escapeHtml(file.currentFile || stepMessage)}
+
+
+
+
+ Log detalhado
+ ${escapeHtml(String(logs.length))} evento(s)
+
+
+ ${logs.length
+ ? logs.map((line) => `${escapeHtml(line)}`).join('')
+ : 'Nenhum evento detalhado ainda.'}
+
+
+
+ `;
+}
+
+function renderAllRunProgress() {
+ for (const profile of state.profiles) {
+ renderRunProgress(profile.id);
+ }
+}
+
+async function pollRun(profileId, runId) {
+ const doneStatus = new Set(['completed', 'completed-with-errors', 'error']);
+
+ while (true) {
+ const run = await api(`/api/runs/${runId}`);
+ state.activeRuns.set(profileId, run);
+ renderRunProgress(profileId);
+
+ if (doneStatus.has(run.status)) {
+ return run;
+ }
+
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, 700);
+ });
+ }
+}
+
+function restoreButtons(profile, backups) {
+ if (!backups.length) {
+ return 'Nenhum backup executado ainda.
';
+ }
+
+ return `
+
+ ${backups.map((backup) => `
+
+ `).join('')}
+
+ `;
+}
+
+async function renderProfiles() {
+ elements.profileCount.textContent = String(state.profiles.length);
+
+ if (!state.profiles.length) {
+ elements.profilesList.innerHTML = 'Nenhum profile salvo.
';
+ return;
+ }
+
+ const backupsByProfile = await Promise.all(
+ state.profiles.map(async (profile) => [profile.id, await api(`/api/profiles/${profile.id}/backups`)])
+ );
+ const backupMap = new Map(backupsByProfile);
+
+ elements.profilesList.innerHTML = state.profiles.map((profile) => `
+
+
+
+
${escapeHtml(profile.name)}
+
${escapeHtml(String(profile.containerIds.length))} container(es) · ${escapeHtml(getProfileScopeLabel(profile.backupScope))}
+
${escapeHtml(profile.backupDir)}
+
+ ${backupButtons(profile)}
+
+
+ ${profile.containerIds.map((containerId) => {
+ const container = state.containers.find((item) => item.id === containerId);
+ return `${escapeHtml(container ? container.name : containerId.slice(0, 12))}`;
+ }).join('')}
+
+
+
+
Restaurar
+ ${restoreButtons(profile, backupMap.get(profile.id) || [])}
+
+
+ `).join('');
+
+ renderAllRunProgress();
+}
+
+function resetForm() {
+ elements.profileForm.reset();
+ elements.profileId.value = '';
+ elements.formModeBadge.textContent = 'criar';
+ renderContainers();
+}
+
+function fillForm(profile) {
+ elements.profileId.value = profile.id;
+ elements.profileName.value = profile.name;
+ elements.backupDir.value = profile.backupDir;
+ const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes';
+ document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true;
+ elements.formModeBadge.textContent = 'editar';
+ renderContainers();
+ for (const containerId of profile.containerIds) {
+ const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`);
+ if (input) {
+ input.checked = true;
+ }
+ }
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+}
+
+async function loadContainers() {
+ state.containers = await api('/api/containers');
+ renderContainers();
+}
+
+async function loadProfiles() {
+ state.profiles = await api('/api/profiles');
+ await renderProfiles();
+}
+
+async function saveProfile(event) {
+ event.preventDefault();
+ const payload = {
+ id: elements.profileId.value || undefined,
+ name: elements.profileName.value,
+ backupDir: elements.backupDir.value,
+ containerIds: getSelectedContainerIds(),
+ backupScope: document.querySelector('input[name="backupScope"]:checked').value,
+ };
+
+ try {
+ await api('/api/profiles', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ await loadProfiles();
+ resetForm();
+ showToast('Profile salvo.');
+ } catch (error) {
+ showToast(error.message, true);
+ }
+}
+
+async function handleProfileAction(event) {
+ const button = event.target.closest('button[data-action]');
+ if (!button) {
+ return;
+ }
+
+ const { action, profileId, backupId } = button.dataset;
+ const profile = state.profiles.find((item) => item.id === profileId);
+ if (!profile) {
+ return;
+ }
+
+ try {
+ if (action === 'edit') {
+ fillForm(profile);
+ return;
+ }
+
+ if (action === 'delete') {
+ if (!window.confirm(`Excluir o profile ${profile.name}?`)) {
+ return;
+ }
+ await api(`/api/profiles/${profileId}`, { method: 'DELETE' });
+ await loadProfiles();
+ showToast('Profile removido.');
+ return;
+ }
+
+ if (action === 'run') {
+ button.disabled = true;
+ button.textContent = 'Executando...';
+ const mode = getRunMode(profileId);
+
+ const start = await api(`/api/profiles/${profileId}/run`, { method: 'POST', body: JSON.stringify({ mode }) });
+ state.activeRuns.set(profileId, {
+ id: start.runId,
+ profileId,
+ status: 'running',
+ progress: {
+ overall: { total: profile.containerIds.length, completed: 0, pending: profile.containerIds.length, percent: 0 },
+ currentContainer: null,
+ },
+ });
+ renderRunProgress(profileId);
+
+ const run = await pollRun(profileId, start.runId);
+ state.activeRuns.delete(profileId);
+ await loadProfiles();
+ if (run.status === 'error') {
+ showToast(run.error || 'Falha durante a execucao do backup.', true);
+ } else {
+ const backupStatus = run.result?.status;
+ const failures = (run.result?.containers || []).filter((item) => item.status === 'error');
+ if (failures.length) {
+ const details = failures
+ .map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`)
+ .join(' | ');
+ showToast(`Backup com falhas. ${details}`, true);
+ } else {
+ showToast(
+ backupStatus === 'ok' ? 'Backup concluido.' : 'Backup concluido com falhas parciais.',
+ backupStatus !== 'ok',
+ );
+ }
+ }
+ return;
+ }
+
+ if (action === 'restore') {
+ if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) {
+ return;
+ }
+
+ const backups = await api(`/api/profiles/${profileId}/backups`);
+ const selectedBackup = backups.find((item) => item.id === backupId);
+ if (!selectedBackup) {
+ throw new Error('Backup selecionado nao encontrado.');
+ }
+
+ const selectedContainerIds = await askRestoreContainerSelection(profile, selectedBackup);
+ if (!selectedContainerIds) {
+ return;
+ }
+
+ button.disabled = true;
+ button.textContent = 'Restaurando...';
+
+ const start = await api(`/api/profiles/${profileId}/restore`, {
+ method: 'POST',
+ body: JSON.stringify({ backupId, containerIds: selectedContainerIds }),
+ });
+ state.activeRuns.set(profileId, {
+ id: start.runId,
+ kind: 'restore',
+ profileId,
+ status: 'running',
+ progress: {
+ operation: 'restore',
+ overall: {
+ total: selectedContainerIds.length,
+ completed: 0,
+ pending: selectedContainerIds.length,
+ percent: 0,
+ },
+ currentContainer: null,
+ },
+ });
+ renderRunProgress(profileId);
+
+ const run = await pollRun(profileId, start.runId);
+ state.activeRuns.delete(profileId);
+ await loadProfiles();
+
+ if (run.status === 'error') {
+ showToast(run.error || 'Falha durante a execucao do restore.', true);
+ } else {
+ const restoreStatus = run.result?.status;
+ const restoreStatsLines = (run.result?.containers || [])
+ .filter((item) => item.status === 'ok' && item.stats)
+ .map((item) => `${item.containerName}: apagados ${item.stats.deleted}, criados ${item.stats.created}, modificados ${item.stats.modified}`);
+
+ const failures = (run.result?.containers || []).filter((item) => item.status === 'error');
+ if (failures.length) {
+ const details = failures
+ .map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`)
+ .join(' | ');
+ const statsSummary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : '';
+ showToast(`Restore com falhas. ${details}.${statsSummary}`, true);
+ } else {
+ const summary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : '';
+ showToast(
+ `${restoreStatus === 'ok' ? 'Restore concluido.' : 'Restore concluido com falhas parciais.'}${summary}`,
+ restoreStatus !== 'ok',
+ );
+ }
+ }
+ }
+ } catch (error) {
+ showToast(error.message, true);
+ } finally {
+ button.disabled = false;
+ }
+}
+
+async function init() {
+ try {
+ await Promise.all([loadContainers(), loadProfiles()]);
+ } catch (error) {
+ showToast(error.message, true);
+ }
+}
+
+elements.profileForm.addEventListener('submit', saveProfile);
+document.querySelector('#refreshContainers').addEventListener('click', init);
+document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles);
+document.querySelector('#clearForm').addEventListener('click', resetForm);
+elements.profilesList.addEventListener('click', handleProfileAction);
+
+init();
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..9956fcd
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,113 @@
+
+
+
+
+
+ Docker Backup Profiles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..e3dbb16
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,548 @@
+:root {
+ --bg: #f3efe6;
+ --bg-soft: #fffaf2;
+ --surface: rgba(255, 252, 245, 0.78);
+ --line: rgba(69, 55, 37, 0.14);
+ --text: #2e2418;
+ --muted: #7b6c58;
+ --accent: #156669;
+ --accent-strong: #0b4d50;
+ --danger: #a03838;
+ --shadow: 0 20px 60px rgba(63, 43, 17, 0.12);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ font-family: 'Space Grotesk', sans-serif;
+ color: var(--text);
+ background:
+ radial-gradient(circle at top left, rgba(21, 102, 105, 0.18), transparent 30%),
+ radial-gradient(circle at 85% 20%, rgba(206, 131, 76, 0.18), transparent 22%),
+ linear-gradient(180deg, #fbf7ef 0%, #efe6d5 100%);
+}
+
+button,
+input {
+ font: inherit;
+}
+
+.page-shell {
+ max-width: 1240px;
+ margin: 0 auto;
+ padding: 32px 20px 64px;
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: 1.6fr 0.8fr;
+ gap: 24px;
+ margin-bottom: 28px;
+ align-items: stretch;
+}
+
+.eyebrow {
+ margin: 0 0 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ color: var(--accent);
+ font-size: 0.78rem;
+}
+
+.hero h1 {
+ margin: 0;
+ font-size: clamp(2.2rem, 4vw, 4.4rem);
+ line-height: 0.96;
+ max-width: 10ch;
+}
+
+.hero-copy {
+ max-width: 64ch;
+ color: var(--muted);
+ font-size: 1.02rem;
+ margin-top: 18px;
+}
+
+.hero-card,
+.panel,
+.toast {
+ backdrop-filter: blur(14px);
+ background: var(--surface);
+ border: 1px solid var(--line);
+ box-shadow: var(--shadow);
+}
+
+.hero-card {
+ border-radius: 28px;
+ padding: 24px;
+ display: grid;
+ gap: 18px;
+ align-content: center;
+}
+
+.metric-label {
+ display: block;
+ font-size: 0.82rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--muted);
+}
+
+.hero-card strong {
+ font-size: 2.6rem;
+}
+
+.layout {
+ display: grid;
+ grid-template-columns: minmax(360px, 420px) 1fr;
+ gap: 20px;
+}
+
+.panel {
+ border-radius: 28px;
+ padding: 22px;
+}
+
+.panel-header,
+.picker-header,
+.profile-card-top {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: center;
+}
+
+.panel-header h2,
+.picker-header h3,
+.profile-card h3,
+.restore-block h4 {
+ margin: 0;
+}
+
+.badge,
+.chip,
+.state,
+.profile-card code {
+ font-family: 'IBM Plex Mono', monospace;
+}
+
+.badge,
+.chip {
+ border-radius: 999px;
+ padding: 6px 10px;
+ background: rgba(21, 102, 105, 0.1);
+ color: var(--accent-strong);
+}
+
+form,
+.restore-block,
+.profiles-list {
+ display: grid;
+ gap: 18px;
+}
+
+label,
+fieldset {
+ display: grid;
+ gap: 8px;
+ border: 0;
+ padding: 0;
+ margin: 0;
+}
+
+label span,
+legend {
+ font-weight: 700;
+}
+
+input[type='text'] {
+ width: 100%;
+ padding: 14px 16px;
+ border-radius: 16px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.72);
+}
+
+.radio-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+.radio-card,
+.container-option,
+.backup-item {
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.65);
+ border-radius: 18px;
+}
+
+.radio-card {
+ padding: 14px;
+}
+
+.radio-card input {
+ margin-bottom: 10px;
+}
+
+.radio-card small,
+.container-option small,
+.profile-card p,
+.empty-state,
+.empty-inline {
+ color: var(--muted);
+}
+
+.container-options,
+.chips,
+.backup-history {
+ display: grid;
+ gap: 10px;
+}
+
+.container-option {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 12px;
+ align-items: center;
+ padding: 14px;
+}
+
+.state {
+ text-transform: uppercase;
+ font-size: 0.78rem;
+}
+
+.state.running {
+ color: var(--accent-strong);
+}
+
+.state.exited,
+.state.created {
+ color: var(--muted);
+}
+
+.primary-button,
+.secondary-button,
+.ghost-button,
+.backup-item {
+ border: 0;
+ border-radius: 16px;
+ cursor: pointer;
+ transition: transform 180ms ease, opacity 180ms ease, background 180ms ease;
+}
+
+.primary-button,
+.secondary-button,
+.ghost-button {
+ padding: 12px 14px;
+}
+
+.small {
+ padding: 10px 12px;
+ font-size: 0.92rem;
+}
+
+.primary-button {
+ background: var(--accent);
+ color: white;
+}
+
+.secondary-button {
+ background: rgba(21, 102, 105, 0.12);
+ color: var(--accent-strong);
+}
+
+.ghost-button {
+ background: rgba(160, 56, 56, 0.08);
+ color: var(--danger);
+}
+
+.primary-button:hover,
+.secondary-button:hover,
+.ghost-button:hover,
+.backup-item:hover {
+ transform: translateY(-1px);
+}
+
+.primary-button:disabled,
+.secondary-button:disabled,
+.ghost-button:disabled,
+.backup-item:disabled {
+ opacity: 0.6;
+ cursor: wait;
+}
+
+.form-actions,
+.card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.run-actions {
+ display: grid;
+ gap: 10px;
+ justify-items: end;
+}
+
+.mode-picker {
+ display: grid;
+ gap: 6px;
+ min-width: 180px;
+}
+
+.mode-picker span {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.mode-select {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.72);
+ color: var(--text);
+ padding: 8px 10px;
+}
+
+.profile-card {
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ padding: 18px;
+ background: rgba(255, 255, 255, 0.68);
+ display: grid;
+ gap: 16px;
+}
+
+.profile-card p,
+.profile-card code {
+ margin: 6px 0 0;
+}
+
+.profile-card code {
+ display: inline-block;
+ padding: 4px 0;
+}
+
+.backup-item {
+ padding: 14px 16px;
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ text-align: left;
+}
+
+.backup-item em {
+ align-self: center;
+ color: var(--accent-strong);
+ font-style: normal;
+ font-weight: 700;
+}
+
+.backup-error {
+ display: block;
+ margin-top: 6px;
+ color: var(--danger);
+}
+
+.run-progress {
+ display: grid;
+}
+
+.progress-card {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 14px;
+ background: rgba(240, 252, 252, 0.72);
+ display: grid;
+ gap: 12px;
+}
+
+.progress-header,
+.progress-label-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: center;
+}
+
+.progress-header small,
+.progress-label-row,
+.current-file {
+ color: var(--muted);
+}
+
+.progress-block {
+ display: grid;
+ gap: 7px;
+}
+
+.progress-track {
+ width: 100%;
+ height: 10px;
+ border-radius: 999px;
+ overflow: hidden;
+ background: rgba(21, 102, 105, 0.16);
+}
+
+.progress-fill {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, #17a2a8 0%, #156669 100%);
+ transition: width 220ms ease;
+}
+
+.current-file {
+ font-size: 0.82rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.progress-log {
+ max-height: 140px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.55);
+ padding: 8px;
+ display: grid;
+ gap: 4px;
+}
+
+.progress-log small {
+ color: var(--muted);
+ font-family: 'IBM Plex Mono', monospace;
+ font-size: 0.76rem;
+ line-height: 1.35;
+}
+
+.toast {
+ position: fixed;
+ right: 24px;
+ bottom: 24px;
+ max-width: 360px;
+ padding: 14px 18px;
+ border-radius: 18px;
+}
+
+.toast.error {
+ border-color: rgba(160, 56, 56, 0.24);
+ color: var(--danger);
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: grid;
+ place-items: center;
+}
+
+.modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(20, 16, 10, 0.45);
+}
+
+.modal-card {
+ position: relative;
+ width: min(640px, calc(100vw - 24px));
+ max-height: min(80vh, 620px);
+ overflow: auto;
+ border-radius: 18px;
+ border: 1px solid var(--line);
+ background: var(--bg-soft);
+ box-shadow: var(--shadow);
+ padding: 18px;
+ display: grid;
+ gap: 12px;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+}
+
+.modal-header h3,
+.modal-subtitle {
+ margin: 0;
+}
+
+.modal-subtitle {
+ color: var(--muted);
+}
+
+.modal-options {
+ display: grid;
+ gap: 8px;
+}
+
+.modal-option {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 10px;
+ align-items: center;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.66);
+}
+
+.modal-option small {
+ color: var(--muted);
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.hidden {
+ display: none;
+}
+
+@media (max-width: 960px) {
+ .hero,
+ .layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 640px) {
+ .page-shell {
+ padding-inline: 14px;
+ }
+
+ .panel,
+ .hero-card {
+ border-radius: 22px;
+ }
+
+ .radio-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .container-option,
+ .profile-card-top,
+ .backup-item {
+ grid-template-columns: 1fr;
+ display: grid;
+ }
+
+ .run-actions,
+ .mode-picker {
+ justify-items: stretch;
+ }
+}
\ No newline at end of file
diff --git a/src/backupService.js b/src/backupService.js
new file mode 100644
index 0000000..3053b7b
--- /dev/null
+++ b/src/backupService.js
@@ -0,0 +1,850 @@
+const path = require('path');
+const fs = require('fs/promises');
+const { randomUUID } = require('crypto');
+
+function shellQuote(value) {
+ return `'${String(value).replace(/'/g, `"'"'`)}'`;
+}
+
+function slugify(value) {
+ return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item';
+}
+
+function formatStamp(date = new Date()) {
+ return date.toISOString().replace(/[:.]/g, '-');
+}
+
+function normalizeDockerHostPath(inputPath) {
+ const value = String(inputPath || '').trim();
+ if (/^[A-Za-z]:[\\/]/.test(value)) {
+ const drive = value[0].toLowerCase();
+ const suffix = value.slice(2).replace(/\\/g, '/').replace(/^\/+/, '');
+ return `/run/desktop/mnt/host/${drive}/${suffix}`;
+ }
+
+ return value.replace(/\\/g, '/');
+}
+
+function normalizeContainerPath(inputPath) {
+ const value = String(inputPath || '').trim().replace(/\\/g, '/');
+ if (!value.startsWith('/')) {
+ throw new Error('Em execucao via Docker, o diretorio de backup deve ser absoluto dentro do container (ex.: /app/data/backups).');
+ }
+
+ return value;
+}
+
+function normalizeMounts(containerInspect) {
+ return (containerInspect.Mounts || [])
+ .filter((mount) => mount.Type === 'bind' || mount.Type === 'volume')
+ .map((mount) => ({
+ type: mount.Type,
+ name: mount.Name || null,
+ source: mount.Source,
+ destination: mount.Destination,
+ rw: mount.RW,
+ }))
+ .sort((left, right) => {
+ const leftKey = `${left.destination}|${left.type}|${left.name || left.source}`;
+ const rightKey = `${right.destination}|${right.type}|${right.name || right.source}`;
+ return leftKey.localeCompare(rightKey);
+ });
+}
+
+function sameMountSignature(leftMounts, rightMounts) {
+ if (leftMounts.length !== rightMounts.length) {
+ return false;
+ }
+
+ return leftMounts.every((left, index) => {
+ const right = rightMounts[index];
+ return (
+ left.type === right.type
+ && left.name === right.name
+ && left.source === right.source
+ && left.destination === right.destination
+ );
+ });
+}
+
+function getMountBindingSource(mount) {
+ return mount.type === 'volume' ? mount.name : mount.source;
+}
+
+function normalizeBackupScope(value) {
+ return value === 'container' ? 'container' : 'volumes';
+}
+
+function containerSnapshotPath(profileId, containerId, scope) {
+ return `/tmp/dockerbackup-${slugify(profileId)}-${containerId.slice(0, 12)}-${scope}.snar`;
+}
+
+function toContainerRelPath(absPath) {
+ return String(absPath || '').replace(/^\/+/, '') || '.';
+}
+
+function parseManifestLines(rawOutput) {
+ const map = new Map();
+ for (const line of String(rawOutput || '').split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ continue;
+ }
+
+ const [relativePath, sizeRaw, mtimeRaw, scopeRaw] = trimmed.split('|');
+ if (!relativePath) {
+ continue;
+ }
+
+ const key = scopeRaw
+ ? `${toContainerRelPath(scopeRaw)}/${relativePath}`.replace(/\/\//g, '/')
+ : relativePath;
+
+ map.set(key, {
+ size: Number(sizeRaw || 0),
+ mtime: Number(mtimeRaw || 0),
+ });
+ }
+
+ return map;
+}
+
+function calculateManifestDiff(beforeMap, afterMap) {
+ let deleted = 0;
+ let created = 0;
+ let modified = 0;
+
+ for (const [filePath, beforeEntry] of beforeMap.entries()) {
+ const afterEntry = afterMap.get(filePath);
+ if (!afterEntry) {
+ deleted += 1;
+ continue;
+ }
+
+ if (beforeEntry.size !== afterEntry.size || beforeEntry.mtime !== afterEntry.mtime) {
+ modified += 1;
+ }
+ }
+
+ for (const filePath of afterMap.keys()) {
+ if (!beforeMap.has(filePath)) {
+ created += 1;
+ }
+ }
+
+ return { deleted, created, modified };
+}
+
+class BackupService {
+ constructor({ dockerService, store }) {
+ this.dockerService = dockerService;
+ this.store = store;
+ }
+
+ async runProfile(profileId, options = {}) {
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
+ const profile = await this.store.getProfile(profileId);
+ if (!profile) {
+ throw new Error('Profile nao encontrado.');
+ }
+
+ if (!profile.containerIds.length) {
+ throw new Error('Selecione ao menos um container no profile.');
+ }
+
+ const effectiveMode = ['full', 'incremental'].includes(options.mode)
+ ? options.mode
+ : (['full', 'incremental'].includes(profile.mode) ? profile.mode : 'full');
+
+ const backupScope = normalizeBackupScope(profile.backupScope);
+
+ const backupRun = {
+ id: randomUUID(),
+ profileId: profile.id,
+ profileName: profile.name,
+ createdAt: new Date().toISOString(),
+ mode: effectiveMode,
+ backupScope,
+ backupDir: profile.backupDir,
+ status: 'ok',
+ containers: [],
+ };
+
+ const progress = {
+ profileId: profile.id,
+ profileName: profile.name,
+ startedAt: backupRun.createdAt,
+ status: 'running',
+ overall: {
+ total: profile.containerIds.length,
+ completed: 0,
+ pending: profile.containerIds.length,
+ percent: 0,
+ },
+ currentContainer: null,
+ };
+
+ const emitProgress = () => {
+ onProgress(JSON.parse(JSON.stringify(progress)));
+ };
+
+ emitProgress();
+
+ for (const [index, containerId] of profile.containerIds.entries()) {
+ progress.currentContainer = {
+ containerId,
+ index: index + 1,
+ total: profile.containerIds.length,
+ containerName: null,
+ status: 'running',
+ step: 'iniciando',
+ message: 'Preparando backup do container.',
+ logs: [],
+ percent: 0,
+ file: {
+ current: 0,
+ total: 0,
+ currentFile: null,
+ percent: 0,
+ },
+ };
+ emitProgress();
+
+ const containerBackup = await this.backupContainer(profile, containerId, backupRun.createdAt, {
+ mode: effectiveMode,
+ backupScope,
+ onProgress: (containerProgress) => {
+ progress.currentContainer = {
+ ...progress.currentContainer,
+ ...containerProgress,
+ };
+ emitProgress();
+ },
+ });
+
+ backupRun.containers.push(containerBackup);
+ if (containerBackup.status !== 'ok') {
+ backupRun.status = 'partial';
+ }
+
+ progress.overall.completed += 1;
+ progress.overall.pending = Math.max(0, progress.overall.total - progress.overall.completed);
+ progress.overall.percent = progress.overall.total
+ ? Math.round((progress.overall.completed / progress.overall.total) * 100)
+ : 100;
+ progress.currentContainer = {
+ ...(progress.currentContainer || {}),
+ status: containerBackup.status,
+ percent: 100,
+ };
+ emitProgress();
+ }
+
+ await this.store.addBackup(backupRun);
+
+ progress.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors';
+ progress.finishedAt = new Date().toISOString();
+ progress.currentContainer = null;
+ progress.overall.percent = 100;
+ progress.overall.pending = 0;
+ emitProgress();
+
+ return backupRun;
+ }
+
+ async backupContainer(profile, containerId, runDateIso, options = {}) {
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
+ const runMode = ['full', 'incremental'].includes(options.mode)
+ ? options.mode
+ : (['full', 'incremental'].includes(profile.mode) ? profile.mode : 'full');
+ const backupScope = normalizeBackupScope(options.backupScope || profile.backupScope);
+
+ let inspect;
+ let mounts = [];
+ let containerName = containerId.slice(0, 12);
+
+ try {
+ inspect = await this.dockerService.inspectContainer(containerId);
+ mounts = normalizeMounts(inspect);
+ containerName = inspect.Name.replace(/^\//, '');
+ } catch (error) {
+ onProgress({ containerName, status: 'error', percent: 100, step: 'erro', message: error.message });
+ return {
+ containerId,
+ containerName,
+ status: 'error',
+ mode: runMode,
+ error: `Falha ao inspecionar container: ${error.message}`,
+ };
+ }
+
+ if (backupScope === 'volumes' && !mounts.length) {
+ onProgress({
+ containerName,
+ status: 'skipped',
+ step: 'concluido',
+ message: 'Container sem volumes elegiveis.',
+ percent: 100,
+ file: { current: 0, total: 0, currentFile: null, percent: 100 },
+ });
+ return {
+ containerId: inspect.Id,
+ containerName,
+ status: 'skipped',
+ mode: runMode,
+ message: 'Container sem volumes ou bind mounts elegiveis.',
+ };
+ }
+
+ const runInDocker = this.dockerService.isRunningInDocker();
+ const backupRoot = runInDocker
+ ? normalizeContainerPath(profile.backupDir)
+ : normalizeDockerHostPath(profile.backupDir);
+ const safeContainerName = slugify(containerName);
+ const safeProfileName = slugify(profile.name);
+ const stamp = formatStamp(new Date(runDateIso));
+ const archiveRelativePath = path.posix.join(safeProfileName, safeContainerName, `${stamp}-${runMode}.tar.gz`);
+ const snapshotRelativePath = path.posix.join(safeProfileName, safeContainerName, 'latest.snar');
+
+ const containerBackup = {
+ containerId: inspect.Id,
+ containerName,
+ backupScope,
+ backupPaths: backupScope === 'container' ? ['/'] : mounts.map((mount) => mount.destination),
+ mountSignature: mounts,
+ archiveRelativePath,
+ snapshotRelativePath,
+ wasRunning: inspect.State?.Running === true,
+ mode: runMode,
+ status: 'ok',
+ };
+
+ const logs = [];
+ const pushLog = (message, step = 'processando') => {
+ const line = `[${new Date().toLocaleTimeString('pt-BR')}] ${message}`;
+ logs.push(line);
+ while (logs.length > 40) {
+ logs.shift();
+ }
+
+ onProgress({
+ containerName,
+ step,
+ message,
+ logs: [...logs],
+ });
+ };
+
+ let fileTotal = 0;
+ let fileCurrent = 0;
+ const updateFileProgress = (currentFile = null) => {
+ const filePercent = fileTotal > 0 ? Math.min(100, Math.round((fileCurrent / fileTotal) * 100)) : 0;
+ onProgress({
+ containerName,
+ status: 'running',
+ step: 'processando',
+ percent: filePercent,
+ file: {
+ current: fileCurrent,
+ total: fileTotal,
+ currentFile,
+ percent: filePercent,
+ },
+ });
+ };
+
+ try {
+ if (backupScope === 'container' && !runInDocker) {
+ throw new Error('Backup do container inteiro requer app executando via Docker.');
+ }
+
+ pushLog(`Escopo selecionado: ${backupScope === 'container' ? 'container inteiro' : 'somente volumes'}.`, 'preparando');
+
+ if (runInDocker) {
+ await this.dockerService.ensureLocalDirectory(backupRoot);
+ pushLog(`Diretorio de backup pronto em ${backupRoot}.`, 'preparando');
+
+ const originalRunning = inspect.State?.Running === true;
+ let tempStarted = false;
+ try {
+ if (originalRunning) {
+ pushLog('Container ativo detectado. Parando antes do backup.', 'preparando');
+ await this.dockerService.stopContainer(containerId);
+ }
+
+ pushLog('Iniciando container temporariamente para snapshot.', 'preparando');
+ await this.dockerService.startContainer(containerId);
+ tempStarted = true;
+
+ const sourcePaths = backupScope === 'container'
+ ? ['/']
+ : mounts.map((mount) => mount.destination);
+ const relSourcePaths = sourcePaths.map((item) => toContainerRelPath(item));
+
+ if (backupScope === 'volumes') {
+ pushLog('Contando arquivos para barra de progresso.', 'contando');
+ const countCmd = `set -eu; TOTAL=0; for p in ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}; do if [ -e \"/$p\" ]; then C=$(find \"/$p\" -type f 2>/dev/null | wc -l | tr -d \" \" ); TOTAL=$((TOTAL + C)); fi; done; echo \"$TOTAL\"`;
+ const output = await this.dockerService.runContainerCommand(containerId, countCmd);
+ const parsed = Number(output.split(/\r?\n/).pop());
+ fileTotal = Number.isFinite(parsed) ? parsed : 0;
+ pushLog(`Total de arquivos identificado: ${fileTotal}.`, 'contando');
+ }
+
+ const absoluteArchivePath = path.posix.join(backupRoot, archiveRelativePath);
+
+ let newerMtimeFlag = '';
+ if (runMode === 'incremental') {
+ const lastTime = await this.store.getLastContainerBackupTime(profile.id, containerId);
+ if (lastTime) {
+ const unixSec = Math.floor(new Date(lastTime).getTime() / 1000);
+ newerMtimeFlag = `--newer-mtime=@${unixSec}`;
+ pushLog(`Backup incremental: incluindo arquivos modificados apos ${lastTime}.`, 'preparando');
+ } else {
+ pushLog('Aviso: nenhum backup anterior encontrado, gerando full.', 'preparando');
+ }
+ }
+
+ const tarParts = [
+ 'set -eu',
+ 'umask 077',
+ 'echo "__DBKP_TAR_BEGIN__" 1>&2',
+ ];
+
+ if (backupScope === 'container') {
+ tarParts.push(
+ `tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / --exclude=proc --exclude=sys --exclude=dev --exclude=run --exclude=tmp .`
+ );
+ } else {
+ tarParts.push(
+ `tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}`
+ );
+ }
+
+ updateFileProgress();
+ pushLog('Iniciando compactacao tar do container.', 'gerando-tar');
+
+ await this.dockerService.streamContainerCommandToFile(containerId, tarParts.join(' && '), absoluteArchivePath, {
+ onOutput: (line, stream) => {
+ const normalizedLine = String(line || '').trim();
+ if (!normalizedLine || stream !== 'stderr' || normalizedLine.startsWith('__DBKP_TAR_BEGIN__')) {
+ return;
+ }
+
+ if (!normalizedLine.startsWith('tar:')) {
+ fileCurrent += 1;
+ updateFileProgress(normalizedLine);
+ } else {
+ pushLog(`Aviso do tar: ${normalizedLine}`, 'gerando-tar');
+ }
+ },
+ });
+
+ pushLog(`Arquivo gerado: ${absoluteArchivePath}`, 'finalizando');
+ } finally {
+ if (tempStarted) {
+ pushLog('Encerrando container apos backup.', 'finalizando');
+ await this.dockerService.stopContainer(containerId).catch(() => null);
+ }
+
+ if (originalRunning) {
+ pushLog('Reiniciando container (estava ativo antes do backup).', 'finalizando');
+ await this.dockerService.startContainer(containerId).catch(() => null);
+ }
+ }
+
+ onProgress({
+ containerName,
+ status: 'ok',
+ step: 'concluido',
+ message: 'Backup concluido com sucesso.',
+ percent: 100,
+ file: {
+ current: Math.max(fileCurrent, fileTotal),
+ total: fileTotal,
+ currentFile: null,
+ percent: 100,
+ },
+ });
+
+ return containerBackup;
+ }
+
+ if (backupScope === 'container') {
+ throw new Error('Backup de container inteiro sem Docker nativo nao e suportado.');
+ }
+
+ await this.dockerService.ensureHostDirectory(backupRoot);
+ const wasRunning = inspect.State?.Running === true;
+ if (wasRunning) {
+ await this.dockerService.stopContainer(containerId);
+ }
+
+ const binds = [`${backupRoot}:/backuproot`];
+ for (const [index, mount] of mounts.entries()) {
+ binds.push(`${getMountBindingSource(mount)}:/payload/m${index}:ro`);
+ }
+
+ const archivePath = `/backuproot/${archiveRelativePath}`;
+ const snapshotPath = `/backuproot/${snapshotRelativePath}`;
+ const parentDir = path.posix.dirname(archivePath);
+ const cmdParts = ['set -eu', `mkdir -p ${shellQuote(parentDir)}`];
+ if (runMode === 'full') {
+ cmdParts.push(`rm -f ${shellQuote(snapshotPath)}`);
+ }
+ cmdParts.push('TOTAL_FILES=$(find /payload -type f | wc -l | tr -d " ")');
+ cmdParts.push('echo "__DBKP_TOTAL_FILES__=${TOTAL_FILES}"');
+ cmdParts.push(`tar --listed-incremental=${shellQuote(snapshotPath)} -czvf ${shellQuote(archivePath)} -C /payload .`);
+
+ await this.dockerService.runHelper({
+ binds,
+ cmd: cmdParts.join(' && '),
+ onOutput: (line, stream) => {
+ const normalizedLine = String(line || '').trim();
+ if (!normalizedLine) {
+ return;
+ }
+
+ if (normalizedLine.startsWith('__DBKP_TOTAL_FILES__=')) {
+ const parsed = Number(normalizedLine.split('=')[1]);
+ fileTotal = Number.isFinite(parsed) ? parsed : 0;
+ updateFileProgress();
+ return;
+ }
+
+ if (stream === 'stdout' && !normalizedLine.startsWith('tar:')) {
+ fileCurrent += 1;
+ updateFileProgress(normalizedLine);
+ }
+ },
+ });
+
+ if (wasRunning) {
+ await this.dockerService.startContainer(containerId).catch(() => null);
+ }
+
+ onProgress({
+ containerName,
+ status: 'ok',
+ step: 'concluido',
+ message: 'Backup concluido com sucesso.',
+ percent: 100,
+ file: {
+ current: Math.max(fileCurrent, fileTotal),
+ total: fileTotal,
+ currentFile: null,
+ percent: 100,
+ },
+ });
+
+ return containerBackup;
+ } catch (error) {
+ onProgress({
+ containerName,
+ status: 'error',
+ step: 'erro',
+ message: error.message,
+ percent: 100,
+ });
+ return {
+ ...containerBackup,
+ status: 'error',
+ error: error.message,
+ };
+ }
+ }
+
+ async restoreBackup(profileId, backupId, options = {}) {
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
+ const selectedContainerIds = options.selectedContainerIds;
+
+ const profile = await this.store.getProfile(profileId);
+ if (!profile) {
+ throw new Error('Profile nao encontrado.');
+ }
+
+ const backupRun = await this.store.getBackup(backupId);
+ if (!backupRun || backupRun.profileId !== profileId) {
+ throw new Error('Backup nao encontrado para este profile.');
+ }
+
+ const allowedContainerIds = new Set(Array.isArray(selectedContainerIds) && selectedContainerIds.length
+ ? selectedContainerIds
+ : backupRun.containers.map((item) => item.containerId));
+
+ const targets = backupRun.containers.filter((item) => item.status === 'ok' && allowedContainerIds.has(item.containerId));
+ if (!targets.length) {
+ throw new Error('Nenhum container valido foi selecionado para restore.');
+ }
+
+ const progress = {
+ profileId: profile.id,
+ profileName: profile.name,
+ operation: 'restore',
+ startedAt: new Date().toISOString(),
+ status: 'running',
+ overall: {
+ total: targets.length,
+ completed: 0,
+ pending: targets.length,
+ percent: 0,
+ },
+ currentContainer: null,
+ };
+
+ const emitProgress = () => {
+ onProgress(JSON.parse(JSON.stringify(progress)));
+ };
+
+ emitProgress();
+
+ const results = [];
+
+ for (const containerEntry of targets) {
+ const logs = [];
+ const pushLog = (message, step = 'restaurando') => {
+ const line = `[${new Date().toLocaleTimeString('pt-BR')}] ${message}`;
+ logs.push(line);
+ while (logs.length > 40) {
+ logs.shift();
+ }
+
+ progress.currentContainer = {
+ ...(progress.currentContainer || {}),
+ message,
+ step,
+ logs: [...logs],
+ };
+ emitProgress();
+ };
+
+ progress.currentContainer = {
+ containerId: containerEntry.containerId,
+ containerName: containerEntry.containerName,
+ status: 'running',
+ step: 'preparando',
+ message: 'Preparando restauracao do container.',
+ logs: [],
+ percent: 0,
+ file: {
+ current: 0,
+ total: 0,
+ currentFile: null,
+ percent: 0,
+ },
+ };
+ emitProgress();
+
+ try {
+ const chain = await this.store.getBackupsForContainer(profileId, containerEntry.containerId, backupId);
+ if (!chain.length || chain[0].mode !== 'full') {
+ throw new Error(`Nao existe cadeia full + incremental valida para ${containerEntry.containerName}.`);
+ }
+
+ pushLog(`Cadeia de restore encontrada com ${chain.length} arquivo(s).`, 'preparando');
+
+ const restoreInfo = await this.restoreContainer(profile, containerEntry, chain, {
+ onProgress: (snapshot) => {
+ progress.currentContainer = {
+ ...progress.currentContainer,
+ ...snapshot,
+ };
+ emitProgress();
+ },
+ pushLog,
+ });
+
+ progress.currentContainer = {
+ ...progress.currentContainer,
+ status: 'ok',
+ step: 'concluido',
+ message: 'Restore concluido com sucesso.',
+ percent: 100,
+ file: {
+ ...(progress.currentContainer?.file || {}),
+ percent: 100,
+ },
+ };
+ emitProgress();
+
+ results.push({
+ containerId: containerEntry.containerId,
+ containerName: containerEntry.containerName,
+ status: 'ok',
+ stats: restoreInfo?.stats || null,
+ });
+ } catch (error) {
+ pushLog(`Falha no restore: ${error.message}`, 'erro');
+ progress.currentContainer = {
+ ...progress.currentContainer,
+ status: 'error',
+ step: 'erro',
+ message: error.message,
+ percent: 100,
+ };
+ emitProgress();
+
+ results.push({
+ containerId: containerEntry.containerId,
+ containerName: containerEntry.containerName,
+ status: 'error',
+ error: error.message,
+ });
+ }
+
+ progress.overall.completed += 1;
+ progress.overall.pending = Math.max(0, progress.overall.total - progress.overall.completed);
+ progress.overall.percent = progress.overall.total
+ ? Math.round((progress.overall.completed / progress.overall.total) * 100)
+ : 100;
+ emitProgress();
+ }
+
+ progress.status = results.every((item) => item.status === 'ok') ? 'completed' : 'completed-with-errors';
+ progress.finishedAt = new Date().toISOString();
+ progress.currentContainer = null;
+ progress.overall.percent = 100;
+ progress.overall.pending = 0;
+ emitProgress();
+
+ return {
+ backupId,
+ status: results.every((item) => item.status === 'ok') ? 'ok' : 'partial',
+ containers: results,
+ };
+ }
+
+ async restoreContainer(profile, targetEntry, chain, options = {}) {
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
+ const pushLog = typeof options.pushLog === 'function' ? options.pushLog : () => {};
+
+ const inspect = await this.dockerService.inspectContainer(targetEntry.containerId);
+ const backupScope = normalizeBackupScope(targetEntry.backupScope || profile.backupScope);
+ const currentMounts = normalizeMounts(inspect);
+
+ if (backupScope === 'volumes' && !sameMountSignature(targetEntry.mountSignature, currentMounts)) {
+ throw new Error(`Os mounts atuais do container ${targetEntry.containerName} nao batem com o backup selecionado.`);
+ }
+
+ const runInDocker = this.dockerService.isRunningInDocker();
+ const useNativeRestore = runInDocker;
+
+ if (useNativeRestore) {
+ const backupRoot = normalizeContainerPath(profile.backupDir);
+ const originalWasRunning = inspect.State?.Running === true;
+ const restoreStats = { deleted: 0, created: 0, modified: 0 };
+
+ try {
+ if (originalWasRunning) {
+ pushLog('Container ativo detectado. Parando antes do restore.', 'preparando');
+ await this.dockerService.stopContainer(targetEntry.containerId);
+ }
+
+ if (backupScope === 'volumes') {
+ const restorePaths = (chain[0]?.backupPaths && chain[0].backupPaths.length)
+ ? chain[0].backupPaths
+ : currentMounts.map((mount) => mount.destination);
+
+ // Validar que todos os archives existem antes de tocar nos dados.
+ for (const entry of chain) {
+ await fs.access(path.posix.join(backupRoot, entry.archiveRelativePath));
+ }
+
+ // Restaurar via Docker API (putArchive) — funciona com container parado.
+ // O archive foi gerado com -C / incluindo os caminhos relativos dos volumes,
+ // portanto o destino do putArchive e sempre /.
+ for (const [index, entry] of chain.entries()) {
+ const absoluteArchivePath = path.posix.join(backupRoot, entry.archiveRelativePath);
+
+ onProgress({
+ step: 'restaurando',
+ file: {
+ current: index + 1,
+ total: chain.length,
+ currentFile: entry.archiveRelativePath,
+ percent: Math.round(((index + 1) / chain.length) * 100),
+ },
+ percent: Math.round(((index + 1) / chain.length) * 100),
+ });
+ pushLog(`Aplicando arquivo ${index + 1}/${chain.length}: ${entry.archiveRelativePath}`, 'restaurando');
+
+ await this.dockerService.putCompressedArchiveFromFile(
+ targetEntry.containerId,
+ '/',
+ absoluteArchivePath,
+ );
+ }
+
+ pushLog('Restore de volumes concluido.', 'finalizando');
+ } else {
+ // Escopo container inteiro.
+ for (const entry of chain) {
+ await fs.access(path.posix.join(backupRoot, entry.archiveRelativePath));
+ }
+
+ for (const [index, entry] of chain.entries()) {
+ const absoluteArchivePath = path.posix.join(backupRoot, entry.archiveRelativePath);
+
+ onProgress({
+ step: 'restaurando',
+ file: {
+ current: index + 1,
+ total: chain.length,
+ currentFile: entry.archiveRelativePath,
+ percent: Math.round(((index + 1) / chain.length) * 100),
+ },
+ percent: Math.round(((index + 1) / chain.length) * 100),
+ });
+ pushLog(`Aplicando arquivo ${index + 1}/${chain.length}: ${entry.archiveRelativePath}`, 'restaurando');
+
+ await this.dockerService.putCompressedArchiveFromFile(targetEntry.containerId, '/', absoluteArchivePath);
+ }
+
+ pushLog('Restore do container concluido.', 'finalizando');
+ }
+ } finally {
+ if (originalWasRunning) {
+ pushLog('Reiniciando container (estava ativo antes do restore).', 'finalizando');
+ await this.dockerService.startContainer(targetEntry.containerId).catch(() => null);
+ }
+ }
+
+ return { stats: restoreStats };
+ }
+
+ if (backupScope === 'container') {
+ throw new Error('Restore do container inteiro requer app executando via Docker.');
+ }
+
+ const backupRoot = normalizeDockerHostPath(profile.backupDir);
+ const wasRunning = inspect.State?.Running === true;
+ const binds = [`${backupRoot}:/backuproot:ro`];
+ for (const [index, mount] of currentMounts.entries()) {
+ binds.push(`${getMountBindingSource(mount)}:/restore/m${index}`);
+ }
+
+ const cleanupCommands = currentMounts.map((_mount, index) => (
+ `find ${shellQuote(`/restore/m${index}`)} -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`
+ ));
+
+ const restoreCommands = chain.map((entry) => (
+ `tar --listed-incremental=/dev/null -xzf ${shellQuote(`/backuproot/${entry.archiveRelativePath}`)} -C /restore`
+ ));
+
+ try {
+ if (wasRunning) {
+ await this.dockerService.stopContainer(targetEntry.containerId);
+ }
+
+ const cmd = ['set -eu', ...cleanupCommands, ...restoreCommands].join(' && ');
+ await this.dockerService.runHelper({ binds, cmd });
+ } finally {
+ if (wasRunning) {
+ await this.dockerService.startContainer(targetEntry.containerId).catch(() => null);
+ }
+ }
+
+ return { stats: null };
+ }
+}
+
+module.exports = BackupService;
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..b2a8528
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,11 @@
+const path = require('path');
+
+const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
+
+module.exports = {
+ port: Number(process.env.PORT || 3000),
+ dataDir,
+ storePath: path.join(dataDir, 'store.json'),
+ dockerSocketPath: process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock',
+ helperImage: process.env.HELPER_IMAGE || 'node:20-bookworm-slim',
+};
\ No newline at end of file
diff --git a/src/dockerService.js b/src/dockerService.js
new file mode 100644
index 0000000..1257ed7
--- /dev/null
+++ b/src/dockerService.js
@@ -0,0 +1,486 @@
+const Docker = require('dockerode');
+const { PassThrough } = require('stream');
+const fs = require('fs');
+const fsp = require('fs/promises');
+const path = require('path');
+const zlib = require('zlib');
+const { spawn } = require('child_process');
+const { pipeline } = require('stream/promises');
+
+function detectRunningInContainer() {
+ if (fs.existsSync('/.dockerenv')) {
+ return true;
+ }
+
+ try {
+ const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
+ return /(docker|containerd|kubepods|cri-o)/i.test(cgroup);
+ } catch {
+ return false;
+ }
+}
+
+function shellQuote(value) {
+ return `'${String(value).replace(/'/g, `"'"'`)}'`;
+}
+
+class DockerService {
+ constructor({ socketPath, helperImage }) {
+ this.docker = new Docker({ socketPath });
+ this.helperImage = helperImage;
+ this.runningInContainer = detectRunningInContainer();
+ }
+
+ isRunningInDocker() {
+ return this.runningInContainer;
+ }
+
+ async listContainers() {
+ const containers = await this.docker.listContainers({ all: true });
+ return containers
+ .map((container) => ({
+ id: container.Id,
+ name: (container.Names[0] || '').replace(/^\//, ''),
+ image: container.Image,
+ state: container.State,
+ status: container.Status,
+ }))
+ .sort((left, right) => left.name.localeCompare(right.name));
+ }
+
+ async inspectContainer(containerId) {
+ return this.docker.getContainer(containerId).inspect();
+ }
+
+ async stopContainer(containerId) {
+ await this.docker.getContainer(containerId).stop();
+ }
+
+ async startContainer(containerId) {
+ await this.docker.getContainer(containerId).start();
+ }
+
+ async ensureImage(imageName = this.helperImage) {
+ try {
+ await this.docker.getImage(imageName).inspect();
+ return;
+ } catch {
+ const stream = await this.docker.pull(imageName);
+ await new Promise((resolve, reject) => {
+ this.docker.modem.followProgress(stream, (error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+ }
+ }
+
+ async ensureHostDirectory(hostPath) {
+ const normalized = String(hostPath || '').trim().replace(/\\/g, '/');
+ if (!normalized || !normalized.startsWith('/')) {
+ throw new Error(`Diretorio de backup invalido: ${hostPath}`);
+ }
+
+ if (this.runningInContainer) {
+ throw new Error('App rodando em container nao deve criar helper para backup/restore.');
+ }
+
+ await this.ensureImage();
+
+ const container = await this.docker.createContainer({
+ Image: this.helperImage,
+ Cmd: ['sh', '-lc', `mkdir -p ${shellQuote(`/hostfs${normalized}`)}`],
+ Tty: false,
+ HostConfig: {
+ Binds: ['/:/hostfs'],
+ AutoRemove: false,
+ NetworkMode: 'none',
+ },
+ });
+
+ try {
+ await container.start();
+ const [result, logs] = await Promise.all([
+ container.wait(),
+ container.logs({ stdout: true, stderr: true, follow: false }),
+ ]);
+
+ if (result.StatusCode !== 0) {
+ throw new Error(logs.toString('utf8').trim() || 'Falha ao criar diretorio de backup no host.');
+ }
+ } finally {
+ try {
+ await container.remove({ force: true });
+ } catch {
+ }
+ }
+ }
+
+ async ensureLocalDirectory(localPath) {
+ const normalized = String(localPath || '').trim().replace(/\\/g, '/');
+ if (!normalized || !normalized.startsWith('/')) {
+ throw new Error(`Diretorio de backup invalido: ${localPath}`);
+ }
+
+ await fsp.mkdir(normalized, { recursive: true });
+ }
+
+ async exportContainerPathArchive(containerId, containerPath, targetFilePath) {
+ const container = this.docker.getContainer(containerId);
+ const archiveStream = await container.getArchive({ path: containerPath });
+ await fsp.mkdir(path.dirname(targetFilePath), { recursive: true });
+ await pipeline(archiveStream, fs.createWriteStream(targetFilePath));
+ }
+
+ async putArchiveFromFile(containerId, destinationPath, sourceFilePath) {
+ const container = this.docker.getContainer(containerId);
+ const source = fs.createReadStream(sourceFilePath);
+ await container.putArchive(source, { path: destinationPath });
+ }
+
+ async putCompressedArchiveFromFile(containerId, destinationPath, sourceFilePath) {
+ const container = this.docker.getContainer(containerId);
+ const source = fs.createReadStream(sourceFilePath).pipe(zlib.createGunzip());
+ await container.putArchive(source, { path: destinationPath });
+ }
+
+ async runContainerCommand(containerId, cmd) {
+ const container = this.docker.getContainer(containerId);
+ const exec = await container.exec({
+ AttachStdout: true,
+ AttachStderr: true,
+ Tty: false,
+ Cmd: ['sh', '-lc', cmd],
+ });
+
+ const stream = await exec.start({ hijack: true, stdin: false });
+ const stdoutStream = new PassThrough();
+ const stderrStream = new PassThrough();
+ this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
+
+ let output = '';
+ stdoutStream.on('data', (chunk) => {
+ output += chunk.toString('utf8');
+ });
+ stderrStream.on('data', (chunk) => {
+ output += chunk.toString('utf8');
+ });
+
+ let info = await exec.inspect();
+ while (info.Running) {
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ info = await exec.inspect();
+ }
+
+ if (typeof stream.destroy === 'function') {
+ stream.destroy();
+ }
+ stdoutStream.end();
+ stderrStream.end();
+
+ const trimmed = output.trim();
+ if (info.ExitCode !== 0) {
+ throw new Error(output.trim() || `Comando em container terminou com codigo ${info.ExitCode}`);
+ }
+
+ return trimmed;
+ }
+
+ async streamContainerCommandToFile(containerId, cmd, targetFilePath, options = {}) {
+ const onOutput = typeof options.onOutput === 'function' ? options.onOutput : () => {};
+ const container = this.docker.getContainer(containerId);
+ const exec = await container.exec({
+ AttachStdout: true,
+ AttachStderr: true,
+ Tty: false,
+ Cmd: ['sh', '-lc', cmd],
+ });
+
+ const stream = await exec.start({ hijack: true, stdin: false });
+ const stdoutStream = new PassThrough();
+ const stderrStream = new PassThrough();
+ this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
+
+ await fsp.mkdir(path.dirname(targetFilePath), { recursive: true });
+ const writeStream = fs.createWriteStream(targetFilePath);
+ stdoutStream.pipe(writeStream);
+
+ const stderrDone = new Promise((resolve) => {
+ let buffer = '';
+
+ stderrStream.on('data', (chunk) => {
+ const text = chunk.toString('utf8');
+ buffer += text;
+ const parts = buffer.split(/\r?\n/);
+ buffer = parts.pop() || '';
+ for (const line of parts) {
+ onOutput(line, 'stderr');
+ }
+ });
+
+ stderrStream.on('end', () => {
+ if (buffer) {
+ onOutput(buffer, 'stderr');
+ }
+ resolve();
+ });
+ });
+
+ let info = await exec.inspect();
+ while (info.Running) {
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ info = await exec.inspect();
+ }
+
+ if (typeof stream.destroy === 'function') {
+ stream.destroy();
+ }
+ stdoutStream.end();
+ stderrStream.end();
+
+ await Promise.all([
+ stderrDone,
+ new Promise((resolve, reject) => {
+ writeStream.on('finish', resolve);
+ writeStream.on('error', reject);
+ }),
+ ]);
+
+ if (info.ExitCode !== 0) {
+ throw new Error(`Comando de stream em container terminou com codigo ${info.ExitCode}`);
+ }
+ }
+
+ async copyFileToContainer(containerId, sourceFilePath, destinationDirInContainer) {
+ const container = this.docker.getContainer(containerId);
+ const tarProc = spawn('tar', ['-cf', '-', '-C', path.dirname(sourceFilePath), path.basename(sourceFilePath)], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let stderr = '';
+ tarProc.stderr.on('data', (chunk) => {
+ stderr += chunk.toString('utf8');
+ });
+
+ await container.putArchive(tarProc.stdout, { path: destinationDirInContainer });
+
+ await new Promise((resolve, reject) => {
+ tarProc.on('close', (code) => {
+ if (code === 0) {
+ resolve();
+ return;
+ }
+
+ reject(new Error(stderr.trim() || `Falha ao empacotar arquivo para copia (codigo ${code})`));
+ });
+ tarProc.on('error', reject);
+ });
+ }
+
+ async streamArchiveToContainer(containerId, archiveFilePath) {
+ const container = this.docker.getContainer(containerId);
+ const exec = await container.exec({
+ AttachStdin: true,
+ AttachStdout: false,
+ AttachStderr: true,
+ Tty: false,
+ Cmd: ['sh', '-lc', 'tar --listed-incremental=/dev/null -xzf - -C /'],
+ });
+
+ const attachStream = await exec.start({ hijack: true, stdin: true });
+ const stderrStream = new PassThrough();
+ this.docker.modem.demuxStream(attachStream, new PassThrough(), stderrStream);
+
+ let stderrOutput = '';
+ stderrStream.on('data', (chunk) => {
+ stderrOutput += chunk.toString('utf8');
+ });
+
+ // Pipar o arquivo diretamente para o stdin do tar no container
+ const fileReadStream = fs.createReadStream(archiveFilePath);
+ await new Promise((resolve, reject) => {
+ fileReadStream.on('error', reject);
+ fileReadStream.on('end', () => {
+ try { attachStream.end(); } catch { /* ignore */ }
+ resolve();
+ });
+ fileReadStream.pipe(attachStream, { end: false });
+ });
+
+ // Aguardar tar finalizar
+ let info = await exec.inspect();
+ while (info.Running) {
+ await new Promise((r) => setTimeout(r, 120));
+ info = await exec.inspect();
+ }
+
+ if (typeof attachStream.destroy === 'function') {
+ attachStream.destroy();
+ }
+
+ if (info.ExitCode !== 0) {
+ throw new Error(stderrOutput.trim() || `Falha ao restaurar archive no container (codigo ${info.ExitCode})`);
+ }
+ }
+
+ async copyFileFromContainer(containerId, containerFilePath, localFilePath) {
+ const container = this.docker.getContainer(containerId);
+ let archiveStream;
+ try {
+ archiveStream = await container.getArchive({ path: containerFilePath });
+ } catch {
+ return false;
+ }
+
+ await fsp.mkdir(path.dirname(localFilePath), { recursive: true });
+
+ await new Promise((resolve, reject) => {
+ const tarProc = spawn('tar', ['-xOf', '-'], { stdio: ['pipe', 'pipe', 'pipe'] });
+ const writeStream = fs.createWriteStream(localFilePath);
+ archiveStream.pipe(tarProc.stdin);
+ tarProc.stdout.pipe(writeStream);
+ tarProc.on('close', (code) => {
+ if (code === 0) { resolve(); return; }
+ reject(new Error(`Falha ao extrair arquivo do container (codigo ${code})`));
+ });
+ tarProc.on('error', reject);
+ writeStream.on('error', reject);
+ });
+
+ return true;
+ }
+
+ async runHostCommand({ cmd, onOutput }) {
+ await new Promise((resolve, reject) => {
+ const child = spawn('sh', ['-lc', cmd], { stdio: ['ignore', 'pipe', 'pipe'] });
+ let output = '';
+
+ const streamOutput = (stream, streamName) => {
+ let buffer = '';
+
+ stream.on('data', (chunk) => {
+ const text = chunk.toString('utf8');
+ output += text;
+
+ if (typeof onOutput !== 'function') {
+ return;
+ }
+
+ buffer += text;
+ const parts = buffer.split(/\r?\n/);
+ buffer = parts.pop() || '';
+ for (const line of parts) {
+ onOutput(line, streamName);
+ }
+ });
+
+ stream.on('end', () => {
+ if (buffer && typeof onOutput === 'function') {
+ onOutput(buffer, streamName);
+ }
+ });
+ };
+
+ streamOutput(child.stdout, 'stdout');
+ streamOutput(child.stderr, 'stderr');
+
+ child.on('error', (error) => {
+ reject(error);
+ });
+
+ child.on('close', (code) => {
+ const trimmedOutput = output.trim();
+ if (code !== 0) {
+ reject(new Error(trimmedOutput || `Comando local terminou com codigo ${code}`));
+ return;
+ }
+
+ resolve(trimmedOutput);
+ });
+ });
+ }
+
+ async runHelper({ binds, cmd, onOutput }) {
+ await this.ensureImage();
+
+ const container = await this.docker.createContainer({
+ Image: this.helperImage,
+ Cmd: ['sh', '-lc', cmd],
+ Tty: false,
+ HostConfig: {
+ Binds: binds,
+ AutoRemove: false,
+ NetworkMode: 'none',
+ },
+ });
+
+ let attachStream;
+
+ try {
+ attachStream = await container.attach({ stream: true, stdout: true, stderr: true });
+ const stdoutStream = new PassThrough();
+ const stderrStream = new PassThrough();
+ this.docker.modem.demuxStream(attachStream, stdoutStream, stderrStream);
+
+ const parseOutput = (stream, streamName) => new Promise((resolve) => {
+ let buffer = '';
+
+ stream.on('data', (chunk) => {
+ const text = chunk.toString('utf8');
+ output += text;
+
+ if (typeof onOutput !== 'function') {
+ return;
+ }
+
+ buffer += text;
+ const parts = buffer.split(/\r?\n/);
+ buffer = parts.pop() || '';
+
+ for (const line of parts) {
+ onOutput(line, streamName);
+ }
+ });
+
+ stream.on('end', () => {
+ if (buffer && typeof onOutput === 'function') {
+ onOutput(buffer, streamName);
+ }
+ resolve();
+ });
+ });
+
+ let output = '';
+ const outputPromises = [
+ parseOutput(stdoutStream, 'stdout'),
+ parseOutput(stderrStream, 'stderr'),
+ ];
+
+ await container.start();
+ const result = await container.wait();
+ await Promise.all(outputPromises);
+
+ output = output.trim();
+
+ if (result.StatusCode !== 0) {
+ throw new Error(output || `Helper container terminou com codigo ${result.StatusCode}`);
+ }
+
+ return output;
+ } finally {
+ if (attachStream && typeof attachStream.destroy === 'function') {
+ attachStream.destroy();
+ }
+
+ try {
+ await container.remove({ force: true });
+ } catch {
+ }
+ }
+ }
+}
+
+module.exports = DockerService;
\ No newline at end of file
diff --git a/src/server.js b/src/server.js
new file mode 100644
index 0000000..0ed0f6b
--- /dev/null
+++ b/src/server.js
@@ -0,0 +1,271 @@
+const express = require('express');
+const path = require('path');
+const fs = require('fs/promises');
+const crypto = require('crypto');
+
+const config = require('./config');
+const JsonStore = require('./store');
+const DockerService = require('./dockerService');
+const BackupService = require('./backupService');
+
+async function main() {
+ await fs.mkdir(config.dataDir, { recursive: true });
+
+ const store = new JsonStore(config.storePath);
+ await store.init();
+
+ const dockerService = new DockerService({
+ socketPath: config.dockerSocketPath,
+ helperImage: config.helperImage,
+ });
+
+ const backupService = new BackupService({ dockerService, store });
+ const runJobs = new Map();
+
+ const app = express();
+
+ app.use(express.json());
+ app.use(express.static(path.join(process.cwd(), 'public')));
+
+ app.get('/api/health', (_request, response) => {
+ response.json({ ok: true });
+ });
+
+ app.get('/api/containers', async (_request, response) => {
+ try {
+ const containers = await dockerService.listContainers();
+ response.json(containers);
+ } catch (error) {
+ response.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/profiles', async (_request, response) => {
+ try {
+ const profiles = await store.listProfiles();
+ response.json(profiles);
+ } catch (error) {
+ response.status(500).json({ error: error.message });
+ }
+ });
+
+ app.post('/api/profiles', async (request, response) => {
+ try {
+ const payload = request.body || {};
+ if (!payload.name || !payload.backupDir || !Array.isArray(payload.containerIds) || !payload.containerIds.length) {
+ response.status(400).json({ error: 'Informe nome, diretorio de backup e ao menos um container.' });
+ return;
+ }
+
+ if (payload.backupScope && !['volumes', 'container'].includes(payload.backupScope)) {
+ response.status(400).json({ error: 'Tipo de backup invalido.' });
+ return;
+ }
+
+ const existing = payload.id ? await store.getProfile(payload.id) : null;
+ const profile = await store.saveProfile({
+ id: payload.id,
+ createdAt: existing?.createdAt,
+ name: payload.name.trim(),
+ backupDir: payload.backupDir.trim(),
+ containerIds: payload.containerIds,
+ mode: existing?.mode || 'full',
+ backupScope: payload.backupScope || existing?.backupScope || 'volumes',
+ });
+
+ response.status(payload.id ? 200 : 201).json(profile);
+ } catch (error) {
+ response.status(500).json({ error: error.message });
+ }
+ });
+
+ app.delete('/api/profiles/:profileId', async (request, response) => {
+ try {
+ const profile = await store.getProfile(request.params.profileId);
+ await store.deleteProfile(request.params.profileId);
+
+ if (profile?.backupDir) {
+ const slugify = (value) => value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item';
+ const profileBackupDir = path.join(profile.backupDir, slugify(profile.name));
+ await fs.rm(profileBackupDir, { recursive: true, force: true });
+ }
+
+ response.status(204).end();
+ } catch (error) {
+ response.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/profiles/:profileId/backups', async (request, response) => {
+ try {
+ const backups = await store.listBackups(request.params.profileId);
+ response.json(backups);
+ } catch (error) {
+ response.status(500).json({ error: error.message });
+ }
+ });
+
+ app.post('/api/profiles/:profileId/run', async (request, response) => {
+ try {
+ const profileId = request.params.profileId;
+ const requestedMode = request.body?.mode;
+ if (requestedMode && !['full', 'incremental'].includes(requestedMode)) {
+ response.status(400).json({ error: 'Modo de backup invalido.' });
+ return;
+ }
+
+ const runningJob = [...runJobs.values()].find((job) => job.profileId === profileId && job.status === 'running');
+ if (runningJob) {
+ response.status(409).json({ error: 'Ja existe um backup em execucao para este profile.', runId: runningJob.id });
+ return;
+ }
+
+ const runId = crypto.randomUUID();
+ const job = {
+ id: runId,
+ profileId,
+ kind: 'backup',
+ status: 'running',
+ startedAt: new Date().toISOString(),
+ progress: null,
+ result: null,
+ error: null,
+ };
+
+ runJobs.set(runId, job);
+
+ void backupService.runProfile(profileId, {
+ mode: requestedMode,
+ onProgress: (progressSnapshot) => {
+ const currentJob = runJobs.get(runId);
+ if (!currentJob) {
+ return;
+ }
+
+ currentJob.progress = progressSnapshot;
+ },
+ }).then((backupRun) => {
+ const currentJob = runJobs.get(runId);
+ if (!currentJob) {
+ return;
+ }
+
+ currentJob.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors';
+ currentJob.result = backupRun;
+ currentJob.finishedAt = new Date().toISOString();
+ }).catch((error) => {
+ const currentJob = runJobs.get(runId);
+ if (!currentJob) {
+ return;
+ }
+
+ currentJob.status = 'error';
+ currentJob.error = error.message;
+ currentJob.finishedAt = new Date().toISOString();
+ });
+
+ response.status(202).json({ runId, status: 'running' });
+ } catch (error) {
+ response.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/api/runs/:runId', (request, response) => {
+ const job = runJobs.get(request.params.runId);
+ if (!job) {
+ response.status(404).json({ error: 'Execucao nao encontrada.' });
+ return;
+ }
+
+ response.json({
+ id: job.id,
+ profileId: job.profileId,
+ kind: job.kind || 'backup',
+ status: job.status,
+ startedAt: job.startedAt,
+ finishedAt: job.finishedAt,
+ progress: job.progress,
+ result: job.result,
+ error: job.error,
+ });
+ });
+
+ app.post('/api/profiles/:profileId/restore', async (request, response) => {
+ try {
+ const profileId = request.params.profileId;
+ if (!request.body?.backupId) {
+ response.status(400).json({ error: 'Informe o backup a ser restaurado.' });
+ return;
+ }
+
+ const selectedContainerIds = request.body?.containerIds;
+ if (selectedContainerIds && (!Array.isArray(selectedContainerIds) || !selectedContainerIds.length)) {
+ response.status(400).json({ error: 'Selecione ao menos um container para restaurar.' });
+ return;
+ }
+
+ const runningJob = [...runJobs.values()].find((job) => job.profileId === profileId && job.status === 'running');
+ if (runningJob) {
+ response.status(409).json({ error: 'Ja existe uma execucao em andamento para este profile.', runId: runningJob.id });
+ return;
+ }
+
+ const runId = crypto.randomUUID();
+ const job = {
+ id: runId,
+ profileId,
+ kind: 'restore',
+ status: 'running',
+ startedAt: new Date().toISOString(),
+ progress: null,
+ result: null,
+ error: null,
+ };
+
+ runJobs.set(runId, job);
+
+ void backupService.restoreBackup(profileId, request.body.backupId, {
+ selectedContainerIds,
+ onProgress: (progressSnapshot) => {
+ const currentJob = runJobs.get(runId);
+ if (!currentJob) {
+ return;
+ }
+
+ currentJob.progress = progressSnapshot;
+ },
+ }).then((restoreResult) => {
+ const currentJob = runJobs.get(runId);
+ if (!currentJob) {
+ return;
+ }
+
+ currentJob.status = restoreResult.status === 'ok' ? 'completed' : 'completed-with-errors';
+ currentJob.result = restoreResult;
+ currentJob.finishedAt = new Date().toISOString();
+ }).catch((error) => {
+ const currentJob = runJobs.get(runId);
+ if (!currentJob) {
+ return;
+ }
+
+ currentJob.status = 'error';
+ currentJob.error = error.message;
+ currentJob.finishedAt = new Date().toISOString();
+ });
+
+ response.status(202).json({ runId, status: 'running', kind: 'restore' });
+ } catch (error) {
+ response.status(500).json({ error: error.message });
+ }
+ });
+
+ app.listen(config.port, () => {
+ console.log(`Docker Backup app ouvindo na porta ${config.port}`);
+ });
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
\ No newline at end of file
diff --git a/src/store.js b/src/store.js
new file mode 100644
index 0000000..457bbe1
--- /dev/null
+++ b/src/store.js
@@ -0,0 +1,157 @@
+const fs = require('fs/promises');
+const path = require('path');
+const { randomUUID } = require('crypto');
+
+class JsonStore {
+ constructor(filePath) {
+ this.filePath = filePath;
+ this.writeQueue = Promise.resolve();
+ }
+
+ async init() {
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
+ try {
+ await fs.access(this.filePath);
+ } catch {
+ await fs.writeFile(this.filePath, JSON.stringify({ profiles: [], backups: [] }, null, 2));
+ }
+ }
+
+ async read() {
+ const raw = await fs.readFile(this.filePath, 'utf8');
+ const parsed = JSON.parse(raw);
+ parsed.profiles ||= [];
+ parsed.backups ||= [];
+ return parsed;
+ }
+
+ async write(mutator) {
+ this.writeQueue = this.writeQueue.then(async () => {
+ const current = await this.read();
+ const next = await mutator(current);
+ await fs.writeFile(this.filePath, JSON.stringify(next, null, 2));
+ return next;
+ });
+
+ return this.writeQueue;
+ }
+
+ async listProfiles() {
+ const data = await this.read();
+ return data.profiles;
+ }
+
+ async getProfile(profileId) {
+ const data = await this.read();
+ return data.profiles.find((profile) => profile.id === profileId) || null;
+ }
+
+ async saveProfile(profileInput) {
+ const now = new Date().toISOString();
+ const profile = {
+ id: profileInput.id || randomUUID(),
+ name: profileInput.name,
+ containerIds: profileInput.containerIds,
+ mode: profileInput.mode,
+ backupScope: profileInput.backupScope || 'volumes',
+ backupDir: profileInput.backupDir,
+ updatedAt: now,
+ createdAt: profileInput.createdAt || now,
+ };
+
+ await this.write((data) => {
+ const index = data.profiles.findIndex((item) => item.id === profile.id);
+ if (index >= 0) {
+ data.profiles[index] = profile;
+ } else {
+ data.profiles.push(profile);
+ }
+ return data;
+ });
+
+ return profile;
+ }
+
+ async deleteProfile(profileId) {
+ await this.write((data) => {
+ data.profiles = data.profiles.filter((profile) => profile.id !== profileId);
+ data.backups = data.backups.filter((backup) => backup.profileId !== profileId);
+ return data;
+ });
+ }
+
+ async addBackup(backupRun) {
+ await this.write((data) => {
+ data.backups.unshift(backupRun);
+ return data;
+ });
+ return backupRun;
+ }
+
+ async listBackups(profileId) {
+ const data = await this.read();
+ return data.backups.filter((backup) => backup.profileId === profileId);
+ }
+
+ async getBackup(backupId) {
+ const data = await this.read();
+ return data.backups.find((backup) => backup.id === backupId) || null;
+ }
+
+ async getBackupsForContainer(profileId, containerId, upToBackupId) {
+ const backups = await this.listBackups(profileId);
+ const ordered = backups
+ .slice()
+ .sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime());
+
+ const chain = [];
+
+ for (const backup of ordered) {
+ const containerBackup = backup.containers.find(
+ (item) => item.containerId === containerId && item.status === 'ok' && item.archiveRelativePath,
+ );
+
+ if (!containerBackup) {
+ if (backup.id === upToBackupId) {
+ return chain;
+ }
+ continue;
+ }
+
+ if (containerBackup.mode === 'full') {
+ chain.length = 0;
+ }
+
+ chain.push({
+ backupId: backup.id,
+ createdAt: backup.createdAt,
+ ...containerBackup,
+ });
+
+ if (backup.id === upToBackupId) {
+ return chain;
+ }
+ }
+
+ return [];
+ }
+ async getLastContainerBackupTime(profileId, containerId) {
+ const backups = await this.listBackups(profileId);
+ const ordered = backups
+ .slice()
+ .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime());
+
+ for (const backup of ordered) {
+ const found = backup.containers.find(
+ (item) => item.containerId === containerId && item.status === 'ok',
+ );
+ if (found) {
+ return backup.createdAt;
+ }
+ }
+
+ return null;
+ }
+}
+
+module.exports = JsonStore;
\ No newline at end of file