diff --git a/.gitignore b/.gitignore index 7d48fd879..7a88d5bec 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ access.log /vectors/ /cache/ public/css/user.css +public/error/ /plugins/ /data /default/scaffold @@ -52,3 +53,5 @@ public/scripts/extensions/third-party /certs .aider* .env +/StartDev.bat + diff --git a/default/config.yaml b/default/config.yaml index 4c28044dc..f3e7db625 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -70,7 +70,7 @@ perUserBasicAuth: false ## Set to a positive number to expire session after a certain time of inactivity ## Set to 0 to expire session when the browser is closed ## Set to a negative number to disable session expiration -sessionTimeout: 86400 +sessionTimeout: -1 # Used to sign session cookies. Will be auto-generated if not set cookieSecret: '' # Disable CSRF protection - NOT RECOMMENDED diff --git a/default/user.css b/default/public/css/user.css similarity index 100% rename from default/user.css rename to default/public/css/user.css diff --git a/default/public/error/forbidden-by-whitelist.html b/default/public/error/forbidden-by-whitelist.html new file mode 100644 index 000000000..70ff71852 --- /dev/null +++ b/default/public/error/forbidden-by-whitelist.html @@ -0,0 +1,22 @@ + + + + + Forbidden + + + +

Forbidden

+

+ If you are the system administrator, add your IP address to the + whitelist or disable whitelist mode by editing + config.yaml in the root directory of your installation. +

+
+

+ Connection from {{ipDetails}} has been blocked. This attempt + has been logged. +

+ + + diff --git a/default/public/error/unauthorized.html b/default/public/error/unauthorized.html new file mode 100644 index 000000000..e3fa5f94d --- /dev/null +++ b/default/public/error/unauthorized.html @@ -0,0 +1,17 @@ + + + + + Unauthorized + + + +

Unauthorized

+

+ If you are the system administrator, you can configure the + basicAuthUser credentials by editing + config.yaml in the root directory of your installation. +

+ + + diff --git a/default/public/error/url-not-found.html b/default/public/error/url-not-found.html new file mode 100644 index 000000000..87974145f --- /dev/null +++ b/default/public/error/url-not-found.html @@ -0,0 +1,15 @@ + + + + + Not found + + + +

Not found

+

+ The requested URL was not found on this server. +

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