Merge pull request #3698 from SillyTavern/integrity Add integrity check when saving solo chat files
Signed| @@ -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 | ||
| @@ -6745,7 +6746,22 @@ export function saveChatDebounced() { | |||
| 6745 | }, DEFAULT_SAVE_EDIT_TIMEOUT); | 6746 | }, DEFAULT_SAVE_EDIT_TIMEOUT); |
| 6746 | } | 6747 | } |
| 6747 | 6748 | ||
| 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 | |||
| 6749 | const metadata = { ...chat_metadata, ...(withMetadata || {}) }; | 6765 | const metadata = { ...chat_metadata, ...(withMetadata || {}) }; |
| 6750 | const fileName = chatName ?? characters[this_chid]?.chat; | 6766 | const fileName = chatName ?? characters[this_chid]?.chat; |
| 6751 | 6767 | ||
| @@ -6765,53 +6781,59 @@ export async function saveChat(chatName, withMetadata, mesId) { | |||
| 6765 | toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`); | 6781 | toastr.error(t`Trying to save group chat with regular saveChat function. Aborting to prevent corruption.`); |
| 6766 | throw new Error('Group chat saved from saveChat'); | 6782 | throw new Error('Group chat saved from saveChat'); |
| 6767 | } | 6783 | } |
| 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 | */ | ||
| 6778 | }); | 6784 | }); |
| 6779 | 6785 | ||
| 6780 | const trimmed_chat = (mesId !== undefined && mesId >= 0 && mesId < chat.length) | 6786 | const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length) |
| 6781 | ? chat.slice(0, parseInt(mesId) + 1) | 6787 | ? chat.slice(0, Number(mesId) + 1) |
| 6782 | : chat; | 6788 | : chat.slice(); |
| 6783 | 6789 | ||
| 6784 | var save_chat = [ | 6790 | const chatToSave = [ |
| 6785 | { | 6791 | { |
| 6786 | user_name: name1, | 6792 | user_name: name1, |
| 6787 | character_name: name2, | 6793 | character_name: name2, |
| 6788 | create_date: chat_create_date, | 6794 | create_date: chat_create_date, |
| 6789 | chat_metadata: metadata, | 6795 | chat_metadata: metadata, |
| 6790 | }, | 6796 | }, |
| 6791 | ...trimmed_chat, | 6797 | ...trimmedChat, |
| 6792 | ]; | 6798 | ]; |
| 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 () { | ||
| 6803 | 6799 | ||
| 6804 | }, | 6800 | try { |
| 6805 | cache: false, | 6801 | const result = await fetch('/api/chats/save', { |
| 6806 | dataType: 'json', | 6802 | method: 'POST', |
| 6807 | contentType: 'application/json', | 6803 | cache: 'no-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 | } | ||
| 6815 | } | 6837 | } |
| 6816 | 6838 | ||
| 6817 | async function read_avatar_load(input) { | 6839 | async function read_avatar_load(input) { |
| @@ -6988,6 +7010,9 @@ export async function getChat() { | |||
| 6988 | } else { | 7010 | } else { |
| 6989 | chat_create_date = humanizedDateTime(); | 7011 | chat_create_date = humanizedDateTime(); |
| 6990 | } | 7012 | } |
| 7013 | if (!chat_metadata['integrity']) { | ||
| 7014 | chat_metadata['integrity'] = uuidv4(); | ||
| 7015 | } | ||
| 6991 | await getChatResult(); | 7016 | await getChatResult(); |
| 6992 | eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } }); | 7017 | eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } }); |
| 6993 | 7018 | ||
| @@ -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,81 @@ 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 | 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 | |||
| 295 | export const router = express.Router(); | 354 | export const router = express.Router(); |
| 296 | 355 | ||
| 297 | router.post('/save', validateAvatarUrlMiddleware, function (request, response) { | 356 | router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { |
| 298 | try { | 357 | try { |
| 299 | const directoryName = String(request.body.avatar_url).replace('.png', ''); | 358 | const directoryName = String(request.body.avatar_url).replace('.png', ''); |
| 300 | const chatData = request.body.chat; | 359 | const chatData = request.body.chat; |
| 301 | const jsonlData = chatData.map(JSON.stringify).join('\n'); | 360 | const jsonlData = chatData.map(JSON.stringify).join('\n'); |
| 302 | const fileName = `${String(request.body.file_name)}.jsonl`; | 361 | const fileName = `${String(request.body.file_name)}.jsonl`; |
| 303 | const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName)); | 362 | 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 | } | ||
| 304 | writeFileAtomicSync(filePath, jsonlData, 'utf8'); | 371 | writeFileAtomicSync(filePath, jsonlData, 'utf8'); |
| 305 | getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData); | 372 | getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData); |
| 306 | return response.send({ result: 'ok' }); | 373 | return response.send({ result: 'ok' }); |