Merge branch 'neo-server' of https://github.com/SillyTavern/SillyTavern into neo-server
This commit is contained in:
commit
f8bf70f0cb
|
@ -10,3 +10,10 @@ declare global {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'express-session' {
|
||||||
|
export interface SessionData {
|
||||||
|
handle: string;
|
||||||
|
// other properties...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
"command-exists": "^1.2.9",
|
"command-exists": "^1.2.9",
|
||||||
"compression": "^1",
|
"compression": "^1",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
|
"cookie-session": "^2.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csrf-csrf": "^2.2.3",
|
"csrf-csrf": "^2.2.3",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
@ -1291,10 +1292,68 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-session": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-u73BDmR8QLGcs+Lprs0cfbcAPKl2HnPcjpwRXT41sEV4DRJ2+W0vJEEZkG31ofkx+HZflA70siRIjiTdIodmOQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"cookies": "0.9.1",
|
||||||
|
"debug": "3.2.7",
|
||||||
|
"on-headers": "~1.0.2",
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-session/node_modules/debug": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-session/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
|
},
|
||||||
|
"node_modules/cookie-session/node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookies": {
|
||||||
|
"version": "0.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
|
||||||
|
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"keygrip": "~1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
@ -2511,6 +2570,17 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/keygrip": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"tsscmp": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.3",
|
"version": "4.5.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
@ -3643,6 +3713,14 @@
|
||||||
"utf8-byte-length": "^1.0.1"
|
"utf8-byte-length": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tsscmp": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
"command-exists": "^1.2.9",
|
"command-exists": "^1.2.9",
|
||||||
"compression": "^1",
|
"compression": "^1",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
|
"cookie-session": "^2.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csrf-csrf": "^2.2.3",
|
"csrf-csrf": "^2.2.3",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
|
|
@ -136,7 +136,7 @@ app.use(CORS);
|
||||||
if (listen && basicAuthMode) app.use(basicAuthMiddleware);
|
if (listen && basicAuthMode) app.use(basicAuthMiddleware);
|
||||||
|
|
||||||
app.use(whitelistMiddleware(listen));
|
app.use(whitelistMiddleware(listen));
|
||||||
app.use(userDataMiddleware());
|
app.use(userDataMiddleware(app));
|
||||||
|
|
||||||
// CSRF Protection //
|
// CSRF Protection //
|
||||||
if (!cliArguments.disableCsrf) {
|
if (!cliArguments.disableCsrf) {
|
||||||
|
@ -228,6 +228,10 @@ app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).
|
||||||
app.get('/', function (request, response) {
|
app.get('/', function (request, response) {
|
||||||
response.sendFile(process.cwd() + '/public/index.html');
|
response.sendFile(process.cwd() + '/public/index.html');
|
||||||
});
|
});
|
||||||
|
// Host login page
|
||||||
|
app.get('/login', (_request, response) => {
|
||||||
|
return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
|
||||||
|
});
|
||||||
app.get('/version', async function (_, response) {
|
app.get('/version', async function (_, response) {
|
||||||
const data = await getVersion();
|
const data = await getVersion();
|
||||||
response.send(data);
|
response.send(data);
|
||||||
|
|
144
src/users.js
144
src/users.js
|
@ -1,19 +1,32 @@
|
||||||
|
// Native Node Modules
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Express and other dependencies
|
||||||
const storage = require('node-persist');
|
const storage = require('node-persist');
|
||||||
|
const express = require('express');
|
||||||
|
const cookieSession = require('cookie-session');
|
||||||
const uuid = require('uuid');
|
const uuid = require('uuid');
|
||||||
const mime = require('mime-types');
|
const mime = require('mime-types');
|
||||||
const slugify = require('slugify').default;
|
const slugify = require('slugify').default;
|
||||||
|
|
||||||
|
// Local imports
|
||||||
|
const { jsonParser } = require('./express-common');
|
||||||
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants');
|
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, DEFAULT_AVATAR } = require('./constants');
|
||||||
const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util');
|
const { getConfigValue, color, delay, setConfigValue, Cache } = require('./util');
|
||||||
const express = require('express');
|
|
||||||
const { readSecret, writeSecret } = require('./endpoints/secrets');
|
const { readSecret, writeSecret } = require('./endpoints/secrets');
|
||||||
const { jsonParser } = require('./express-common');
|
|
||||||
const { checkForNewContent } = require('./endpoints/content-manager');
|
const { checkForNewContent } = require('./endpoints/content-manager');
|
||||||
|
|
||||||
const DATA_ROOT = getConfigValue('dataRoot', './data');
|
const DATA_ROOT = getConfigValue('dataRoot', './data');
|
||||||
|
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
|
||||||
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
||||||
|
/**
|
||||||
|
* Cache for user directories.
|
||||||
|
* @type {Map<string, UserDirectoryList>}
|
||||||
|
*/
|
||||||
|
const DIRECTORIES_CACHE = new Map();
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
users: 'users',
|
users: 'users',
|
||||||
|
@ -298,6 +311,7 @@ async function migrateUserData() {
|
||||||
async function initUserStorage() {
|
async function initUserStorage() {
|
||||||
await storage.init({
|
await storage.init({
|
||||||
dir: path.join(DATA_ROOT, '_storage'),
|
dir: path.join(DATA_ROOT, '_storage'),
|
||||||
|
ttl: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const users = await storage.getItem('users');
|
const users = await storage.getItem('users');
|
||||||
|
@ -323,10 +337,34 @@ function getCookieSecret() {
|
||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a random password salt.
|
||||||
|
* @returns {string} The password salt
|
||||||
|
*/
|
||||||
function getPasswordSalt() {
|
function getPasswordSalt() {
|
||||||
return crypto.randomBytes(16).toString('base64');
|
return crypto.randomBytes(16).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the session name for the current server.
|
||||||
|
* @returns {string} The session name
|
||||||
|
*/
|
||||||
|
function getCookieSessionName() {
|
||||||
|
// Get server hostname and hash it to generate a session suffix
|
||||||
|
const suffix = crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 8);
|
||||||
|
return `session-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
@ -347,15 +385,6 @@ function getCsrfSecret(request) {
|
||||||
return csrfSecret;
|
return csrfSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a user for the current request. Hard coded to return the default user.
|
|
||||||
* @param {import('express').Request} _req - The request object. Currently unused.
|
|
||||||
* @returns {Promise<string>} - The user's handle
|
|
||||||
*/
|
|
||||||
async function getCurrentUserHandle(_req) {
|
|
||||||
return DEFAULT_USER.handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a list of all user handles.
|
* Gets a list of all user handles.
|
||||||
* @returns {Promise<string[]>} - The list of user handles
|
* @returns {Promise<string[]>} - The list of user handles
|
||||||
|
@ -365,15 +394,6 @@ async function getAllUserHandles() {
|
||||||
return users.map(user => user.handle);
|
return users.map(user => user.handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the directories listing for the provided user.
|
|
||||||
* @param {import('express').Request} req - The request object
|
|
||||||
* @returns {Promise<UserDirectoryList>} - The user's directories like {worlds: 'data/user0/worlds/', ...
|
|
||||||
*/
|
|
||||||
async function getCurrentUserDirectories(req) {
|
|
||||||
const handle = await getCurrentUserHandle(req);
|
|
||||||
return getUserDirectories(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the directories listing for the provided user.
|
* Gets the directories listing for the provided user.
|
||||||
|
@ -381,18 +401,35 @@ async function getCurrentUserDirectories(req) {
|
||||||
* @returns {UserDirectoryList} User directories
|
* @returns {UserDirectoryList} User directories
|
||||||
*/
|
*/
|
||||||
function getUserDirectories(handle) {
|
function getUserDirectories(handle) {
|
||||||
|
if (DIRECTORIES_CACHE.has(handle)) {
|
||||||
|
const cache = DIRECTORIES_CACHE.get(handle);
|
||||||
|
if (cache) {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const directories = structuredClone(USER_DIRECTORY_TEMPLATE);
|
const directories = structuredClone(USER_DIRECTORY_TEMPLATE);
|
||||||
for (const key in directories) {
|
for (const key in directories) {
|
||||||
directories[key] = path.join(DATA_ROOT, handle, USER_DIRECTORY_TEMPLATE[key]);
|
directories[key] = path.join(DATA_ROOT, handle, USER_DIRECTORY_TEMPLATE[key]);
|
||||||
}
|
}
|
||||||
|
DIRECTORIES_CACHE.set(handle, directories);
|
||||||
return directories;
|
return directories;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* @returns {import('express').RequestHandler}
|
* @returns {import('express').RequestHandler}
|
||||||
*/
|
*/
|
||||||
function userDataMiddleware() {
|
function userDataMiddleware(app) {
|
||||||
|
app.use(cookieSession({
|
||||||
|
name: getCookieSessionName(),
|
||||||
|
sameSite: 'strict',
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
secret: getCookieSecret(),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to add user data to the request object.
|
* Middleware to add user data to the request object.
|
||||||
* @param {import('express').Request} req Request object
|
* @param {import('express').Request} req Request object
|
||||||
|
@ -400,12 +437,56 @@ function userDataMiddleware() {
|
||||||
* @param {import('express').NextFunction} next Next function
|
* @param {import('express').NextFunction} next Next function
|
||||||
*/
|
*/
|
||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
const directories = await getCurrentUserDirectories(req);
|
// Skip for login page
|
||||||
|
if (req.path === '/login') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user accounts are disabled, use the default user
|
||||||
|
if (!ENABLE_ACCOUNTS) {
|
||||||
|
const handle = DEFAULT_USER.handle;
|
||||||
|
const directories = getUserDirectories(handle);
|
||||||
req.user = {
|
req.user = {
|
||||||
profile: DEFAULT_USER,
|
profile: DEFAULT_USER,
|
||||||
directories: directories,
|
directories: directories,
|
||||||
};
|
};
|
||||||
next();
|
return next();
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// If we have the only user and it's not password protected, use it
|
||||||
|
if (!handle && users.length === 1 && !users[0].password) {
|
||||||
|
handle = users[0].handle;
|
||||||
|
req.session.handle = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handle) {
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users.find(user => user.handle === handle);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error('User not found:', handle);
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.enabled) {
|
||||||
|
console.error('User is disabled:', handle);
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = getUserDirectories(handle);
|
||||||
|
req.user = {
|
||||||
|
profile: user,
|
||||||
|
directories: directories,
|
||||||
|
};
|
||||||
|
return next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -461,16 +542,6 @@ router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.us
|
||||||
|
|
||||||
const endpoints = express.Router();
|
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) => {
|
endpoints.get('/list', async (_request, response) => {
|
||||||
/** @type {User[]} */
|
/** @type {User[]} */
|
||||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
const users = await storage.getItem(STORAGE_KEYS.users);
|
||||||
|
@ -587,12 +658,13 @@ endpoints.post('/login', jsonParser, async (request, response) => {
|
||||||
return response.status(403).json({ error: 'User is disabled' });
|
return response.status(403).json({ error: 'User is disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.password !== getPasswordHash(request.body.password, user.salt)) {
|
if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) {
|
||||||
console.log('Login failed: Incorrect password');
|
console.log('Login failed: Incorrect password');
|
||||||
return response.status(401).json({ error: 'Incorrect password' });
|
return response.status(401).json({ error: 'Incorrect password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Login successful:', user.handle);
|
request.session.handle = user.handle;
|
||||||
|
console.log('Login successful:', user.handle, request.session);
|
||||||
return response.json({ handle: user.handle });
|
return response.json({ handle: user.handle });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -640,8 +712,6 @@ router.use('/api/users', endpoints);
|
||||||
module.exports = {
|
module.exports = {
|
||||||
initUserStorage,
|
initUserStorage,
|
||||||
ensurePublicDirectoriesExist,
|
ensurePublicDirectoriesExist,
|
||||||
getCurrentUserDirectories,
|
|
||||||
getCurrentUserHandle,
|
|
||||||
getAllUserHandles,
|
getAllUserHandles,
|
||||||
getUserDirectories,
|
getUserDirectories,
|
||||||
userDataMiddleware,
|
userDataMiddleware,
|
||||||
|
|
Loading…
Reference in New Issue