Add chat integrity check to saveChat
| @@ -114,6 +114,8 @@ backups: | |||
| 114 | chat: | 114 | chat: |
| 115 | # Enable automatic chat backups | 115 | # Enable automatic chat backups |
| 116 | enabled: true | 116 | 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: -1 | 120 | maxTotalBackups: -1 |
| 119 | # Interval in milliseconds to throttle chat backups per user | 121 | # Interval in milliseconds to throttle chat backups per user |
| @@ -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'; |
| 176 | import { debounce_timeout } from './scripts/constants.js'; | 177 | import { debounce_timeout } from './scripts/constants.js'; |
| 177 | 178 | ||
| @@ -6668,7 +6669,17 @@ export function saveChatDebounced() { | |||
| 6668 | }, DEFAULT_SAVE_EDIT_TIMEOUT); | 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 | 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; |
| 6674 | 6685 | ||
| @@ -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 | }); |
| 6702 | 6703 | ||
| 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(); |
| 6706 | 6707 | ||
| 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 | |
| 6717 | type: 'POST', | 6718 | try { |
| 6718 | url: '/api/chats/save', | 6719 | const result = await fetch('/api/chats/save', { |
| 6719 | data: JSON.stringify({ | 6720 | method: 'POST', |
| 6721 | headers: getRequestHeaders(), | ||
| 6722 | body: JSON.stringify({ | ||
| 6720 | ch_name: characters[this_chid].name, | 6723 | ch_name: characters[this_chid].name, |
| 6721 | file_name: fileName, | 6724 | file_name: fileName, |
| 6722 | chat: save_chat, | 6725 | chat: chatToSave, |
| 6723 | avatar_url: characters[this_chid].avatar, | 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 | toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`); | 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 | async function read_avatar_load(input) { | 6750 | async 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] } }); |
| 6916 | 6929 | ||
| @@ -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 exists | 161 | // append to branches list if it exists |
| 162 | // otherwise create it | 162 | // 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 | } |
| 217 | 217 | ||
| 218 | lastMes.extra['bookmark_link'] = name; | 218 | lastMes.extra['bookmark_link'] = name; |
| @@ -21,6 +21,7 @@ import { | |||
| 21 | const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean'); | 21 | const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean'); |
| 22 | const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number')); | 22 | const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number')); |
| 23 | const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number')); | 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 | * 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 | } |
| 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 | export const router = express.Router(); | 340 | export const router = express.Router(); |
| 296 | 341 | ||
| 297 | router.post('/save', validateAvatarUrlMiddleware, function (request, response) { | 342 | router.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' }); |