Add user management endpoints

This commit is contained in:
Cohee
2024-04-07 17:44:40 +03:00
parent b07aef02c7
commit c6ffe4502a
7 changed files with 346 additions and 73 deletions

View File

@ -1,10 +1,12 @@
# -- NETWORK CONFIGURATION -- # -- DATA CONFIGURATION --
# Root directory for user data storage # Root directory for user data storage
dataRoot: ./data dataRoot: ./data
# -- SERVER CONFIGURATION --
# Listen for incoming connections # Listen for incoming connections
listen: false listen: false
# Server port # Server port
port: 8000 port: 8000
# -- SECURITY CONFIGURATION --
# Toggle whitelist mode # Toggle whitelist mode
whitelistMode: true whitelistMode: true
# Whitelist of allowed IP addresses # Whitelist of allowed IP addresses
@ -18,6 +20,8 @@ basicAuthUser:
password: "password" password: "password"
# Enables CORS proxy middleware # Enables CORS proxy middleware
enableCorsProxy: false enableCorsProxy: false
# Enable multi-user mode
enableUserAccounts: true
# Used to sign session cookies. Will be auto-generated if not set # Used to sign session cookies. Will be auto-generated if not set
cookieSecret: '' cookieSecret: ''
# Disable security checks - NOT RECOMMENDED # Disable security checks - NOT RECOMMENDED

19
package-lock.json generated
View File

@ -41,6 +41,8 @@
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sillytavern-transformers": "^2.14.6", "sillytavern-transformers": "^2.14.6",
"simple-git": "^3.19.1", "simple-git": "^3.19.1",
"slugify": "^1.6.6",
"uuid": "^9.0.1",
"vectra": "^0.2.2", "vectra": "^0.2.2",
"wavefile": "^11.0.0", "wavefile": "^11.0.0",
"write-file-atomic": "^5.0.1", "write-file-atomic": "^5.0.1",
@ -3510,6 +3512,14 @@
"version": "1.0.1", "version": "1.0.1",
"license": "MIT" "license": "MIT"
}, },
"node_modules/slugify": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz",
"integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"license": "MIT", "license": "MIT",
@ -3703,8 +3713,13 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.0", "version": "9.0.1",
"license": "MIT", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }

View File

@ -31,6 +31,8 @@
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sillytavern-transformers": "^2.14.6", "sillytavern-transformers": "^2.14.6",
"simple-git": "^3.19.1", "simple-git": "^3.19.1",
"slugify": "^1.6.6",
"uuid": "^9.0.1",
"vectra": "^0.2.2", "vectra": "^0.2.2",
"wavefile": "^11.0.0", "wavefile": "^11.0.0",
"write-file-atomic": "^5.0.1", "write-file-atomic": "^5.0.1",

View File

@ -35,8 +35,6 @@ util.inspect.defaultOptions.depth = 4;
const { const {
initUserStorage, initUserStorage,
userDataMiddleware, userDataMiddleware,
getUserDirectories,
getAllUserHandles,
migrateUserData, migrateUserData,
getCsrfSecret, getCsrfSecret,
getCookieSecret, getCookieSecret,
@ -120,7 +118,7 @@ const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN);
const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY); const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY);
const basicAuthMode = getConfigValue('basicAuthMode', false); const basicAuthMode = getConfigValue('basicAuthMode', false);
const { UPLOADS_PATH, PUBLIC_DIRECTORIES } = require('./src/constants'); const { UPLOADS_PATH } = require('./src/constants');
// CORS Settings // // CORS Settings //
const CORS = cors({ const CORS = cors({
@ -476,7 +474,7 @@ const setupTasks = async function () {
// in any order for encapsulation reasons, but right now it's unknown if that would break anything. // in any order for encapsulation reasons, but right now it's unknown if that would break anything.
await initUserStorage(); await initUserStorage();
await settingsEndpoint.init(); await settingsEndpoint.init();
ensurePublicDirectoriesExist(); await contentManager.ensurePublicDirectoriesExist();
await migrateUserData(); await migrateUserData();
contentManager.checkForNewContent(); contentManager.checkForNewContent();
await ensureThumbnailCache(); await ensureThumbnailCache();
@ -567,21 +565,3 @@ if (cliArguments.ssl) {
setupTasks, setupTasks,
); );
} }
async function ensurePublicDirectoriesExist() {
for (const dir of Object.values(PUBLIC_DIRECTORIES)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
const userHandles = await getAllUserHandles();
for (const handle of userHandles) {
const userDirectories = getUserDirectories(handle);
for (const dir of Object.values(userDirectories)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
}
}

View File

@ -5,6 +5,8 @@ const PUBLIC_DIRECTORIES = {
extensions: 'public/scripts/extensions', extensions: 'public/scripts/extensions',
}; };
const DEFAULT_AVATAR = '/img/ai4.png';
/** /**
* @type {import('./users').UserDirectoryList} * @type {import('./users').UserDirectoryList}
* @readonly * @readonly
@ -52,6 +54,7 @@ const DEFAULT_USER = Object.freeze({
password: '', password: '',
admin: true, admin: true,
enabled: true, enabled: true,
salt: '',
}); });
const UNSAFE_EXTENSIONS = [ const UNSAFE_EXTENSIONS = [
@ -296,6 +299,7 @@ const OPENROUTER_KEYS = [
module.exports = { module.exports = {
DEFAULT_USER, DEFAULT_USER,
DEFAULT_AVATAR,
PUBLIC_DIRECTORIES, PUBLIC_DIRECTORIES,
USER_DIRECTORY_TEMPLATE, USER_DIRECTORY_TEMPLATE,
UNSAFE_EXTENSIONS, UNSAFE_EXTENSIONS,

View File

@ -9,6 +9,35 @@ const contentDirectory = path.join(process.cwd(), 'default/content');
const contentIndexPath = path.join(contentDirectory, 'index.json'); const contentIndexPath = path.join(contentDirectory, 'index.json');
const { getAllUserHandles, getUserDirectories } = require('../users'); const { getAllUserHandles, getUserDirectories } = require('../users');
const characterCardParser = require('../character-card-parser.js'); const characterCardParser = require('../character-card-parser.js');
const { PUBLIC_DIRECTORIES } = require('../constants');
/**
* @typedef {Object} ContentItem
* @property {string} filename
* @property {string} type
*/
/**
* Ensures that the content directories exist.
* @returns {Promise<void>}
*/
async function ensurePublicDirectoriesExist() {
for (const dir of Object.values(PUBLIC_DIRECTORIES)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
const userHandles = await getAllUserHandles();
for (const handle of userHandles) {
const userDirectories = getUserDirectories(handle);
for (const dir of Object.values(userDirectories)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
}
}
/** /**
* Gets the default presets from the content directory. * Gets the default presets from the content directory.
@ -58,17 +87,12 @@ function getDefaultPresetFile(filename) {
} }
} }
async function checkForNewContent() { /**
try { * Seeds content for a user.
if (getConfigValue('skipContentCheck', false)) { * @param {ContentItem[]} contentIndex Content index
return; * @param {string} userHandle User handle
} */
async function seedContentForUser(contentIndex, userHandle) {
const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
const contentIndex = JSON.parse(contentIndexText);
const userHandles = await getAllUserHandles();
for (const userHandle of userHandles) {
const directories = getUserDirectories(userHandle); const directories = getUserDirectories(userHandle);
if (!fs.existsSync(directories.root)) { if (!fs.existsSync(directories.root)) {
@ -112,6 +136,30 @@ async function checkForNewContent() {
} }
fs.writeFileSync(contentLogPath, contentLog.join('\n')); fs.writeFileSync(contentLogPath, contentLog.join('\n'));
}
/**
* Checks for new content and seeds it for all users.
* @param {string} [userHandle] User to check the content for (optional)
* @returns {Promise<void>}
*/
async function checkForNewContent(userHandle) {
try {
if (getConfigValue('skipContentCheck', false)) {
return;
}
const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
const contentIndex = JSON.parse(contentIndexText);
const userHandles = await getAllUserHandles();
if (userHandle && userHandles.includes(userHandle)) {
await seedContentForUser(contentIndex, userHandle);
return;
}
for (const userHandle of userHandles) {
await seedContentForUser(contentIndex, userHandle);
} }
} catch (err) { } catch (err) {
console.log('Content check failed', err); console.log('Content check failed', err);
@ -461,6 +509,7 @@ router.post('/importUUID', jsonParser, async (request, response) => {
}); });
module.exports = { module.exports = {
ensurePublicDirectoriesExist,
checkForNewContent, checkForNewContent,
getDefaultPresets, getDefaultPresets,
getDefaultPresetFile, getDefaultPresetFile,

View File

@ -2,12 +2,18 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const storage = require('node-persist'); const storage = require('node-persist');
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES } = require('./constants'); const uuid = require('uuid');
const { getConfigValue, color, delay, setConfigValue } = require('./util'); const mime = require('mime-types');
const slugify = require('slugify').default;
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants');
const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util');
const express = require('express'); const express = require('express');
const { readSecret, writeSecret } = require('./endpoints/secrets'); const { readSecret, writeSecret } = require('./endpoints/secrets');
const { jsonParser } = require('./express-common');
const contentManager = require('./endpoints/content-manager');
const DATA_ROOT = getConfigValue('dataRoot', './data'); const DATA_ROOT = getConfigValue('dataRoot', './data');
const MFA_CACHE = new Cache(5 * 60 * 1000);
const STORAGE_KEYS = { const STORAGE_KEYS = {
users: 'users', users: 'users',
@ -22,10 +28,20 @@ const STORAGE_KEYS = {
* @property {string} name - The user's name. Displayed in the UI * @property {string} name - The user's name. Displayed in the UI
* @property {number} created - The timestamp when the user was created * @property {number} created - The timestamp when the user was created
* @property {string} password - SHA256 hash of the user's password * @property {string} password - SHA256 hash of the user's password
* @property {string} salt - Salt used for hashing the password
* @property {boolean} enabled - Whether the user is enabled * @property {boolean} enabled - Whether the user is enabled
* @property {boolean} admin - Whether the user is an admin (can manage other users) * @property {boolean} admin - Whether the user is an admin (can manage other users)
*/ */
/**
* @typedef {Object} UserViewModel
* @property {string} handle - The user's short handle. Used for directories and other references
* @property {string} name - The user's name. Displayed in the UI
* @property {string} avatar - The user's avatar image
* @property {boolean} admin - Whether the user is an admin (can manage other users)
* @property {boolean} password - Whether the user is password protected
*/
/** /**
* @typedef {Object} UserDirectoryList * @typedef {Object} UserDirectoryList
* @property {string} root - The root directory for the user * @property {string} root - The root directory for the user
@ -284,6 +300,10 @@ function getCookieSecret() {
return secret; return secret;
} }
function getPasswordSalt() {
return crypto.randomBytes(16).toString('base64');
}
/** /**
* Get the CSRF secret from the storage. * Get the CSRF secret from the storage.
* @param {import('express').Request} [request] HTTP request object * @param {import('express').Request} [request] HTTP request object
@ -314,11 +334,12 @@ async function getCurrentUserHandle(_req) {
} }
/** /**
* Gets a list of all user handles. Currently hard coded to return the default user's handle. * Gets a list of all user handles.
* @returns {Promise<string[]>} - The list of user handles * @returns {Promise<string[]>} - The list of user handles
*/ */
async function getAllUserHandles() { async function getAllUserHandles() {
return [DEFAULT_USER.handle]; const users = await storage.getItem(STORAGE_KEYS.users);
return users.map(user => user.handle);
} }
/** /**
@ -383,6 +404,26 @@ function createRouteHandler(directoryFn) {
}; };
} }
/**
* Verifies that the current user is an admin.
* @param {import('express').Request} request Request object
* @param {import('express').Response} response Response object
* @param {import('express').NextFunction} next Next function
* @returns {any}
*/
function requireAdminMiddleware(request, response, next) {
if (!request.user) {
return response.sendStatus(401);
}
if (request.user.profile.admin) {
return next();
}
console.warn('Unauthorized access to admin endpoint:', request.originalUrl);
return response.sendStatus(403);
}
/** /**
* Express router for serving files from the user's directories. * Express router for serving files from the user's directories.
*/ */
@ -395,6 +436,184 @@ router.use('/user/images/*', createRouteHandler(req => req.user.directories.user
router.use('/user/files/*', createRouteHandler(req => req.user.directories.files)); router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions)); router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions));
const endpoints = express.Router();
/**
* Hashes a password using SHA256.
* @param {string} password Password to hash
* @param {string} salt Salt to use for hashing
* @returns {string} Hashed password
*/
function getPasswordHash(password, salt) {
return crypto.createHash('sha256').update(password + salt).digest('hex');
}
endpoints.get('/list', async (_request, response) => {
/** @type {User[]} */
const users = await storage.getItem(STORAGE_KEYS.users);
const viewModels = users.filter(x => x.enabled).map(user => ({
handle: user.handle,
name: user.name,
avatar: DEFAULT_AVATAR,
admin: user.admin,
password: !!user.password,
}));
// Load avatars for each user
for (const user of viewModels) {
try {
const directory = getUserDirectories(user.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) {
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);
});
endpoints.post('/recover-step1', jsonParser, async (request, response) => {
if (!request.body.handle) {
console.log('Recover step 1 failed: Missing required fields');
return response.status(400).json({ error: 'Missing required fields' });
}
/** @type {User[]} */
const users = await storage.getItem(STORAGE_KEYS.users);
const user = users.find(user => user.handle === request.body.handle);
if (!user) {
console.log('Recover step 1 failed: User not found');
return response.status(404).json({ error: 'User not found' });
}
if (!user.enabled) {
console.log('Recover step 1 failed: User is disabled');
return response.status(403).json({ error: 'User is disabled' });
}
const mfaCode = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
console.log(color.blue(`${user.name} YOUR PASSWORD RECOVERY CODE IS: `) + color.magenta(mfaCode));
MFA_CACHE.set(user.handle, mfaCode);
return response.sendStatus(204);
});
endpoints.post('/recover-step2', jsonParser, async (request, response) => {
if (!request.body.handle || !request.body.code || !request.body.password) {
console.log('Recover step 2 failed: Missing required fields');
return response.status(400).json({ error: 'Missing required fields' });
}
/** @type {User[]} */
const users = await storage.getItem(STORAGE_KEYS.users);
const user = users.find(user => user.handle === request.body.handle);
if (!user) {
console.log('Recover step 2 failed: User not found');
return response.status(404).json({ error: 'User not found' });
}
if (!user.enabled) {
console.log('Recover step 2 failed: User is disabled');
return response.status(403).json({ error: 'User is disabled' });
}
const mfaCode = MFA_CACHE.get(user.handle);
if (request.body.code !== mfaCode) {
console.log('Recover step 2 failed: Incorrect code');
return response.status(401).json({ error: 'Incorrect code' });
}
const salt = getPasswordSalt();
user.password = getPasswordHash(request.body.password, salt);
user.salt = salt;
await storage.setItem(STORAGE_KEYS.users, users);
return response.sendStatus(204);
});
endpoints.post('/login', jsonParser, async (request, response) => {
if (!request.body.handle || !request.body.password) {
console.log('Login failed: Missing required fields');
return response.status(400).json({ error: 'Missing required fields' });
}
/** @type {User[]} */
const users = await storage.getItem(STORAGE_KEYS.users);
const user = users.find(user => user.handle === request.body.handle);
if (!user) {
console.log('Login failed: User not found');
return response.status(401).json({ error: 'User not found' });
}
if (!user.enabled) {
console.log('Login failed: User is disabled');
return response.status(403).json({ error: 'User is disabled' });
}
if (user.password !== getPasswordHash(request.body.password, user.salt)) {
console.log('Login failed: Incorrect password');
return response.status(401).json({ error: 'Incorrect password' });
}
console.log('Login successful:', user.handle);
return response.json({ handle: user.handle });
});
endpoints.post('/create', requireAdminMiddleware, jsonParser, async (request, response) => {
if (!request.body.handle || !request.body.name) {
console.log('Create user failed: Missing required fields');
return response.status(400).json({ error: 'Missing required fields' });
}
/** @type {User[]} */
const users = await storage.getItem(STORAGE_KEYS.users);
const handle = slugify(request.body.handle, { lower: true, trim: true });
if (users.some(user => user.handle === request.body.handle)) {
console.log('Create user failed: User with that handle already exists');
return response.status(409).json({ error: 'User already exists' });
}
const salt = getPasswordSalt();
const password = request.body.password ? getPasswordHash(request.body.password, salt) : '';
const newUser = {
uuid: uuid.v4(),
handle: handle,
name: request.body.name || 'Anonymous',
created: Date.now(),
password: password,
salt: salt,
admin: !!request.body.admin,
enabled: !!request.body.enabled,
};
users.push(newUser);
await storage.setItem(STORAGE_KEYS.users, users);
// Create user directories
console.log('Creating data directories for', newUser.handle);
await contentManager.ensurePublicDirectoriesExist();
await contentManager.checkForNewContent(newUser.handle);
return response.json({ handle: newUser.handle });
});
router.use('/api/users', endpoints);
module.exports = { module.exports = {
initUserStorage, initUserStorage,
getCurrentUserDirectories, getCurrentUserDirectories,