diff --git a/migrations/20250724102340-backupTargets-create-table.js b/migrations/20250724102340-backupTargets-create-table.js index 955a42398..8dffb95af 100644 --- a/migrations/20250724102340-backupTargets-create-table.js +++ b/migrations/20250724102340-backupTargets-create-table.js @@ -14,7 +14,7 @@ exports.up = async function (db) { 'encryptionJson TEXT,' + 'format VARCHAR(16) NOT NULL,' + 'schedule VARCHAR(128),' + - 'priority BOOLEAN DEFAULT false,' + + 'main BOOLEAN DEFAULT false,' + 'creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,' + 'ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,' + 'PRIMARY KEY (id)) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin'; @@ -23,7 +23,7 @@ exports.up = async function (db) { const results = await db.runSql('SELECT name, value FROM settings WHERE name=? OR name=? OR name=?', [ 'backup_storage', 'backup_limits', 'backup_policy' ]); - const label = '', priority = true; + const label = '', main = true; let config = null, limits = null, encryption = null, format = null, provider = null; let retention = { keepWithinSecs: 2 * 24 * 60 * 60 }; let schedule = '00 00 23 * * *'; @@ -57,8 +57,8 @@ exports.up = async function (db) { } await db.runSql('START TRANSACTION'); - await db.runSql('INSERT INTO backupTargets (id, label, provider, configJson, limitsJson, retentionJson, schedule, encryptionJson, format, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ `bc-${uuid.v4()}`, label, provider, JSON.stringify(config), JSON.stringify(limits), JSON.stringify(retention), schedule, JSON.stringify(encryption), format, priority ]); + await db.runSql('INSERT INTO backupTargets (id, label, provider, configJson, limitsJson, retentionJson, schedule, encryptionJson, format, main) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ `bc-${uuid.v4()}`, label, provider, JSON.stringify(config), JSON.stringify(limits), JSON.stringify(retention), schedule, JSON.stringify(encryption), format, main ]); await db.runSql('DELETE FROM settings WHERE name=? OR name=? OR name=?', [ 'backup_storage', 'backup_limits', 'backup_policy' ]); await db.runSql('COMMIT'); diff --git a/migrations/schema.sql b/migrations/schema.sql index 5fc1f4966..e0c103a14 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -312,7 +312,7 @@ CREATE TABLE IF NOT EXISTS backupTargets( encryptionJson TEXT, format VARCHAR(16) NOT NULL, schedule VARCHAR(128), - priority BOOLEAN DEFAULT false, // only because 'default' and 'primary' are reserved keywords + main BOOLEAN DEFAULT false, // 'primary' and 'default' are SQL keywords creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/src/backuptargets.js b/src/backuptargets.js index 86a3e0bad..07b0c1255 100644 --- a/src/backuptargets.js +++ b/src/backuptargets.js @@ -10,6 +10,7 @@ exports = module.exports = { setLimits, setSchedule, setRetention, + setPrimary, removePrivateFields, @@ -49,7 +50,7 @@ const assert = require('assert'), tasks = require('./tasks.js'), uuid = require('uuid'); -const BACKUP_TARGET_FIELDS = [ 'id', 'label', 'provider', 'configJson', 'limitsJson', 'retentionJson', 'schedule', 'encryptionJson', 'format', 'priority', 'creationTime', 'ts' ].join(','); +const BACKUP_TARGET_FIELDS = [ 'id', 'label', 'provider', 'configJson', 'limitsJson', 'retentionJson', 'schedule', 'encryptionJson', 'format', 'main', 'creationTime', 'ts' ].join(','); function getRootPath(provider, config, mountPath) { assert.strictEqual(typeof config, 'object'); @@ -85,7 +86,8 @@ function postProcess(result) { result.encryption = result.encryptionJson ? safe.JSON.parse(result.encryptionJson) : null; delete result.encryptionJson; - result.priority = !!result.priority; + result.primary = !!result.main; // primary is a reserved keyword in mysql + delete result.main; return result; } @@ -170,7 +172,7 @@ async function update(target, data) { const args = []; const fields = []; for (const k in data) { - if (k === 'label' || k === 'schedule' || k === 'priority') { // format, provider cannot be updated + if (k === 'label' || k === 'schedule' || k === 'main') { // format, provider cannot be updated fields.push(k + ' = ?'); args.push(data[k]); } else if (k === 'config' || k === 'limits' || k === 'retention') { // encryption cannot be updated @@ -214,15 +216,31 @@ async function setRetention(target, retention) { await update(target, { retention }); } +async function setPrimary(target) { + assert.strictEqual(typeof target, 'object'); + assert.strictEqual(typeof retention, 'object'); + + const queries = [ + { query: 'SELECT 1 FROM backupTargets WHERE id=? FOR UPDATE', args: [ target.id ] }, // ensure this exists! + { query: 'UPDATE backupTargets SET main=?', args: [ false ] }, + { query: 'UPDATE backupTargets SET main=? WHERE id=?', args: [ true, target.id ] } + ]; + + const [error, result] = await safe(database.transaction(queries)); + if (error) throw error; + if (result[2].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Target not found'); +} + async function del(target, auditSource) { assert.strictEqual(typeof target, 'object'); assert(auditSource && typeof auditSource === 'object'); - if (target.priority) throw new BoxError(BoxError.CONFLICT, 'Cannot delete the primary backup target'); + if (target.main) throw new BoxError(BoxError.CONFLICT, 'Cannot delete the primary backup target'); - const queries = []; - queries.push({ query: 'DELETE FROM backups WHERE targetId = ?', args: [ target.id ] }); - queries.push({ query: 'DELETE FROM backupTargets WHERE id=? AND priority=?', args: [ target.id, false ] }); + const queries = [ + { query: 'DELETE FROM backups WHERE targetId = ?', args: [ target.id ] }, + { query: 'DELETE FROM backupTargets WHERE id=? AND main=?', args: [ target.id, false ] }, + ]; const [error, result] = await safe(database.transaction(queries)); if (error && error.code === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, error); @@ -422,7 +440,7 @@ async function add(data) { await storage.setupManagedMount(provider, config, paths.MANAGED_BACKUP_MOUNT_DIR); const id = `bc-${uuid.v4()}`; - await database.query('INSERT INTO backupTargets (id, label, provider, configJson, limitsJson, retentionJson, schedule, encryptionJson, format, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + await database.query('INSERT INTO backupTargets (id, label, provider, configJson, limitsJson, retentionJson, schedule, encryptionJson, format, main) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ id, label, provider, JSON.stringify(config), JSON.stringify(limits), JSON.stringify(retention), schedule, JSON.stringify(encryption), format, false ]); return id; } diff --git a/src/routes/backuptargets.js b/src/routes/backuptargets.js index f7da9689c..1dcdd2ff0 100644 --- a/src/routes/backuptargets.js +++ b/src/routes/backuptargets.js @@ -13,6 +13,7 @@ exports = module.exports = { setLimits, setSchedule, setRetention, + setPrimary, create, cleanup, @@ -214,3 +215,12 @@ async function setRetention(req, res, next) { next(new HttpSuccess(200, {})); } + +async function setPrimary(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + const [error] = await safe(backupTargets.setPrimary(req.resources.backupTarget)); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, {})); +} diff --git a/src/server.js b/src/server.js index b4de8add3..19d7222bf 100644 --- a/src/server.js +++ b/src/server.js @@ -168,6 +168,7 @@ async function initializeExpressSync() { router.post('/api/v1/backup_targets/:id/configure/limits', json, token, authorizeOwner, routes.backupTargets.load, routes.backupTargets.setLimits); router.post('/api/v1/backup_targets/:id/configure/schedule', json, token, authorizeOwner, routes.backupTargets.load, routes.backupTargets.setSchedule); router.post('/api/v1/backup_targets/:id/configure/retention', json, token, authorizeOwner, routes.backupTargets.load, routes.backupTargets.setRetention); + router.post('/api/v1/backup_targets/:id/configure/primary', json, token, authorizeOwner, routes.backupTargets.load, routes.backupTargets.setPrimary); // app archive routes router.get ('/api/v1/archives', token, authorizeAdmin, routes.archives.list);