diff --git a/default/config.yaml b/default/config.yaml index f4bacf9ff..51fb39867 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -114,6 +114,8 @@ backups: chat: # Enable automatic chat backups enabled: true + # Verify integrity of chat files before saving + checkIntegrity: true # Maximum number of chat backups to keep per user (starting from the most recent). Set to -1 to keep all backups. maxTotalBackups: -1 # Interval in milliseconds to throttle chat backups per user diff --git a/public/script.js b/public/script.js index c85303d0e..bf4c3c4a9 100644 --- a/public/script.js +++ b/public/script.js @@ -172,6 +172,7 @@ import { copyText, escapeHtml, saveBase64AsFile, + uuidv4, } from './scripts/utils.js'; import { debounce_timeout } from './scripts/constants.js'; @@ -6668,7 +6669,17 @@ export function saveChatDebounced() { }, DEFAULT_SAVE_EDIT_TIMEOUT); } -export async function saveChat(chatName, withMetadata, mesId) { +/** + * Saves the chat to the server. + * @param {object} [options] - Additional options. + * @param {string} [options.chatName] The name of the chat file to save to + * @param {object} [options.withMetadata] Additional metadata to save with the chat + * @param {number} [options.mesId] The message ID to save the chat up to + * @param {boolean} [options.force] Force the saving despire the integrity check result + * + * @returns {Promise} + */ +export async function saveChat({ chatName, withMetadata, mesId, force } = {}) { const metadata = { ...chat_metadata, ...(withMetadata || {}) }; const fileName = chatName ?? characters[this_chid]?.chat; @@ -6688,53 +6699,52 @@ export async function saveChat(chatName, withMetadata, mesId) { toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`); throw new Error('Group chat saved from saveChat'); } - /* - if (item.is_user) { - //var str = item.mes.replace(`${name1}:`, `${name1}:`); - //chat[i].mes = str; - //chat[i].name = name1; - } else if (i !== chat.length - 1 && chat[i].swipe_id !== undefined) { - // delete chat[i].swipes; - // delete chat[i].swipe_id; - } - */ }); - const trimmed_chat = (mesId !== undefined && mesId >= 0 && mesId < chat.length) - ? chat.slice(0, parseInt(mesId) + 1) - : chat; + const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length) + ? chat.slice(0, Number(mesId) + 1) + : chat.slice(); - var save_chat = [ + const chatToSave = [ { user_name: name1, character_name: name2, create_date: chat_create_date, chat_metadata: metadata, }, - ...trimmed_chat, + ...trimmedChat, ]; - return jQuery.ajax({ - type: 'POST', - url: '/api/chats/save', - data: JSON.stringify({ - ch_name: characters[this_chid].name, - file_name: fileName, - chat: save_chat, - avatar_url: characters[this_chid].avatar, - }), - beforeSend: function () { - }, - cache: false, - dataType: 'json', - contentType: 'application/json', - success: function (data) { }, - error: function (jqXHR, exception) { - toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`); - console.log(exception); - console.log(jqXHR); - }, - }); + try { + const result = await fetch('/api/chats/save', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + ch_name: characters[this_chid].name, + file_name: fileName, + chat: chatToSave, + avatar_url: characters[this_chid].avatar, + }), + cache: 'no-cache', + }); + + if (!result.ok) { + const errorData = await result.json(); + if (errorData?.error === 'integrity' && !force) { + const forceSaveConfirmed = await Popup.show.confirm( + t`Chat integrity check failed, operation may result in data loss.`, + t`Would you like to overwrite the chat file anyway? Pressing "NO" will cancel the save operation.`, + ) === POPUP_RESULT.AFFIRMATIVE; + + if (forceSaveConfirmed) { + await saveChat({ chatName, withMetadata, mesId, force: true }); + } + } + } + } catch (error) { + console.error(error); + toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`); + } } async function read_avatar_load(input) { @@ -6911,6 +6921,9 @@ export async function getChat() { } else { chat_create_date = humanizedDateTime(); } + if (!chat_metadata['integrity']) { + chat_metadata['integrity'] = uuidv4(); + } await getChatResult(); eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } }); diff --git a/public/scripts/bookmarks.js b/public/scripts/bookmarks.js index 61baf34a4..64b777b9c 100644 --- a/public/scripts/bookmarks.js +++ b/public/scripts/bookmarks.js @@ -156,7 +156,7 @@ export async function createBranch(mesId) { if (selected_group) { await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId); } else { - await saveChat(name, newMetadata, mesId); + await saveChat({ chatName: name, withMetadata: newMetadata, mesId }); } // append to branches list if it exists // otherwise create it @@ -212,7 +212,7 @@ export async function createNewBookmark(mesId, { forceName = null } = {}) { if (selected_group) { await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId); } else { - await saveChat(name, newMetadata, mesId); + await saveChat({ chatName: name, withMetadata: newMetadata, mesId }); } lastMes.extra['bookmark_link'] = name; diff --git a/src/endpoints/chats.js b/src/endpoints/chats.js index 8f1e2815d..a59486251 100644 --- a/src/endpoints/chats.js +++ b/src/endpoints/chats.js @@ -21,6 +21,7 @@ import { const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean'); const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number')); const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number')); +const checkIntegrity = !!getConfigValue('backups.chat.checkIntegrity', true, 'boolean'); /** * Saves a chat to the backups directory. @@ -292,15 +293,67 @@ function importRisuChat(userName, characterName, jsonData) { return chat.map(obj => JSON.stringify(obj)).join('\n'); } +/** + * Reads the first line of a file asynchronously. + * @param {string} filePath Path to the file + * @returns {Promise} The first line of the file + */ +function readFirstLine(filePath) { + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream }); + return new Promise((resolve, reject) => { + rl.on('line', line => { + rl.close(); + stream.close(); + resolve(line); + }); + rl.on('error', reject); + }); +} + +/** + * Checks if the chat being saved has the same integrity as the one being loaded. + * @param {string} filePath Path to the chat file + * @param {string} integritySlug Integrity slug + * @returns {Promise} Whether the chat is intact + */ +async function checkChatIntegrity(filePath, integritySlug) { + // If the chat file doesn't exist, assume it's intact + if (!integritySlug || !fs.existsSync(filePath)) { + return true; + } + + // Parse the first line of the chat file as JSON + const firstLine = await readFirstLine(filePath); + const jsonData = tryParse(firstLine); + const chatIntegrity = jsonData?.chat_metadata?.integrity; + + // If the chat has no integrity metadata, assume it's intact + if (!chatIntegrity) { + return true; + } + + // Check if the integrity matches + return chatIntegrity === integritySlug; +} + export const router = express.Router(); -router.post('/save', validateAvatarUrlMiddleware, function (request, response) { +router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { try { const directoryName = String(request.body.avatar_url).replace('.png', ''); const chatData = request.body.chat; const jsonlData = chatData.map(JSON.stringify).join('\n'); const fileName = `${String(request.body.file_name)}.jsonl`; const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName)); + if (checkIntegrity && !request.body.force) { + const integritySlug = chatData?.[0]?.chat_metadata?.integrity; + const isIntact = await checkChatIntegrity(filePath, integritySlug); + if (!isIntact) { + console.error(`Chat integrity check failed for ${filePath}`); + return response.status(400).send({ error: 'integrity' }); + } + } writeFileAtomicSync(filePath, jsonlData, 'utf8'); getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData); return response.send({ result: 'ok' });