mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Merge branch 'staging' into woo-yeah
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,6 +45,7 @@ access.log
|
|||||||
/vectors/
|
/vectors/
|
||||||
/cache/
|
/cache/
|
||||||
public/css/user.css
|
public/css/user.css
|
||||||
|
public/error/
|
||||||
/plugins/
|
/plugins/
|
||||||
/data
|
/data
|
||||||
/default/scaffold
|
/default/scaffold
|
||||||
@@ -52,3 +53,5 @@ public/scripts/extensions/third-party
|
|||||||
/certs
|
/certs
|
||||||
.aider*
|
.aider*
|
||||||
.env
|
.env
|
||||||
|
/StartDev.bat
|
||||||
|
|
||||||
|
@@ -70,7 +70,7 @@ perUserBasicAuth: false
|
|||||||
## Set to a positive number to expire session after a certain time of inactivity
|
## 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 0 to expire session when the browser is closed
|
||||||
## Set to a negative number to disable session expiration
|
## 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
|
# Used to sign session cookies. Will be auto-generated if not set
|
||||||
cookieSecret: ''
|
cookieSecret: ''
|
||||||
# Disable CSRF protection - NOT RECOMMENDED
|
# Disable CSRF protection - NOT RECOMMENDED
|
||||||
|
22
default/public/error/forbidden-by-whitelist.html
Normal file
22
default/public/error/forbidden-by-whitelist.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Forbidden</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Forbidden</h1>
|
||||||
|
<p>
|
||||||
|
If you are the system administrator, add your IP address to the
|
||||||
|
whitelist or disable whitelist mode by editing
|
||||||
|
<code>config.yaml</code> in the root directory of your installation.
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
<em>Connection from {{ipDetails}} has been blocked. This attempt
|
||||||
|
has been logged.</em>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
17
default/public/error/unauthorized.html
Normal file
17
default/public/error/unauthorized.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Unauthorized</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Unauthorized</h1>
|
||||||
|
<p>
|
||||||
|
If you are the system administrator, you can configure the
|
||||||
|
<code>basicAuthUser</code> credentials by editing
|
||||||
|
<code>config.yaml</code> in the root directory of your installation.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
15
default/public/error/url-not-found.html
Normal file
15
default/public/error/url-not-found.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Not found</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Not found</h1>
|
||||||
|
<p>
|
||||||
|
The requested URL was not found on this server.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
26
index.d.ts
vendored
26
index.d.ts
vendored
@@ -1,6 +1,24 @@
|
|||||||
import { UserDirectoryList, User } from "./src/users";
|
import { UserDirectoryList, User } from "./src/users";
|
||||||
|
import { CsrfSyncedToken } from "csrf-sync";
|
||||||
|
|
||||||
declare global {
|
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 {
|
namespace Express {
|
||||||
export interface Request {
|
export interface Request {
|
||||||
user: {
|
user: {
|
||||||
@@ -15,11 +33,3 @@ declare global {
|
|||||||
*/
|
*/
|
||||||
var DATA_ROOT: string;
|
var DATA_ROOT: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'express-session' {
|
|
||||||
export interface SessionData {
|
|
||||||
handle: string;
|
|
||||||
touch: number;
|
|
||||||
// other properties...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
10
package-lock.json
generated
10
package-lock.json
generated
@@ -26,7 +26,7 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cookie-session": "^2.1.0",
|
"cookie-session": "^2.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csrf-csrf": "^2.2.3",
|
"csrf-sync": "^4.0.3",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"dompurify": "^3.1.7",
|
"dompurify": "^3.1.7",
|
||||||
"droll": "^0.2.1",
|
"droll": "^0.2.1",
|
||||||
@@ -2987,10 +2987,10 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/csrf-csrf": {
|
"node_modules/csrf-sync": {
|
||||||
"version": "2.2.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.0.3.tgz",
|
||||||
"integrity": "sha512-LuhBmy5RfRmEfeqeYqgaAuS1eDpVtKZB/Eiec9xiKQLBynJxrGVRdM2yRT/YMl1Njo/yKh2L9AYsIwSlTPnx2A==",
|
"integrity": "sha512-wXzltBBzt/7imzDt6ZT7G/axQG7jo4Sm0uXDUzFY8hR59qhDHdjqpW2hojS4oAVIZDzwlMQloIVCTJoDDh0wwA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"http-errors": "^2.0.0"
|
"http-errors": "^2.0.0"
|
||||||
|
@@ -16,7 +16,7 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cookie-session": "^2.1.0",
|
"cookie-session": "^2.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csrf-csrf": "^2.2.3",
|
"csrf-sync": "^4.0.3",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"dompurify": "^3.1.7",
|
"dompurify": "^3.1.7",
|
||||||
"droll": "^0.2.1",
|
"droll": "^0.2.1",
|
||||||
|
@@ -213,20 +213,60 @@ function addMissingConfigValues() {
|
|||||||
* Creates the default config files if they don't exist yet.
|
* Creates the default config files if they don't exist yet.
|
||||||
*/
|
*/
|
||||||
function createDefaultFiles() {
|
function createDefaultFiles() {
|
||||||
const files = {
|
/**
|
||||||
config: './config.yaml',
|
* @typedef DefaultItem
|
||||||
user: './public/css/user.css',
|
* @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 {
|
try {
|
||||||
if (!fs.existsSync(file)) {
|
if (defaultItem.type === 'file') {
|
||||||
const defaultFilePath = path.join('./default', path.parse(file).base);
|
if (!fs.existsSync(defaultItem.productionPath)) {
|
||||||
fs.copyFileSync(defaultFilePath, file);
|
fs.copyFileSync(
|
||||||
console.log(color.green(`Created default file: ${file}`));
|
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) {
|
} 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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
57
server.js
57
server.js
@@ -18,10 +18,9 @@ import { hideBin } from 'yargs/helpers';
|
|||||||
|
|
||||||
// express/server related library imports
|
// express/server related library imports
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { doubleCsrf } from 'csrf-csrf';
|
import { csrfSync } from 'csrf-sync';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import cookieParser from 'cookie-parser';
|
|
||||||
import cookieSession from 'cookie-session';
|
import cookieSession from 'cookie-session';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import responseTime from 'response-time';
|
import responseTime from 'response-time';
|
||||||
@@ -40,7 +39,6 @@ util.inspect.defaultOptions.depth = 4;
|
|||||||
import { loadPlugins } from './src/plugin-loader.js';
|
import { loadPlugins } from './src/plugin-loader.js';
|
||||||
import {
|
import {
|
||||||
initUserStorage,
|
initUserStorage,
|
||||||
getCsrfSecret,
|
|
||||||
getCookieSecret,
|
getCookieSecret,
|
||||||
getCookieSessionName,
|
getCookieSessionName,
|
||||||
getAllEnabledUsers,
|
getAllEnabledUsers,
|
||||||
@@ -67,6 +65,7 @@ import {
|
|||||||
forwardFetchResponse,
|
forwardFetchResponse,
|
||||||
removeColorFormatting,
|
removeColorFormatting,
|
||||||
getSeparator,
|
getSeparator,
|
||||||
|
safeReadFileSync,
|
||||||
} from './src/util.js';
|
} from './src/util.js';
|
||||||
import { UPLOADS_DIRECTORY } from './src/constants.js';
|
import { UPLOADS_DIRECTORY } from './src/constants.js';
|
||||||
import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
|
import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
|
||||||
@@ -347,8 +346,8 @@ if (enableCorsProxy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSessionCookieAge() {
|
function getSessionCookieAge() {
|
||||||
// Defaults to 24 hours in seconds if not set
|
// Defaults to "no expiration" if not set
|
||||||
const configValue = getConfigValue('sessionTimeout', 24 * 60 * 60);
|
const configValue = getConfigValue('sessionTimeout', -1);
|
||||||
|
|
||||||
// Convert to milliseconds
|
// Convert to milliseconds
|
||||||
if (configValue > 0) {
|
if (configValue > 0) {
|
||||||
@@ -377,27 +376,38 @@ app.use(setUserDataMiddleware);
|
|||||||
|
|
||||||
// CSRF Protection //
|
// CSRF Protection //
|
||||||
if (!disableCsrf) {
|
if (!disableCsrf) {
|
||||||
const COOKIES_SECRET = getCookieSecret();
|
const csrfSyncProtection = csrfSync({
|
||||||
|
getTokenFromState: (req) => {
|
||||||
const { generateToken, doubleCsrfProtection } = doubleCsrf({
|
if (!req.session) {
|
||||||
getSecret: getCsrfSecret,
|
console.error('(CSRF error) getTokenFromState: Session object not initialized');
|
||||||
cookieName: 'X-CSRF-Token',
|
return;
|
||||||
cookieOptions: {
|
}
|
||||||
sameSite: 'strict',
|
return req.session.csrfToken;
|
||||||
secure: false,
|
|
||||||
},
|
},
|
||||||
size: 64,
|
getTokenFromRequest: (req) => {
|
||||||
getTokenFromRequest: (req) => req.headers['x-csrf-token'],
|
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) => {
|
app.get('/csrf-token', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
'token': generateToken(res, req),
|
'token': csrfSyncProtection.generateToken(req),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(cookieParser(COOKIES_SECRET));
|
// Customize the error message
|
||||||
app.use(doubleCsrfProtection);
|
csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.');
|
||||||
|
csrfSyncProtection.invalidCsrfTokenError.stack = undefined;
|
||||||
|
|
||||||
|
app.use(csrfSyncProtection.csrfSynchronisedProtection);
|
||||||
} else {
|
} else {
|
||||||
console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
|
console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
|
||||||
app.get('/csrf-token', (req, res) => {
|
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
|
// User storage module needs to be initialized before starting the server
|
||||||
initUserStorage(dataRoot)
|
initUserStorage(dataRoot)
|
||||||
.then(ensurePublicDirectoriesExist)
|
.then(ensurePublicDirectoriesExist)
|
||||||
@@ -928,4 +948,5 @@ initUserStorage(dataRoot)
|
|||||||
.then(migrateSystemPrompts)
|
.then(migrateSystemPrompts)
|
||||||
.then(verifySecuritySettings)
|
.then(verifySecuritySettings)
|
||||||
.then(preSetupTasks)
|
.then(preSetupTasks)
|
||||||
|
.then(apply404Middleware)
|
||||||
.finally(startServer);
|
.finally(startServer);
|
||||||
|
@@ -37,6 +37,8 @@ import {
|
|||||||
getTiktokenTokenizer,
|
getTiktokenTokenizer,
|
||||||
sentencepieceTokenizers,
|
sentencepieceTokenizers,
|
||||||
TEXT_COMPLETION_MODELS,
|
TEXT_COMPLETION_MODELS,
|
||||||
|
webTokenizers,
|
||||||
|
getWebTokenizer,
|
||||||
} from '../tokenizers.js';
|
} from '../tokenizers.js';
|
||||||
|
|
||||||
const API_OPENAI = 'https://api.openai.com/v1';
|
const API_OPENAI = 'https://api.openai.com/v1';
|
||||||
@@ -865,6 +867,14 @@ router.post('/bias', jsonParser, async function (request, response) {
|
|||||||
return response.send({});
|
return response.send({});
|
||||||
}
|
}
|
||||||
encodeFunction = (text) => new Uint32Array(instance.encodeIds(text));
|
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 {
|
} else {
|
||||||
const tokenizer = getTiktokenTokenizer(model);
|
const tokenizer = getTiktokenTokenizer(model);
|
||||||
encodeFunction = (tokenizer.encode.bind(tokenizer));
|
encodeFunction = (tokenizer.encode.bind(tokenizer));
|
||||||
|
@@ -238,6 +238,15 @@ export const sentencepieceTokenizers = [
|
|||||||
'jamba',
|
'jamba',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const webTokenizers = [
|
||||||
|
'claude',
|
||||||
|
'llama3',
|
||||||
|
'command-r',
|
||||||
|
'qwen2',
|
||||||
|
'nemo',
|
||||||
|
'deepseek',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the Sentencepiece tokenizer by the model name.
|
* Gets the Sentencepiece tokenizer by the model name.
|
||||||
* @param {string} model Sentencepiece model name
|
* @param {string} model Sentencepiece model name
|
||||||
@@ -275,6 +284,39 @@ export function getSentencepiceTokenizer(model) {
|
|||||||
return null;
|
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.
|
* Counts the token ids for the given text using the Sentencepiece tokenizer.
|
||||||
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
|
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
|
||||||
|
@@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
request.session.handle = null;
|
request.session.handle = null;
|
||||||
|
request.session.csrfToken = null;
|
||||||
request.session = null;
|
request.session = null;
|
||||||
return response.sendStatus(204);
|
return response.sendStatus(204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@@ -5,17 +5,18 @@
|
|||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import storage from 'node-persist';
|
import storage from 'node-persist';
|
||||||
import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
|
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 PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false);
|
||||||
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', 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 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 config = getConfig();
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
|
|
||||||
|
@@ -1,10 +1,11 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import Handlebars from 'handlebars';
|
||||||
import ipMatching from 'ip-matching';
|
import ipMatching from 'ip-matching';
|
||||||
|
|
||||||
import { getIpFromRequest } from '../express-common.js';
|
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 whitelistPath = path.join(process.cwd(), './whitelist.txt');
|
||||||
const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
|
const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);
|
||||||
@@ -52,12 +53,16 @@ function getForwardedIp(req) {
|
|||||||
* @returns {import('express').RequestHandler} The middleware function
|
* @returns {import('express').RequestHandler} The middleware function
|
||||||
*/
|
*/
|
||||||
export default function whitelistMiddleware(whitelistMode, listen) {
|
export default function whitelistMiddleware(whitelistMode, listen) {
|
||||||
|
const forbiddenWebpage = Handlebars.compile(
|
||||||
|
safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
return function (req, res, next) {
|
return function (req, res, next) {
|
||||||
const clientIp = getIpFromRequest(req);
|
const clientIp = getIpFromRequest(req);
|
||||||
const forwardedIp = getForwardedIp(req);
|
const forwardedIp = getForwardedIp(req);
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
if (listen && !knownIPs.has(clientIp)) {
|
if (listen && !knownIPs.has(clientIp)) {
|
||||||
const userAgent = req.headers['user-agent'];
|
|
||||||
console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
|
console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
|
||||||
knownIPs.add(clientIp);
|
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)))
|
|| forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
|
||||||
) {
|
) {
|
||||||
// Log the connection attempt with real IP address
|
// Log the connection attempt with real IP address
|
||||||
const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp;
|
const ipDetails = forwardedIp
|
||||||
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'));
|
? `${clientIp} (forwarded from ${forwardedIp})`
|
||||||
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.');
|
: 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();
|
next();
|
||||||
};
|
};
|
||||||
|
11
src/util.js
11
src/util.js
@@ -871,3 +871,14 @@ export class MemoryLimitedMap {
|
|||||||
return this.map[Symbol.iterator]();
|
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<typeof fs.readFileSync>[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;
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user