Files
cloudron-box/src/backups.js
T

257 lines
10 KiB
JavaScript
Raw Normal View History

import assert from 'node:assert';
import BoxError from './boxerror.js';
import * as database from './database.js';
import debugModule from 'debug';
import eventlog from './eventlog.js';
import hat from './hat.js';
import safe from 'safetydance';
import tasks from './tasks.js';
2025-07-24 18:09:33 +02:00
const debug = debugModule('box:backups');
const BACKUP_TYPE_APP = 'app';
const BACKUP_STATE_NORMAL = 'normal';
export default {
get,
getByIdentifierAndStatePaged,
getLatestInTargetByIdentifier, // brutal function name
add,
update,
listByTypePaged,
del,
removePrivateFields,
clearTasks,
startIntegrityCheck,
2026-02-09 21:58:40 +01:00
stopIntegrityCheck,
setIntegrityResult,
BACKUP_IDENTIFIER_BOX: 'box',
BACKUP_IDENTIFIER_MAIL: 'mail',
BACKUP_TYPE_APP,
BACKUP_TYPE_BOX: 'box',
BACKUP_TYPE_MAIL: 'mail',
BACKUP_STATE_NORMAL,
BACKUP_STATE_CREATING: 'creating',
BACKUP_STATE_ERROR: 'error',
};
const BACKUPS_FIELDS = [ 'id', 'remotePath', 'label', 'identifier', 'creationTime', 'packageVersion', 'type', 'integrityJson',
'statsJson', 'dependsOnJson', 'state', 'manifestJson', 'preserveSecs', 'encryptionVersion', 'appConfigJson', 'siteId',
'integrityCheckTaskId', 'lastIntegrityCheckTime', 'integrityCheckStatus', 'integrityCheckResultJson' ].join(',');
2025-07-24 18:09:33 +02:00
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
result.dependsOn = result.dependsOnJson ? safe.JSON.parse(result.dependsOnJson) : [];
delete result.dependsOnJson;
result.manifest = result.manifestJson ? safe.JSON.parse(result.manifestJson) : null;
delete result.manifestJson;
2025-08-11 19:30:22 +05:30
result.integrity = result.integrityJson ? safe.JSON.parse(result.integrityJson) : null;
delete result.integrityJson;
result.stats = result.statsJson ? safe.JSON.parse(result.statsJson) : null;
delete result.statsJson;
2025-07-24 18:09:33 +02:00
result.appConfig = result.appConfigJson ? safe.JSON.parse(result.appConfigJson) : null;
delete result.appConfigJson;
result.integrityCheckResult = result.integrityCheckResultJson ? safe.JSON.parse(result.integrityCheckResultJson) : null;
delete result.integrityCheckResultJson;
2025-07-24 18:09:33 +02:00
return result;
}
2025-08-13 19:45:52 +05:30
function removePrivateFields(backup) {
return backup;
}
2025-07-24 18:09:33 +02:00
async function add(data) {
assert(data && typeof data === 'object');
assert.strictEqual(typeof data.remotePath, 'string');
assert(data.encryptionVersion === null || typeof data.encryptionVersion === 'number');
assert.strictEqual(typeof data.packageVersion, 'string');
assert.strictEqual(typeof data.type, 'string');
assert.strictEqual(typeof data.identifier, 'string');
assert.strictEqual(typeof data.state, 'string');
assert(Array.isArray(data.dependsOn));
assert.strictEqual(typeof data.manifest, 'object');
assert.strictEqual(typeof data.preserveSecs, 'number');
assert.strictEqual(typeof data.appConfig, 'object');
2025-09-12 09:48:37 +02:00
assert.strictEqual(typeof data.siteId, 'string');
2025-07-24 18:09:33 +02:00
const creationTime = data.creationTime || new Date(); // allow tests to set the time
const manifestJson = JSON.stringify(data.manifest);
const prefixId = data.type === BACKUP_TYPE_APP ? `${data.type}_${data.identifier}` : data.type; // type and identifier are same for other types
2025-07-24 18:09:33 +02:00
const id = `${prefixId}_v${data.packageVersion}_${hat(32)}`; // id is used by the UI to derive dependent packages. making this a UUID will require a lot of db querying
const appConfigJson = data.appConfig ? JSON.stringify(data.appConfig) : null;
2025-08-13 19:33:39 +05:30
const statsJson = data.stats ? JSON.stringify(data.stats) : null;
const integrityJson = data.integrity ? JSON.stringify(data.integrity) : null;
2025-07-24 18:09:33 +02:00
2025-09-12 09:48:37 +02:00
const [error] = await safe(database.query('INSERT INTO backups (id, remotePath, identifier, encryptionVersion, packageVersion, type, creationTime, state, dependsOnJson, manifestJson, preserveSecs, appConfigJson, siteId, statsJson, integrityJson) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ id, data.remotePath, data.identifier, data.encryptionVersion, data.packageVersion, data.type, creationTime, data.state, JSON.stringify(data.dependsOn), manifestJson, data.preserveSecs, appConfigJson, data.siteId, statsJson, integrityJson ]));
2025-07-24 18:09:33 +02:00
2025-09-29 11:55:15 +02:00
if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Backup already exists');
2025-07-24 18:09:33 +02:00
if (error) throw error;
return id;
}
async function getByIdentifierAndStatePaged(identifier, state, page, perPage) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof state, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE identifier = ? AND state = ? ORDER BY creationTime DESC LIMIT ?,?`, [ identifier, state, (page-1)*perPage, perPage ]);
results.forEach(postProcess);
2025-07-24 18:09:33 +02:00
return results;
}
2025-09-12 09:48:37 +02:00
async function getLatestInTargetByIdentifier(identifier, siteId) {
assert.strictEqual(typeof identifier, 'string');
2025-09-12 09:48:37 +02:00
assert.strictEqual(typeof siteId, 'string');
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE identifier = ? AND state = ? AND siteId = ? LIMIT 1`, [ identifier, BACKUP_STATE_NORMAL, siteId ]);
if (!results.length) return null;
return postProcess(results[0]);
}
2025-07-24 18:09:33 +02:00
async function get(id) {
assert.strictEqual(typeof id, 'string');
const result = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE id = ? ORDER BY creationTime DESC`, [ id ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
function validateLabel(label) {
assert.strictEqual(typeof label, 'string');
if (label.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'label too long');
if (/[^a-zA-Z0-9._() -]/.test(label)) return new BoxError(BoxError.BAD_FIELD, 'label can only contain alphanumerals, space, dot, hyphen, brackets or underscore');
return null;
}
// this is called by REST API
async function update(backup, data) {
assert.strictEqual(typeof backup, 'object');
2025-07-24 18:09:33 +02:00
assert.strictEqual(typeof data, 'object');
let error;
if ('label' in data) {
error = validateLabel(data.label);
if (error) throw error;
}
const fields = [], values = [];
for (const p in data) {
if (p === 'label' || p === 'preserveSecs' || p === 'state') {
2025-07-24 18:09:33 +02:00
fields.push(p + ' = ?');
values.push(data[p]);
} else if (p === 'stats') {
fields.push(`${p}Json=?`);
values.push(JSON.stringify(data[p]));
2025-07-24 18:09:33 +02:00
}
}
values.push(backup.id);
2025-07-24 18:09:33 +02:00
const result = await database.query('UPDATE backups SET ' + fields.join(', ') + ' WHERE id = ?', values);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
if ('preserveSecs' in data) {
// update the dependancies
for (const depId of backup.dependsOn) {
await database.query('UPDATE backups SET preserveSecs=? WHERE id = ?', [ data.preserveSecs, depId]);
}
}
}
async function listByTypePaged(type, siteId, page, perPage) {
2025-10-07 12:07:27 +02:00
assert.strictEqual(typeof type, 'string');
2025-10-06 14:10:29 +02:00
assert.strictEqual(typeof siteId, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE siteId=? AND type = ? ORDER BY creationTime DESC LIMIT ?,?`, [ siteId, type, (page-1)*perPage, perPage ]);
2025-10-06 14:10:29 +02:00
results.forEach(function (result) { postProcess(result); });
return results;
}
2025-07-24 18:09:33 +02:00
async function del(id) {
assert.strictEqual(typeof id, 'string');
const result = await database.query('DELETE FROM backups WHERE id=?', [ id ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
}
2025-08-15 16:09:58 +05:30
async function setIntegrityResult(backup, status, result) {
assert.strictEqual(typeof backup, 'object');
assert.strictEqual(typeof status, 'string');
assert.strictEqual(typeof result, 'object');
const now = new Date();
await database.query('UPDATE backups SET integrityCheckTaskId = NULL, lastIntegrityCheckTime = ?, integrityCheckStatus = ?, integrityCheckResultJson = ? WHERE id = ?',
[ now, status, JSON.stringify(result), backup.id ]);
}
async function startIntegrityCheck(backup, auditSource) {
2025-08-15 16:09:58 +05:30
assert.strictEqual(typeof backup, 'object');
assert.strictEqual(typeof auditSource, 'object');
if (backup.integrityCheckTaskId) throw new BoxError(BoxError.CONFLICT, 'An integrity check is already in progress for this backup');
2025-08-15 16:09:58 +05:30
const taskId = await tasks.add(tasks.TASK_CHECK_BACKUP_INTEGRITY, [ backup.id ]);
const ids = [backup.id, ...backup.dependsOn];
const placeholders = ids.map(() => '?').join(',');
const didUpdate = await database.runInTransaction(async (query) => {
const result = await query(`SELECT id FROM backups WHERE id IN (${placeholders}) AND integrityCheckTaskId IS NULL FOR UPDATE`, [ ...ids ]);
if (result.length !== ids.length) return false;
await query(`UPDATE backups SET integrityCheckTaskId = ? WHERE id IN (${placeholders})`, [taskId, ...ids]);
return true;
});
if (!didUpdate) throw new BoxError(BoxError.CONFLICT, 'An integrity check is already in progress for a dependent backup');
await eventlog.add(eventlog.ACTION_BACKUP_INTEGRITY_START, auditSource, { taskId, backupId: backup.id });
// background
tasks.startTask(taskId, {})
.then(async () => debug(`startIntegrityCheck: task completed`))
.catch((error) => debug(`startIntegrityCheck: task error. ${error.message}`))
.finally(async () => { // clear the taskId
await eventlog.add(eventlog.ACTION_BACKUP_INTEGRITY_FINISH, auditSource, { taskId, backupId: backup.id });
await database.query(`UPDATE backups SET integrityCheckTaskId = ? WHERE id IN (${placeholders})`, [null, ...ids]);
});
2025-08-15 16:09:58 +05:30
return taskId;
}
2025-10-07 18:42:51 +02:00
2026-02-09 21:58:40 +01:00
async function stopIntegrityCheck(backup, auditSource) {
assert.strictEqual(typeof backup, 'object');
assert.strictEqual(typeof auditSource, 'object');
if (!backup.integrityCheckTaskId) throw new BoxError(BoxError.BAD_STATE, 'task is not active');
await tasks.stopTask(backup.integrityCheckTaskId);
}
async function clearTasks() {
await database.query('UPDATE backups SET integrityCheckTaskId = NULL');
}