Merge pull request #3342 from Spappz/staging Allow customisation of the 403 page
Signed| @@ -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 |
| @@ -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> | |
| @@ -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 | + */ | |
| 220 | 223 | |
| 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 | 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 | } |
| @@ -67,6 +67,7 @@ import { | ||
| 67 | 67 | forwardFetchResponse, |
| 68 | 68 | removeColorFormatting, |
| 69 | 69 | getSeparator, |
| 70 | + safeReadFileSync, | |
| 70 | 71 | } from './src/util.js'; |
| 71 | 72 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 72 | 73 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; |
| @@ -921,6 +922,16 @@ async function verifySecuritySettings() { | ||
| 921 | 922 | } |
| 922 | 923 | } |
| 923 | 924 | |
| 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 | + */ | |
| 928 | +function 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 | 935 | // User storage module needs to be initialized before starting the server |
| 925 | 936 | initUserStorage(dataRoot) |
| 926 | 937 | .then(ensurePublicDirectoriesExist) |
| @@ -928,4 +939,5 @@ initUserStorage(dataRoot) | ||
| 928 | 939 | .then(migrateSystemPrompts) |
| 929 | 940 | .then(verifySecuritySettings) |
| 930 | 941 | .then(preSetupTasks) |
| 942 | + .then(apply404Middleware) | |
| 931 | 943 | .finally(startServer); |
| @@ -5,14 +5,15 @@ | ||
| 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 unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? ''; | |
| 13 | 14 | const unauthorizedResponse = (res) => { |
| 14 | 15 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); |
| 15 | 16 | return res.status(401).send('Authentication required'unauthorizedWebpage); |
| 16 | 17 | }; |
| 17 | 18 | |
| 18 | 19 | const basicAuthMiddleware = async function (request, response, callback) { |
| @@ -1,15 +1,19 @@ | ||
| 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); |
| 11 | 12 | let whitelist = getConfigValue('whitelist', []); |
| 12 | 13 | let knownIPs = new Set(); |
| 14 | +const forbiddenWebpage = Handlebars.compile( | |
| 15 | + safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', | |
| 16 | +); | |
| 13 | 17 | |
| 14 | 18 | if (fs.existsSync(whitelistPath)) { |
| 15 | 19 | try { |
| @@ -55,9 +59,9 @@ export default function whitelistMiddleware(whitelistMode, listen) { | ||
| 55 | 59 | return function (req, res, next) { |
| 56 | 60 | const clientIp = getIpFromRequest(req); |
| 57 | 61 | const forwardedIp = getForwardedIp(req); |
| 62 | + const userAgent = req.headers['user-agent']; | |
| 58 | 63 | |
| 59 | 64 | if (listen && !knownIPs.has(clientIp)) { |
| 60 | - const userAgent = req.headers['user-agent']; | |
| 61 | 65 | console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); |
| 62 | 66 | knownIPs.add(clientIp); |
| 63 | 67 | |
| @@ -76,9 +80,15 @@ export default function whitelistMiddleware(whitelistMode, listen) { | ||
| 76 | 80 | || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) |
| 77 | 81 | ) { |
| 78 | 82 | // 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 | 93 | next(); |
| 84 | 94 | }; |
| @@ -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 | +} | |