Merge branch 'neo-server' of https://github.com/SillyTavern/SillyTavern into neo-server

This commit is contained in:
RossAscends 2024-04-08 02:22:46 +09:00
commit f8bf70f0cb
6 changed files with 200 additions and 40 deletions

7
index.d.ts vendored
View File

@ -10,3 +10,10 @@ declare global {
} }
} }
} }
declare module 'express-session' {
export interface SessionData {
handle: string;
// other properties...
}
}

78
package-lock.json generated
View File

@ -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,

View File

@ -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",

0
public/login.html Normal file
View File

View File

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

View File

@ -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 = {
profile: DEFAULT_USER,
directories: directories,
};
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 = { req.user = {
profile: DEFAULT_USER, profile: user,
directories: directories, directories: directories,
}; };
next(); 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);
@ -528,7 +599,7 @@ endpoints.post('/recover-step1', jsonParser, async (request, response) => {
} }
const mfaCode = Math.floor(Math.random() * 1000000).toString().padStart(6, '0'); 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)); console.log(color.blue(`${user.name} YOUR PASSWORD RECOVERY CODE IS: `) + color.magenta(mfaCode));
MFA_CACHE.set(user.handle, mfaCode); MFA_CACHE.set(user.handle, mfaCode);
return response.sendStatus(204); return response.sendStatus(204);
}); });
@ -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,