atualiza versão para 0.2.5; adiciona visualizador de snapshot de backup, extração seletiva de arquivos e busca de arquivos no snapshot

This commit is contained in:
Alexander Sabino 2026-05-12 11:13:08 +01:00
parent c6c0db01f1
commit dac4c98e18
6 changed files with 576 additions and 4 deletions

View File

@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/VERSION-0.2.2-blue?style=flat-square" /> <img src="https://img.shields.io/badge/VERSION-0.2.5-blue?style=flat-square" />
<img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" /> <img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" />
<img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" /> <img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" /> <img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" />
@ -18,12 +18,23 @@
> ⚠️ **AVISO CRÍTICO:** Aplicação em estágio inicial de desenvolvimento. Não use em produção — há risco de perda de dados. > ⚠️ **AVISO CRÍTICO:** Aplicação em estágio inicial de desenvolvimento. Não use em produção — há risco de perda de dados.
Versão atual: **0.2.2** Versão atual: **0.2.5**
--- ---
## 📋 Changelog ## 📋 Changelog
### [0.2.5] — 2026-05-12
#### Adicionado
- **Visualizador de snapshot de backup:** botão de ícone em cada linha de backup abre um modal com a lista completa de arquivos do archive, refletindo o estado final após restore (incluindo cadeia full + incrementais).
- **Extração seletiva de arquivos:** dentro do modal de snapshot é possível selecionar um ou mais arquivos individualmente (ou todos de uma vez) e baixá-los como `.tar.gz`.
- **Busca de arquivos no snapshot:** campo de filtro em tempo real para localizar arquivos pelo nome dentro do modal.
- **Abas por container:** quando um backup contém múltiplos containers, o modal exibe abas para navegar entre eles.
- **Suporte à cadeia incremental:** a listagem de arquivos mescla automaticamente full + incrementais, garantindo que a visão do snapshot corresponda ao estado real restaurado.
---
### [0.2.2] — 2026-05-12 ### [0.2.2] — 2026-05-12
#### Corrigido #### Corrigido

View File

@ -1,6 +1,6 @@
{ {
"name": "dockerbackup-app", "name": "dockerbackup-app",
"version": "0.2.2", "version": "0.2.5",
"description": "Aplicacao web para backup e restauracao de volumes Docker", "description": "Aplicacao web para backup e restauracao de volumes Docker",
"main": "src/server.js", "main": "src/server.js",
"scripts": { "scripts": {

View File

@ -119,6 +119,17 @@ const elements = {
sourceFormPort: document.querySelector('#sourceFormPort'), sourceFormPort: document.querySelector('#sourceFormPort'),
sourcesList: document.querySelector('#sourcesList'), sourcesList: document.querySelector('#sourcesList'),
profileSourceSelect: document.querySelector('#profileSourceId'), profileSourceSelect: document.querySelector('#profileSourceId'),
// snapshot modal
snapshotModal: document.querySelector('#snapshotModal'),
snapshotModalSubtitle: document.querySelector('#snapshotModalSubtitle'),
snapshotContainerTabs: document.querySelector('#snapshotContainerTabs'),
snapshotSearch: document.querySelector('#snapshotSearch'),
snapshotStats: document.querySelector('#snapshotStats'),
snapshotLoading: document.querySelector('#snapshotLoading'),
snapshotFileList: document.querySelector('#snapshotFileList'),
snapshotSelectAll: document.querySelector('#snapshotSelectAll'),
snapshotExtract: document.querySelector('#snapshotExtract'),
snapshotModalClose: document.querySelector('#snapshotModalClose'),
}; };
// ─── View navigation ────────────────────────────────────── // ─── View navigation ──────────────────────────────────────
@ -776,6 +787,220 @@ function renderServers() {
// Servers view was removed // Servers view was removed
} }
// ─── Snapshot modal (browse de arquivos do backup) ───────────
const snapshotState = {
backup: null,
profile: null,
activeContainerId: null,
filesByContainer: {},
};
async function openSnapshotModal(backupId, profileId) {
snapshotState.backup = null;
snapshotState.filesByContainer = {};
snapshotState.activeContainerId = null;
let backup;
try {
backup = await api(`/api/backups/${encodeURIComponent(backupId)}`);
} catch (error) {
showToast(`Erro ao carregar backup: ${error.message}`, true);
return;
}
const profile = state.profiles.find((p) => p.id === profileId) || { id: profileId, name: profileId };
snapshotState.backup = backup;
snapshotState.profile = profile;
const modeLabel = backup.mode === 'full' ? 'Full' : 'Incremental';
elements.snapshotModalSubtitle.textContent =
`${escapeHtml(profile.name)}${new Date(backup.createdAt).toLocaleString('pt-BR')}${modeLabel}`;
const containers = (backup.containers || []).filter((c) => c.status === 'ok' && c.archiveRelativePath);
if (!containers.length) {
showToast('Backup sem containers com arquivos disponíveis.', true);
return;
}
elements.snapshotSearch.value = '';
elements.snapshotFileList.innerHTML = '';
elements.snapshotStats.textContent = '';
elements.snapshotExtract.disabled = true;
elements.snapshotExtract.textContent = 'Extrair selecionados';
// Build container tabs
if (containers.length > 1) {
elements.snapshotContainerTabs.classList.remove('hidden');
elements.snapshotContainerTabs.innerHTML = containers.map((c, i) => `
<button class="snapshot-tab${i === 0 ? ' active' : ''}"
data-container-id="${escapeHtml(c.containerId)}">${escapeHtml(c.containerName)}</button>
`).join('');
elements.snapshotContainerTabs.querySelectorAll('.snapshot-tab').forEach((btn) => {
btn.addEventListener('click', () => {
elements.snapshotContainerTabs.querySelectorAll('.snapshot-tab').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
loadSnapshotContainerFiles(btn.dataset.containerId);
});
});
} else {
elements.snapshotContainerTabs.classList.add('hidden');
}
elements.snapshotModal.classList.remove('hidden');
elements.snapshotModal.setAttribute('aria-hidden', 'false');
await loadSnapshotContainerFiles(containers[0].containerId);
}
async function loadSnapshotContainerFiles(containerId) {
snapshotState.activeContainerId = containerId;
elements.snapshotLoading.classList.remove('hidden');
elements.snapshotFileList.innerHTML = '';
elements.snapshotStats.textContent = '';
elements.snapshotExtract.disabled = true;
elements.snapshotExtract.textContent = 'Extrair selecionados';
elements.snapshotSearch.value = '';
try {
if (!snapshotState.filesByContainer[containerId]) {
const result = await api(
`/api/backups/${encodeURIComponent(snapshotState.backup.id)}/containers/${encodeURIComponent(containerId)}/files`,
);
snapshotState.filesByContainer[containerId] = result;
}
elements.snapshotLoading.classList.add('hidden');
renderSnapshotFiles(snapshotState.filesByContainer[containerId].files, '');
} catch (error) {
elements.snapshotLoading.classList.add('hidden');
elements.snapshotFileList.innerHTML = `<div class="snapshot-empty">Erro ao carregar: ${escapeHtml(error.message)}</div>`;
}
}
function renderSnapshotFiles(files, filter) {
const lc = filter.toLowerCase();
const visible = filter ? files.filter((f) => !f.isDir && f.name.toLowerCase().includes(lc)) : files.filter((f) => !f.isDir);
const fileCount = visible.length;
const totalSize = visible.reduce((s, f) => s + (f.size || 0), 0);
elements.snapshotStats.textContent = `${fileCount} arquivo(s) — ${formatBytes(totalSize)}`;
if (!visible.length) {
elements.snapshotFileList.innerHTML = '<div class="snapshot-empty">Nenhum arquivo encontrado.</div>';
updateSnapshotExtractBtn();
return;
}
// Group by directory
const groups = new Map();
for (const file of visible) {
const slash = file.name.lastIndexOf('/');
const dir = slash >= 0 ? file.name.slice(0, slash) : '';
if (!groups.has(dir)) groups.set(dir, []);
groups.get(dir).push(file);
}
const sortedGroups = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
const folderIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" style="flex-shrink:0"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
const rootIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" style="flex-shrink:0"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`;
elements.snapshotFileList.innerHTML = sortedGroups.map(([dir, dirFiles]) => {
const header = dir
? `<div class="snapshot-dir-header">${folderIcon}<span>${escapeHtml(dir)}/</span></div>`
: `<div class="snapshot-dir-header">${rootIcon}<span>(raiz)</span></div>`;
const rows = dirFiles.map((file) => {
const shortName = dir ? file.name.slice(dir.length + 1) : file.name;
return `<label class="snapshot-file-item">
<input type="checkbox" name="snapshotFile" value="${escapeHtml(file.name)}" />
<span class="snapshot-file-name" title="${escapeHtml(file.name)}">${escapeHtml(shortName)}</span>
<span class="snapshot-file-size">${formatBytes(file.size || 0)}</span>
<span class="snapshot-file-mtime">${escapeHtml(file.mtime || '')}</span>
</label>`;
}).join('');
return `<div class="snapshot-dir-group">${header}${rows}</div>`;
}).join('');
elements.snapshotFileList.querySelectorAll('input[name="snapshotFile"]').forEach((cb) => {
cb.addEventListener('change', updateSnapshotExtractBtn);
});
updateSnapshotExtractBtn();
}
function updateSnapshotExtractBtn() {
const checked = elements.snapshotFileList.querySelectorAll('input[name="snapshotFile"]:checked').length;
elements.snapshotExtract.disabled = checked === 0;
elements.snapshotExtract.textContent = checked > 0 ? `Extrair ${checked} arquivo(s)` : 'Extrair selecionados';
}
function closeSnapshotModal() {
elements.snapshotModal.classList.add('hidden');
elements.snapshotModal.setAttribute('aria-hidden', 'true');
}
elements.snapshotModalClose?.addEventListener('click', closeSnapshotModal);
elements.snapshotModal?.querySelector('[data-action="close-snapshot-modal"]')?.addEventListener('click', closeSnapshotModal);
elements.snapshotSearch?.addEventListener('input', () => {
const data = snapshotState.filesByContainer[snapshotState.activeContainerId];
if (data) renderSnapshotFiles(data.files, elements.snapshotSearch.value);
});
elements.snapshotSelectAll?.addEventListener('click', () => {
const checkboxes = elements.snapshotFileList.querySelectorAll('input[name="snapshotFile"]:not(:disabled)');
const allChecked = [...checkboxes].every((c) => c.checked);
checkboxes.forEach((c) => { c.checked = !allChecked; });
updateSnapshotExtractBtn();
});
elements.snapshotExtract?.addEventListener('click', async () => {
const checked = [...elements.snapshotFileList.querySelectorAll('input[name="snapshotFile"]:checked')].map((c) => c.value);
if (!checked.length) return;
const containerId = snapshotState.activeContainerId;
const backupId = snapshotState.backup.id;
elements.snapshotExtract.disabled = true;
elements.snapshotExtract.textContent = 'Preparando...';
try {
const token = localStorage.getItem('authToken');
const resp = await fetch(
`/api/backups/${encodeURIComponent(backupId)}/containers/${encodeURIComponent(containerId)}/extract`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'x-auth-token': token } : {}),
},
body: JSON.stringify({ paths: checked }),
},
);
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: 'Erro ao extrair arquivos.' }));
throw new Error(err.error || 'Erro ao extrair arquivos.');
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const cd = resp.headers.get('Content-Disposition') || '';
const fnMatch = cd.match(/filename="?([^"]+)"?/);
a.download = fnMatch ? fnMatch[1] : 'extract.tar.gz';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`${checked.length} arquivo(s) extraído(s) com sucesso.`);
} catch (error) {
showToast(`Erro ao extrair: ${error.message}`, true);
} finally {
updateSnapshotExtractBtn();
}
});
async function renderBackupsView() { async function renderBackupsView() {
const host = document.querySelector('#backupsViewList'); const host = document.querySelector('#backupsViewList');
if (!host) return; if (!host) return;
@ -835,6 +1060,13 @@ function renderBackupRow(b, profile, isFull) {
<td><span class="status-badge status-badge--${escapeHtml(b.status)}">${escapeHtml(b.status)}</span></td> <td><span class="status-badge status-badge--${escapeHtml(b.status)}">${escapeHtml(b.status)}</span></td>
<td>${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))}</td> <td>${escapeHtml((b.containers || []).map((c) => c.containerName).join(', '))}</td>
<td> <td>
<button
class="btn btn--secondary btn--sm btn--icon"
data-action="browse-backup"
data-profile-id="${escapeHtml(profile.id)}"
data-backup-id="${escapeHtml(b.id)}"
title="Ver arquivos do backup"
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></button>
<button <button
class="btn btn--secondary btn--sm" class="btn btn--secondary btn--sm"
data-action="restore" data-action="restore"
@ -1722,6 +1954,11 @@ async function handleProfileAction(event) {
return; return;
} }
if (action === 'browse-backup') {
openSnapshotModal(backupId, profileId);
return;
}
if (action === 'restore') { if (action === 'restore') {
if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) { if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) {
return; return;

View File

@ -398,6 +398,34 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Modal: Snapshot/browse de arquivos do backup -->
<div id="snapshotModal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" data-action="close-snapshot-modal"></div>
<div class="modal-card modal-card--wide modal-card--snapshot" role="dialog" aria-modal="true" aria-labelledby="snapshotModalTitle">
<div class="modal-header">
<div>
<h3 id="snapshotModalTitle">Snapshot do Backup</h3>
<p id="snapshotModalSubtitle" class="modal-subtitle"></p>
</div>
<button id="snapshotModalClose" class="btn btn--ghost btn--sm" type="button">Fechar</button>
</div>
<div id="snapshotContainerTabs" class="snapshot-tabs hidden"></div>
<div class="snapshot-toolbar">
<input id="snapshotSearch" type="search" class="snapshot-search" placeholder="Filtrar arquivos..." />
<span id="snapshotStats" class="snapshot-stats"></span>
</div>
<div id="snapshotLoading" class="snapshot-loading hidden">
<span class="spinner-inline"></span> Carregando arquivos do archive...
</div>
<div id="snapshotFileList" class="snapshot-file-list"></div>
<div class="modal-actions">
<button id="snapshotSelectAll" class="btn btn--secondary" type="button">Selecionar todos</button>
<button id="snapshotExtract" class="btn btn--primary" type="button" disabled>Extrair selecionados</button>
</div>
</div>
</div>
<div id="profileFormModal" class="modal hidden" aria-hidden="true"> <div id="profileFormModal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" data-action="close-profile-modal"></div> <div class="modal-backdrop" data-action="close-profile-modal"></div>
<div class="modal-card modal-card--wide" role="dialog" aria-modal="true" aria-labelledby="profileModalTitle"> <div class="modal-card modal-card--wide" role="dialog" aria-modal="true" aria-labelledby="profileModalTitle">

View File

@ -1415,6 +1415,164 @@ button, input, select {
max-height: min(90vh, 800px); max-height: min(90vh, 800px);
} }
/* ─── Snapshot modal ───────────────────────────────────── */
.modal-card--snapshot {
max-height: min(90vh, 800px);
overflow: visible;
display: flex;
flex-direction: column;
gap: 10px;
}
.snapshot-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.snapshot-tab {
padding: 4px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card, var(--surface));
color: var(--text);
font-size: 12px;
cursor: pointer;
transition: background 0.12s;
}
.snapshot-tab:hover { background: var(--hover, rgba(0,0,0,0.04)); }
.snapshot-tab.active { background: var(--accent, #4a6cf7); color: #fff; border-color: var(--accent, #4a6cf7); }
.snapshot-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.snapshot-search {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card, var(--surface));
color: var(--text);
font-size: 13px;
}
.snapshot-stats {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.snapshot-loading {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
padding: 16px 0;
font-size: 13px;
}
.spinner-inline {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent, #4a6cf7);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.snapshot-file-list {
flex: 1;
overflow-y: auto;
max-height: 420px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
}
.snapshot-empty {
padding: 28px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.snapshot-dir-group { }
.snapshot-dir-group + .snapshot-dir-group { border-top: 1px solid var(--border); }
.snapshot-dir-header {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--bg, rgba(0,0,0,0.03));
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
border-bottom: 1px solid var(--border);
word-break: break-all;
}
.snapshot-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 4px 26px;
cursor: pointer;
border-bottom: 1px solid var(--border-light, rgba(0,0,0,0.05));
}
.snapshot-file-item:last-child { border-bottom: none; }
.snapshot-file-item:hover { background: var(--hover, rgba(0,0,0,0.03)); }
.snapshot-file-item input[type="checkbox"] { width: 13px; height: 13px; flex-shrink: 0; cursor: pointer; }
.snapshot-file-name {
flex: 1;
font-family: 'IBM Plex Mono', monospace;
font-size: 11.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snapshot-file-size {
font-size: 10.5px;
color: var(--text-muted);
white-space: nowrap;
min-width: 52px;
text-align: right;
}
.snapshot-file-mtime {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
min-width: 110px;
text-align: right;
}
/* icon-only button */
.btn--icon {
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn--icon svg { width: 14px; height: 14px; }
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -2,8 +2,9 @@ const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs/promises'); const fs = require('fs/promises');
const crypto = require('crypto'); const crypto = require('crypto');
const { execFile } = require('child_process'); const { execFile, spawn } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const os = require('os');
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@ -975,6 +976,143 @@ async function main() {
setInterval(() => runScheduledJobs().catch(console.error), 60_000); setInterval(() => runScheduledJobs().catch(console.error), 60_000);
setTimeout(() => runScheduledJobs().catch(console.error), 10_000); setTimeout(() => runScheduledJobs().catch(console.error), 10_000);
// ─── Backup file browser ──────────────────────────────────
function resolveArchivePath(profile, containerBackup) {
if (!profile || !profile.backupDir || !containerBackup || !containerBackup.archiveRelativePath) return null;
const parts = containerBackup.archiveRelativePath.split('/');
return path.join(profile.backupDir, ...parts);
}
async function listTarFiles(archivePath) {
const { stdout } = await execFileAsync('tar', ['-tvzf', archivePath], { maxBuffer: 100 * 1024 * 1024 });
const files = [];
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
// GNU tar verbose: permissions owner size date time name
const parts = trimmed.split(/\s+/);
if (parts.length < 6) continue;
const perms = parts[0];
const size = parseInt(parts[2], 10) || 0;
const mtime = `${parts[3]} ${parts[4]}`;
const name = parts.slice(5).join(' ').replace(/^\.\//, '');
if (!name || name === '.' || name.endsWith('/')) continue;
files.push({ name, size, isDir: perms.startsWith('d'), mtime });
}
return files;
}
// GET /api/backups/:backupId/containers/:containerId/files
app.get('/api/backups/:backupId/containers/:containerId/files', authMiddleware, async (request, response) => {
try {
const backup = await store.getBackup(request.params.backupId);
if (!backup) { response.status(404).json({ error: 'Backup não encontrado.' }); return; }
const profile = await store.getProfile(backup.profileId);
if (!profile) { response.status(404).json({ error: 'Profile não encontrado.' }); return; }
const containerId = request.params.containerId;
const chain = await store.getBackupsForContainer(backup.profileId, containerId, request.params.backupId);
if (!chain.length) { response.status(404).json({ error: 'Container não encontrado neste backup.' }); return; }
// Merge file listings across chain (later archives overwrite earlier ones for same path)
const fileMap = new Map();
for (const cb of chain) {
const archivePath = resolveArchivePath(profile, cb);
if (!archivePath) continue;
try {
const files = await listTarFiles(archivePath);
for (const file of files) fileMap.set(file.name, file);
} catch { /* archive not accessible */ }
}
const files = [...fileMap.values()].sort((a, b) => a.name.localeCompare(b.name));
response.json({ files, chainLength: chain.length });
} catch (error) {
response.status(500).json({ error: error.message });
}
});
// POST /api/backups/:backupId/containers/:containerId/extract
app.post('/api/backups/:backupId/containers/:containerId/extract', authMiddleware, async (request, response) => {
let tmpDir = null;
try {
const selectedPaths = request.body?.paths;
if (!Array.isArray(selectedPaths) || !selectedPaths.length) {
response.status(400).json({ error: 'Selecione ao menos um arquivo para extrair.' }); return;
}
// Sanitize: strip leading slashes, null bytes, traversal sequences
const safePaths = selectedPaths
.map((p) => String(p).replace(/\0/g, '').replace(/^\/+/, '').replace(/\.\.\//g, ''))
.filter((p) => p.length > 0 && !p.includes('\0'));
if (!safePaths.length) { response.status(400).json({ error: 'Caminhos inválidos.' }); return; }
const backup = await store.getBackup(request.params.backupId);
if (!backup) { response.status(404).json({ error: 'Backup não encontrado.' }); return; }
const profile = await store.getProfile(backup.profileId);
if (!profile) { response.status(404).json({ error: 'Profile não encontrado.' }); return; }
const containerId = request.params.containerId;
const chain = await store.getBackupsForContainer(backup.profileId, containerId, request.params.backupId);
if (!chain.length) { response.status(404).json({ error: 'Container não encontrado neste backup.' }); return; }
// For each selected path, find latest archive in chain containing it
const archiveExtractMap = new Map(); // archivePath → [paths]
for (const selectedPath of safePaths) {
for (let i = chain.length - 1; i >= 0; i--) {
const archivePath = resolveArchivePath(profile, chain[i]);
if (!archivePath) continue;
try {
const files = await listTarFiles(archivePath);
const found = files.some((f) => f.name === selectedPath || f.name.startsWith(selectedPath + '/'));
if (found) {
if (!archiveExtractMap.has(archivePath)) archiveExtractMap.set(archivePath, []);
archiveExtractMap.get(archivePath).push(selectedPath);
break;
}
} catch { continue; }
}
}
if (!archiveExtractMap.size) {
response.status(404).json({ error: 'Arquivos não encontrados nos archives.' }); return;
}
tmpDir = path.join(os.tmpdir(), `dbkp-extract-${crypto.randomUUID()}`);
await fs.mkdir(tmpDir, { recursive: true });
for (const [archivePath, pathsToExtract] of archiveExtractMap) {
await execFileAsync('tar', ['-xzf', archivePath, '-C', tmpDir, '--', ...pathsToExtract], {
maxBuffer: 500 * 1024 * 1024,
}).catch(() => null);
}
const containerName = (chain[0]?.containerName || containerId.slice(0, 12)).replace(/[^a-zA-Z0-9_-]/g, '-');
const filename = `extract-${containerName}.tar.gz`;
response.setHeader('Content-Type', 'application/gzip');
response.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const tarProc = spawn('tar', ['-czf', '-', '-C', tmpDir, '.'], { stdio: ['ignore', 'pipe', 'ignore'] });
tarProc.stdout.pipe(response);
const cleanup = async () => {
if (tmpDir) {
const dirToRemove = tmpDir;
tmpDir = null;
await fs.rm(dirToRemove, { recursive: true, force: true }).catch(() => null);
}
};
tarProc.on('close', cleanup);
tarProc.on('error', cleanup);
response.on('close', cleanup);
} catch (error) {
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => null);
if (!response.headersSent) response.status(500).json({ error: error.message });
}
});
app.listen(config.port, () => { app.listen(config.port, () => {
console.log(`Docker Backup app ouvindo na porta ${config.port}`); console.log(`Docker Backup app ouvindo na porta ${config.port}`);
}); });