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
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+ */
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
221238 for (const filedefaultItem of Object.values(files)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}
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/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';
@@ -865,6 +867,14 @@ router.post('/bias', jsonParser, async function (request, response) {
865867 return response.send({});
866868 }
867869 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));
868878 } else {
869879 const tokenizer = getTiktokenTokenizer(model);
870880 encodeFunction = (tokenizer.encode.bind(tokenizer));
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+4 -3
@@ -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 basicAuthMiddleware = async function (request, response, callback) {
14+ const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';
1315 const unauthorizedResponse = (res) => {
1416 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
1517 return res.status(401).send('Authentication required'unauthorizedWebpage);
1618 };
1719
18-const basicAuthMiddleware = async function (request, response, callback) {
1920 const config = getConfig();
2021 const authHeader = request.headers.authorization;
2122
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/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+}