From 333ba82970ba2e83f227d50f97898678a3525b90 Mon Sep 17 00:00:00 2001 From: ansuz Date: Tue, 13 Sep 2022 18:32:50 +0530 Subject: [PATCH] allow admins to overwrite live data with archived data when both exist --- lib/commands/admin-rpc.js | 42 ++++++++++++++++++ lib/storage/file.js | 90 +++++++++++++++++++++++++++++---------- www/admin/app-admin.less | 6 ++- www/admin/inner.js | 71 ++++++++++++++++++++++++++++-- 4 files changed, 181 insertions(+), 28 deletions(-) diff --git a/lib/commands/admin-rpc.js b/lib/commands/admin-rpc.js index c3c6cb0b6..365987229 100644 --- a/lib/commands/admin-rpc.js +++ b/lib/commands/admin-rpc.js @@ -200,6 +200,46 @@ var archiveDocument = function (Env, Server, cb, data) { // Env.blobStore.archive.proof(userSafeKey, blobId, cb) }; +var removeDocument = function (Env, Server, cb, data) { + if (!Array.isArray(data)) { return void cb("EINVAL"); } + var args = data[1]; + + var id, reason; + if (typeof(args) === 'string') { + id = args; + } else if (args && typeof(args) === 'object') { + id = args.id; + reason = args.reason; + } + + if (typeof(id) !== 'string' || id.length < 32) { return void cb("EINVAL"); } + + switch (id.length) { + case 32: + return void Env.msgStore.removeChannel(id, Util.both(cb, function (err) { + Env.Log.info("REMOVAL_CHANNEL_BY_ADMIN_RPC", { + channelId: id, + reason: reason, + status: err? String(err): "SUCCESS", + }); + Channel.disconnectChannelMembers(Env, Server, id, 'EDELETED', err => { + if (err) { } // TODO + }); + })); + case 48: + return void Env.blobStore.remove.blob(id, Util.both(cb, function (err) { + Env.Log.info("REMOVAL_BLOB_BY_ADMIN_RPC", { + id: id, + reason: reason, + status: err? String(err): "SUCCESS", + }); + })); + default: + return void cb("INVALID_ID_LENGTH"); + } +}; + + var restoreArchivedDocument = function (Env, Server, cb, data) { if (!Array.isArray(data)) { return void cb("EINVAL"); } var args = data[1]; @@ -733,6 +773,8 @@ var commands = { SET_LAST_EVICTION: setLastEviction, GET_WORKER_PROFILES: getWorkerProfiles, GET_USER_TOTAL_SIZE: getUserTotalSize, + + REMOVE_DOCUMENT: removeDocument, }; Admin.command = function (Env, safeKey, data, _cb, Server) { diff --git a/lib/storage/file.js b/lib/storage/file.js index 30e6c7644..48d912cdd 100644 --- a/lib/storage/file.js +++ b/lib/storage/file.js @@ -85,6 +85,62 @@ var channelExists = function (filepath, cb) { }); }; +var isChannelAvailable = function (env, channelName, cb) { + // construct the path + var filepath = mkPath(env, channelName); + var metapath = mkMetadataPath(env, channelName); + +// (ansuz) I'm uncertain whether this task should be unordered or ordered. +// there's a round trip to the client (and possibly the user) before they decide +// to act on the information of whether there is already content present in this channel. +// so it's practically impossible to avoid race conditions where someone else creates +// some content before you. +// if that's the case, it's basically impossible that you'd generate the same signing key, +// and thus historykeeper should reject the signed messages of whoever loses the race. +// thus 'unordered' seems appropriate. + env.schedule.unordered(channelName, function (next) { + var done = Util.once(Util.mkAsync(Util.both(cb, next))); + var exists = false; + var handler = function (err, _exists) { + if (err) { return void done(err); } + exists = exists || _exists; + }; + + nThen(function (w) { + channelExists(filepath, w(handler)); + channelExists(metapath, w(handler)); + }).nThen(function () { + done(void 0, exists); + }); + }); +}; + +var isChannelArchived = function (env, channelName, cb) { + // construct the path + var filepath = mkArchivePath(env, channelName); + var metapath = mkArchiveMetadataPath(env, channelName); + +// as with the method above, somebody might remove, restore, or overwrite an archive +// in the time that it takes to answer this query and to execute whatever follows. +// since it's impossible to win the race every time let's just make this 'unordered' + + env.schedule.unordered(channelName, function (next) { + var done = Util.once(Util.mkAsync(Util.both(cb, next))); + var exists = false; + var handler = function (err, _exists) { + if (err) { return void done(err); } + exists = exists || _exists; + }; + + nThen(function (w) { + channelExists(filepath, w(handler)); + channelExists(metapath, w(handler)); + }).nThen(function () { + done(void 0, exists); + }); + }); +}; + const destroyStream = function (stream) { if (!stream) { return; } try { @@ -683,6 +739,7 @@ var unarchiveChannel = function (env, channelName, cb) { // so unlike 'archiveChannel' we won't overwrite. // Fse.move will call back with EEXIST in such a situation + var ENOENT = false; nThen(function (w) { // if either metadata or a file exist in prod, abort channelExists(channelPath, w(function (err, exists) { @@ -702,7 +759,7 @@ var unarchiveChannel = function (env, channelName, cb) { } if (exists) { w.abort(); - return CB("UNARCHIVE_METADATA_CONFLICT"); + return CB("UNARCHIVE_METADATA_CONFLICT"); // XXX } })); }).nThen(function (w) { @@ -710,6 +767,10 @@ var unarchiveChannel = function (env, channelName, cb) { var archiveChannelPath = mkArchivePath(env, channelName); // restore the archived channel Fse.move(archiveChannelPath, channelPath, w(function (err) { + if (err && err.code === 'ENOENT') { + ENOENT = true; + return; + } if (err) { w.abort(); return void CB(err); @@ -723,6 +784,10 @@ var unarchiveChannel = function (env, channelName, cb) { Fse.move(archiveMetadataPath, metadataPath, w(function (err) { // if there's nothing to move, you're done. if (err && err.code === 'ENOENT') { + if (ENOENT) { + // nothing was deleted? the client probably wants to know about that. + return void cb("ENOENT"); + } return CB(); } // call back with an error if something goes wrong @@ -1175,31 +1240,12 @@ module.exports.create = function (conf, _cb) { // check if a channel exists in the database isChannelAvailable: function (channelName, cb) { if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); } - // construct the path - var filepath = mkPath(env, channelName); -// (ansuz) I'm uncertain whether this task should be unordered or ordered. -// there's a round trip to the client (and possibly the user) before they decide -// to act on the information of whether there is already content present in this channel. -// so it's practically impossible to avoid race conditions where someone else creates -// some content before you. -// if that's the case, it's basically impossible that you'd generate the same signing key, -// and thus historykeeper should reject the signed messages of whoever loses the race. -// thus 'unordered' seems appropriate. - schedule.unordered(channelName, function (next) { - channelExists(filepath, Util.both(cb, next)); - }); + isChannelAvailable(env, channelName, cb); }, // check if a channel exists in the archive isChannelArchived: function (channelName, cb) { if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); } - // construct the path - var filepath = mkArchivePath(env, channelName); -// as with the method above, somebody might remove, restore, or overwrite an archive -// in the time that it takes to answer this query and to execute whatever follows. -// since it's impossible to win the race every time let's just make this 'unordered' - schedule.unordered(channelName, function (next) { - channelExists(filepath, Util.both(cb, next)); - }); + isChannelArchived(env, channelName, cb); }, // move a channel from the database to the archive, along with its metadata archiveChannel: function (channelName, cb) { diff --git a/www/admin/app-admin.less b/www/admin/app-admin.less index 08c0d8e2d..923918e71 100644 --- a/www/admin/app-admin.less +++ b/www/admin/app-admin.less @@ -21,8 +21,10 @@ text-decoration: underline; } - .alert.alert-info.cp-admin-bigger-alert { - font-size: 16px; + .alert.alert-info, .alert.alert-danger { + &.cp-admin-bigger-alert { + font-size: 16px; + } } diff --git a/www/admin/inner.js b/www/admin/inner.js index b67a02fb6..ab53c61a2 100644 --- a/www/admin/inner.js +++ b/www/admin/inner.js @@ -788,7 +788,72 @@ define([ } - if (data.live) { + if (data.live && data.archived) { + let disableButtons; + let restoreButton = danger(Messages.admin_unarchiveButton, function () { + justifyRestorationDialog('', reason => { + nThen(function (w) { + sframeCommand('REMOVE_DOCUMENT', { + id: data.id, + reason: reason, + }, w(err => { + if (err) { + w.abort(); + return void UI.warn(Messages.error); + } + })) + }).nThen(function () { + sframeCommand("RESTORE_ARCHIVED_DOCUMENT", { + id: data.id, + reason: reason, + }, (err /*, response */) => { + if (err) { + console.error(err); + return void UI.warn(Messages.error); + } + UI.log(Messages.restoredFromServer); + disableButtons(); + }); + }); + }); + }); + + let archiveButton = danger(Messages.admin_archiveButton, function () { + justifyArchivalDialog('', result => { + sframeCommand('ARCHIVE_DOCUMENT', { + id: data.id, + reason: result, + }, (err /*, response */) => { + if (err) { + console.error(err); + return void UI.warn(Messages.error); + } + UI.log(Messages.archivedFromServer); + disableButtons(); + }); + }); + }); + + disableButtons = function () { + [archiveButton, restoreButton].forEach(el => { + disable($(el)); + }); + }; + + row(h('span', [ + Messages.admin_documentConflict, + h('br'), + h('small', Messages.ui_experimental), + ]), h('span', [ + h('div.alert.alert-danger.cp-admin-bigger-alert', [ + Messages.admin_conflictExplanation, + ]), + h('p', [ + restoreButton, + archiveButton, + ]), + ])); + } else if (data.live) { // archive var archiveDocumentButton = danger(Messages.admin_archiveButton, function () { justifyArchivalDialog('', result => { @@ -809,9 +874,7 @@ define([ archiveDocumentButton, h('small', Messages.admin_archiveHint), ])); - } - - if (data.archived && !data.live) { + } else if (data.archived) { var restoreDocumentButton = primary(Messages.admin_unarchiveButton, function () { justifyRestorationDialog('', reason => { sframeCommand("RESTORE_ARCHIVED_DOCUMENT", {