| 1 | import path from 'node:path'; |
| 2 | import fs from 'node:fs'; |
| 3 | import http2 from 'node:http2'; |
| 4 | import process from 'node:process'; |
| 5 | import { Readable } from 'node:stream'; |
| 6 | import { createRequire } from 'node:module'; |
| 7 | import { Buffer } from 'node:buffer'; |
| 8 | import { promises as dnsPromise } from 'node:dns'; |
| 9 | import os from 'node:os'; |
| 10 | import crypto from 'node:crypto'; |
| 11 | import readline from 'node:readline'; |
| 12 | |
| 13 | import yaml from 'yaml'; |
| 14 | import { sync as commandExistsSync } from 'command-exists'; |
| 15 | import _ from 'lodash'; |
| 16 | import yauzl from 'yauzl'; |
| 17 | import mime from 'mime-types'; |
| 18 | import { default as simpleGit } from 'simple-git'; |
| 19 | import chalk from 'chalk'; |
| 20 | import bytes from 'bytes'; |
| 21 | import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js'; |
| 22 | import { serverDirectory } from './server-directory.js'; |
| 23 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 24 | import { isFirefox } from './express-common.js'; |
| 25 | |
| 26 | /** |
| 27 | * Parsed config object. |
| 28 | */ |
| 29 | let CACHED_CONFIG = null; |
| 30 | let CONFIG_PATH = null; |
| 31 | |
| 32 | /** |
| 33 | * Converts a configuration key to an environment variable key. |
| 34 | * @param {string} key Configuration key |
| 35 | * @returns {string} Environment variable key |
| 36 | * @example keyToEnv('extensions.models.speechToText') // 'SILLYTAVERN_EXTENSIONS_MODELS_SPEECHTOTEXT' |
| 37 | */ |
| 38 | export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_'); |
| 39 | |
| 40 | /** |
| 41 | * Set the config file path. |
| 42 | * @param {string} configFilePath Path to the config file |
| 43 | */ |
| 44 | export function setConfigFilePath(configFilePath) { |
| 45 | if (CONFIG_PATH !== null) { |
| 46 | console.error(color.red('Config file path already set. Please restart the server to change the config file path.')); |
| 47 | } |
| 48 | CONFIG_PATH = path.resolve(configFilePath); |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Returns the config object from the config.yaml file. |
| 53 | * @returns {object} Config object |
| 54 | */ |
| 55 | export function getConfig() { |
| 56 | if (CONFIG_PATH === null) { |
| 57 | console.trace(); |
| 58 | console.error(color.red('No config file path set. Please set the config file path using setConfigFilePath().')); |
| 59 | process.exit(1); |
| 60 | } |
| 61 | if (CACHED_CONFIG) { |
| 62 | return CACHED_CONFIG; |
| 63 | } |
| 64 | if (!fs.existsSync(CONFIG_PATH)) { |
| 65 | console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.')); |
| 66 | console.error(color.red('The program will now exit.')); |
| 67 | process.exit(1); |
| 68 | } |
| 69 | |
| 70 | try { |
| 71 | const config = yaml.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); |
| 72 | CACHED_CONFIG = config; |
| 73 | return config; |
| 74 | } catch (error) { |
| 75 | console.error(color.red('FATAL: Failed to read config.yaml. Please check the file for syntax errors.')); |
| 76 | console.error(error.message); |
| 77 | process.exit(1); |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Returns the value for the given key from the config object. |
| 83 | * @param {string} key - Key to get from the config object |
| 84 | * @param {any} defaultValue - Default value to return if the key is not found |
| 85 | * @param {'number'|'boolean'|null} typeConverter - Type to convert the value to |
| 86 | * @returns {any} Value for the given key |
| 87 | */ |
| 88 | export function getConfigValue(key, defaultValue = null, typeConverter = null) { |
| 89 | function _getValue() { |
| 90 | const envKey = keyToEnv(key); |
| 91 | if (envKey in process.env) { |
| 92 | const needsJsonParse = defaultValue && typeof defaultValue === 'object'; |
| 93 | const envValue = process.env[envKey]; |
| 94 | return needsJsonParse ? (tryParse(envValue) ?? defaultValue) : envValue; |
| 95 | } |
| 96 | const config = getConfig(); |
| 97 | return _.get(config, key, defaultValue); |
| 98 | } |
| 99 | |
| 100 | const value = _getValue(); |
| 101 | switch (typeConverter) { |
| 102 | case 'number': |
| 103 | return isNaN(parseFloat(value)) ? defaultValue : parseFloat(value); |
| 104 | case 'boolean': |
| 105 | return toBoolean(value); |
| 106 | default: |
| 107 | return value; |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * THIS FUNCTION IS DEPRECATED AND ONLY EXISTS FOR BACKWARDS COMPATIBILITY. DON'T USE IT. |
| 113 | * @param {any} _key Unused |
| 114 | * @param {any} _value Unused |
| 115 | * @deprecated Configs are read-only. Use environment variables instead. |
| 116 | */ |
| 117 | export function setConfigValue(_key, _value) { |
| 118 | console.trace(color.yellow('setConfigValue is deprecated and should not be used.')); |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Encodes the Basic Auth header value for the given user and password. |
| 123 | * @param {string} auth username:password |
| 124 | * @returns {string} Basic Auth header value |
| 125 | */ |
| 126 | export function getBasicAuthHeader(auth) { |
| 127 | const encoded = Buffer.from(`${auth}`).toString('base64'); |
| 128 | return `Basic ${encoded}`; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Returns the version of the running instance. Get the version from the package.json file and the git revision. |
| 133 | * Also returns the agent string for the Horde API. |
| 134 | * @returns {Promise<{agent: string, pkgVersion: string, gitRevision: string | null, gitBranch: string | null, commitDate: string | null, isLatest: boolean}>} Version info object |
| 135 | */ |
| 136 | export async function getVersion() { |
| 137 | let pkgVersion = 'UNKNOWN'; |
| 138 | let gitRevision = null; |
| 139 | let gitBranch = null; |
| 140 | let commitDate = null; |
| 141 | let isLatest = true; |
| 142 | |
| 143 | try { |
| 144 | const require = createRequire(import.meta.url); |
| 145 | const pkgJson = require(path.join(serverDirectory, './package.json')); |
| 146 | pkgVersion = pkgJson.version; |
| 147 | if (commandExistsSync('git')) { |
| 148 | const git = simpleGit({ baseDir: serverDirectory }); |
| 149 | gitRevision = await git.revparse(['--short', 'HEAD']); |
| 150 | gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']); |
| 151 | commitDate = await git.show(['-s', '--format=%ci', gitRevision]); |
| 152 | |
| 153 | const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']); |
| 154 | |
| 155 | // Might fail, but exception is caught. Just don't run anything relevant after in this block... |
| 156 | const localLatest = await git.revparse(['HEAD']); |
| 157 | const remoteLatest = await git.revparse([trackingBranch]); |
| 158 | isLatest = localLatest === remoteLatest; |
| 159 | } |
| 160 | } catch { |
| 161 | // suppress exception |
| 162 | } |
| 163 | |
| 164 | const agent = `SillyTavern:${pkgVersion}:Cohee#1207`; |
| 165 | return { agent, pkgVersion, gitRevision, gitBranch, commitDate: commitDate?.trim() ?? null, isLatest }; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Delays the current async function by the given amount of milliseconds. |
| 170 | * @param {number} ms Milliseconds to wait |
| 171 | * @returns {Promise<void>} Promise that resolves after the given amount of milliseconds |
| 172 | */ |
| 173 | export function delay(ms) { |
| 174 | return new Promise(resolve => setTimeout(resolve, ms)); |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Generates a random hex string of the given length. |
| 179 | * @param {number} length String length |
| 180 | * @returns {string} Random hex string |
| 181 | * @example getHexString(8) // 'a1b2c3d4' |
| 182 | */ |
| 183 | export function getHexString(length) { |
| 184 | const chars = '0123456789abcdef'; |
| 185 | let result = ''; |
| 186 | for (let i = 0; i < length; i++) { |
| 187 | result += chars[Math.floor(Math.random() * chars.length)]; |
| 188 | } |
| 189 | return result; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Formats a byte size into a human-readable string with units |
| 194 | * @param {number} numBytes - The size in bytes to format |
| 195 | * @returns {string} The formatted string (e.g., "1.5 MB") |
| 196 | */ |
| 197 | export function formatBytes(numBytes) { |
| 198 | return bytes.format(numBytes) ?? ''; |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * Extracts a file with given extension from an ArrayBuffer containing a ZIP archive. |
| 203 | * @param {ArrayBufferLike} archiveBuffer Buffer containing a ZIP archive |
| 204 | * @param {string} fileExtension File extension to look for |
| 205 | * @returns {Promise<Buffer|null>} Buffer containing the extracted file. Null if the file was not found. |
| 206 | */ |
| 207 | export async function extractFileFromZipBuffer(archiveBuffer, fileExtension) { |
| 208 | return await new Promise((resolve) => { |
| 209 | try { |
| 210 | yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => { |
| 211 | if (err) { |
| 212 | console.warn(`Error opening ZIP file: ${err.message}`); |
| 213 | return resolve(null); |
| 214 | } |
| 215 | |
| 216 | zipfile.readEntry(); |
| 217 | |
| 218 | zipfile.on('entry', (entry) => { |
| 219 | if (entry.fileName.endsWith(fileExtension) && !entry.fileName.startsWith('__MACOSX')) { |
| 220 | zipfile.openReadStream(entry, (err, readStream) => { |
| 221 | if (err) { |
| 222 | console.warn(`Error opening read stream: ${err.message}`); |
| 223 | return zipfile.readEntry(); |
| 224 | } else { |
| 225 | const chunks = []; |
| 226 | readStream.on('data', (chunk) => { |
| 227 | chunks.push(chunk); |
| 228 | }); |
| 229 | |
| 230 | readStream.on('end', () => { |
| 231 | const buffer = Buffer.concat(chunks); |
| 232 | resolve(buffer); |
| 233 | zipfile.readEntry(); // Continue to the next entry |
| 234 | }); |
| 235 | |
| 236 | readStream.on('error', (err) => { |
| 237 | console.warn(`Error reading stream: ${err.message}`); |
| 238 | zipfile.readEntry(); |
| 239 | }); |
| 240 | } |
| 241 | }); |
| 242 | } else { |
| 243 | zipfile.readEntry(); |
| 244 | } |
| 245 | }); |
| 246 | |
| 247 | zipfile.on('error', (err) => { |
| 248 | console.warn('ZIP processing error', err); |
| 249 | resolve(null); |
| 250 | }); |
| 251 | |
| 252 | zipfile.on('end', () => resolve(null)); |
| 253 | }); |
| 254 | } catch (error) { |
| 255 | console.warn('Failed to process ZIP buffer', error); |
| 256 | resolve(null); |
| 257 | } |
| 258 | }); |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Normalizes a ZIP entry path for safe extraction. |
| 263 | * @param {string} entryName The entry name from the ZIP archive |
| 264 | * @returns {string|null} Normalized path or null if invalid |
| 265 | */ |
| 266 | export function normalizeZipEntryPath(entryName) { |
| 267 | if (typeof entryName !== 'string') { |
| 268 | return null; |
| 269 | } |
| 270 | |
| 271 | let normalized = entryName.replace(/\\/g, '/').trim(); |
| 272 | |
| 273 | if (!normalized) { |
| 274 | return null; |
| 275 | } |
| 276 | |
| 277 | normalized = normalized.replace(/^\.\/+/g, ''); |
| 278 | normalized = path.posix.normalize(normalized); |
| 279 | |
| 280 | if (!normalized || normalized === '.' || normalized.startsWith('..')) { |
| 281 | return null; |
| 282 | } |
| 283 | |
| 284 | if (normalized.startsWith('/')) { |
| 285 | normalized = normalized.slice(1); |
| 286 | } |
| 287 | |
| 288 | return normalized; |
| 289 | } |
| 290 | |
| 291 | /** |
| 292 | * Extracts multiple files from an ArrayBuffer containing a ZIP archive. |
| 293 | * @param {ArrayBufferLike} archiveBuffer Buffer containing a ZIP archive |
| 294 | * @param {string[]} fileNames Array of file paths to extract |
| 295 | * @returns {Promise<Map<string, Buffer>>} Map of normalized paths to their extracted buffers |
| 296 | */ |
| 297 | export async function extractFilesFromZipBuffer(archiveBuffer, fileNames) { |
| 298 | const targets = new Map(); |
| 299 | |
| 300 | if (Array.isArray(fileNames)) { |
| 301 | for (const fileName of fileNames) { |
| 302 | const normalized = normalizeZipEntryPath(fileName); |
| 303 | if (normalized && !targets.has(normalized)) { |
| 304 | targets.set(normalized, true); |
| 305 | } |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | if (targets.size === 0) { |
| 310 | return new Map(); |
| 311 | } |
| 312 | |
| 313 | return await new Promise((resolve) => { |
| 314 | const results = new Map(); |
| 315 | |
| 316 | try { |
| 317 | yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => { |
| 318 | if (err) { |
| 319 | console.warn(`Error opening ZIP file: ${err.message}`); |
| 320 | return resolve(results); |
| 321 | } |
| 322 | |
| 323 | let finished = false; |
| 324 | const finalize = () => { |
| 325 | if (finished) { |
| 326 | return; |
| 327 | } |
| 328 | finished = true; |
| 329 | resolve(results); |
| 330 | }; |
| 331 | |
| 332 | zipfile.readEntry(); |
| 333 | |
| 334 | zipfile.on('entry', (entry) => { |
| 335 | const normalizedEntry = normalizeZipEntryPath(entry.fileName); |
| 336 | if (!normalizedEntry || !targets.has(normalizedEntry)) { |
| 337 | return zipfile.readEntry(); |
| 338 | } |
| 339 | |
| 340 | zipfile.openReadStream(entry, (streamErr, readStream) => { |
| 341 | if (streamErr) { |
| 342 | console.warn(`Error opening read stream: ${streamErr.message}`); |
| 343 | return zipfile.readEntry(); |
| 344 | } |
| 345 | |
| 346 | const chunks = []; |
| 347 | readStream.on('data', (chunk) => { |
| 348 | chunks.push(chunk); |
| 349 | }); |
| 350 | |
| 351 | readStream.on('end', () => { |
| 352 | results.set(normalizedEntry, Buffer.concat(chunks)); |
| 353 | targets.delete(normalizedEntry); |
| 354 | |
| 355 | if (targets.size === 0) { |
| 356 | finalize(); |
| 357 | } else { |
| 358 | zipfile.readEntry(); |
| 359 | } |
| 360 | }); |
| 361 | |
| 362 | readStream.on('error', (streamError) => { |
| 363 | console.warn(`Error reading stream: ${streamError.message}`); |
| 364 | zipfile.readEntry(); |
| 365 | }); |
| 366 | }); |
| 367 | }); |
| 368 | |
| 369 | zipfile.on('error', (zipError) => { |
| 370 | console.warn('ZIP processing error', zipError); |
| 371 | finalize(); |
| 372 | }); |
| 373 | |
| 374 | zipfile.on('close', () => { |
| 375 | finalize(); |
| 376 | }); |
| 377 | |
| 378 | zipfile.on('end', () => { |
| 379 | finalize(); |
| 380 | }); |
| 381 | }); |
| 382 | } catch (error) { |
| 383 | console.warn('Failed to process ZIP buffer', error); |
| 384 | resolve(results); |
| 385 | } |
| 386 | }); |
| 387 | } |
| 388 | |
| 389 | /** |
| 390 | * Ensures a directory exists, creating it if necessary. |
| 391 | * @param {string} dirPath Path to the directory |
| 392 | * @returns {boolean} True if the directory exists or was created, false on error |
| 393 | */ |
| 394 | export function ensureDirectory(dirPath) { |
| 395 | try { |
| 396 | if (!fs.existsSync(dirPath)) { |
| 397 | fs.mkdirSync(dirPath, { recursive: true }); |
| 398 | } else if (!fs.statSync(dirPath).isDirectory()) { |
| 399 | console.warn(`ensureDirectory: Path ${dirPath} exists and is not a directory.`); |
| 400 | return false; |
| 401 | } |
| 402 | return true; |
| 403 | } catch (error) { |
| 404 | console.error(`ensureDirectory: Failed to prepare directory ${dirPath}`, error); |
| 405 | return false; |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | /** |
| 410 | * Extracts all images from a ZIP archive. |
| 411 | * @param {string} zipFilePath Path to the ZIP archive |
| 412 | * @returns {Promise<[string, Buffer][]>} Array of image buffers |
| 413 | */ |
| 414 | export async function getImageBuffers(zipFilePath) { |
| 415 | return new Promise((resolve, reject) => { |
| 416 | // Check if the zip file exists |
| 417 | if (!fs.existsSync(zipFilePath)) { |
| 418 | reject(new Error('File not found')); |
| 419 | return; |
| 420 | } |
| 421 | |
| 422 | const imageBuffers = []; |
| 423 | |
| 424 | yauzl.open(zipFilePath, { lazyEntries: true }, (err, zipfile) => { |
| 425 | if (err) { |
| 426 | reject(err); |
| 427 | } else { |
| 428 | zipfile.readEntry(); |
| 429 | zipfile.on('entry', (entry) => { |
| 430 | const mimeType = mime.lookup(entry.fileName); |
| 431 | if (mimeType && mimeType.startsWith('image/') && !entry.fileName.startsWith('__MACOSX')) { |
| 432 | zipfile.openReadStream(entry, (err, readStream) => { |
| 433 | if (err) { |
| 434 | reject(err); |
| 435 | } else { |
| 436 | const chunks = []; |
| 437 | readStream.on('data', (chunk) => { |
| 438 | chunks.push(chunk); |
| 439 | }); |
| 440 | |
| 441 | readStream.on('end', () => { |
| 442 | imageBuffers.push([path.parse(entry.fileName).base, Buffer.concat(chunks)]); |
| 443 | zipfile.readEntry(); // Continue to the next entry |
| 444 | }); |
| 445 | } |
| 446 | }); |
| 447 | } else { |
| 448 | zipfile.readEntry(); // Continue to the next entry |
| 449 | } |
| 450 | }); |
| 451 | |
| 452 | zipfile.on('end', () => { |
| 453 | resolve(imageBuffers); |
| 454 | }); |
| 455 | |
| 456 | zipfile.on('error', (err) => { |
| 457 | reject(err); |
| 458 | }); |
| 459 | } |
| 460 | }); |
| 461 | }); |
| 462 | } |
| 463 | |
| 464 | /** |
| 465 | * Gets all chunks of data from the given readable stream. |
| 466 | * @param {any} readableStream Readable stream to read from |
| 467 | * @returns {Promise<Buffer[]>} Array of chunks |
| 468 | */ |
| 469 | export async function readAllChunks(readableStream) { |
| 470 | return new Promise((resolve, reject) => { |
| 471 | // Consume the readable stream |
| 472 | const chunks = []; |
| 473 | readableStream.on('data', (chunk) => { |
| 474 | chunks.push(chunk); |
| 475 | }); |
| 476 | |
| 477 | readableStream.on('end', () => { |
| 478 | //console.log('Finished reading the stream.'); |
| 479 | resolve(chunks); |
| 480 | }); |
| 481 | |
| 482 | readableStream.on('error', (error) => { |
| 483 | console.error('Error while reading the stream:', error); |
| 484 | reject(); |
| 485 | }); |
| 486 | }); |
| 487 | } |
| 488 | |
| 489 | function isObject(item) { |
| 490 | return (item && typeof item === 'object' && !Array.isArray(item)); |
| 491 | } |
| 492 | |
| 493 | export function deepMerge(target, source) { |
| 494 | let output = Object.assign({}, target); |
| 495 | if (isObject(target) && isObject(source)) { |
| 496 | Object.keys(source).forEach(key => { |
| 497 | if (isObject(source[key])) { |
| 498 | if (!(key in target)) { |
| 499 | Object.assign(output, { [key]: source[key] }); |
| 500 | } else { |
| 501 | output[key] = deepMerge(target[key], source[key]); |
| 502 | } |
| 503 | } else { |
| 504 | Object.assign(output, { [key]: source[key] }); |
| 505 | } |
| 506 | }); |
| 507 | } |
| 508 | return output; |
| 509 | } |
| 510 | |
| 511 | export const color = chalk; |
| 512 | |
| 513 | /** |
| 514 | * Gets a random UUIDv4 string. |
| 515 | * @returns {string} A UUIDv4 string |
| 516 | */ |
| 517 | export function uuidv4() { |
| 518 | // Node v16.7.0+ |
| 519 | if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) { |
| 520 | return globalThis.crypto.randomUUID(); |
| 521 | } |
| 522 | // Node v14.17.0+ |
| 523 | if ('randomUUID' in crypto) { |
| 524 | return crypto.randomUUID(); |
| 525 | } |
| 526 | // Very insecure UUID generator, but it's better than nothing. |
| 527 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { |
| 528 | const r = Math.random() * 16 | 0; |
| 529 | const v = c === 'x' ? r : (r & 0x3 | 0x8); |
| 530 | return v.toString(16); |
| 531 | }); |
| 532 | } |
| 533 | |
| 534 | /** |
| 535 | * Gets a humanized date time string from a given timestamp. |
| 536 | * @param {number} timestamp Timestamp in milliseconds |
| 537 | * @returns {string} Humanized date time string in the format `YYYY-MM-DD@HHhMMmSSsMSms` |
| 538 | */ |
| 539 | export function humanizedDateTime(timestamp = Date.now()) { |
| 540 | const date = new Date(timestamp); |
| 541 | const dt = { |
| 542 | year: date.getFullYear(), |
| 543 | month: date.getMonth() + 1, |
| 544 | day: date.getDate(), |
| 545 | hour: date.getHours(), |
| 546 | minute: date.getMinutes(), |
| 547 | second: date.getSeconds(), |
| 548 | millisecond: date.getMilliseconds(), |
| 549 | }; |
| 550 | for (const key in dt) { |
| 551 | const padLength = key === 'millisecond' ? 3 : 2; |
| 552 | dt[key] = dt[key].toString().padStart(padLength, '0'); |
| 553 | } |
| 554 | return `${dt.year}-${dt.month}-${dt.day}@${dt.hour}h${dt.minute}m${dt.second}s${dt.millisecond}ms`; |
| 555 | } |
| 556 | |
| 557 | export function tryParse(str) { |
| 558 | try { |
| 559 | return JSON.parse(str); |
| 560 | } catch { |
| 561 | return undefined; |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | /** |
| 566 | * Takes a path to a client-accessible file in the data folder and converts it to a relative URL segment that the |
| 567 | * client can fetch it from. This involves stripping the data root path prefix and always using `/` as the separator. |
| 568 | * @param {string} root The root directory of the user data folder. |
| 569 | * @param {string} inputPath The path to be converted. |
| 570 | * @returns The relative URL path from which the client can access the file. |
| 571 | */ |
| 572 | export function clientRelativePath(root, inputPath) { |
| 573 | if (!inputPath.startsWith(root)) { |
| 574 | throw new Error('Input path does not start with the root directory'); |
| 575 | } |
| 576 | |
| 577 | return inputPath.slice(root.length).split(path.sep).join('/'); |
| 578 | } |
| 579 | |
| 580 | /** |
| 581 | * Returns a name that is unique among the names that exist. |
| 582 | * @param {string} baseName The name to check. |
| 583 | * @param {{ (name: string): boolean; }} exists Function to check if name exists. |
| 584 | * @param {Object} [options] The options. |
| 585 | * @param {((baseName: string, i: number) => string)|null} [options.nameBuilder=null] Function to build the name. |
| 586 | * Starts with the index provided by `startIndex` (default is 1). If not provided, uses "${baseName} (${i})". |
| 587 | * @param {number} [options.maxTries=1000] The maximum number of tries to find a unique name. Default is 1000. |
| 588 | * @param {number} [options.startIndex=1] The index to start with when building the name. Default is 1. |
| 589 | * When set to 0, the intention is to also check if the basename (without applied index) is free. |
| 590 | * @returns {string|null} A unique name. Null if no unique name could be found in `maxTries`. |
| 591 | */ |
| 592 | export function getUniqueName(baseName, exists, { nameBuilder = null, maxTries = 1000, startIndex = 1 } = {}) { |
| 593 | nameBuilder ??= (baseName, i) => i === 0 ? baseName : `${baseName} (${i})`; |
| 594 | let i = startIndex; |
| 595 | let name; |
| 596 | while (i < maxTries + startIndex) { |
| 597 | name = nameBuilder(baseName, i); |
| 598 | if (!exists(name)) { |
| 599 | return name; |
| 600 | } |
| 601 | i++; |
| 602 | } |
| 603 | return null; |
| 604 | } |
| 605 | |
| 606 | /** |
| 607 | * Provides safe replacements for characters in filenames. Intended for use with sanitize() from the sanitize-filename package. |
| 608 | * @param {string} char Character to sanitize |
| 609 | * @returns {string} Safe replacement character |
| 610 | */ |
| 611 | export function sanitizeSafeCharacterReplacements(char) { |
| 612 | return '_'; |
| 613 | } |
| 614 | |
| 615 | /** |
| 616 | * Strip the last file extension from a given file name. If there are multiple extensions, only the last is removed. |
| 617 | * @param {string} filename The file name to remove the extension from. |
| 618 | * @returns The file name, sans extension |
| 619 | */ |
| 620 | export function removeFileExtension(filename) { |
| 621 | return filename.replace(/\.[^.]+$/, ''); |
| 622 | } |
| 623 | |
| 624 | export function generateTimestamp() { |
| 625 | const now = new Date(); |
| 626 | const year = now.getFullYear(); |
| 627 | const month = String(now.getMonth() + 1).padStart(2, '0'); |
| 628 | const day = String(now.getDate()).padStart(2, '0'); |
| 629 | const hours = String(now.getHours()).padStart(2, '0'); |
| 630 | const minutes = String(now.getMinutes()).padStart(2, '0'); |
| 631 | const seconds = String(now.getSeconds()).padStart(2, '0'); |
| 632 | |
| 633 | return `${year}${month}${day}-${hours}${minutes}${seconds}`; |
| 634 | } |
| 635 | |
| 636 | /** |
| 637 | * Remove old backups with the given prefix from a specified directory. |
| 638 | * @param {string} directory The root directory to remove backups from. |
| 639 | * @param {string} prefix File prefix to filter backups by. |
| 640 | * @param {number?} limit Maximum number of backups to keep. If null, the limit is determined by the `backups.common.numberOfBackups` config value. |
| 641 | */ |
| 642 | export function removeOldBackups(directory, prefix, limit = null) { |
| 643 | const MAX_BACKUPS = limit ?? Number(getConfigValue('backups.common.numberOfBackups', 50, 'number')); |
| 644 | |
| 645 | let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix)); |
| 646 | if (files.length > MAX_BACKUPS) { |
| 647 | files = files.map(f => path.join(directory, f)); |
| 648 | files.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs); |
| 649 | |
| 650 | while (files.length > MAX_BACKUPS) { |
| 651 | const oldest = files.shift(); |
| 652 | if (!oldest) { |
| 653 | break; |
| 654 | } |
| 655 | |
| 656 | fs.unlinkSync(oldest); |
| 657 | } |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | /** |
| 662 | * Get a list of images in a directory. |
| 663 | * @param {string} directoryPath Path to the directory containing the images |
| 664 | * @param {'name' | 'date'} sortBy Sort images by name or date |
| 665 | * @param {number} type Bitwise flag representing media types to include |
| 666 | * @returns {string[]} List of image file names |
| 667 | */ |
| 668 | export function getImages(directoryPath, sortBy = 'name', type = MEDIA_REQUEST_TYPE.IMAGE) { |
| 669 | function getSortFunction() { |
| 670 | switch (sortBy) { |
| 671 | case 'name': |
| 672 | return Intl.Collator().compare; |
| 673 | case 'date': |
| 674 | return (a, b) => fs.statSync(path.join(directoryPath, a)).mtimeMs - fs.statSync(path.join(directoryPath, b)).mtimeMs; |
| 675 | default: |
| 676 | return (_a, _b) => 0; |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | return fs |
| 681 | .readdirSync(directoryPath, { withFileTypes: true }) |
| 682 | .filter(dirent => dirent.isFile()) |
| 683 | .map(dirent => dirent.name) |
| 684 | .filter(file => { |
| 685 | const fileType = mime.lookup(file); |
| 686 | if (!fileType) { |
| 687 | return false; |
| 688 | } |
| 689 | if ((type & MEDIA_REQUEST_TYPE.IMAGE) && fileType.startsWith('image/')) { |
| 690 | return true; |
| 691 | } |
| 692 | if ((type & MEDIA_REQUEST_TYPE.VIDEO) && fileType.startsWith('video/')) { |
| 693 | return true; |
| 694 | } |
| 695 | if ((type & MEDIA_REQUEST_TYPE.AUDIO) && fileType.startsWith('audio/')) { |
| 696 | return true; |
| 697 | } |
| 698 | return false; |
| 699 | }) |
| 700 | .sort(getSortFunction()); |
| 701 | } |
| 702 | |
| 703 | /** |
| 704 | * Pipe a fetch() response to an Express.js Response, including status code. |
| 705 | * @param {import('node-fetch').Response} from The Fetch API response to pipe from. |
| 706 | * @param {import('express').Response} to The Express response to pipe to. |
| 707 | * @returns {Promise<void>} |
| 708 | */ |
| 709 | export async function forwardFetchResponse(from, to) { |
| 710 | let statusCode = from.status; |
| 711 | let statusText = from.statusText; |
| 712 | |
| 713 | // Avoid sending 401 responses as they reset the client Basic auth. |
| 714 | // This can produce an interesting artifact as "400 Unauthorized", but it's not out of spec. |
| 715 | // https://www.rfc-editor.org/rfc/rfc9110.html#name-overview-of-status-codes |
| 716 | // "The reason phrases listed here are only recommendations -- they can be replaced by local |
| 717 | // equivalents or left out altogether without affecting the protocol." |
| 718 | if (statusCode === 401) { |
| 719 | statusCode = 400; |
| 720 | } |
| 721 | |
| 722 | to.statusCode = statusCode; |
| 723 | to.statusMessage = statusText; |
| 724 | |
| 725 | if (!from.ok) { |
| 726 | try { |
| 727 | const rawErrorText = await from.text(); |
| 728 | const detail = rawErrorText || 'Unknown error occurred'; |
| 729 | |
| 730 | console.warn(`Streaming request failed with status ${from.status} ${statusText}: ${detail}`); |
| 731 | to.end(rawErrorText, 'utf-8'); |
| 732 | } catch { |
| 733 | console.warn(`Streaming request failed with status ${from.status} ${statusText}: Unknown error occurred`); |
| 734 | to.end(); |
| 735 | } |
| 736 | |
| 737 | return; |
| 738 | } |
| 739 | |
| 740 | if (from.body && to.socket) { |
| 741 | from.body.pipe(to); |
| 742 | |
| 743 | to.socket.on('close', function () { |
| 744 | if (from.body instanceof Readable) from.body.destroy(); // Close the remote stream |
| 745 | |
| 746 | to.end(); // End the Express response |
| 747 | }); |
| 748 | |
| 749 | from.body.on('end', function () { |
| 750 | console.info('Streaming request finished'); |
| 751 | to.end(); |
| 752 | }); |
| 753 | } else { |
| 754 | to.end(); |
| 755 | } |
| 756 | } |
| 757 | |
| 758 | /** |
| 759 | * Makes an HTTP/2 request to the specified endpoint. |
| 760 | * |
| 761 | * @deprecated Use `node-fetch` if possible. |
| 762 | * @param {string} endpoint URL to make the request to |
| 763 | * @param {string} method HTTP method to use |
| 764 | * @param {string} body Request body |
| 765 | * @param {object} headers Request headers |
| 766 | * @returns {Promise<string>} Response body |
| 767 | */ |
| 768 | export function makeHttp2Request(endpoint, method, body, headers) { |
| 769 | return new Promise((resolve, reject) => { |
| 770 | try { |
| 771 | const url = new URL(endpoint); |
| 772 | const client = http2.connect(url.origin); |
| 773 | |
| 774 | const req = client.request({ |
| 775 | ':method': method, |
| 776 | ':path': url.pathname, |
| 777 | ...headers, |
| 778 | }); |
| 779 | req.setEncoding('utf8'); |
| 780 | |
| 781 | req.on('response', (headers) => { |
| 782 | const status = Number(headers[':status']); |
| 783 | |
| 784 | if (status < 200 || status >= 300) { |
| 785 | reject(new Error(`Request failed with status ${status}`)); |
| 786 | } |
| 787 | |
| 788 | let data = ''; |
| 789 | |
| 790 | req.on('data', (chunk) => { |
| 791 | data += chunk; |
| 792 | }); |
| 793 | |
| 794 | req.on('end', () => { |
| 795 | console.debug(data); |
| 796 | resolve(data); |
| 797 | }); |
| 798 | }); |
| 799 | |
| 800 | req.on('error', (err) => { |
| 801 | reject(err); |
| 802 | }); |
| 803 | |
| 804 | if (body) { |
| 805 | req.write(body); |
| 806 | } |
| 807 | |
| 808 | req.end(); |
| 809 | } catch (e) { |
| 810 | reject(e); |
| 811 | } |
| 812 | }); |
| 813 | } |
| 814 | |
| 815 | /** |
| 816 | * Adds YAML-serialized object to the object. |
| 817 | * @param {object} obj Object |
| 818 | * @param {string} yamlString YAML-serialized object |
| 819 | * @returns |
| 820 | */ |
| 821 | export function mergeObjectWithYaml(obj, yamlString) { |
| 822 | if (!yamlString) { |
| 823 | return; |
| 824 | } |
| 825 | |
| 826 | try { |
| 827 | const parsedObject = yaml.parse(yamlString); |
| 828 | |
| 829 | if (Array.isArray(parsedObject)) { |
| 830 | for (const item of parsedObject) { |
| 831 | if (typeof item === 'object' && item && !Array.isArray(item)) { |
| 832 | Object.assign(obj, item); |
| 833 | } |
| 834 | } |
| 835 | } else if (parsedObject && typeof parsedObject === 'object') { |
| 836 | Object.assign(obj, parsedObject); |
| 837 | } |
| 838 | } catch { |
| 839 | // Do nothing |
| 840 | } |
| 841 | } |
| 842 | |
| 843 | /** |
| 844 | * Removes keys from the object by YAML-serialized array. |
| 845 | * @param {object} obj Object |
| 846 | * @param {string} yamlString YAML-serialized array |
| 847 | * @returns {void} Nothing |
| 848 | */ |
| 849 | export function excludeKeysByYaml(obj, yamlString) { |
| 850 | if (!yamlString) { |
| 851 | return; |
| 852 | } |
| 853 | |
| 854 | try { |
| 855 | const parsedObject = yaml.parse(yamlString); |
| 856 | |
| 857 | if (Array.isArray(parsedObject)) { |
| 858 | parsedObject.forEach(key => { |
| 859 | delete obj[key]; |
| 860 | }); |
| 861 | } else if (typeof parsedObject === 'object') { |
| 862 | Object.keys(parsedObject).forEach(key => { |
| 863 | delete obj[key]; |
| 864 | }); |
| 865 | } else if (typeof parsedObject === 'string') { |
| 866 | delete obj[parsedObject]; |
| 867 | } |
| 868 | } catch { |
| 869 | // Do nothing |
| 870 | } |
| 871 | } |
| 872 | |
| 873 | /** |
| 874 | * Removes trailing slash and /v1 from a string. |
| 875 | * @param {string} str Input string |
| 876 | * @returns {string} Trimmed string |
| 877 | */ |
| 878 | export function trimV1(str) { |
| 879 | return String(str ?? '').replace(/\/$/, '').replace(/\/v1$/, ''); |
| 880 | } |
| 881 | |
| 882 | /** |
| 883 | * Removes trailing slash from a string. |
| 884 | * @param {string} str Input string |
| 885 | * @returns {string} String with trailing slash removed |
| 886 | */ |
| 887 | export function trimTrailingSlash(str) { |
| 888 | return String(str ?? '').replace(/\/$/, ''); |
| 889 | } |
| 890 | |
| 891 | /** |
| 892 | * Simple TTL memory cache. |
| 893 | */ |
| 894 | export class Cache { |
| 895 | /** |
| 896 | * @param {number} ttl Time to live in milliseconds |
| 897 | */ |
| 898 | constructor(ttl) { |
| 899 | this.cache = new Map(); |
| 900 | this.ttl = ttl; |
| 901 | } |
| 902 | |
| 903 | /** |
| 904 | * Gets a value from the cache. |
| 905 | * @param {string} key Cache key |
| 906 | */ |
| 907 | get(key) { |
| 908 | const value = this.cache.get(key); |
| 909 | if (value?.expiry > Date.now()) { |
| 910 | return value.value; |
| 911 | } |
| 912 | |
| 913 | // Cache miss or expired, remove the key |
| 914 | this.cache.delete(key); |
| 915 | return null; |
| 916 | } |
| 917 | |
| 918 | /** |
| 919 | * Sets a value in the cache. |
| 920 | * @param {string} key Key |
| 921 | * @param {object} value Value |
| 922 | */ |
| 923 | set(key, value) { |
| 924 | this.cache.set(key, { |
| 925 | value: value, |
| 926 | expiry: Date.now() + this.ttl, |
| 927 | }); |
| 928 | } |
| 929 | |
| 930 | /** |
| 931 | * Removes a value from the cache. |
| 932 | * @param {string} key Key |
| 933 | */ |
| 934 | remove(key) { |
| 935 | this.cache.delete(key); |
| 936 | } |
| 937 | |
| 938 | /** |
| 939 | * Clears the cache. |
| 940 | */ |
| 941 | clear() { |
| 942 | this.cache.clear(); |
| 943 | } |
| 944 | } |
| 945 | |
| 946 | /** |
| 947 | * Removes color formatting from a text string. |
| 948 | * @param {string} text Text with color formatting |
| 949 | * @returns {string} Text without color formatting |
| 950 | */ |
| 951 | export function removeColorFormatting(text) { |
| 952 | // ANSI escape codes for colors are usually in the format \x1b[<codes>m |
| 953 | return text.replace(/\x1b\[\d{1,2}(;\d{1,2})*m/g, ''); |
| 954 | } |
| 955 | |
| 956 | /** |
| 957 | * Gets a separator string repeated n times. |
| 958 | * @param {number} n Number of times to repeat the separator |
| 959 | * @returns {string} Separator string |
| 960 | */ |
| 961 | export function getSeparator(n) { |
| 962 | return '='.repeat(n); |
| 963 | } |
| 964 | |
| 965 | /** |
| 966 | * Checks if the string is a valid URL. |
| 967 | * @param {string} url String to check |
| 968 | * @returns {boolean} If the URL is valid |
| 969 | */ |
| 970 | export function isValidUrl(url) { |
| 971 | try { |
| 972 | new URL(url); |
| 973 | return true; |
| 974 | } catch (error) { |
| 975 | return false; |
| 976 | } |
| 977 | } |
| 978 | |
| 979 | /** |
| 980 | * removes starting `[` or ending `]` from hostname. |
| 981 | * @param {string} hostname hostname to use |
| 982 | * @returns {string} hostname plus the modifications |
| 983 | */ |
| 984 | export function urlHostnameToIPv6(hostname) { |
| 985 | if (hostname.startsWith('[')) { |
| 986 | hostname = hostname.slice(1); |
| 987 | } |
| 988 | if (hostname.endsWith(']')) { |
| 989 | hostname = hostname.slice(0, -1); |
| 990 | } |
| 991 | return hostname; |
| 992 | } |
| 993 | |
| 994 | /** |
| 995 | * Test if can resolve a dns name. |
| 996 | * @param {string} name Domain name to use |
| 997 | * @param {boolean} useIPv6 If use IPv6 |
| 998 | * @param {boolean} useIPv4 If use IPv4 |
| 999 | * @returns Promise<boolean> If the URL is valid |
| 1000 | */ |
| 1001 | export async function canResolve(name, useIPv6 = true, useIPv4 = true) { |
| 1002 | try { |
| 1003 | let v6Resolved = false; |
| 1004 | let v4Resolved = false; |
| 1005 | |
| 1006 | if (useIPv6) { |
| 1007 | try { |
| 1008 | await dnsPromise.resolve6(name); |
| 1009 | v6Resolved = true; |
| 1010 | } catch (error) { |
| 1011 | v6Resolved = false; |
| 1012 | } |
| 1013 | } |
| 1014 | |
| 1015 | if (useIPv4) { |
| 1016 | try { |
| 1017 | await dnsPromise.resolve(name); |
| 1018 | v4Resolved = true; |
| 1019 | } catch (error) { |
| 1020 | v4Resolved = false; |
| 1021 | } |
| 1022 | } |
| 1023 | |
| 1024 | return v6Resolved || v4Resolved; |
| 1025 | } catch (error) { |
| 1026 | return false; |
| 1027 | } |
| 1028 | } |
| 1029 | |
| 1030 | /** |
| 1031 | * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses. |
| 1032 | * |
| 1033 | * @typedef {object} IPQueryResult |
| 1034 | * @property {boolean} hasIPv6Any - Whether the computer has any IPv6 address, including (`::1`). |
| 1035 | * @property {boolean} hasIPv4Any - Whether the computer has any IPv4 address, including (`127.0.0.1`). |
| 1036 | * @property {boolean} hasIPv6Local - Whether the computer has local IPv6 address (`::1`). |
| 1037 | * @property {boolean} hasIPv4Local - Whether the computer has local IPv4 address (`127.0.0.1`). |
| 1038 | * @returns {Promise<IPQueryResult>} A promise that resolves to an array containing: |
| 1039 | */ |
| 1040 | export async function getHasIP() { |
| 1041 | let hasIPv6Any = false; |
| 1042 | let hasIPv6Local = false; |
| 1043 | |
| 1044 | let hasIPv4Any = false; |
| 1045 | let hasIPv4Local = false; |
| 1046 | |
| 1047 | const interfaces = os.networkInterfaces(); |
| 1048 | |
| 1049 | for (const iface of Object.values(interfaces)) { |
| 1050 | if (iface === undefined) { |
| 1051 | continue; |
| 1052 | } |
| 1053 | |
| 1054 | for (const info of iface) { |
| 1055 | if (info.family === 'IPv6') { |
| 1056 | hasIPv6Any = true; |
| 1057 | if (info.address === '::1') { |
| 1058 | hasIPv6Local = true; |
| 1059 | } |
| 1060 | } |
| 1061 | |
| 1062 | if (info.family === 'IPv4') { |
| 1063 | hasIPv4Any = true; |
| 1064 | if (info.address === '127.0.0.1') { |
| 1065 | hasIPv4Local = true; |
| 1066 | } |
| 1067 | } |
| 1068 | if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break; |
| 1069 | } |
| 1070 | if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break; |
| 1071 | } |
| 1072 | |
| 1073 | return { hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local }; |
| 1074 | } |
| 1075 | |
| 1076 | |
| 1077 | /** |
| 1078 | * Converts various JavaScript primitives to boolean values. |
| 1079 | * Handles special case for "true"/"false" strings (case-insensitive) |
| 1080 | * |
| 1081 | * @param {any} value - The value to convert to boolean |
| 1082 | * @returns {boolean} - The boolean representation of the value |
| 1083 | */ |
| 1084 | export function toBoolean(value) { |
| 1085 | // Handle string values case-insensitively |
| 1086 | if (typeof value === 'string') { |
| 1087 | // Trim and convert to lowercase for case-insensitive comparison |
| 1088 | const trimmedLower = value.trim().toLowerCase(); |
| 1089 | |
| 1090 | // Handle explicit "true"/"false" strings |
| 1091 | if (trimmedLower === 'true') return true; |
| 1092 | if (trimmedLower === 'false') return false; |
| 1093 | } |
| 1094 | |
| 1095 | // Handle all other JavaScript values based on their "truthiness" |
| 1096 | return Boolean(value); |
| 1097 | } |
| 1098 | |
| 1099 | /** |
| 1100 | * converts string to boolean accepts 'true' or 'false' else it returns the string put in |
| 1101 | * @param {string|null} str Input string or null |
| 1102 | * @returns {boolean|string|null} boolean else original input string or null if input is |
| 1103 | */ |
| 1104 | export function stringToBool(str) { |
| 1105 | if (String(str).trim().toLowerCase() === 'true') return true; |
| 1106 | if (String(str).trim().toLowerCase() === 'false') return false; |
| 1107 | return str; |
| 1108 | } |
| 1109 | |
| 1110 | /** |
| 1111 | * Setup the minimum log level |
| 1112 | */ |
| 1113 | export function setupLogLevel() { |
| 1114 | const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number'); |
| 1115 | |
| 1116 | globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { }; |
| 1117 | globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { }; |
| 1118 | globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { }; |
| 1119 | globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => { }; |
| 1120 | } |
| 1121 | |
| 1122 | /** |
| 1123 | * MemoryLimitedMap class that limits the memory usage of string values. |
| 1124 | */ |
| 1125 | export class MemoryLimitedMap { |
| 1126 | /** |
| 1127 | * Creates an instance of MemoryLimitedMap. |
| 1128 | * @param {string} cacheCapacity - Maximum memory usage in human-readable format (e.g., '1 GB'). |
| 1129 | */ |
| 1130 | constructor(cacheCapacity) { |
| 1131 | this.maxMemory = bytes.parse(cacheCapacity) ?? 0; |
| 1132 | this.currentMemory = 0; |
| 1133 | this.map = new Map(); |
| 1134 | this.queue = []; |
| 1135 | } |
| 1136 | |
| 1137 | /** |
| 1138 | * Estimates the memory usage of a string in bytes. |
| 1139 | * Assumes each character occupies 2 bytes (UTF-16). |
| 1140 | * @param {string} str |
| 1141 | * @returns {number} |
| 1142 | */ |
| 1143 | static estimateStringSize(str) { |
| 1144 | return str ? str.length * 2 : 0; |
| 1145 | } |
| 1146 | |
| 1147 | /** |
| 1148 | * Adds or updates a key-value pair in the map. |
| 1149 | * If adding the new value exceeds the memory limit, evicts oldest entries. |
| 1150 | * @param {string} key |
| 1151 | * @param {string} value |
| 1152 | */ |
| 1153 | set(key, value) { |
| 1154 | if (this.maxMemory <= 0) { |
| 1155 | return; |
| 1156 | } |
| 1157 | |
| 1158 | if (typeof key !== 'string' || typeof value !== 'string') { |
| 1159 | return; |
| 1160 | } |
| 1161 | |
| 1162 | const newValueSize = MemoryLimitedMap.estimateStringSize(value); |
| 1163 | |
| 1164 | // If the new value itself exceeds the max memory, reject it |
| 1165 | if (newValueSize > this.maxMemory) { |
| 1166 | return; |
| 1167 | } |
| 1168 | |
| 1169 | // Check if the key already exists to adjust memory accordingly |
| 1170 | if (this.map.has(key)) { |
| 1171 | const oldValue = this.map.get(key); |
| 1172 | const oldValueSize = MemoryLimitedMap.estimateStringSize(oldValue); |
| 1173 | this.currentMemory -= oldValueSize; |
| 1174 | // Remove the key from its current position in the queue |
| 1175 | const index = this.queue.indexOf(key); |
| 1176 | if (index > -1) { |
| 1177 | this.queue.splice(index, 1); |
| 1178 | } |
| 1179 | } |
| 1180 | |
| 1181 | // Evict oldest entries until there's enough space |
| 1182 | while (this.currentMemory + newValueSize > this.maxMemory && this.queue.length > 0) { |
| 1183 | const oldestKey = this.queue.shift(); |
| 1184 | const oldestValue = this.map.get(oldestKey); |
| 1185 | const oldestValueSize = MemoryLimitedMap.estimateStringSize(oldestValue); |
| 1186 | this.map.delete(oldestKey); |
| 1187 | this.currentMemory -= oldestValueSize; |
| 1188 | } |
| 1189 | |
| 1190 | // After eviction, check again if there's enough space |
| 1191 | if (this.currentMemory + newValueSize > this.maxMemory) { |
| 1192 | return; |
| 1193 | } |
| 1194 | |
| 1195 | // Add the new key-value pair |
| 1196 | this.map.set(key, value); |
| 1197 | this.queue.push(key); |
| 1198 | this.currentMemory += newValueSize; |
| 1199 | } |
| 1200 | |
| 1201 | /** |
| 1202 | * Retrieves the value associated with the given key. |
| 1203 | * @param {string} key |
| 1204 | * @returns {string | undefined} |
| 1205 | */ |
| 1206 | get(key) { |
| 1207 | return this.map.get(key); |
| 1208 | } |
| 1209 | |
| 1210 | /** |
| 1211 | * Checks if the map contains the given key. |
| 1212 | * @param {string} key |
| 1213 | * @returns {boolean} |
| 1214 | */ |
| 1215 | has(key) { |
| 1216 | return this.map.has(key); |
| 1217 | } |
| 1218 | |
| 1219 | /** |
| 1220 | * Deletes the key-value pair associated with the given key. |
| 1221 | * @param {string} key |
| 1222 | * @returns {boolean} - Returns true if the key was found and deleted, else false. |
| 1223 | */ |
| 1224 | delete(key) { |
| 1225 | if (!this.map.has(key)) { |
| 1226 | return false; |
| 1227 | } |
| 1228 | const value = this.map.get(key); |
| 1229 | const valueSize = MemoryLimitedMap.estimateStringSize(value); |
| 1230 | this.map.delete(key); |
| 1231 | this.currentMemory -= valueSize; |
| 1232 | |
| 1233 | // Remove the key from the queue |
| 1234 | const index = this.queue.indexOf(key); |
| 1235 | if (index > -1) { |
| 1236 | this.queue.splice(index, 1); |
| 1237 | } |
| 1238 | |
| 1239 | return true; |
| 1240 | } |
| 1241 | |
| 1242 | /** |
| 1243 | * Clears all entries from the map. |
| 1244 | */ |
| 1245 | clear() { |
| 1246 | this.map.clear(); |
| 1247 | this.queue = []; |
| 1248 | this.currentMemory = 0; |
| 1249 | } |
| 1250 | |
| 1251 | /** |
| 1252 | * Returns the number of key-value pairs in the map. |
| 1253 | * @returns {number} |
| 1254 | */ |
| 1255 | size() { |
| 1256 | return this.map.size; |
| 1257 | } |
| 1258 | |
| 1259 | /** |
| 1260 | * Returns the current memory usage in bytes. |
| 1261 | * @returns {number} |
| 1262 | */ |
| 1263 | totalMemory() { |
| 1264 | return this.currentMemory; |
| 1265 | } |
| 1266 | |
| 1267 | /** |
| 1268 | * Returns an iterator over the keys in the map. |
| 1269 | * @returns {IterableIterator<string>} |
| 1270 | */ |
| 1271 | keys() { |
| 1272 | return this.map.keys(); |
| 1273 | } |
| 1274 | |
| 1275 | /** |
| 1276 | * Returns an iterator over the values in the map. |
| 1277 | * @returns {IterableIterator<string>} |
| 1278 | */ |
| 1279 | values() { |
| 1280 | return this.map.values(); |
| 1281 | } |
| 1282 | |
| 1283 | /** |
| 1284 | * Iterates over the map in insertion order. |
| 1285 | * @param {Function} callback - Function to execute for each element. |
| 1286 | */ |
| 1287 | forEach(callback) { |
| 1288 | this.map.forEach((value, key) => { |
| 1289 | callback(value, key, this); |
| 1290 | }); |
| 1291 | } |
| 1292 | |
| 1293 | /** |
| 1294 | * Makes the MemoryLimitedMap iterable. |
| 1295 | * @returns {Iterator} - Iterator over [key, value] pairs. |
| 1296 | */ |
| 1297 | [Symbol.iterator]() { |
| 1298 | return this.map[Symbol.iterator](); |
| 1299 | } |
| 1300 | } |
| 1301 | |
| 1302 | /** |
| 1303 | * A 'safe' version of `fs.readFileSync()`. Returns the contents of a file if it exists, falling back to a default value if not. |
| 1304 | * @param {string} filePath Path of the file to be read. |
| 1305 | * @param {Parameters<typeof fs.readFileSync>[1]} options Options object to pass through to `fs.readFileSync()` (default: `{ encoding: 'utf-8' }`). |
| 1306 | * @returns The contents at `filePath` if it exists, or `null` if not. |
| 1307 | */ |
| 1308 | export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { |
| 1309 | if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); |
| 1310 | return null; |
| 1311 | } |
| 1312 | |
| 1313 | /** |
| 1314 | * Set the title of the terminal window |
| 1315 | * @param {string} title Desired title for the window |
| 1316 | */ |
| 1317 | export function setWindowTitle(title) { |
| 1318 | if (process.platform === 'win32') { |
| 1319 | process.title = title; |
| 1320 | } else { |
| 1321 | process.stdout.write(`\x1b]2;${title}\x1b\x5c`); |
| 1322 | } |
| 1323 | } |
| 1324 | |
| 1325 | /** |
| 1326 | * Parses a JSON string and applies a mutation function to the parsed object. |
| 1327 | * @param {string} jsonString JSON string to parse |
| 1328 | * @param {function(any): void} mutation Mutation function to apply to the parsed JSON object |
| 1329 | * @returns {string} Mutated JSON string |
| 1330 | */ |
| 1331 | export function mutateJsonString(jsonString, mutation) { |
| 1332 | try { |
| 1333 | const json = JSON.parse(jsonString); |
| 1334 | mutation(json); |
| 1335 | return JSON.stringify(json); |
| 1336 | } catch (error) { |
| 1337 | console.error('Error parsing or mutating JSON:', error); |
| 1338 | return jsonString; |
| 1339 | } |
| 1340 | } |
| 1341 | |
| 1342 | /** |
| 1343 | * Sets the permissions of a file or directory to be writable. |
| 1344 | * @param {string} targetPath Path to the file or directory |
| 1345 | */ |
| 1346 | export function setPermissionsSync(targetPath) { |
| 1347 | /** |
| 1348 | * Appends writable permission to the file mode. |
| 1349 | * @param {string} filePath Path to the file |
| 1350 | * @param {fs.Stats} stats File stats |
| 1351 | */ |
| 1352 | function appendWritablePermission(filePath, stats) { |
| 1353 | const currentMode = stats.mode; |
| 1354 | const newMode = currentMode | 0o200; |
| 1355 | if (newMode != currentMode) { |
| 1356 | fs.chmodSync(filePath, newMode); |
| 1357 | } |
| 1358 | } |
| 1359 | |
| 1360 | try { |
| 1361 | const stats = fs.statSync(targetPath); |
| 1362 | |
| 1363 | if (stats.isDirectory()) { |
| 1364 | appendWritablePermission(targetPath, stats); |
| 1365 | const files = fs.readdirSync(targetPath); |
| 1366 | |
| 1367 | files.forEach((file) => { |
| 1368 | setPermissionsSync(path.join(targetPath, file)); |
| 1369 | }); |
| 1370 | } else { |
| 1371 | appendWritablePermission(targetPath, stats); |
| 1372 | } |
| 1373 | } catch (error) { |
| 1374 | console.error(`Error setting write permissions for ${targetPath}:`, error); |
| 1375 | } |
| 1376 | } |
| 1377 | |
| 1378 | /** |
| 1379 | * Checks if a child path is under a parent path. |
| 1380 | * @param {string} parentPath Parent path |
| 1381 | * @param {string} childPath Child path |
| 1382 | * @returns {boolean} Returns true if the child path is under the parent path, false otherwise |
| 1383 | */ |
| 1384 | export function isPathUnderParent(parentPath, childPath) { |
| 1385 | const normalizedParent = path.normalize(parentPath); |
| 1386 | const normalizedChild = path.normalize(childPath); |
| 1387 | |
| 1388 | const relativePath = path.relative(normalizedParent, normalizedChild); |
| 1389 | |
| 1390 | return relativePath !== '..' && !relativePath.startsWith('..' + path.sep) && !path.isAbsolute(relativePath); |
| 1391 | } |
| 1392 | |
| 1393 | /** |
| 1394 | * Checks if the given request is a file URL. |
| 1395 | * @param {string | URL | Request} request The request to check |
| 1396 | * @return {boolean} Returns true if the request is a file URL, false otherwise |
| 1397 | */ |
| 1398 | export function isFileURL(request) { |
| 1399 | if (typeof request === 'string') { |
| 1400 | return request.startsWith('file://'); |
| 1401 | } |
| 1402 | if (request instanceof URL) { |
| 1403 | return request.protocol === 'file:'; |
| 1404 | } |
| 1405 | if (request instanceof Request) { |
| 1406 | return request.url.startsWith('file://'); |
| 1407 | } |
| 1408 | return false; |
| 1409 | } |
| 1410 | |
| 1411 | /** |
| 1412 | * Gets the URL from the request. |
| 1413 | * @param {string | URL | Request} request The request to get the URL from |
| 1414 | * @return {string} The URL of the request |
| 1415 | */ |
| 1416 | export function getRequestURL(request) { |
| 1417 | if (typeof request === 'string') { |
| 1418 | return request; |
| 1419 | } |
| 1420 | if (request instanceof URL) { |
| 1421 | return request.href; |
| 1422 | } |
| 1423 | if (request instanceof Request) { |
| 1424 | return request.url; |
| 1425 | } |
| 1426 | throw new TypeError('Invalid request type'); |
| 1427 | } |
| 1428 | |
| 1429 | /** |
| 1430 | * Flattens and simplifies a JSON schema to be compatible with the strict requirements |
| 1431 | * of Google's Generative AI API. |
| 1432 | * @param {object} schema The JSON schema to process. |
| 1433 | * @param {string} api The API source. |
| 1434 | * @returns {object} The flattened and simplified schema. |
| 1435 | */ |
| 1436 | export function flattenSchema(schema, api) { |
| 1437 | if (!schema || typeof schema !== 'object') { |
| 1438 | return schema; |
| 1439 | } |
| 1440 | |
| 1441 | const schemaCopy = structuredClone(schema); |
| 1442 | const isGoogleApi = [CHAT_COMPLETION_SOURCES.VERTEXAI, CHAT_COMPLETION_SOURCES.MAKERSUITE].includes(api); |
| 1443 | |
| 1444 | const definitions = schemaCopy.$defs || {}; |
| 1445 | delete schemaCopy.$defs; |
| 1446 | |
| 1447 | function resolve(obj, parents = []) { |
| 1448 | if (!obj || typeof obj !== 'object') { |
| 1449 | return obj; |
| 1450 | } |
| 1451 | if (Array.isArray(obj)) { |
| 1452 | return obj.map(item => resolve(item, parents)); |
| 1453 | } |
| 1454 | |
| 1455 | // 1. Resolve $refs first |
| 1456 | if (obj.$ref?.startsWith('#/$defs/')) { |
| 1457 | const defName = obj.$ref.split('/').pop(); |
| 1458 | if (parents.includes(defName)) return {}; // Prevent infinite recursion |
| 1459 | if (definitions[defName]) { |
| 1460 | return resolve(structuredClone(definitions[defName]), [...parents, defName]); |
| 1461 | } |
| 1462 | return {}; // Broken reference |
| 1463 | } |
| 1464 | |
| 1465 | // 2. Process the object's properties |
| 1466 | const result = {}; |
| 1467 | for (const key in obj) { |
| 1468 | if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; |
| 1469 | |
| 1470 | // For Google, filter unsupported top-level keywords |
| 1471 | if (isGoogleApi && ['default', 'additionalProperties', 'exclusiveMinimum', 'propertyNames'].includes(key)) { |
| 1472 | continue; |
| 1473 | } |
| 1474 | |
| 1475 | result[key] = resolve(obj[key], parents); |
| 1476 | } |
| 1477 | |
| 1478 | return result; |
| 1479 | } |
| 1480 | |
| 1481 | const flattenedSchema = resolve(schemaCopy); |
| 1482 | delete flattenedSchema.$schema; |
| 1483 | return flattenedSchema; |
| 1484 | } |
| 1485 | |
| 1486 | /** |
| 1487 | * Writes to a file, creating it's parent directories if needed. |
| 1488 | * @param {string} filePath |
| 1489 | * @param {string} data |
| 1490 | */ |
| 1491 | export function tryWriteFileSync(filePath, data) { |
| 1492 | const directory = path.dirname(filePath); |
| 1493 | //Ensure the directory exists. |
| 1494 | if (!fs.existsSync(directory)) { |
| 1495 | fs.mkdirSync(directory, { recursive: true }); |
| 1496 | } |
| 1497 | writeFileAtomicSync(filePath, data, 'utf8'); |
| 1498 | } |
| 1499 | |
| 1500 | /** |
| 1501 | * Attempts to read a file as utf8. |
| 1502 | * @param {string} filePath |
| 1503 | * @returns {string|null} |
| 1504 | */ |
| 1505 | export function tryReadFileSync(filePath) { |
| 1506 | try { |
| 1507 | if (fs.existsSync(filePath)) { |
| 1508 | return fs.readFileSync(filePath, 'utf8'); |
| 1509 | } |
| 1510 | } catch (error) { |
| 1511 | console.error(`Error reading ${filePath}: ${error.message}`); |
| 1512 | } |
| 1513 | return null; |
| 1514 | } |
| 1515 | |
| 1516 | /** |
| 1517 | * Attempts to delete a file. |
| 1518 | * @param {string} filePath Target file. |
| 1519 | * @returns {boolean} Returns true if the file was found and deleted. |
| 1520 | */ |
| 1521 | export function tryDeleteFile(filePath) { |
| 1522 | if (fs.existsSync(filePath)) { |
| 1523 | fs.unlinkSync(filePath); |
| 1524 | console.info(`Deleted file: ${filePath}`); |
| 1525 | return true; |
| 1526 | } else { |
| 1527 | console.error(`File not found '${filePath}'`); |
| 1528 | return false; |
| 1529 | } |
| 1530 | } |
| 1531 | |
| 1532 | /** |
| 1533 | * Reads the first line of a file asynchronously. |
| 1534 | * @param {string} filePath Path to the file |
| 1535 | * @returns {Promise<string>} The first line of the file |
| 1536 | */ |
| 1537 | export function readFirstLine(filePath) { |
| 1538 | const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); |
| 1539 | const rl = readline.createInterface({ input: stream }); |
| 1540 | return new Promise((resolve, reject) => { |
| 1541 | let resolved = false; |
| 1542 | rl.on('line', line => { |
| 1543 | resolved = true; |
| 1544 | rl.close(); |
| 1545 | stream.close(); |
| 1546 | resolve(line); |
| 1547 | }); |
| 1548 | |
| 1549 | rl.on('error', error => { |
| 1550 | resolved = true; |
| 1551 | reject(error); |
| 1552 | }); |
| 1553 | |
| 1554 | // Handle empty files |
| 1555 | stream.on('end', () => { |
| 1556 | if (!resolved) { |
| 1557 | resolved = true; |
| 1558 | resolve(''); |
| 1559 | } |
| 1560 | }); |
| 1561 | }); |
| 1562 | } |
| 1563 | |
| 1564 | /** |
| 1565 | * If the file is an image, and the request's user agent matches Firefox, then the response's headers are set to invalidate the cache. |
| 1566 | * Without this, Firefox ignores updated images even after a refresh. |
| 1567 | * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control |
| 1568 | * @param {string} file File path |
| 1569 | * @param {import('express').Request} request Request object |
| 1570 | * @param {import('express').Response} response Response object |
| 1571 | */ |
| 1572 | export function invalidateFirefoxCache(file, request, response) { |
| 1573 | const mimeType = isFirefox(request) && mime.lookup(file); |
| 1574 | if (mimeType && mimeType.startsWith('image/')) { |
| 1575 | response.setHeader('Cache-Control', 'must-understand, no-store'); |
| 1576 | } |
| 1577 | } |