Blame Raw
Cohee · 51ad27fb · · 1219 lines (43.3 KB)
1 contributor
1// Native Node Modules
2import path from 'node:path';
3import fs from 'node:fs';
4import crypto from 'node:crypto';
5import os from 'node:os';
6import process from 'node:process';
7import { Buffer } from 'node:buffer';
8
9// Express and other dependencies
10import storage from 'node-persist';
11import express from 'express';
12import mime from 'mime-types';
13import archiver from 'archiver';
14import _ from 'lodash';
15import { sync as writeFileAtomicSync } from 'write-file-atomic';
16import sanitize from 'sanitize-filename';
17import ipMatching from 'ip-matching';
18
19import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';
20import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js';
21import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js';
22import { getContentOfType } from './endpoints/content-manager.js';
23import { serverDirectory } from './server-directory.js';
24import { filterValidIpPatterns, getIpFromRequest } from './express-common.js';
25import { extensionsEnabledFeatureGuard } from './endpoints/extensions.js';
26
27export const KEY_PREFIX = 'user:';
28const AVATAR_PREFIX = 'avatar:';
29const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');
30const AUTHELIA_AUTH = getConfigValue('sso.autheliaAuth', false, 'boolean');
31const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean');
32const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');
33const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
34const TRUSTED_PROXIES = filterValidIpPatterns(getConfigValue('sso.trustedProxies', ['127.0.0.1', '::1']) ?? [], (entry, message) => `${color.red('Warning')}: Ignoring invalid sso.trustedProxies entry ${color.yellow(entry)} - ${message}`);
35
36/**
37 * Cache for user directories.
38 * @type {Map<string, UserDirectoryList>}
39 */
40const DIRECTORIES_CACHE = new Map();
41const PUBLIC_USER_AVATAR = '/img/default-user.png';
42const COOKIE_SECRET_PATH = 'cookie-secret.txt';
43
44const STORAGE_KEYS = {
45 csrfSecret: 'csrfSecret',
46 /**
47 * @deprecated Read from COOKIE_SECRET_PATH in DATA_ROOT instead.
48 */
49 cookieSecret: 'cookieSecret',
50};
51
52/**
53 * @typedef {Object} User
54 * @property {string} handle - The user's short handle. Used for directories and other references
55 * @property {string} name - The user's name. Displayed in the UI
56 * @property {number} created - The timestamp when the user was created
57 * @property {string} password - Scrypt hash of the user's password
58 * @property {string} salt - Salt used for hashing the password
59 * @property {boolean} enabled - Whether the user is enabled
60 * @property {boolean} admin - Whether the user is an admin (can manage other users)
61 */
62
63/**
64 * @typedef {Object} UserViewModel
65 * @property {string} handle - The user's short handle. Used for directories and other references
66 * @property {string} name - The user's name. Displayed in the UI
67 * @property {string} avatar - The user's avatar image
68 * @property {boolean} [admin] - Whether the user is an admin (can manage other users)
69 * @property {boolean} password - Whether the user is password protected
70 * @property {boolean} [enabled] - Whether the user is enabled
71 * @property {number} [created] - The timestamp when the user was created
72 */
73
74/**
75 * @typedef {Object} UserDirectoryList
76 * @property {string} root - The root directory for the user
77 * @property {string} thumbnails - The directory where the thumbnails are stored
78 * @property {string} thumbnailsBg - The directory where the background thumbnails are stored
79 * @property {string} thumbnailsAvatar - The directory where the avatar thumbnails are stored
80 * @property {string} thumbnailsPersona - The directory where the persona thumbnails are stored
81 * @property {string} worlds - The directory where the WI are stored
82 * @property {string} user - The directory where the user's public data is stored
83 * @property {string} avatars - The directory where the avatars are stored
84 * @property {string} userImages - The directory where the images are stored
85 * @property {string} groups - The directory where the groups are stored
86 * @property {string} groupChats - The directory where the group chats are stored
87 * @property {string} chats - The directory where the chats are stored
88 * @property {string} characters - The directory where the characters are stored
89 * @property {string} backgrounds - The directory where the backgrounds are stored
90 * @property {string} novelAI_Settings - The directory where the NovelAI settings are stored
91 * @property {string} koboldAI_Settings - The directory where the KoboldAI settings are stored
92 * @property {string} openAI_Settings - The directory where the OpenAI settings are stored
93 * @property {string} textGen_Settings - The directory where the TextGen settings are stored
94 * @property {string} themes - The directory where the themes are stored
95 * @property {string} movingUI - The directory where the moving UI data is stored
96 * @property {string} extensions - The directory where the extensions are stored
97 * @property {string} instruct - The directory where the instruct templates is stored
98 * @property {string} context - The directory where the context templates is stored
99 * @property {string} quickreplies - The directory where the quick replies are stored
100 * @property {string} assets - The directory where the assets are stored
101 * @property {string} comfyWorkflows - The directory where the ComfyUI workflows are stored
102 * @property {string} files - The directory where the uploaded files are stored
103 * @property {string} vectors - The directory where the vectors are stored
104 * @property {string} backups - The directory where the backups are stored
105 * @property {string} sysprompt - The directory where the system prompt data is stored
106 * @property {string} reasoning - The directory where the reasoning templates are stored
107 */
108
109/**
110 * Ensures that the content directories exist.
111 * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories
112 */
113export async function ensurePublicDirectoriesExist() {
114 for (const dir of Object.values(PUBLIC_DIRECTORIES)) {
115 if (!fs.existsSync(dir)) {
116 fs.mkdirSync(dir, { recursive: true });
117 }
118 }
119
120 const userHandles = await getAllUserHandles();
121 const directoriesList = userHandles.map(handle => getUserDirectories(handle));
122 for (const userDirectories of directoriesList) {
123 for (const dir of Object.values(userDirectories)) {
124 if (!fs.existsSync(dir)) {
125 fs.mkdirSync(dir, { recursive: true });
126 }
127 }
128 }
129 return directoriesList;
130}
131
132/**
133 * Prints an error message and exits the process if necessary
134 * @param {string} message The error message to print
135 * @returns {void}
136 */
137function logSecurityAlert(message) {
138 const { basicAuthMode, whitelistMode } = globalThis.COMMAND_LINE_ARGS;
139 if (basicAuthMode || whitelistMode) return; // safe!
140 console.error(color.red(message));
141 if (getConfigValue('securityOverride', false, 'boolean')) {
142 console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
143 return;
144 }
145 process.exit(1);
146}
147
148/**
149 * Verifies the security settings and prints warnings if necessary
150 * @returns {Promise<void>}
151 */
152export async function verifySecuritySettings() {
153 const { listen, basicAuthMode } = globalThis.COMMAND_LINE_ARGS;
154
155 // Skip all security checks as listen is set to false
156 if (!listen) {
157 return;
158 }
159
160 if (!ENABLE_ACCOUNTS) {
161 logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.');
162 }
163
164 const users = await getAllEnabledUsers();
165 const unprotectedUsers = users.filter(x => !x.password);
166 const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin);
167
168 if (unprotectedUsers.length > 0) {
169 console.warn(color.blue('A friendly reminder that the following users are not password protected:'));
170 unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x));
171 console.log();
172 console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`);
173 console.log();
174
175 if (unprotectedAdminUsers.length > 0) {
176 logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');
177 }
178 }
179
180 if (basicAuthMode) {
181 const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean');
182 if (perUserBasicAuth && !ENABLE_ACCOUNTS) {
183 console.error(color.red(
184 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',
185 ));
186 } else if (!perUserBasicAuth) {
187 const basicAuthUserName = getConfigValue('basicAuthUser.username', '');
188 const basicAuthUserPassword = getConfigValue('basicAuthUser.password', '');
189 if (!basicAuthUserName || !basicAuthUserPassword) {
190 console.warn(color.yellow(
191 'Basic Authentication is enabled, but username or password is not set or empty!',
192 ));
193 }
194 }
195 }
196}
197
198export function cleanUploads() {
199 try {
200 const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
201 if (fs.existsSync(uploadsPath)) {
202 const uploads = fs.readdirSync(uploadsPath);
203
204 if (!uploads.length) {
205 return;
206 }
207
208 console.debug(`Cleaning uploads folder (${uploads.length} files)`);
209 uploads.forEach(file => {
210 const pathToFile = path.join(uploadsPath, file);
211 fs.unlinkSync(pathToFile);
212 });
213 }
214 } catch (err) {
215 console.error(err);
216 }
217}
218
219/**
220 * Gets a list of all user directories.
221 * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories
222 */
223export async function getUserDirectoriesList() {
224 const userHandles = await getAllUserHandles();
225 const directoriesList = userHandles.map(handle => getUserDirectories(handle));
226 return directoriesList;
227}
228
229/**
230 * Perform migration from the old user data format to the new one.
231 */
232export async function migrateUserData() {
233 const publicDirectory = path.join(process.cwd(), 'public');
234
235 // No need to migrate if the characters directory doesn't exists
236 if (!fs.existsSync(path.join(publicDirectory, 'characters'))) {
237 return;
238 }
239
240 const TIMEOUT = 10;
241
242 console.log();
243 console.log(color.magenta('Preparing to migrate user data...'));
244 console.log(`All public data will be moved to the ${globalThis.DATA_ROOT} directory.`);
245 console.log('This process may take a while depending on the amount of data to move.');
246 console.log(`Backups will be placed in the ${PUBLIC_DIRECTORIES.backups} directory.`);
247 console.log(`The process will start in ${TIMEOUT} seconds. Press Ctrl+C to cancel.`);
248
249 for (let i = TIMEOUT; i > 0; i--) {
250 console.log(`${i}...`);
251 await delay(1000);
252 }
253
254 console.log(color.magenta('Starting migration... Do not interrupt the process!'));
255
256 const userDirectories = getUserDirectories(DEFAULT_USER.handle);
257
258 const dataMigrationMap = [
259 {
260 old: path.join(publicDirectory, 'assets'),
261 new: userDirectories.assets,
262 file: false,
263 },
264 {
265 old: path.join(publicDirectory, 'backgrounds'),
266 new: userDirectories.backgrounds,
267 file: false,
268 },
269 {
270 old: path.join(publicDirectory, 'characters'),
271 new: userDirectories.characters,
272 file: false,
273 },
274 {
275 old: path.join(publicDirectory, 'chats'),
276 new: userDirectories.chats,
277 file: false,
278 },
279 {
280 old: path.join(publicDirectory, 'context'),
281 new: userDirectories.context,
282 file: false,
283 },
284 {
285 old: path.join(publicDirectory, 'group chats'),
286 new: userDirectories.groupChats,
287 file: false,
288 },
289 {
290 old: path.join(publicDirectory, 'groups'),
291 new: userDirectories.groups,
292 file: false,
293 },
294 {
295 old: path.join(publicDirectory, 'instruct'),
296 new: userDirectories.instruct,
297 file: false,
298 },
299 {
300 old: path.join(publicDirectory, 'KoboldAI Settings'),
301 new: userDirectories.koboldAI_Settings,
302 file: false,
303 },
304 {
305 old: path.join(publicDirectory, 'movingUI'),
306 new: userDirectories.movingUI,
307 file: false,
308 },
309 {
310 old: path.join(publicDirectory, 'NovelAI Settings'),
311 new: userDirectories.novelAI_Settings,
312 file: false,
313 },
314 {
315 old: path.join(publicDirectory, 'OpenAI Settings'),
316 new: userDirectories.openAI_Settings,
317 file: false,
318 },
319 {
320 old: path.join(publicDirectory, 'QuickReplies'),
321 new: userDirectories.quickreplies,
322 file: false,
323 },
324 {
325 old: path.join(publicDirectory, 'TextGen Settings'),
326 new: userDirectories.textGen_Settings,
327 file: false,
328 },
329 {
330 old: path.join(publicDirectory, 'themes'),
331 new: userDirectories.themes,
332 file: false,
333 },
334 {
335 old: path.join(publicDirectory, 'user'),
336 new: userDirectories.user,
337 file: false,
338 },
339 {
340 old: path.join(publicDirectory, 'User Avatars'),
341 new: userDirectories.avatars,
342 file: false,
343 },
344 {
345 old: path.join(publicDirectory, 'worlds'),
346 new: userDirectories.worlds,
347 file: false,
348 },
349 {
350 old: path.join(publicDirectory, 'scripts/extensions/third-party'),
351 new: userDirectories.extensions,
352 file: false,
353 },
354 {
355 old: path.join(process.cwd(), 'thumbnails'),
356 new: userDirectories.thumbnails,
357 file: false,
358 },
359 {
360 old: path.join(process.cwd(), 'vectors'),
361 new: userDirectories.vectors,
362 file: false,
363 },
364 {
365 old: path.join(process.cwd(), 'secrets.json'),
366 new: path.join(userDirectories.root, 'secrets.json'),
367 file: true,
368 },
369 {
370 old: path.join(publicDirectory, 'settings.json'),
371 new: path.join(userDirectories.root, 'settings.json'),
372 file: true,
373 },
374 {
375 old: path.join(publicDirectory, 'stats.json'),
376 new: path.join(userDirectories.root, 'stats.json'),
377 file: true,
378 },
379 ];
380
381 const currentDate = new Date().toISOString().split('T')[0];
382 const backupDirectory = path.join(process.cwd(), PUBLIC_DIRECTORIES.backups, '_migration', currentDate);
383
384 if (!fs.existsSync(backupDirectory)) {
385 fs.mkdirSync(backupDirectory, { recursive: true });
386 }
387
388 const errors = [];
389
390 for (const migration of dataMigrationMap) {
391 console.log(`Migrating ${migration.old} to ${migration.new}...`);
392
393 try {
394 if (!fs.existsSync(migration.old)) {
395 console.log(color.yellow(`Skipping migration of ${migration.old} as it does not exist.`));
396 continue;
397 }
398
399 if (migration.file) {
400 // Copy the file to the new location
401 fs.cpSync(migration.old, migration.new, { force: true });
402 // Move the file to the backup location
403 fs.cpSync(
404 migration.old,
405 path.join(backupDirectory, path.basename(migration.old)),
406 { recursive: true, force: true },
407 );
408 fs.rmSync(migration.old, { recursive: true, force: true });
409 } else {
410 // Copy the directory to the new location
411 fs.cpSync(migration.old, migration.new, { recursive: true, force: true });
412 // Move the directory to the backup location
413 fs.cpSync(
414 migration.old,
415 path.join(backupDirectory, path.basename(migration.old)),
416 { recursive: true, force: true },
417 );
418 fs.rmSync(migration.old, { recursive: true, force: true });
419 }
420 } catch (error) {
421 console.error(color.red(`Error migrating ${migration.old} to ${migration.new}:`), error.message);
422 errors.push(migration.old);
423 }
424 }
425
426 if (errors.length > 0) {
427 console.log(color.red('Migration completed with errors. Move the following files manually:'));
428 errors.forEach(error => console.error(error));
429 }
430
431 console.log(color.green('Migration completed!'));
432}
433
434export async function migrateSystemPrompts() {
435 /**
436 * Gets the default system prompts.
437 * @returns {Promise<any[]>} - The list of default system prompts
438 */
439 async function getDefaultSystemPrompts() {
440 try {
441 return getContentOfType('sysprompt', 'json');
442 } catch {
443 return [];
444 }
445 }
446
447 const directories = await getUserDirectoriesList();
448 for (const directory of directories) {
449 try {
450 const migrateMarker = path.join(directory.sysprompt, '.migrated');
451 if (fs.existsSync(migrateMarker)) {
452 continue;
453 }
454 const backupsPath = path.join(directory.backups, '_sysprompt');
455 fs.mkdirSync(backupsPath, { recursive: true });
456 const defaultPrompts = await getDefaultSystemPrompts();
457 const instucts = fs.readdirSync(directory.instruct);
458 let migratedPrompts = [];
459 for (const instruct of instucts) {
460 const instructPath = path.join(directory.instruct, instruct);
461 const sysPromptPath = path.join(directory.sysprompt, instruct);
462 if (path.extname(instruct) === '.json' && !fs.existsSync(sysPromptPath)) {
463 const instructData = JSON.parse(fs.readFileSync(instructPath, 'utf8'));
464 if ('system_prompt' in instructData && 'name' in instructData) {
465 const backupPath = path.join(backupsPath, `${instructData.name}.json`);
466 fs.cpSync(instructPath, backupPath, { force: true });
467 const syspromptData = { name: instructData.name, content: instructData.system_prompt };
468 migratedPrompts.push(syspromptData);
469 delete instructData.system_prompt;
470 writeFileAtomicSync(instructPath, JSON.stringify(instructData, null, 4));
471 }
472 }
473 }
474 // Only leave unique contents
475 migratedPrompts = _.uniqBy(migratedPrompts, 'content');
476 // Only leave contents that are not in the default prompts
477 migratedPrompts = migratedPrompts.filter(x => !defaultPrompts.some(y => y.content === x.content));
478 for (const sysPromptData of migratedPrompts) {
479 sysPromptData.name = `[Migrated] ${sysPromptData.name}`;
480 const syspromptPath = path.join(directory.sysprompt, `${sysPromptData.name}.json`);
481 writeFileAtomicSync(syspromptPath, JSON.stringify(sysPromptData, null, 4));
482 console.log(`Migrated system prompt ${sysPromptData.name} for ${directory.root.split(path.sep).pop()}`);
483 }
484 writeFileAtomicSync(migrateMarker, '');
485 } catch (error) {
486 console.error('Error migrating system prompts:', error);
487 }
488 }
489}
490
491export async function migratePublicOverrides() {
492 const migrationMap = [
493 {
494 oldPath: path.join(serverDirectory, 'public', 'error', 'forbidden-by-whitelist.html'),
495 newPath: path.join(globalThis.DATA_ROOT, '_errors', 'forbidden-by-whitelist.html'),
496 },
497 {
498 oldPath: path.join(serverDirectory, 'public', 'error', 'host-not-allowed.html'),
499 newPath: path.join(globalThis.DATA_ROOT, '_errors', 'host-not-allowed.html'),
500 },
501 {
502 oldPath: path.join(serverDirectory, 'public', 'error', 'unauthorized.html'),
503 newPath: path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html'),
504 },
505 {
506 oldPath: path.join(serverDirectory, 'public', 'error', 'url-not-found.html'),
507 newPath: path.join(globalThis.DATA_ROOT, '_errors', 'url-not-found.html'),
508 },
509 {
510 oldPath: path.join(serverDirectory, 'public', 'css', 'user.css'),
511 newPath: path.join(globalThis.DATA_ROOT, '_css', 'user.css'),
512 },
513 ];
514
515 for (const { oldPath, newPath } of migrationMap) {
516 try {
517 if (fs.existsSync(newPath)) {
518 continue;
519 }
520 if (fs.existsSync(oldPath)) {
521 fs.mkdirSync(path.dirname(newPath), { recursive: true });
522 fs.cpSync(oldPath, newPath, { force: true });
523 fs.unlinkSync(oldPath);
524 setPermissionsSync(newPath);
525 console.log(`Migrated ${path.basename(oldPath)} to data root.`);
526 }
527 } catch (error) {
528 console.error(`Error migrating ${oldPath} to ${newPath}:`, error);
529 }
530 }
531}
532
533/**
534 * Converts a user handle to a storage key.
535 * @param {string} handle User handle
536 * @returns {string} The key for the user storage
537 */
538export function toKey(handle) {
539 return `${KEY_PREFIX}${handle}`;
540}
541
542/**
543 * Converts a user handle to a storage key for avatars.
544 * @param {string} handle User handle
545 * @returns {string} The key for the avatar storage
546 */
547export function toAvatarKey(handle) {
548 return `${AVATAR_PREFIX}${handle}`;
549}
550
551/**
552 * Initializes the user storage.
553 * @param {string} dataRoot The root directory for user data
554 * @returns {Promise<void>}
555 */
556export async function initUserStorage(dataRoot) {
557 console.log('Using data root:', color.green(dataRoot));
558 await storage.init({
559 dir: path.join(dataRoot, '_storage'),
560 ttl: false, // Never expire
561 expiredInterval: 0,
562 });
563
564 const keys = await getAllUserHandles();
565
566 // If there are no users, create the default user
567 if (keys.length === 0) {
568 await storage.setItem(toKey(DEFAULT_USER.handle), DEFAULT_USER);
569 }
570}
571
572/**
573 * Get the cookie secret from the config. If it doesn't exist, generate a new one.
574 * @param {string} dataRoot The root directory for user data
575 * @returns {string} The cookie secret
576 */
577export function getCookieSecret(dataRoot) {
578 const cookieSecretPath = path.join(dataRoot, COOKIE_SECRET_PATH);
579
580 if (fs.existsSync(cookieSecretPath)) {
581 const stat = fs.statSync(cookieSecretPath);
582 if (stat.size > 0) {
583 return fs.readFileSync(cookieSecretPath, 'utf8');
584 }
585 }
586
587 const oldSecret = getConfigValue(STORAGE_KEYS.cookieSecret);
588 if (oldSecret) {
589 console.log('Migrating cookie secret from config.yaml...');
590 writeFileAtomicSync(cookieSecretPath, oldSecret, { encoding: 'utf8' });
591 return oldSecret;
592 }
593
594 console.warn(color.yellow('Cookie secret is missing from data root. Generating a new one...'));
595 const secret = crypto.randomBytes(64).toString('base64');
596 writeFileAtomicSync(cookieSecretPath, secret, { encoding: 'utf8' });
597 return secret;
598}
599
600/**
601 * Generates a random password salt.
602 * @returns {string} The password salt
603 */
604export function getPasswordSalt() {
605 return crypto.randomBytes(16).toString('base64');
606}
607
608/**
609 * Get the session name for the current server.
610 * @returns {string} The session name
611 */
612export function getCookieSessionName() {
613 // Get server hostname and hash it to generate a session suffix
614 const hostname = os.hostname() || 'localhost';
615 const suffix = crypto.createHash('sha256').update(hostname).digest('hex').slice(0, 8);
616 return `session-${suffix}`;
617}
618
619export function getSessionCookieAge() {
620 // Defaults to "no expiration" if not set
621 const configValue = getConfigValue('sessionTimeout', -1, 'number');
622
623 // Convert to milliseconds
624 if (configValue > 0) {
625 return configValue * 1000;
626 }
627
628 // "No expiration" is just 400 days as per RFC 6265
629 if (configValue < 0) {
630 return 400 * 24 * 60 * 60 * 1000;
631 }
632
633 // 0 means session cookie is deleted when the browser session ends
634 // (depends on the implementation of the browser)
635 return undefined;
636}
637
638/**
639 * Hashes a password using scrypt with the provided salt.
640 * @param {string} password Password to hash
641 * @param {string} salt Salt to use for hashing
642 * @returns {string} Hashed password
643 */
644export function getPasswordHash(password, salt) {
645 return crypto.scryptSync(password.normalize(), salt, 64).toString('base64');
646}
647
648/**
649 * Get the CSRF secret from the storage.
650 * @param {import('express').Request} [request] HTTP request object
651 * @returns {string} The CSRF secret
652 */
653export function getCsrfSecret(request) {
654 if (!request || !request.user) {
655 return ANON_CSRF_SECRET;
656 }
657
658 let csrfSecret = readSecret(request.user.directories, STORAGE_KEYS.csrfSecret);
659
660 if (!csrfSecret) {
661 csrfSecret = crypto.randomBytes(64).toString('base64');
662 writeSecret(request.user.directories, STORAGE_KEYS.csrfSecret, csrfSecret);
663 }
664
665 return csrfSecret;
666}
667
668/**
669 * Gets a list of all user handles.
670 * @returns {Promise<string[]>} - The list of user handles
671 */
672export async function getAllUserHandles() {
673 const keys = await storage.keys(x => x.key.startsWith(KEY_PREFIX));
674 const handles = keys.map(x => x.replace(KEY_PREFIX, ''));
675 return handles;
676}
677
678/**
679 * Gets the directories listing for the provided user.
680 * @param {string} handle User handle
681 * @returns {UserDirectoryList} User directories
682 */
683export function getUserDirectories(handle) {
684 if (DIRECTORIES_CACHE.has(handle)) {
685 const cache = DIRECTORIES_CACHE.get(handle);
686 if (cache) {
687 return cache;
688 }
689 }
690
691 const directories = structuredClone(USER_DIRECTORY_TEMPLATE);
692 for (const key in directories) {
693 directories[key] = path.join(globalThis.DATA_ROOT, handle, USER_DIRECTORY_TEMPLATE[key]);
694 }
695 DIRECTORIES_CACHE.set(handle, directories);
696 return directories;
697}
698
699/**
700 * Gets the avatar URL for the provided user.
701 * @param {string} handle User handle
702 * @returns {Promise<string>} User avatar URL
703 */
704export async function getUserAvatar(handle) {
705 try {
706 // Check if the user has a custom avatar
707 const avatarKey = toAvatarKey(handle);
708 const avatar = await storage.getItem(avatarKey);
709
710 if (avatar) {
711 return avatar;
712 }
713
714 // Fallback to reading from files if custom avatar is not set
715 const directory = getUserDirectories(handle);
716 const pathToSettings = path.join(directory.root, SETTINGS_FILE);
717 const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {};
718 const avatarFile = settings?.power_user?.default_persona || settings?.user_avatar;
719 if (!avatarFile) {
720 return PUBLIC_USER_AVATAR;
721 }
722 const avatarPath = path.join(directory.avatars, sanitize(avatarFile));
723 if (!fs.existsSync(avatarPath)) {
724 return PUBLIC_USER_AVATAR;
725 }
726 const mimeType = mime.lookup(avatarPath);
727 const base64Content = fs.readFileSync(avatarPath, 'base64');
728 return `data:${mimeType};base64,${base64Content}`;
729 } catch {
730 // Ignore errors
731 return PUBLIC_USER_AVATAR;
732 }
733}
734
735/**
736 * Checks if the user should be redirected to the login page.
737 * @param {import('express').Request} request Request object
738 * @returns {boolean} Whether the user should be redirected to the login page
739 */
740export function shouldRedirectToLogin(request) {
741 return ENABLE_ACCOUNTS && !request.user;
742}
743
744/**
745 * Tries auto-login if there is only one user and it's not password protected.
746 * or another configured method such authlia or basic
747 * @param {import('express').Request} request Request object
748 * @param {boolean} basicAuthMode If Basic auth mode is enabled
749 * @returns {Promise<boolean>} Whether auto-login was performed
750 */
751export async function tryAutoLogin(request, basicAuthMode) {
752 if (!ENABLE_ACCOUNTS || request.user || !request.session) {
753 return false;
754 }
755
756 if (!request.query.noauto) {
757 if (await singleUserLogin(request)) {
758 return true;
759 }
760
761 if (AUTHELIA_AUTH && await autheliaUserLogin(request)) {
762 return true;
763 }
764
765 if (AUTHENTIK_AUTH && await authentikUserLogin(request)) {
766 return true;
767 }
768
769 if (basicAuthMode && PER_USER_BASIC_AUTH && await basicUserLogin(request)) {
770 return true;
771 }
772 }
773
774 return false;
775}
776
777/**
778 * Tries auto-login if there is only one user and it's not password protected.
779 * @param {import('express').Request} request Request object
780 * @returns {Promise<boolean>} Whether auto-login was performed
781 */
782async function singleUserLogin(request) {
783 if (!request.session) {
784 return false;
785 }
786
787 const userHandles = await getAllUserHandles();
788 if (userHandles.length === 1) {
789 const user = await storage.getItem(toKey(userHandles[0]));
790 if (user && !user.password) {
791 request.session.handle = userHandles[0];
792 request.session.version = getAccountVersion(user);
793 return true;
794 }
795 }
796 return false;
797}
798
799/**
800 * Attempts auto-login using an Authelia header.
801 * https://www.authelia.com/integration/trusted-header-sso/introduction/
802 * @param {import('express').Request} request Request object
803 * @returns {Promise<boolean>} Whether auto-login was performed
804 */
805async function autheliaUserLogin(request) {
806 return headerUserLogin(request, 'Remote-User');
807}
808
809/**
810 * Attempts auto-login using an Authentik header.
811 * https://docs.goauthentik.io/add-secure-apps/providers/proxy/forward_auth/
812 * @param {import('express').Request} request Request object
813 * @returns {Promise<boolean>} Whether auto-login was performed
814 */
815async function authentikUserLogin(request) {
816 return headerUserLogin(request, 'X-Authentik-Username');
817}
818
819/**
820 * Check if the request can authenticate SSO users based on the trusted proxies configuration and the request's IP address.
821 * @param {string} ip The IP address of the request
822 * @return {boolean} If the request is from a trusted proxy based on the configuration
823 */
824function isRequestFromTrustedProxy(ip) {
825 if (!Array.isArray(TRUSTED_PROXIES)) {
826 console.warn(color.yellow('sso.trustedProxies is not an array. Please check your config.yaml. SSO auto-login will not work.'));
827 return false;
828 }
829
830 // Bypass magic value check if the user explicitly configured
831 if (TRUSTED_PROXIES.length === 1 && TRUSTED_PROXIES[0] === '*') {
832 console.warn(color.yellow('sso.trustedProxies is set to accept all IPs. This is not recommended for production environments.'));
833 return true;
834 }
835
836 // If the IP is missing or unknown, we can't trust it
837 if (!ip || ip === 'unknown') {
838 return false;
839 }
840
841 // At least one entry in the trusted proxies list must match the request IP for it to be considered trusted
842 for (const entry of TRUSTED_PROXIES) {
843 try {
844 // This will throw if the entry is not a valid IP or CIDR
845 const match = ipMatching.getMatch(entry);
846 if (ipMatching.matches(ip, match)) {
847 return true;
848 }
849 } catch (e) {
850 continue;
851 }
852 }
853
854 return false;
855}
856
857/**
858 * Tries auto-login with a given header.
859 * @param {import('express').Request} request Request object
860 * @param {string} [header='Remote-User'] The header to use for the trusted user
861 * @returns {Promise<boolean>} Whether auto-login was performed
862 */
863async function headerUserLogin(request, header = 'Remote-User') {
864 if (!request.session) {
865 return false;
866 }
867
868 const remoteUser = request.get(header);
869 if (!remoteUser) {
870 return false;
871 }
872 console.debug(`Attempting auto-login for user from header ${header}: ${remoteUser}`);
873
874 const ip = getIpFromRequest(request);
875 const isTrusted = isRequestFromTrustedProxy(ip);
876 if (!isTrusted) {
877 console.warn(color.yellow(`Received ${header} header from untrusted IP ${ip}. Ignoring for auto-login.`));
878 return false;
879 }
880
881 const userHandles = await getAllUserHandles();
882 for (const userHandle of userHandles) {
883 if (remoteUser.toLowerCase() === userHandle) {
884 const user = await storage.getItem(toKey(userHandle));
885 if (user && user.enabled) {
886 request.session.handle = userHandle;
887 request.session.version = getAccountVersion(user);
888 return true;
889 }
890 }
891 }
892 return false;
893}
894
895/**
896 * Tries auto-login with basic auth username.
897 * @param {import('express').Request} request Request object
898 * @returns {Promise<boolean>} Whether auto-login was performed
899 */
900async function basicUserLogin(request) {
901 if (!request.session) {
902 return false;
903 }
904
905 const authHeader = request.headers.authorization;
906
907 if (!authHeader) {
908 return false;
909 }
910
911 const [scheme, credentials] = authHeader.split(' ');
912
913 if (scheme !== 'Basic' || !credentials) {
914 return false;
915 }
916
917 const [username, ...passwordParts] = Buffer.from(credentials, 'base64')
918 .toString('utf8')
919 .split(':');
920 const password = passwordParts.join(':');
921
922 const userHandles = await getAllUserHandles();
923 for (const userHandle of userHandles) {
924 if (username === userHandle) {
925 const user = await storage.getItem(toKey(userHandle));
926 // Verify pass again here just to be sure
927 if (user && user.enabled && user.password && user.password === getPasswordHash(password, user.salt)) {
928 request.session.handle = userHandle;
929 request.session.version = getAccountVersion(user);
930 return true;
931 }
932 }
933 }
934
935 return false;
936}
937
938/**
939 * Gets the account version tag for the provided user.
940 * @param {User} user User account object
941 * @returns {string} Account version tag
942 */
943export function getAccountVersion(user) {
944 return crypto.createHash('shake256', { outputLength: 8 })
945 .update(JSON.stringify([user.handle, user.password, user.salt]))
946 .digest('hex');
947}
948
949/**
950 * Middleware to add user data to the request object.
951 * @param {import('express').Request} request Request object
952 * @param {import('express').Response} response Response object
953 * @param {import('express').NextFunction} next Next function
954 */
955export async function setUserDataMiddleware(request, response, next) {
956 // If user accounts are disabled, use the default user
957 if (!ENABLE_ACCOUNTS) {
958 const handle = DEFAULT_USER.handle;
959 const directories = getUserDirectories(handle);
960 request.user = {
961 profile: DEFAULT_USER,
962 directories: directories,
963 };
964 return next();
965 }
966
967 if (!request.session) {
968 console.error('Session not available');
969 return response.sendStatus(500);
970 }
971
972 // If user accounts are enabled, get the user from the session
973 let handle = request.session?.handle;
974
975 // If we have the only user and it's not password protected, use it
976 if (!handle) {
977 return next();
978 }
979
980 /** @type {User} */
981 const user = await storage.getItem(toKey(handle));
982
983 if (!user) {
984 console.error('User not found:', handle);
985 return next();
986 }
987
988 if (!user.enabled) {
989 console.error('User is disabled:', handle);
990 return next();
991 }
992
993 if (Object.hasOwn(request.session, 'version')) {
994 if (request.session.version !== getAccountVersion(user)) {
995 console.warn('User data has changed since the session was created. Invalidating session for user:', handle);
996 request.session.handle = null;
997 request.session.csrfToken = null;
998 request.session.version = null;
999 request.session = null;
1000 return response.sendStatus(403);
1001 }
1002 } else {
1003 // If there is no version in the session, it means it's an old session. Upgrade it by adding the version.
1004 request.session.version = getAccountVersion(user);
1005 }
1006
1007 const directories = getUserDirectories(handle);
1008 request.user = {
1009 profile: user,
1010 directories: directories,
1011 };
1012
1013 // Touch the session if loading the home page
1014 if (request.method === 'GET' && request.path === '/') {
1015 request.session.touch = Date.now();
1016 }
1017
1018 return next();
1019}
1020
1021/**
1022 * Middleware to add user data to the request object.
1023 * @param {import('express').Request} request Request object
1024 * @param {import('express').Response} response Response object
1025 * @param {import('express').NextFunction} next Next function
1026 */
1027export function requireLoginMiddleware(request, response, next) {
1028 if (!request.user) {
1029 return response.sendStatus(403);
1030 }
1031
1032 return next();
1033}
1034
1035/**
1036 * Middleware to host the login page.
1037 * @param {import('express').Request} request Request object
1038 * @param {import('express').Response} response Response object
1039 */
1040export async function loginPageMiddleware(request, response) {
1041 if (!ENABLE_ACCOUNTS) {
1042 console.log('User accounts are disabled. Redirecting to index page.');
1043 return response.redirect('/');
1044 }
1045
1046 try {
1047 const { basicAuthMode } = globalThis.COMMAND_LINE_ARGS;
1048 const autoLogin = await tryAutoLogin(request, basicAuthMode);
1049
1050 if (autoLogin) {
1051 return response.redirect('/');
1052 }
1053 } catch (error) {
1054 console.error('Error during auto-login:', error);
1055 }
1056
1057 return response.sendFile('login.html', { root: path.join(serverDirectory, 'public') });
1058}
1059
1060/**
1061 * Creates a route handler for serving files from a specific directory.
1062 * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
1063 * @returns {import('express').RequestHandler}
1064 */
1065function createRouteHandler(directoryFn) {
1066 return async (req, res) => {
1067 try {
1068 const directory = directoryFn(req);
1069 const filePath = decodeURIComponent(req.params[0]);
1070 const fullPath = path.join(directory, filePath);
1071 if (!isPathUnderParent(directory, path.resolve(fullPath))) {
1072 return res.sendStatus(403);
1073 }
1074 const exists = fs.existsSync(fullPath);
1075 if (!exists) {
1076 return res.sendStatus(404);
1077 }
1078
1079 invalidateFirefoxCache(filePath, req, res);
1080 return res.sendFile(filePath, { root: directory });
1081 } catch (error) {
1082 return res.sendStatus(500);
1083 }
1084 };
1085}
1086
1087/**
1088 * Creates a route handler for serving extensions.
1089 * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
1090 * @returns {import('express').RequestHandler}
1091 */
1092function createExtensionsRouteHandler(directoryFn) {
1093 return async (req, res) => {
1094 try {
1095 const directory = directoryFn(req);
1096 const filePath = decodeURIComponent(req.params[0]);
1097 const localPath = path.join(directory, filePath);
1098 if (!isPathUnderParent(directory, path.resolve(localPath))) {
1099 return res.sendStatus(403);
1100 }
1101 const existsLocal = fs.existsSync(localPath);
1102 if (existsLocal) {
1103 return res.sendFile(filePath, { root: directory });
1104 }
1105
1106 const globalPath = path.join(PUBLIC_DIRECTORIES.globalExtensions, filePath);
1107 if (!isPathUnderParent(PUBLIC_DIRECTORIES.globalExtensions, path.resolve(globalPath))) {
1108 return res.sendStatus(403);
1109 }
1110 const existsGlobal = fs.existsSync(globalPath);
1111 if (existsGlobal) {
1112 return res.sendFile(filePath, { root: PUBLIC_DIRECTORIES.globalExtensions });
1113 }
1114
1115 return res.sendStatus(404);
1116 } catch (error) {
1117 return res.sendStatus(500);
1118 }
1119 };
1120}
1121
1122/**
1123 * Verifies that the current user is an admin.
1124 * @param {import('express').Request} request Request object
1125 * @param {import('express').Response} response Response object
1126 * @param {import('express').NextFunction} next Next function
1127 * @returns {any}
1128 */
1129export function requireAdminMiddleware(request, response, next) {
1130 if (!request.user) {
1131 return response.sendStatus(403);
1132 }
1133
1134 if (request.user.profile.admin) {
1135 return next();
1136 }
1137
1138 console.warn('Unauthorized access to admin endpoint:', request.originalUrl);
1139 return response.sendStatus(403);
1140}
1141
1142/**
1143 * Creates an archive of the user's data root directory.
1144 * @param {string} handle User handle
1145 * @param {import('express').Response} response Express response object to write to
1146 * @returns {Promise<void>} Promise that resolves when the archive is created
1147 */
1148export async function createBackupArchive(handle, response) {
1149 const directories = getUserDirectories(handle);
1150
1151 console.info('Backup requested for', handle);
1152 const archive = archiver('zip');
1153
1154 archive.on('error', function (err) {
1155 response.status(500).send({ error: err.message });
1156 });
1157
1158 // On stream closed we can end the request
1159 archive.on('end', function () {
1160 console.info('Archive wrote %d bytes', archive.pointer());
1161 response.end(); // End the Express response
1162 });
1163
1164 const timestamp = generateTimestamp();
1165
1166 // Set the archive name
1167 response.attachment(`${handle}-${timestamp}.zip`);
1168
1169 // This is the streaming magic
1170 // @ts-ignore
1171 archive.pipe(response);
1172
1173 // Append files from a sub-directory, putting its contents at the root of archive
1174 const ignore = allowKeysExposure ? [] : [SECRETS_FILE, 'backups/secrets_migration_*.json'];
1175 archive.glob('**/*', {
1176 cwd: directories.root,
1177 follow: false,
1178 stat: true,
1179 dot: true,
1180 ignore,
1181 });
1182 archive.finalize();
1183}
1184
1185/**
1186 * Gets all of the users.
1187 * @returns {Promise<User[]>}
1188 */
1189async function getAllUsers() {
1190 if (!ENABLE_ACCOUNTS) {
1191 return [];
1192 }
1193 /**
1194 * @type {User[]}
1195 */
1196 const users = await storage.values();
1197 return users;
1198}
1199
1200/**
1201 * Gets all of the enabled users.
1202 * @returns {Promise<User[]>}
1203 */
1204export async function getAllEnabledUsers() {
1205 const users = await getAllUsers();
1206 return users.filter(x => x.enabled);
1207}
1208
1209/**
1210 * Express router for serving files from the user's directories.
1211 */
1212export const router = express.Router();
1213router.use('/backgrounds/*', createRouteHandler(req => req.user.directories.backgrounds));
1214router.use('/characters/*', createRouteHandler(req => req.user.directories.characters));
1215router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.avatars));
1216router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));
1217router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
1218router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
1219router.use('/scripts/extensions/third-party/*', extensionsEnabledFeatureGuard, createExtensionsRouteHandler(req => req.user.directories.extensions));