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