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>

d5320f57cf2619a3a0812d45aa922be315aff690

DeclineThyself <235079501+DeclineThyself@users.noreply.github.com>

Signed
2 files changed, +214 -130Ignore whitespace
src/endpoints/chats.js+134 -130
@@ -16,6 +16,10 @@ import {
1616 generateTimestamp,
1717 removeOldBackups,
1818 formatBytes,
19+ tryWriteFileSync,
20+ tryReadFileSync,
21+ tryDeleteFile,
22+ readFirstLine,
1923} from '../util.js';
2024
2125const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');
@@ -27,43 +31,43 @@ export const CHAT_BACKUPS_PREFIX = 'chat_';
2731
2832/**
2933 * Saves a chat to the backups directory.
3034 * @param {string} directory The user's backupsbackup directory.
3135 * @param {string} name The name of the chat.
3236 * @param {string} chatdata The serialized chat to save.
37+ * @param {string} backupPrefix The file prefix. Typically CHAT_BACKUPS_PREFIX.
38+ * @returns
3339 */
3440function backupChat(directory, name, chatdata, backupPrefix = CHAT_BACKUPS_PREFIX) {
3541 try {
3642 if (!isBackupEnabled || !fs.existsSync(directory)) { return; }
37- return;
43+ if (!fs.existsSync(directory)) {
44+ console.error(`The chat couldn't be backed up because no directory exists at ${directory}!`);
3845 }
39-
4046 // replace non-alphanumeric characters with underscores
4147 name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase();
4248
4349 const backupFile = path.join(directory, `${CHAT_BACKUPS_PREFIXbackupPrefix}${name}_${generateTimestamp()}.jsonl`);
44- writeFileAtomicSync(backupFile, chat, 'utf-8');
45-
46- removeOldBackups(directory, `${CHAT_BACKUPS_PREFIX}${name}_`);
4750
51+ tryWriteFileSync(backupFile, data);
52+ removeOldBackups(directory, `${backupPrefix}${name}_`);
4853 if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) {
4954 return;
5055 }
51-
56+ removeOldBackups(directory, backupPrefix, maxTotalChatBackups);
52- removeOldBackups(directory, CHAT_BACKUPS_PREFIX, maxTotalChatBackups);
5357 } catch (err) {
5458 console.error(`Could not backup chat for ${name}`, err);
5559 }
5660}
5761
5862/**
5963 * @type {Map<string, import('lodash').DebouncedFunc<function(string, string, string):typeof voidbackupChat>>}
6064 */
6165const backupFunctions = new Map();
6266
6367/**
6468 * Gets a backup function for a user.
6569 * @param {string} handle User handle
6670 * @returns {function(string, string, string):typeof voidbackupChat} Backup function
6771 */
6872function getBackupFunction(handle) {
6973 if (!backupFunctions.has(handle)) {
@@ -304,38 +308,6 @@ function importRisuChat(userName, characterName, jsonData) {
304308}
305309
306310/**
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-/**
339311 * Checks if the chat being saved has the same integrity as the one being loaded.
340312 * @param {string} filePath Path to the chat file
341313 * @param {string} integritySlug Integrity slug
@@ -354,6 +326,7 @@ async function checkChatIntegrity(filePath, integritySlug) {
354326
355327 // If the chat has no integrity metadata, assume it's intact
356328 if (!chatIntegrity) {
329+ console.debug(`File "${filePath}" does not have integrity metadata matching "${integritySlug}". The integrity validation has been skipped.`);
357330 return true;
358331 }
359332
@@ -439,30 +412,85 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
439412
440413export const router = express.Router();
441414
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+
442450router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
443451 try {
444452 const directoryNamehandle = String(request.body.avatar_url)user.replace('profile.png', '')handle;
453+ const cardName = String(request.body.avatar_url).replace('.png', '');
445454 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+
449458 if (checkIntegrity && !request.bodyArray.forceisArray(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- }
456463 }
457- writeFileAtomicSync(filePath, jsonlData, 'utf8');
458- getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData);
459- return response.send({ result: 'ok' });
460464 } catch (error) {
465+ if (error instanceof IntegrityMismatchError) {
466+ console.error(error.message);
467+ return response.status(400).send({ error: 'integrity' });
468+ }
461469 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.' });
463471 }
464472});
465473
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+
466494router.post('/get', validateAvatarUrlMiddleware, function (request, response) {
467495 try {
468496 const dirName = String(request.body.avatar_url).replace('.png', '');
@@ -479,20 +507,10 @@ router.post('/get', validateAvatarUrlMiddleware, function (request, response) {
479507 return response.send({});
480508 }
481509
482510 const fileNamechatFileName = `${String(request.body.file_name)}.jsonl`;
483511 const filePathchatFilePath = path.join(directoryPath, sanitize(fileNamechatFileName));
484- const chatFileExists = fs.existsSync(filePath);
485-
486- if (!chatFileExists) {
487- return response.send({});
488- }
489-
490- const data = fs.readFileSync(filePath, 'utf8');
491- const lines = data.split('\n');
492512
493- // Iterate through the array of strings and parse each line as JSON
513+ return response.send(getChatData(chatFilePath));
494- const jsonData = lines.map((l) => { try { return JSON.parse(l); } catch (_) { return; } }).filter(x => x);
495- return response.send(jsonData);
496514 } catch (error) {
497515 console.error(error);
498516 return response.send({});
@@ -536,18 +554,15 @@ router.post('/delete', validateAvatarUrlMiddleware, function (request, response)
536554 }
537555
538556 const dirName = String(request.body.avatar_url).replace('.png', '');
539557 const fileNamechatFileName = String(request.body.chatfile);
540558 const filePathchatFilePath = path.join(request.user.directories.chats, dirName, sanitize(fileNamechatFileName));
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.');
545564 return response.sendStatus(400);
546565 }
547-
548- fs.unlinkSync(filePath);
549- console.info(`Deleted chat file: ${filePath}`);
550- return response.send('ok');
551566 } catch (error) {
552567 console.error(error);
553568 return response.sendStatus(500);
@@ -745,67 +760,58 @@ router.post('/group/get', (request, response) => {
745760 }
746761
747762 const id = request.body.id;
748763 const pathToFilechatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`);
749764
750- if (fs.existsSync(pathToFile)) {
765+ return response.send(getChatData(chatFilePath));
751- const data = fs.readFileSync(pathToFile, 'utf8');
752- const lines = data.split('\n');
753-
754- // Iterate through the array of strings and parse each line as JSON
755- const jsonData = lines.map(line => tryParse(line)).filter(x => x);
756- return response.send(jsonData);
757- } else {
758- return response.send([]);
759- }
760766});
761767
762768router.post('/group/delete', (request, response) => {
763- if (!request.body || !request.body.id) {
769+ try {
764- return response.sendStatus(400);
770+ if (!request.body || !request.body.id) {
765- }
771+ return response.sendStatus(400);
772+ }
766773
767774 const id = request.body.id;
768775 const pathToFilechatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`);
769776
770- if (fs.existsSync(pathToFile)) {
777+ //Return success if the file was deleted.
771- fs.unlinkSync(pathToFile);
778+ if (tryDeleteFile(chatFilePath)) {
772779 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);
773787 }
774-
775- return response.send({ error: true });
776788});
777789
778790router.post('/group/save', async function (request, response) => {
779791 try {
780792 if (!request.body || !request.body.id) {
781793 return response.sendStatus(400);
782794 }
783795
784796 const id = request.body.id;
785797 const filePathhandle = path.join(request.user.directories.groupChats, sanitize(`${id}profile.jsonl`))handle;
798+ const chatFilePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`));
799+ const chatData = request.body.chat;
786800
787801 if (!fsArray.existsSyncisArray(request.user.directories.groupChatschatData)) {
788- fs.mkdirSync(request.user.directories.groupChats, { recursive: true });
802+ await trySaveChat(chatData, chatFilePath, request.body.force, handle, String(id), request.user.directories.backups);
803+ return response.send({ ok: true });
789804 }
790-
805+ else {
791- const chatData = request.body.chat;
806+ return response.status(400).send({ error: 'The request\'s body.chat is not an array.' });
792- const jsonlData = chatData.map(JSON.stringify).join('\n');
793-
794- if (checkIntegrity && !request.body.force) {
795- const integritySlug = chatData?.[0]?.chat_metadata?.integrity;
796- const isIntact = await checkChatIntegrity(filePath, integritySlug);
797- if (!isIntact) {
798- console.error(`Chat integrity check failed for ${filePath}`);
799- return response.status(400).send({ error: 'integrity' });
800- }
801807 }
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 });
806808 } catch (error) {
809+ if (error instanceof IntegrityMismatchError) {
810+ console.error(error.message);
811+ return response.status(400).send({ error: 'integrity' });
812+ }
807813 console.error(error);
808814 return response.status(500).send({ error: true'An error has occurred, see the console logs for more information.' });
809815 }
810816});
811817
@@ -877,10 +883,8 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
877883
878884 // Search logic
879885 for (const chatFile of chatFiles) {
880886 const data = fs.readFileSyncgetChatData(chatFile.path, 'utf8');
881887 const messages = data.splitfilter(x => x && typeof x.mes === '\nstring');
882- .map(line => { try { return JSON.parse(line); } catch (_) { return null; } })
883- .filter(x => x && typeof x.mes === 'string');
884888
885889 if (query && messages.length === 0) {
886890 continue;
src/util.js+80 -0
@@ -8,6 +8,7 @@ import { Buffer } from 'node:buffer';
88import { promises as dnsPromise } from 'node:dns';
99import os from 'node:os';
1010import crypto from 'node:crypto';
11+import readline from 'node:readline';
1112
1213import yaml from 'yaml';
1314import { sync as commandExistsSync } from 'command-exists';
@@ -19,6 +20,7 @@ import chalk from 'chalk';
1920import bytes from 'bytes';
2021import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js';
2122import { serverDirectory } from './server-directory.js';
23+import { sync as writeFileAtomicSync } from 'write-file-atomic';
2224import { isFirefox } from './express-common.js';
2325
2426/**
@@ -1464,6 +1466,84 @@ export function flattenSchema(schema, api) {
14641466}
14651467
14661468/**
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+/**
14671547 * 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.
14681548 * Without this, Firefox ignores updated images even after a refresh.
14691549 * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control