| 1 | // Native Node Modules |
| 2 | import path from 'node:path'; |
| 3 | import fs from 'node:fs'; |
| 4 | import crypto from 'node:crypto'; |
| 5 | import os from 'node:os'; |
| 6 | import process from 'node:process'; |
| 7 | import { Buffer } from 'node:buffer'; |
| 8 | |
| 9 | // Express and other dependencies |
| 10 | import storage from 'node-persist'; |
| 11 | import express from 'express'; |
| 12 | import mime from 'mime-types'; |
| 13 | import archiver from 'archiver'; |
| 14 | import _ from 'lodash'; |
| 15 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 16 | import sanitize from 'sanitize-filename'; |
| 17 | import ipMatching from 'ip-matching'; |
| 18 | |
| 19 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; |
| 20 | import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js'; |
| 21 | import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js'; |
| 22 | import { getContentOfType } from './endpoints/content-manager.js'; |
| 23 | import { serverDirectory } from './server-directory.js'; |
| 24 | import { filterValidIpPatterns, getIpFromRequest } from './express-common.js'; |
| 25 | import { extensionsEnabledFeatureGuard } from './endpoints/extensions.js'; |
| 26 | |
| 27 | export const KEY_PREFIX = 'user:'; |
| 28 | const AVATAR_PREFIX = 'avatar:'; |
| 29 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean'); |
| 30 | const AUTHELIA_AUTH = getConfigValue('sso.autheliaAuth', false, 'boolean'); |
| 31 | const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean'); |
| 32 | const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean'); |
| 33 | const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); |
| 34 | const 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 | */ |
| 40 | const DIRECTORIES_CACHE = new Map(); |
| 41 | const PUBLIC_USER_AVATAR = '/img/default-user.png'; |
| 42 | const COOKIE_SECRET_PATH = 'cookie-secret.txt'; |
| 43 | |
| 44 | const 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 | */ |
| 113 | export 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 | */ |
| 137 | function 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 | */ |
| 152 | export 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 | |
| 198 | export 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 | */ |
| 223 | export 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 | */ |
| 232 | export 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 | |
| 434 | export 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 | |
| 491 | export 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 | */ |
| 538 | export 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 | */ |
| 547 | export 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 | */ |
| 556 | export 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 | */ |
| 577 | export 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 | */ |
| 604 | export 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 | */ |
| 612 | export 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 | |
| 619 | export 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 | */ |
| 644 | export 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 | */ |
| 653 | export 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 | */ |
| 672 | export 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 | */ |
| 683 | export 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 | */ |
| 704 | export 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 | */ |
| 740 | export 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 | */ |
| 751 | export 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 | */ |
| 782 | async 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 | */ |
| 805 | async 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 | */ |
| 815 | async 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 | */ |
| 824 | function 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 | */ |
| 863 | async 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 | */ |
| 900 | async 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 | */ |
| 943 | export 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 | */ |
| 955 | export 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 | */ |
| 1027 | export 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 | */ |
| 1040 | export 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 | */ |
| 1065 | function 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 | */ |
| 1092 | function 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 | */ |
| 1129 | export 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 | */ |
| 1148 | export 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 | */ |
| 1189 | async 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 | */ |
| 1204 | export 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 | */ |
| 1212 | export const router = express.Router(); |
| 1213 | router.use('/backgrounds/*', createRouteHandler(req => req.user.directories.backgrounds)); |
| 1214 | router.use('/characters/*', createRouteHandler(req => req.user.directories.characters)); |
| 1215 | router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.avatars)); |
| 1216 | router.use('/assets/*', createRouteHandler(req => req.user.directories.assets)); |
| 1217 | router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages)); |
| 1218 | router.use('/user/files/*', createRouteHandler(req => req.user.directories.files)); |
| 1219 | router.use('/scripts/extensions/third-party/*', extensionsEnabledFeatureGuard, createExtensionsRouteHandler(req => req.user.directories.extensions)); |