Add chat integrity check to saveChat

400d29e97e205e6b953800729e7c0ea4f3f7138d

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

4 files changed, +103 -35Showing whitespace changes
default/config.yaml+2 -0
@@ -114,6 +114,8 @@ backups:
114114 chat:
115115 # Enable automatic chat backups
116116 enabled: true
117+ # Verify integrity of chat files before saving
118+ checkIntegrity: true
117119 # Maximum number of chat backups to keep per user (starting from the most recent). Set to -1 to keep all backups.
118120 maxTotalBackups: -1
119121 # Interval in milliseconds to throttle chat backups per user
public/script.js+45 -32
@@ -172,6 +172,7 @@ import {
172172 copyText,
173173 escapeHtml,
174174 saveBase64AsFile,
175+ uuidv4,
175176} from './scripts/utils.js';
176177import { debounce_timeout } from './scripts/constants.js';
177178
@@ -6668,7 +6669,17 @@ export function saveChatDebounced() {
66686669 }, DEFAULT_SAVE_EDIT_TIMEOUT);
66696670}
66706671
6671-export 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+ */
6682+export async function saveChat({ chatName, withMetadata, mesId, force } = {}) {
66726683 const metadata = { ...chat_metadata, ...(withMetadata || {}) };
66736684 const fileName = chatName ?? characters[this_chid]?.chat;
66746685
@@ -6688,53 +6699,52 @@ export async function saveChat(chatName, withMetadata, mesId) {
66886699 toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`);
66896700 throw new Error('Group chat saved from saveChat');
66906701 }
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- */
67016702 });
67026703
67036704 const trimmed_chattrimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
67046705 ? chat.slice(0, parseIntNumber(mesId) + 1)
67056706 : chat.slice();
67066707
67076708 varconst save_chatchatToSave = [
67086709 {
67096710 user_name: name1,
67106711 character_name: name2,
67116712 create_date: chat_create_date,
67126713 chat_metadata: metadata,
67136714 },
67146715 ...trimmed_chattrimmedChat,
67156716 ];
6716- return jQuery.ajax({
6717+
6717- type: 'POST',
6718+ try {
67186719 url:const result = await fetch('/api/chats/save', {
6719- data: JSON.stringify({
6720+ method: 'POST',
6721+ headers: getRequestHeaders(),
6722+ body: JSON.stringify({
67206723 ch_name: characters[this_chid].name,
67216724 file_name: fileName,
67226725 chat: save_chatchatToSave,
67236726 avatar_url: characters[this_chid].avatar,
67246727 }),
6725- beforeSend: function () {
6728+ cache: 'no-cache',
6729+ });
67266730
6727- },
6731+ if (!result.ok) {
6728- cache: false,
6732+ const errorData = await result.json();
6729- dataType: 'json',
6733+ if (errorData?.error === 'integrity' && !force) {
6730- contentType: 'application/json',
6734+ const forceSaveConfirmed = await Popup.show.confirm(
6731- success: function (data) { },
6735+ t`Chat integrity check failed, operation may result in data loss.`,
6732- error: function (jqXHR, exception) {
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);
67336746 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`);
6734- console.log(exception);
6747+ }
6735- console.log(jqXHR);
6736- },
6737- });
67386748}
67396749
67406750async function read_avatar_load(input) {
@@ -6911,6 +6921,9 @@ export async function getChat() {
69116921 } else {
69126922 chat_create_date = humanizedDateTime();
69136923 }
6924+ if (!chat_metadata['integrity']) {
6925+ chat_metadata['integrity'] = uuidv4();
6926+ }
69146927 await getChatResult();
69156928 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) {
156156 if (selected_group) {
157157 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
158158 } else {
159159 await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
160160 }
161161 // append to branches list if it exists
162162 // otherwise create it
@@ -212,7 +212,7 @@ export async function createNewBookmark(mesId, { forceName = null } = {}) {
212212 if (selected_group) {
213213 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
214214 } else {
215215 await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
216216 }
217217
218218 lastMes.extra['bookmark_link'] = name;
src/endpoints/chats.js+54 -1
@@ -21,6 +21,7 @@ import {
2121const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');
2222const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number'));
2323const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number'));
24+const checkIntegrity = !!getConfigValue('backups.chat.checkIntegrity', true, 'boolean');
2425
2526/**
2627 * Saves a chat to the backups directory.
@@ -292,15 +293,67 @@ function importRisuChat(userName, characterName, jsonData) {
292293 return chat.map(obj => JSON.stringify(obj)).join('\n');
293294}
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+ */
301+function 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+ */
320+async 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+
295340export const router = express.Router();
296341
297342router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
298343 try {
299344 const directoryName = String(request.body.avatar_url).replace('.png', '');
300345 const chatData = request.body.chat;
301346 const jsonlData = chatData.map(JSON.stringify).join('\n');
302347 const fileName = `${String(request.body.file_name)}.jsonl`;
303348 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+ }
304357 writeFileAtomicSync(filePath, jsonlData, 'utf8');
305358 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData);
306359 return response.send({ result: 'ok' });