474 lines
16 KiB
JavaScript
474 lines
16 KiB
JavaScript
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs/promises');
|
|
const crypto = require('crypto');
|
|
const { execFile } = require('child_process');
|
|
const { promisify } = require('util');
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const config = require('./config');
|
|
const JsonStore = require('./store');
|
|
const DockerService = require('./dockerService');
|
|
const BackupService = require('./backupService');
|
|
|
|
function hashPassword(password) {
|
|
return crypto.createHash('sha256').update(password).digest('hex');
|
|
}
|
|
|
|
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());
|
|
|
|
// ─── Auth middleware ──────────────────────────────────────
|
|
async function authMiddleware(request, response, next) {
|
|
const settings = await store.getSettings();
|
|
if (!settings.requireAuth) {
|
|
return next();
|
|
}
|
|
|
|
const token = request.headers['x-auth-token'];
|
|
if (!token) {
|
|
return response.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
const expected = hashPassword(`${settings.username}:${settings.passwordHash}:${settings.username}`);
|
|
if (token !== expected) {
|
|
return response.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
return next();
|
|
}
|
|
|
|
// Static files served without auth (login page needs to load)
|
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
|
|
|
app.get('/api/health', (_request, response) => {
|
|
response.json({ ok: true });
|
|
});
|
|
|
|
// Login endpoint (public)
|
|
app.post('/api/login', async (request, response) => {
|
|
try {
|
|
const settings = await store.getSettings();
|
|
if (!settings.requireAuth) {
|
|
return response.json({ token: null, requireAuth: false });
|
|
}
|
|
|
|
const { username, password } = request.body || {};
|
|
if (!username || !password) {
|
|
return response.status(400).json({ error: 'Informe usuario e senha.' });
|
|
}
|
|
|
|
if (username !== settings.username || hashPassword(password) !== settings.passwordHash) {
|
|
return response.status(401).json({ error: 'Usuario ou senha incorretos.' });
|
|
}
|
|
|
|
const token = hashPassword(`${settings.username}:${settings.passwordHash}:${settings.username}`);
|
|
return response.json({ token, requireAuth: true });
|
|
} catch (error) {
|
|
return response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Auth check endpoint (public)
|
|
app.get('/api/auth-status', async (_request, response) => {
|
|
try {
|
|
const settings = await store.getSettings();
|
|
response.json({ requireAuth: settings.requireAuth });
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Settings endpoints (auth-protected)
|
|
app.get('/api/settings', authMiddleware, async (_request, response) => {
|
|
try {
|
|
const settings = await store.getSettings();
|
|
response.json({ language: settings.language, requireAuth: settings.requireAuth, username: settings.username });
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/settings', authMiddleware, async (request, response) => {
|
|
try {
|
|
const payload = request.body || {};
|
|
const current = await store.getSettings();
|
|
|
|
const update = {
|
|
language: payload.language || current.language,
|
|
requireAuth: typeof payload.requireAuth === 'boolean' ? payload.requireAuth : current.requireAuth,
|
|
username: payload.username !== undefined ? String(payload.username).trim() : current.username,
|
|
passwordHash: current.passwordHash,
|
|
};
|
|
|
|
if (payload.password) {
|
|
update.passwordHash = hashPassword(payload.password);
|
|
}
|
|
|
|
if (update.requireAuth && (!update.username || !update.passwordHash)) {
|
|
return response.status(400).json({ error: 'Defina usuario e senha para ativar autenticacao.' });
|
|
}
|
|
|
|
await store.saveSettings(update);
|
|
response.json({ ok: true });
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// About endpoint (auth-protected)
|
|
app.get('/api/about', authMiddleware, async (_request, response) => {
|
|
try {
|
|
const pkgPath = path.join(process.cwd(), 'package.json');
|
|
const pkgRaw = await fs.readFile(pkgPath, 'utf8');
|
|
const pkg = JSON.parse(pkgRaw);
|
|
response.json({ currentVersion: pkg.version, name: pkg.name || 'dockerbackup' });
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Update endpoint (auth-protected)
|
|
app.post('/api/update', authMiddleware, async (_request, response) => {
|
|
try {
|
|
await execFileAsync('git', ['pull', '--ff-only', 'origin', 'main'], { cwd: process.cwd() });
|
|
try {
|
|
await execFileAsync('npm', ['install', '--omit=dev'], { cwd: process.cwd() });
|
|
} catch {
|
|
// Non-fatal: deps may already be up to date
|
|
}
|
|
response.json({ ok: true });
|
|
setTimeout(() => process.exit(0), 500);
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/containers', authMiddleware, async (_request, response) => {
|
|
try {
|
|
const containers = await dockerService.listContainers();
|
|
response.json(containers);
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/containers/:containerId/mounts', authMiddleware, async (request, response) => {
|
|
try {
|
|
const inspect = await dockerService.inspectContainer(request.params.containerId);
|
|
const mounts = (inspect.Mounts || [])
|
|
.filter((m) => m.Type === 'bind' || m.Type === 'volume')
|
|
.map((m) => ({
|
|
type: m.Type,
|
|
name: m.Name || null,
|
|
source: m.Source,
|
|
destination: m.Destination,
|
|
rw: m.RW,
|
|
}));
|
|
response.json(mounts);
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/profiles', authMiddleware, 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', authMiddleware, async (request, response) => {
|
|
try {
|
|
const payload = request.body || {};
|
|
if (!payload.name || !Array.isArray(payload.containerIds) || !payload.containerIds.length) {
|
|
response.status(400).json({ error: 'Informe nome, local de armazenamento e ao menos um container.' });
|
|
return;
|
|
}
|
|
|
|
let resolvedBackupDir = payload.backupDir;
|
|
if (payload.storageLocationId) {
|
|
const locations = await store.listStorageLocations();
|
|
const loc = locations.find((l) => l.id === payload.storageLocationId);
|
|
if (!loc) {
|
|
response.status(400).json({ error: 'Local de armazenamento nao encontrado.' });
|
|
return;
|
|
}
|
|
resolvedBackupDir = loc.directory;
|
|
}
|
|
|
|
if (!resolvedBackupDir) {
|
|
response.status(400).json({ error: 'Informe o local de armazenamento.' });
|
|
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: resolvedBackupDir.trim(),
|
|
storageLocationId: payload.storageLocationId || existing?.storageLocationId || null,
|
|
containerIds: payload.containerIds,
|
|
mode: existing?.mode || 'full',
|
|
backupScope: payload.backupScope || existing?.backupScope || 'volumes',
|
|
volumeSelections: payload.volumeSelections || existing?.volumeSelections || {},
|
|
});
|
|
|
|
response.status(payload.id ? 200 : 201).json(profile);
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/profiles/:profileId', authMiddleware, 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', authMiddleware, 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', authMiddleware, async (request, response) => {
|
|
try {
|
|
const profileId = request.params.profileId;
|
|
const requestedMode = request.body?.mode;
|
|
const basedOnFullBackupId = request.body?.basedOnFullBackupId || null;
|
|
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,
|
|
basedOnFullBackupId,
|
|
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', authMiddleware, (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', authMiddleware, 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.get('/api/storage-locations', authMiddleware, async (_request, response) => {
|
|
try {
|
|
const locations = await store.listStorageLocations();
|
|
response.json(locations);
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/storage-locations', authMiddleware, async (request, response) => {
|
|
try {
|
|
const payload = request.body || {};
|
|
if (!payload.name || !payload.directory) {
|
|
response.status(400).json({ error: 'Informe nome e diretorio do local de armazenamento.' });
|
|
return;
|
|
}
|
|
const location = await store.saveStorageLocation({
|
|
id: payload.id,
|
|
name: payload.name.trim(),
|
|
directory: payload.directory.trim(),
|
|
});
|
|
response.status(payload.id ? 200 : 201).json(location);
|
|
} catch (error) {
|
|
response.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/storage-locations/:id', authMiddleware, async (request, response) => {
|
|
try {
|
|
await store.deleteStorageLocation(request.params.id);
|
|
response.status(204).end();
|
|
} 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);
|
|
}); |