Merge branch 'staging' into woo-yeah

30426d21e7dd261887e308de62bf2d801545db60

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

17 files changed, +255 -51Showing whitespace changes
.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 */
223
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 ];
220237
221 for (const file of Object.values(files)) {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}
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/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';
@@ -865,6 +867,14 @@ router.post('/bias', jsonParser, async function (request, response) {
865 return response.send({});867 return response.send({});
866 }868 }
867 encodeFunction = (text) => new Uint32Array(instance.encodeIds(text));869 encodeFunction = (text) => new Uint32Array(instance.encodeIds(text));
870 } else if (webTokenizers.includes(model)) {
871 const tokenizer = getWebTokenizer(model);
872 const instance = await tokenizer?.get();
873 if (!instance) {
874 console.warn('Tokenizer not initialized:', model);
875 return response.send({});
876 }
877 encodeFunction = (text) => new Uint32Array(instance.encode(text));
868 } else {878 } else {
869 const tokenizer = getTiktokenTokenizer(model);879 const tokenizer = getTiktokenTokenizer(model);
870 encodeFunction = (tokenizer.encode.bind(tokenizer));880 encodeFunction = (tokenizer.encode.bind(tokenizer));
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+4 -3
@@ -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 basicAuthMiddleware = async function (request, response, callback) {
14 const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';
13 const unauthorizedResponse = (res) => {15 const unauthorizedResponse = (res) => {
14 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');16 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
15 return res.status(401).send('Authentication required');17 return res.status(401).send(unauthorizedWebpage);
16 };18 };
1719
18const basicAuthMiddleware = async function (request, response, callback) {
19 const config = getConfig();20 const config = getConfig();
20 const authHeader = request.headers.authorization;21 const authHeader = request.headers.authorization;
2122
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/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}