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