Merge branch 'staging' into woo-yeah
| @@ -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 |
| @@ -52,3 +53,5 @@ public/scripts/extensions/third-party | |||
| 52 | /certs | 53 | /certs |
| 53 | .aider* | 54 | .aider* |
| 54 | .env | 55 | .env |
| 56 | /StartDev.bat | ||
| 57 | |||
| @@ -70,7 +70,7 @@ perUserBasicAuth: false | |||
| 70 | ## Set to a positive number to expire session after a certain time of inactivity | 70 | ## Set to a positive number to expire session after a certain time of inactivity |
| 71 | ## Set to 0 to expire session when the browser is closed | 71 | ## Set to 0 to expire session when the browser is closed |
| 72 | ## Set to a negative number to disable session expiration | 72 | ## Set to a negative number to disable session expiration |
| 73 | sessionTimeout: 86400 | 73 | sessionTimeout: -1 |
| 74 | # Used to sign session cookies. Will be auto-generated if not set | 74 | # Used to sign session cookies. Will be auto-generated if not set |
| 75 | cookieSecret: '' | 75 | cookieSecret: '' |
| 76 | # Disable CSRF protection - NOT RECOMMENDED | 76 | # Disable CSRF protection - NOT RECOMMENDED |
| @@ -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> | ||
| @@ -1,6 +1,24 @@ | |||
| 1 | import { UserDirectoryList, User } from "./src/users"; | 1 | import { UserDirectoryList, User } from "./src/users"; |
| 2 | import { CsrfSyncedToken } from "csrf-sync"; | ||
| 2 | 3 | ||
| 3 | declare global { | 4 | declare global { |
| 5 | declare namespace CookieSessionInterfaces { | ||
| 6 | export interface CookieSessionObject { | ||
| 7 | /** | ||
| 8 | * The CSRF token for the session. | ||
| 9 | */ | ||
| 10 | csrfToken: CsrfSyncedToken; | ||
| 11 | /** | ||
| 12 | * Authenticated user handle. | ||
| 13 | */ | ||
| 14 | handle: string; | ||
| 15 | /** | ||
| 16 | * Last time the session was extended. | ||
| 17 | */ | ||
| 18 | touch: number; | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 4 | namespace Express { | 22 | namespace Express { |
| 5 | export interface Request { | 23 | export interface Request { |
| 6 | user: { | 24 | user: { |
| @@ -15,11 +33,3 @@ declare global { | |||
| 15 | */ | 33 | */ |
| 16 | var DATA_ROOT: string; | 34 | var DATA_ROOT: string; |
| 17 | } | 35 | } |
| 18 | |||
| 19 | declare module 'express-session' { | ||
| 20 | export interface SessionData { | ||
| 21 | handle: string; | ||
| 22 | touch: number; | ||
| 23 | // other properties... | ||
| 24 | } | ||
| 25 | } | ||
| @@ -26,7 +26,7 @@ | |||
| 26 | "cookie-parser": "^1.4.6", | 26 | "cookie-parser": "^1.4.6", |
| 27 | "cookie-session": "^2.1.0", | 27 | "cookie-session": "^2.1.0", |
| 28 | "cors": "^2.8.5", | 28 | "cors": "^2.8.5", |
| 29 | "csrf-csrf": "^2.2.3", | 29 | "csrf-sync": "^4.0.3", |
| 30 | "diff-match-patch": "^1.0.5", | 30 | "diff-match-patch": "^1.0.5", |
| 31 | "dompurify": "^3.1.7", | 31 | "dompurify": "^3.1.7", |
| 32 | "droll": "^0.2.1", | 32 | "droll": "^0.2.1", |
| @@ -2987,10 +2987,10 @@ | |||
| 2987 | "node": "*" | 2987 | "node": "*" |
| 2988 | } | 2988 | } |
| 2989 | }, | 2989 | }, |
| 2990 | "node_modules/csrf-csrf": { | 2990 | "node_modules/csrf-sync": { |
| 2991 | "version": "2.2.4", | 2991 | "version": "4.0.3", |
| 2992 | "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-2.2.4.tgz", | 2992 | "resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.0.3.tgz", |
| 2993 | "integrity": "sha512-LuhBmy5RfRmEfeqeYqgaAuS1eDpVtKZB/Eiec9xiKQLBynJxrGVRdM2yRT/YMl1Njo/yKh2L9AYsIwSlTPnx2A==", | 2993 | "integrity": "sha512-wXzltBBzt/7imzDt6ZT7G/axQG7jo4Sm0uXDUzFY8hR59qhDHdjqpW2hojS4oAVIZDzwlMQloIVCTJoDDh0wwA==", |
| 2994 | "license": "ISC", | 2994 | "license": "ISC", |
| 2995 | "dependencies": { | 2995 | "dependencies": { |
| 2996 | "http-errors": "^2.0.0" | 2996 | "http-errors": "^2.0.0" |
| @@ -16,7 +16,7 @@ | |||
| 16 | "cookie-parser": "^1.4.6", | 16 | "cookie-parser": "^1.4.6", |
| 17 | "cookie-session": "^2.1.0", | 17 | "cookie-session": "^2.1.0", |
| 18 | "cors": "^2.8.5", | 18 | "cors": "^2.8.5", |
| 19 | "csrf-csrf": "^2.2.3", | 19 | "csrf-sync": "^4.0.3", |
| 20 | "diff-match-patch": "^1.0.5", | 20 | "diff-match-patch": "^1.0.5", |
| 21 | "dompurify": "^3.1.7", | 21 | "dompurify": "^3.1.7", |
| 22 | "droll": "^0.2.1", | 22 | "droll": "^0.2.1", |
| @@ -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 | */ | ||
| 223 | |||
| 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 | ]; | ||
| 220 | 237 | ||
| 221 | for (const file of Object.values(files)) { | 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 | } |
| @@ -18,10 +18,9 @@ import { hideBin } from 'yargs/helpers'; | |||
| 18 | 18 | ||
| 19 | // express/server related library imports | 19 | // express/server related library imports |
| 20 | import cors from 'cors'; | 20 | import cors from 'cors'; |
| 21 | import { doubleCsrf } from 'csrf-csrf'; | 21 | import { csrfSync } from 'csrf-sync'; |
| 22 | import express from 'express'; | 22 | import express from 'express'; |
| 23 | import compression from 'compression'; | 23 | import compression from 'compression'; |
| 24 | import cookieParser from 'cookie-parser'; | ||
| 25 | import cookieSession from 'cookie-session'; | 24 | import cookieSession from 'cookie-session'; |
| 26 | import multer from 'multer'; | 25 | import multer from 'multer'; |
| 27 | import responseTime from 'response-time'; | 26 | import responseTime from 'response-time'; |
| @@ -40,7 +39,6 @@ util.inspect.defaultOptions.depth = 4; | |||
| 40 | import { loadPlugins } from './src/plugin-loader.js'; | 39 | import { loadPlugins } from './src/plugin-loader.js'; |
| 41 | import { | 40 | import { |
| 42 | initUserStorage, | 41 | initUserStorage, |
| 43 | getCsrfSecret, | ||
| 44 | getCookieSecret, | 42 | getCookieSecret, |
| 45 | getCookieSessionName, | 43 | getCookieSessionName, |
| 46 | getAllEnabledUsers, | 44 | getAllEnabledUsers, |
| @@ -67,6 +65,7 @@ import { | |||
| 67 | forwardFetchResponse, | 65 | forwardFetchResponse, |
| 68 | removeColorFormatting, | 66 | removeColorFormatting, |
| 69 | getSeparator, | 67 | getSeparator, |
| 68 | safeReadFileSync, | ||
| 70 | } from './src/util.js'; | 69 | } from './src/util.js'; |
| 71 | import { UPLOADS_DIRECTORY } from './src/constants.js'; | 70 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 72 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; | 71 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; |
| @@ -347,8 +346,8 @@ if (enableCorsProxy) { | |||
| 347 | } | 346 | } |
| 348 | 347 | ||
| 349 | function getSessionCookieAge() { | 348 | function getSessionCookieAge() { |
| 350 | // Defaults to 24 hours in seconds if not set | 349 | // Defaults to "no expiration" if not set |
| 351 | const configValue = getConfigValue('sessionTimeout', 24 * 60 * 60); | 350 | const configValue = getConfigValue('sessionTimeout', -1); |
| 352 | 351 | ||
| 353 | // Convert to milliseconds | 352 | // Convert to milliseconds |
| 354 | if (configValue > 0) { | 353 | if (configValue > 0) { |
| @@ -377,27 +376,38 @@ app.use(setUserDataMiddleware); | |||
| 377 | 376 | ||
| 378 | // CSRF Protection // | 377 | // CSRF Protection // |
| 379 | if (!disableCsrf) { | 378 | if (!disableCsrf) { |
| 380 | const COOKIES_SECRET = getCookieSecret(); | 379 | const csrfSyncProtection = csrfSync({ |
| 381 | 380 | getTokenFromState: (req) => { | |
| 382 | const { generateToken, doubleCsrfProtection } = doubleCsrf({ | 381 | if (!req.session) { |
| 383 | getSecret: getCsrfSecret, | 382 | console.error('(CSRF error) getTokenFromState: Session object not initialized'); |
| 384 | cookieName: 'X-CSRF-Token', | 383 | return; |
| 385 | cookieOptions: { | 384 | } |
| 386 | sameSite: 'strict', | 385 | return req.session.csrfToken; |
| 387 | secure: false, | 386 | }, |
| 387 | getTokenFromRequest: (req) => { | ||
| 388 | return req.headers['x-csrf-token']?.toString(); | ||
| 389 | }, | ||
| 390 | storeTokenInState: (req, token) => { | ||
| 391 | if (!req.session) { | ||
| 392 | console.error('(CSRF error) storeTokenInState: Session object not initialized'); | ||
| 393 | return; | ||
| 394 | } | ||
| 395 | req.session.csrfToken = token; | ||
| 388 | }, | 396 | }, |
| 389 | size: 64, | 397 | size: 32, |
| 390 | getTokenFromRequest: (req) => req.headers['x-csrf-token'], | ||
| 391 | }); | 398 | }); |
| 392 | 399 | ||
| 393 | app.get('/csrf-token', (req, res) => { | 400 | app.get('/csrf-token', (req, res) => { |
| 394 | res.json({ | 401 | res.json({ |
| 395 | 'token': generateToken(res, req), | 402 | 'token': csrfSyncProtection.generateToken(req), |
| 396 | }); | 403 | }); |
| 397 | }); | 404 | }); |
| 398 | 405 | ||
| 399 | app.use(cookieParser(COOKIES_SECRET)); | 406 | // Customize the error message |
| 400 | app.use(doubleCsrfProtection); | 407 | csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); |
| 408 | csrfSyncProtection.invalidCsrfTokenError.stack = undefined; | ||
| 409 | |||
| 410 | app.use(csrfSyncProtection.csrfSynchronisedProtection); | ||
| 401 | } else { | 411 | } else { |
| 402 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); | 412 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); |
| 403 | app.get('/csrf-token', (req, res) => { | 413 | app.get('/csrf-token', (req, res) => { |
| @@ -921,6 +931,16 @@ async function verifySecuritySettings() { | |||
| 921 | } | 931 | } |
| 922 | } | 932 | } |
| 923 | 933 | ||
| 934 | /** | ||
| 935 | * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. | ||
| 936 | */ | ||
| 937 | function apply404Middleware() { | ||
| 938 | const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? ''; | ||
| 939 | app.use((req, res) => { | ||
| 940 | res.status(404).send(notFoundWebpage); | ||
| 941 | }); | ||
| 942 | } | ||
| 943 | |||
| 924 | // User storage module needs to be initialized before starting the server | 944 | // User storage module needs to be initialized before starting the server |
| 925 | initUserStorage(dataRoot) | 945 | initUserStorage(dataRoot) |
| 926 | .then(ensurePublicDirectoriesExist) | 946 | .then(ensurePublicDirectoriesExist) |
| @@ -928,4 +948,5 @@ initUserStorage(dataRoot) | |||
| 928 | .then(migrateSystemPrompts) | 948 | .then(migrateSystemPrompts) |
| 929 | .then(verifySecuritySettings) | 949 | .then(verifySecuritySettings) |
| 930 | .then(preSetupTasks) | 950 | .then(preSetupTasks) |
| 951 | .then(apply404Middleware) | ||
| 931 | .finally(startServer); | 952 | .finally(startServer); |
| @@ -37,6 +37,8 @@ import { | |||
| 37 | getTiktokenTokenizer, | 37 | getTiktokenTokenizer, |
| 38 | sentencepieceTokenizers, | 38 | sentencepieceTokenizers, |
| 39 | TEXT_COMPLETION_MODELS, | 39 | TEXT_COMPLETION_MODELS, |
| 40 | webTokenizers, | ||
| 41 | getWebTokenizer, | ||
| 40 | } from '../tokenizers.js'; | 42 | } from '../tokenizers.js'; |
| 41 | 43 | ||
| 42 | const API_OPENAI = 'https://api.openai.com/v1'; | 44 | const API_OPENAI = 'https://api.openai.com/v1'; |
| @@ -865,6 +867,14 @@ router.post('/bias', jsonParser, async function (request, response) { | |||
| 865 | return response.send({}); | 867 | return response.send({}); |
| 866 | } | 868 | } |
| 867 | encodeFunction = (text) => new Uint32Array(instance.encodeIds(text)); | 869 | encodeFunction = (text) => new Uint32Array(instance.encodeIds(text)); |
| 870 | } else if (webTokenizers.includes(model)) { | ||
| 871 | const tokenizer = getWebTokenizer(model); | ||
| 872 | const instance = await tokenizer?.get(); | ||
| 873 | if (!instance) { | ||
| 874 | console.warn('Tokenizer not initialized:', model); | ||
| 875 | return response.send({}); | ||
| 876 | } | ||
| 877 | encodeFunction = (text) => new Uint32Array(instance.encode(text)); | ||
| 868 | } else { | 878 | } else { |
| 869 | const tokenizer = getTiktokenTokenizer(model); | 879 | const tokenizer = getTiktokenTokenizer(model); |
| 870 | encodeFunction = (tokenizer.encode.bind(tokenizer)); | 880 | encodeFunction = (tokenizer.encode.bind(tokenizer)); |
| @@ -238,6 +238,15 @@ export const sentencepieceTokenizers = [ | |||
| 238 | 'jamba', | 238 | 'jamba', |
| 239 | ]; | 239 | ]; |
| 240 | 240 | ||
| 241 | export const webTokenizers = [ | ||
| 242 | 'claude', | ||
| 243 | 'llama3', | ||
| 244 | 'command-r', | ||
| 245 | 'qwen2', | ||
| 246 | 'nemo', | ||
| 247 | 'deepseek', | ||
| 248 | ]; | ||
| 249 | |||
| 241 | /** | 250 | /** |
| 242 | * Gets the Sentencepiece tokenizer by the model name. | 251 | * Gets the Sentencepiece tokenizer by the model name. |
| 243 | * @param {string} model Sentencepiece model name | 252 | * @param {string} model Sentencepiece model name |
| @@ -276,6 +285,39 @@ export function getSentencepiceTokenizer(model) { | |||
| 276 | } | 285 | } |
| 277 | 286 | ||
| 278 | /** | 287 | /** |
| 288 | * Gets the Web tokenizer by the model name. | ||
| 289 | * @param {string} model Web tokenizer model name | ||
| 290 | * @returns {WebTokenizer|null} Web tokenizer | ||
| 291 | */ | ||
| 292 | export function getWebTokenizer(model) { | ||
| 293 | if (model.includes('llama3')) { | ||
| 294 | return llama3_tokenizer; | ||
| 295 | } | ||
| 296 | |||
| 297 | if (model.includes('claude')) { | ||
| 298 | return claude_tokenizer; | ||
| 299 | } | ||
| 300 | |||
| 301 | if (model.includes('command-r')) { | ||
| 302 | return commandTokenizer; | ||
| 303 | } | ||
| 304 | |||
| 305 | if (model.includes('qwen2')) { | ||
| 306 | return qwen2Tokenizer; | ||
| 307 | } | ||
| 308 | |||
| 309 | if (model.includes('nemo')) { | ||
| 310 | return nemoTokenizer; | ||
| 311 | } | ||
| 312 | |||
| 313 | if (model.includes('deepseek')) { | ||
| 314 | return deepseekTokenizer; | ||
| 315 | } | ||
| 316 | |||
| 317 | return null; | ||
| 318 | } | ||
| 319 | |||
| 320 | /** | ||
| 279 | * Counts the token ids for the given text using the Sentencepiece tokenizer. | 321 | * Counts the token ids for the given text using the Sentencepiece tokenizer. |
| 280 | * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer | 322 | * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer |
| 281 | * @param {string} text Text to tokenize | 323 | * @param {string} text Text to tokenize |
| @@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => { | |||
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | request.session.handle = null; | 25 | request.session.handle = null; |
| 26 | request.session.csrfToken = null; | ||
| 26 | request.session = null; | 27 | request.session = null; |
| 27 | return response.sendStatus(204); | 28 | return response.sendStatus(204); |
| 28 | } catch (error) { | 29 | } catch (error) { |
| @@ -5,17 +5,18 @@ | |||
| 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 basicAuthMiddleware = async function (request, response, callback) { | ||
| 14 | const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? ''; | ||
| 13 | const unauthorizedResponse = (res) => { | 15 | const unauthorizedResponse = (res) => { |
| 14 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); | 16 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); |
| 15 | return res.status(401).send('Authentication required'); | 17 | return res.status(401).send(unauthorizedWebpage); |
| 16 | }; | 18 | }; |
| 17 | 19 | ||
| 18 | const basicAuthMiddleware = async function (request, response, callback) { | ||
| 19 | const config = getConfig(); | 20 | const config = getConfig(); |
| 20 | const authHeader = request.headers.authorization; | 21 | const authHeader = request.headers.authorization; |
| 21 | 22 | ||
| @@ -1,10 +1,11 @@ | |||
| 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); |
| @@ -52,12 +53,16 @@ function getForwardedIp(req) { | |||
| 52 | * @returns {import('express').RequestHandler} The middleware function | 53 | * @returns {import('express').RequestHandler} The middleware function |
| 53 | */ | 54 | */ |
| 54 | export default function whitelistMiddleware(whitelistMode, listen) { | 55 | export default function whitelistMiddleware(whitelistMode, listen) { |
| 56 | const forbiddenWebpage = Handlebars.compile( | ||
| 57 | safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', | ||
| 58 | ); | ||
| 59 | |||
| 55 | return function (req, res, next) { | 60 | return function (req, res, next) { |
| 56 | const clientIp = getIpFromRequest(req); | 61 | const clientIp = getIpFromRequest(req); |
| 57 | const forwardedIp = getForwardedIp(req); | 62 | const forwardedIp = getForwardedIp(req); |
| 63 | const userAgent = req.headers['user-agent']; | ||
| 58 | 64 | ||
| 59 | if (listen && !knownIPs.has(clientIp)) { | 65 | 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`)); | 66 | console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); |
| 62 | knownIPs.add(clientIp); | 67 | knownIPs.add(clientIp); |
| 63 | 68 | ||
| @@ -76,9 +81,15 @@ export default function whitelistMiddleware(whitelistMode, listen) { | |||
| 76 | || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) | 81 | || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) |
| 77 | ) { | 82 | ) { |
| 78 | // Log the connection attempt with real IP address | 83 | // Log the connection attempt with real IP address |
| 79 | const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp; | 84 | 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')); | 85 | ? `${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.'); | 86 | : clientIp; |
| 87 | console.log( | ||
| 88 | color.red( | ||
| 89 | `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`, | ||
| 90 | ), | ||
| 91 | ); | ||
| 92 | return res.status(403).send(forbiddenWebpage({ ipDetails })); | ||
| 82 | } | 93 | } |
| 83 | next(); | 94 | next(); |
| 84 | }; | 95 | }; |
| @@ -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 | } | ||