mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-02-02 20:36:49 +01:00
Add user backups download
This commit is contained in:
parent
411a8ef8a7
commit
31cc6e51b5
807
package-lock.json
generated
807
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@
|
||||
"@agnai/web-tokenizers": "^0.1.3",
|
||||
"@dqbd/tiktoken": "^1.0.13",
|
||||
"@zeldafan0225/ai_horde": "^4.0.1",
|
||||
"archiver": "^7.0.1",
|
||||
"bing-translate-api": "^2.9.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"command-exists": "^1.2.9",
|
||||
|
@ -37,9 +37,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="userEditButton menu_button">Edit</div>
|
||||
<div class="userDisableButton menu_button">Disable</div>
|
||||
<div class="userEnableButton menu_button">Enable</div>
|
||||
<div class="userEditButton menu_button disabled">
|
||||
<i class="fa-fw fa-solid fa-pencil"></i>
|
||||
</div>
|
||||
<div class="userEnableButton menu_button" title="Enable user account.">
|
||||
<i class="fa-fw fa-solid fa-check"></i>
|
||||
</div>
|
||||
<div class="userDisableButton menu_button" title="Disable user account.">
|
||||
<i class="fa-fw fa-solid fa-ban"></i>
|
||||
</div>
|
||||
<div class="userPromoteButton menu_button" title="Promote user to admin.">
|
||||
<i class="fa-fw fa-solid fa-arrow-up"></i>
|
||||
</div>
|
||||
<div class="userDemoteButton menu_button" title="Demote user to regular user.">
|
||||
<i class="fa-fw fa-solid fa-arrow-down"></i>
|
||||
</div>
|
||||
<div class="userBackupButton menu_button menu_button_icon" title="Download a backup of user data.">
|
||||
<i class="fa-fw fa-solid fa-download"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -56,15 +71,15 @@
|
||||
</div>
|
||||
<div class="flex-container flexNoGap">
|
||||
<span>Display Name:</span>
|
||||
<input name="_name" class="text_pole" type="text" placeholder="Anonymous">
|
||||
<input name="_name" class="text_pole" type="text" placeholder="Anonymous" autocomplete="username">
|
||||
</div>
|
||||
<div class="flex-container flexNoGap">
|
||||
<span>Password:</span>
|
||||
<input name="password" class="text_pole" type="password" placeholder="[ No password ]">
|
||||
<input name="password" class="text_pole" type="password" placeholder="[ No password ]" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="flex-container flexNoGap">
|
||||
<span>Confirm Password:</span>
|
||||
<input name="confirm" class="text_pole" type="password" placeholder="[ No password ]">
|
||||
<input name="confirm" class="text_pole" type="password" placeholder="[ No password ]" autocomplete="new-password">
|
||||
</div>
|
||||
<span>
|
||||
This will create a new subfolder in the /data/ directory with the user's handle as the folder name.
|
||||
|
@ -116,6 +116,57 @@ async function disableUser(handle, callback) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a user to admin.
|
||||
* @param {string} handle User handle
|
||||
* @param {function} callback Success callback
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function promoteUser(handle, callback) {
|
||||
try {
|
||||
const response = await fetch('/api/users/promote', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ handle }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
toastr.error(data.error || 'Unknown error', 'Failed to promote user');
|
||||
throw new Error('Failed to promote user');
|
||||
}
|
||||
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error('Error promoting user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Demote a user from admin.
|
||||
* @param {string} handle User handle
|
||||
* @param {function} callback Success callback
|
||||
*/
|
||||
async function demoteUser(handle, callback) {
|
||||
try {
|
||||
const response = await fetch('/api/users/demote', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ handle }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
toastr.error(data.error || 'Unknown error', 'Failed to demote user');
|
||||
throw new Error('Failed to demote user');
|
||||
}
|
||||
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error('Error demoting user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user.
|
||||
* @param {HTMLFormElement} form Form element
|
||||
@ -168,6 +219,43 @@ async function createUser(form, callback) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup a user's data.
|
||||
* @param {string} handle Handle of the user to backup
|
||||
* @param {function} callback Success callback
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function backupUserData(handle, callback) {
|
||||
try {
|
||||
toastr.info('Please wait for the download to start.', 'Backup Requested');
|
||||
const response = await fetch('/api/users/backup', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ handle }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
toastr.error(data.error || 'Unknown error', 'Failed to backup user data');
|
||||
throw new Error('Failed to backup user data');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const header = response.headers.get('Content-Disposition');
|
||||
const parts = header.split(';');
|
||||
const filename = parts[1].split('=')[1].replaceAll('"', '');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error('Error backing up user data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAdminPanel() {
|
||||
async function renderUsers() {
|
||||
const users = await getUsers();
|
||||
@ -184,6 +272,12 @@ async function openAdminPanel() {
|
||||
userBlock.find('.userCreated').text(new Date(user.created).toLocaleString());
|
||||
userBlock.find('.userEnableButton').toggle(!user.enabled).on('click', () => enableUser(user.handle, renderUsers));
|
||||
userBlock.find('.userDisableButton').toggle(user.enabled).on('click', () => disableUser(user.handle, renderUsers));
|
||||
userBlock.find('.userPromoteButton').toggle(!user.admin).on('click', () => promoteUser(user.handle, renderUsers));
|
||||
userBlock.find('.userDemoteButton').toggle(user.admin).on('click', () => demoteUser(user.handle, renderUsers));
|
||||
userBlock.find('.userBackupButton').on('click', function () {
|
||||
$(this).addClass('disabled').off('click');
|
||||
backupUserData(user.handle, renderUsers);
|
||||
});
|
||||
template.find('.usersList').append(userBlock);
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ const {
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||
try {
|
||||
/** @type {import('../users').User[]} */
|
||||
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
|
||||
|
||||
@ -35,9 +36,14 @@ router.post('/get', requireAdminMiddleware, jsonParser, async (request, response
|
||||
}));
|
||||
|
||||
return response.json(viewModels);
|
||||
} catch (error) {
|
||||
console.error('User list failed:', error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.handle) {
|
||||
console.log('Disable user failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
@ -59,9 +65,14 @@ router.post('/disable', requireAdminMiddleware, jsonParser, async (request, resp
|
||||
user.enabled = false;
|
||||
await storage.setItem(toKey(request.body.handle), user);
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error('User disable failed:', error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.handle) {
|
||||
console.log('Enable user failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
@ -78,9 +89,67 @@ router.post('/enable', requireAdminMiddleware, jsonParser, async (request, respo
|
||||
user.enabled = true;
|
||||
await storage.setItem(toKey(request.body.handle), user);
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error('User enable failed:', error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/promote', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.handle) {
|
||||
console.log('Promote user failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
/** @type {import('../users').User} */
|
||||
const user = await storage.getItem(toKey(request.body.handle));
|
||||
|
||||
if (!user) {
|
||||
console.log('Promote user failed: User not found');
|
||||
return response.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
user.admin = true;
|
||||
await storage.setItem(toKey(request.body.handle), user);
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error('User promote failed:', error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/demote', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.handle) {
|
||||
console.log('Demote user failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
if (request.body.handle === request.user.profile.handle) {
|
||||
console.log('Demote user failed: Cannot demote yourself');
|
||||
return response.status(400).json({ error: 'Cannot demote yourself' });
|
||||
}
|
||||
|
||||
/** @type {import('../users').User} */
|
||||
const user = await storage.getItem(toKey(request.body.handle));
|
||||
|
||||
if (!user) {
|
||||
console.log('Demote user failed: User not found');
|
||||
return response.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
user.admin = false;
|
||||
await storage.setItem(toKey(request.body.handle), user);
|
||||
return response.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error('User demote failed:', error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.handle || !request.body.name) {
|
||||
console.log('Create user failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
@ -116,6 +185,10 @@ router.post('/create', requireAdminMiddleware, jsonParser, async (request, respo
|
||||
const directories = getUserDirectories(newUser.handle);
|
||||
await checkForNewContent([directories]);
|
||||
return response.json({ handle: newUser.handle });
|
||||
} catch (error) {
|
||||
console.error('User create failed:', error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
|
@ -1,7 +1,7 @@
|
||||
const storage = require('node-persist');
|
||||
const express = require('express');
|
||||
const { jsonParser } = require('../express-common');
|
||||
const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt } = require('../users');
|
||||
const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive } = require('../users');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -44,11 +44,16 @@ router.get('/me', async (request, response) => {
|
||||
|
||||
router.post('/change-password', jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.handle || !request.body.oldPassword || !request.body.newPassword) {
|
||||
if (!request.body.handle) {
|
||||
console.log('Change password failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
|
||||
console.log('Change password failed: Unauthorized');
|
||||
return response.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
/** @type {import('../users').User} */
|
||||
const user = await storage.getItem(toKey(request.body.handle));
|
||||
|
||||
@ -62,7 +67,8 @@ router.post('/change-password', jsonParser, async (request, response) => {
|
||||
return response.status(403).json({ error: 'User is disabled' });
|
||||
}
|
||||
|
||||
if (user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) {
|
||||
const isAdminChange = request.user.profile.admin && request.body.handle !== request.user.profile.handle;
|
||||
if (!isAdminChange && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) {
|
||||
console.log('Change password failed: Incorrect password');
|
||||
return response.status(401).json({ error: 'Incorrect password' });
|
||||
}
|
||||
@ -78,6 +84,27 @@ router.post('/change-password', jsonParser, async (request, response) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/backup', jsonParser, async (request, response) => {
|
||||
try {
|
||||
const handle = request.body.handle;
|
||||
|
||||
if (!handle) {
|
||||
console.log('Backup failed: Missing required fields');
|
||||
return response.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
if (handle !== request.user.profile.handle && !request.user.profile.admin) {
|
||||
console.log('Backup failed: Unauthorized');
|
||||
return response.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
await createBackupArchive(handle, response);
|
||||
} catch (error) {
|
||||
console.error('Backup failed', error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
router,
|
||||
};
|
||||
|
40
src/users.js
40
src/users.js
@ -8,9 +8,10 @@ const os = require('os');
|
||||
const storage = require('node-persist');
|
||||
const express = require('express');
|
||||
const mime = require('mime-types');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants');
|
||||
const { getConfigValue, color, delay, setConfigValue } = require('./util');
|
||||
const { getConfigValue, color, delay, setConfigValue, generateTimestamp } = require('./util');
|
||||
const { readSecret, writeSecret } = require('./endpoints/secrets');
|
||||
|
||||
const KEY_PREFIX = 'user:';
|
||||
@ -584,6 +585,42 @@ function requireAdminMiddleware(request, response, next) {
|
||||
return response.sendStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an archive of the user's data root directory.
|
||||
* @param {string} handle User handle
|
||||
* @param {import('express').Response} response Express response object to write to
|
||||
* @returns {Promise<void>} Promise that resolves when the archive is created
|
||||
*/
|
||||
async function createBackupArchive(handle, response) {
|
||||
const directories = getUserDirectories(handle);
|
||||
|
||||
console.log('Backup requested for', handle);
|
||||
const archive = archiver('zip');
|
||||
|
||||
archive.on('error', function (err) {
|
||||
response.status(500).send({ error: err.message });
|
||||
});
|
||||
|
||||
// On stream closed we can end the request
|
||||
archive.on('end', function () {
|
||||
console.log('Archive wrote %d bytes', archive.pointer());
|
||||
response.end(); // End the Express response
|
||||
});
|
||||
|
||||
const timestamp = generateTimestamp();
|
||||
|
||||
// Set the archive name
|
||||
response.attachment(`${handle}-${timestamp}.zip`);
|
||||
|
||||
// This is the streaming magic
|
||||
// @ts-ignore
|
||||
archive.pipe(response);
|
||||
|
||||
// Append files from a sub-directory, putting its contents at the root of archive
|
||||
archive.directory(directories.root, false);
|
||||
archive.finalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Express router for serving files from the user's directories.
|
||||
*/
|
||||
@ -614,6 +651,7 @@ module.exports = {
|
||||
getCookieSessionName,
|
||||
getUserAvatar,
|
||||
shouldRedirectToLogin,
|
||||
createBackupArchive,
|
||||
tryAutoLogin,
|
||||
router,
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user