Merge pull request #3698 from SillyTavern/integrity Add integrity check when saving solo chat files

e2f94896848410c41cc5611caadeb456a0ed6d7f

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
4 files changed, +134 -40Ignore whitespace
default/config.yaml+2 -0
@@ -114,6 +114,8 @@ backups:
114 chat:114 chat:
115 # Enable automatic chat backups115 # Enable automatic chat backups
116 enabled: true116 enabled: true
117 # Verify integrity of chat files before saving
118 checkIntegrity: true
117 # Maximum number of chat backups to keep per user (starting from the most recent). Set to -1 to keep all backups.119 # Maximum number of chat backups to keep per user (starting from the most recent). Set to -1 to keep all backups.
118 maxTotalBackups: -1120 maxTotalBackups: -1
119 # Interval in milliseconds to throttle chat backups per user121 # Interval in milliseconds to throttle chat backups per user
public/script.js+62 -37
@@ -172,6 +172,7 @@ import {
172 copyText,172 copyText,
173 escapeHtml,173 escapeHtml,
174 saveBase64AsFile,174 saveBase64AsFile,
175 uuidv4,
175} from './scripts/utils.js';176} from './scripts/utils.js';
176import { debounce_timeout } from './scripts/constants.js';177import { debounce_timeout } from './scripts/constants.js';
177178
@@ -6745,7 +6746,22 @@ export function saveChatDebounced() {
6745 }, DEFAULT_SAVE_EDIT_TIMEOUT);6746 }, DEFAULT_SAVE_EDIT_TIMEOUT);
6746}6747}
67476748
6748export async function saveChat(chatName, withMetadata, mesId) {6749/**
6750 * Saves the chat to the server.
6751 * @param {object} [options] - Additional options.
6752 * @param {string} [options.chatName] The name of the chat file to save to
6753 * @param {object} [options.withMetadata] Additional metadata to save with the chat
6754 * @param {number} [options.mesId] The message ID to save the chat up to
6755 * @param {boolean} [options.force] Force the saving despire the integrity check result
6756 *
6757 * @returns {Promise<void>}
6758 */
6759export async function saveChat({ chatName, withMetadata, mesId, force = false } = {}) {
6760 if (arguments.length > 1 && typeof arguments[0] !== 'object') {
6761 console.trace('saveChat called with positional arguments. Please use an object instead.');
6762 [chatName, withMetadata, mesId, force] = arguments;
6763 }
6764
6749 const metadata = { ...chat_metadata, ...(withMetadata || {}) };6765 const metadata = { ...chat_metadata, ...(withMetadata || {}) };
6750 const fileName = chatName ?? characters[this_chid]?.chat;6766 const fileName = chatName ?? characters[this_chid]?.chat;
67516767
@@ -6765,53 +6781,59 @@ export async function saveChat(chatName, withMetadata, mesId) {
6765 toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`);6781 toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`);
6766 throw new Error('Group chat saved from saveChat');6782 throw new Error('Group chat saved from saveChat');
6767 }6783 }
6768 /*
6769 if (item.is_user) {
6770 //var str = item.mes.replace(`${name1}:`, `${name1}:`);
6771 //chat[i].mes = str;
6772 //chat[i].name = name1;
6773 } else if (i !== chat.length - 1 && chat[i].swipe_id !== undefined) {
6774 // delete chat[i].swipes;
6775 // delete chat[i].swipe_id;
6776 }
6777 */
6778 });6784 });
67796785
6780 const trimmed_chat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)6786 const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
6781 ? chat.slice(0, parseInt(mesId) + 1)6787 ? chat.slice(0, Number(mesId) + 1)
6782 : chat;6788 : chat.slice();
67836789
6784 var save_chat = [6790 const chatToSave = [
6785 {6791 {
6786 user_name: name1,6792 user_name: name1,
6787 character_name: name2,6793 character_name: name2,
6788 create_date: chat_create_date,6794 create_date: chat_create_date,
6789 chat_metadata: metadata,6795 chat_metadata: metadata,
6790 },6796 },
6791 ...trimmed_chat,6797 ...trimmedChat,
6792 ];6798 ];
6793 return jQuery.ajax({
6794 type: 'POST',
6795 url: '/api/chats/save',
6796 data: JSON.stringify({
6797 ch_name: characters[this_chid].name,
6798 file_name: fileName,
6799 chat: save_chat,
6800 avatar_url: characters[this_chid].avatar,
6801 }),
6802 beforeSend: function () {
68036799
6804 },6800 try {
6805 cache: false,6801 const result = await fetch('/api/chats/save', {
6806 dataType: 'json',6802 method: 'POST',
6807 contentType: 'application/json',6803 cache: 'no-cache',
6808 success: function (data) { },6804 headers: getRequestHeaders(),
6809 error: function (jqXHR, exception) {6805 body: JSON.stringify({
6810 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`);6806 ch_name: characters[this_chid].name,
6811 console.log(exception);6807 file_name: fileName,
6812 console.log(jqXHR);6808 chat: chatToSave,
6813 },6809 avatar_url: characters[this_chid].avatar,
6814 });6810 force: force,
6811 }),
6812 });
6813
6814 if (result.ok) {
6815 return;
6816 }
6817
6818 const errorData = await result.json();
6819 const isIntegrityError = errorData?.error === 'integrity' && !force;
6820 if (!isIntegrityError) {
6821 throw new Error(result.statusText);
6822 }
6823
6824 const forceSaveConfirmed = await Popup.show.confirm(
6825 t`ERROR: Chat integrity check failed.`,
6826 t`Continuing the operation may result in data loss. Would you like to overwrite the chat file anyway? Pressing "NO" will cancel the save operation.`,
6827 { okButton: t`Yes, overwrite`, cancelButton: t`No, cancel` },
6828 ) === POPUP_RESULT.AFFIRMATIVE;
6829
6830 if (forceSaveConfirmed) {
6831 await saveChat({ chatName, withMetadata, mesId, force: true });
6832 }
6833 } catch (error) {
6834 console.error(error);
6835 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`);
6836 }
6815}6837}
68166838
6817async function read_avatar_load(input) {6839async function read_avatar_load(input) {
@@ -6988,6 +7010,9 @@ export async function getChat() {
6988 } else {7010 } else {
6989 chat_create_date = humanizedDateTime();7011 chat_create_date = humanizedDateTime();
6990 }7012 }
7013 if (!chat_metadata['integrity']) {
7014 chat_metadata['integrity'] = uuidv4();
7015 }
6991 await getChatResult();7016 await getChatResult();
6992 eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } });7017 eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } });
69937018
public/scripts/bookmarks.js+2 -2
@@ -156,7 +156,7 @@ export async function createBranch(mesId) {
156 if (selected_group) {156 if (selected_group) {
157 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);157 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
158 } else {158 } else {
159 await saveChat(name, newMetadata, mesId);159 await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
160 }160 }
161 // append to branches list if it exists161 // append to branches list if it exists
162 // otherwise create it162 // otherwise create it
@@ -212,7 +212,7 @@ export async function createNewBookmark(mesId, { forceName = null } = {}) {
212 if (selected_group) {212 if (selected_group) {
213 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);213 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
214 } else {214 } else {
215 await saveChat(name, newMetadata, mesId);215 await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
216 }216 }
217217
218 lastMes.extra['bookmark_link'] = name;218 lastMes.extra['bookmark_link'] = name;
src/endpoints/chats.js+68 -1
@@ -21,6 +21,7 @@ import {
21const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');21const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');
22const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number'));22const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number'));
23const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number'));23const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number'));
24const checkIntegrity = !!getConfigValue('backups.chat.checkIntegrity', true, 'boolean');
2425
25/**26/**
26 * Saves a chat to the backups directory.27 * Saves a chat to the backups directory.
@@ -292,15 +293,81 @@ function importRisuChat(userName, characterName, jsonData) {
292 return chat.map(obj => JSON.stringify(obj)).join('\n');293 return chat.map(obj => JSON.stringify(obj)).join('\n');
293}294}
294295
296/**
297 * Reads the first line of a file asynchronously.
298 * @param {string} filePath Path to the file
299 * @returns {Promise<string>} The first line of the file
300 */
301function readFirstLine(filePath) {
302 const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
303 const rl = readline.createInterface({ input: stream });
304 return new Promise((resolve, reject) => {
305 let resolved = false;
306 rl.on('line', line => {
307 resolved = true;
308 rl.close();
309 stream.close();
310 resolve(line);
311 });
312
313 rl.on('error', error => {
314 resolved = true;
315 reject(error);
316 });
317
318 // Handle empty files
319 stream.on('end', () => {
320 if (!resolved) {
321 resolved = true;
322 resolve('');
323 }
324 });
325 });
326}
327
328/**
329 * Checks if the chat being saved has the same integrity as the one being loaded.
330 * @param {string} filePath Path to the chat file
331 * @param {string} integritySlug Integrity slug
332 * @returns {Promise<boolean>} Whether the chat is intact
333 */
334async function checkChatIntegrity(filePath, integritySlug) {
335 // If the chat file doesn't exist, assume it's intact
336 if (!fs.existsSync(filePath)) {
337 return true;
338 }
339
340 // Parse the first line of the chat file as JSON
341 const firstLine = await readFirstLine(filePath);
342 const jsonData = tryParse(firstLine);
343 const chatIntegrity = jsonData?.chat_metadata?.integrity;
344
345 // If the chat has no integrity metadata, assume it's intact
346 if (!chatIntegrity) {
347 return true;
348 }
349
350 // Check if the integrity matches
351 return chatIntegrity === integritySlug;
352}
353
295export const router = express.Router();354export const router = express.Router();
296355
297router.post('/save', validateAvatarUrlMiddleware, function (request, response) {356router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
298 try {357 try {
299 const directoryName = String(request.body.avatar_url).replace('.png', '');358 const directoryName = String(request.body.avatar_url).replace('.png', '');
300 const chatData = request.body.chat;359 const chatData = request.body.chat;
301 const jsonlData = chatData.map(JSON.stringify).join('\n');360 const jsonlData = chatData.map(JSON.stringify).join('\n');
302 const fileName = `${String(request.body.file_name)}.jsonl`;361 const fileName = `${String(request.body.file_name)}.jsonl`;
303 const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName));362 const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName));
363 if (checkIntegrity && !request.body.force) {
364 const integritySlug = chatData?.[0]?.chat_metadata?.integrity;
365 const isIntact = await checkChatIntegrity(filePath, integritySlug);
366 if (!isIntact) {
367 console.error(`Chat integrity check failed for ${filePath}`);
368 return response.status(400).send({ error: 'integrity' });
369 }
370 }
304 writeFileAtomicSync(filePath, jsonlData, 'utf8');371 writeFileAtomicSync(filePath, jsonlData, 'utf8');
305 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData);372 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData);
306 return response.send({ result: 'ok' });373 return response.send({ result: 'ok' });