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 {
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';
2024
21const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');25const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');
@@ -27,43 +31,43 @@ export const CHAT_BACKUPS_PREFIX = 'chat_';
2731
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 */
34function backupChat(directory, name, chat) {40function 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 underscores46 // 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();
4248
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}_`);
4750
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 }
5156 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}
5761
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 */
61const backupFunctions = new Map();65const backupFunctions = new Map();
6266
63/**67/**
64 * Gets a backup function for a user.68 * Gets a backup function for a user.
65 * @param {string} handle User handle69 * @param {string} handle User handle
66 * @returns {function(string, string, string): void} Backup function70 * @returns {typeof backupChat} Backup function
67 */71 */
68function getBackupFunction(handle) {72function getBackupFunction(handle) {
69 if (!backupFunctions.has(handle)) {73 if (!backupFunctions.has(handle)) {
@@ -304,38 +308,6 @@ function importRisuChat(userName, characterName, jsonData) {
304}308}
305309
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 */
311function 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 file312 * @param {string} filePath Path to the chat file
341 * @param {string} integritySlug Integrity slug313 * @param {string} integritySlug Integrity slug
@@ -354,6 +326,7 @@ async function checkChatIntegrity(filePath, integritySlug) {
354326
355 // If the chat has no integrity metadata, assume it's intact327 // 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 }
359332
@@ -439,30 +412,85 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
439412
440export const router = express.Router();413export const router = express.Router();
441414
415// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
416class 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 */
437export 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
442router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {450router.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});
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 */
479export 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
466router.post('/get', validateAvatarUrlMiddleware, function (request, response) {494router.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 }
481509
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
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 JSON513 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);
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 }
537555
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.
542560 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 }
746761
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`);
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 }
760});766});
761767
762router.post('/group/delete', (request, response) => {768router.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
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`);
769776
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});
777789
778router.post('/group/save', async (request, response) => {790router.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 }
783795
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;
798 const chatFilePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`));
799 const chatData = request.body.chat;
786800
787 if (!fs.existsSync(request.user.directories.groupChats)) {801 if (Array.isArray(chatData)) {
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 });
789 }804 }
790805 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 }
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});
811817
@@ -877,10 +883,8 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response)
877883
878 // Search logic884 // 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');
884888
885 if (query && messages.length === 0) {889 if (query && messages.length === 0) {
886 continue;890 continue;
src/util.js+80 -0
@@ -8,6 +8,7 @@ import { Buffer } from 'node:buffer';
8import { promises as dnsPromise } from 'node:dns';8import { promises as dnsPromise } from 'node:dns';
9import os from 'node:os';9import os from 'node:os';
10import crypto from 'node:crypto';10import crypto from 'node:crypto';
11import readline from 'node:readline';
1112
12import yaml from 'yaml';13import yaml from 'yaml';
13import { sync as commandExistsSync } from 'command-exists';14import { sync as commandExistsSync } from 'command-exists';
@@ -19,6 +20,7 @@ import chalk from 'chalk';
19import bytes from 'bytes';20import bytes from 'bytes';
20import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js';21import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js';
21import { serverDirectory } from './server-directory.js';22import { serverDirectory } from './server-directory.js';
23import { sync as writeFileAtomicSync } from 'write-file-atomic';
22import { isFirefox } from './express-common.js';24import { isFirefox } from './express-common.js';
2325
24/**26/**
@@ -1464,6 +1466,84 @@ export function flattenSchema(schema, api) {
1464}1466}
14651467
1466/**1468/**
1469 * Writes to a file, creating it's parent directories if needed.
1470 * @param {string} filePath
1471 * @param {string} data
1472 */
1473export 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*/
1487export 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*/
1503export 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 */
1519export 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-Control1549 * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control