diff --git a/default/config.yaml b/default/config.yaml index 506a270e9..3e914009a 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -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 -- diff --git a/jsconfig.json b/jsconfig.json index bcf9db917..e4298105a 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -13,6 +13,7 @@ "exclude": [ "node_modules", "**/node_modules/*", - "public/lib" + "public/lib", + "backups/*", ] } diff --git a/package-lock.json b/package-lock.json index dca9e969d..b4e5d82c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index d491384c0..d358ef5d4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server.js b/server.js index de2123cd2..c20bd30b0 100644 --- a/server.js +++ b/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 }); - } - } - } -} diff --git a/src/constants.js b/src/constants.js index 01ca9af96..2c5bd5e7d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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, diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js index 202411043..905d8f1bc 100644 --- a/src/endpoints/content-manager.js +++ b/src/endpoints/content-manager.js @@ -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} + */ +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); diff --git a/src/users.js b/src/users.js index 6c9a62e53..41f69c0f4 100644 --- a/src/users.js +++ b/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} - 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} */ 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} - 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, }; diff --git a/src/util.js b/src/util.js index b823419a5..e1410eee8 100644 --- a/src/util.js +++ b/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,