Merge pull request #3857 from wrvsrx/allow-readonly-install Allow read-only installation
Signed| @@ -13,3 +13,4 @@ access.log | |||
| 13 | /cache | 13 | /cache |
| 14 | .DS_Store | 14 | .DS_Store |
| 15 | /public/scripts/extensions/third-party | 15 | /public/scripts/extensions/third-party |
| 16 | /colab | ||
| @@ -350,6 +350,7 @@ Start.bat --port 8000 --listen false | |||
| 350 | | Option | Description | Type | | 350 | | Option | Description | Type | |
| 351 | |-------------------------|----------------------------------------------------------------------|----------| | 351 | |-------------------------|----------------------------------------------------------------------|----------| |
| 352 | | `--version` | Show version number | boolean | | 352 | | `--version` | Show version number | boolean | |
| 353 | | `--configPath` | Override the path to the config.yaml file | string | | ||
| 353 | | `--dataRoot` | Root directory for data storage | string | | 354 | | `--dataRoot` | Root directory for data storage | string | |
| 354 | | `--port` | Sets the port under which SillyTavern will run | number | | 355 | | `--port` | Sets the port under which SillyTavern will run | number | |
| 355 | | `--listen` | SillyTavern will listen on all network interfaces | boolean | | 356 | | `--listen` | SillyTavern will listen on all network interfaces | boolean | |
| @@ -12,3 +12,4 @@ access.log | |||
| 12 | .vscode | 12 | .vscode |
| 13 | .git | 13 | .git |
| 14 | /public/scripts/extensions/third-party | 14 | /public/scripts/extensions/third-party |
| 15 | /colab | ||
| @@ -12,15 +12,13 @@ WORKDIR ${APP_HOME} | |||
| 12 | # Set NODE_ENV to production | 12 | # Set NODE_ENV to production |
| 13 | ENV NODE_ENV=production | 13 | ENV NODE_ENV=production |
| 14 | 14 | ||
| 15 | # Install app dependencies | 15 | # Bundle app source |
| 16 | COPY package*.json post-install.js ./ | 16 | COPY . ./ |
| 17 | |||
| 17 | RUN \ | 18 | RUN \ |
| 18 | echo "*** Install npm packages ***" && \ | 19 | echo "*** Install npm packages ***" && \ |
| 19 | npm i --no-audit --no-fund --loglevel=error --no-progress --omit=dev && npm cache clean --force | 20 | npm i --no-audit --no-fund --loglevel=error --no-progress --omit=dev && npm cache clean --force |
| 20 | 21 | ||
| 21 | # Bundle app source | ||
| 22 | COPY . ./ | ||
| 23 | |||
| 24 | # Copy default chats, characters and user avatars to <folder>.default folder | 22 | # Copy default chats, characters and user avatars to <folder>.default folder |
| 25 | RUN \ | 23 | RUN \ |
| 26 | rm -f "config.yaml" || true && \ | 24 | rm -f "config.yaml" || true && \ |
| @@ -3,133 +3,17 @@ | |||
| 3 | */ | 3 | */ |
| 4 | import fs from 'node:fs'; | 4 | import fs from 'node:fs'; |
| 5 | import path from 'node:path'; | 5 | import path from 'node:path'; |
| 6 | import crypto from 'node:crypto'; | ||
| 7 | import process from 'node:process'; | 6 | import process from 'node:process'; |
| 8 | import yaml from 'yaml'; | 7 | import yaml from 'yaml'; |
| 9 | import _ from 'lodash'; | ||
| 10 | import chalk from 'chalk'; | 8 | import chalk from 'chalk'; |
| 11 | import { createRequire } from 'node:module'; | 9 | import { createRequire } from 'node:module'; |
| 10 | import { addMissingConfigValues } from './src/config-init.js'; | ||
| 12 | 11 | ||
| 13 | /** | 12 | /** |
| 14 | * Colorizes console output. | 13 | * Colorizes console output. |
| 15 | */ | 14 | */ |
| 16 | const color = chalk; | 15 | const color = chalk; |
| 17 | 16 | ||
| 18 | const keyMigrationMap = [ | ||
| 19 | { | ||
| 20 | oldKey: 'disableThumbnails', | ||
| 21 | newKey: 'thumbnails.enabled', | ||
| 22 | migrate: (value) => !value, | ||
| 23 | }, | ||
| 24 | { | ||
| 25 | oldKey: 'thumbnailsQuality', | ||
| 26 | newKey: 'thumbnails.quality', | ||
| 27 | migrate: (value) => value, | ||
| 28 | }, | ||
| 29 | { | ||
| 30 | oldKey: 'avatarThumbnailsPng', | ||
| 31 | newKey: 'thumbnails.format', | ||
| 32 | migrate: (value) => (value ? 'png' : 'jpg'), | ||
| 33 | }, | ||
| 34 | { | ||
| 35 | oldKey: 'disableChatBackup', | ||
| 36 | newKey: 'backups.chat.enabled', | ||
| 37 | migrate: (value) => !value, | ||
| 38 | }, | ||
| 39 | { | ||
| 40 | oldKey: 'numberOfBackups', | ||
| 41 | newKey: 'backups.common.numberOfBackups', | ||
| 42 | migrate: (value) => value, | ||
| 43 | }, | ||
| 44 | { | ||
| 45 | oldKey: 'maxTotalChatBackups', | ||
| 46 | newKey: 'backups.chat.maxTotalBackups', | ||
| 47 | migrate: (value) => value, | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | oldKey: 'chatBackupThrottleInterval', | ||
| 51 | newKey: 'backups.chat.throttleInterval', | ||
| 52 | migrate: (value) => value, | ||
| 53 | }, | ||
| 54 | { | ||
| 55 | oldKey: 'enableExtensions', | ||
| 56 | newKey: 'extensions.enabled', | ||
| 57 | migrate: (value) => value, | ||
| 58 | }, | ||
| 59 | { | ||
| 60 | oldKey: 'enableExtensionsAutoUpdate', | ||
| 61 | newKey: 'extensions.autoUpdate', | ||
| 62 | migrate: (value) => value, | ||
| 63 | }, | ||
| 64 | { | ||
| 65 | oldKey: 'extras.disableAutoDownload', | ||
| 66 | newKey: 'extensions.models.autoDownload', | ||
| 67 | migrate: (value) => !value, | ||
| 68 | }, | ||
| 69 | { | ||
| 70 | oldKey: 'extras.classificationModel', | ||
| 71 | newKey: 'extensions.models.classification', | ||
| 72 | migrate: (value) => value, | ||
| 73 | }, | ||
| 74 | { | ||
| 75 | oldKey: 'extras.captioningModel', | ||
| 76 | newKey: 'extensions.models.captioning', | ||
| 77 | migrate: (value) => value, | ||
| 78 | }, | ||
| 79 | { | ||
| 80 | oldKey: 'extras.embeddingModel', | ||
| 81 | newKey: 'extensions.models.embedding', | ||
| 82 | migrate: (value) => value, | ||
| 83 | }, | ||
| 84 | { | ||
| 85 | oldKey: 'extras.speechToTextModel', | ||
| 86 | newKey: 'extensions.models.speechToText', | ||
| 87 | migrate: (value) => value, | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | oldKey: 'extras.textToSpeechModel', | ||
| 91 | newKey: 'extensions.models.textToSpeech', | ||
| 92 | migrate: (value) => value, | ||
| 93 | }, | ||
| 94 | { | ||
| 95 | oldKey: 'minLogLevel', | ||
| 96 | newKey: 'logging.minLogLevel', | ||
| 97 | migrate: (value) => value, | ||
| 98 | }, | ||
| 99 | { | ||
| 100 | oldKey: 'cardsCacheCapacity', | ||
| 101 | newKey: 'performance.memoryCacheCapacity', | ||
| 102 | migrate: (value) => `${value}mb`, | ||
| 103 | }, | ||
| 104 | { | ||
| 105 | oldKey: 'cookieSecret', | ||
| 106 | newKey: 'cookieSecret', | ||
| 107 | migrate: () => void 0, | ||
| 108 | remove: true, | ||
| 109 | }, | ||
| 110 | ]; | ||
| 111 | |||
| 112 | /** | ||
| 113 | * Gets all keys from an object recursively. | ||
| 114 | * @param {object} obj Object to get all keys from | ||
| 115 | * @param {string} prefix Prefix to prepend to all keys | ||
| 116 | * @returns {string[]} Array of all keys in the object | ||
| 117 | */ | ||
| 118 | function getAllKeys(obj, prefix = '') { | ||
| 119 | if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) { | ||
| 120 | return []; | ||
| 121 | } | ||
| 122 | |||
| 123 | return _.flatMap(Object.keys(obj), key => { | ||
| 124 | const newPrefix = prefix ? `${prefix}.${key}` : key; | ||
| 125 | if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) { | ||
| 126 | return getAllKeys(obj[key], newPrefix); | ||
| 127 | } else { | ||
| 128 | return [newPrefix]; | ||
| 129 | } | ||
| 130 | }); | ||
| 131 | } | ||
| 132 | |||
| 133 | /** | 17 | /** |
| 134 | * Converts the old config.conf file to the new config.yaml format. | 18 | * Converts the old config.conf file to the new config.yaml format. |
| 135 | */ | 19 | */ |
| @@ -157,71 +41,6 @@ function convertConfig() { | |||
| 157 | } | 41 | } |
| 158 | 42 | ||
| 159 | /** | 43 | /** |
| 160 | * Compares the current config.yaml with the default config.yaml and adds any missing values. | ||
| 161 | */ | ||
| 162 | function addMissingConfigValues() { | ||
| 163 | try { | ||
| 164 | const defaultConfig = yaml.parse(fs.readFileSync(path.join(process.cwd(), './default/config.yaml'), 'utf8')); | ||
| 165 | let config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8')); | ||
| 166 | |||
| 167 | // Migrate old keys to new keys | ||
| 168 | const migratedKeys = []; | ||
| 169 | for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) { | ||
| 170 | if (_.has(config, oldKey)) { | ||
| 171 | if (remove) { | ||
| 172 | _.unset(config, oldKey); | ||
| 173 | migratedKeys.push({ | ||
| 174 | oldKey, | ||
| 175 | newValue: void 0, | ||
| 176 | }); | ||
| 177 | continue; | ||
| 178 | } | ||
| 179 | |||
| 180 | const oldValue = _.get(config, oldKey); | ||
| 181 | const newValue = migrate(oldValue); | ||
| 182 | _.set(config, newKey, newValue); | ||
| 183 | _.unset(config, oldKey); | ||
| 184 | |||
| 185 | migratedKeys.push({ | ||
| 186 | oldKey, | ||
| 187 | newKey, | ||
| 188 | oldValue, | ||
| 189 | newValue, | ||
| 190 | }); | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | // Get all keys from the original config | ||
| 195 | const originalKeys = getAllKeys(config); | ||
| 196 | |||
| 197 | // Use lodash's defaultsDeep function to recursively apply default properties | ||
| 198 | config = _.defaultsDeep(config, defaultConfig); | ||
| 199 | |||
| 200 | // Get all keys from the updated config | ||
| 201 | const updatedKeys = getAllKeys(config); | ||
| 202 | |||
| 203 | // Find the keys that were added | ||
| 204 | const addedKeys = _.difference(updatedKeys, originalKeys); | ||
| 205 | |||
| 206 | if (addedKeys.length === 0 && migratedKeys.length === 0) { | ||
| 207 | return; | ||
| 208 | } | ||
| 209 | |||
| 210 | if (addedKeys.length > 0) { | ||
| 211 | console.log('Adding missing config values to config.yaml:', addedKeys); | ||
| 212 | } | ||
| 213 | |||
| 214 | if (migratedKeys.length > 0) { | ||
| 215 | console.log('Migrating config values in config.yaml:', migratedKeys); | ||
| 216 | } | ||
| 217 | |||
| 218 | fs.writeFileSync('./config.yaml', yaml.stringify(config)); | ||
| 219 | } catch (error) { | ||
| 220 | console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error); | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | /** | ||
| 225 | * Creates the default config files if they don't exist yet. | 44 | * Creates the default config files if they don't exist yet. |
| 226 | */ | 45 | */ |
| 227 | function createDefaultFiles() { | 46 | function createDefaultFiles() { |
| @@ -283,58 +102,13 @@ function createDefaultFiles() { | |||
| 283 | } | 102 | } |
| 284 | } | 103 | } |
| 285 | 104 | ||
| 286 | /** | ||
| 287 | * Returns the MD5 hash of the given data. | ||
| 288 | * @param {Buffer} data Input data | ||
| 289 | * @returns {string} MD5 hash of the input data | ||
| 290 | */ | ||
| 291 | function getMd5Hash(data) { | ||
| 292 | return crypto | ||
| 293 | .createHash('md5') | ||
| 294 | .update(new Uint8Array(data)) | ||
| 295 | .digest('hex'); | ||
| 296 | } | ||
| 297 | |||
| 298 | /** | ||
| 299 | * Copies the WASM binaries from the sillytavern-transformers package to the dist folder. | ||
| 300 | */ | ||
| 301 | function copyWasmFiles() { | ||
| 302 | if (!fs.existsSync('./dist')) { | ||
| 303 | fs.mkdirSync('./dist'); | ||
| 304 | } | ||
| 305 | |||
| 306 | const listDir = fs.readdirSync('./node_modules/sillytavern-transformers/dist'); | ||
| 307 | |||
| 308 | for (const file of listDir) { | ||
| 309 | if (file.endsWith('.wasm')) { | ||
| 310 | const sourcePath = `./node_modules/sillytavern-transformers/dist/${file}`; | ||
| 311 | const targetPath = `./dist/${file}`; | ||
| 312 | |||
| 313 | // Don't copy if the file already exists and is the same checksum | ||
| 314 | if (fs.existsSync(targetPath)) { | ||
| 315 | const sourceChecksum = getMd5Hash(fs.readFileSync(sourcePath)); | ||
| 316 | const targetChecksum = getMd5Hash(fs.readFileSync(targetPath)); | ||
| 317 | |||
| 318 | if (sourceChecksum === targetChecksum) { | ||
| 319 | continue; | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | fs.copyFileSync(sourcePath, targetPath); | ||
| 324 | console.log(`${file} successfully copied to ./dist/${file}`); | ||
| 325 | } | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | try { | 105 | try { |
| 330 | // 0. Convert config.conf to config.yaml | 106 | // 0. Convert config.conf to config.yaml |
| 331 | convertConfig(); | 107 | convertConfig(); |
| 332 | // 1. Create default config files | 108 | // 1. Create default config files |
| 333 | createDefaultFiles(); | 109 | createDefaultFiles(); |
| 334 | // 2. Copy transformers WASM binaries from node_modules | 110 | // 2. Add missing config values |
| 335 | copyWasmFiles(); | 111 | addMissingConfigValues(path.join(process.cwd(), './config.yaml')); |
| 336 | // 3. Add missing config values | ||
| 337 | addMissingConfigValues(); | ||
| 338 | } catch (error) { | 112 | } catch (error) { |
| 339 | console.error(error); | 113 | console.error(error); |
| 340 | } | 114 | } |
| @@ -1,393 +1,15 @@ | |||
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | |||
| 3 | // native node modules | ||
| 4 | import path from 'node:path'; | ||
| 5 | import util from 'node:util'; | ||
| 6 | import net from 'node:net'; | ||
| 7 | import dns from 'node:dns'; | ||
| 8 | import process from 'node:process'; | ||
| 9 | import { fileURLToPath } from 'node:url'; | ||
| 10 | |||
| 11 | import cors from 'cors'; | ||
| 12 | import { csrfSync } from 'csrf-sync'; | ||
| 13 | import express from 'express'; | ||
| 14 | import compression from 'compression'; | ||
| 15 | import cookieSession from 'cookie-session'; | ||
| 16 | import multer from 'multer'; | ||
| 17 | import responseTime from 'response-time'; | ||
| 18 | import helmet from 'helmet'; | ||
| 19 | import bodyParser from 'body-parser'; | ||
| 20 | import open from 'open'; | ||
| 21 | |||
| 22 | // local library imports | ||
| 23 | import './src/fetch-patch.js'; | ||
| 24 | import { serverEvents, EVENT_NAMES } from './src/server-events.js'; | ||
| 25 | import { CommandLineParser } from './src/command-line.js'; | 2 | import { CommandLineParser } from './src/command-line.js'; |
| 26 | import { loadPlugins } from './src/plugin-loader.js'; | 3 | import { serverDirectory } from './src/server-directory.js'; |
| 27 | import { | ||
| 28 | initUserStorage, | ||
| 29 | getCookieSecret, | ||
| 30 | getCookieSessionName, | ||
| 31 | ensurePublicDirectoriesExist, | ||
| 32 | getUserDirectoriesList, | ||
| 33 | migrateSystemPrompts, | ||
| 34 | migrateUserData, | ||
| 35 | requireLoginMiddleware, | ||
| 36 | setUserDataMiddleware, | ||
| 37 | shouldRedirectToLogin, | ||
| 38 | cleanUploads, | ||
| 39 | getSessionCookieAge, | ||
| 40 | verifySecuritySettings, | ||
| 41 | loginPageMiddleware, | ||
| 42 | } from './src/users.js'; | ||
| 43 | |||
| 44 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; | ||
| 45 | import basicAuthMiddleware from './src/middleware/basicAuth.js'; | ||
| 46 | import getWhitelistMiddleware from './src/middleware/whitelist.js'; | ||
| 47 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js'; | ||
| 48 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; | ||
| 49 | import initRequestProxy from './src/request-proxy.js'; | ||
| 50 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; | ||
| 51 | import corsProxyMiddleware from './src/middleware/corsProxy.js'; | ||
| 52 | import { | ||
| 53 | getVersion, | ||
| 54 | color, | ||
| 55 | removeColorFormatting, | ||
| 56 | getSeparator, | ||
| 57 | safeReadFileSync, | ||
| 58 | setupLogLevel, | ||
| 59 | setWindowTitle, | ||
| 60 | } from './src/util.js'; | ||
| 61 | import { UPLOADS_DIRECTORY } from './src/constants.js'; | ||
| 62 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; | ||
| 63 | |||
| 64 | // Routers | ||
| 65 | import { router as usersPublicRouter } from './src/endpoints/users-public.js'; | ||
| 66 | import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js'; | ||
| 67 | import { checkForNewContent } from './src/endpoints/content-manager.js'; | ||
| 68 | import { init as settingsInit } from './src/endpoints/settings.js'; | ||
| 69 | import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js'; | ||
| 70 | import { diskCache } from './src/endpoints/characters.js'; | ||
| 71 | |||
| 72 | // Unrestrict console logs display limit | ||
| 73 | util.inspect.defaultOptions.maxArrayLength = null; | ||
| 74 | util.inspect.defaultOptions.maxStringLength = null; | ||
| 75 | util.inspect.defaultOptions.depth = 4; | ||
| 76 | |||
| 77 | // Set a working directory for the server | ||
| 78 | const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); | ||
| 79 | console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); | ||
| 80 | process.chdir(serverDirectory); | ||
| 81 | |||
| 82 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. | ||
| 83 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 | ||
| 84 | // Safe to remove once support for Node v20 is dropped. | ||
| 85 | if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) { | ||
| 86 | // @ts-ignore | ||
| 87 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); | ||
| 88 | } | ||
| 89 | 4 | ||
| 5 | // config.yaml will be set when parsing command line arguments | ||
| 90 | const cliArgs = new CommandLineParser().parse(process.argv); | 6 | const cliArgs = new CommandLineParser().parse(process.argv); |
| 91 | globalThis.DATA_ROOT = cliArgs.dataRoot; | 7 | globalThis.DATA_ROOT = cliArgs.dataRoot; |
| 92 | globalThis.COMMAND_LINE_ARGS = cliArgs; | 8 | globalThis.COMMAND_LINE_ARGS = cliArgs; |
| 9 | process.chdir(serverDirectory); | ||
| 93 | 10 | ||
| 94 | if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { | ||
| 95 | console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); | ||
| 96 | process.exit(1); | ||
| 97 | } | ||
| 98 | |||
| 99 | try { | ||
| 100 | if (cliArgs.dnsPreferIPv6) { | ||
| 101 | dns.setDefaultResultOrder('ipv6first'); | ||
| 102 | console.log('Preferring IPv6 for DNS resolution'); | ||
| 103 | } else { | ||
| 104 | dns.setDefaultResultOrder('ipv4first'); | ||
| 105 | console.log('Preferring IPv4 for DNS resolution'); | ||
| 106 | } | ||
| 107 | } catch (error) { | ||
| 108 | console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.'); | ||
| 109 | } | ||
| 110 | |||
| 111 | const app = express(); | ||
| 112 | app.use(helmet({ | ||
| 113 | contentSecurityPolicy: false, | ||
| 114 | })); | ||
| 115 | app.use(compression()); | ||
| 116 | app.use(responseTime()); | ||
| 117 | |||
| 118 | app.use(bodyParser.json({ limit: '200mb' })); | ||
| 119 | app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' })); | ||
| 120 | |||
| 121 | // CORS Settings // | ||
| 122 | const CORS = cors({ | ||
| 123 | origin: 'null', | ||
| 124 | methods: ['OPTIONS'], | ||
| 125 | }); | ||
| 126 | |||
| 127 | app.use(CORS); | ||
| 128 | |||
| 129 | if (cliArgs.listen && cliArgs.basicAuthMode) { | ||
| 130 | app.use(basicAuthMiddleware); | ||
| 131 | } | ||
| 132 | |||
| 133 | if (cliArgs.whitelistMode) { | ||
| 134 | const whitelistMiddleware = await getWhitelistMiddleware(); | ||
| 135 | app.use(whitelistMiddleware); | ||
| 136 | } | ||
| 137 | |||
| 138 | if (cliArgs.listen) { | ||
| 139 | app.use(accessLoggerMiddleware()); | ||
| 140 | } | ||
| 141 | |||
| 142 | if (cliArgs.enableCorsProxy) { | ||
| 143 | app.use('/proxy/:url(*)', corsProxyMiddleware); | ||
| 144 | } else { | ||
| 145 | app.use('/proxy/:url(*)', async (_, res) => { | ||
| 146 | const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; | ||
| 147 | console.log(message); | ||
| 148 | res.status(404).send(message); | ||
| 149 | }); | ||
| 150 | } | ||
| 151 | |||
| 152 | app.use(cookieSession({ | ||
| 153 | name: getCookieSessionName(), | ||
| 154 | sameSite: 'lax', | ||
| 155 | httpOnly: true, | ||
| 156 | maxAge: getSessionCookieAge(), | ||
| 157 | secret: getCookieSecret(globalThis.DATA_ROOT), | ||
| 158 | })); | ||
| 159 | |||
| 160 | app.use(setUserDataMiddleware); | ||
| 161 | |||
| 162 | // CSRF Protection // | ||
| 163 | if (!cliArgs.disableCsrf) { | ||
| 164 | const csrfSyncProtection = csrfSync({ | ||
| 165 | getTokenFromState: (req) => { | ||
| 166 | if (!req.session) { | ||
| 167 | console.error('(CSRF error) getTokenFromState: Session object not initialized'); | ||
| 168 | return; | ||
| 169 | } | ||
| 170 | return req.session.csrfToken; | ||
| 171 | }, | ||
| 172 | getTokenFromRequest: (req) => { | ||
| 173 | return req.headers['x-csrf-token']?.toString(); | ||
| 174 | }, | ||
| 175 | storeTokenInState: (req, token) => { | ||
| 176 | if (!req.session) { | ||
| 177 | console.error('(CSRF error) storeTokenInState: Session object not initialized'); | ||
| 178 | return; | ||
| 179 | } | ||
| 180 | req.session.csrfToken = token; | ||
| 181 | }, | ||
| 182 | size: 32, | ||
| 183 | }); | ||
| 184 | |||
| 185 | app.get('/csrf-token', (req, res) => { | ||
| 186 | res.json({ | ||
| 187 | 'token': csrfSyncProtection.generateToken(req), | ||
| 188 | }); | ||
| 189 | }); | ||
| 190 | |||
| 191 | // Customize the error message | ||
| 192 | csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); | ||
| 193 | csrfSyncProtection.invalidCsrfTokenError.stack = undefined; | ||
| 194 | |||
| 195 | app.use(csrfSyncProtection.csrfSynchronisedProtection); | ||
| 196 | } else { | ||
| 197 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); | ||
| 198 | app.get('/csrf-token', (req, res) => { | ||
| 199 | res.json({ | ||
| 200 | 'token': 'disabled', | ||
| 201 | }); | ||
| 202 | }); | ||
| 203 | } | ||
| 204 | |||
| 205 | // Static files | ||
| 206 | // Host index page | ||
| 207 | app.get('/', getCacheBusterMiddleware(), (request, response) => { | ||
| 208 | if (shouldRedirectToLogin(request)) { | ||
| 209 | const query = request.url.split('?')[1]; | ||
| 210 | const redirectUrl = query ? `/login?${query}` : '/login'; | ||
| 211 | return response.redirect(redirectUrl); | ||
| 212 | } | ||
| 213 | |||
| 214 | return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') }); | ||
| 215 | }); | ||
| 216 | |||
| 217 | // Callback endpoint for OAuth PKCE flows (e.g. OpenRouter) | ||
| 218 | app.get('/callback/:source?', (request, response) => { | ||
| 219 | const source = request.params.source; | ||
| 220 | const query = request.url.split('?')[1]; | ||
| 221 | const searchParams = new URLSearchParams(); | ||
| 222 | source && searchParams.set('source', source); | ||
| 223 | query && searchParams.set('query', query); | ||
| 224 | const path = `/?${searchParams.toString()}`; | ||
| 225 | return response.redirect(307, path); | ||
| 226 | }); | ||
| 227 | |||
| 228 | // Host login page | ||
| 229 | app.get('/login', loginPageMiddleware); | ||
| 230 | |||
| 231 | // Host frontend assets | ||
| 232 | const webpackMiddleware = getWebpackServeMiddleware(); | ||
| 233 | app.use(webpackMiddleware); | ||
| 234 | app.use(express.static(process.cwd() + '/public', {})); | ||
| 235 | |||
| 236 | // Public API | ||
| 237 | app.use('/api/users', usersPublicRouter); | ||
| 238 | |||
| 239 | // Everything below this line requires authentication | ||
| 240 | app.use(requireLoginMiddleware); | ||
| 241 | app.get('/api/ping', (request, response) => { | ||
| 242 | if (request.query.extend && request.session) { | ||
| 243 | request.session.touch = Date.now(); | ||
| 244 | } | ||
| 245 | |||
| 246 | response.sendStatus(204); | ||
| 247 | }); | ||
| 248 | |||
| 249 | // File uploads | ||
| 250 | const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY); | ||
| 251 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); | ||
| 252 | app.use(multerMonkeyPatch); | ||
| 253 | |||
| 254 | app.get('/version', async function (_, response) { | ||
| 255 | const data = await getVersion(); | ||
| 256 | response.send(data); | ||
| 257 | }); | ||
| 258 | |||
| 259 | redirectDeprecatedEndpoints(app); | ||
| 260 | setupPrivateEndpoints(app); | ||
| 261 | |||
| 262 | /** | ||
| 263 | * Tasks that need to be run before the server starts listening. | ||
| 264 | * @returns {Promise<void>} | ||
| 265 | */ | ||
| 266 | async function preSetupTasks() { | ||
| 267 | const version = await getVersion(); | ||
| 268 | |||
| 269 | // Print formatted header | ||
| 270 | console.log(); | ||
| 271 | console.log(`SillyTavern ${version.pkgVersion}`); | ||
| 272 | if (version.gitBranch) { | ||
| 273 | console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}`); | ||
| 274 | if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) { | ||
| 275 | console.log('INFO: Currently not on the latest commit.'); | ||
| 276 | console.log(' Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.'); | ||
| 277 | } | ||
| 278 | } | ||
| 279 | console.log(); | ||
| 280 | |||
| 281 | const directories = await getUserDirectoriesList(); | ||
| 282 | await checkForNewContent(directories); | ||
| 283 | await ensureThumbnailCache(directories); | ||
| 284 | await diskCache.verify(directories); | ||
| 285 | cleanUploads(); | ||
| 286 | migrateAccessLog(); | ||
| 287 | |||
| 288 | await settingsInit(); | ||
| 289 | await statsInit(); | ||
| 290 | |||
| 291 | const pluginsDirectory = path.join(serverDirectory, 'plugins'); | ||
| 292 | const cleanupPlugins = await loadPlugins(app, pluginsDirectory); | ||
| 293 | const consoleTitle = process.title; | ||
| 294 | |||
| 295 | let isExiting = false; | ||
| 296 | const exitProcess = async () => { | ||
| 297 | if (isExiting) return; | ||
| 298 | isExiting = true; | ||
| 299 | await statsOnExit(); | ||
| 300 | if (typeof cleanupPlugins === 'function') { | ||
| 301 | await cleanupPlugins(); | ||
| 302 | } | ||
| 303 | diskCache.dispose(); | ||
| 304 | setWindowTitle(consoleTitle); | ||
| 305 | process.exit(); | ||
| 306 | }; | ||
| 307 | |||
| 308 | // Set up event listeners for a graceful shutdown | ||
| 309 | process.on('SIGINT', exitProcess); | ||
| 310 | process.on('SIGTERM', exitProcess); | ||
| 311 | process.on('uncaughtException', (err) => { | ||
| 312 | console.error('Uncaught exception:', err); | ||
| 313 | exitProcess(); | ||
| 314 | }); | ||
| 315 | |||
| 316 | // Add request proxy. | ||
| 317 | initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass }); | ||
| 318 | |||
| 319 | // Wait for frontend libs to compile | ||
| 320 | await webpackMiddleware.runWebpackCompiler(); | ||
| 321 | } | ||
| 322 | |||
| 323 | /** | ||
| 324 | * Tasks that need to be run after the server starts listening. | ||
| 325 | * @param {import('./src/server-startup.js').ServerStartupResult} result The result of the server startup | ||
| 326 | * @returns {Promise<void>} | ||
| 327 | */ | ||
| 328 | async function postSetupTasks(result) { | ||
| 329 | const autorunHostname = await cliArgs.getAutorunHostname(result); | ||
| 330 | const autorunUrl = cliArgs.getAutorunUrl(autorunHostname); | ||
| 331 | |||
| 332 | if (cliArgs.autorun) { | ||
| 333 | try { | 11 | try { |
| 334 | console.log('Launching in a browser...'); | 12 | await import('./src/server-main.js'); |
| 335 | await open(autorunUrl.toString()); | ||
| 336 | } catch (error) { | 13 | } catch (error) { |
| 337 | console.error('Failed to launch the browser. Open the URL manually.'); | 14 | console.error('A critical error has occurred while starting the server:', error); |
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | setWindowTitle('SillyTavern WebServer'); | ||
| 342 | |||
| 343 | let logListen = 'SillyTavern is listening on'; | ||
| 344 | |||
| 345 | if (result.useIPv6 && !result.v6Failed) { | ||
| 346 | logListen += color.green( | ||
| 347 | ' IPv6: ' + cliArgs.getIPv6ListenUrl().host, | ||
| 348 | ); | ||
| 349 | } | 15 | } |
| 350 | |||
| 351 | if (result.useIPv4 && !result.v4Failed) { | ||
| 352 | logListen += color.green( | ||
| 353 | ' IPv4: ' + cliArgs.getIPv4ListenUrl().host, | ||
| 354 | ); | ||
| 355 | } | ||
| 356 | |||
| 357 | const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern'; | ||
| 358 | const plainGoToLog = removeColorFormatting(goToLog); | ||
| 359 | |||
| 360 | console.log(logListen); | ||
| 361 | if (cliArgs.listen) { | ||
| 362 | console.log(); | ||
| 363 | console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); | ||
| 364 | console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); | ||
| 365 | } | ||
| 366 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | ||
| 367 | console.log(goToLog); | ||
| 368 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | ||
| 369 | |||
| 370 | setupLogLevel(); | ||
| 371 | serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: autorunUrl }); | ||
| 372 | } | ||
| 373 | |||
| 374 | /** | ||
| 375 | * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. | ||
| 376 | */ | ||
| 377 | function apply404Middleware() { | ||
| 378 | const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? ''; | ||
| 379 | app.use((req, res) => { | ||
| 380 | res.status(404).send(notFoundWebpage); | ||
| 381 | }); | ||
| 382 | } | ||
| 383 | |||
| 384 | // User storage module needs to be initialized before starting the server | ||
| 385 | initUserStorage(globalThis.DATA_ROOT) | ||
| 386 | .then(ensurePublicDirectoriesExist) | ||
| 387 | .then(migrateUserData) | ||
| 388 | .then(migrateSystemPrompts) | ||
| 389 | .then(verifySecuritySettings) | ||
| 390 | .then(preSetupTasks) | ||
| 391 | .then(apply404Middleware) | ||
| 392 | .then(() => new ServerStartup(app, cliArgs).start()) | ||
| 393 | .then(postSetupTasks); | ||
| @@ -2,9 +2,11 @@ import yargs from 'yargs/yargs'; | |||
| 2 | import { hideBin } from 'yargs/helpers'; | 2 | import { hideBin } from 'yargs/helpers'; |
| 3 | import ipRegex from 'ip-regex'; | 3 | import ipRegex from 'ip-regex'; |
| 4 | import { canResolve, color, getConfigValue, stringToBool } from './util.js'; | 4 | import { canResolve, color, getConfigValue, stringToBool } from './util.js'; |
| 5 | import { initConfig } from './config-init.js'; | ||
| 5 | 6 | ||
| 6 | /** | 7 | /** |
| 7 | * @typedef {object} CommandLineArguments Parsed command line arguments | 8 | * @typedef {object} CommandLineArguments Parsed command line arguments |
| 9 | * @property {string} configPath Path to the config file | ||
| 8 | * @property {string} dataRoot Data root directory | 10 | * @property {string} dataRoot Data root directory |
| 9 | * @property {number} port Port number | 11 | * @property {number} port Port number |
| 10 | * @property {boolean} listen If SillyTavern is listening on all network interfaces | 12 | * @property {boolean} listen If SillyTavern is listening on all network interfaces |
| @@ -40,6 +42,7 @@ export class CommandLineParser { | |||
| 40 | constructor() { | 42 | constructor() { |
| 41 | /** @type {CommandLineArguments} */ | 43 | /** @type {CommandLineArguments} */ |
| 42 | this.default = Object.freeze({ | 44 | this.default = Object.freeze({ |
| 45 | configPath: './config.yaml', | ||
| 43 | dataRoot: './data', | 46 | dataRoot: './data', |
| 44 | port: 8000, | 47 | port: 8000, |
| 45 | listen: false, | 48 | listen: false, |
| @@ -88,6 +91,11 @@ export class CommandLineParser { | |||
| 88 | parse(args) { | 91 | parse(args) { |
| 89 | const cliArguments = yargs(hideBin(args)) | 92 | const cliArguments = yargs(hideBin(args)) |
| 90 | .usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.') | 93 | .usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.') |
| 94 | .option('configPath', { | ||
| 95 | type: 'string', | ||
| 96 | default: null, | ||
| 97 | describe: 'Path to the config file', | ||
| 98 | }) | ||
| 91 | .option('enableIPv6', { | 99 | .option('enableIPv6', { |
| 92 | type: 'string', | 100 | type: 'string', |
| 93 | default: null, | 101 | default: null, |
| @@ -177,8 +185,11 @@ export class CommandLineParser { | |||
| 177 | describe: 'Request proxy bypass list (space separated list of hosts)', | 185 | describe: 'Request proxy bypass list (space separated list of hosts)', |
| 178 | }).parseSync(); | 186 | }).parseSync(); |
| 179 | 187 | ||
| 188 | const configPath = cliArguments.configPath ?? this.default.configPath; | ||
| 189 | initConfig(configPath); | ||
| 180 | /** @type {CommandLineArguments} */ | 190 | /** @type {CommandLineArguments} */ |
| 181 | const result = { | 191 | const result = { |
| 192 | configPath: configPath, | ||
| 182 | dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot), | 193 | dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot), |
| 183 | port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'), | 194 | port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'), |
| 184 | listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'), | 195 | listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'), |
| @@ -0,0 +1,197 @@ | |||
| 1 | import fs from 'node:fs'; | ||
| 2 | import path from 'node:path'; | ||
| 3 | import yaml from 'yaml'; | ||
| 4 | import color from 'chalk'; | ||
| 5 | import _ from 'lodash'; | ||
| 6 | import { serverDirectory } from './server-directory.js'; | ||
| 7 | import { setConfigFilePath } from './util.js'; | ||
| 8 | |||
| 9 | const keyMigrationMap = [ | ||
| 10 | { | ||
| 11 | oldKey: 'disableThumbnails', | ||
| 12 | newKey: 'thumbnails.enabled', | ||
| 13 | migrate: (value) => !value, | ||
| 14 | }, | ||
| 15 | { | ||
| 16 | oldKey: 'thumbnailsQuality', | ||
| 17 | newKey: 'thumbnails.quality', | ||
| 18 | migrate: (value) => value, | ||
| 19 | }, | ||
| 20 | { | ||
| 21 | oldKey: 'avatarThumbnailsPng', | ||
| 22 | newKey: 'thumbnails.format', | ||
| 23 | migrate: (value) => (value ? 'png' : 'jpg'), | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | oldKey: 'disableChatBackup', | ||
| 27 | newKey: 'backups.chat.enabled', | ||
| 28 | migrate: (value) => !value, | ||
| 29 | }, | ||
| 30 | { | ||
| 31 | oldKey: 'numberOfBackups', | ||
| 32 | newKey: 'backups.common.numberOfBackups', | ||
| 33 | migrate: (value) => value, | ||
| 34 | }, | ||
| 35 | { | ||
| 36 | oldKey: 'maxTotalChatBackups', | ||
| 37 | newKey: 'backups.chat.maxTotalBackups', | ||
| 38 | migrate: (value) => value, | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | oldKey: 'chatBackupThrottleInterval', | ||
| 42 | newKey: 'backups.chat.throttleInterval', | ||
| 43 | migrate: (value) => value, | ||
| 44 | }, | ||
| 45 | { | ||
| 46 | oldKey: 'enableExtensions', | ||
| 47 | newKey: 'extensions.enabled', | ||
| 48 | migrate: (value) => value, | ||
| 49 | }, | ||
| 50 | { | ||
| 51 | oldKey: 'enableExtensionsAutoUpdate', | ||
| 52 | newKey: 'extensions.autoUpdate', | ||
| 53 | migrate: (value) => value, | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | oldKey: 'extras.disableAutoDownload', | ||
| 57 | newKey: 'extensions.models.autoDownload', | ||
| 58 | migrate: (value) => !value, | ||
| 59 | }, | ||
| 60 | { | ||
| 61 | oldKey: 'extras.classificationModel', | ||
| 62 | newKey: 'extensions.models.classification', | ||
| 63 | migrate: (value) => value, | ||
| 64 | }, | ||
| 65 | { | ||
| 66 | oldKey: 'extras.captioningModel', | ||
| 67 | newKey: 'extensions.models.captioning', | ||
| 68 | migrate: (value) => value, | ||
| 69 | }, | ||
| 70 | { | ||
| 71 | oldKey: 'extras.embeddingModel', | ||
| 72 | newKey: 'extensions.models.embedding', | ||
| 73 | migrate: (value) => value, | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | oldKey: 'extras.speechToTextModel', | ||
| 77 | newKey: 'extensions.models.speechToText', | ||
| 78 | migrate: (value) => value, | ||
| 79 | }, | ||
| 80 | { | ||
| 81 | oldKey: 'extras.textToSpeechModel', | ||
| 82 | newKey: 'extensions.models.textToSpeech', | ||
| 83 | migrate: (value) => value, | ||
| 84 | }, | ||
| 85 | { | ||
| 86 | oldKey: 'minLogLevel', | ||
| 87 | newKey: 'logging.minLogLevel', | ||
| 88 | migrate: (value) => value, | ||
| 89 | }, | ||
| 90 | { | ||
| 91 | oldKey: 'cardsCacheCapacity', | ||
| 92 | newKey: 'performance.memoryCacheCapacity', | ||
| 93 | migrate: (value) => `${value}mb`, | ||
| 94 | }, | ||
| 95 | { | ||
| 96 | oldKey: 'cookieSecret', | ||
| 97 | newKey: 'cookieSecret', | ||
| 98 | migrate: () => void 0, | ||
| 99 | remove: true, | ||
| 100 | }, | ||
| 101 | ]; | ||
| 102 | |||
| 103 | /** | ||
| 104 | * Gets all keys from an object recursively. | ||
| 105 | * @param {object} obj Object to get all keys from | ||
| 106 | * @param {string} prefix Prefix to prepend to all keys | ||
| 107 | * @returns {string[]} Array of all keys in the object | ||
| 108 | */ | ||
| 109 | function getAllKeys(obj, prefix = '') { | ||
| 110 | if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) { | ||
| 111 | return []; | ||
| 112 | } | ||
| 113 | |||
| 114 | return _.flatMap(Object.keys(obj), key => { | ||
| 115 | const newPrefix = prefix ? `${prefix}.${key}` : key; | ||
| 116 | if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) { | ||
| 117 | return getAllKeys(obj[key], newPrefix); | ||
| 118 | } else { | ||
| 119 | return [newPrefix]; | ||
| 120 | } | ||
| 121 | }); | ||
| 122 | } | ||
| 123 | |||
| 124 | /** | ||
| 125 | * Compares the current config.yaml with the default config.yaml and adds any missing values. | ||
| 126 | * @param {string} configPath Path to config.yaml | ||
| 127 | */ | ||
| 128 | export function addMissingConfigValues(configPath) { | ||
| 129 | try { | ||
| 130 | const defaultConfig = yaml.parse(fs.readFileSync(path.join(serverDirectory, './default/config.yaml'), 'utf8')); | ||
| 131 | let config = yaml.parse(fs.readFileSync(configPath, 'utf8')); | ||
| 132 | |||
| 133 | // Migrate old keys to new keys | ||
| 134 | const migratedKeys = []; | ||
| 135 | for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) { | ||
| 136 | if (_.has(config, oldKey)) { | ||
| 137 | if (remove) { | ||
| 138 | _.unset(config, oldKey); | ||
| 139 | migratedKeys.push({ | ||
| 140 | oldKey, | ||
| 141 | newValue: void 0, | ||
| 142 | }); | ||
| 143 | continue; | ||
| 144 | } | ||
| 145 | |||
| 146 | const oldValue = _.get(config, oldKey); | ||
| 147 | const newValue = migrate(oldValue); | ||
| 148 | _.set(config, newKey, newValue); | ||
| 149 | _.unset(config, oldKey); | ||
| 150 | |||
| 151 | migratedKeys.push({ | ||
| 152 | oldKey, | ||
| 153 | newKey, | ||
| 154 | oldValue, | ||
| 155 | newValue, | ||
| 156 | }); | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | // Get all keys from the original config | ||
| 161 | const originalKeys = getAllKeys(config); | ||
| 162 | |||
| 163 | // Use lodash's defaultsDeep function to recursively apply default properties | ||
| 164 | config = _.defaultsDeep(config, defaultConfig); | ||
| 165 | |||
| 166 | // Get all keys from the updated config | ||
| 167 | const updatedKeys = getAllKeys(config); | ||
| 168 | |||
| 169 | // Find the keys that were added | ||
| 170 | const addedKeys = _.difference(updatedKeys, originalKeys); | ||
| 171 | |||
| 172 | if (addedKeys.length === 0 && migratedKeys.length === 0) { | ||
| 173 | return; | ||
| 174 | } | ||
| 175 | |||
| 176 | if (addedKeys.length > 0) { | ||
| 177 | console.log('Adding missing config values to config.yaml:', addedKeys); | ||
| 178 | } | ||
| 179 | |||
| 180 | if (migratedKeys.length > 0) { | ||
| 181 | console.log('Migrating config values in config.yaml:', migratedKeys); | ||
| 182 | } | ||
| 183 | |||
| 184 | fs.writeFileSync(configPath, yaml.stringify(config)); | ||
| 185 | } catch (error) { | ||
| 186 | console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error); | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | /** | ||
| 191 | * Performs early initialization tasks before the server starts. | ||
| 192 | * @param {string} configPath Path to config.yaml | ||
| 193 | */ | ||
| 194 | export function initConfig(configPath) { | ||
| 195 | setConfigFilePath(configPath); | ||
| 196 | addMissingConfigValues(configPath); | ||
| 197 | } | ||
| @@ -1,6 +1,5 @@ | |||
| 1 | import fs from 'node:fs'; | 1 | import fs from 'node:fs'; |
| 2 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 3 | import process from 'node:process'; | ||
| 4 | import { Buffer } from 'node:buffer'; | 3 | import { Buffer } from 'node:buffer'; |
| 5 | 4 | ||
| 6 | import express from 'express'; | 5 | import express from 'express'; |
| @@ -8,11 +7,12 @@ import fetch from 'node-fetch'; | |||
| 8 | import sanitize from 'sanitize-filename'; | 7 | import sanitize from 'sanitize-filename'; |
| 9 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; | 8 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 10 | 9 | ||
| 11 | import { getConfigValue, color } from '../util.js'; | 10 | import { getConfigValue, color, setPermissionsSync } from '../util.js'; |
| 12 | import { write } from '../character-card-parser.js'; | 11 | import { write } from '../character-card-parser.js'; |
| 12 | import { serverDirectory } from '../server-directory.js'; | ||
| 13 | 13 | ||
| 14 | const contentDirectory = path.join(process.cwd(), 'default/content'); | 14 | const contentDirectory = path.join(serverDirectory, 'default/content'); |
| 15 | const scaffoldDirectory = path.join(process.cwd(), 'default/scaffold'); | 15 | const scaffoldDirectory = path.join(serverDirectory, 'default/scaffold'); |
| 16 | const contentIndexPath = path.join(contentDirectory, 'index.json'); | 16 | const contentIndexPath = path.join(contentDirectory, 'index.json'); |
| 17 | const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json'); | 17 | const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json'); |
| 18 | 18 | ||
| @@ -149,6 +149,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) { | |||
| 149 | } | 149 | } |
| 150 | 150 | ||
| 151 | fs.cpSync(contentPath, targetPath, { recursive: true, force: false }); | 151 | fs.cpSync(contentPath, targetPath, { recursive: true, force: false }); |
| 152 | setPermissionsSync(targetPath); | ||
| 152 | console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`); | 153 | console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`); |
| 153 | anyContentAdded = true; | 154 | anyContentAdded = true; |
| 154 | } | 155 | } |
| @@ -2,6 +2,7 @@ import fs from 'node:fs'; | |||
| 2 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 3 | import { fileURLToPath } from 'node:url'; | 3 | import { fileURLToPath } from 'node:url'; |
| 4 | import mime from 'mime-types'; | 4 | import mime from 'mime-types'; |
| 5 | import { serverDirectory } from './server-directory.js'; | ||
| 5 | 6 | ||
| 6 | const originalFetch = globalThis.fetch; | 7 | const originalFetch = globalThis.fetch; |
| 7 | 8 | ||
| @@ -67,10 +68,9 @@ globalThis.fetch = async (/** @type {string | URL | Request} */ request, /** @ty | |||
| 67 | } | 68 | } |
| 68 | const url = getRequestURL(request); | 69 | const url = getRequestURL(request); |
| 69 | const filePath = path.resolve(fileURLToPath(url)); | 70 | const filePath = path.resolve(fileURLToPath(url)); |
| 70 | const cwd = path.resolve(process.cwd()) + path.sep; | 71 | const isUnderServerDirectory = isPathUnderParent(serverDirectory, filePath); |
| 71 | const isUnderCwd = isPathUnderParent(cwd, filePath); | 72 | if (!isUnderServerDirectory) { |
| 72 | if (!isUnderCwd) { | 73 | throw new Error('Requested file path is outside of the server directory.'); |
| 73 | throw new Error('Requested file path is outside of the current working directory.'); | ||
| 74 | } | 74 | } |
| 75 | const parsedPath = path.parse(filePath); | 75 | const parsedPath = path.parse(filePath); |
| 76 | if (!ALLOWED_EXTENSIONS.includes(parsedPath.ext)) { | 76 | if (!ALLOWED_EXTENSIONS.includes(parsedPath.ext)) { |
| @@ -0,0 +1,3 @@ | |||
| 1 | import path from 'node:path'; | ||
| 2 | import { fileURLToPath } from 'node:url'; | ||
| 3 | export const serverDirectory = path.dirname(import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url))); | ||
| @@ -0,0 +1,386 @@ | |||
| 1 | // native node modules | ||
| 2 | import path from 'node:path'; | ||
| 3 | import util from 'node:util'; | ||
| 4 | import net from 'node:net'; | ||
| 5 | import dns from 'node:dns'; | ||
| 6 | import process from 'node:process'; | ||
| 7 | |||
| 8 | import cors from 'cors'; | ||
| 9 | import { csrfSync } from 'csrf-sync'; | ||
| 10 | import express from 'express'; | ||
| 11 | import compression from 'compression'; | ||
| 12 | import cookieSession from 'cookie-session'; | ||
| 13 | import multer from 'multer'; | ||
| 14 | import responseTime from 'response-time'; | ||
| 15 | import helmet from 'helmet'; | ||
| 16 | import bodyParser from 'body-parser'; | ||
| 17 | import open from 'open'; | ||
| 18 | |||
| 19 | // local library imports | ||
| 20 | import './fetch-patch.js'; | ||
| 21 | import { serverDirectory } from './server-directory.js'; | ||
| 22 | |||
| 23 | console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); | ||
| 24 | |||
| 25 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. | ||
| 26 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 | ||
| 27 | // Safe to remove once support for Node v20 is dropped. | ||
| 28 | if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) { | ||
| 29 | // @ts-ignore | ||
| 30 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); | ||
| 31 | } | ||
| 32 | |||
| 33 | import { serverEvents, EVENT_NAMES } from './server-events.js'; | ||
| 34 | import { loadPlugins } from './plugin-loader.js'; | ||
| 35 | import { | ||
| 36 | initUserStorage, | ||
| 37 | getCookieSecret, | ||
| 38 | getCookieSessionName, | ||
| 39 | ensurePublicDirectoriesExist, | ||
| 40 | getUserDirectoriesList, | ||
| 41 | migrateSystemPrompts, | ||
| 42 | migrateUserData, | ||
| 43 | requireLoginMiddleware, | ||
| 44 | setUserDataMiddleware, | ||
| 45 | shouldRedirectToLogin, | ||
| 46 | cleanUploads, | ||
| 47 | getSessionCookieAge, | ||
| 48 | verifySecuritySettings, | ||
| 49 | loginPageMiddleware, | ||
| 50 | } from './users.js'; | ||
| 51 | |||
| 52 | import getWebpackServeMiddleware from './middleware/webpack-serve.js'; | ||
| 53 | import basicAuthMiddleware from './middleware/basicAuth.js'; | ||
| 54 | import getWhitelistMiddleware from './middleware/whitelist.js'; | ||
| 55 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js'; | ||
| 56 | import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; | ||
| 57 | import initRequestProxy from './request-proxy.js'; | ||
| 58 | import getCacheBusterMiddleware from './middleware/cacheBuster.js'; | ||
| 59 | import corsProxyMiddleware from './middleware/corsProxy.js'; | ||
| 60 | import { | ||
| 61 | getVersion, | ||
| 62 | color, | ||
| 63 | removeColorFormatting, | ||
| 64 | getSeparator, | ||
| 65 | safeReadFileSync, | ||
| 66 | setupLogLevel, | ||
| 67 | setWindowTitle, | ||
| 68 | } from './util.js'; | ||
| 69 | import { UPLOADS_DIRECTORY } from './constants.js'; | ||
| 70 | import { ensureThumbnailCache } from './endpoints/thumbnails.js'; | ||
| 71 | |||
| 72 | // Routers | ||
| 73 | import { router as usersPublicRouter } from './endpoints/users-public.js'; | ||
| 74 | import { init as statsInit, onExit as statsOnExit } from './endpoints/stats.js'; | ||
| 75 | import { checkForNewContent } from './endpoints/content-manager.js'; | ||
| 76 | import { init as settingsInit } from './endpoints/settings.js'; | ||
| 77 | import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js'; | ||
| 78 | import { diskCache } from './endpoints/characters.js'; | ||
| 79 | |||
| 80 | // Unrestrict console logs display limit | ||
| 81 | util.inspect.defaultOptions.maxArrayLength = null; | ||
| 82 | util.inspect.defaultOptions.maxStringLength = null; | ||
| 83 | util.inspect.defaultOptions.depth = 4; | ||
| 84 | |||
| 85 | const cliArgs = globalThis.COMMAND_LINE_ARGS; | ||
| 86 | |||
| 87 | if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { | ||
| 88 | console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); | ||
| 89 | process.exit(1); | ||
| 90 | } | ||
| 91 | |||
| 92 | try { | ||
| 93 | if (cliArgs.dnsPreferIPv6) { | ||
| 94 | dns.setDefaultResultOrder('ipv6first'); | ||
| 95 | console.log('Preferring IPv6 for DNS resolution'); | ||
| 96 | } else { | ||
| 97 | dns.setDefaultResultOrder('ipv4first'); | ||
| 98 | console.log('Preferring IPv4 for DNS resolution'); | ||
| 99 | } | ||
| 100 | } catch (error) { | ||
| 101 | console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.'); | ||
| 102 | } | ||
| 103 | |||
| 104 | const app = express(); | ||
| 105 | app.use(helmet({ | ||
| 106 | contentSecurityPolicy: false, | ||
| 107 | })); | ||
| 108 | app.use(compression()); | ||
| 109 | app.use(responseTime()); | ||
| 110 | |||
| 111 | app.use(bodyParser.json({ limit: '200mb' })); | ||
| 112 | app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' })); | ||
| 113 | |||
| 114 | // CORS Settings // | ||
| 115 | const CORS = cors({ | ||
| 116 | origin: 'null', | ||
| 117 | methods: ['OPTIONS'], | ||
| 118 | }); | ||
| 119 | |||
| 120 | app.use(CORS); | ||
| 121 | |||
| 122 | if (cliArgs.listen && cliArgs.basicAuthMode) { | ||
| 123 | app.use(basicAuthMiddleware); | ||
| 124 | } | ||
| 125 | |||
| 126 | if (cliArgs.whitelistMode) { | ||
| 127 | const whitelistMiddleware = await getWhitelistMiddleware(); | ||
| 128 | app.use(whitelistMiddleware); | ||
| 129 | } | ||
| 130 | |||
| 131 | if (cliArgs.listen) { | ||
| 132 | app.use(accessLoggerMiddleware()); | ||
| 133 | } | ||
| 134 | |||
| 135 | if (cliArgs.enableCorsProxy) { | ||
| 136 | app.use('/proxy/:url(*)', corsProxyMiddleware); | ||
| 137 | } else { | ||
| 138 | app.use('/proxy/:url(*)', async (_, res) => { | ||
| 139 | const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; | ||
| 140 | console.log(message); | ||
| 141 | res.status(404).send(message); | ||
| 142 | }); | ||
| 143 | } | ||
| 144 | |||
| 145 | app.use(cookieSession({ | ||
| 146 | name: getCookieSessionName(), | ||
| 147 | sameSite: 'lax', | ||
| 148 | httpOnly: true, | ||
| 149 | maxAge: getSessionCookieAge(), | ||
| 150 | secret: getCookieSecret(globalThis.DATA_ROOT), | ||
| 151 | })); | ||
| 152 | |||
| 153 | app.use(setUserDataMiddleware); | ||
| 154 | |||
| 155 | // CSRF Protection // | ||
| 156 | if (!cliArgs.disableCsrf) { | ||
| 157 | const csrfSyncProtection = csrfSync({ | ||
| 158 | getTokenFromState: (req) => { | ||
| 159 | if (!req.session) { | ||
| 160 | console.error('(CSRF error) getTokenFromState: Session object not initialized'); | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | return req.session.csrfToken; | ||
| 164 | }, | ||
| 165 | getTokenFromRequest: (req) => { | ||
| 166 | return req.headers['x-csrf-token']?.toString(); | ||
| 167 | }, | ||
| 168 | storeTokenInState: (req, token) => { | ||
| 169 | if (!req.session) { | ||
| 170 | console.error('(CSRF error) storeTokenInState: Session object not initialized'); | ||
| 171 | return; | ||
| 172 | } | ||
| 173 | req.session.csrfToken = token; | ||
| 174 | }, | ||
| 175 | size: 32, | ||
| 176 | }); | ||
| 177 | |||
| 178 | app.get('/csrf-token', (req, res) => { | ||
| 179 | res.json({ | ||
| 180 | 'token': csrfSyncProtection.generateToken(req), | ||
| 181 | }); | ||
| 182 | }); | ||
| 183 | |||
| 184 | // Customize the error message | ||
| 185 | csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); | ||
| 186 | csrfSyncProtection.invalidCsrfTokenError.stack = undefined; | ||
| 187 | |||
| 188 | app.use(csrfSyncProtection.csrfSynchronisedProtection); | ||
| 189 | } else { | ||
| 190 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); | ||
| 191 | app.get('/csrf-token', (req, res) => { | ||
| 192 | res.json({ | ||
| 193 | 'token': 'disabled', | ||
| 194 | }); | ||
| 195 | }); | ||
| 196 | } | ||
| 197 | |||
| 198 | // Static files | ||
| 199 | // Host index page | ||
| 200 | app.get('/', getCacheBusterMiddleware(), (request, response) => { | ||
| 201 | if (shouldRedirectToLogin(request)) { | ||
| 202 | const query = request.url.split('?')[1]; | ||
| 203 | const redirectUrl = query ? `/login?${query}` : '/login'; | ||
| 204 | return response.redirect(redirectUrl); | ||
| 205 | } | ||
| 206 | |||
| 207 | return response.sendFile('index.html', { root: path.join(serverDirectory, 'public') }); | ||
| 208 | }); | ||
| 209 | |||
| 210 | // Callback endpoint for OAuth PKCE flows (e.g. OpenRouter) | ||
| 211 | app.get('/callback/:source?', (request, response) => { | ||
| 212 | const source = request.params.source; | ||
| 213 | const query = request.url.split('?')[1]; | ||
| 214 | const searchParams = new URLSearchParams(); | ||
| 215 | source && searchParams.set('source', source); | ||
| 216 | query && searchParams.set('query', query); | ||
| 217 | const path = `/?${searchParams.toString()}`; | ||
| 218 | return response.redirect(307, path); | ||
| 219 | }); | ||
| 220 | |||
| 221 | // Host login page | ||
| 222 | app.get('/login', loginPageMiddleware); | ||
| 223 | |||
| 224 | // Host frontend assets | ||
| 225 | const webpackMiddleware = getWebpackServeMiddleware(); | ||
| 226 | app.use(webpackMiddleware); | ||
| 227 | app.use(express.static(path.join(serverDirectory, 'public'), {})); | ||
| 228 | |||
| 229 | // Public API | ||
| 230 | app.use('/api/users', usersPublicRouter); | ||
| 231 | |||
| 232 | // Everything below this line requires authentication | ||
| 233 | app.use(requireLoginMiddleware); | ||
| 234 | app.get('/api/ping', (request, response) => { | ||
| 235 | if (request.query.extend && request.session) { | ||
| 236 | request.session.touch = Date.now(); | ||
| 237 | } | ||
| 238 | |||
| 239 | response.sendStatus(204); | ||
| 240 | }); | ||
| 241 | |||
| 242 | // File uploads | ||
| 243 | const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY); | ||
| 244 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); | ||
| 245 | app.use(multerMonkeyPatch); | ||
| 246 | |||
| 247 | app.get('/version', async function (_, response) { | ||
| 248 | const data = await getVersion(); | ||
| 249 | response.send(data); | ||
| 250 | }); | ||
| 251 | |||
| 252 | redirectDeprecatedEndpoints(app); | ||
| 253 | setupPrivateEndpoints(app); | ||
| 254 | |||
| 255 | /** | ||
| 256 | * Tasks that need to be run before the server starts listening. | ||
| 257 | * @returns {Promise<void>} | ||
| 258 | */ | ||
| 259 | async function preSetupTasks() { | ||
| 260 | const version = await getVersion(); | ||
| 261 | |||
| 262 | // Print formatted header | ||
| 263 | console.log(); | ||
| 264 | console.log(`SillyTavern ${version.pkgVersion}`); | ||
| 265 | if (version.gitBranch) { | ||
| 266 | console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}`); | ||
| 267 | if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) { | ||
| 268 | console.log('INFO: Currently not on the latest commit.'); | ||
| 269 | console.log(' Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.'); | ||
| 270 | } | ||
| 271 | } | ||
| 272 | console.log(); | ||
| 273 | |||
| 274 | const directories = await getUserDirectoriesList(); | ||
| 275 | await checkForNewContent(directories); | ||
| 276 | await ensureThumbnailCache(directories); | ||
| 277 | await diskCache.verify(directories); | ||
| 278 | cleanUploads(); | ||
| 279 | migrateAccessLog(); | ||
| 280 | |||
| 281 | await settingsInit(); | ||
| 282 | await statsInit(); | ||
| 283 | |||
| 284 | const pluginsDirectory = path.join(serverDirectory, 'plugins'); | ||
| 285 | const cleanupPlugins = await loadPlugins(app, pluginsDirectory); | ||
| 286 | const consoleTitle = process.title; | ||
| 287 | |||
| 288 | let isExiting = false; | ||
| 289 | const exitProcess = async () => { | ||
| 290 | if (isExiting) return; | ||
| 291 | isExiting = true; | ||
| 292 | await statsOnExit(); | ||
| 293 | if (typeof cleanupPlugins === 'function') { | ||
| 294 | await cleanupPlugins(); | ||
| 295 | } | ||
| 296 | diskCache.dispose(); | ||
| 297 | setWindowTitle(consoleTitle); | ||
| 298 | process.exit(); | ||
| 299 | }; | ||
| 300 | |||
| 301 | // Set up event listeners for a graceful shutdown | ||
| 302 | process.on('SIGINT', exitProcess); | ||
| 303 | process.on('SIGTERM', exitProcess); | ||
| 304 | process.on('uncaughtException', (err) => { | ||
| 305 | console.error('Uncaught exception:', err); | ||
| 306 | exitProcess(); | ||
| 307 | }); | ||
| 308 | |||
| 309 | // Add request proxy. | ||
| 310 | initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass }); | ||
| 311 | |||
| 312 | // Wait for frontend libs to compile | ||
| 313 | await webpackMiddleware.runWebpackCompiler(); | ||
| 314 | } | ||
| 315 | |||
| 316 | /** | ||
| 317 | * Tasks that need to be run after the server starts listening. | ||
| 318 | * @param {import('./server-startup.js').ServerStartupResult} result The result of the server startup | ||
| 319 | * @returns {Promise<void>} | ||
| 320 | */ | ||
| 321 | async function postSetupTasks(result) { | ||
| 322 | const autorunHostname = await cliArgs.getAutorunHostname(result); | ||
| 323 | const autorunUrl = cliArgs.getAutorunUrl(autorunHostname); | ||
| 324 | |||
| 325 | if (cliArgs.autorun) { | ||
| 326 | try { | ||
| 327 | console.log('Launching in a browser...'); | ||
| 328 | await open(autorunUrl.toString()); | ||
| 329 | } catch (error) { | ||
| 330 | console.error('Failed to launch the browser. Open the URL manually.'); | ||
| 331 | } | ||
| 332 | } | ||
| 333 | |||
| 334 | setWindowTitle('SillyTavern WebServer'); | ||
| 335 | |||
| 336 | let logListen = 'SillyTavern is listening on'; | ||
| 337 | |||
| 338 | if (result.useIPv6 && !result.v6Failed) { | ||
| 339 | logListen += color.green( | ||
| 340 | ' IPv6: ' + cliArgs.getIPv6ListenUrl().host, | ||
| 341 | ); | ||
| 342 | } | ||
| 343 | |||
| 344 | if (result.useIPv4 && !result.v4Failed) { | ||
| 345 | logListen += color.green( | ||
| 346 | ' IPv4: ' + cliArgs.getIPv4ListenUrl().host, | ||
| 347 | ); | ||
| 348 | } | ||
| 349 | |||
| 350 | const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern'; | ||
| 351 | const plainGoToLog = removeColorFormatting(goToLog); | ||
| 352 | |||
| 353 | console.log(logListen); | ||
| 354 | if (cliArgs.listen) { | ||
| 355 | console.log(); | ||
| 356 | console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); | ||
| 357 | console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); | ||
| 358 | } | ||
| 359 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | ||
| 360 | console.log(goToLog); | ||
| 361 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | ||
| 362 | |||
| 363 | setupLogLevel(); | ||
| 364 | serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: autorunUrl }); | ||
| 365 | } | ||
| 366 | |||
| 367 | /** | ||
| 368 | * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. | ||
| 369 | */ | ||
| 370 | function apply404Middleware() { | ||
| 371 | const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? ''; | ||
| 372 | app.use((req, res) => { | ||
| 373 | res.status(404).send(notFoundWebpage); | ||
| 374 | }); | ||
| 375 | } | ||
| 376 | |||
| 377 | // User storage module needs to be initialized before starting the server | ||
| 378 | initUserStorage(globalThis.DATA_ROOT) | ||
| 379 | .then(ensurePublicDirectoriesExist) | ||
| 380 | .then(migrateUserData) | ||
| 381 | .then(migrateSystemPrompts) | ||
| 382 | .then(verifySecuritySettings) | ||
| 383 | .then(preSetupTasks) | ||
| 384 | .then(apply404Middleware) | ||
| 385 | .then(() => new ServerStartup(app, cliArgs).start()) | ||
| 386 | .then(postSetupTasks); | ||
| @@ -5,6 +5,7 @@ import { Buffer } from 'node:buffer'; | |||
| 5 | 5 | ||
| 6 | import { pipeline, env, RawImage } from 'sillytavern-transformers'; | 6 | import { pipeline, env, RawImage } from 'sillytavern-transformers'; |
| 7 | import { getConfigValue } from './util.js'; | 7 | import { getConfigValue } from './util.js'; |
| 8 | import { serverDirectory } from './server-directory.js'; | ||
| 8 | 9 | ||
| 9 | configureTransformers(); | 10 | configureTransformers(); |
| 10 | 11 | ||
| @@ -12,7 +13,7 @@ function configureTransformers() { | |||
| 12 | // Limit the number of threads to 1 to avoid issues on Android | 13 | // Limit the number of threads to 1 to avoid issues on Android |
| 13 | env.backends.onnx.wasm.numThreads = 1; | 14 | env.backends.onnx.wasm.numThreads = 1; |
| 14 | // Use WASM from a local folder to avoid CDN connections | 15 | // Use WASM from a local folder to avoid CDN connections |
| 15 | env.backends.onnx.wasm.wasmPaths = path.join(process.cwd(), 'dist') + path.sep; | 16 | env.backends.onnx.wasm.wasmPaths = path.join(serverDirectory, 'node_modules', 'sillytavern-transformers', 'dist') + path.sep; |
| 16 | } | 17 | } |
| 17 | 18 | ||
| 18 | const tasks = { | 19 | const tasks = { |
| @@ -18,6 +18,7 @@ import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FIL | |||
| 18 | import { getConfigValue, color, delay, generateTimestamp } from './util.js'; | 18 | import { getConfigValue, color, delay, generateTimestamp } from './util.js'; |
| 19 | import { readSecret, writeSecret } from './endpoints/secrets.js'; | 19 | import { readSecret, writeSecret } from './endpoints/secrets.js'; |
| 20 | import { getContentOfType } from './endpoints/content-manager.js'; | 20 | import { getContentOfType } from './endpoints/content-manager.js'; |
| 21 | import { serverDirectory } from './server-directory.js'; | ||
| 21 | 22 | ||
| 22 | export const KEY_PREFIX = 'user:'; | 23 | export const KEY_PREFIX = 'user:'; |
| 23 | const AVATAR_PREFIX = 'avatar:'; | 24 | const AVATAR_PREFIX = 'avatar:'; |
| @@ -905,7 +906,7 @@ export async function loginPageMiddleware(request, response) { | |||
| 905 | console.error('Error during auto-login:', error); | 906 | console.error('Error during auto-login:', error); |
| 906 | } | 907 | } |
| 907 | 908 | ||
| 908 | return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); | 909 | return response.sendFile('login.html', { root: path.join(serverDirectory, 'public') }); |
| 909 | } | 910 | } |
| 910 | 911 | ||
| 911 | /** | 912 | /** |
| @@ -15,13 +15,15 @@ import yauzl from 'yauzl'; | |||
| 15 | import mime from 'mime-types'; | 15 | import mime from 'mime-types'; |
| 16 | import { default as simpleGit } from 'simple-git'; | 16 | import { default as simpleGit } from 'simple-git'; |
| 17 | import chalk from 'chalk'; | 17 | import chalk from 'chalk'; |
| 18 | import { LOG_LEVELS } from './constants.js'; | ||
| 19 | import bytes from 'bytes'; | 18 | import bytes from 'bytes'; |
| 19 | import { LOG_LEVELS } from './constants.js'; | ||
| 20 | import { serverDirectory } from './server-directory.js'; | ||
| 20 | 21 | ||
| 21 | /** | 22 | /** |
| 22 | * Parsed config object. | 23 | * Parsed config object. |
| 23 | */ | 24 | */ |
| 24 | let CACHED_CONFIG = null; | 25 | let CACHED_CONFIG = null; |
| 26 | let CONFIG_PATH = null; | ||
| 25 | 27 | ||
| 26 | /** | 28 | /** |
| 27 | * Converts a configuration key to an environment variable key. | 29 | * Converts a configuration key to an environment variable key. |
| @@ -32,22 +34,37 @@ let CACHED_CONFIG = null; | |||
| 32 | export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_'); | 34 | export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_'); |
| 33 | 35 | ||
| 34 | /** | 36 | /** |
| 37 | * Set the config file path. | ||
| 38 | * @param {string} configFilePath Path to the config file | ||
| 39 | */ | ||
| 40 | export function setConfigFilePath(configFilePath) { | ||
| 41 | if (CONFIG_PATH !== null) { | ||
| 42 | console.error(color.red('Config file path already set. Please restart the server to change the config file path.')); | ||
| 43 | } | ||
| 44 | CONFIG_PATH = path.resolve(configFilePath); | ||
| 45 | } | ||
| 46 | |||
| 47 | /** | ||
| 35 | * Returns the config object from the config.yaml file. | 48 | * Returns the config object from the config.yaml file. |
| 36 | * @returns {object} Config object | 49 | * @returns {object} Config object |
| 37 | */ | 50 | */ |
| 38 | export function getConfig() { | 51 | export function getConfig() { |
| 52 | if (CONFIG_PATH === null) { | ||
| 53 | console.trace(); | ||
| 54 | console.error(color.red('No config file path set. Please set the config file path using setConfigFilePath().')); | ||
| 55 | process.exit(1); | ||
| 56 | } | ||
| 39 | if (CACHED_CONFIG) { | 57 | if (CACHED_CONFIG) { |
| 40 | return CACHED_CONFIG; | 58 | return CACHED_CONFIG; |
| 41 | } | 59 | } |
| 42 | 60 | if (!fs.existsSync(CONFIG_PATH)) { | |
| 43 | if (!fs.existsSync('./config.yaml')) { | ||
| 44 | console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.')); | 61 | console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.')); |
| 45 | console.error(color.red('The program will now exit.')); | 62 | console.error(color.red('The program will now exit.')); |
| 46 | process.exit(1); | 63 | process.exit(1); |
| 47 | } | 64 | } |
| 48 | 65 | ||
| 49 | try { | 66 | try { |
| 50 | const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8')); | 67 | const config = yaml.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); |
| 51 | CACHED_CONFIG = config; | 68 | CACHED_CONFIG = config; |
| 52 | return config; | 69 | return config; |
| 53 | } catch (error) { | 70 | } catch (error) { |
| @@ -121,20 +138,19 @@ export async function getVersion() { | |||
| 121 | 138 | ||
| 122 | try { | 139 | try { |
| 123 | const require = createRequire(import.meta.url); | 140 | const require = createRequire(import.meta.url); |
| 124 | const pkgJson = require(path.join(process.cwd(), './package.json')); | 141 | const pkgJson = require(path.join(serverDirectory, './package.json')); |
| 125 | pkgVersion = pkgJson.version; | 142 | pkgVersion = pkgJson.version; |
| 126 | if (commandExistsSync('git')) { | 143 | if (commandExistsSync('git')) { |
| 127 | const git = simpleGit(); | 144 | const git = simpleGit({ baseDir: serverDirectory }); |
| 128 | const cwd = process.cwd(); | 145 | gitRevision = await git.revparse(['--short', 'HEAD']); |
| 129 | gitRevision = await git.cwd(cwd).revparse(['--short', 'HEAD']); | 146 | gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']); |
| 130 | gitBranch = await git.cwd(cwd).revparse(['--abbrev-ref', 'HEAD']); | 147 | commitDate = await git.show(['-s', '--format=%ci', gitRevision]); |
| 131 | commitDate = await git.cwd(cwd).show(['-s', '--format=%ci', gitRevision]); | ||
| 132 | 148 | ||
| 133 | const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']); | 149 | const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']); |
| 134 | 150 | ||
| 135 | // Might fail, but exception is caught. Just don't run anything relevant after in this block... | 151 | // Might fail, but exception is caught. Just don't run anything relevant after in this block... |
| 136 | const localLatest = await git.cwd(cwd).revparse(['HEAD']); | 152 | const localLatest = await git.revparse(['HEAD']); |
| 137 | const remoteLatest = await git.cwd(cwd).revparse([trackingBranch]); | 153 | const remoteLatest = await git.revparse([trackingBranch]); |
| 138 | isLatest = localLatest === remoteLatest; | 154 | isLatest = localLatest === remoteLatest; |
| 139 | } | 155 | } |
| 140 | } | 156 | } |
| @@ -1071,3 +1087,39 @@ export function mutateJsonString(jsonString, mutation) { | |||
| 1071 | return jsonString; | 1087 | return jsonString; |
| 1072 | } | 1088 | } |
| 1073 | } | 1089 | } |
| 1090 | |||
| 1091 | /** | ||
| 1092 | * Sets the permissions of a file or directory to be writable. | ||
| 1093 | * @param {string} targetPath Path to the file or directory | ||
| 1094 | */ | ||
| 1095 | export function setPermissionsSync(targetPath) { | ||
| 1096 | /** | ||
| 1097 | * Appends writable permission to the file mode. | ||
| 1098 | * @param {string} filePath Path to the file | ||
| 1099 | * @param {fs.Stats} stats File stats | ||
| 1100 | */ | ||
| 1101 | function appendWritablePermission(filePath, stats) { | ||
| 1102 | const currentMode = stats.mode; | ||
| 1103 | const newMode = currentMode | 0o200; | ||
| 1104 | if (newMode != currentMode) { | ||
| 1105 | fs.chmodSync(filePath, newMode); | ||
| 1106 | } | ||
| 1107 | } | ||
| 1108 | |||
| 1109 | try { | ||
| 1110 | const stats = fs.statSync(targetPath); | ||
| 1111 | |||
| 1112 | if (stats.isDirectory()) { | ||
| 1113 | appendWritablePermission(targetPath, stats); | ||
| 1114 | const files = fs.readdirSync(targetPath); | ||
| 1115 | |||
| 1116 | files.forEach((file) => { | ||
| 1117 | setPermissionsSync(path.join(targetPath, file)); | ||
| 1118 | }); | ||
| 1119 | } else { | ||
| 1120 | appendWritablePermission(targetPath, stats); | ||
| 1121 | } | ||
| 1122 | } catch (error) { | ||
| 1123 | console.error(`Error setting write permissions for ${targetPath}:`, error); | ||
| 1124 | } | ||
| 1125 | } | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | import process from 'node:process'; | 1 | import process from 'node:process'; |
| 2 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 3 | import isDocker from 'is-docker'; | 3 | import isDocker from 'is-docker'; |
| 4 | import { serverDirectory } from './src/server-directory.js'; | ||
| 4 | 5 | ||
| 5 | /** | 6 | /** |
| 6 | * Get the Webpack configuration for the public/lib.js file. | 7 | * Get the Webpack configuration for the public/lib.js file. |
| @@ -40,7 +41,7 @@ export default function getPublicLibConfig(forceDist = false) { | |||
| 40 | 41 | ||
| 41 | return { | 42 | return { |
| 42 | mode: 'production', | 43 | mode: 'production', |
| 43 | entry: './public/lib.js', | 44 | entry: path.join(serverDirectory, 'public/lib.js'), |
| 44 | cache: { | 45 | cache: { |
| 45 | type: 'filesystem', | 46 | type: 'filesystem', |
| 46 | cacheDirectory: cacheDirectory, | 47 | cacheDirectory: cacheDirectory, |