diff --git a/.gitignore b/.gitignore
index 7d48fd879..7a88d5bec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,6 +45,7 @@ access.log
/vectors/
/cache/
public/css/user.css
+public/error/
/plugins/
/data
/default/scaffold
@@ -52,3 +53,5 @@ public/scripts/extensions/third-party
/certs
.aider*
.env
+/StartDev.bat
+
diff --git a/default/config.yaml b/default/config.yaml
index 4c28044dc..f3e7db625 100644
--- a/default/config.yaml
+++ b/default/config.yaml
@@ -70,7 +70,7 @@ perUserBasicAuth: false
## Set to a positive number to expire session after a certain time of inactivity
## Set to 0 to expire session when the browser is closed
## Set to a negative number to disable session expiration
-sessionTimeout: 86400
+sessionTimeout: -1
# Used to sign session cookies. Will be auto-generated if not set
cookieSecret: ''
# Disable CSRF protection - NOT RECOMMENDED
diff --git a/default/user.css b/default/public/css/user.css
similarity index 100%
rename from default/user.css
rename to default/public/css/user.css
diff --git a/default/public/error/forbidden-by-whitelist.html b/default/public/error/forbidden-by-whitelist.html
new file mode 100644
index 000000000..70ff71852
--- /dev/null
+++ b/default/public/error/forbidden-by-whitelist.html
@@ -0,0 +1,22 @@
+
+
+
+
+ Forbidden
+
+
+
+
Forbidden
+
+ If you are the system administrator, add your IP address to the
+ whitelist or disable whitelist mode by editing
+ config.yaml in the root directory of your installation.
+
+
+
+ Connection from {{ipDetails}} has been blocked. This attempt
+ has been logged.
+
+ If you are the system administrator, you can configure the
+ basicAuthUser credentials by editing
+ config.yaml in the root directory of your installation.
+
+
+
+
diff --git a/default/public/error/url-not-found.html b/default/public/error/url-not-found.html
new file mode 100644
index 000000000..87974145f
--- /dev/null
+++ b/default/public/error/url-not-found.html
@@ -0,0 +1,15 @@
+
+
+
+
+ Not found
+
+
+
+
Not found
+
+ The requested URL was not found on this server.
+
+
+
+
diff --git a/index.d.ts b/index.d.ts
index 015d8e353..35f34c22c 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,6 +1,24 @@
import { UserDirectoryList, User } from "./src/users";
+import { CsrfSyncedToken } from "csrf-sync";
declare global {
+ declare namespace CookieSessionInterfaces {
+ export interface CookieSessionObject {
+ /**
+ * The CSRF token for the session.
+ */
+ csrfToken: CsrfSyncedToken;
+ /**
+ * Authenticated user handle.
+ */
+ handle: string;
+ /**
+ * Last time the session was extended.
+ */
+ touch: number;
+ }
+ }
+
namespace Express {
export interface Request {
user: {
@@ -15,11 +33,3 @@ declare global {
*/
var DATA_ROOT: string;
}
-
-declare module 'express-session' {
- export interface SessionData {
- handle: string;
- touch: number;
- // other properties...
- }
- }
diff --git a/package-lock.json b/package-lock.json
index 5784a9946..4e79f9f50 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,7 +26,7 @@
"cookie-parser": "^1.4.6",
"cookie-session": "^2.1.0",
"cors": "^2.8.5",
- "csrf-csrf": "^2.2.3",
+ "csrf-sync": "^4.0.3",
"diff-match-patch": "^1.0.5",
"dompurify": "^3.1.7",
"droll": "^0.2.1",
@@ -2987,10 +2987,10 @@
"node": "*"
}
},
- "node_modules/csrf-csrf": {
- "version": "2.2.4",
- "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-2.2.4.tgz",
- "integrity": "sha512-LuhBmy5RfRmEfeqeYqgaAuS1eDpVtKZB/Eiec9xiKQLBynJxrGVRdM2yRT/YMl1Njo/yKh2L9AYsIwSlTPnx2A==",
+ "node_modules/csrf-sync": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.0.3.tgz",
+ "integrity": "sha512-wXzltBBzt/7imzDt6ZT7G/axQG7jo4Sm0uXDUzFY8hR59qhDHdjqpW2hojS4oAVIZDzwlMQloIVCTJoDDh0wwA==",
"license": "ISC",
"dependencies": {
"http-errors": "^2.0.0"
diff --git a/package.json b/package.json
index 672ffada5..b14a487b8 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"cookie-parser": "^1.4.6",
"cookie-session": "^2.1.0",
"cors": "^2.8.5",
- "csrf-csrf": "^2.2.3",
+ "csrf-sync": "^4.0.3",
"diff-match-patch": "^1.0.5",
"dompurify": "^3.1.7",
"droll": "^0.2.1",
diff --git a/post-install.js b/post-install.js
index 4c9765eb8..46ad160cb 100644
--- a/post-install.js
+++ b/post-install.js
@@ -213,20 +213,60 @@ function addMissingConfigValues() {
* Creates the default config files if they don't exist yet.
*/
function createDefaultFiles() {
- const files = {
- config: './config.yaml',
- user: './public/css/user.css',
- };
+ /**
+ * @typedef DefaultItem
+ * @type {object}
+ * @property {'file' | 'directory'} type - Whether the item should be copied as a single file or merged into a directory structure.
+ * @property {string} defaultPath - The path to the default item (typically in `default/`).
+ * @property {string} productionPath - The path to the copied item for production use.
+ */
- for (const file of Object.values(files)) {
+ /** @type {DefaultItem[]} */
+ const defaultItems = [
+ {
+ type: 'file',
+ defaultPath: './default/config.yaml',
+ productionPath: './config.yaml',
+ },
+ {
+ type: 'directory',
+ defaultPath: './default/public/',
+ productionPath: './public/',
+ },
+ ];
+
+ for (const defaultItem of defaultItems) {
try {
- if (!fs.existsSync(file)) {
- const defaultFilePath = path.join('./default', path.parse(file).base);
- fs.copyFileSync(defaultFilePath, file);
- console.log(color.green(`Created default file: ${file}`));
+ if (defaultItem.type === 'file') {
+ if (!fs.existsSync(defaultItem.productionPath)) {
+ fs.copyFileSync(
+ defaultItem.defaultPath,
+ defaultItem.productionPath,
+ );
+ console.log(
+ color.green(`Created default file: ${defaultItem.productionPath}`),
+ );
+ }
+ } else if (defaultItem.type === 'directory') {
+ fs.cpSync(defaultItem.defaultPath, defaultItem.productionPath, {
+ force: false, // Don't overwrite existing files!
+ recursive: true,
+ });
+ console.log(
+ color.green(`Synchronized missing files: ${defaultItem.productionPath}`),
+ );
+ } else {
+ throw new Error(
+ 'FATAL: Unexpected default file format in `post-install.js#createDefaultFiles()`.',
+ );
}
} catch (error) {
- console.error(color.red(`FATAL: Could not write default file: ${file}`), error);
+ console.error(
+ color.red(
+ `FATAL: Could not write default ${defaultItem.type}: ${defaultItem.productionPath}`,
+ ),
+ error,
+ );
}
}
}
diff --git a/public/index.html b/public/index.html
index 3a0c85c06..5d4099e02 100644
--- a/public/index.html
+++ b/public/index.html
@@ -6372,7 +6372,10 @@
-
+
+
+
+
diff --git a/public/locales/ar-sa.json b/public/locales/ar-sa.json
index ba367ad46..46f8a2805 100644
--- a/public/locales/ar-sa.json
+++ b/public/locales/ar-sa.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Chub Lorebook (رابط مباشر أو معرف)",
"char_import_3": "حرف JanitorAI (رابط مباشر أو UUID)",
"char_import_4": "حرف Pygmalion.chat (رابط مباشر أو UUID)",
- "char_import_5": "حرف AICharacterCard.com (رابط مباشر أو معرف)",
+ "char_import_5": "حرف AICharacterCards.com (رابط مباشر أو معرف)",
"char_import_6": "رابط PNG المباشر (راجع",
"char_import_7": "للمضيفين المسموح بهم)",
"char_import_8": "شخصية RisuRealm (رابط مباشر)",
diff --git a/public/locales/de-de.json b/public/locales/de-de.json
index 92c56c0b7..a10d80e0b 100644
--- a/public/locales/de-de.json
+++ b/public/locales/de-de.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Chub Lorebook (Direktlink oder ID)",
"char_import_3": "JanitorAI-Charakter (Direktlink oder UUID)",
"char_import_4": "Pygmalion.chat-Charakter (Direktlink oder UUID)",
- "char_import_5": "AICharacterCard.com-Charakter (Direktlink oder ID)",
+ "char_import_5": "AICharacterCards.com-Charakter (Direktlink oder ID)",
"char_import_6": "Direkter PNG-Link (siehe",
"char_import_7": "für erlaubte Hosts)",
"char_import_8": "RisuRealm-Charakter (Direktlink)",
diff --git a/public/locales/es-es.json b/public/locales/es-es.json
index 23a8f97be..334a283b3 100644
--- a/public/locales/es-es.json
+++ b/public/locales/es-es.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Chub Lorebook (enlace directo o ID)",
"char_import_3": "Carácter de JanitorAI (enlace directo o UUID)",
"char_import_4": "Carácter Pygmalion.chat (enlace directo o UUID)",
- "char_import_5": "Carácter AICharacterCard.com (enlace directo o ID)",
+ "char_import_5": "Carácter AICharacterCards.com (enlace directo o ID)",
"char_import_6": "Enlace PNG directo (consulte",
"char_import_7": "para hosts permitidos)",
"char_import_8": "Personaje RisuRealm (Enlace directo)",
diff --git a/public/locales/fr-fr.json b/public/locales/fr-fr.json
index 96124e043..b2fc0b361 100644
--- a/public/locales/fr-fr.json
+++ b/public/locales/fr-fr.json
@@ -1297,7 +1297,7 @@
"char_import_2": "Lorebook de Chub (lien direct ou ID)",
"char_import_3": "Personnage de JanitorAI (lien direct ou UUID)",
"char_import_4": "Personnage de Pygmalion.chat (lien direct ou UUID)",
- "char_import_5": "Personnage de AICharacterCard.com (lien direct ou identifiant)",
+ "char_import_5": "Personnage de AICharacterCards.com (lien direct ou identifiant)",
"char_import_6": "Lien PNG direct (voir",
"char_import_7": "pour les hôtes autorisés)",
"char_import_8": "Personnage de RisuRealm (lien direct)",
diff --git a/public/locales/is-is.json b/public/locales/is-is.json
index 576437e67..2cacc1511 100644
--- a/public/locales/is-is.json
+++ b/public/locales/is-is.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Chub Lorebook (beinn hlekkur eða auðkenni)",
"char_import_3": "JanitorAI karakter (beinn hlekkur eða UUID)",
"char_import_4": "Pygmalion.chat karakter (beinn hlekkur eða UUID)",
- "char_import_5": "AICharacterCard.com Karakter (beinn hlekkur eða auðkenni)",
+ "char_import_5": "AICharacterCards.com Karakter (beinn hlekkur eða auðkenni)",
"char_import_6": "Beinn PNG hlekkur (sjá",
"char_import_7": "fyrir leyfilega gestgjafa)",
"char_import_8": "RisuRealm karakter (beinn hlekkur)",
diff --git a/public/locales/it-it.json b/public/locales/it-it.json
index 807a03e91..eeea5c0fb 100644
--- a/public/locales/it-it.json
+++ b/public/locales/it-it.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Lorebook di Chub (collegamento diretto o ID)",
"char_import_3": "Carattere JanitorAI (collegamento diretto o UUID)",
"char_import_4": "Carattere Pygmalion.chat (collegamento diretto o UUID)",
- "char_import_5": "Carattere AICharacterCard.com (Link diretto o ID)",
+ "char_import_5": "Carattere AICharacterCards.com (Link diretto o ID)",
"char_import_6": "Collegamento PNG diretto (fare riferimento a",
"char_import_7": "per gli host consentiti)",
"char_import_8": "Personaggio RisuRealm (collegamento diretto)",
diff --git a/public/locales/ja-jp.json b/public/locales/ja-jp.json
index bd5fa8281..2b3119e6e 100644
--- a/public/locales/ja-jp.json
+++ b/public/locales/ja-jp.json
@@ -1378,7 +1378,7 @@
"char_import_2": "Chub ロアブック (直接リンクまたは ID)",
"char_import_3": "JanitorAI キャラクター (直接リンクまたは UUID)",
"char_import_4": "Pygmalion.chat キャラクター (直接リンクまたは UUID)",
- "char_import_5": "AICharacterCard.com キャラクター (直接リンクまたは ID)",
+ "char_import_5": "AICharacterCards.com キャラクター (直接リンクまたは ID)",
"char_import_6": "直接PNGリンク(参照",
"char_import_7": "許可されたホストの場合)",
"char_import_8": "RisuRealm キャラクター (直接リンク)",
diff --git a/public/locales/ko-kr.json b/public/locales/ko-kr.json
index 044e57841..2c2dfca86 100644
--- a/public/locales/ko-kr.json
+++ b/public/locales/ko-kr.json
@@ -1395,7 +1395,7 @@
"char_import_2": "Chub Lorebook(직접 링크 또는 ID)",
"char_import_3": "JanitorAI 캐릭터(직접 링크 또는 UUID)",
"char_import_4": "Pygmalion.chat 문자(직접 링크 또는 UUID)",
- "char_import_5": "AICharacterCard.com 캐릭터(직접 링크 또는 ID)",
+ "char_import_5": "AICharacterCards.com 캐릭터(직접 링크 또는 ID)",
"char_import_6": "직접 PNG 링크(참조",
"char_import_7": "허용된 호스트의 경우)",
"char_import_8": "RisuRealm 캐릭터 (직접링크)",
diff --git a/public/locales/nl-nl.json b/public/locales/nl-nl.json
index 327b332b0..c4820f5b8 100644
--- a/public/locales/nl-nl.json
+++ b/public/locales/nl-nl.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Chub Lorebook (directe link of ID)",
"char_import_3": "JanitorAI-personage (directe link of UUID)",
"char_import_4": "Pygmalion.chat-teken (directe link of UUID)",
- "char_import_5": "AICharacterCard.com-teken (directe link of ID)",
+ "char_import_5": "AICharacterCards.com-teken (directe link of ID)",
"char_import_6": "Directe PNG-link (zie",
"char_import_7": "voor toegestane hosts)",
"char_import_8": "RisuRealm-personage (directe link)",
diff --git a/public/locales/pt-pt.json b/public/locales/pt-pt.json
index d0f1d2681..5a2fba569 100644
--- a/public/locales/pt-pt.json
+++ b/public/locales/pt-pt.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Chub Lorebook (link direto ou ID)",
"char_import_3": "Personagem JanitorAI (Link Direto ou UUID)",
"char_import_4": "Caractere Pygmalion.chat (Link Direto ou UUID)",
- "char_import_5": "Personagem AICharacterCard.com (link direto ou ID)",
+ "char_import_5": "Personagem AICharacterCards.com (link direto ou ID)",
"char_import_6": "Link PNG direto (consulte",
"char_import_7": "para hosts permitidos)",
"char_import_8": "Personagem RisuRealm (link direto)",
diff --git a/public/locales/ru-ru.json b/public/locales/ru-ru.json
index 8e304da71..4c67beaa9 100644
--- a/public/locales/ru-ru.json
+++ b/public/locales/ru-ru.json
@@ -966,7 +966,7 @@
"char_import_2": "Лорбук с Chub (прямая ссылка или ID)",
"char_import_3": "Персонаж с JanitorAI (прямая ссылка или UUID)",
"char_import_4": "Персонаж с Pygmalion.chat (прямая ссылка или UUID)",
- "char_import_5": "Персонаж с AICharacterCard.com (прямая ссылка или ID)",
+ "char_import_5": "Персонаж с AICharacterCards.com (прямая ссылка или ID)",
"char_import_6": "Прямая ссылка на PNG-файл (чтобы узнать список разрешённых хостов, загляните в",
"char_import_7": ")",
"Grammar String": "Грамматика",
diff --git a/public/locales/uk-ua.json b/public/locales/uk-ua.json
index b60945a5b..216ed3928 100644
--- a/public/locales/uk-ua.json
+++ b/public/locales/uk-ua.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Chub Lorebook (пряме посилання або ID)",
"char_import_3": "Символ JanitorAI (пряме посилання або UUID)",
"char_import_4": "Символ Pygmalion.chat (пряме посилання або UUID)",
- "char_import_5": "Символ AICharacterCard.com (пряме посилання або ідентифікатор)",
+ "char_import_5": "Символ AICharacterCards.com (пряме посилання або ідентифікатор)",
"char_import_6": "Пряме посилання на PNG (див",
"char_import_7": "для дозволених хостів)",
"char_import_8": "Персонаж RisuRealm (пряме посилання)",
diff --git a/public/locales/vi-vn.json b/public/locales/vi-vn.json
index be9894621..36459e94f 100644
--- a/public/locales/vi-vn.json
+++ b/public/locales/vi-vn.json
@@ -1376,7 +1376,7 @@
"char_import_2": "Chub (Nhập URL trực tiếp hoặc ID)",
"char_import_3": "JanitorAI (Nhập URL trực tiếp hoặc UUID)",
"char_import_4": "Pygmalion.chat (Nhập URL trực tiếp hoặc UUID)",
- "char_import_5": "AICharacterCard.com (Nhập URL trực tiếp hoặc ID)",
+ "char_import_5": "AICharacterCards.com (Nhập URL trực tiếp hoặc ID)",
"char_import_6": "Nhập PNG trực tiếp (tham khảo",
"char_import_7": "đối với các máy chủ được phép)",
"char_import_8": "RisuRealm (URL trực tiếp)",
diff --git a/public/locales/zh-cn.json b/public/locales/zh-cn.json
index 04129749c..17a6ab6e8 100644
--- a/public/locales/zh-cn.json
+++ b/public/locales/zh-cn.json
@@ -1829,7 +1829,7 @@
"char_import_2": "Chub 知识书(直链或ID)",
"char_import_3": "JanitorAI 角色(直链或UUID)",
"char_import_4": "Pygmalion.chat 角色(直链或UUID)",
- "char_import_5": "AICharacterCard.com 角色(直链或ID)",
+ "char_import_5": "AICharacterCards.com 角色(直链或ID)",
"char_import_6": "被允许的PNG直链(请参阅",
"char_import_7": ")",
"char_import_8": "RisuRealm 角色(直链)",
diff --git a/public/locales/zh-tw.json b/public/locales/zh-tw.json
index 5d8313c1d..062aa7942 100644
--- a/public/locales/zh-tw.json
+++ b/public/locales/zh-tw.json
@@ -1381,7 +1381,7 @@
"char_import_2": "Chub Lorebook(直接連結或 ID)",
"char_import_3": "JanitorAI 角色(直接連結或 ID)",
"char_import_4": "Pygmalion.chat 角色(直接連結或 ID)",
- "char_import_5": "AICharacterCard.com 角色(直接連結或 ID)",
+ "char_import_5": "AICharacterCards.com 角色(直接連結或 ID)",
"char_import_6": "直接 PNG 連結(請參閱",
"char_import_7": "對於允許的主機)",
"char_import_8": "RisuRealm角色(直接連結)",
diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js
index ceba70232..2ba133a2b 100644
--- a/public/scripts/group-chats.js
+++ b/public/scripts/group-chats.js
@@ -1368,6 +1368,15 @@ function getGroupCharacterBlock(character) {
template.find('.ch_fav').val(isFav);
template.toggleClass('is_fav', isFav);
+ const auxFieldName = power_user.aux_field || 'character_version';
+ const auxFieldValue = (character.data && character.data[auxFieldName]) || '';
+ if (auxFieldValue) {
+ template.find('.character_version').text(auxFieldValue);
+ }
+ else {
+ template.find('.character_version').hide();
+ }
+
let queuePosition = groupChatQueueOrder.get(character.avatar);
if (queuePosition) {
template.find('.queue_position').text(queuePosition);
diff --git a/public/scripts/templates/importCharacters.html b/public/scripts/templates/importCharacters.html
index ee226f741..4bc4807bf 100644
--- a/public/scripts/templates/importCharacters.html
+++ b/public/scripts/templates/importCharacters.html
@@ -7,7 +7,7 @@
Chub Lorebook (Direct Link or ID) Example:lorebooks/bartleby/example-lorebook
JanitorAI Character (Direct Link or UUID) Example:ddd1498a-a370-4136-b138-a8cd9461fdfe_character-aqua-the-useless-goddess
Pygmalion.chat Character (Direct Link or UUID) Example:a7ca95a1-0c88-4e23-91b3-149db1e78ab9
-
AICharacterCard.com Character (Direct Link or ID) Example:AICC/aicharcards/the-game-master
+
AICharacterCards.com Character (Direct Link or ID) Example:AICC/aicharcards/the-game-master
Direct PNG Link (refer toconfig.yaml for allowed hosts) Example:https://files.catbox.moe/notarealfile.png
RisuRealm Character (Direct Link) Example:https://realm.risuai.net/character/3ca54c71-6efe-46a2-b9d0-4f62df23d712
diff --git a/public/style.css b/public/style.css
index 246d4b6c9..b79a54a59 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2928,7 +2928,7 @@ input[type=search]:focus::-webkit-search-cancel-button {
position: relative;
}
-#rm_print_characters_block .ch_name,
+.character_name_block .ch_name,
.avatar-container .ch_name {
flex: 1 1 auto;
white-space: nowrap;
@@ -2938,6 +2938,13 @@ input[type=search]:focus::-webkit-search-cancel-button {
display: block;
}
+.character_name_block .character_version {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ text-wrap: nowrap;
+ max-width: 50%;
+}
+
#rm_print_characters_block .character_name_block> :last-child {
flex: 0 100000 auto;
/* Force shrinking first */
diff --git a/server.js b/server.js
index db7ba9306..6cd78d753 100644
--- a/server.js
+++ b/server.js
@@ -18,10 +18,9 @@ import { hideBin } from 'yargs/helpers';
// express/server related library imports
import cors from 'cors';
-import { doubleCsrf } from 'csrf-csrf';
+import { csrfSync } from 'csrf-sync';
import express from 'express';
import compression from 'compression';
-import cookieParser from 'cookie-parser';
import cookieSession from 'cookie-session';
import multer from 'multer';
import responseTime from 'response-time';
@@ -40,7 +39,6 @@ util.inspect.defaultOptions.depth = 4;
import { loadPlugins } from './src/plugin-loader.js';
import {
initUserStorage,
- getCsrfSecret,
getCookieSecret,
getCookieSessionName,
getAllEnabledUsers,
@@ -67,6 +65,7 @@ import {
forwardFetchResponse,
removeColorFormatting,
getSeparator,
+ safeReadFileSync,
} from './src/util.js';
import { UPLOADS_DIRECTORY } from './src/constants.js';
import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
@@ -347,8 +346,8 @@ if (enableCorsProxy) {
}
function getSessionCookieAge() {
- // Defaults to 24 hours in seconds if not set
- const configValue = getConfigValue('sessionTimeout', 24 * 60 * 60);
+ // Defaults to "no expiration" if not set
+ const configValue = getConfigValue('sessionTimeout', -1);
// Convert to milliseconds
if (configValue > 0) {
@@ -377,27 +376,38 @@ app.use(setUserDataMiddleware);
// CSRF Protection //
if (!disableCsrf) {
- const COOKIES_SECRET = getCookieSecret();
-
- const { generateToken, doubleCsrfProtection } = doubleCsrf({
- getSecret: getCsrfSecret,
- cookieName: 'X-CSRF-Token',
- cookieOptions: {
- sameSite: 'strict',
- secure: false,
+ const csrfSyncProtection = csrfSync({
+ getTokenFromState: (req) => {
+ if (!req.session) {
+ console.error('(CSRF error) getTokenFromState: Session object not initialized');
+ return;
+ }
+ return req.session.csrfToken;
},
- size: 64,
- getTokenFromRequest: (req) => req.headers['x-csrf-token'],
+ getTokenFromRequest: (req) => {
+ return req.headers['x-csrf-token']?.toString();
+ },
+ storeTokenInState: (req, token) => {
+ if (!req.session) {
+ console.error('(CSRF error) storeTokenInState: Session object not initialized');
+ return;
+ }
+ req.session.csrfToken = token;
+ },
+ size: 32,
});
app.get('/csrf-token', (req, res) => {
res.json({
- 'token': generateToken(res, req),
+ 'token': csrfSyncProtection.generateToken(req),
});
});
- app.use(cookieParser(COOKIES_SECRET));
- app.use(doubleCsrfProtection);
+ // Customize the error message
+ csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.');
+ csrfSyncProtection.invalidCsrfTokenError.stack = undefined;
+
+ app.use(csrfSyncProtection.csrfSynchronisedProtection);
} else {
console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
app.get('/csrf-token', (req, res) => {
@@ -921,6 +931,16 @@ async function verifySecuritySettings() {
}
}
+/**
+ * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
+ */
+function apply404Middleware() {
+ const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? '';
+ app.use((req, res) => {
+ res.status(404).send(notFoundWebpage);
+ });
+}
+
// User storage module needs to be initialized before starting the server
initUserStorage(dataRoot)
.then(ensurePublicDirectoriesExist)
@@ -928,4 +948,5 @@ initUserStorage(dataRoot)
.then(migrateSystemPrompts)
.then(verifySecuritySettings)
.then(preSetupTasks)
+ .then(apply404Middleware)
.finally(startServer);
diff --git a/src/endpoints/avatars.js b/src/endpoints/avatars.js
index 73b995ffb..f84527670 100644
--- a/src/endpoints/avatars.js
+++ b/src/endpoints/avatars.js
@@ -9,6 +9,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
import { jsonParser, urlencodedParser } from '../express-common.js';
import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
import { getImages, tryParse } from '../util.js';
+import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
export const router = express.Router();
@@ -17,7 +18,7 @@ router.post('/get', jsonParser, function (request, response) {
response.send(JSON.stringify(images));
});
-router.post('/delete', jsonParser, function (request, response) {
+router.post('/delete', jsonParser, getFileNameValidationFunction('avatar'), function (request, response) {
if (!request.body) return response.sendStatus(400);
if (request.body.avatar !== sanitize(request.body.avatar)) {
diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js
index 9dcaf4fcf..884a95c65 100644
--- a/src/endpoints/backends/chat-completions.js
+++ b/src/endpoints/backends/chat-completions.js
@@ -37,6 +37,8 @@ import {
getTiktokenTokenizer,
sentencepieceTokenizers,
TEXT_COMPLETION_MODELS,
+ webTokenizers,
+ getWebTokenizer,
} from '../tokenizers.js';
const API_OPENAI = 'https://api.openai.com/v1';
@@ -863,6 +865,14 @@ router.post('/bias', jsonParser, async function (request, response) {
return response.send({});
}
encodeFunction = (text) => new Uint32Array(instance.encodeIds(text));
+ } else if (webTokenizers.includes(model)) {
+ const tokenizer = getWebTokenizer(model);
+ const instance = await tokenizer?.get();
+ if (!instance) {
+ console.warn('Tokenizer not initialized:', model);
+ return response.send({});
+ }
+ encodeFunction = (text) => new Uint32Array(instance.encode(text));
} else {
const tokenizer = getTiktokenTokenizer(model);
encodeFunction = (tokenizer.encode.bind(tokenizer));
diff --git a/src/endpoints/backgrounds.js b/src/endpoints/backgrounds.js
index 0638415e6..13705fa7a 100644
--- a/src/endpoints/backgrounds.js
+++ b/src/endpoints/backgrounds.js
@@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename';
import { jsonParser, urlencodedParser } from '../express-common.js';
import { invalidateThumbnail } from './thumbnails.js';
import { getImages } from '../util.js';
+import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
export const router = express.Router();
@@ -15,7 +16,7 @@ router.post('/all', jsonParser, function (request, response) {
response.send(JSON.stringify(images));
});
-router.post('/delete', jsonParser, function (request, response) {
+router.post('/delete', jsonParser, getFileNameValidationFunction('bg'), function (request, response) {
if (!request.body) return response.sendStatus(400);
if (request.body.bg !== sanitize(request.body.bg)) {
diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js
index 2aa2cca10..1e5df80bd 100644
--- a/src/endpoints/characters.js
+++ b/src/endpoints/characters.js
@@ -14,6 +14,7 @@ import jimp from 'jimp';
import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
import { jsonParser, urlencodedParser } from '../express-common.js';
+import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue } from '../util.js';
import { TavernCardValidator } from '../validator/TavernCardValidator.js';
import { parse, write } from '../character-card-parser.js';
@@ -73,12 +74,18 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
* Read the image, resize, and save it as a PNG into the buffer.
* @returns {Promise} Image buffer
*/
- function getInputImage() {
- if (Buffer.isBuffer(inputFile)) {
- return parseImageBuffer(inputFile, crop);
- }
+ async function getInputImage() {
+ try {
+ if (Buffer.isBuffer(inputFile)) {
+ return await parseImageBuffer(inputFile, crop);
+ }
- return tryReadImage(inputFile, crop);
+ return await tryReadImage(inputFile, crop);
+ } catch (error) {
+ const message = Buffer.isBuffer(inputFile) ? 'Failed to read image buffer.' : `Failed to read image: ${inputFile}.`;
+ console.warn(message, 'Using a fallback image.', error);
+ return await fs.promises.readFile(defaultAvatarPath);
+ }
}
const inputImage = await getInputImage();
@@ -756,7 +763,7 @@ router.post('/create', urlencodedParser, async function (request, response) {
}
});
-router.post('/rename', jsonParser, async function (request, response) {
+router.post('/rename', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
if (!request.body.avatar_url || !request.body.new_name) {
return response.sendStatus(400);
}
@@ -803,7 +810,7 @@ router.post('/rename', jsonParser, async function (request, response) {
}
});
-router.post('/edit', urlencodedParser, async function (request, response) {
+router.post('/edit', urlencodedParser, validateAvatarUrlMiddleware, async function (request, response) {
if (!request.body) {
console.error('Error: no response body detected');
response.status(400).send('Error: no response body detected');
@@ -852,7 +859,7 @@ router.post('/edit', urlencodedParser, async function (request, response) {
* @param {Object} response - The HTTP response object.
* @returns {void}
*/
-router.post('/edit-attribute', jsonParser, async function (request, response) {
+router.post('/edit-attribute', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
console.log(request.body);
if (!request.body) {
console.error('Error: no response body detected');
@@ -898,7 +905,7 @@ router.post('/edit-attribute', jsonParser, async function (request, response) {
*
* @returns {void}
* */
-router.post('/merge-attributes', jsonParser, async function (request, response) {
+router.post('/merge-attributes', jsonParser, getFileNameValidationFunction('avatar'), async function (request, response) {
try {
const update = request.body;
const avatarPath = path.join(request.user.directories.characters, update.avatar);
@@ -929,7 +936,7 @@ router.post('/merge-attributes', jsonParser, async function (request, response)
}
});
-router.post('/delete', jsonParser, async function (request, response) {
+router.post('/delete', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
if (!request.body || !request.body.avatar_url) {
return response.sendStatus(400);
}
@@ -992,7 +999,7 @@ router.post('/all', jsonParser, async function (request, response) {
}
});
-router.post('/get', jsonParser, async function (request, response) {
+router.post('/get', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
try {
if (!request.body) return response.sendStatus(400);
const item = request.body.avatar_url;
@@ -1011,7 +1018,7 @@ router.post('/get', jsonParser, async function (request, response) {
}
});
-router.post('/chats', jsonParser, async function (request, response) {
+router.post('/chats', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
if (!request.body) return response.sendStatus(400);
const characterDirectory = (request.body.avatar_url).replace('.png', '');
@@ -1160,7 +1167,7 @@ router.post('/import', urlencodedParser, async function (request, response) {
}
});
-router.post('/duplicate', jsonParser, async function (request, response) {
+router.post('/duplicate', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
try {
if (!request.body.avatar_url) {
console.log('avatar URL not found in request body');
@@ -1207,7 +1214,7 @@ router.post('/duplicate', jsonParser, async function (request, response) {
}
});
-router.post('/export', jsonParser, async function (request, response) {
+router.post('/export', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
try {
if (!request.body.format || !request.body.avatar_url) {
return response.sendStatus(400);
diff --git a/src/endpoints/chats.js b/src/endpoints/chats.js
index 2df194797..c3bab87ac 100644
--- a/src/endpoints/chats.js
+++ b/src/endpoints/chats.js
@@ -9,6 +9,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
import _ from 'lodash';
import { jsonParser, urlencodedParser } from '../express-common.js';
+import validateAvatarUrlMiddleware from '../middleware/validateFileName.js';
import {
getConfigValue,
humanizedISO8601DateTime,
@@ -294,7 +295,7 @@ function importRisuChat(userName, characterName, jsonData) {
export const router = express.Router();
-router.post('/save', jsonParser, function (request, response) {
+router.post('/save', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
try {
const directoryName = String(request.body.avatar_url).replace('.png', '');
const chatData = request.body.chat;
@@ -310,7 +311,7 @@ router.post('/save', jsonParser, function (request, response) {
}
});
-router.post('/get', jsonParser, function (request, response) {
+router.post('/get', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
try {
const dirName = String(request.body.avatar_url).replace('.png', '');
const directoryPath = path.join(request.user.directories.chats, dirName);
@@ -347,7 +348,7 @@ router.post('/get', jsonParser, function (request, response) {
});
-router.post('/rename', jsonParser, async function (request, response) {
+router.post('/rename', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
if (!request.body || !request.body.original_file || !request.body.renamed_file) {
return response.sendStatus(400);
}
@@ -372,7 +373,7 @@ router.post('/rename', jsonParser, async function (request, response) {
return response.send({ ok: true, sanitizedFileName });
});
-router.post('/delete', jsonParser, function (request, response) {
+router.post('/delete', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
const dirName = String(request.body.avatar_url).replace('.png', '');
const fileName = String(request.body.chatfile);
const filePath = path.join(request.user.directories.chats, dirName, sanitize(fileName));
@@ -388,7 +389,7 @@ router.post('/delete', jsonParser, function (request, response) {
return response.send('ok');
});
-router.post('/export', jsonParser, async function (request, response) {
+router.post('/export', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) {
return response.sendStatus(400);
}
@@ -478,7 +479,7 @@ router.post('/group/import', urlencodedParser, function (request, response) {
}
});
-router.post('/import', urlencodedParser, function (request, response) {
+router.post('/import', urlencodedParser, validateAvatarUrlMiddleware, function (request, response) {
if (!request.body) return response.sendStatus(400);
const format = request.body.file_type;
@@ -626,7 +627,7 @@ router.post('/group/save', jsonParser, (request, response) => {
return response.send({ ok: true });
});
-router.post('/search', jsonParser, function (request, response) {
+router.post('/search', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
try {
const { query, avatar_url, group_id } = request.body;
let chatFiles = [];
diff --git a/src/endpoints/settings.js b/src/endpoints/settings.js
index 3cc7e3bec..4df4978cc 100644
--- a/src/endpoints/settings.js
+++ b/src/endpoints/settings.js
@@ -9,6 +9,7 @@ import { SETTINGS_FILE } from '../constants.js';
import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';
import { jsonParser } from '../express-common.js';
import { getAllUserHandles, getUserDirectories } from '../users.js';
+import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true);
const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true);
@@ -296,7 +297,7 @@ router.post('/get-snapshots', jsonParser, async (request, response) => {
}
});
-router.post('/load-snapshot', jsonParser, async (request, response) => {
+router.post('/load-snapshot', jsonParser, getFileNameValidationFunction('name'), async (request, response) => {
try {
const userFilesPattern = getFilePrefix(request.user.profile.handle);
@@ -330,7 +331,7 @@ router.post('/make-snapshot', jsonParser, async (request, response) => {
}
});
-router.post('/restore-snapshot', jsonParser, async (request, response) => {
+router.post('/restore-snapshot', jsonParser, getFileNameValidationFunction('name'), async (request, response) => {
try {
const userFilesPattern = getFilePrefix(request.user.profile.handle);
diff --git a/src/endpoints/tokenizers.js b/src/endpoints/tokenizers.js
index bd6fdeec0..5f693b751 100644
--- a/src/endpoints/tokenizers.js
+++ b/src/endpoints/tokenizers.js
@@ -238,6 +238,15 @@ export const sentencepieceTokenizers = [
'jamba',
];
+export const webTokenizers = [
+ 'claude',
+ 'llama3',
+ 'command-r',
+ 'qwen2',
+ 'nemo',
+ 'deepseek',
+];
+
/**
* Gets the Sentencepiece tokenizer by the model name.
* @param {string} model Sentencepiece model name
@@ -275,6 +284,39 @@ export function getSentencepiceTokenizer(model) {
return null;
}
+/**
+ * Gets the Web tokenizer by the model name.
+ * @param {string} model Web tokenizer model name
+ * @returns {WebTokenizer|null} Web tokenizer
+ */
+export function getWebTokenizer(model) {
+ if (model.includes('llama3')) {
+ return llama3_tokenizer;
+ }
+
+ if (model.includes('claude')) {
+ return claude_tokenizer;
+ }
+
+ if (model.includes('command-r')) {
+ return commandTokenizer;
+ }
+
+ if (model.includes('qwen2')) {
+ return qwen2Tokenizer;
+ }
+
+ if (model.includes('nemo')) {
+ return nemoTokenizer;
+ }
+
+ if (model.includes('deepseek')) {
+ return deepseekTokenizer;
+ }
+
+ return null;
+}
+
/**
* Counts the token ids for the given text using the Sentencepiece tokenizer.
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js
index 81b60acea..1fde87083 100644
--- a/src/endpoints/users-private.js
+++ b/src/endpoints/users-private.js
@@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => {
}
request.session.handle = null;
+ request.session.csrfToken = null;
request.session = null;
return response.sendStatus(204);
} catch (error) {
diff --git a/src/middleware/basicAuth.js b/src/middleware/basicAuth.js
index 87b7fbcf8..b75856289 100644
--- a/src/middleware/basicAuth.js
+++ b/src/middleware/basicAuth.js
@@ -5,17 +5,18 @@
import { Buffer } from 'node:buffer';
import storage from 'node-persist';
import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
-import { getConfig, getConfigValue } from '../util.js';
+import { getConfig, getConfigValue, safeReadFileSync } from '../util.js';
const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false);
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
-const unauthorizedResponse = (res) => {
- res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
- return res.status(401).send('Authentication required');
-};
-
const basicAuthMiddleware = async function (request, response, callback) {
+ const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';
+ const unauthorizedResponse = (res) => {
+ res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
+ return res.status(401).send(unauthorizedWebpage);
+ };
+
const config = getConfig();
const authHeader = request.headers.authorization;
diff --git a/src/middleware/validateFileName.js b/src/middleware/validateFileName.js
new file mode 100644
index 000000000..ccfb0e88d
--- /dev/null
+++ b/src/middleware/validateFileName.js
@@ -0,0 +1,34 @@
+import path from 'node:path';
+
+/**
+ * Gets a middleware function that validates the field in the request body.
+ * @param {string} fieldName Field name
+ * @returns {import('express').RequestHandler} Middleware function
+ */
+export function getFileNameValidationFunction(fieldName) {
+ /**
+ * Validates the field in the request body.
+ * @param {import('express').Request} req Request object
+ * @param {import('express').Response} res Response object
+ * @param {import('express').NextFunction} next Next middleware
+ */
+ return function validateAvatarUrlMiddleware(req, res, next) {
+ if (req.body && fieldName in req.body && typeof req.body[fieldName] === 'string') {
+ const forbiddenRegExp = path.sep === '/' ? /[/\x00]/ : /[/\x00\\]/;
+ if (forbiddenRegExp.test(req.body[fieldName])) {
+ console.error('An error occurred while validating the request body', {
+ handle: req.user.profile.handle,
+ path: req.originalUrl,
+ field: fieldName,
+ value: req.body[fieldName],
+ });
+ return res.sendStatus(400);
+ }
+ }
+
+ next();
+ };
+}
+
+const avatarUrlValidationFunction = getFileNameValidationFunction('avatar_url');
+export default avatarUrlValidationFunction;
diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js
index 9864e19ae..4df5a6798 100644
--- a/src/middleware/whitelist.js
+++ b/src/middleware/whitelist.js
@@ -1,10 +1,11 @@
import path from 'node:path';
import fs from 'node:fs';
import process from 'node:process';
+import Handlebars from 'handlebars';
import ipMatching from 'ip-matching';
import { getIpFromRequest } from '../express-common.js';
-import { color, getConfigValue } from '../util.js';
+import { color, getConfigValue, safeReadFileSync } from '../util.js';
const whitelistPath = path.join(process.cwd(), './whitelist.txt');
const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
@@ -52,12 +53,16 @@ function getForwardedIp(req) {
* @returns {import('express').RequestHandler} The middleware function
*/
export default function whitelistMiddleware(whitelistMode, listen) {
+ const forbiddenWebpage = Handlebars.compile(
+ safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
+ );
+
return function (req, res, next) {
const clientIp = getIpFromRequest(req);
const forwardedIp = getForwardedIp(req);
+ const userAgent = req.headers['user-agent'];
if (listen && !knownIPs.has(clientIp)) {
- const userAgent = req.headers['user-agent'];
console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
knownIPs.add(clientIp);
@@ -76,9 +81,15 @@ export default function whitelistMiddleware(whitelistMode, listen) {
|| forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
) {
// Log the connection attempt with real IP address
- const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp;
- console.log(color.red('Forbidden: Connection attempt from ' + ipDetails + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.\n'));
- return res.status(403).send('Forbidden: Connection attempt from ' + ipDetails + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.');
+ const ipDetails = forwardedIp
+ ? `${clientIp} (forwarded from ${forwardedIp})`
+ : clientIp;
+ console.log(
+ color.red(
+ `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`,
+ ),
+ );
+ return res.status(403).send(forbiddenWebpage({ ipDetails }));
}
next();
};
diff --git a/src/users.js b/src/users.js
index 36a5d62b3..c8df39779 100644
--- a/src/users.js
+++ b/src/users.js
@@ -458,7 +458,8 @@ export function getPasswordSalt() {
*/
export function getCookieSessionName() {
// Get server hostname and hash it to generate a session suffix
- const suffix = crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 8);
+ const hostname = os.hostname() || 'localhost';
+ const suffix = crypto.createHash('sha256').update(hostname).digest('hex').slice(0, 8);
return `session-${suffix}`;
}
diff --git a/src/util.js b/src/util.js
index 0fe03e76c..19bd3ccc7 100644
--- a/src/util.js
+++ b/src/util.js
@@ -871,3 +871,14 @@ export class MemoryLimitedMap {
return this.map[Symbol.iterator]();
}
}
+
+/**
+ * A 'safe' version of `fs.readFileSync()`. Returns the contents of a file if it exists, falling back to a default value if not.
+ * @param {string} filePath Path of the file to be read.
+ * @param {Parameters[1]} options Options object to pass through to `fs.readFileSync()` (default: `{ encoding: 'utf-8' }`).
+ * @returns The contents at `filePath` if it exists, or `null` if not.
+ */
+export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
+ if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
+ return null;
+}