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
4545/vectors/
4646/cache/
4747public/css/user.css
48+public/error/
4849/plugins/
4950/data
5051/default/scaffold
@@ -52,3 +53,5 @@ public/scripts/extensions/third-party
5253/certs
5354.aider*
5455.env
56+/StartDev.bat
57+
default/config.yaml+1 -1
@@ -70,7 +70,7 @@ perUserBasicAuth: false
7070## Set to a positive number to expire session after a certain time of inactivity
7171## Set to 0 to expire session when the browser is closed
7272## Set to a negative number to disable session expiration
7373sessionTimeout: 86400-1
7474# Used to sign session cookies. Will be auto-generated if not set
7575cookieSecret: ''
7676# 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 @@
11import { UserDirectoryList, User } from "./src/users";
2+import { CsrfSyncedToken } from "csrf-sync";
23
34declare 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+
422 namespace Express {
523 export interface Request {
624 user: {
@@ -15,11 +33,3 @@ declare global {
1533 */
1634 var DATA_ROOT: string;
1735}
18-
19-declare 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 @@
2626 "cookie-parser": "^1.4.6",
2727 "cookie-session": "^2.1.0",
2828 "cors": "^2.8.5",
2929 "csrf-csrfsync": "^24.20.3",
3030 "diff-match-patch": "^1.0.5",
3131 "dompurify": "^3.1.7",
3232 "droll": "^0.2.1",
@@ -2987,10 +2987,10 @@
29872987 "node": "*"
29882988 }
29892989 },
29902990 "node_modules/csrf-csrfsync": {
29912991 "version": "24.20.43",
29922992 "resolved": "https://registry.npmjs.org/csrf-csrfsync/-/csrf-csrfsync-24.20.43.tgz",
29932993 "integrity": "sha512-LuhBmy5RfRmEfeqeYqgaAuS1eDpVtKZB/Eiec9xiKQLBynJxrGVRdM2yRTwXzltBBzt/YMl1Njo7imzDt6ZT7G/yKh2L9AYsIwSlTPnx2AaxQG7jo4Sm0uXDUzFY8hR59qhDHdjqpW2hojS4oAVIZDzwlMQloIVCTJoDDh0wwA==",
29942994 "license": "ISC",
29952995 "dependencies": {
29962996 "http-errors": "^2.0.0"
package.json+1 -1
@@ -16,7 +16,7 @@
1616 "cookie-parser": "^1.4.6",
1717 "cookie-session": "^2.1.0",
1818 "cors": "^2.8.5",
1919 "csrf-csrfsync": "^24.20.3",
2020 "diff-match-patch": "^1.0.5",
2121 "dompurify": "^3.1.7",
2222 "droll": "^0.2.1",
post-install.js+50 -10
@@ -213,20 +213,60 @@ function addMissingConfigValues() {
213213 * Creates the default config files if they don't exist yet.
214214 */
215215function 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) {
222239 try {
223240 if (!fsdefaultItem.existsSync(type === 'file)') {
224- const defaultFilePath = path.join('./default', path.parse(file).base);
241+ if (!fs.existsSync(defaultItem.productionPath)) {
225242 fs.copyFileSync(defaultFilePath, file);
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+ );
227262 }
228263 } 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+ );
230270 }
231271 }
232272}
public/index.html+4 -1
@@ -6372,7 +6372,10 @@
63726372 <img alt="Avatar" src="" />
63736373 </div>
63746374 <div class="group_member_name">
63756375 <div class="ch_namecharacter_name_block"></div>
6376+ <span class="ch_name"></span>
6377+ <small class="ch_additional_info character_version"></small>
6378+ </div>
63766379 <div class="tags tags_inline"></div>
63776380 </div>
63786381 <input class="ch_fav" value="" hidden />
public/locales/ar-sa.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Chub Lorebook (رابط مباشر أو معرف)",
13771377 "char_import_3": "حرف JanitorAI (رابط مباشر أو UUID)",
13781378 "char_import_4": "حرف Pygmalion.chat (رابط مباشر أو UUID)",
13791379 "char_import_5": "حرف AICharacterCardAICharacterCards.com (رابط مباشر أو معرف)",
13801380 "char_import_6": "رابط PNG المباشر (راجع",
13811381 "char_import_7": "للمضيفين المسموح بهم)",
13821382 "char_import_8": "شخصية RisuRealm (رابط مباشر)",
public/locales/de-de.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Chub Lorebook (Direktlink oder ID)",
13771377 "char_import_3": "JanitorAI-Charakter (Direktlink oder UUID)",
13781378 "char_import_4": "Pygmalion.chat-Charakter (Direktlink oder UUID)",
13791379 "char_import_5": "AICharacterCardAICharacterCards.com-Charakter (Direktlink oder ID)",
13801380 "char_import_6": "Direkter PNG-Link (siehe",
13811381 "char_import_7": "für erlaubte Hosts)",
13821382 "char_import_8": "RisuRealm-Charakter (Direktlink)",
public/locales/es-es.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Chub Lorebook (enlace directo o ID)",
13771377 "char_import_3": "Carácter de JanitorAI (enlace directo o UUID)",
13781378 "char_import_4": "Carácter Pygmalion.chat (enlace directo o UUID)",
13791379 "char_import_5": "Carácter AICharacterCardAICharacterCards.com (enlace directo o ID)",
13801380 "char_import_6": "Enlace PNG directo (consulte",
13811381 "char_import_7": "para hosts permitidos)",
13821382 "char_import_8": "Personaje RisuRealm (Enlace directo)",
public/locales/fr-fr.json+1 -1
@@ -1297,7 +1297,7 @@
12971297 "char_import_2": "Lorebook de Chub (lien direct ou ID)",
12981298 "char_import_3": "Personnage de JanitorAI (lien direct ou UUID)",
12991299 "char_import_4": "Personnage de Pygmalion.chat (lien direct ou UUID)",
13001300 "char_import_5": "Personnage de AICharacterCardAICharacterCards.com (lien direct ou identifiant)",
13011301 "char_import_6": "Lien PNG direct (voir",
13021302 "char_import_7": "pour les hôtes autorisés)",
13031303 "char_import_8": "Personnage de RisuRealm (lien direct)",
public/locales/is-is.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Chub Lorebook (beinn hlekkur eða auðkenni)",
13771377 "char_import_3": "JanitorAI karakter (beinn hlekkur eða UUID)",
13781378 "char_import_4": "Pygmalion.chat karakter (beinn hlekkur eða UUID)",
13791379 "char_import_5": "AICharacterCardAICharacterCards.com Karakter (beinn hlekkur eða auðkenni)",
13801380 "char_import_6": "Beinn PNG hlekkur (sjá",
13811381 "char_import_7": "fyrir leyfilega gestgjafa)",
13821382 "char_import_8": "RisuRealm karakter (beinn hlekkur)",
public/locales/it-it.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Lorebook di Chub (collegamento diretto o ID)",
13771377 "char_import_3": "Carattere JanitorAI (collegamento diretto o UUID)",
13781378 "char_import_4": "Carattere Pygmalion.chat (collegamento diretto o UUID)",
13791379 "char_import_5": "Carattere AICharacterCardAICharacterCards.com (Link diretto o ID)",
13801380 "char_import_6": "Collegamento PNG diretto (fare riferimento a",
13811381 "char_import_7": "per gli host consentiti)",
13821382 "char_import_8": "Personaggio RisuRealm (collegamento diretto)",
public/locales/ja-jp.json+1 -1
@@ -1378,7 +1378,7 @@
13781378 "char_import_2": "Chub ロアブック (直接リンクまたは ID)",
13791379 "char_import_3": "JanitorAI キャラクター (直接リンクまたは UUID)",
13801380 "char_import_4": "Pygmalion.chat キャラクター (直接リンクまたは UUID)",
13811381 "char_import_5": "AICharacterCardAICharacterCards.com キャラクター (直接リンクまたは ID)",
13821382 "char_import_6": "直接PNGリンク(参照",
13831383 "char_import_7": "許可されたホストの場合)",
13841384 "char_import_8": "RisuRealm キャラクター (直接リンク)",
public/locales/ko-kr.json+1 -1
@@ -1395,7 +1395,7 @@
13951395 "char_import_2": "Chub Lorebook(직접 링크 또는 ID)",
13961396 "char_import_3": "JanitorAI 캐릭터(직접 링크 또는 UUID)",
13971397 "char_import_4": "Pygmalion.chat 문자(직접 링크 또는 UUID)",
13981398 "char_import_5": "AICharacterCardAICharacterCards.com 캐릭터(직접 링크 또는 ID)",
13991399 "char_import_6": "직접 PNG 링크(참조",
14001400 "char_import_7": "허용된 호스트의 경우)",
14011401 "char_import_8": "RisuRealm 캐릭터 (직접링크)",
public/locales/nl-nl.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Chub Lorebook (directe link of ID)",
13771377 "char_import_3": "JanitorAI-personage (directe link of UUID)",
13781378 "char_import_4": "Pygmalion.chat-teken (directe link of UUID)",
13791379 "char_import_5": "AICharacterCardAICharacterCards.com-teken (directe link of ID)",
13801380 "char_import_6": "Directe PNG-link (zie",
13811381 "char_import_7": "voor toegestane hosts)",
13821382 "char_import_8": "RisuRealm-personage (directe link)",
public/locales/pt-pt.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Chub Lorebook (link direto ou ID)",
13771377 "char_import_3": "Personagem JanitorAI (Link Direto ou UUID)",
13781378 "char_import_4": "Caractere Pygmalion.chat (Link Direto ou UUID)",
13791379 "char_import_5": "Personagem AICharacterCardAICharacterCards.com (link direto ou ID)",
13801380 "char_import_6": "Link PNG direto (consulte",
13811381 "char_import_7": "para hosts permitidos)",
13821382 "char_import_8": "Personagem RisuRealm (link direto)",
public/locales/ru-ru.json+1 -1
@@ -966,7 +966,7 @@
966966 "char_import_2": "Лорбук с Chub (прямая ссылка или ID)",
967967 "char_import_3": "Персонаж с JanitorAI (прямая ссылка или UUID)",
968968 "char_import_4": "Персонаж с Pygmalion.chat (прямая ссылка или UUID)",
969969 "char_import_5": "Персонаж с AICharacterCardAICharacterCards.com (прямая ссылка или ID)",
970970 "char_import_6": "Прямая ссылка на PNG-файл (чтобы узнать список разрешённых хостов, загляните в",
971971 "char_import_7": ")",
972972 "Grammar String": "Грамматика",
public/locales/uk-ua.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Chub Lorebook (пряме посилання або ID)",
13771377 "char_import_3": "Символ JanitorAI (пряме посилання або UUID)",
13781378 "char_import_4": "Символ Pygmalion.chat (пряме посилання або UUID)",
13791379 "char_import_5": "Символ AICharacterCardAICharacterCards.com (пряме посилання або ідентифікатор)",
13801380 "char_import_6": "Пряме посилання на PNG (див",
13811381 "char_import_7": "для дозволених хостів)",
13821382 "char_import_8": "Персонаж RisuRealm (пряме посилання)",
public/locales/vi-vn.json+1 -1
@@ -1376,7 +1376,7 @@
13761376 "char_import_2": "Chub (Nhập URL trực tiếp hoặc ID)",
13771377 "char_import_3": "JanitorAI (Nhập URL trực tiếp hoặc UUID)",
13781378 "char_import_4": "Pygmalion.chat (Nhập URL trực tiếp hoặc UUID)",
13791379 "char_import_5": "AICharacterCardAICharacterCards.com (Nhập URL trực tiếp hoặc ID)",
13801380 "char_import_6": "Nhập PNG trực tiếp (tham khảo",
13811381 "char_import_7": "đối với các máy chủ được phép)",
13821382 "char_import_8": "RisuRealm (URL trực tiếp)",
public/locales/zh-cn.json+1 -1
@@ -1829,7 +1829,7 @@
18291829 "char_import_2": "Chub 知识书(直链或ID)",
18301830 "char_import_3": "JanitorAI 角色(直链或UUID)",
18311831 "char_import_4": "Pygmalion.chat 角色(直链或UUID)",
18321832 "char_import_5": "AICharacterCardAICharacterCards.com 角色(直链或ID)",
18331833 "char_import_6": "被允许的PNG直链(请参阅",
18341834 "char_import_7": ")",
18351835 "char_import_8": "RisuRealm 角色(直链)",
public/locales/zh-tw.json+1 -1
@@ -1381,7 +1381,7 @@
13811381 "char_import_2": "Chub Lorebook(直接連結或 ID)",
13821382 "char_import_3": "JanitorAI 角色(直接連結或 ID)",
13831383 "char_import_4": "Pygmalion.chat 角色(直接連結或 ID)",
13841384 "char_import_5": "AICharacterCardAICharacterCards.com 角色(直接連結或 ID)",
13851385 "char_import_6": "直接 PNG 連結(請參閱",
13861386 "char_import_7": "對於允許的主機)",
13871387 "char_import_8": "RisuRealm角色(直接連結)",
public/scripts/group-chats.js+9 -0
@@ -1368,6 +1368,15 @@ function getGroupCharacterBlock(character) {
13681368 template.find('.ch_fav').val(isFav);
13691369 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+
13711380 let queuePosition = groupChatQueueOrder.get(character.avatar);
13721381 if (queuePosition) {
13731382 template.find('.queue_position').text(queuePosition);
public/scripts/templates/importCharacters.html+1 -1
@@ -7,7 +7,7 @@
77 <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>
88 <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>
99 <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>
1010 <li><span data-i18n="char_import_5">AICharacterCardAICharacterCards.com Character (Direct Link or ID)</span><br><span data-i18n="char_import_example">Example:</span> <tt>AICC/aicharcards/the-game-master</tt></li>
1111 <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>
1212 <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>
1313 </ul>
public/style.css+8 -1
@@ -2928,7 +2928,7 @@ input[type=search]:focus::-webkit-search-cancel-button {
29282928 position: relative;
29292929}
29302930
29312931#rm_print_characters_block.character_name_block .ch_name,
29322932.avatar-container .ch_name {
29332933 flex: 1 1 auto;
29342934 white-space: nowrap;
@@ -2938,6 +2938,13 @@ input[type=search]:focus::-webkit-search-cancel-button {
29382938 display: block;
29392939}
29402940
2941+.character_name_block .character_version {
2942+ text-overflow: ellipsis;
2943+ overflow: hidden;
2944+ text-wrap: nowrap;
2945+ max-width: 50%;
2946+}
2947+
29412948#rm_print_characters_block .character_name_block> :last-child {
29422949 flex: 0 100000 auto;
29432950 /* Force shrinking first */
server.js+39 -18
@@ -18,10 +18,9 @@ import { hideBin } from 'yargs/helpers';
1818
1919// express/server related library imports
2020import cors from 'cors';
2121import { doubleCsrfcsrfSync } from 'csrf-csrfsync';
2222import express from 'express';
2323import compression from 'compression';
24-import cookieParser from 'cookie-parser';
2524import cookieSession from 'cookie-session';
2625import multer from 'multer';
2726import responseTime from 'response-time';
@@ -40,7 +39,6 @@ util.inspect.defaultOptions.depth = 4;
4039import { loadPlugins } from './src/plugin-loader.js';
4140import {
4241 initUserStorage,
43- getCsrfSecret,
4442 getCookieSecret,
4543 getCookieSessionName,
4644 getAllEnabledUsers,
@@ -67,6 +65,7 @@ import {
6765 forwardFetchResponse,
6866 removeColorFormatting,
6967 getSeparator,
68+ safeReadFileSync,
7069} from './src/util.js';
7170import { UPLOADS_DIRECTORY } from './src/constants.js';
7271import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
@@ -347,8 +346,8 @@ if (enableCorsProxy) {
347346}
348347
349348function getSessionCookieAge() {
350349 // Defaults to 24 hours in"no secondsexpiration" if not set
351350 const configValue = getConfigValue('sessionTimeout', 24 * 60 * 60-1);
352351
353352 // Convert to milliseconds
354353 if (configValue > 0) {
@@ -377,27 +376,38 @@ app.use(setUserDataMiddleware);
377376
378377// CSRF Protection //
379378if (!disableCsrf) {
380379 const COOKIES_SECRETcsrfSyncProtection = getCookieSecretcsrfSync();{
381-
380+ 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;
388396 },
389397 size: 6432,
390- getTokenFromRequest: (req) => req.headers['x-csrf-token'],
391398 });
392399
393400 app.get('/csrf-token', (req, res) => {
394401 res.json({
395402 'token': csrfSyncProtection.generateToken(res, req),
396403 });
397404 });
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);
401411} else {
402412 console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
403413 app.get('/csrf-token', (req, res) => {
@@ -921,6 +931,16 @@ async function verifySecuritySettings() {
921931 }
922932}
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+ */
937+function 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+
924944// User storage module needs to be initialized before starting the server
925945initUserStorage(dataRoot)
926946 .then(ensurePublicDirectoriesExist)
@@ -928,4 +948,5 @@ initUserStorage(dataRoot)
928948 .then(migrateSystemPrompts)
929949 .then(verifySecuritySettings)
930950 .then(preSetupTasks)
951+ .then(apply404Middleware)
931952 .finally(startServer);
src/endpoints/avatars.js+2 -1
@@ -9,6 +9,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
99import { jsonParser, urlencodedParser } from '../express-common.js';
1010import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
1111import { getImages, tryParse } from '../util.js';
12+import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1213
1314export const router = express.Router();
1415
@@ -17,7 +18,7 @@ router.post('/get', jsonParser, function (request, response) {
1718 response.send(JSON.stringify(images));
1819});
1920
2021router.post('/delete', jsonParser, getFileNameValidationFunction('avatar'), function (request, response) {
2122 if (!request.body) return response.sendStatus(400);
2223
2324 if (request.body.avatar !== sanitize(request.body.avatar)) {
src/endpoints/backends/chat-completions.js+10 -0
@@ -37,6 +37,8 @@ import {
3737 getTiktokenTokenizer,
3838 sentencepieceTokenizers,
3939 TEXT_COMPLETION_MODELS,
40+ webTokenizers,
41+ getWebTokenizer,
4042} from '../tokenizers.js';
4143
4244const API_OPENAI = 'https://api.openai.com/v1';
@@ -863,6 +865,14 @@ router.post('/bias', jsonParser, async function (request, response) {
863865 return response.send({});
864866 }
865867 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));
866876 } else {
867877 const tokenizer = getTiktokenTokenizer(model);
868878 encodeFunction = (tokenizer.encode.bind(tokenizer));
src/endpoints/backgrounds.js+2 -1
@@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename';
77import { jsonParser, urlencodedParser } from '../express-common.js';
88import { invalidateThumbnail } from './thumbnails.js';
99import { getImages } from '../util.js';
10+import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1011
1112export const router = express.Router();
1213
@@ -15,7 +16,7 @@ router.post('/all', jsonParser, function (request, response) {
1516 response.send(JSON.stringify(images));
1617});
1718
1819router.post('/delete', jsonParser, getFileNameValidationFunction('bg'), function (request, response) {
1920 if (!request.body) return response.sendStatus(400);
2021
2122 if (request.body.bg !== sanitize(request.body.bg)) {
src/endpoints/characters.js+21 -14
@@ -14,6 +14,7 @@ import jimp from 'jimp';
1414
1515import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
1616import { jsonParser, urlencodedParser } from '../express-common.js';
17+import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
1718import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue } from '../util.js';
1819import { TavernCardValidator } from '../validator/TavernCardValidator.js';
1920import { parse, write } from '../character-card-parser.js';
@@ -73,12 +74,18 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
7374 * Read the image, resize, and save it as a PNG into the buffer.
7475 * @returns {Promise<Buffer>} Image buffer
7576 */
7677 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
8183 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+ }
8289 }
8390
8491 const inputImage = await getInputImage();
@@ -756,7 +763,7 @@ router.post('/create', urlencodedParser, async function (request, response) {
756763 }
757764});
758765
759766router.post('/rename', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
760767 if (!request.body.avatar_url || !request.body.new_name) {
761768 return response.sendStatus(400);
762769 }
@@ -803,7 +810,7 @@ router.post('/rename', jsonParser, async function (request, response) {
803810 }
804811});
805812
806813router.post('/edit', urlencodedParser, validateAvatarUrlMiddleware, async function (request, response) {
807814 if (!request.body) {
808815 console.error('Error: no response body detected');
809816 response.status(400).send('Error: no response body detected');
@@ -852,7 +859,7 @@ router.post('/edit', urlencodedParser, async function (request, response) {
852859 * @param {Object} response - The HTTP response object.
853860 * @returns {void}
854861 */
855862router.post('/edit-attribute', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
856863 console.log(request.body);
857864 if (!request.body) {
858865 console.error('Error: no response body detected');
@@ -898,7 +905,7 @@ router.post('/edit-attribute', jsonParser, async function (request, response) {
898905 *
899906 * @returns {void}
900907 * */
901908router.post('/merge-attributes', jsonParser, getFileNameValidationFunction('avatar'), async function (request, response) {
902909 try {
903910 const update = request.body;
904911 const avatarPath = path.join(request.user.directories.characters, update.avatar);
@@ -929,7 +936,7 @@ router.post('/merge-attributes', jsonParser, async function (request, response)
929936 }
930937});
931938
932939router.post('/delete', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
933940 if (!request.body || !request.body.avatar_url) {
934941 return response.sendStatus(400);
935942 }
@@ -992,7 +999,7 @@ router.post('/all', jsonParser, async function (request, response) {
992999 }
9931000});
9941001
9951002router.post('/get', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
9961003 try {
9971004 if (!request.body) return response.sendStatus(400);
9981005 const item = request.body.avatar_url;
@@ -1011,7 +1018,7 @@ router.post('/get', jsonParser, async function (request, response) {
10111018 }
10121019});
10131020
10141021router.post('/chats', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
10151022 if (!request.body) return response.sendStatus(400);
10161023
10171024 const characterDirectory = (request.body.avatar_url).replace('.png', '');
@@ -1160,7 +1167,7 @@ router.post('/import', urlencodedParser, async function (request, response) {
11601167 }
11611168});
11621169
11631170router.post('/duplicate', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
11641171 try {
11651172 if (!request.body.avatar_url) {
11661173 console.log('avatar URL not found in request body');
@@ -1207,7 +1214,7 @@ router.post('/duplicate', jsonParser, async function (request, response) {
12071214 }
12081215});
12091216
12101217router.post('/export', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
12111218 try {
12121219 if (!request.body.format || !request.body.avatar_url) {
12131220 return response.sendStatus(400);
src/endpoints/chats.js+8 -7
@@ -9,6 +9,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
99import _ from 'lodash';
1010
1111import { jsonParser, urlencodedParser } from '../express-common.js';
12+import validateAvatarUrlMiddleware from '../middleware/validateFileName.js';
1213import {
1314 getConfigValue,
1415 humanizedISO8601DateTime,
@@ -294,7 +295,7 @@ function importRisuChat(userName, characterName, jsonData) {
294295
295296export const router = express.Router();
296297
297298router.post('/save', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
298299 try {
299300 const directoryName = String(request.body.avatar_url).replace('.png', '');
300301 const chatData = request.body.chat;
@@ -310,7 +311,7 @@ router.post('/save', jsonParser, function (request, response) {
310311 }
311312});
312313
313314router.post('/get', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
314315 try {
315316 const dirName = String(request.body.avatar_url).replace('.png', '');
316317 const directoryPath = path.join(request.user.directories.chats, dirName);
@@ -347,7 +348,7 @@ router.post('/get', jsonParser, function (request, response) {
347348});
348349
349350
350351router.post('/rename', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
351352 if (!request.body || !request.body.original_file || !request.body.renamed_file) {
352353 return response.sendStatus(400);
353354 }
@@ -372,7 +373,7 @@ router.post('/rename', jsonParser, async function (request, response) {
372373 return response.send({ ok: true, sanitizedFileName });
373374});
374375
375376router.post('/delete', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
376377 const dirName = String(request.body.avatar_url).replace('.png', '');
377378 const fileName = String(request.body.chatfile);
378379 const filePath = path.join(request.user.directories.chats, dirName, sanitize(fileName));
@@ -388,7 +389,7 @@ router.post('/delete', jsonParser, function (request, response) {
388389 return response.send('ok');
389390});
390391
391392router.post('/export', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
392393 if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) {
393394 return response.sendStatus(400);
394395 }
@@ -478,7 +479,7 @@ router.post('/group/import', urlencodedParser, function (request, response) {
478479 }
479480});
480481
481482router.post('/import', urlencodedParser, validateAvatarUrlMiddleware, function (request, response) {
482483 if (!request.body) return response.sendStatus(400);
483484
484485 const format = request.body.file_type;
@@ -626,7 +627,7 @@ router.post('/group/save', jsonParser, (request, response) => {
626627 return response.send({ ok: true });
627628});
628629
629630router.post('/search', jsonParser, validateAvatarUrlMiddleware, function (request, response) {
630631 try {
631632 const { query, avatar_url, group_id } = request.body;
632633 let chatFiles = [];
src/endpoints/settings.js+3 -2
@@ -9,6 +9,7 @@ import { SETTINGS_FILE } from '../constants.js';
99import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';
1010import { jsonParser } from '../express-common.js';
1111import { getAllUserHandles, getUserDirectories } from '../users.js';
12+import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1213
1314const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true);
1415const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true);
@@ -296,7 +297,7 @@ router.post('/get-snapshots', jsonParser, async (request, response) => {
296297 }
297298});
298299
299300router.post('/load-snapshot', jsonParser, getFileNameValidationFunction('name'), async (request, response) => {
300301 try {
301302 const userFilesPattern = getFilePrefix(request.user.profile.handle);
302303
@@ -330,7 +331,7 @@ router.post('/make-snapshot', jsonParser, async (request, response) => {
330331 }
331332});
332333
333334router.post('/restore-snapshot', jsonParser, getFileNameValidationFunction('name'), async (request, response) => {
334335 try {
335336 const userFilesPattern = getFilePrefix(request.user.profile.handle);
336337
src/endpoints/tokenizers.js+42 -0
@@ -238,6 +238,15 @@ export const sentencepieceTokenizers = [
238238 'jamba',
239239];
240240
241+export const webTokenizers = [
242+ 'claude',
243+ 'llama3',
244+ 'command-r',
245+ 'qwen2',
246+ 'nemo',
247+ 'deepseek',
248+];
249+
241250/**
242251 * Gets the Sentencepiece tokenizer by the model name.
243252 * @param {string} model Sentencepiece model name
@@ -276,6 +285,39 @@ export function getSentencepiceTokenizer(model) {
276285}
277286
278287/**
288+ * Gets the Web tokenizer by the model name.
289+ * @param {string} model Web tokenizer model name
290+ * @returns {WebTokenizer|null} Web tokenizer
291+ */
292+export 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+/**
279321 * Counts the token ids for the given text using the Sentencepiece tokenizer.
280322 * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
281323 * @param {string} text Text to tokenize
src/endpoints/users-private.js+1 -0
@@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => {
2323 }
2424
2525 request.session.handle = null;
26+ request.session.csrfToken = null;
2627 request.session = null;
2728 return response.sendStatus(204);
2829 } catch (error) {
src/middleware/basicAuth.js+7 -6
@@ -5,17 +5,18 @@
55import { Buffer } from 'node:buffer';
66import storage from 'node-persist';
77import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
88import { getConfig, getConfigValue, safeReadFileSync } from '../util.js';
99
1010const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false);
1111const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
1212
13-const unauthorizedResponse = (res) => {
14- res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
15- return res.status(401).send('Authentication required');
16-};
17-
1813const 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+
1920 const config = getConfig();
2021 const authHeader = request.headers.authorization;
2122
src/middleware/validateFileName.js+34 -0
@@ -0,0 +1,34 @@
1+import 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+ */
8+export 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+
33+const avatarUrlValidationFunction = getFileNameValidationFunction('avatar_url');
34+export default avatarUrlValidationFunction;
src/middleware/whitelist.js+16 -5
@@ -1,10 +1,11 @@
11import path from 'node:path';
22import fs from 'node:fs';
33import process from 'node:process';
4+import Handlebars from 'handlebars';
45import ipMatching from 'ip-matching';
56
67import { getIpFromRequest } from '../express-common.js';
78import { color, getConfigValue, safeReadFileSync } from '../util.js';
89
910const whitelistPath = path.join(process.cwd(), './whitelist.txt');
1011const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
@@ -52,12 +53,16 @@ function getForwardedIp(req) {
5253 * @returns {import('express').RequestHandler} The middleware function
5354 */
5455export default function whitelistMiddleware(whitelistMode, listen) {
56+ const forbiddenWebpage = Handlebars.compile(
57+ safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
58+ );
59+
5560 return function (req, res, next) {
5661 const clientIp = getIpFromRequest(req);
5762 const forwardedIp = getForwardedIp(req);
63+ const userAgent = req.headers['user-agent'];
5864
5965 if (listen && !knownIPs.has(clientIp)) {
60- const userAgent = req.headers['user-agent'];
6166 console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
6267 knownIPs.add(clientIp);
6368
@@ -76,9 +81,15 @@ export default function whitelistMiddleware(whitelistMode, listen) {
7681 || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
7782 ) {
7883 // 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 }));
8293 }
8394 next();
8495 };
src/users.js+2 -1
@@ -458,7 +458,8 @@ export function getPasswordSalt() {
458458 */
459459export function getCookieSessionName() {
460460 // 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);
462463 return `session-${suffix}`;
463464}
464465
src/util.js+11 -0
@@ -871,3 +871,14 @@ export class MemoryLimitedMap {
871871 return this.map[Symbol.iterator]();
872872 }
873873}
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+ */
881+export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
882+ if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
883+ return null;
884+}