Merge branch 'neo-server' of https://github.com/SillyTavern/SillyTavern into neo-server
This commit is contained in:
commit
af8627b999
|
@ -3385,7 +3385,7 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
#ui_language_select {
|
#ui_language_select {
|
||||||
width: 10em;
|
width: 8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#extensions_settings .inline-drawer-toggle.inline-drawer-header:hover,
|
#extensions_settings .inline-drawer-toggle.inline-drawer-header:hover,
|
||||||
|
|
233
src/users.js
233
src/users.js
|
@ -19,6 +19,7 @@ const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util'
|
||||||
const { readSecret, writeSecret } = require('./endpoints/secrets');
|
const { readSecret, writeSecret } = require('./endpoints/secrets');
|
||||||
const { checkForNewContent } = require('./endpoints/content-manager');
|
const { checkForNewContent } = require('./endpoints/content-manager');
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'user:';
|
||||||
const DATA_ROOT = getConfigValue('dataRoot', './data');
|
const DATA_ROOT = getConfigValue('dataRoot', './data');
|
||||||
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
|
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
|
||||||
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
||||||
|
@ -29,7 +30,6 @@ const MFA_CACHE = new Cache(5 * 60 * 1000);
|
||||||
const DIRECTORIES_CACHE = new Map();
|
const DIRECTORIES_CACHE = new Map();
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
users: 'users',
|
|
||||||
csrfSecret: 'csrfSecret',
|
csrfSecret: 'csrfSecret',
|
||||||
cookieSecret: 'cookieSecret',
|
cookieSecret: 'cookieSecret',
|
||||||
};
|
};
|
||||||
|
@ -304,6 +304,15 @@ async function migrateUserData() {
|
||||||
console.log(color.green('Migration completed!'));
|
console.log(color.green('Migration completed!'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a user handle to a storage key.
|
||||||
|
* @param {string} handle User handle
|
||||||
|
* @returns {string} The key for the user storage
|
||||||
|
*/
|
||||||
|
function toKey(handle) {
|
||||||
|
return `${KEY_PREFIX}${handle}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the user storage. Currently a no-op.
|
* Initializes the user storage. Currently a no-op.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
|
@ -314,10 +323,11 @@ async function initUserStorage() {
|
||||||
ttl: true,
|
ttl: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const users = await storage.getItem('users');
|
const keys = await getAllUserHandles();
|
||||||
|
|
||||||
if (!users) {
|
// If there are no users, create the default user
|
||||||
await storage.setItem('users', [DEFAULT_USER]);
|
if (keys.length === 0) {
|
||||||
|
await storage.setItem(toKey(DEFAULT_USER.handle), DEFAULT_USER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -390,11 +400,11 @@ function getCsrfSecret(request) {
|
||||||
* @returns {Promise<string[]>} - The list of user handles
|
* @returns {Promise<string[]>} - The list of user handles
|
||||||
*/
|
*/
|
||||||
async function getAllUserHandles() {
|
async function getAllUserHandles() {
|
||||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
const keys = await storage.keys(x=> x.key.startsWith(KEY_PREFIX));
|
||||||
return users.map(user => user.handle);
|
const handles = keys.map(x => x.replace(KEY_PREFIX, ''));
|
||||||
|
return handles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the directories listing for the provided user.
|
* Gets the directories listing for the provided user.
|
||||||
* @param {string} handle User handle
|
* @param {string} handle User handle
|
||||||
|
@ -416,6 +426,34 @@ function getUserDirectories(handle) {
|
||||||
return directories;
|
return directories;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the avatar URL for the provided user.
|
||||||
|
* @param {string} handle User handle
|
||||||
|
* @returns {string} User avatar URL
|
||||||
|
*/
|
||||||
|
function getUserAvatar(handle) {
|
||||||
|
try {
|
||||||
|
const directory = getUserDirectories(handle);
|
||||||
|
const pathToSettings = path.join(directory.root, 'settings.json');
|
||||||
|
const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {};
|
||||||
|
const avatarFile = settings?.power_user?.default_persona || settings?.user_avatar;
|
||||||
|
if (!avatarFile) {
|
||||||
|
return DEFAULT_AVATAR;
|
||||||
|
}
|
||||||
|
const avatarPath = path.join(directory.avatars, avatarFile);
|
||||||
|
if (!fs.existsSync(avatarPath)) {
|
||||||
|
return DEFAULT_AVATAR;
|
||||||
|
}
|
||||||
|
const mimeType = mime.lookup(avatarPath);
|
||||||
|
const base64Content = fs.readFileSync(avatarPath, 'base64');
|
||||||
|
return `data:${mimeType};base64,${base64Content}`;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Ignore errors
|
||||||
|
return DEFAULT_AVATAR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to add user data to the request object.
|
* Middleware to add user data to the request object.
|
||||||
* @param {import('express').Express} app Express app
|
* @param {import('express').Express} app Express app
|
||||||
|
@ -452,24 +490,34 @@ function userDataMiddleware(app) {
|
||||||
};
|
};
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!req.session) {
|
||||||
|
console.error('Session not available');
|
||||||
|
return res.sendStatus(500);
|
||||||
|
}
|
||||||
|
|
||||||
// If user accounts are enabled, get the user from the session
|
// If user accounts are enabled, get the user from the session
|
||||||
/**
|
|
||||||
* @type {User[]}
|
|
||||||
*/
|
|
||||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
|
||||||
let handle = req.session?.handle;
|
let handle = req.session?.handle;
|
||||||
|
|
||||||
// If we have the only user and it's not password protected, use it
|
// If we have the only user and it's not password protected, use it
|
||||||
if (!handle && users.length === 1 && !users[0].password) {
|
if (!handle) {
|
||||||
handle = users[0].handle;
|
const handles = await getAllUserHandles();
|
||||||
|
if (handles.length === 1) {
|
||||||
|
/** @type {User} */
|
||||||
|
const user = await storage.getItem(toKey(handles[0]));
|
||||||
|
if (!user.password) {
|
||||||
|
handle = user.handle;
|
||||||
req.session.handle = handle;
|
req.session.handle = handle;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!handle) {
|
if (!handle) {
|
||||||
return res.redirect('/login');
|
return res.redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = users.find(user => user.handle === handle);
|
/** @type {User} */
|
||||||
|
const user = await storage.getItem(toKey(handle));
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.error('User not found:', handle);
|
console.error('User not found:', handle);
|
||||||
|
@ -544,38 +592,33 @@ const endpoints = express.Router();
|
||||||
|
|
||||||
endpoints.get('/list', async (_request, response) => {
|
endpoints.get('/list', async (_request, response) => {
|
||||||
/** @type {User[]} */
|
/** @type {User[]} */
|
||||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
const users = await storage.values();
|
||||||
const viewModels = users.filter(x => x.enabled).map(user => ({
|
const viewModels = users.filter(x => x.enabled).map(user => ({
|
||||||
handle: user.handle,
|
handle: user.handle,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
avatar: DEFAULT_AVATAR,
|
avatar: getUserAvatar(user.handle),
|
||||||
admin: user.admin,
|
admin: user.admin,
|
||||||
password: !!user.password,
|
password: !!user.password,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Load avatars for each user
|
return response.json(viewModels);
|
||||||
for (const user of viewModels) {
|
});
|
||||||
try {
|
|
||||||
const directory = getUserDirectories(user.handle);
|
endpoints.get('/me', async (request, response) => {
|
||||||
const pathToSettings = path.join(directory.root, 'settings.json');
|
if (!request.user) {
|
||||||
const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {};
|
return response.sendStatus(401);
|
||||||
const avatarFile = settings?.power_user?.default_persona || settings?.user_avatar;
|
|
||||||
if (!avatarFile) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const avatarPath = path.join(directory.avatars, avatarFile);
|
|
||||||
if (!fs.existsSync(avatarPath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const mimeType = mime.lookup(avatarPath);
|
|
||||||
const base64Content = fs.readFileSync(avatarPath, 'base64');
|
|
||||||
user.avatar = `data:${mimeType};base64,${base64Content}`;
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json(viewModels);
|
const user = request.user.profile;
|
||||||
|
const viewModel = {
|
||||||
|
handle: user.handle,
|
||||||
|
name: user.name,
|
||||||
|
avatar: getUserAvatar(user.handle),
|
||||||
|
admin: user.admin,
|
||||||
|
password: !!user.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response.json(viewModel);
|
||||||
});
|
});
|
||||||
|
|
||||||
endpoints.post('/recover-step1', jsonParser, async (request, response) => {
|
endpoints.post('/recover-step1', jsonParser, async (request, response) => {
|
||||||
|
@ -584,9 +627,8 @@ endpoints.post('/recover-step1', jsonParser, async (request, response) => {
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {User[]} */
|
/** @type {User} */
|
||||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
const user = users.find(user => user.handle === request.body.handle);
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log('Recover step 1 failed: User not found');
|
console.log('Recover step 1 failed: User not found');
|
||||||
|
@ -610,9 +652,8 @@ endpoints.post('/recover-step2', jsonParser, async (request, response) => {
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {User[]} */
|
/** @type {User} */
|
||||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
const user = users.find(user => user.handle === request.body.handle);
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log('Recover step 2 failed: User not found');
|
console.log('Recover step 2 failed: User not found');
|
||||||
|
@ -634,7 +675,7 @@ endpoints.post('/recover-step2', jsonParser, async (request, response) => {
|
||||||
const salt = getPasswordSalt();
|
const salt = getPasswordSalt();
|
||||||
user.password = getPasswordHash(request.body.password, salt);
|
user.password = getPasswordHash(request.body.password, salt);
|
||||||
user.salt = salt;
|
user.salt = salt;
|
||||||
await storage.setItem(STORAGE_KEYS.users, users);
|
await storage.setItem(toKey(user.handle), user);
|
||||||
return response.sendStatus(204);
|
return response.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -644,9 +685,8 @@ endpoints.post('/login', jsonParser, async (request, response) => {
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {User[]} */
|
/** @type {User} */
|
||||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
const user = users.find(user => user.handle === request.body.handle);
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log('Login failed: User not found');
|
console.log('Login failed: User not found');
|
||||||
|
@ -663,22 +703,110 @@ endpoints.post('/login', jsonParser, async (request, response) => {
|
||||||
return response.status(401).json({ error: 'Incorrect password' });
|
return response.status(401).json({ error: 'Incorrect password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!request.session) {
|
||||||
|
console.error('Login failed: Session not available');
|
||||||
|
return response.status(500).json({ error: 'Session not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate session to prevent session fixation attacks
|
||||||
|
await new Promise(resolve => request.session?.regenerate(resolve));
|
||||||
|
|
||||||
|
|
||||||
request.session.handle = user.handle;
|
request.session.handle = user.handle;
|
||||||
console.log('Login successful:', user.handle, request.session);
|
console.log('Login successful:', user.handle, request.session);
|
||||||
return response.json({ handle: user.handle });
|
return response.json({ handle: user.handle });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
endpoints.post('/logout', async (request, response) => {
|
||||||
|
request.session?.destroy(() => {
|
||||||
|
return response.sendStatus(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
endpoints.post('/disable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||||
|
if (!request.body.handle) {
|
||||||
|
console.log('Disable user failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.body.handle === request.user.profile.handle) {
|
||||||
|
console.log('Disable user failed: Cannot disable yourself');
|
||||||
|
return response.status(400).json({ error: 'Cannot disable yourself' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Disable user failed: User not found');
|
||||||
|
return response.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.enabled = false;
|
||||||
|
await storage.setItem(toKey(request.body.handle), user);
|
||||||
|
return response.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
endpoints.post('/enable', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||||
|
if (!request.body.handle) {
|
||||||
|
console.log('Enable user failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Enable user failed: User not found');
|
||||||
|
return response.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.enabled = true;
|
||||||
|
await storage.setItem(toKey(request.body.handle), user);
|
||||||
|
return response.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
endpoints.post('/change-password', jsonParser, async (request, response) => {
|
||||||
|
if (!request.body.handle || !request.body.oldPassword || !request.body.newPassword) {
|
||||||
|
console.log('Change password failed: Missing required fields');
|
||||||
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {User} */
|
||||||
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Change password failed: User not found');
|
||||||
|
return response.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.enabled) {
|
||||||
|
console.log('Change password failed: User is disabled');
|
||||||
|
return response.status(403).json({ error: 'User is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = getPasswordSalt();
|
||||||
|
user.password = getPasswordHash(request.body.newPassword, salt);
|
||||||
|
user.salt = salt;
|
||||||
|
await storage.setItem(toKey(request.body.handle), user);
|
||||||
|
return response.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
endpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => {
|
endpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => {
|
||||||
if (!request.body.handle || !request.body.name) {
|
if (!request.body.handle || !request.body.name) {
|
||||||
console.log('Create user failed: Missing required fields');
|
console.log('Create user failed: Missing required fields');
|
||||||
return response.status(400).json({ error: 'Missing required fields' });
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {User[]} */
|
const handles = await getAllUserHandles();
|
||||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
|
||||||
const handle = slugify(request.body.handle, { lower: true, trim: true });
|
const handle = slugify(request.body.handle, { lower: true, trim: true });
|
||||||
|
|
||||||
if (users.some(user => user.handle === request.body.handle)) {
|
if (handles.some(x => x === handle)) {
|
||||||
console.log('Create user failed: User with that handle already exists');
|
console.log('Create user failed: User with that handle already exists');
|
||||||
return response.status(409).json({ error: 'User already exists' });
|
return response.status(409).json({ error: 'User already exists' });
|
||||||
}
|
}
|
||||||
|
@ -694,11 +822,10 @@ endpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, re
|
||||||
password: password,
|
password: password,
|
||||||
salt: salt,
|
salt: salt,
|
||||||
admin: !!request.body.admin,
|
admin: !!request.body.admin,
|
||||||
enabled: !!request.body.enabled,
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
users.push(newUser);
|
await storage.setItem(toKey(handle), newUser);
|
||||||
await storage.setItem(STORAGE_KEYS.users, users);
|
|
||||||
|
|
||||||
// Create user directories
|
// Create user directories
|
||||||
console.log('Creating data directories for', newUser.handle);
|
console.log('Creating data directories for', newUser.handle);
|
||||||
|
|
Loading…
Reference in New Issue