Merge pull request #3342 from Spappz/staging Allow customisation of the 403 page
Signed| @@ -45,6 +45,7 @@ access.log | |||
| 45 | /vectors/ | 45 | /vectors/ |
| 46 | /cache/ | 46 | /cache/ |
| 47 | public/css/user.css | 47 | public/css/user.css |
| 48 | public/error/ | ||
| 48 | /plugins/ | 49 | /plugins/ |
| 49 | /data | 50 | /data |
| 50 | /default/scaffold | 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 | * 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 | */ |
| 215 | function createDefaultFiles() { | 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 | 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 | } |
| @@ -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'; |
| 71 | import { UPLOADS_DIRECTORY } from './src/constants.js'; | 72 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 72 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; | 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 | // User storage module needs to be initialized before starting the server | 935 | // User storage module needs to be initialized before starting the server |
| 925 | initUserStorage(dataRoot) | 936 | initUserStorage(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); |
| @@ -5,14 +5,15 @@ | |||
| 5 | import { Buffer } from 'node:buffer'; | 5 | import { Buffer } from 'node:buffer'; |
| 6 | import storage from 'node-persist'; | 6 | import storage from 'node-persist'; |
| 7 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; | 7 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; |
| 8 | import { getConfig, getConfigValue } from '../util.js'; | 8 | import { getConfig, getConfigValue, safeReadFileSync } from '../util.js'; |
| 9 | 9 | ||
| 10 | const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); | 10 | const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); |
| 11 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); | 11 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); |
| 12 | 12 | ||
| 13 | const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? ''; | ||
| 13 | const unauthorizedResponse = (res) => { | 14 | const 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 | }; |
| 17 | 18 | ||
| 18 | const basicAuthMiddleware = async function (request, response, callback) { | 19 | const basicAuthMiddleware = async function (request, response, callback) { |
| @@ -1,15 +1,19 @@ | |||
| 1 | import path from 'node:path'; | 1 | import path from 'node:path'; |
| 2 | import fs from 'node:fs'; | 2 | import fs from 'node:fs'; |
| 3 | import process from 'node:process'; | 3 | import process from 'node:process'; |
| 4 | import Handlebars from 'handlebars'; | ||
| 4 | import ipMatching from 'ip-matching'; | 5 | import ipMatching from 'ip-matching'; |
| 5 | 6 | ||
| 6 | import { getIpFromRequest } from '../express-common.js'; | 7 | import { getIpFromRequest } from '../express-common.js'; |
| 7 | import { color, getConfigValue } from '../util.js'; | 8 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; |
| 8 | 9 | ||
| 9 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); | 10 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); |
| 10 | const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); | 11 | const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); |
| 11 | let whitelist = getConfigValue('whitelist', []); | 12 | let whitelist = getConfigValue('whitelist', []); |
| 12 | let knownIPs = new Set(); | 13 | let knownIPs = new Set(); |
| 14 | const forbiddenWebpage = Handlebars.compile( | ||
| 15 | safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', | ||
| 16 | ); | ||
| 13 | 17 | ||
| 14 | if (fs.existsSync(whitelistPath)) { | 18 | if (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']; | ||
| 58 | 63 | ||
| 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); |
| 63 | 67 | ||
| @@ -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 address | 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 | next(); | 93 | next(); |
| 84 | }; | 94 | }; |
| @@ -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 | */ | ||
| 881 | export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { | ||
| 882 | if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); | ||
| 883 | return null; | ||
| 884 | } | ||