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:
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+62 -37
@@ -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
@@ -6745,7 +6746,22 @@ export function saveChatDebounced() {
67456746 }, DEFAULT_SAVE_EDIT_TIMEOUT);
67466747}
67476748
6748-export 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+ */
6759+export 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+
67496765 const metadata = { ...chat_metadata, ...(withMetadata || {}) };
67506766 const fileName = chatName ?? characters[this_chid]?.chat;
67516767
@@ -6765,53 +6781,59 @@ export async function saveChat(chatName, withMetadata, mesId) {
67656781 toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`);
67666782 throw new Error('Group chat saved from saveChat');
67676783 }
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- */
67786784 });
67796785
67806786 const trimmed_chattrimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
67816787 ? chat.slice(0, parseIntNumber(mesId) + 1)
67826788 : chat.slice();
67836789
67846790 varconst save_chatchatToSave = [
67856791 {
67866792 user_name: name1,
67876793 character_name: name2,
67886794 create_date: chat_create_date,
67896795 chat_metadata: metadata,
67906796 },
67916797 ...trimmed_chattrimmedChat,
67926798 ];
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', {
68066802 dataType method: 'jsonPOST',
68076803 contentType cache: 'application/jsonno-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+ }
68156837}
68166838
68176839async function read_avatar_load(input) {
@@ -6988,6 +7010,9 @@ export async function getChat() {
69887010 } else {
69897011 chat_create_date = humanizedDateTime();
69907012 }
7013+ if (!chat_metadata['integrity']) {
7014+ chat_metadata['integrity'] = uuidv4();
7015+ }
69917016 await getChatResult();
69927017 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) {
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+68 -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,81 @@ 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+ 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+ */
334+async 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+
295354export const router = express.Router();
296355
297356router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
298357 try {
299358 const directoryName = String(request.body.avatar_url).replace('.png', '');
300359 const chatData = request.body.chat;
301360 const jsonlData = chatData.map(JSON.stringify).join('\n');
302361 const fileName = `${String(request.body.file_name)}.jsonl`;
303362 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+ }
304371 writeFileAtomicSync(filePath, jsonlData, 'utf8');
305372 getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData);
306373 return response.send({ result: 'ok' });