Add chat integrity check to saveChat

400d29e97e205e6b953800729e7c0ea4f3f7138d

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

4 files changed, +108 -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+50 -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
@@ -6668,7 +6669,17 @@ export function saveChatDebounced() {
6668 }, DEFAULT_SAVE_EDIT_TIMEOUT);6669 }, DEFAULT_SAVE_EDIT_TIMEOUT);
6669}6670}
66706671
6671export async function saveChat(chatName, withMetadata, mesId) {6672/**
6673 * Saves the chat to the server.
6674 * @param {object} [options] - Additional options.
6675 * @param {string} [options.chatName] The name of the chat file to save to
6676 * @param {object} [options.withMetadata] Additional metadata to save with the chat
6677 * @param {number} [options.mesId] The message ID to save the chat up to
6678 * @param {boolean} [options.force] Force the saving despire the integrity check result
6679 *
6680 * @returns {Promise<void>}
6681 */
6682export async function saveChat({ chatName, withMetadata, mesId, force } = {}) {
6672 const metadata = { ...chat_metadata, ...(withMetadata || {}) };6683 const metadata = { ...chat_metadata, ...(withMetadata || {}) };
6673 const fileName = chatName ?? characters[this_chid]?.chat;6684 const fileName = chatName ?? characters[this_chid]?.chat;
66746685
@@ -6688,53 +6699,52 @@ export async function saveChat(chatName, withMetadata, mesId) {
6688 toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`);6699 toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`);
6689 throw new Error('Group chat saved from saveChat');6700 throw new Error('Group chat saved from saveChat');
6690 }6701 }
6691 /*
6692 if (item.is_user) {
6693 //var str = item.mes.replace(`${name1}:`, `${name1}:`);
6694 //chat[i].mes = str;
6695 //chat[i].name = name1;
6696 } else if (i !== chat.length - 1 && chat[i].swipe_id !== undefined) {
6697 // delete chat[i].swipes;
6698 // delete chat[i].swipe_id;
6699 }
6700 */
6701 });6702 });
67026703
6703 const trimmed_chat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)6704 const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
6704 ? chat.slice(0, parseInt(mesId) + 1)6705 ? chat.slice(0, Number(mesId) + 1)
6705 : chat;6706 : chat.slice();
67066707
6707 var save_chat = [6708 const chatToSave = [
6708 {6709 {
6709 user_name: name1,6710 user_name: name1,
6710 character_name: name2,6711 character_name: name2,
6711 create_date: chat_create_date,6712 create_date: chat_create_date,
6712 chat_metadata: metadata,6713 chat_metadata: metadata,
6713 },6714 },
6714 ...trimmed_chat,6715 ...trimmedChat,
6715 ];6716 ];
6716 return jQuery.ajax({
6717 type: 'POST',
6718 url: '/api/chats/save',
6719 data: JSON.stringify({
6720 ch_name: characters[this_chid].name,
6721 file_name: fileName,
6722 chat: save_chat,
6723 avatar_url: characters[this_chid].avatar,
6724 }),
6725 beforeSend: function () {
67266717
6727 },6718 try {
6728 cache: false,6719 const result = await fetch('/api/chats/save', {
6729 dataType: 'json',6720 method: 'POST',
6730 contentType: 'application/json',6721 headers: getRequestHeaders(),
6731 success: function (data) { },6722 body: JSON.stringify({
6732 error: function (jqXHR, exception) {6723 ch_name: characters[this_chid].name,
6733 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`);6724 file_name: fileName,
6734 console.log(exception);6725 chat: chatToSave,
6735 console.log(jqXHR);6726 avatar_url: characters[this_chid].avatar,
6736 },6727 }),
6737 });6728 cache: 'no-cache',
6729 });
6730
6731 if (!result.ok) {
6732 const errorData = await result.json();
6733 if (errorData?.error === 'integrity' && !force) {
6734 const forceSaveConfirmed = await Popup.show.confirm(
6735 t`Chat integrity check failed, operation may result in data loss.`,
6736 t`Would you like to overwrite the chat file anyway? Pressing "NO" will cancel the save operation.`,
6737 ) === POPUP_RESULT.AFFIRMATIVE;
6738
6739 if (forceSaveConfirmed) {
6740 await saveChat({ chatName, withMetadata, mesId, force: true });
6741 }
6742 }
6743 }
6744 } catch (error) {
6745 console.error(error);
6746 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`);
6747 }
6738}6748}
67396749
6740async function read_avatar_load(input) {6750async function read_avatar_load(input) {
@@ -6911,6 +6921,9 @@ export async function getChat() {
6911 } else {6921 } else {
6912 chat_create_date = humanizedDateTime();6922 chat_create_date = humanizedDateTime();
6913 }6923 }
6924 if (!chat_metadata['integrity']) {
6925 chat_metadata['integrity'] = uuidv4();
6926 }
6914 await getChatResult();6927 await getChatResult();
6915 eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } });6928 eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } });
69166929
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+54 -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,67 @@ 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 rl.on('line', line => {
306 rl.close();
307 stream.close();
308 resolve(line);
309 });
310 rl.on('error', reject);
311 });
312}
313
314/**
315 * Checks if the chat being saved has the same integrity as the one being loaded.
316 * @param {string} filePath Path to the chat file
317 * @param {string} integritySlug Integrity slug
318 * @returns {Promise<boolean>} Whether the chat is intact
319 */
320async function checkChatIntegrity(filePath, integritySlug) {
321 // If the chat file doesn't exist, assume it's intact
322 if (!integritySlug || !fs.existsSync(filePath)) {
323 return true;
324 }
325
326 // Parse the first line of the chat file as JSON
327 const firstLine = await readFirstLine(filePath);
328 const jsonData = tryParse(firstLine);
329 const chatIntegrity = jsonData?.chat_metadata?.integrity;
330
331 // If the chat has no integrity metadata, assume it's intact
332 if (!chatIntegrity) {
333 return true;
334 }
335
336 // Check if the integrity matches
337 return chatIntegrity === integritySlug;
338}
339
295export const router = express.Router();340export const router = express.Router();
296341
297router.post('/save', validateAvatarUrlMiddleware, function (request, response) {342router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
298 try {343 try {
299 const directoryName = String(request.body.avatar_url).replace('.png', '');344 const directoryName = String(request.body.avatar_url).replace('.png', '');
300 const chatData = request.body.chat;345 const chatData = request.body.chat;
301 const jsonlData = chatData.map(JSON.stringify).join('\n');346 const jsonlData = chatData.map(JSON.stringify).join('\n');
302 const fileName = `${String(request.body.file_name)}.jsonl`;347 const fileName = `${String(request.body.file_name)}.jsonl`;
303 const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName));348 const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName));
349 if (checkIntegrity && !request.body.force) {
350 const integritySlug = chatData?.[0]?.chat_metadata?.integrity;
351 const isIntact = await checkChatIntegrity(filePath, integritySlug);
352 if (!isIntact) {
353 console.error(`Chat integrity check failed for ${filePath}`);
354 return response.status(400).send({ error: 'integrity' });
355 }
356 }
304 writeFileAtomicSync(filePath, jsonlData, 'utf8');357 writeFileAtomicSync(filePath, jsonlData, 'utf8');
305 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData);358 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData);
306 return response.send({ result: 'ok' });359 return response.send({ result: 'ok' });