mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Add chat integrity check to saveChat
This commit is contained in:
@ -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
|
||||
|
@ -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<void>}
|
||||
*/
|
||||
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({
|
||||
|
||||
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: save_chat,
|
||||
chat: chatToSave,
|
||||
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);
|
||||
},
|
||||
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] } });
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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<string>} 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<boolean>} 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' });
|
||||
|
Reference in New Issue
Block a user