Backported refactor of the chats endpoint from `feat/chat-tree.` (#4870) * Backported cleanup and refactor of chats endpoint from `feat/chat-tree`. * https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599941781 * Error message. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599941796 https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599941797 * chatIntegritySlug https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599958991 * /save should not hang on empty body.chat. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599959001 https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599958997 * When the backup directory does not exist, log an error instead of silently failing. * chatData should be an array. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599990728 * Skip body check. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599987542 * Change integrity warning to debug. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2600005665 * Return null on error in `tryReadFileSync`. * Update warning message for missing chat file to clarify potential emptiness * Rename IntegrityMismatch to IntegrityMismatchError for clarity and consistency * Fix a type error and formatting * Improve debug messages and documentation for integrity checks in chat saving * Add newline before file writing function for improved readability * Improve debug message formatting for integrity validation in checkChatIntegrity function --------- Co-authored-by: user <user@exmaple.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -16,6 +16,10 @@ import { | |||
| 16 | generateTimestamp, | 16 | generateTimestamp, |
| 17 | removeOldBackups, | 17 | removeOldBackups, |
| 18 | formatBytes, | 18 | formatBytes, |
| 19 | tryWriteFileSync, | ||
| 20 | tryReadFileSync, | ||
| 21 | tryDeleteFile, | ||
| 22 | readFirstLine, | ||
| 19 | } from '../util.js'; | 23 | } from '../util.js'; |
| 20 | 24 | ||
| 21 | const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean'); | 25 | const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean'); |
| @@ -27,43 +31,43 @@ export const CHAT_BACKUPS_PREFIX = 'chat_'; | |||
| 27 | 31 | ||
| 28 | /** | 32 | /** |
| 29 | * Saves a chat to the backups directory. | 33 | * Saves a chat to the backups directory. |
| 30 | * @param {string} directory The user's backups directory. | 34 | * @param {string} directory The user's backup directory. |
| 31 | * @param {string} name The name of the chat. | 35 | * @param {string} name The name of the chat. |
| 32 | * @param {string} chat The serialized chat to save. | 36 | * @param {string} data The serialized chat to save. |
| 37 | * @param {string} backupPrefix The file prefix. Typically CHAT_BACKUPS_PREFIX. | ||
| 38 | * @returns | ||
| 33 | */ | 39 | */ |
| 34 | function backupChat(directory, name, chat) { | 40 | function backupChat(directory, name, data, backupPrefix = CHAT_BACKUPS_PREFIX) { |
| 35 | try { | 41 | try { |
| 36 | if (!isBackupEnabled || !fs.existsSync(directory)) { | 42 | if (!isBackupEnabled) { return; } |
| 37 | return; | 43 | if (!fs.existsSync(directory)) { |
| 44 | console.error(`The chat couldn't be backed up because no directory exists at ${directory}!`); | ||
| 38 | } | 45 | } |
| 39 | |||
| 40 | // replace non-alphanumeric characters with underscores | 46 | // replace non-alphanumeric characters with underscores |
| 41 | name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase(); | 47 | name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase(); |
| 42 | 48 | ||
| 43 | const backupFile = path.join(directory, `${CHAT_BACKUPS_PREFIX}${name}_${generateTimestamp()}.jsonl`); | 49 | const backupFile = path.join(directory, `${backupPrefix}${name}_${generateTimestamp()}.jsonl`); |
| 44 | writeFileAtomicSync(backupFile, chat, 'utf-8'); | ||
| 45 | |||
| 46 | removeOldBackups(directory, `${CHAT_BACKUPS_PREFIX}${name}_`); | ||
| 47 | 50 | ||
| 51 | tryWriteFileSync(backupFile, data); | ||
| 52 | removeOldBackups(directory, `${backupPrefix}${name}_`); | ||
| 48 | if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) { | 53 | if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) { |
| 49 | return; | 54 | return; |
| 50 | } | 55 | } |
| 51 | 56 | removeOldBackups(directory, backupPrefix, maxTotalChatBackups); | |
| 52 | removeOldBackups(directory, CHAT_BACKUPS_PREFIX, maxTotalChatBackups); | ||
| 53 | } catch (err) { | 57 | } catch (err) { |
| 54 | console.error(`Could not backup chat for ${name}`, err); | 58 | console.error(`Could not backup chat for ${name}`, err); |
| 55 | } | 59 | } |
| 56 | } | 60 | } |
| 57 | 61 | ||
| 58 | /** | 62 | /** |
| 59 | * @type {Map<string, import('lodash').DebouncedFunc<function(string, string, string): void>>} | 63 | * @type {Map<string, import('lodash').DebouncedFunc<typeof backupChat>>} |
| 60 | */ | 64 | */ |
| 61 | const backupFunctions = new Map(); | 65 | const backupFunctions = new Map(); |
| 62 | 66 | ||
| 63 | /** | 67 | /** |
| 64 | * Gets a backup function for a user. | 68 | * Gets a backup function for a user. |
| 65 | * @param {string} handle User handle | 69 | * @param {string} handle User handle |
| 66 | * @returns {function(string, string, string): void} Backup function | 70 | * @returns {typeof backupChat} Backup function |
| 67 | */ | 71 | */ |
| 68 | function getBackupFunction(handle) { | 72 | function getBackupFunction(handle) { |
| 69 | if (!backupFunctions.has(handle)) { | 73 | if (!backupFunctions.has(handle)) { |
| @@ -304,38 +308,6 @@ function importRisuChat(userName, characterName, jsonData) { | |||
| 304 | } | 308 | } |
| 305 | 309 | ||
| 306 | /** | 310 | /** |
| 307 | * Reads the first line of a file asynchronously. | ||
| 308 | * @param {string} filePath Path to the file | ||
| 309 | * @returns {Promise<string>} The first line of the file | ||
| 310 | */ | ||
| 311 | function readFirstLine(filePath) { | ||
| 312 | const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); | ||
| 313 | const rl = readline.createInterface({ input: stream }); | ||
| 314 | return new Promise((resolve, reject) => { | ||
| 315 | let resolved = false; | ||
| 316 | rl.on('line', line => { | ||
| 317 | resolved = true; | ||
| 318 | rl.close(); | ||
| 319 | stream.close(); | ||
| 320 | resolve(line); | ||
| 321 | }); | ||
| 322 | |||
| 323 | rl.on('error', error => { | ||
| 324 | resolved = true; | ||
| 325 | reject(error); | ||
| 326 | }); | ||
| 327 | |||
| 328 | // Handle empty files | ||
| 329 | stream.on('end', () => { | ||
| 330 | if (!resolved) { | ||
| 331 | resolved = true; | ||
| 332 | resolve(''); | ||
| 333 | } | ||
| 334 | }); | ||
| 335 | }); | ||
| 336 | } | ||
| 337 | |||
| 338 | /** | ||
| 339 | * Checks if the chat being saved has the same integrity as the one being loaded. | 311 | * Checks if the chat being saved has the same integrity as the one being loaded. |
| 340 | * @param {string} filePath Path to the chat file | 312 | * @param {string} filePath Path to the chat file |
| 341 | * @param {string} integritySlug Integrity slug | 313 | * @param {string} integritySlug Integrity slug |
| @@ -354,6 +326,7 @@ async function checkChatIntegrity(filePath, integritySlug) { | |||
| 354 | 326 | ||
| 355 | // If the chat has no integrity metadata, assume it's intact | 327 | // If the chat has no integrity metadata, assume it's intact |
| 356 | if (!chatIntegrity) { | 328 | if (!chatIntegrity) { |
| 329 | console.debug(`File "${filePath}" does not have integrity metadata matching "${integritySlug}". The integrity validation has been skipped.`); | ||
| 357 | return true; | 330 | return true; |
| 358 | } | 331 | } |
| 359 | 332 | ||
| @@ -439,30 +412,85 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata | |||
| 439 | 412 | ||
| 440 | export const router = express.Router(); | 413 | export const router = express.Router(); |
| 441 | 414 | ||
| 415 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error | ||
| 416 | class IntegrityMismatchError extends Error { | ||
| 417 | constructor(...params) { | ||
| 418 | // Pass remaining arguments (including vendor specific ones) to parent constructor | ||
| 419 | super(...params); | ||
| 420 | // Maintains proper stack trace for where our error was thrown (non-standard) | ||
| 421 | if (Error.captureStackTrace) { | ||
| 422 | Error.captureStackTrace(this, IntegrityMismatchError); | ||
| 423 | } | ||
| 424 | this.date = new Date(); | ||
| 425 | } | ||
| 426 | } | ||
| 427 | |||
| 428 | /** | ||
| 429 | * Tries to save the chat data to a file, performing an integrity check if required. | ||
| 430 | * @param {Array} chatData The chat array to save. | ||
| 431 | * @param {string} filePath Target file path for the data. | ||
| 432 | * @param {boolean} skipIntegrityCheck If undefined, the chat's integrity will not be checked. | ||
| 433 | * @param {string} handle The users handle, passed to getBackupFunction. | ||
| 434 | * @param {string} cardName Passed to backupChat. | ||
| 435 | * @param {string} backupDirectory Passed to backupChat. | ||
| 436 | */ | ||
| 437 | export async function trySaveChat(chatData, filePath, skipIntegrityCheck = false, handle, cardName, backupDirectory) { | ||
| 438 | const jsonlData = chatData?.map(m => JSON.stringify(m)).join('\n'); | ||
| 439 | |||
| 440 | const doIntegrityCheck = (checkIntegrity && !skipIntegrityCheck); | ||
| 441 | const chatIntegritySlug = doIntegrityCheck ? chatData?.[0]?.chat_metadata?.integrity : undefined; | ||
| 442 | |||
| 443 | if (chatIntegritySlug && !await checkChatIntegrity(filePath, chatIntegritySlug)) { | ||
| 444 | throw new IntegrityMismatchError(`Chat integrity check failed for "${filePath}". The expected integrity slug was "${chatIntegritySlug}".`); | ||
| 445 | } | ||
| 446 | tryWriteFileSync(filePath, jsonlData); | ||
| 447 | getBackupFunction(handle)(backupDirectory, cardName, jsonlData); | ||
| 448 | } | ||
| 449 | |||
| 442 | router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { | 450 | router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { |
| 443 | try { | 451 | try { |
| 444 | const directoryName = String(request.body.avatar_url).replace('.png', ''); | 452 | const handle = request.user.profile.handle; |
| 453 | const cardName = String(request.body.avatar_url).replace('.png', ''); | ||
| 445 | const chatData = request.body.chat; | 454 | const chatData = request.body.chat; |
| 446 | const jsonlData = chatData.map(JSON.stringify).join('\n'); | 455 | const chatFileName = `${String(request.body.file_name)}.jsonl`; |
| 447 | const fileName = `${String(request.body.file_name)}.jsonl`; | 456 | const chatFilePath = path.join(request.user.directories.chats, cardName, sanitize(chatFileName)); |
| 448 | const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName)); | 457 | |
| 449 | if (checkIntegrity && !request.body.force) { | 458 | if (Array.isArray(chatData)) { |
| 450 | const integritySlug = chatData?.[0]?.chat_metadata?.integrity; | 459 | await trySaveChat(chatData, chatFilePath, request.body.force, handle, cardName, request.user.directories.backups); |
| 451 | const isIntact = await checkChatIntegrity(filePath, integritySlug); | 460 | return response.send({ ok: true }); |
| 452 | if (!isIntact) { | 461 | } else { |
| 453 | console.error(`Chat integrity check failed for ${filePath}`); | 462 | return response.status(400).send({ error: 'The request\'s body.chat is not an array.' }); |
| 454 | return response.status(400).send({ error: 'integrity' }); | ||
| 455 | } | ||
| 456 | } | 463 | } |
| 457 | writeFileAtomicSync(filePath, jsonlData, 'utf8'); | ||
| 458 | getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData); | ||
| 459 | return response.send({ result: 'ok' }); | ||
| 460 | } catch (error) { | 464 | } catch (error) { |
| 465 | if (error instanceof IntegrityMismatchError) { | ||
| 466 | console.error(error.message); | ||
| 467 | return response.status(400).send({ error: 'integrity' }); | ||
| 468 | } | ||
| 461 | console.error(error); | 469 | console.error(error); |
| 462 | return response.send(error); | 470 | return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' }); |
| 463 | } | 471 | } |
| 464 | }); | 472 | }); |
| 465 | 473 | ||
| 474 | /** | ||
| 475 | * Gets the chat as an object. | ||
| 476 | * @param {string} chatFilePath The full chat file path. | ||
| 477 | * @returns {Array}} If the chatFilePath cannot be read, this will return []. | ||
| 478 | */ | ||
| 479 | export function getChatData(chatFilePath) { | ||
| 480 | let chatData = []; | ||
| 481 | |||
| 482 | const chatJSON = tryReadFileSync(chatFilePath) ?? ''; | ||
| 483 | if (chatJSON.length > 0) { | ||
| 484 | const lines = chatJSON.split('\n'); | ||
| 485 | // Iterate through the array of strings and parse each line as JSON | ||
| 486 | chatData = lines.map(line => tryParse(line)).filter(x => x); | ||
| 487 | } else { | ||
| 488 | console.warn(`File not found: ${chatFilePath}. The chat does not exist or is empty.`); | ||
| 489 | } | ||
| 490 | |||
| 491 | return chatData; | ||
| 492 | } | ||
| 493 | |||
| 466 | router.post('/get', validateAvatarUrlMiddleware, function (request, response) { | 494 | router.post('/get', validateAvatarUrlMiddleware, function (request, response) { |
| 467 | try { | 495 | try { |
| 468 | const dirName = String(request.body.avatar_url).replace('.png', ''); | 496 | const dirName = String(request.body.avatar_url).replace('.png', ''); |
| @@ -479,20 +507,10 @@ router.post('/get', validateAvatarUrlMiddleware, function (request, response) { | |||
| 479 | return response.send({}); | 507 | return response.send({}); |
| 480 | } | 508 | } |
| 481 | 509 | ||
| 482 | const fileName = `${String(request.body.file_name)}.jsonl`; | 510 | const chatFileName = `${String(request.body.file_name)}.jsonl`; |
| 483 | const filePath = path.join(directoryPath, sanitize(fileName)); | 511 | const chatFilePath = path.join(directoryPath, sanitize(chatFileName)); |
| 484 | const chatFileExists = fs.existsSync(filePath); | ||
| 485 | 512 | ||
| 486 | if (!chatFileExists) { | 513 | return response.send(getChatData(chatFilePath)); |
| 487 | return response.send({}); | ||
| 488 | } | ||
| 489 | |||
| 490 | const data = fs.readFileSync(filePath, 'utf8'); | ||
| 491 | const lines = data.split('\n'); | ||
| 492 | |||
| 493 | // Iterate through the array of strings and parse each line as JSON | ||
| 494 | const jsonData = lines.map((l) => { try { return JSON.parse(l); } catch (_) { return; } }).filter(x => x); | ||
| 495 | return response.send(jsonData); | ||
| 496 | } catch (error) { | 514 | } catch (error) { |
| 497 | console.error(error); | 515 | console.error(error); |
| 498 | return response.send({}); | 516 | return response.send({}); |
| @@ -536,18 +554,15 @@ router.post('/delete', validateAvatarUrlMiddleware, function (request, response) | |||
| 536 | } | 554 | } |
| 537 | 555 | ||
| 538 | const dirName = String(request.body.avatar_url).replace('.png', ''); | 556 | const dirName = String(request.body.avatar_url).replace('.png', ''); |
| 539 | const fileName = String(request.body.chatfile); | 557 | const chatFileName = String(request.body.chatfile); |
| 540 | const filePath = path.join(request.user.directories.chats, dirName, sanitize(fileName)); | 558 | const chatFilePath = path.join(request.user.directories.chats, dirName, sanitize(chatFileName)); |
| 541 | const chatFileExists = fs.existsSync(filePath); | 559 | //Return success if the file was deleted. |
| 542 | 560 | if (tryDeleteFile(chatFilePath)) { | |
| 543 | if (!chatFileExists) { | 561 | return response.send({ ok: true }); |
| 544 | console.error(`Chat file not found '${filePath}'`); | 562 | } else { |
| 563 | console.error('The chat file was not deleted.'); | ||
| 545 | return response.sendStatus(400); | 564 | return response.sendStatus(400); |
| 546 | } | 565 | } |
| 547 | |||
| 548 | fs.unlinkSync(filePath); | ||
| 549 | console.info(`Deleted chat file: ${filePath}`); | ||
| 550 | return response.send('ok'); | ||
| 551 | } catch (error) { | 566 | } catch (error) { |
| 552 | console.error(error); | 567 | console.error(error); |
| 553 | return response.sendStatus(500); | 568 | return response.sendStatus(500); |
| @@ -745,67 +760,58 @@ router.post('/group/get', (request, response) => { | |||
| 745 | } | 760 | } |
| 746 | 761 | ||
| 747 | const id = request.body.id; | 762 | const id = request.body.id; |
| 748 | const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); | 763 | const chatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`); |
| 749 | |||
| 750 | if (fs.existsSync(pathToFile)) { | ||
| 751 | const data = fs.readFileSync(pathToFile, 'utf8'); | ||
| 752 | const lines = data.split('\n'); | ||
| 753 | 764 | ||
| 754 | // Iterate through the array of strings and parse each line as JSON | 765 | return response.send(getChatData(chatFilePath)); |
| 755 | const jsonData = lines.map(line => tryParse(line)).filter(x => x); | ||
| 756 | return response.send(jsonData); | ||
| 757 | } else { | ||
| 758 | return response.send([]); | ||
| 759 | } | ||
| 760 | }); | 766 | }); |
| 761 | 767 | ||
| 762 | router.post('/group/delete', (request, response) => { | 768 | router.post('/group/delete', (request, response) => { |
| 769 | try { | ||
| 763 | if (!request.body || !request.body.id) { | 770 | if (!request.body || !request.body.id) { |
| 764 | return response.sendStatus(400); | 771 | return response.sendStatus(400); |
| 765 | } | 772 | } |
| 766 | 773 | ||
| 767 | const id = request.body.id; | 774 | const id = request.body.id; |
| 768 | const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); | 775 | const chatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`); |
| 769 | 776 | ||
| 770 | if (fs.existsSync(pathToFile)) { | 777 | //Return success if the file was deleted. |
| 771 | fs.unlinkSync(pathToFile); | 778 | if (tryDeleteFile(chatFilePath)) { |
| 772 | return response.send({ ok: true }); | 779 | return response.send({ ok: true }); |
| 780 | } else { | ||
| 781 | console.error('The group chat file was not deleted.\''); | ||
| 782 | return response.sendStatus(400); | ||
| 783 | } | ||
| 784 | } catch (error) { | ||
| 785 | console.error(error); | ||
| 786 | return response.sendStatus(500); | ||
| 773 | } | 787 | } |
| 774 | |||
| 775 | return response.send({ error: true }); | ||
| 776 | }); | 788 | }); |
| 777 | 789 | ||
| 778 | router.post('/group/save', async (request, response) => { | 790 | router.post('/group/save', async function (request, response) { |
| 779 | try { | 791 | try { |
| 780 | if (!request.body || !request.body.id) { | 792 | if (!request.body || !request.body.id) { |
| 781 | return response.sendStatus(400); | 793 | return response.sendStatus(400); |
| 782 | } | 794 | } |
| 783 | 795 | ||
| 784 | const id = request.body.id; | 796 | const id = request.body.id; |
| 785 | const filePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`)); | 797 | const handle = request.user.profile.handle; |
| 786 | 798 | const chatFilePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`)); | |
| 787 | if (!fs.existsSync(request.user.directories.groupChats)) { | ||
| 788 | fs.mkdirSync(request.user.directories.groupChats, { recursive: true }); | ||
| 789 | } | ||
| 790 | |||
| 791 | const chatData = request.body.chat; | 799 | const chatData = request.body.chat; |
| 792 | const jsonlData = chatData.map(JSON.stringify).join('\n'); | ||
| 793 | 800 | ||
| 794 | if (checkIntegrity && !request.body.force) { | 801 | if (Array.isArray(chatData)) { |
| 795 | const integritySlug = chatData?.[0]?.chat_metadata?.integrity; | 802 | await trySaveChat(chatData, chatFilePath, request.body.force, handle, String(id), request.user.directories.backups); |
| 796 | const isIntact = await checkChatIntegrity(filePath, integritySlug); | 803 | return response.send({ ok: true }); |
| 797 | if (!isIntact) { | ||
| 798 | console.error(`Chat integrity check failed for ${filePath}`); | ||
| 799 | return response.status(400).send({ error: 'integrity' }); | ||
| 800 | } | 804 | } |
| 805 | else { | ||
| 806 | return response.status(400).send({ error: 'The request\'s body.chat is not an array.' }); | ||
| 801 | } | 807 | } |
| 802 | |||
| 803 | writeFileAtomicSync(filePath, jsonlData, 'utf8'); | ||
| 804 | getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData); | ||
| 805 | return response.send({ ok: true }); | ||
| 806 | } catch (error) { | 808 | } catch (error) { |
| 809 | if (error instanceof IntegrityMismatchError) { | ||
| 810 | console.error(error.message); | ||
| 811 | return response.status(400).send({ error: 'integrity' }); | ||
| 812 | } | ||
| 807 | console.error(error); | 813 | console.error(error); |
| 808 | return response.send({ error: true }); | 814 | return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' }); |
| 809 | } | 815 | } |
| 810 | }); | 816 | }); |
| 811 | 817 | ||
| @@ -877,10 +883,8 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response) | |||
| 877 | 883 | ||
| 878 | // Search logic | 884 | // Search logic |
| 879 | for (const chatFile of chatFiles) { | 885 | for (const chatFile of chatFiles) { |
| 880 | const data = fs.readFileSync(chatFile.path, 'utf8'); | 886 | const data = getChatData(chatFile.path); |
| 881 | const messages = data.split('\n') | 887 | const messages = data.filter(x => x && typeof x.mes === 'string'); |
| 882 | .map(line => { try { return JSON.parse(line); } catch (_) { return null; } }) | ||
| 883 | .filter(x => x && typeof x.mes === 'string'); | ||
| 884 | 888 | ||
| 885 | if (query && messages.length === 0) { | 889 | if (query && messages.length === 0) { |
| 886 | continue; | 890 | continue; |
| @@ -8,6 +8,7 @@ import { Buffer } from 'node:buffer'; | |||
| 8 | import { promises as dnsPromise } from 'node:dns'; | 8 | import { promises as dnsPromise } from 'node:dns'; |
| 9 | import os from 'node:os'; | 9 | import os from 'node:os'; |
| 10 | import crypto from 'node:crypto'; | 10 | import crypto from 'node:crypto'; |
| 11 | import readline from 'node:readline'; | ||
| 11 | 12 | ||
| 12 | import yaml from 'yaml'; | 13 | import yaml from 'yaml'; |
| 13 | import { sync as commandExistsSync } from 'command-exists'; | 14 | import { sync as commandExistsSync } from 'command-exists'; |
| @@ -19,6 +20,7 @@ import chalk from 'chalk'; | |||
| 19 | import bytes from 'bytes'; | 20 | import bytes from 'bytes'; |
| 20 | import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js'; | 21 | import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js'; |
| 21 | import { serverDirectory } from './server-directory.js'; | 22 | import { serverDirectory } from './server-directory.js'; |
| 23 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; | ||
| 22 | import { isFirefox } from './express-common.js'; | 24 | import { isFirefox } from './express-common.js'; |
| 23 | 25 | ||
| 24 | /** | 26 | /** |
| @@ -1464,6 +1466,84 @@ export function flattenSchema(schema, api) { | |||
| 1464 | } | 1466 | } |
| 1465 | 1467 | ||
| 1466 | /** | 1468 | /** |
| 1469 | * Writes to a file, creating it's parent directories if needed. | ||
| 1470 | * @param {string} filePath | ||
| 1471 | * @param {string} data | ||
| 1472 | */ | ||
| 1473 | export function tryWriteFileSync(filePath, data) { | ||
| 1474 | const directory = path.dirname(filePath); | ||
| 1475 | //Ensure the directory exists. | ||
| 1476 | if (!fs.existsSync(directory)) { | ||
| 1477 | fs.mkdirSync(directory, { recursive: true }); | ||
| 1478 | } | ||
| 1479 | writeFileAtomicSync(filePath, data, 'utf8'); | ||
| 1480 | } | ||
| 1481 | |||
| 1482 | /** | ||
| 1483 | * Attempts to read a file as utf8. | ||
| 1484 | * @param {string} filePath | ||
| 1485 | * @returns {string|null} | ||
| 1486 | */ | ||
| 1487 | export function tryReadFileSync(filePath) { | ||
| 1488 | try { | ||
| 1489 | if (fs.existsSync(filePath)) { | ||
| 1490 | return fs.readFileSync(filePath, 'utf8'); | ||
| 1491 | } | ||
| 1492 | } catch (error) { | ||
| 1493 | console.error(`Error reading ${filePath}: ${error.message}`); | ||
| 1494 | } | ||
| 1495 | return null; | ||
| 1496 | } | ||
| 1497 | |||
| 1498 | /** | ||
| 1499 | * Attempts to delete a file. | ||
| 1500 | * @param {string} filePath Target file. | ||
| 1501 | * @returns {boolean} Returns true if the file was found and deleted. | ||
| 1502 | */ | ||
| 1503 | export function tryDeleteFile(filePath) { | ||
| 1504 | if (fs.existsSync(filePath)) { | ||
| 1505 | fs.unlinkSync(filePath); | ||
| 1506 | console.info(`Deleted file: ${filePath}`); | ||
| 1507 | return true; | ||
| 1508 | } else { | ||
| 1509 | console.error(`File not found '${filePath}'`); | ||
| 1510 | return false; | ||
| 1511 | } | ||
| 1512 | } | ||
| 1513 | |||
| 1514 | /** | ||
| 1515 | * Reads the first line of a file asynchronously. | ||
| 1516 | * @param {string} filePath Path to the file | ||
| 1517 | * @returns {Promise<string>} The first line of the file | ||
| 1518 | */ | ||
| 1519 | export function readFirstLine(filePath) { | ||
| 1520 | const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); | ||
| 1521 | const rl = readline.createInterface({ input: stream }); | ||
| 1522 | return new Promise((resolve, reject) => { | ||
| 1523 | let resolved = false; | ||
| 1524 | rl.on('line', line => { | ||
| 1525 | resolved = true; | ||
| 1526 | rl.close(); | ||
| 1527 | stream.close(); | ||
| 1528 | resolve(line); | ||
| 1529 | }); | ||
| 1530 | |||
| 1531 | rl.on('error', error => { | ||
| 1532 | resolved = true; | ||
| 1533 | reject(error); | ||
| 1534 | }); | ||
| 1535 | |||
| 1536 | // Handle empty files | ||
| 1537 | stream.on('end', () => { | ||
| 1538 | if (!resolved) { | ||
| 1539 | resolved = true; | ||
| 1540 | resolve(''); | ||
| 1541 | } | ||
| 1542 | }); | ||
| 1543 | }); | ||
| 1544 | } | ||
| 1545 | |||
| 1546 | /** | ||
| 1467 | * 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. | 1547 | * 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. |
| 1468 | * Without this, Firefox ignores updated images even after a refresh. | 1548 | * Without this, Firefox ignores updated images even after a refresh. |
| 1469 | * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control | 1549 | * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control |