Merge pull request #3342 from Spappz/staging

Allow customisation of the 403 page
This commit is contained in:
Cohee 2025-01-25 23:00:23 +02:00 committed by GitHub
commit 44ad69ceca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 146 additions and 17 deletions

1
.gitignore vendored
View File

@ -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

View 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>

View 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>

View 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>

View File

@ -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,
);
} }
} }
} }

View File

@ -67,6 +67,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';
@ -921,6 +922,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 +939,5 @@ initUserStorage(dataRoot)
.then(migrateSystemPrompts) .then(migrateSystemPrompts)
.then(verifySecuritySettings) .then(verifySecuritySettings)
.then(preSetupTasks) .then(preSetupTasks)
.then(apply404Middleware)
.finally(startServer); .finally(startServer);

View File

@ -5,14 +5,15 @@
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 unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';
const unauthorizedResponse = (res) => { const unauthorizedResponse = (res) => {
res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
return res.status(401).send('Authentication required'); return res.status(401).send(unauthorizedWebpage);
}; };
const basicAuthMiddleware = async function (request, response, callback) { const basicAuthMiddleware = async function (request, response, callback) {

View File

@ -1,15 +1,19 @@
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);
let whitelist = getConfigValue('whitelist', []); let whitelist = getConfigValue('whitelist', []);
let knownIPs = new Set(); let knownIPs = new Set();
const forbiddenWebpage = Handlebars.compile(
safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
);
if (fs.existsSync(whitelistPath)) { if (fs.existsSync(whitelistPath)) {
try { try {
@ -55,9 +59,9 @@ export default function whitelistMiddleware(whitelistMode, listen) {
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 +80,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();
}; };

View File

@ -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;
}