Merge branch 'neo-server' of https://github.com/SillyTavern/SillyTavern into neo-server
This commit is contained in:
commit
5ad498f3ca
|
@ -1,10 +1,12 @@
|
|||
# -- NETWORK CONFIGURATION --
|
||||
# -- DATA CONFIGURATION --
|
||||
# Root directory for user data storage
|
||||
dataRoot: ./data
|
||||
# -- SERVER CONFIGURATION --
|
||||
# Listen for incoming connections
|
||||
listen: false
|
||||
# Server port
|
||||
port: 8000
|
||||
# -- SECURITY CONFIGURATION --
|
||||
# Toggle whitelist mode
|
||||
whitelistMode: true
|
||||
# Whitelist of allowed IP addresses
|
||||
|
@ -18,6 +20,10 @@ basicAuthUser:
|
|||
password: "password"
|
||||
# Enables CORS proxy middleware
|
||||
enableCorsProxy: false
|
||||
# Enable multi-user mode
|
||||
enableUserAccounts: true
|
||||
# Used to sign session cookies. Will be auto-generated if not set
|
||||
cookieSecret: ''
|
||||
# Disable security checks - NOT RECOMMENDED
|
||||
securityOverride: false
|
||||
# -- ADVANCED CONFIGURATION --
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"exclude": [
|
||||
"node_modules",
|
||||
"**/node_modules/*",
|
||||
"public/lib"
|
||||
"public/lib",
|
||||
"backups/*",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
"form-data": "^4.0.0",
|
||||
"google-translate-api-browser": "^3.0.1",
|
||||
"gpt3-tokenizer": "^1.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"ip-matching": "^2.1.2",
|
||||
"ipaddr.js": "^2.0.1",
|
||||
"jimp": "^0.22.10",
|
||||
|
@ -32,6 +33,7 @@
|
|||
"mime-types": "^2.1.35",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-fetch": "^2.6.11",
|
||||
"node-persist": "^4.0.1",
|
||||
"open": "^8.4.2",
|
||||
"png-chunk-text": "^1.0.0",
|
||||
"png-chunks-encode": "^1.0.0",
|
||||
|
@ -40,6 +42,8 @@
|
|||
"sanitize-filename": "^1.6.3",
|
||||
"sillytavern-transformers": "^2.14.6",
|
||||
"simple-git": "^3.19.1",
|
||||
"slugify": "^1.6.6",
|
||||
"uuid": "^9.0.1",
|
||||
"vectra": "^0.2.2",
|
||||
"wavefile": "^11.0.0",
|
||||
"write-file-atomic": "^5.0.1",
|
||||
|
@ -2173,6 +2177,14 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz",
|
||||
"integrity": "sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"funding": [
|
||||
|
@ -2735,6 +2747,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-persist": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/node-persist/-/node-persist-4.0.1.tgz",
|
||||
"integrity": "sha512-QtRjwAlcOQChQpfG6odtEhxYmA3nS5XYr+bx9JRjwahl1TM3sm9J3CCn51/MI0eoHRb2DrkEsCOFo8sq8jG5sQ==",
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "6.1.0",
|
||||
"license": "MIT",
|
||||
|
@ -3501,6 +3521,14 @@
|
|||
"version": "1.0.1",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
|
@ -3694,8 +3722,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.0",
|
||||
"license": "MIT",
|
||||
"version": "9.0.1",
|
||||
"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": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
"form-data": "^4.0.0",
|
||||
"google-translate-api-browser": "^3.0.1",
|
||||
"gpt3-tokenizer": "^1.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"ip-matching": "^2.1.2",
|
||||
"ipaddr.js": "^2.0.1",
|
||||
"jimp": "^0.22.10",
|
||||
|
@ -22,6 +23,7 @@
|
|||
"mime-types": "^2.1.35",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-fetch": "^2.6.11",
|
||||
"node-persist": "^4.0.1",
|
||||
"open": "^8.4.2",
|
||||
"png-chunk-text": "^1.0.0",
|
||||
"png-chunks-encode": "^1.0.0",
|
||||
|
@ -30,6 +32,8 @@
|
|||
"sanitize-filename": "^1.6.3",
|
||||
"sillytavern-transformers": "^2.14.6",
|
||||
"simple-git": "^3.19.1",
|
||||
"slugify": "^1.6.6",
|
||||
"uuid": "^9.0.1",
|
||||
"vectra": "^0.2.2",
|
||||
"wavefile": "^11.0.0",
|
||||
"write-file-atomic": "^5.0.1",
|
||||
|
|
41
server.js
41
server.js
|
@ -1,7 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
// native node modules
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
@ -21,6 +20,7 @@ const compression = require('compression');
|
|||
const cookieParser = require('cookie-parser');
|
||||
const multer = require('multer');
|
||||
const responseTime = require('response-time');
|
||||
const helmet = require('helmet').default;
|
||||
|
||||
// net related library imports
|
||||
const net = require('net');
|
||||
|
@ -35,10 +35,11 @@ util.inspect.defaultOptions.depth = 4;
|
|||
// local library imports
|
||||
const {
|
||||
initUserStorage,
|
||||
ensurePublicDirectoriesExist,
|
||||
userDataMiddleware,
|
||||
getUserDirectories,
|
||||
getAllUserHandles,
|
||||
migrateUserData,
|
||||
getCsrfSecret,
|
||||
getCookieSecret,
|
||||
} = require('./src/users');
|
||||
const basicAuthMiddleware = require('./src/middleware/basicAuth');
|
||||
const whitelistMiddleware = require('./src/middleware/whitelist');
|
||||
|
@ -110,6 +111,9 @@ const serverDirectory = __dirname;
|
|||
process.chdir(serverDirectory);
|
||||
|
||||
const app = express();
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}));
|
||||
app.use(compression());
|
||||
app.use(responseTime());
|
||||
|
||||
|
@ -119,7 +123,7 @@ const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN);
|
|||
const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY);
|
||||
const basicAuthMode = getConfigValue('basicAuthMode', false);
|
||||
|
||||
const { UPLOADS_PATH, PUBLIC_DIRECTORIES } = require('./src/constants');
|
||||
const { UPLOADS_PATH } = require('./src/constants');
|
||||
|
||||
// CORS Settings //
|
||||
const CORS = cors({
|
||||
|
@ -132,14 +136,14 @@ app.use(CORS);
|
|||
if (listen && basicAuthMode) app.use(basicAuthMiddleware);
|
||||
|
||||
app.use(whitelistMiddleware(listen));
|
||||
app.use(userDataMiddleware());
|
||||
|
||||
// CSRF Protection //
|
||||
if (!cliArguments.disableCsrf) {
|
||||
const CSRF_SECRET = crypto.randomBytes(8).toString('hex');
|
||||
const COOKIES_SECRET = crypto.randomBytes(8).toString('hex');
|
||||
const COOKIES_SECRET = getCookieSecret();
|
||||
|
||||
const { generateToken, doubleCsrfProtection } = doubleCsrf({
|
||||
getSecret: () => CSRF_SECRET,
|
||||
getSecret: getCsrfSecret,
|
||||
cookieName: 'X-CSRF-Token',
|
||||
cookieOptions: {
|
||||
httpOnly: true,
|
||||
|
@ -218,7 +222,6 @@ if (enableCorsProxy) {
|
|||
}
|
||||
|
||||
app.use(express.static(process.cwd() + '/public', {}));
|
||||
app.use(userDataMiddleware());
|
||||
app.use('/', require('./src/users').router);
|
||||
|
||||
app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
|
||||
|
@ -476,9 +479,9 @@ const setupTasks = async function () {
|
|||
// in any order for encapsulation reasons, but right now it's unknown if that would break anything.
|
||||
await initUserStorage();
|
||||
await settingsEndpoint.init();
|
||||
ensurePublicDirectoriesExist();
|
||||
const directories = await ensurePublicDirectoriesExist();
|
||||
await migrateUserData();
|
||||
contentManager.checkForNewContent();
|
||||
await contentManager.checkForNewContent(directories);
|
||||
await ensureThumbnailCache();
|
||||
cleanUploads();
|
||||
|
||||
|
@ -567,21 +570,3 @@ if (cliArguments.ssl) {
|
|||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ const PUBLIC_DIRECTORIES = {
|
|||
extensions: 'public/scripts/extensions',
|
||||
};
|
||||
|
||||
const DEFAULT_AVATAR = '/img/ai4.png';
|
||||
|
||||
/**
|
||||
* @type {import('./users').UserDirectoryList}
|
||||
* @readonly
|
||||
|
@ -40,12 +42,19 @@ const USER_DIRECTORY_TEMPLATE = Object.freeze({
|
|||
vectors: 'vectors',
|
||||
});
|
||||
|
||||
/**
|
||||
* @type {import('./users').User}
|
||||
* @readonly
|
||||
*/
|
||||
const DEFAULT_USER = Object.freeze({
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
handle: 'user0',
|
||||
name: 'User',
|
||||
created: 0,
|
||||
password: '',
|
||||
admin: true,
|
||||
enabled: true,
|
||||
salt: '',
|
||||
});
|
||||
|
||||
const UNSAFE_EXTENSIONS = [
|
||||
|
@ -290,6 +299,7 @@ const OPENROUTER_KEYS = [
|
|||
|
||||
module.exports = {
|
||||
DEFAULT_USER,
|
||||
DEFAULT_AVATAR,
|
||||
PUBLIC_DIRECTORIES,
|
||||
USER_DIRECTORY_TEMPLATE,
|
||||
UNSAFE_EXTENSIONS,
|
||||
|
|
|
@ -7,9 +7,14 @@ const { getConfigValue } = require('../util');
|
|||
const { jsonParser } = require('../express-common');
|
||||
const contentDirectory = path.join(process.cwd(), 'default/content');
|
||||
const contentIndexPath = path.join(contentDirectory, 'index.json');
|
||||
const { getAllUserHandles, getUserDirectories } = require('../users');
|
||||
const characterCardParser = require('../character-card-parser.js');
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentItem
|
||||
* @property {string} filename
|
||||
* @property {string} type
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the default presets from the content directory.
|
||||
* @param {import('../users').UserDirectoryList} directories User directories
|
||||
|
@ -58,7 +63,61 @@ function getDefaultPresetFile(filename) {
|
|||
}
|
||||
}
|
||||
|
||||
async function checkForNewContent() {
|
||||
/**
|
||||
* Seeds content for a user.
|
||||
* @param {ContentItem[]} contentIndex Content index
|
||||
* @param {import('../users').UserDirectoryList} directories User directories
|
||||
*/
|
||||
async function seedContentForUser(contentIndex, directories) {
|
||||
if (!fs.existsSync(directories.root)) {
|
||||
fs.mkdirSync(directories.root, { recursive: true });
|
||||
}
|
||||
|
||||
const contentLogPath = path.join(directories.root, 'content.log');
|
||||
const contentLog = getContentLog(contentLogPath);
|
||||
|
||||
for (const contentItem of contentIndex) {
|
||||
// If the content item is already in the log, skip it
|
||||
if (contentLog.includes(contentItem.filename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
contentLog.push(contentItem.filename);
|
||||
const contentPath = path.join(contentDirectory, contentItem.filename);
|
||||
|
||||
if (!fs.existsSync(contentPath)) {
|
||||
console.log(`Content file ${contentItem.filename} is missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentTarget = getTargetByType(contentItem.type, directories);
|
||||
|
||||
if (!contentTarget) {
|
||||
console.log(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const basePath = path.parse(contentItem.filename).base;
|
||||
const targetPath = path.join(process.cwd(), contentTarget, basePath);
|
||||
|
||||
if (fs.existsSync(targetPath)) {
|
||||
console.log(`Content file ${contentItem.filename} already exists in ${contentTarget}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
|
||||
console.log(`Content file ${contentItem.filename} copied to ${contentTarget}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(contentLogPath, contentLog.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for new content and seeds it for all users.
|
||||
* @param {import('../users').UserDirectoryList[]} directoriesList List of user directories
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function checkForNewContent(directoriesList) {
|
||||
try {
|
||||
if (getConfigValue('skipContentCheck', false)) {
|
||||
return;
|
||||
|
@ -66,52 +125,9 @@ async function checkForNewContent() {
|
|||
|
||||
const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
|
||||
const contentIndex = JSON.parse(contentIndexText);
|
||||
const userHandles = await getAllUserHandles();
|
||||
|
||||
for (const userHandle of userHandles) {
|
||||
const directories = getUserDirectories(userHandle);
|
||||
|
||||
if (!fs.existsSync(directories.root)) {
|
||||
fs.mkdirSync(directories.root, { recursive: true });
|
||||
}
|
||||
|
||||
const contentLogPath = path.join(directories.root, 'content.log');
|
||||
const contentLog = getContentLog(contentLogPath);
|
||||
|
||||
for (const contentItem of contentIndex) {
|
||||
// If the content item is already in the log, skip it
|
||||
if (contentLog.includes(contentItem.filename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
contentLog.push(contentItem.filename);
|
||||
const contentPath = path.join(contentDirectory, contentItem.filename);
|
||||
|
||||
if (!fs.existsSync(contentPath)) {
|
||||
console.log(`Content file ${contentItem.filename} is missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentTarget = getTargetByType(contentItem.type, directories);
|
||||
|
||||
if (!contentTarget) {
|
||||
console.log(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const basePath = path.parse(contentItem.filename).base;
|
||||
const targetPath = path.join(process.cwd(), contentTarget, basePath);
|
||||
|
||||
if (fs.existsSync(targetPath)) {
|
||||
console.log(`Content file ${contentItem.filename} already exists in ${contentTarget}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
|
||||
console.log(`Content file ${contentItem.filename} copied to ${contentTarget}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(contentLogPath, contentLog.join('\n'));
|
||||
for (const directories of directoriesList) {
|
||||
await seedContentForUser(contentIndex, directories);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Content check failed', err);
|
||||
|
|
310
src/users.js
310
src/users.js
|
@ -1,10 +1,25 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES } = require('./constants');
|
||||
const { getConfigValue, color, delay } = require('./util');
|
||||
const crypto = require('crypto');
|
||||
const storage = require('node-persist');
|
||||
const uuid = require('uuid');
|
||||
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 { readSecret, writeSecret } = require('./endpoints/secrets');
|
||||
const { jsonParser } = require('./express-common');
|
||||
const { checkForNewContent } = require('./endpoints/content-manager');
|
||||
|
||||
const DATA_ROOT = getConfigValue('dataRoot', './data');
|
||||
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
users: 'users',
|
||||
csrfSecret: 'csrfSecret',
|
||||
cookieSecret: 'cookieSecret',
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} User
|
||||
|
@ -13,6 +28,18 @@ const DATA_ROOT = getConfigValue('dataRoot', './data');
|
|||
* @property {string} name - The user's name. Displayed in the UI
|
||||
* @property {number} created - The timestamp when the user was created
|
||||
* @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} 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
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -46,6 +73,29 @@ const DATA_ROOT = getConfigValue('dataRoot', './data');
|
|||
* @property {string} vectors - The directory where the vectors are stored
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ensures that the content directories exist.
|
||||
* @returns {Promise<import('./users').UserDirectoryList[]>} - The list of user directories
|
||||
*/
|
||||
async function ensurePublicDirectoriesExist() {
|
||||
for (const dir of Object.values(PUBLIC_DIRECTORIES)) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const userHandles = await getAllUserHandles();
|
||||
const directoriesList = userHandles.map(handle => getUserDirectories(handle));
|
||||
for (const userDirectories of directoriesList) {
|
||||
for (const dir of Object.values(userDirectories)) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return directoriesList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform migration from the old user data format to the new one.
|
||||
*/
|
||||
|
@ -246,7 +296,55 @@ async function migrateUserData() {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function initUserStorage() {
|
||||
return Promise.resolve();
|
||||
await storage.init({
|
||||
dir: path.join(DATA_ROOT, '_storage'),
|
||||
});
|
||||
|
||||
const users = await storage.getItem('users');
|
||||
|
||||
if (!users) {
|
||||
await storage.setItem('users', [DEFAULT_USER]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cookie secret from the config. If it doesn't exist, generate a new one.
|
||||
* @returns {string} The cookie secret
|
||||
*/
|
||||
function getCookieSecret() {
|
||||
let secret = getConfigValue(STORAGE_KEYS.cookieSecret);
|
||||
|
||||
if (!secret) {
|
||||
console.warn(color.yellow('Cookie secret is missing from config.yaml. Generating a new one...'));
|
||||
secret = crypto.randomBytes(64).toString('base64');
|
||||
setConfigValue(STORAGE_KEYS.cookieSecret, secret);
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
function getPasswordSalt() {
|
||||
return crypto.randomBytes(16).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSRF secret from the storage.
|
||||
* @param {import('express').Request} [request] HTTP request object
|
||||
* @returns {string} The CSRF secret
|
||||
*/
|
||||
function getCsrfSecret(request) {
|
||||
if (!request || !request.user) {
|
||||
throw new Error('Request object is required to get the CSRF secret.');
|
||||
}
|
||||
|
||||
let csrfSecret = readSecret(request.user.directories, STORAGE_KEYS.csrfSecret);
|
||||
|
||||
if (!csrfSecret) {
|
||||
csrfSecret = crypto.randomBytes(64).toString('base64');
|
||||
writeSecret(request.user.directories, STORAGE_KEYS.csrfSecret, csrfSecret);
|
||||
}
|
||||
|
||||
return csrfSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -259,11 +357,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
|
||||
*/
|
||||
async function getAllUserHandles() {
|
||||
return [DEFAULT_USER.handle];
|
||||
const users = await storage.getItem(STORAGE_KEYS.users);
|
||||
return users.map(user => user.handle);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -328,6 +427,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.
|
||||
*/
|
||||
|
@ -340,13 +459,194 @@ router.use('/user/images/*', createRouteHandler(req => req.user.directories.user
|
|||
router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
|
||||
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);
|
||||
const directories = await ensurePublicDirectoriesExist();
|
||||
await checkForNewContent(directories);
|
||||
return response.json({ handle: newUser.handle });
|
||||
});
|
||||
|
||||
router.use('/api/users', endpoints);
|
||||
|
||||
module.exports = {
|
||||
initUserStorage,
|
||||
ensurePublicDirectoriesExist,
|
||||
getCurrentUserDirectories,
|
||||
getCurrentUserHandle,
|
||||
getAllUserHandles,
|
||||
getUserDirectories,
|
||||
userDataMiddleware,
|
||||
migrateUserData,
|
||||
getCsrfSecret,
|
||||
getCookieSecret,
|
||||
router,
|
||||
};
|
||||
|
|
51
src/util.js
51
src/util.js
|
@ -15,38 +15,19 @@ const { PUBLIC_DIRECTORIES } = require('./constants');
|
|||
* @returns {object} Config object
|
||||
*/
|
||||
function getConfig() {
|
||||
function getNewConfig() {
|
||||
try {
|
||||
const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.warn('Failed to read config.yaml');
|
||||
return {};
|
||||
}
|
||||
if (!fs.existsSync('./config.yaml')) {
|
||||
console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.'));
|
||||
console.error(color.red('The program will now exit.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function getLegacyConfig() {
|
||||
try {
|
||||
console.log(color.yellow('WARNING: config.conf is deprecated. Please run "npm run postinstall" to convert to config.yaml'));
|
||||
const config = require(path.join(process.cwd(), './config.conf'));
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.warn('Failed to read config.conf');
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.warn('Failed to read config.yaml');
|
||||
return {};
|
||||
}
|
||||
|
||||
if (fs.existsSync('./config.yaml')) {
|
||||
return getNewConfig();
|
||||
}
|
||||
|
||||
if (fs.existsSync('./config.conf')) {
|
||||
return getLegacyConfig();
|
||||
}
|
||||
|
||||
console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.'));
|
||||
console.error(color.red('The program will now exit.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -60,6 +41,17 @@ function getConfigValue(key, defaultValue = null) {
|
|||
return _.get(config, key, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value for the given key in the config object and writes it to the config.yaml file.
|
||||
* @param {string} key Key to set
|
||||
* @param {any} value Value to set
|
||||
*/
|
||||
function setConfigValue(key, value) {
|
||||
const config = getConfig();
|
||||
_.set(config, key, value);
|
||||
fs.writeFileSync('./config.yaml', yaml.stringify(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the Basic Auth header value for the given user and password.
|
||||
* @param {string} auth username:password
|
||||
|
@ -600,6 +592,7 @@ class Cache {
|
|||
module.exports = {
|
||||
getConfig,
|
||||
getConfigValue,
|
||||
setConfigValue,
|
||||
getVersion,
|
||||
getBasicAuthHeader,
|
||||
extractFileFromZipBuffer,
|
||||
|
|
Loading…
Reference in New Issue