Merge pull request #3342 from Spappz/staging Allow customisation of the 403 page

44ad69ceca223f3c69bc908b017235e4bbebd53b

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

Signed
10 files changed, +146 -17Ignore whitespace
.gitignore+1 -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
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>
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 */
220223
221 for (const file of Object.values(files)) {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 ];
237
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+12 -0
@@ -67,6 +67,7 @@ import {
67 forwardFetchResponse,67 forwardFetchResponse,
68 removeColorFormatting,68 removeColorFormatting,
69 getSeparator,69 getSeparator,
70 safeReadFileSync,
70} from './src/util.js';71} from './src/util.js';
71import { UPLOADS_DIRECTORY } from './src/constants.js';72import { UPLOADS_DIRECTORY } from './src/constants.js';
72import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';73import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
@@ -921,6 +922,16 @@ async function verifySecuritySettings() {
921 }922 }
922}923}
923924
925/**
926 * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
927 */
928function apply404Middleware() {
929 const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? '';
930 app.use((req, res) => {
931 res.status(404).send(notFoundWebpage);
932 });
933}
934
924// User storage module needs to be initialized before starting the server935// User storage module needs to be initialized before starting the server
925initUserStorage(dataRoot)936initUserStorage(dataRoot)
926 .then(ensurePublicDirectoriesExist)937 .then(ensurePublicDirectoriesExist)
@@ -928,4 +939,5 @@ initUserStorage(dataRoot)
928 .then(migrateSystemPrompts)939 .then(migrateSystemPrompts)
929 .then(verifySecuritySettings)940 .then(verifySecuritySettings)
930 .then(preSetupTasks)941 .then(preSetupTasks)
942 .then(apply404Middleware)
931 .finally(startServer);943 .finally(startServer);
src/middleware/basicAuth.js+3 -2
@@ -5,14 +5,15 @@
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 unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';
13const unauthorizedResponse = (res) => {14const unauthorizedResponse = (res) => {
14 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');15 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
15 return res.status(401).send('Authentication required');16 return res.status(401).send(unauthorizedWebpage);
16};17};
1718
18const basicAuthMiddleware = async function (request, response, callback) {19const basicAuthMiddleware = async function (request, response, callback) {
src/middleware/whitelist.js+15 -5
@@ -1,15 +1,19 @@
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);
11let whitelist = getConfigValue('whitelist', []);12let whitelist = getConfigValue('whitelist', []);
12let knownIPs = new Set();13let knownIPs = new Set();
14const forbiddenWebpage = Handlebars.compile(
15 safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
16);
1317
14if (fs.existsSync(whitelistPath)) {18if (fs.existsSync(whitelistPath)) {
15 try {19 try {
@@ -55,9 +59,9 @@ export default function whitelistMiddleware(whitelistMode, listen) {
55 return function (req, res, next) {59 return function (req, res, next) {
56 const clientIp = getIpFromRequest(req);60 const clientIp = getIpFromRequest(req);
57 const forwardedIp = getForwardedIp(req);61 const forwardedIp = getForwardedIp(req);
62 const userAgent = req.headers['user-agent'];
5863
59 if (listen && !knownIPs.has(clientIp)) {64 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`));65 console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
62 knownIPs.add(clientIp);66 knownIPs.add(clientIp);
6367
@@ -76,9 +80,15 @@ export default function whitelistMiddleware(whitelistMode, listen) {
76 || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))80 || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
77 ) {81 ) {
78 // Log the connection attempt with real IP address82 // Log the connection attempt with real IP address
79 const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp;83 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'));84 ? `${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.');85 : clientIp;
86 console.log(
87 color.red(
88 `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`,
89 ),
90 );
91 return res.status(403).send(forbiddenWebpage({ ipDetails }));
82 }92 }
83 next();93 next();
84 };94 };
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}