Merge branch 'staging' into woo-yeah
| @@ -45,6 +45,7 @@ access.log | ||
| 45 | 45 | /vectors/ |
| 46 | 46 | /cache/ |
| 47 | 47 | public/css/user.css |
| 48 | +public/error/ | |
| 48 | 49 | /plugins/ |
| 49 | 50 | /data |
| 50 | 51 | /default/scaffold |
| @@ -52,3 +53,5 @@ public/scripts/extensions/third-party | ||
| 52 | 53 | /certs |
| 53 | 54 | .aider* |
| 54 | 55 | .env |
| 56 | +/StartDev.bat | |
| 57 | + | |
| @@ -70,7 +70,7 @@ perUserBasicAuth: false | ||
| 70 | 70 | ## Set to a positive number to expire session after a certain time of inactivity |
| 71 | 71 | ## Set to 0 to expire session when the browser is closed |
| 72 | 72 | ## Set to a negative number to disable session expiration |
| 73 | 73 | sessionTimeout: 86400-1 |
| 74 | 74 | # Used to sign session cookies. Will be auto-generated if not set |
| 75 | 75 | cookieSecret: '' |
| 76 | 76 | # Disable CSRF protection - NOT RECOMMENDED |
| @@ -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> | |
| @@ -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> | |
| @@ -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> | |
| @@ -1,6 +1,24 @@ | ||
| 1 | 1 | import { UserDirectoryList, User } from "./src/users"; |
| 2 | +import { CsrfSyncedToken } from "csrf-sync"; | |
| 2 | 3 | |
| 3 | 4 | declare 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 | 22 | namespace Express { |
| 5 | 23 | export interface Request { |
| 6 | 24 | user: { |
| @@ -15,11 +33,3 @@ declare global { | ||
| 15 | 33 | */ |
| 16 | 34 | var DATA_ROOT: string; |
| 17 | 35 | } |
| 18 | - | |
| 19 | -declare module 'express-session' { | |
| 20 | - export interface SessionData { | |
| 21 | - handle: string; | |
| 22 | - touch: number; | |
| 23 | - // other properties... | |
| 24 | - } | |
| 25 | - } | |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | "cookie-parser": "^1.4.6", |
| 27 | 27 | "cookie-session": "^2.1.0", |
| 28 | 28 | "cors": "^2.8.5", |
| 29 | 29 | "csrf-csrfsync": "^24.20.3", |
| 30 | 30 | "diff-match-patch": "^1.0.5", |
| 31 | 31 | "dompurify": "^3.1.7", |
| 32 | 32 | "droll": "^0.2.1", |
| @@ -2987,10 +2987,10 @@ | ||
| 2987 | 2987 | "node": "*" |
| 2988 | 2988 | } |
| 2989 | 2989 | }, |
| 2990 | 2990 | "node_modules/csrf-csrfsync": { |
| 2991 | 2991 | "version": "24.20.43", |
| 2992 | 2992 | "resolved": "https://registry.npmjs.org/csrf-csrfsync/-/csrf-csrfsync-24.20.43.tgz", |
| 2993 | 2993 | "integrity": "sha512-LuhBmy5RfRmEfeqeYqgaAuS1eDpVtKZB/Eiec9xiKQLBynJxrGVRdM2yRTwXzltBBzt/YMl1Njo7imzDt6ZT7G/yKh2L9AYsIwSlTPnx2AaxQG7jo4Sm0uXDUzFY8hR59qhDHdjqpW2hojS4oAVIZDzwlMQloIVCTJoDDh0wwA==", |
| 2994 | 2994 | "license": "ISC", |
| 2995 | 2995 | "dependencies": { |
| 2996 | 2996 | "http-errors": "^2.0.0" |
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | "cookie-parser": "^1.4.6", |
| 17 | 17 | "cookie-session": "^2.1.0", |
| 18 | 18 | "cors": "^2.8.5", |
| 19 | 19 | "csrf-csrfsync": "^24.20.3", |
| 20 | 20 | "diff-match-patch": "^1.0.5", |
| 21 | 21 | "dompurify": "^3.1.7", |
| 22 | 22 | "droll": "^0.2.1", |
| @@ -213,20 +213,60 @@ function addMissingConfigValues() { | ||
| 213 | 213 | * Creates the default config files if they don't exist yet. |
| 214 | 214 | */ |
| 215 | 215 | function 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 | + ]; | |
| 220 | 237 | |
| 221 | 238 | for (const filedefaultItem of Object.values(files)defaultItems) { |
| 222 | 239 | try { |
| 223 | 240 | if (!fsdefaultItem.existsSync(type === 'file)') { |
| 224 | - const defaultFilePath = path.join('./default', path.parse(file).base); | |
| 241 | + if (!fs.existsSync(defaultItem.productionPath)) { | |
| 225 | 242 | 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 | + ); | |
| 227 | 262 | } |
| 228 | 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 | } |
| @@ -18,10 +18,9 @@ import { hideBin } from 'yargs/helpers'; | ||
| 18 | 18 | |
| 19 | 19 | // express/server related library imports |
| 20 | 20 | import cors from 'cors'; |
| 21 | 21 | import { doubleCsrfcsrfSync } from 'csrf-csrfsync'; |
| 22 | 22 | import express from 'express'; |
| 23 | 23 | import compression from 'compression'; |
| 24 | -import cookieParser from 'cookie-parser'; | |
| 25 | 24 | import cookieSession from 'cookie-session'; |
| 26 | 25 | import multer from 'multer'; |
| 27 | 26 | import responseTime from 'response-time'; |
| @@ -40,7 +39,6 @@ util.inspect.defaultOptions.depth = 4; | ||
| 40 | 39 | import { loadPlugins } from './src/plugin-loader.js'; |
| 41 | 40 | import { |
| 42 | 41 | initUserStorage, |
| 43 | - getCsrfSecret, | |
| 44 | 42 | getCookieSecret, |
| 45 | 43 | getCookieSessionName, |
| 46 | 44 | getAllEnabledUsers, |
| @@ -67,6 +65,7 @@ import { | ||
| 67 | 65 | forwardFetchResponse, |
| 68 | 66 | removeColorFormatting, |
| 69 | 67 | getSeparator, |
| 68 | + safeReadFileSync, | |
| 70 | 69 | } from './src/util.js'; |
| 71 | 70 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 72 | 71 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; |
| @@ -347,8 +346,8 @@ if (enableCorsProxy) { | ||
| 347 | 346 | } |
| 348 | 347 | |
| 349 | 348 | function getSessionCookieAge() { |
| 350 | 349 | // Defaults to 24 hours in"no secondsexpiration" if not set |
| 351 | 350 | const configValue = getConfigValue('sessionTimeout', 24 * 60 * 60-1); |
| 352 | 351 | |
| 353 | 352 | // Convert to milliseconds |
| 354 | 353 | if (configValue > 0) { |
| @@ -377,27 +376,38 @@ app.use(setUserDataMiddleware); | ||
| 377 | 376 | |
| 378 | 377 | // CSRF Protection // |
| 379 | 378 | if (!disableCsrf) { |
| 380 | 379 | 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; | |
| 388 | 396 | }, |
| 389 | 397 | size: 6432, |
| 390 | - getTokenFromRequest: (req) => req.headers['x-csrf-token'], | |
| 391 | 398 | }); |
| 392 | 399 | |
| 393 | 400 | app.get('/csrf-token', (req, res) => { |
| 394 | 401 | res.json({ |
| 395 | 402 | 'token': csrfSyncProtection.generateToken(res, req), |
| 396 | 403 | }); |
| 397 | 404 | }); |
| 398 | 405 | |
| 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 | 411 | } else { |
| 402 | 412 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); |
| 403 | 413 | app.get('/csrf-token', (req, res) => { |
| @@ -921,6 +931,16 @@ async function verifySecuritySettings() { | ||
| 921 | 931 | } |
| 922 | 932 | } |
| 923 | 933 | |
| 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 | + | |
| 924 | 944 | // User storage module needs to be initialized before starting the server |
| 925 | 945 | initUserStorage(dataRoot) |
| 926 | 946 | .then(ensurePublicDirectoriesExist) |
| @@ -928,4 +948,5 @@ initUserStorage(dataRoot) | ||
| 928 | 948 | .then(migrateSystemPrompts) |
| 929 | 949 | .then(verifySecuritySettings) |
| 930 | 950 | .then(preSetupTasks) |
| 951 | + .then(apply404Middleware) | |
| 931 | 952 | .finally(startServer); |
| @@ -37,6 +37,8 @@ import { | ||
| 37 | 37 | getTiktokenTokenizer, |
| 38 | 38 | sentencepieceTokenizers, |
| 39 | 39 | TEXT_COMPLETION_MODELS, |
| 40 | + webTokenizers, | |
| 41 | + getWebTokenizer, | |
| 40 | 42 | } from '../tokenizers.js'; |
| 41 | 43 | |
| 42 | 44 | const API_OPENAI = 'https://api.openai.com/v1'; |
| @@ -865,6 +867,14 @@ router.post('/bias', jsonParser, async function (request, response) { | ||
| 865 | 867 | return response.send({}); |
| 866 | 868 | } |
| 867 | 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 | 878 | } else { |
| 869 | 879 | const tokenizer = getTiktokenTokenizer(model); |
| 870 | 880 | encodeFunction = (tokenizer.encode.bind(tokenizer)); |
| @@ -238,6 +238,15 @@ export const sentencepieceTokenizers = [ | ||
| 238 | 238 | 'jamba', |
| 239 | 239 | ]; |
| 240 | 240 | |
| 241 | +export const webTokenizers = [ | |
| 242 | + 'claude', | |
| 243 | + 'llama3', | |
| 244 | + 'command-r', | |
| 245 | + 'qwen2', | |
| 246 | + 'nemo', | |
| 247 | + 'deepseek', | |
| 248 | +]; | |
| 249 | + | |
| 241 | 250 | /** |
| 242 | 251 | * Gets the Sentencepiece tokenizer by the model name. |
| 243 | 252 | * @param {string} model Sentencepiece model name |
| @@ -276,6 +285,39 @@ export function getSentencepiceTokenizer(model) { | ||
| 276 | 285 | } |
| 277 | 286 | |
| 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 | + */ | |
| 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 | +/** | |
| 279 | 321 | * Counts the token ids for the given text using the Sentencepiece tokenizer. |
| 280 | 322 | * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer |
| 281 | 323 | * @param {string} text Text to tokenize |
| @@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => { | ||
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | request.session.handle = null; |
| 26 | + request.session.csrfToken = null; | |
| 26 | 27 | request.session = null; |
| 27 | 28 | return response.sendStatus(204); |
| 28 | 29 | } catch (error) { |
| @@ -5,17 +5,18 @@ | ||
| 5 | 5 | import { Buffer } from 'node:buffer'; |
| 6 | 6 | import storage from 'node-persist'; |
| 7 | 7 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; |
| 8 | 8 | import { getConfig, getConfigValue, safeReadFileSync } from '../util.js'; |
| 9 | 9 | |
| 10 | 10 | const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); |
| 11 | 11 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); |
| 12 | 12 | |
| 13 | +const basicAuthMiddleware = async function (request, response, callback) { | |
| 14 | + const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? ''; | |
| 13 | 15 | const unauthorizedResponse = (res) => { |
| 14 | 16 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); |
| 15 | 17 | return res.status(401).send('Authentication required'unauthorizedWebpage); |
| 16 | 18 | }; |
| 17 | 19 | |
| 18 | -const basicAuthMiddleware = async function (request, response, callback) { | |
| 19 | 20 | const config = getConfig(); |
| 20 | 21 | const authHeader = request.headers.authorization; |
| 21 | 22 | |
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import path from 'node:path'; |
| 2 | 2 | import fs from 'node:fs'; |
| 3 | 3 | import process from 'node:process'; |
| 4 | +import Handlebars from 'handlebars'; | |
| 4 | 5 | import ipMatching from 'ip-matching'; |
| 5 | 6 | |
| 6 | 7 | import { getIpFromRequest } from '../express-common.js'; |
| 7 | 8 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; |
| 8 | 9 | |
| 9 | 10 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); |
| 10 | 11 | const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); |
| @@ -52,12 +53,16 @@ function getForwardedIp(req) { | ||
| 52 | 53 | * @returns {import('express').RequestHandler} The middleware function |
| 53 | 54 | */ |
| 54 | 55 | export default function whitelistMiddleware(whitelistMode, listen) { |
| 56 | + const forbiddenWebpage = Handlebars.compile( | |
| 57 | + safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', | |
| 58 | + ); | |
| 59 | + | |
| 55 | 60 | return function (req, res, next) { |
| 56 | 61 | const clientIp = getIpFromRequest(req); |
| 57 | 62 | const forwardedIp = getForwardedIp(req); |
| 63 | + const userAgent = req.headers['user-agent']; | |
| 58 | 64 | |
| 59 | 65 | if (listen && !knownIPs.has(clientIp)) { |
| 60 | - const userAgent = req.headers['user-agent']; | |
| 61 | 66 | console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); |
| 62 | 67 | knownIPs.add(clientIp); |
| 63 | 68 | |
| @@ -76,9 +81,15 @@ export default function whitelistMiddleware(whitelistMode, listen) { | ||
| 76 | 81 | || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) |
| 77 | 82 | ) { |
| 78 | 83 | // 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 | 94 | next(); |
| 84 | 95 | }; |
| @@ -871,3 +871,14 @@ export class MemoryLimitedMap { | ||
| 871 | 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 | + */ | |
| 881 | +export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { | |
| 882 | + if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); | |
| 883 | + return null; | |
| 884 | +} | |