Merge branch 'staging' into persona-improvements

8448d6c6e6526e5e47b2c436e2f56ff1033cf9b0

Cohee <18619528+Cohee1207@users.noreply.github.com>

43 files changed, +367 -98Ignore whitespace
.gitignore+3 -0
@@ -45,6 +45,7 @@ access.log
45/vectors/45/vectors/
46/cache/46/cache/
47public/css/user.css47public/css/user.css
48public/error/
48/plugins/49/plugins/
49/data50/data
50/default/scaffold51/default/scaffold
@@ -52,3 +53,5 @@ public/scripts/extensions/third-party
52/certs53/certs
53.aider*54.aider*
54.env55.env
56/StartDev.bat
57
default/config.yaml+1 -1
@@ -70,7 +70,7 @@ perUserBasicAuth: false
70## Set to a positive number to expire session after a certain time of inactivity70## Set to a positive number to expire session after a certain time of inactivity
71## Set to 0 to expire session when the browser is closed71## Set to 0 to expire session when the browser is closed
72## Set to a negative number to disable session expiration72## Set to a negative number to disable session expiration
73sessionTimeout: 8640073sessionTimeout: -1
74# Used to sign session cookies. Will be auto-generated if not set74# Used to sign session cookies. Will be auto-generated if not set
75cookieSecret: ''75cookieSecret: ''
76# Disable CSRF protection - NOT RECOMMENDED76# Disable CSRF protection - NOT RECOMMENDED
default/user.css → default/public/css/user.css+0 -0
default/public/error/forbidden-by-whitelist.html+22 -0
@@ -0,0 +1,22 @@
1<!DOCTYPE html>
2<html>
3
4<head>
5 <title>Forbidden</title>
6</head>
7
8<body>
9 <h1>Forbidden</h1>
10 <p>
11 If you are the system administrator, add your IP address to the
12 whitelist or disable whitelist mode by editing
13 <code>config.yaml</code> in the root directory of your installation.
14 </p>
15 <hr />
16 <p>
17 <em>Connection from {{ipDetails}} has been blocked. This attempt
18 has been logged.</em>
19 </p>
20</body>
21
22</html>
default/public/error/unauthorized.html+17 -0
@@ -0,0 +1,17 @@
1<!DOCTYPE html>
2<html>
3
4<head>
5 <title>Unauthorized</title>
6</head>
7
8<body>
9 <h1>Unauthorized</h1>
10 <p>
11 If you are the system administrator, you can configure the
12 <code>basicAuthUser</code> credentials by editing
13 <code>config.yaml</code> in the root directory of your installation.
14 </p>
15</body>
16
17</html>
default/public/error/url-not-found.html+15 -0
@@ -0,0 +1,15 @@
1<!DOCTYPE html>
2<html>
3
4<head>
5 <title>Not found</title>
6</head>
7
8<body>
9 <h1>Not found</h1>
10 <p>
11 The requested URL was not found on this server.
12 </p>
13</body>
14
15</html>
index.d.ts+18 -8
@@ -1,6 +1,24 @@
1import { UserDirectoryList, User } from "./src/users";1import { UserDirectoryList, User } from "./src/users";
2import { CsrfSyncedToken } from "csrf-sync";
23
3declare global {4declare global {
5 declare namespace CookieSessionInterfaces {
6 export interface CookieSessionObject {
7 /**
8 * The CSRF token for the session.
9 */
10 csrfToken: CsrfSyncedToken;
11 /**
12 * Authenticated user handle.
13 */
14 handle: string;
15 /**
16 * Last time the session was extended.
17 */
18 touch: number;
19 }
20 }
21
4 namespace Express {22 namespace Express {
5 export interface Request {23 export interface Request {
6 user: {24 user: {
@@ -15,11 +33,3 @@ declare global {
15 */33 */
16 var DATA_ROOT: string;34 var DATA_ROOT: string;
17}35}
18
19declare module 'express-session' {
20 export interface SessionData {
21 handle: string;
22 touch: number;
23 // other properties...
24 }
25 }
package-lock.json+5 -5
@@ -26,7 +26,7 @@
26 "cookie-parser": "^1.4.6",26 "cookie-parser": "^1.4.6",
27 "cookie-session": "^2.1.0",27 "cookie-session": "^2.1.0",
28 "cors": "^2.8.5",28 "cors": "^2.8.5",
29 "csrf-csrf": "^2.2.3",29 "csrf-sync": "^4.0.3",
30 "diff-match-patch": "^1.0.5",30 "diff-match-patch": "^1.0.5",
31 "dompurify": "^3.1.7",31 "dompurify": "^3.1.7",
32 "droll": "^0.2.1",32 "droll": "^0.2.1",
@@ -2987,10 +2987,10 @@
2987 "node": "*"2987 "node": "*"
2988 }2988 }
2989 },2989 },
2990 "node_modules/csrf-csrf": {2990 "node_modules/csrf-sync": {
2991 "version": "2.2.4",2991 "version": "4.0.3",
2992 "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-2.2.4.tgz",2992 "resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.0.3.tgz",
2993 "integrity": "sha512-LuhBmy5RfRmEfeqeYqgaAuS1eDpVtKZB/Eiec9xiKQLBynJxrGVRdM2yRT/YMl1Njo/yKh2L9AYsIwSlTPnx2A==",2993 "integrity": "sha512-wXzltBBzt/7imzDt6ZT7G/axQG7jo4Sm0uXDUzFY8hR59qhDHdjqpW2hojS4oAVIZDzwlMQloIVCTJoDDh0wwA==",
2994 "license": "ISC",2994 "license": "ISC",
2995 "dependencies": {2995 "dependencies": {
2996 "http-errors": "^2.0.0"2996 "http-errors": "^2.0.0"
package.json+1 -1
@@ -16,7 +16,7 @@
16 "cookie-parser": "^1.4.6",16 "cookie-parser": "^1.4.6",
17 "cookie-session": "^2.1.0",17 "cookie-session": "^2.1.0",
18 "cors": "^2.8.5",18 "cors": "^2.8.5",
19 "csrf-csrf": "^2.2.3",19 "csrf-sync": "^4.0.3",
20 "diff-match-patch": "^1.0.5",20 "diff-match-patch": "^1.0.5",
21 "dompurify": "^3.1.7",21 "dompurify": "^3.1.7",
22 "droll": "^0.2.1",22 "droll": "^0.2.1",
post-install.js+50 -10
@@ -213,20 +213,60 @@ function addMissingConfigValues() {
213 * Creates the default config files if they don't exist yet.213 * Creates the default config files if they don't exist yet.
214 */214 */
215function createDefaultFiles() {215function createDefaultFiles() {
216 const files = {216 /**
217 config: './config.yaml',217 * @typedef DefaultItem
218 user: './public/css/user.css',218 * @type {object}
219 };219 * @property {'file' | 'directory'} type - Whether the item should be copied as a single file or merged into a directory structure.
220 * @property {string} defaultPath - The path to the default item (typically in `default/`).
221 * @property {string} productionPath - The path to the copied item for production use.
222 */
220223
221 for (const file of Object.values(files)) {224 /** @type {DefaultItem[]} */
225 const defaultItems = [
226 {
227 type: 'file',
228 defaultPath: './default/config.yaml',
229 productionPath: './config.yaml',
230 },
231 {
232 type: 'directory',
233 defaultPath: './default/public/',
234 productionPath: './public/',
235 },
236 ];
237
238 for (const defaultItem of defaultItems) {
222 try {239 try {
223 if (!fs.existsSync(file)) {240 if (defaultItem.type === 'file') {
224 const defaultFilePath = path.join('./default', path.parse(file).base);241 if (!fs.existsSync(defaultItem.productionPath)) {
225 fs.copyFileSync(defaultFilePath, file);242 fs.copyFileSync(
226 console.log(color.green(`Created default file: ${file}`));243 defaultItem.defaultPath,
244 defaultItem.productionPath,
245 );
246 console.log(
247 color.green(`Created default file: ${defaultItem.productionPath}`),
248 );
249 }
250 } else if (defaultItem.type === 'directory') {
251 fs.cpSync(defaultItem.defaultPath, defaultItem.productionPath, {
252 force: false, // Don't overwrite existing files!
253 recursive: true,
254 });
255 console.log(
256 color.green(`Synchronized missing files: ${defaultItem.productionPath}`),
257 );
258 } else {
259 throw new Error(
260 'FATAL: Unexpected default file format in `post-install.js#createDefaultFiles()`.',
261 );
227 }262 }
228 } catch (error) {263 } catch (error) {
229 console.error(color.red(`FATAL: Could not write default file: ${file}`), error);264 console.error(
265 color.red(
266 `FATAL: Could not write default ${defaultItem.type}: ${defaultItem.productionPath}`,
267 ),
268 error,
269 );
230 }270 }
231 }271 }
232}272}
public/index.html+4 -1
@@ -6372,7 +6372,10 @@
6372 <img alt="Avatar" src="" />6372 <img alt="Avatar" src="" />
6373 </div>6373 </div>
6374 <div class="group_member_name">6374 <div class="group_member_name">
6375 <div class="ch_name"></div>6375 <div class="character_name_block">
6376 <span class="ch_name"></span>
6377 <small class="ch_additional_info character_version"></small>
6378 </div>
6376 <div class="tags tags_inline"></div>6379 <div class="tags tags_inline"></div>
6377 </div>6380 </div>
6378 <input class="ch_fav" value="" hidden />6381 <input class="ch_fav" value="" hidden />
public/locales/ar-sa.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Chub Lorebook (رابط مباشر أو معرف)",1376 "char_import_2": "Chub Lorebook (رابط مباشر أو معرف)",
1377 "char_import_3": "حرف JanitorAI (رابط مباشر أو UUID)",1377 "char_import_3": "حرف JanitorAI (رابط مباشر أو UUID)",
1378 "char_import_4": "حرف Pygmalion.chat (رابط مباشر أو UUID)",1378 "char_import_4": "حرف Pygmalion.chat (رابط مباشر أو UUID)",
1379 "char_import_5": "حرف AICharacterCard.com (رابط مباشر أو معرف)",1379 "char_import_5": "حرف AICharacterCards.com (رابط مباشر أو معرف)",
1380 "char_import_6": "رابط PNG المباشر (راجع",1380 "char_import_6": "رابط PNG المباشر (راجع",
1381 "char_import_7": "للمضيفين المسموح بهم)",1381 "char_import_7": "للمضيفين المسموح بهم)",
1382 "char_import_8": "شخصية RisuRealm (رابط مباشر)",1382 "char_import_8": "شخصية RisuRealm (رابط مباشر)",
public/locales/de-de.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Chub Lorebook (Direktlink oder ID)",1376 "char_import_2": "Chub Lorebook (Direktlink oder ID)",
1377 "char_import_3": "JanitorAI-Charakter (Direktlink oder UUID)",1377 "char_import_3": "JanitorAI-Charakter (Direktlink oder UUID)",
1378 "char_import_4": "Pygmalion.chat-Charakter (Direktlink oder UUID)",1378 "char_import_4": "Pygmalion.chat-Charakter (Direktlink oder UUID)",
1379 "char_import_5": "AICharacterCard.com-Charakter (Direktlink oder ID)",1379 "char_import_5": "AICharacterCards.com-Charakter (Direktlink oder ID)",
1380 "char_import_6": "Direkter PNG-Link (siehe",1380 "char_import_6": "Direkter PNG-Link (siehe",
1381 "char_import_7": "für erlaubte Hosts)",1381 "char_import_7": "für erlaubte Hosts)",
1382 "char_import_8": "RisuRealm-Charakter (Direktlink)",1382 "char_import_8": "RisuRealm-Charakter (Direktlink)",
public/locales/es-es.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Chub Lorebook (enlace directo o ID)",1376 "char_import_2": "Chub Lorebook (enlace directo o ID)",
1377 "char_import_3": "Carácter de JanitorAI (enlace directo o UUID)",1377 "char_import_3": "Carácter de JanitorAI (enlace directo o UUID)",
1378 "char_import_4": "Carácter Pygmalion.chat (enlace directo o UUID)",1378 "char_import_4": "Carácter Pygmalion.chat (enlace directo o UUID)",
1379 "char_import_5": "Carácter AICharacterCard.com (enlace directo o ID)",1379 "char_import_5": "Carácter AICharacterCards.com (enlace directo o ID)",
1380 "char_import_6": "Enlace PNG directo (consulte",1380 "char_import_6": "Enlace PNG directo (consulte",
1381 "char_import_7": "para hosts permitidos)",1381 "char_import_7": "para hosts permitidos)",
1382 "char_import_8": "Personaje RisuRealm (Enlace directo)",1382 "char_import_8": "Personaje RisuRealm (Enlace directo)",
public/locales/fr-fr.json+1 -1
@@ -1297,7 +1297,7 @@
1297 "char_import_2": "Lorebook de Chub (lien direct ou ID)",1297 "char_import_2": "Lorebook de Chub (lien direct ou ID)",
1298 "char_import_3": "Personnage de JanitorAI (lien direct ou UUID)",1298 "char_import_3": "Personnage de JanitorAI (lien direct ou UUID)",
1299 "char_import_4": "Personnage de Pygmalion.chat (lien direct ou UUID)",1299 "char_import_4": "Personnage de Pygmalion.chat (lien direct ou UUID)",
1300 "char_import_5": "Personnage de AICharacterCard.com (lien direct ou identifiant)",1300 "char_import_5": "Personnage de AICharacterCards.com (lien direct ou identifiant)",
1301 "char_import_6": "Lien PNG direct (voir",1301 "char_import_6": "Lien PNG direct (voir",
1302 "char_import_7": "pour les hôtes autorisés)",1302 "char_import_7": "pour les hôtes autorisés)",
1303 "char_import_8": "Personnage de RisuRealm (lien direct)",1303 "char_import_8": "Personnage de RisuRealm (lien direct)",
public/locales/is-is.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Chub Lorebook (beinn hlekkur eða auðkenni)",1376 "char_import_2": "Chub Lorebook (beinn hlekkur eða auðkenni)",
1377 "char_import_3": "JanitorAI karakter (beinn hlekkur eða UUID)",1377 "char_import_3": "JanitorAI karakter (beinn hlekkur eða UUID)",
1378 "char_import_4": "Pygmalion.chat karakter (beinn hlekkur eða UUID)",1378 "char_import_4": "Pygmalion.chat karakter (beinn hlekkur eða UUID)",
1379 "char_import_5": "AICharacterCard.com Karakter (beinn hlekkur eða auðkenni)",1379 "char_import_5": "AICharacterCards.com Karakter (beinn hlekkur eða auðkenni)",
1380 "char_import_6": "Beinn PNG hlekkur (sjá",1380 "char_import_6": "Beinn PNG hlekkur (sjá",
1381 "char_import_7": "fyrir leyfilega gestgjafa)",1381 "char_import_7": "fyrir leyfilega gestgjafa)",
1382 "char_import_8": "RisuRealm karakter (beinn hlekkur)",1382 "char_import_8": "RisuRealm karakter (beinn hlekkur)",
public/locales/it-it.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Lorebook di Chub (collegamento diretto o ID)",1376 "char_import_2": "Lorebook di Chub (collegamento diretto o ID)",
1377 "char_import_3": "Carattere JanitorAI (collegamento diretto o UUID)",1377 "char_import_3": "Carattere JanitorAI (collegamento diretto o UUID)",
1378 "char_import_4": "Carattere Pygmalion.chat (collegamento diretto o UUID)",1378 "char_import_4": "Carattere Pygmalion.chat (collegamento diretto o UUID)",
1379 "char_import_5": "Carattere AICharacterCard.com (Link diretto o ID)",1379 "char_import_5": "Carattere AICharacterCards.com (Link diretto o ID)",
1380 "char_import_6": "Collegamento PNG diretto (fare riferimento a",1380 "char_import_6": "Collegamento PNG diretto (fare riferimento a",
1381 "char_import_7": "per gli host consentiti)",1381 "char_import_7": "per gli host consentiti)",
1382 "char_import_8": "Personaggio RisuRealm (collegamento diretto)",1382 "char_import_8": "Personaggio RisuRealm (collegamento diretto)",
public/locales/ja-jp.json+1 -1
@@ -1378,7 +1378,7 @@
1378 "char_import_2": "Chub ロアブック (直接リンクまたは ID)",1378 "char_import_2": "Chub ロアブック (直接リンクまたは ID)",
1379 "char_import_3": "JanitorAI キャラクター (直接リンクまたは UUID)",1379 "char_import_3": "JanitorAI キャラクター (直接リンクまたは UUID)",
1380 "char_import_4": "Pygmalion.chat キャラクター (直接リンクまたは UUID)",1380 "char_import_4": "Pygmalion.chat キャラクター (直接リンクまたは UUID)",
1381 "char_import_5": "AICharacterCard.com キャラクター (直接リンクまたは ID)",1381 "char_import_5": "AICharacterCards.com キャラクター (直接リンクまたは ID)",
1382 "char_import_6": "直接PNGリンク(参照",1382 "char_import_6": "直接PNGリンク(参照",
1383 "char_import_7": "許可されたホストの場合)",1383 "char_import_7": "許可されたホストの場合)",
1384 "char_import_8": "RisuRealm キャラクター (直接リンク)",1384 "char_import_8": "RisuRealm キャラクター (直接リンク)",
public/locales/ko-kr.json+1 -1
@@ -1395,7 +1395,7 @@
1395 "char_import_2": "Chub Lorebook(직접 링크 또는 ID)",1395 "char_import_2": "Chub Lorebook(직접 링크 또는 ID)",
1396 "char_import_3": "JanitorAI 캐릭터(직접 링크 또는 UUID)",1396 "char_import_3": "JanitorAI 캐릭터(직접 링크 또는 UUID)",
1397 "char_import_4": "Pygmalion.chat 문자(직접 링크 또는 UUID)",1397 "char_import_4": "Pygmalion.chat 문자(직접 링크 또는 UUID)",
1398 "char_import_5": "AICharacterCard.com 캐릭터(직접 링크 또는 ID)",1398 "char_import_5": "AICharacterCards.com 캐릭터(직접 링크 또는 ID)",
1399 "char_import_6": "직접 PNG 링크(참조",1399 "char_import_6": "직접 PNG 링크(참조",
1400 "char_import_7": "허용된 호스트의 경우)",1400 "char_import_7": "허용된 호스트의 경우)",
1401 "char_import_8": "RisuRealm 캐릭터 (직접링크)",1401 "char_import_8": "RisuRealm 캐릭터 (직접링크)",
public/locales/nl-nl.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Chub Lorebook (directe link of ID)",1376 "char_import_2": "Chub Lorebook (directe link of ID)",
1377 "char_import_3": "JanitorAI-personage (directe link of UUID)",1377 "char_import_3": "JanitorAI-personage (directe link of UUID)",
1378 "char_import_4": "Pygmalion.chat-teken (directe link of UUID)",1378 "char_import_4": "Pygmalion.chat-teken (directe link of UUID)",
1379 "char_import_5": "AICharacterCard.com-teken (directe link of ID)",1379 "char_import_5": "AICharacterCards.com-teken (directe link of ID)",
1380 "char_import_6": "Directe PNG-link (zie",1380 "char_import_6": "Directe PNG-link (zie",
1381 "char_import_7": "voor toegestane hosts)",1381 "char_import_7": "voor toegestane hosts)",
1382 "char_import_8": "RisuRealm-personage (directe link)",1382 "char_import_8": "RisuRealm-personage (directe link)",
public/locales/pt-pt.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Chub Lorebook (link direto ou ID)",1376 "char_import_2": "Chub Lorebook (link direto ou ID)",
1377 "char_import_3": "Personagem JanitorAI (Link Direto ou UUID)",1377 "char_import_3": "Personagem JanitorAI (Link Direto ou UUID)",
1378 "char_import_4": "Caractere Pygmalion.chat (Link Direto ou UUID)",1378 "char_import_4": "Caractere Pygmalion.chat (Link Direto ou UUID)",
1379 "char_import_5": "Personagem AICharacterCard.com (link direto ou ID)",1379 "char_import_5": "Personagem AICharacterCards.com (link direto ou ID)",
1380 "char_import_6": "Link PNG direto (consulte",1380 "char_import_6": "Link PNG direto (consulte",
1381 "char_import_7": "para hosts permitidos)",1381 "char_import_7": "para hosts permitidos)",
1382 "char_import_8": "Personagem RisuRealm (link direto)",1382 "char_import_8": "Personagem RisuRealm (link direto)",
public/locales/ru-ru.json+1 -1
@@ -966,7 +966,7 @@
966 "char_import_2": "Лорбук с Chub (прямая ссылка или ID)",966 "char_import_2": "Лорбук с Chub (прямая ссылка или ID)",
967 "char_import_3": "Персонаж с JanitorAI (прямая ссылка или UUID)",967 "char_import_3": "Персонаж с JanitorAI (прямая ссылка или UUID)",
968 "char_import_4": "Персонаж с Pygmalion.chat (прямая ссылка или UUID)",968 "char_import_4": "Персонаж с Pygmalion.chat (прямая ссылка или UUID)",
969 "char_import_5": "Персонаж с AICharacterCard.com (прямая ссылка или ID)",969 "char_import_5": "Персонаж с AICharacterCards.com (прямая ссылка или ID)",
970 "char_import_6": "Прямая ссылка на PNG-файл (чтобы узнать список разрешённых хостов, загляните в",970 "char_import_6": "Прямая ссылка на PNG-файл (чтобы узнать список разрешённых хостов, загляните в",
971 "char_import_7": ")",971 "char_import_7": ")",
972 "Grammar String": "Грамматика",972 "Grammar String": "Грамматика",
public/locales/uk-ua.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Chub Lorebook (пряме посилання або ID)",1376 "char_import_2": "Chub Lorebook (пряме посилання або ID)",
1377 "char_import_3": "Символ JanitorAI (пряме посилання або UUID)",1377 "char_import_3": "Символ JanitorAI (пряме посилання або UUID)",
1378 "char_import_4": "Символ Pygmalion.chat (пряме посилання або UUID)",1378 "char_import_4": "Символ Pygmalion.chat (пряме посилання або UUID)",
1379 "char_import_5": "Символ AICharacterCard.com (пряме посилання або ідентифікатор)",1379 "char_import_5": "Символ AICharacterCards.com (пряме посилання або ідентифікатор)",
1380 "char_import_6": "Пряме посилання на PNG (див",1380 "char_import_6": "Пряме посилання на PNG (див",
1381 "char_import_7": "для дозволених хостів)",1381 "char_import_7": "для дозволених хостів)",
1382 "char_import_8": "Персонаж RisuRealm (пряме посилання)",1382 "char_import_8": "Персонаж RisuRealm (пряме посилання)",
public/locales/vi-vn.json+1 -1
@@ -1376,7 +1376,7 @@
1376 "char_import_2": "Chub (Nhập URL trực tiếp hoặc ID)",1376 "char_import_2": "Chub (Nhập URL trực tiếp hoặc ID)",
1377 "char_import_3": "JanitorAI (Nhập URL trực tiếp hoặc UUID)",1377 "char_import_3": "JanitorAI (Nhập URL trực tiếp hoặc UUID)",
1378 "char_import_4": "Pygmalion.chat (Nhập URL trực tiếp hoặc UUID)",1378 "char_import_4": "Pygmalion.chat (Nhập URL trực tiếp hoặc UUID)",
1379 "char_import_5": "AICharacterCard.com (Nhập URL trực tiếp hoặc ID)",1379 "char_import_5": "AICharacterCards.com (Nhập URL trực tiếp hoặc ID)",
1380 "char_import_6": "Nhập PNG trực tiếp (tham khảo",1380 "char_import_6": "Nhập PNG trực tiếp (tham khảo",
1381 "char_import_7": "đối với các máy chủ được phép)",1381 "char_import_7": "đối với các máy chủ được phép)",
1382 "char_import_8": "RisuRealm (URL trực tiếp)",1382 "char_import_8": "RisuRealm (URL trực tiếp)",
public/locales/zh-cn.json+1 -1
@@ -1829,7 +1829,7 @@
1829 "char_import_2": "Chub 知识书(直链或ID)",1829 "char_import_2": "Chub 知识书(直链或ID)",
1830 "char_import_3": "JanitorAI 角色(直链或UUID)",1830 "char_import_3": "JanitorAI 角色(直链或UUID)",
1831 "char_import_4": "Pygmalion.chat 角色(直链或UUID)",1831 "char_import_4": "Pygmalion.chat 角色(直链或UUID)",
1832 "char_import_5": "AICharacterCard.com 角色(直链或ID)",1832 "char_import_5": "AICharacterCards.com 角色(直链或ID)",
1833 "char_import_6": "被允许的PNG直链(请参阅",1833 "char_import_6": "被允许的PNG直链(请参阅",
1834 "char_import_7": ")",1834 "char_import_7": ")",
1835 "char_import_8": "RisuRealm 角色(直链)",1835 "char_import_8": "RisuRealm 角色(直链)",
public/locales/zh-tw.json+1 -1
@@ -1381,7 +1381,7 @@
1381 "char_import_2": "Chub Lorebook(直接連結或 ID)",1381 "char_import_2": "Chub Lorebook(直接連結或 ID)",
1382 "char_import_3": "JanitorAI 角色(直接連結或 ID)",1382 "char_import_3": "JanitorAI 角色(直接連結或 ID)",
1383 "char_import_4": "Pygmalion.chat 角色(直接連結或 ID)",1383 "char_import_4": "Pygmalion.chat 角色(直接連結或 ID)",
1384 "char_import_5": "AICharacterCard.com 角色(直接連結或 ID)",1384 "char_import_5": "AICharacterCards.com 角色(直接連結或 ID)",
1385 "char_import_6": "直接 PNG 連結(請參閱",1385 "char_import_6": "直接 PNG 連結(請參閱",
1386 "char_import_7": "對於允許的主機)",1386 "char_import_7": "對於允許的主機)",
1387 "char_import_8": "RisuRealm角色(直接連結)",1387 "char_import_8": "RisuRealm角色(直接連結)",
public/scripts/group-chats.js+9 -0
@@ -1368,6 +1368,15 @@ function getGroupCharacterBlock(character) {
1368 template.find('.ch_fav').val(isFav);1368 template.find('.ch_fav').val(isFav);
1369 template.toggleClass('is_fav', isFav);1369 template.toggleClass('is_fav', isFav);
13701370
1371 const auxFieldName = power_user.aux_field || 'character_version';
1372 const auxFieldValue = (character.data && character.data[auxFieldName]) || '';
1373 if (auxFieldValue) {
1374 template.find('.character_version').text(auxFieldValue);
1375 }
1376 else {
1377 template.find('.character_version').hide();
1378 }
1379
1371 let queuePosition = groupChatQueueOrder.get(character.avatar);1380 let queuePosition = groupChatQueueOrder.get(character.avatar);
1372 if (queuePosition) {1381 if (queuePosition) {
1373 template.find('.queue_position').text(queuePosition);1382 template.find('.queue_position').text(queuePosition);
public/scripts/templates/importCharacters.html+1 -1
@@ -7,7 +7,7 @@
7 <li><span data-i18n="char_import_2">Chub Lorebook (Direct Link or ID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>lorebooks/bartleby/example-lorebook</tt></li>7 <li><span data-i18n="char_import_2">Chub Lorebook (Direct Link or ID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>lorebooks/bartleby/example-lorebook</tt></li>
8 <li><span data-i18n="char_import_3">JanitorAI Character (Direct Link or UUID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>ddd1498a-a370-4136-b138-a8cd9461fdfe_character-aqua-the-useless-goddess</tt></li>8 <li><span data-i18n="char_import_3">JanitorAI Character (Direct Link or UUID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>ddd1498a-a370-4136-b138-a8cd9461fdfe_character-aqua-the-useless-goddess</tt></li>
9 <li><span data-i18n="char_import_4">Pygmalion.chat Character (Direct Link or UUID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>a7ca95a1-0c88-4e23-91b3-149db1e78ab9</tt></li>9 <li><span data-i18n="char_import_4">Pygmalion.chat Character (Direct Link or UUID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>a7ca95a1-0c88-4e23-91b3-149db1e78ab9</tt></li>
10 <li><span data-i18n="char_import_5">AICharacterCard.com Character (Direct Link or ID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>AICC/aicharcards/the-game-master</tt></li>10 <li><span data-i18n="char_import_5">AICharacterCards.com Character (Direct Link or ID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>AICC/aicharcards/the-game-master</tt></li>
11 <li><span data-i18n="char_import_6">Direct PNG Link (refer to</span> <code>config.yaml</code><span data-i18n="char_import_7"> for allowed hosts)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://files.catbox.moe/notarealfile.png</tt></li>11 <li><span data-i18n="char_import_6">Direct PNG Link (refer to</span> <code>config.yaml</code><span data-i18n="char_import_7"> for allowed hosts)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://files.catbox.moe/notarealfile.png</tt></li>
12 <li><span data-i18n="char_import_8">RisuRealm Character (Direct Link)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://realm.risuai.net/character/3ca54c71-6efe-46a2-b9d0-4f62df23d712</tt></li>12 <li><span data-i18n="char_import_8">RisuRealm Character (Direct Link)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://realm.risuai.net/character/3ca54c71-6efe-46a2-b9d0-4f62df23d712</tt></li>
13 </ul>13 </ul>
public/style.css+8 -1
@@ -2928,7 +2928,7 @@ input[type=search]:focus::-webkit-search-cancel-button {
2928 position: relative;2928 position: relative;
2929}2929}
29302930
2931#rm_print_characters_block .ch_name,2931.character_name_block .ch_name,
2932.avatar-container .ch_name {2932.avatar-container .ch_name {
2933 flex: 1 1 auto;2933 flex: 1 1 auto;
2934 white-space: nowrap;2934 white-space: nowrap;
@@ -2938,6 +2938,13 @@ input[type=search]:focus::-webkit-search-cancel-button {
2938 display: block;2938 display: block;
2939}2939}
29402940
2941.character_name_block .character_version {
2942 text-overflow: ellipsis;
2943 overflow: hidden;
2944 text-wrap: nowrap;
2945 max-width: 50%;
2946}
2947
2941#rm_print_characters_block .character_name_block> :last-child {2948#rm_print_characters_block .character_name_block> :last-child {
2942 flex: 0 100000 auto;2949 flex: 0 100000 auto;
2943 /* Force shrinking first */2950 /* Force shrinking first */
server.js+39 -18
@@ -18,10 +18,9 @@ import { hideBin } from 'yargs/helpers';
1818
19// express/server related library imports19// express/server related library imports
20import cors from 'cors';20import cors from 'cors';
21import { doubleCsrf } from 'csrf-csrf';21import { csrfSync } from 'csrf-sync';
22import express from 'express';22import express from 'express';
23import compression from 'compression';23import compression from 'compression';
24import cookieParser from 'cookie-parser';
25import cookieSession from 'cookie-session';24import cookieSession from 'cookie-session';
26import multer from 'multer';25import multer from 'multer';
27import responseTime from 'response-time';26import responseTime from 'response-time';
@@ -40,7 +39,6 @@ util.inspect.defaultOptions.depth = 4;
40import { loadPlugins } from './src/plugin-loader.js';39import { loadPlugins } from './src/plugin-loader.js';
41import {40import {
42 initUserStorage,41 initUserStorage,
43 getCsrfSecret,
44 getCookieSecret,42 getCookieSecret,
45 getCookieSessionName,43 getCookieSessionName,
46 getAllEnabledUsers,44 getAllEnabledUsers,
@@ -67,6 +65,7 @@ import {
67 forwardFetchResponse,65 forwardFetchResponse,
68 removeColorFormatting,66 removeColorFormatting,
69 getSeparator,67 getSeparator,
68 safeReadFileSync,
70} from './src/util.js';69} from './src/util.js';
71import { UPLOADS_DIRECTORY } from './src/constants.js';70import { UPLOADS_DIRECTORY } from './src/constants.js';
72import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';71import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
@@ -347,8 +346,8 @@ if (enableCorsProxy) {
347}346}
348347
349function getSessionCookieAge() {348function getSessionCookieAge() {
350 // Defaults to 24 hours in seconds if not set349 // Defaults to "no expiration" if not set
351 const configValue = getConfigValue('sessionTimeout', 24 * 60 * 60);350 const configValue = getConfigValue('sessionTimeout', -1);
352351
353 // Convert to milliseconds352 // Convert to milliseconds
354 if (configValue > 0) {353 if (configValue > 0) {
@@ -377,27 +376,38 @@ app.use(setUserDataMiddleware);
377376
378// CSRF Protection //377// CSRF Protection //
379if (!disableCsrf) {378if (!disableCsrf) {
380 const COOKIES_SECRET = getCookieSecret();379 const csrfSyncProtection = csrfSync({
381380 getTokenFromState: (req) => {
382 const { generateToken, doubleCsrfProtection } = doubleCsrf({381 if (!req.session) {
383 getSecret: getCsrfSecret,382 console.error('(CSRF error) getTokenFromState: Session object not initialized');
384 cookieName: 'X-CSRF-Token',383 return;
385 cookieOptions: {384 }
386 sameSite: 'strict',385 return req.session.csrfToken;
387 secure: false,386 },
387 getTokenFromRequest: (req) => {
388 return req.headers['x-csrf-token']?.toString();
389 },
390 storeTokenInState: (req, token) => {
391 if (!req.session) {
392 console.error('(CSRF error) storeTokenInState: Session object not initialized');
393 return;
394 }
395 req.session.csrfToken = token;
388 },396 },
389 size: 64,397 size: 32,
390 getTokenFromRequest: (req) => req.headers['x-csrf-token'],
391 });398 });
392399
393 app.get('/csrf-token', (req, res) => {400 app.get('/csrf-token', (req, res) => {
394 res.json({401 res.json({
395 'token': generateToken(res, req),402 'token': csrfSyncProtection.generateToken(req),
396 });403 });
397 });404 });
398405
399 app.use(cookieParser(COOKIES_SECRET));406 // Customize the error message
400 app.use(doubleCsrfProtection);407 csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.');
408 csrfSyncProtection.invalidCsrfTokenError.stack = undefined;
409
410 app.use(csrfSyncProtection.csrfSynchronisedProtection);
401} else {411} else {
402 console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');412 console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
403 app.get('/csrf-token', (req, res) => {413 app.get('/csrf-token', (req, res) => {
@@ -921,6 +931,16 @@ async function verifySecuritySettings() {
921 }931 }
922}932}
923933
934/**
935 * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
936 */
937function apply404Middleware() {
938 const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? '';
939 app.use((req, res) => {
940 res.status(404).send(notFoundWebpage);
941 });
942}
943
924// User storage module needs to be initialized before starting the server944// User storage module needs to be initialized before starting the server
925initUserStorage(dataRoot)945initUserStorage(dataRoot)
926 .then(ensurePublicDirectoriesExist)946 .then(ensurePublicDirectoriesExist)
@@ -928,4 +948,5 @@ initUserStorage(dataRoot)
928 .then(migrateSystemPrompts)948 .then(migrateSystemPrompts)
929 .then(verifySecuritySettings)949 .then(verifySecuritySettings)
930 .then(preSetupTasks)950 .then(preSetupTasks)
951 .then(apply404Middleware)
931 .finally(startServer);952 .finally(startServer);
src/endpoints/avatars.js+2 -1
@@ -9,6 +9,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
9import { jsonParser, urlencodedParser } from '../express-common.js';9import { jsonParser, urlencodedParser } from '../express-common.js';
10import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';10import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
11import { getImages, tryParse } from '../util.js';11import { getImages, tryParse } from '../util.js';
12import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1213
13export const router = express.Router();14export const router = express.Router();
1415
@@ -17,7 +18,7 @@ router.post('/get', jsonParser, function (request, response) {
17 response.send(JSON.stringify(images));18 response.send(JSON.stringify(images));
18});19});
1920
20router.post('/delete', jsonParser, function (request, response) {21router.post('/delete', jsonParser, getFileNameValidationFunction('avatar'), function (request, response) {
21 if (!request.body) return response.sendStatus(400);22 if (!request.body) return response.sendStatus(400);
2223
23 if (request.body.avatar !== sanitize(request.body.avatar)) {24 if (request.body.avatar !== sanitize(request.body.avatar)) {
src/endpoints/backends/chat-completions.js+10 -0
@@ -37,6 +37,8 @@ import {
37 getTiktokenTokenizer,37 getTiktokenTokenizer,
38 sentencepieceTokenizers,38 sentencepieceTokenizers,
39 TEXT_COMPLETION_MODELS,39 TEXT_COMPLETION_MODELS,
40 webTokenizers,
41 getWebTokenizer,
40} from '../tokenizers.js';42} from '../tokenizers.js';
4143
42const API_OPENAI = 'https://api.openai.com/v1';44const API_OPENAI = 'https://api.openai.com/v1';
@@ -863,6 +865,14 @@ router.post('/bias', jsonParser, async function (request, response) {
863 return response.send({});865 return response.send({});
864 }866 }
865 encodeFunction = (text) => new Uint32Array(instance.encodeIds(text));867 encodeFunction = (text) => new Uint32Array(instance.encodeIds(text));
868 } else if (webTokenizers.includes(model)) {
869 const tokenizer = getWebTokenizer(model);
870 const instance = await tokenizer?.get();
871 if (!instance) {
872 console.warn('Tokenizer not initialized:', model);
873 return response.send({});
874 }
875 encodeFunction = (text) => new Uint32Array(instance.encode(text));
866 } else {876 } else {
867 const tokenizer = getTiktokenTokenizer(model);877 const tokenizer = getTiktokenTokenizer(model);
868 encodeFunction = (tokenizer.encode.bind(tokenizer));878 encodeFunction = (tokenizer.encode.bind(tokenizer));
src/endpoints/backgrounds.js+2 -1
@@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename';
7import { jsonParser, urlencodedParser } from '../express-common.js';7import { jsonParser, urlencodedParser } from '../express-common.js';
8import { invalidateThumbnail } from './thumbnails.js';8import { invalidateThumbnail } from './thumbnails.js';
9import { getImages } from '../util.js';9import { getImages } from '../util.js';
10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1011
11export const router = express.Router();12export const router = express.Router();
1213
@@ -15,7 +16,7 @@ router.post('/all', jsonParser, function (request, response) {
15 response.send(JSON.stringify(images));16 response.send(JSON.stringify(images));
16});17});
1718
18router.post('/delete', jsonParser, function (request, response) {19router.post('/delete', jsonParser, getFileNameValidationFunction('bg'), function (request, response) {
19 if (!request.body) return response.sendStatus(400);20 if (!request.body) return response.sendStatus(400);
2021
21 if (request.body.bg !== sanitize(request.body.bg)) {22 if (request.body.bg !== sanitize(request.body.bg)) {
src/endpoints/characters.js+21 -14
@@ -14,6 +14,7 @@ import jimp from 'jimp';
1414
15import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';15import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
16import { jsonParser, urlencodedParser } from '../express-common.js';16import { jsonParser, urlencodedParser } from '../express-common.js';
17import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
17import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue } from '../util.js';18import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue } from '../util.js';
18import { TavernCardValidator } from '../validator/TavernCardValidator.js';19import { TavernCardValidator } from '../validator/TavernCardValidator.js';
19import { parse, write } from '../character-card-parser.js';20import { parse, write } from '../character-card-parser.js';
@@ -73,12 +74,18 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
73 * Read the image, resize, and save it as a PNG into the buffer.74 * Read the image, resize, and save it as a PNG into the buffer.
74 * @returns {Promise<Buffer>} Image buffer75 * @returns {Promise<Buffer>} Image buffer
75 */76 */
76 function getInputImage() {77 async function getInputImage() {
77 if (Buffer.isBuffer(inputFile)) {78 try {
78 return parseImageBuffer(inputFile, crop);79 if (Buffer.isBuffer(inputFile)) {
79 }80 return await parseImageBuffer(inputFile, crop);
81 }
8082
81 return tryReadImage(inputFile, crop);83 return await tryReadImage(inputFile, crop);
84 } catch (error) {
85 const message = Buffer.isBuffer(inputFile) ? 'Failed to read image buffer.' : `Failed to read image: ${inputFile}.`;
86 console.warn(message, 'Using a fallback image.', error);
87 return await fs.promises.readFile(defaultAvatarPath);
88 }
82 }89 }
8390
84 const inputImage = await getInputImage();91 const inputImage = await getInputImage();
@@ -756,7 +763,7 @@ router.post('/create', urlencodedParser, async function (request, response) {
756 }763 }
757});764});
758765
759router.post('/rename', jsonParser, async function (request, response) {766router.post('/rename', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
760 if (!request.body.avatar_url || !request.body.new_name) {767 if (!request.body.avatar_url || !request.body.new_name) {
761 return response.sendStatus(400);768 return response.sendStatus(400);
762 }769 }
@@ -803,7 +810,7 @@ router.post('/rename', jsonParser, async function (request, response) {
803 }810 }
804});811});
805812
806router.post('/edit', urlencodedParser, async function (request, response) {813router.post('/edit', urlencodedParser, validateAvatarUrlMiddleware, async function (request, response) {
807 if (!request.body) {814 if (!request.body) {
808 console.error('Error: no response body detected');815 console.error('Error: no response body detected');
809 response.status(400).send('Error: no response body detected');816 response.status(400).send('Error: no response body detected');
@@ -852,7 +859,7 @@ router.post('/edit', urlencodedParser, async function (request, response) {
852 * @param {Object} response - The HTTP response object.859 * @param {Object} response - The HTTP response object.
853 * @returns {void}860 * @returns {void}
854 */861 */
855router.post('/edit-attribute', jsonParser, async function (request, response) {862router.post('/edit-attribute', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
856 console.log(request.body);863 console.log(request.body);
857 if (!request.body) {864 if (!request.body) {
858 console.error('Error: no response body detected');865 console.error('Error: no response body detected');
@@ -898,7 +905,7 @@ router.post('/edit-attribute', jsonParser, async function (request, response) {
898 *905 *
899 * @returns {void}906 * @returns {void}
900 * */907 * */
901router.post('/merge-attributes', jsonParser, async function (request, response) {908router.post('/merge-attributes', jsonParser, getFileNameValidationFunction('avatar'), async function (request, response) {
902 try {909 try {
903 const update = request.body;910 const update = request.body;
904 const avatarPath = path.join(request.user.directories.characters, update.avatar);911 const avatarPath = path.join(request.user.directories.characters, update.avatar);
@@ -929,7 +936,7 @@ router.post('/merge-attributes', jsonParser, async function (request, response)
929 }936 }
930});937});
931938
932router.post('/delete', jsonParser, async function (request, response) {939router.post('/delete', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
933 if (!request.body || !request.body.avatar_url) {940 if (!request.body || !request.body.avatar_url) {
934 return response.sendStatus(400);941 return response.sendStatus(400);
935 }942 }
@@ -992,7 +999,7 @@ router.post('/all', jsonParser, async function (request, response) {
992 }999 }
993});1000});
9941001
995router.post('/get', jsonParser, async function (request, response) {1002router.post('/get', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
996 try {1003 try {
997 if (!request.body) return response.sendStatus(400);1004 if (!request.body) return response.sendStatus(400);
998 const item = request.body.avatar_url;1005 const item = request.body.avatar_url;
@@ -1011,7 +1018,7 @@ router.post('/get', jsonParser, async function (request, response) {
1011 }1018 }
1012});1019});
10131020
1014router.post('/chats', jsonParser, async function (request, response) {1021router.post('/chats', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
1015 if (!request.body) return response.sendStatus(400);1022 if (!request.body) return response.sendStatus(400);
10161023
1017 const characterDirectory = (request.body.avatar_url).replace('.png', '');1024 const characterDirectory = (request.body.avatar_url).replace('.png', '');
@@ -1160,7 +1167,7 @@ router.post('/import', urlencodedParser, async function (request, response) {
1160 }1167 }
1161});1168});
11621169
1163router.post('/duplicate', jsonParser, async function (request, response) {1170router.post('/duplicate', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
1164 try {1171 try {
1165 if (!request.body.avatar_url) {1172 if (!request.body.avatar_url) {
1166 console.log('avatar URL not found in request body');1173 console.log('avatar URL not found in request body');
@@ -1207,7 +1214,7 @@ router.post('/duplicate', jsonParser, async function (request, response) {
1207 }1214 }
1208});1215});
12091216
1210router.post('/export', jsonParser, async function (request, response) {1217router.post('/export', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
1211 try {1218 try {
1212 if (!request.body.format || !request.body.avatar_url) {1219 if (!request.body.format || !request.body.avatar_url) {
1213 return response.sendStatus(400);1220 return response.sendStatus(400);
src/endpoints/chats.js+8 -7
@@ -9,6 +9,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
9import _ from 'lodash';9import _ from 'lodash';
1010
11import { jsonParser, urlencodedParser } from '../express-common.js';11import { jsonParser, urlencodedParser } from '../express-common.js';
12import validateAvatarUrlMiddleware from '../middleware/validateFileName.js';
12import {13import {
13 getConfigValue,14 getConfigValue,
14 humanizedISO8601DateTime,15 humanizedISO8601DateTime,
@@ -294,7 +295,7 @@ function importRisuChat(userName, characterName, jsonData) {
294295
295export const router = express.Router();296export const router = express.Router();
296297
297router.post('/save', jsonParser, function (request, response) {298router.post('/save', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
298 try {299 try {
299 const directoryName = String(request.body.avatar_url).replace('.png', '');300 const directoryName = String(request.body.avatar_url).replace('.png', '');
300 const chatData = request.body.chat;301 const chatData = request.body.chat;
@@ -310,7 +311,7 @@ router.post('/save', jsonParser, function (request, response) {
310 }311 }
311});312});
312313
313router.post('/get', jsonParser, function (request, response) {314router.post('/get', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
314 try {315 try {
315 const dirName = String(request.body.avatar_url).replace('.png', '');316 const dirName = String(request.body.avatar_url).replace('.png', '');
316 const directoryPath = path.join(request.user.directories.chats, dirName);317 const directoryPath = path.join(request.user.directories.chats, dirName);
@@ -347,7 +348,7 @@ router.post('/get', jsonParser, function (request, response) {
347});348});
348349
349350
350router.post('/rename', jsonParser, async function (request, response) {351router.post('/rename', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
351 if (!request.body || !request.body.original_file || !request.body.renamed_file) {352 if (!request.body || !request.body.original_file || !request.body.renamed_file) {
352 return response.sendStatus(400);353 return response.sendStatus(400);
353 }354 }
@@ -372,7 +373,7 @@ router.post('/rename', jsonParser, async function (request, response) {
372 return response.send({ ok: true, sanitizedFileName });373 return response.send({ ok: true, sanitizedFileName });
373});374});
374375
375router.post('/delete', jsonParser, function (request, response) {376router.post('/delete', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
376 const dirName = String(request.body.avatar_url).replace('.png', '');377 const dirName = String(request.body.avatar_url).replace('.png', '');
377 const fileName = String(request.body.chatfile);378 const fileName = String(request.body.chatfile);
378 const filePath = path.join(request.user.directories.chats, dirName, sanitize(fileName));379 const filePath = path.join(request.user.directories.chats, dirName, sanitize(fileName));
@@ -388,7 +389,7 @@ router.post('/delete', jsonParser, function (request, response) {
388 return response.send('ok');389 return response.send('ok');
389});390});
390391
391router.post('/export', jsonParser, async function (request, response) {392router.post('/export', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
392 if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) {393 if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) {
393 return response.sendStatus(400);394 return response.sendStatus(400);
394 }395 }
@@ -478,7 +479,7 @@ router.post('/group/import', urlencodedParser, function (request, response) {
478 }479 }
479});480});
480481
481router.post('/import', urlencodedParser, function (request, response) {482router.post('/import', urlencodedParser, validateAvatarUrlMiddleware, function (request, response) {
482 if (!request.body) return response.sendStatus(400);483 if (!request.body) return response.sendStatus(400);
483484
484 const format = request.body.file_type;485 const format = request.body.file_type;
@@ -626,7 +627,7 @@ router.post('/group/save', jsonParser, (request, response) => {
626 return response.send({ ok: true });627 return response.send({ ok: true });
627});628});
628629
629router.post('/search', jsonParser, function (request, response) {630router.post('/search', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
630 try {631 try {
631 const { query, avatar_url, group_id } = request.body;632 const { query, avatar_url, group_id } = request.body;
632 let chatFiles = [];633 let chatFiles = [];
src/endpoints/settings.js+3 -2
@@ -9,6 +9,7 @@ import { SETTINGS_FILE } from '../constants.js';
9import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';9import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';
10import { jsonParser } from '../express-common.js';10import { jsonParser } from '../express-common.js';
11import { getAllUserHandles, getUserDirectories } from '../users.js';11import { getAllUserHandles, getUserDirectories } from '../users.js';
12import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1213
13const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true);14const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true);
14const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true);15const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true);
@@ -296,7 +297,7 @@ router.post('/get-snapshots', jsonParser, async (request, response) => {
296 }297 }
297});298});
298299
299router.post('/load-snapshot', jsonParser, async (request, response) => {300router.post('/load-snapshot', jsonParser, getFileNameValidationFunction('name'), async (request, response) => {
300 try {301 try {
301 const userFilesPattern = getFilePrefix(request.user.profile.handle);302 const userFilesPattern = getFilePrefix(request.user.profile.handle);
302303
@@ -330,7 +331,7 @@ router.post('/make-snapshot', jsonParser, async (request, response) => {
330 }331 }
331});332});
332333
333router.post('/restore-snapshot', jsonParser, async (request, response) => {334router.post('/restore-snapshot', jsonParser, getFileNameValidationFunction('name'), async (request, response) => {
334 try {335 try {
335 const userFilesPattern = getFilePrefix(request.user.profile.handle);336 const userFilesPattern = getFilePrefix(request.user.profile.handle);
336337
src/endpoints/tokenizers.js+42 -0
@@ -238,6 +238,15 @@ export const sentencepieceTokenizers = [
238 'jamba',238 'jamba',
239];239];
240240
241export const webTokenizers = [
242 'claude',
243 'llama3',
244 'command-r',
245 'qwen2',
246 'nemo',
247 'deepseek',
248];
249
241/**250/**
242 * Gets the Sentencepiece tokenizer by the model name.251 * Gets the Sentencepiece tokenizer by the model name.
243 * @param {string} model Sentencepiece model name252 * @param {string} model Sentencepiece model name
@@ -276,6 +285,39 @@ export function getSentencepiceTokenizer(model) {
276}285}
277286
278/**287/**
288 * Gets the Web tokenizer by the model name.
289 * @param {string} model Web tokenizer model name
290 * @returns {WebTokenizer|null} Web tokenizer
291 */
292export function getWebTokenizer(model) {
293 if (model.includes('llama3')) {
294 return llama3_tokenizer;
295 }
296
297 if (model.includes('claude')) {
298 return claude_tokenizer;
299 }
300
301 if (model.includes('command-r')) {
302 return commandTokenizer;
303 }
304
305 if (model.includes('qwen2')) {
306 return qwen2Tokenizer;
307 }
308
309 if (model.includes('nemo')) {
310 return nemoTokenizer;
311 }
312
313 if (model.includes('deepseek')) {
314 return deepseekTokenizer;
315 }
316
317 return null;
318}
319
320/**
279 * Counts the token ids for the given text using the Sentencepiece tokenizer.321 * Counts the token ids for the given text using the Sentencepiece tokenizer.
280 * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer322 * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
281 * @param {string} text Text to tokenize323 * @param {string} text Text to tokenize
src/endpoints/users-private.js+1 -0
@@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => {
23 }23 }
2424
25 request.session.handle = null;25 request.session.handle = null;
26 request.session.csrfToken = null;
26 request.session = null;27 request.session = null;
27 return response.sendStatus(204);28 return response.sendStatus(204);
28 } catch (error) {29 } catch (error) {
src/middleware/basicAuth.js+7 -6
@@ -5,17 +5,18 @@
5import { Buffer } from 'node:buffer';5import { Buffer } from 'node:buffer';
6import storage from 'node-persist';6import storage from 'node-persist';
7import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';7import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
8import { getConfig, getConfigValue } from '../util.js';8import { getConfig, getConfigValue, safeReadFileSync } from '../util.js';
99
10const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false);10const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false);
11const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);11const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
1212
13const unauthorizedResponse = (res) => {
14 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
15 return res.status(401).send('Authentication required');
16};
17
18const basicAuthMiddleware = async function (request, response, callback) {13const basicAuthMiddleware = async function (request, response, callback) {
14 const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';
15 const unauthorizedResponse = (res) => {
16 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
17 return res.status(401).send(unauthorizedWebpage);
18 };
19
19 const config = getConfig();20 const config = getConfig();
20 const authHeader = request.headers.authorization;21 const authHeader = request.headers.authorization;
2122
src/middleware/validateFileName.js+34 -0
@@ -0,0 +1,34 @@
1import path from 'node:path';
2
3/**
4 * Gets a middleware function that validates the field in the request body.
5 * @param {string} fieldName Field name
6 * @returns {import('express').RequestHandler} Middleware function
7 */
8export function getFileNameValidationFunction(fieldName) {
9 /**
10 * Validates the field in the request body.
11 * @param {import('express').Request} req Request object
12 * @param {import('express').Response} res Response object
13 * @param {import('express').NextFunction} next Next middleware
14 */
15 return function validateAvatarUrlMiddleware(req, res, next) {
16 if (req.body && fieldName in req.body && typeof req.body[fieldName] === 'string') {
17 const forbiddenRegExp = path.sep === '/' ? /[/\x00]/ : /[/\x00\\]/;
18 if (forbiddenRegExp.test(req.body[fieldName])) {
19 console.error('An error occurred while validating the request body', {
20 handle: req.user.profile.handle,
21 path: req.originalUrl,
22 field: fieldName,
23 value: req.body[fieldName],
24 });
25 return res.sendStatus(400);
26 }
27 }
28
29 next();
30 };
31}
32
33const avatarUrlValidationFunction = getFileNameValidationFunction('avatar_url');
34export default avatarUrlValidationFunction;
src/middleware/whitelist.js+16 -5
@@ -1,10 +1,11 @@
1import path from 'node:path';1import path from 'node:path';
2import fs from 'node:fs';2import fs from 'node:fs';
3import process from 'node:process';3import process from 'node:process';
4import Handlebars from 'handlebars';
4import ipMatching from 'ip-matching';5import ipMatching from 'ip-matching';
56
6import { getIpFromRequest } from '../express-common.js';7import { getIpFromRequest } from '../express-common.js';
7import { color, getConfigValue } from '../util.js';8import { color, getConfigValue, safeReadFileSync } from '../util.js';
89
9const whitelistPath = path.join(process.cwd(), './whitelist.txt');10const whitelistPath = path.join(process.cwd(), './whitelist.txt');
10const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);11const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
@@ -52,12 +53,16 @@ function getForwardedIp(req) {
52 * @returns {import('express').RequestHandler} The middleware function53 * @returns {import('express').RequestHandler} The middleware function
53 */54 */
54export default function whitelistMiddleware(whitelistMode, listen) {55export default function whitelistMiddleware(whitelistMode, listen) {
56 const forbiddenWebpage = Handlebars.compile(
57 safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
58 );
59
55 return function (req, res, next) {60 return function (req, res, next) {
56 const clientIp = getIpFromRequest(req);61 const clientIp = getIpFromRequest(req);
57 const forwardedIp = getForwardedIp(req);62 const forwardedIp = getForwardedIp(req);
63 const userAgent = req.headers['user-agent'];
5864
59 if (listen && !knownIPs.has(clientIp)) {65 if (listen && !knownIPs.has(clientIp)) {
60 const userAgent = req.headers['user-agent'];
61 console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));66 console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
62 knownIPs.add(clientIp);67 knownIPs.add(clientIp);
6368
@@ -76,9 +81,15 @@ export default function whitelistMiddleware(whitelistMode, listen) {
76 || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))81 || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
77 ) {82 ) {
78 // Log the connection attempt with real IP address83 // Log the connection attempt with real IP address
79 const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp;84 const ipDetails = forwardedIp
80 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'));85 ? `${clientIp} (forwarded from ${forwardedIp})`
81 return res.status(403).send('<b>Forbidden</b>: Connection attempt from <b>' + ipDetails + '</b>. 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.');86 : clientIp;
87 console.log(
88 color.red(
89 `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`,
90 ),
91 );
92 return res.status(403).send(forbiddenWebpage({ ipDetails }));
82 }93 }
83 next();94 next();
84 };95 };
src/users.js+2 -1
@@ -458,7 +458,8 @@ export function getPasswordSalt() {
458 */458 */
459export function getCookieSessionName() {459export function getCookieSessionName() {
460 // Get server hostname and hash it to generate a session suffix460 // Get server hostname and hash it to generate a session suffix
461 const suffix = crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 8);461 const hostname = os.hostname() || 'localhost';
462 const suffix = crypto.createHash('sha256').update(hostname).digest('hex').slice(0, 8);
462 return `session-${suffix}`;463 return `session-${suffix}`;
463}464}
464465
src/util.js+11 -0
@@ -871,3 +871,14 @@ export class MemoryLimitedMap {
871 return this.map[Symbol.iterator]();871 return this.map[Symbol.iterator]();
872 }872 }
873}873}
874
875/**
876 * A 'safe' version of `fs.readFileSync()`. Returns the contents of a file if it exists, falling back to a default value if not.
877 * @param {string} filePath Path of the file to be read.
878 * @param {Parameters<typeof fs.readFileSync>[1]} options Options object to pass through to `fs.readFileSync()` (default: `{ encoding: 'utf-8' }`).
879 * @returns The contents at `filePath` if it exists, or `null` if not.
880 */
881export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
882 if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
883 return null;
884}