dockerbackup/src/server.js

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);
});