From 1990a2d9bd1a58290a92ecc1241155883311b23d Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Thu, 11 Apr 2024 01:44:48 +0300 Subject: [PATCH] Add user snapshot settings management --- public/scripts/templates/snapshotsView.html | 31 ++++ public/scripts/user.js | 153 ++++++++++++++++++++ src/endpoints/settings.js | 127 ++++++++++++++-- src/users.js | 1 - 4 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 public/scripts/templates/snapshotsView.html diff --git a/public/scripts/templates/snapshotsView.html b/public/scripts/templates/snapshotsView.html new file mode 100644 index 000000000..5281be319 --- /dev/null +++ b/public/scripts/templates/snapshotsView.html @@ -0,0 +1,31 @@ +
+

+ Settings Snapshots + +

+
+
+
+
+
+
+
+ +
+ + () +
+
+
+ +
+
+
+ +
+
+
+
diff --git a/public/scripts/user.js b/public/scripts/user.js index e36460227..27ebf2e01 100644 --- a/public/scripts/user.js +++ b/public/scripts/user.js @@ -1,5 +1,6 @@ import { getRequestHeaders, renderTemplate } from '../script.js'; import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js'; +import { humanFileSize } from './utils.js'; /** * @type {import('../../src/users.js').UserViewModel} Logged in user @@ -435,6 +436,157 @@ async function changeName(handle, name, callback) { } } +/** + * Restore a settings snapshot. + * @param {string} name Snapshot name + * @param {function} callback Success callback + */ +async function restoreSnapshot(name, callback) { + try { + const confirm = await callGenericPopup( + `Are you sure you want to restore the settings from "${name}"?`, + POPUP_TYPE.CONFIRM, + '', + { okButton: 'Restore', cancelButton: 'Cancel', wide: false, large: false }, + ); + + if (confirm !== POPUP_RESULT.AFFIRMATIVE) { + throw new Error('Restore snapshot cancelled'); + } + + const response = await fetch('/api/settings/restore-snapshot', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ name }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to restore snapshot'); + throw new Error('Failed to restore snapshot'); + } + + callback(); + } catch (error) { + console.error('Error restoring snapshot:', error); + } + +} + +/** + * Load the content of a settings snapshot. + * @param {string} name Snapshot name + * @returns {Promise} Snapshot content + */ +async function loadSnapshotContent(name) { + try { + const response = await fetch('/api/settings/load-snapshot', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ name }), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to load snapshot content'); + throw new Error('Failed to load snapshot content'); + } + + return response.text(); + } catch (error) { + console.error('Error loading snapshot content:', error); + } +} + +/** + * Gets a list of settings snapshots. + * @returns {Promise} List of snapshots + * @typedef {Object} Snapshot + * @property {string} name Snapshot name + * @property {number} date Date in milliseconds + * @property {number} size File size in bytes + */ +async function getSnapshots() { + try { + const response = await fetch('/api/settings/get-snapshots', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to get settings snapshots'); + throw new Error('Failed to get settings snapshots'); + } + + const snapshots = await response.json(); + return snapshots; + } catch (error) { + console.error('Error getting settings snapshots:', error); + return []; + } +} + +/** + * Make a snapshot of the current settings. + * @param {function} callback Success callback + * @returns {Promise} + */ +async function makeSnapshot(callback) { + try { + const response = await fetch('/api/settings/make-snapshot', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + const data = await response.json(); + toastr.error(data.error || 'Unknown error', 'Failed to make snapshot'); + throw new Error('Failed to make snapshot'); + } + + toastr.success('Snapshot created successfully', 'Snapshot Created'); + callback(); + } catch (error) { + console.error('Error making snapshot:', error); + } +} + +/** + * Open the settings snapshots view. + */ +async function viewSettingsSnapshots() { + const template = $(renderTemplate('snapshotsView')); + async function renderSnapshots() { + const snapshots = await getSnapshots(); + template.find('.snapshotList').empty(); + + for (const snapshot of snapshots.sort((a, b) => b.date - a.date)) { + const snapshotBlock = template.find('.snapshotTemplate .snapshot').clone(); + snapshotBlock.find('.snapshotName').text(snapshot.name); + snapshotBlock.find('.snapshotDate').text(new Date(snapshot.date).toLocaleString()); + snapshotBlock.find('.snapshotSize').text(humanFileSize(snapshot.size)); + snapshotBlock.find('.snapshotRestoreButton').on('click', async (e) => { + e.stopPropagation(); + restoreSnapshot(snapshot.name, () => location.reload()); + }); + snapshotBlock.find('.inline-drawer-toggle').on('click', async () => { + const contentBlock = snapshotBlock.find('.snapshotContent'); + if (!contentBlock.val()) { + const content = await loadSnapshotContent(snapshot.name); + contentBlock.val(content); + } + + }); + template.find('.snapshotList').append(snapshotBlock); + } + } + + callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false }); + template.find('.makeSnapshotButton').on('click', () => makeSnapshot(renderSnapshots)); + renderSnapshots(); +} + async function openUserProfile() { await getCurrentUser(); const template = $(renderTemplate('userProfile')); @@ -445,6 +597,7 @@ async function openUserProfile() { template.find('.userCreated').text(new Date(currentUser.created).toLocaleString()); template.find('.hasPassword').toggle(currentUser.password); template.find('.noPassword').toggle(!currentUser.password); + template.find('.userSettingsSnapshotsButton').on('click', () => viewSettingsSnapshots()); template.find('.userChangeNameButton').on('click', async () => changeName(currentUser.handle, currentUser.name, async () => { await getCurrentUser(); template.find('.userName').text(currentUser.name); diff --git a/src/endpoints/settings.js b/src/endpoints/settings.js index 855a702d9..c3d0caa74 100644 --- a/src/endpoints/settings.js +++ b/src/endpoints/settings.js @@ -10,6 +10,12 @@ const { getAllUserHandles, getUserDirectories } = require('../users'); const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true); const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); +/** + * Reads and parses files from a directory. + * @param {string} directoryPath Path to the directory + * @param {string} fileExtension File extension + * @returns {Array} Parsed files + */ function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { const files = fs .readdirSync(directoryPath) @@ -31,10 +37,24 @@ function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { return parsedFiles; } +/** + * Gets a sort function for sorting strings. + * @param {*} _ + * @returns {(a: string, b: string) => number} Sort function + */ function sortByName(_) { return (a, b) => a.localeCompare(b); } +/** + * Gets backup file prefix for user settings. + * @param {string} handle User handle + * @returns {string} File prefix + */ +function getFilePrefix(handle) { + return `settings_${handle}_`; +} + function readPresetsFromDirectory(directoryPath, options = {}) { const { sortFunction, @@ -70,22 +90,31 @@ async function backupSettings() { const userHandles = await getAllUserHandles(); for (const handle of userHandles) { - const userDirectories = getUserDirectories(handle); - const backupFile = path.join(PUBLIC_DIRECTORIES.backups, `settings_${handle}_${generateTimestamp()}.json`); - const sourceFile = path.join(userDirectories.root, SETTINGS_FILE); - - if (!fs.existsSync(sourceFile)) { - continue; - } - - fs.copyFileSync(sourceFile, backupFile); - removeOldBackups(`settings_${handle}`); + backupUserSettings(handle); } } catch (err) { console.log('Could not backup settings file', err); } } +/** + * Makes a backup of the user's settings file. + * @param {string} handle User handle + * @returns {void} + */ +function backupUserSettings(handle) { + const userDirectories = getUserDirectories(handle); + const backupFile = path.join(PUBLIC_DIRECTORIES.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`); + const sourceFile = path.join(userDirectories.root, SETTINGS_FILE); + + if (!fs.existsSync(sourceFile)) { + return; + } + + fs.copyFileSync(sourceFile, backupFile); + removeOldBackups(`settings_${handle}`); +} + const router = express.Router(); router.post('/save', jsonParser, function (request, response) { @@ -168,6 +197,84 @@ router.post('/get', jsonParser, (request, response) => { }); }); +router.post('/get-snapshots', jsonParser, async (request, response) => { + try { + const snapshots = fs.readdirSync(PUBLIC_DIRECTORIES.backups); + const userFilesPattern = getFilePrefix(request.user.profile.handle); + const userSnapshots = snapshots.filter(x => x.startsWith(userFilesPattern)); + + const result = userSnapshots.map(x => { + const stat = fs.statSync(path.join(PUBLIC_DIRECTORIES.backups, x)); + return { date: stat.ctimeMs, name: x, size: stat.size }; + }); + + response.json(result); + } catch (error) { + console.log(error); + response.sendStatus(500); + } +}); + +router.post('/load-snapshot', jsonParser, async (request, response) => { + try { + const userFilesPattern = getFilePrefix(request.user.profile.handle); + + if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) { + return response.status(400).send({ error: 'Invalid snapshot name' }); + } + + const snapshotName = request.body.name; + const snapshotPath = path.join(PUBLIC_DIRECTORIES.backups, snapshotName); + + if (!fs.existsSync(snapshotPath)) { + return response.sendStatus(404); + } + + const content = fs.readFileSync(snapshotPath, 'utf8'); + + response.send(content); + } catch (error) { + console.log(error); + response.sendStatus(500); + } +}); + +router.post('/make-snapshot', jsonParser, async (request, response) => { + try { + backupUserSettings(request.user.profile.handle); + response.sendStatus(204); + } catch (error) { + console.log(error); + response.sendStatus(500); + } +}); + +router.post('/restore-snapshot', jsonParser, async (request, response) => { + try { + const userFilesPattern = getFilePrefix(request.user.profile.handle); + + if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) { + return response.status(400).send({ error: 'Invalid snapshot name' }); + } + + const snapshotName = request.body.name; + const snapshotPath = path.join(PUBLIC_DIRECTORIES.backups, snapshotName); + + if (!fs.existsSync(snapshotPath)) { + return response.sendStatus(404); + } + + const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE); + fs.rmSync(pathToSettings, { force: true }); + fs.copyFileSync(snapshotPath, pathToSettings); + + response.sendStatus(204); + } catch (error) { + console.log(error); + response.sendStatus(500); + } +}); + /** * Initializes the settings endpoint */ diff --git a/src/users.js b/src/users.js index b5daae291..6984d2b32 100644 --- a/src/users.js +++ b/src/users.js @@ -561,7 +561,6 @@ function createRouteHandler(directoryFn) { const filePath = decodeURIComponent(req.params[0]); return res.sendFile(filePath, { root: directory }); } catch (error) { - console.error(error); return res.sendStatus(404); } };