Add chat integrity check to saveChat
| @@ -114,6 +114,8 @@ backups: | ||
| 114 | 114 | chat: |
| 115 | 115 | # Enable automatic chat backups |
| 116 | 116 | enabled: true |
| 117 | + # Verify integrity of chat files before saving | |
| 118 | + checkIntegrity: true | |
| 117 | 119 | # Maximum number of chat backups to keep per user (starting from the most recent). Set to -1 to keep all backups. |
| 118 | 120 | maxTotalBackups: -1 |
| 119 | 121 | # Interval in milliseconds to throttle chat backups per user |
| @@ -172,6 +172,7 @@ import { | ||
| 172 | 172 | copyText, |
| 173 | 173 | escapeHtml, |
| 174 | 174 | saveBase64AsFile, |
| 175 | + uuidv4, | |
| 175 | 176 | } from './scripts/utils.js'; |
| 176 | 177 | import { debounce_timeout } from './scripts/constants.js'; |
| 177 | 178 | |
| @@ -6668,7 +6669,17 @@ export function saveChatDebounced() { | ||
| 6668 | 6669 | }, DEFAULT_SAVE_EDIT_TIMEOUT); |
| 6669 | 6670 | } |
| 6670 | 6671 | |
| 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 } = {}) { | |
| 6672 | 6683 | const metadata = { ...chat_metadata, ...(withMetadata || {}) }; |
| 6673 | 6684 | const fileName = chatName ?? characters[this_chid]?.chat; |
| 6674 | 6685 | |
| @@ -6688,53 +6699,52 @@ export async function saveChat(chatName, withMetadata, mesId) { | ||
| 6688 | 6699 | toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`); |
| 6689 | 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 | }); |
| 6702 | 6703 | |
| 6703 | 6704 | const trimmed_chattrimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length) |
| 6704 | 6705 | ? chat.slice(0, parseIntNumber(mesId) + 1) |
| 6705 | 6706 | : chat.slice(); |
| 6706 | 6707 | |
| 6707 | 6708 | varconst save_chatchatToSave = [ |
| 6708 | 6709 | { |
| 6709 | 6710 | user_name: name1, |
| 6710 | 6711 | character_name: name2, |
| 6711 | 6712 | create_date: chat_create_date, |
| 6712 | 6713 | chat_metadata: metadata, |
| 6713 | 6714 | }, |
| 6714 | 6715 | ...trimmed_chattrimmedChat, |
| 6715 | 6716 | ]; |
| 6716 | - return jQuery.ajax({ | |
| 6717 | + | |
| 6717 | - type: 'POST', | |
| 6718 | + try { | |
| 6718 | 6719 | url:const result = await fetch('/api/chats/save', { |
| 6719 | - data: JSON.stringify({ | |
| 6720 | + method: 'POST', | |
| 6721 | + headers: getRequestHeaders(), | |
| 6722 | + body: JSON.stringify({ | |
| 6720 | 6723 | ch_name: characters[this_chid].name, |
| 6721 | 6724 | file_name: fileName, |
| 6722 | 6725 | chat: save_chatchatToSave, |
| 6723 | 6726 | avatar_url: characters[this_chid].avatar, |
| 6724 | 6727 | }), |
| 6725 | - beforeSend: function () { | |
| 6728 | + cache: 'no-cache', | |
| 6729 | + }); | |
| 6726 | 6730 | |
| 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); | |
| 6733 | 6746 | 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 | - }); | |
| 6738 | 6748 | } |
| 6739 | 6749 | |
| 6740 | 6750 | async function read_avatar_load(input) { |
| @@ -6911,6 +6921,9 @@ export async function getChat() { | ||
| 6911 | 6921 | } else { |
| 6912 | 6922 | chat_create_date = humanizedDateTime(); |
| 6913 | 6923 | } |
| 6924 | + if (!chat_metadata['integrity']) { | |
| 6925 | + chat_metadata['integrity'] = uuidv4(); | |
| 6926 | + } | |
| 6914 | 6927 | await getChatResult(); |
| 6915 | 6928 | eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } }); |
| 6916 | 6929 | |
| @@ -156,7 +156,7 @@ export async function createBranch(mesId) { | ||
| 156 | 156 | if (selected_group) { |
| 157 | 157 | await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId); |
| 158 | 158 | } else { |
| 159 | 159 | await saveChat({ chatName: name, withMetadata: newMetadata, mesId }); |
| 160 | 160 | } |
| 161 | 161 | // append to branches list if it exists |
| 162 | 162 | // otherwise create it |
| @@ -212,7 +212,7 @@ export async function createNewBookmark(mesId, { forceName = null } = {}) { | ||
| 212 | 212 | if (selected_group) { |
| 213 | 213 | await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId); |
| 214 | 214 | } else { |
| 215 | 215 | await saveChat({ chatName: name, withMetadata: newMetadata, mesId }); |
| 216 | 216 | } |
| 217 | 217 | |
| 218 | 218 | lastMes.extra['bookmark_link'] = name; |
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean'); |
| 22 | 22 | const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number')); |
| 23 | 23 | const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number')); |
| 24 | +const checkIntegrity = !!getConfigValue('backups.chat.checkIntegrity', true, 'boolean'); | |
| 24 | 25 | |
| 25 | 26 | /** |
| 26 | 27 | * Saves a chat to the backups directory. |
| @@ -292,15 +293,67 @@ function importRisuChat(userName, characterName, jsonData) { | ||
| 292 | 293 | return chat.map(obj => JSON.stringify(obj)).join('\n'); |
| 293 | 294 | } |
| 294 | 295 | |
| 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 | + | |
| 295 | 340 | export const router = express.Router(); |
| 296 | 341 | |
| 297 | 342 | router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { |
| 298 | 343 | try { |
| 299 | 344 | const directoryName = String(request.body.avatar_url).replace('.png', ''); |
| 300 | 345 | const chatData = request.body.chat; |
| 301 | 346 | const jsonlData = chatData.map(JSON.stringify).join('\n'); |
| 302 | 347 | const fileName = `${String(request.body.file_name)}.jsonl`; |
| 303 | 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 | 357 | writeFileAtomicSync(filePath, jsonlData, 'utf8'); |
| 305 | 358 | getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData); |
| 306 | 359 | return response.send({ result: 'ok' }); |