Merge pull request #3857 from wrvsrx/allow-readonly-install Allow read-only installation
Signed| @@ -13,3 +13,4 @@ access.log | ||
| 13 | 13 | /cache |
| 14 | 14 | .DS_Store |
| 15 | 15 | /public/scripts/extensions/third-party |
| 16 | +/colab | |
| @@ -350,6 +350,7 @@ Start.bat --port 8000 --listen false | ||
| 350 | 350 | | Option | Description | Type | |
| 351 | 351 | |-------------------------|----------------------------------------------------------------------|----------| |
| 352 | 352 | | `--version` | Show version number | boolean | |
| 353 | +| `--configPath` | Override the path to the config.yaml file | string | | |
| 353 | 354 | | `--dataRoot` | Root directory for data storage | string | |
| 354 | 355 | | `--port` | Sets the port under which SillyTavern will run | number | |
| 355 | 356 | | `--listen` | SillyTavern will listen on all network interfaces | boolean | |
| @@ -12,3 +12,4 @@ access.log | ||
| 12 | 12 | .vscode |
| 13 | 13 | .git |
| 14 | 14 | /public/scripts/extensions/third-party |
| 15 | +/colab | |
| @@ -12,15 +12,13 @@ WORKDIR ${APP_HOME} | ||
| 12 | 12 | # Set NODE_ENV to production |
| 13 | 13 | ENV NODE_ENV=production |
| 14 | 14 | |
| 15 | 15 | # InstallBundle app dependenciessource |
| 16 | 16 | COPY package*.json post-install.js ./ |
| 17 | + | |
| 17 | 18 | RUN \ |
| 18 | 19 | echo "*** Install npm packages ***" && \ |
| 19 | 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 | 22 | # Copy default chats, characters and user avatars to <folder>.default folder |
| 25 | 23 | RUN \ |
| 26 | 24 | rm -f "config.yaml" || true && \ |
| @@ -3,133 +3,17 @@ | ||
| 3 | 3 | */ |
| 4 | 4 | import fs from 'node:fs'; |
| 5 | 5 | import path from 'node:path'; |
| 6 | -import crypto from 'node:crypto'; | |
| 7 | 6 | import process from 'node:process'; |
| 8 | 7 | import yaml from 'yaml'; |
| 9 | -import _ from 'lodash'; | |
| 10 | 8 | import chalk from 'chalk'; |
| 11 | 9 | import { createRequire } from 'node:module'; |
| 10 | +import { addMissingConfigValues } from './src/config-init.js'; | |
| 12 | 11 | |
| 13 | 12 | /** |
| 14 | 13 | * Colorizes console output. |
| 15 | 14 | */ |
| 16 | 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 | 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 | 44 | * Creates the default config files if they don't exist yet. |
| 226 | 45 | */ |
| 227 | 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 | 105 | try { |
| 330 | 106 | // 0. Convert config.conf to config.yaml |
| 331 | 107 | convertConfig(); |
| 332 | 108 | // 1. Create default config files |
| 333 | 109 | createDefaultFiles(); |
| 334 | 110 | // 2. Copy transformers WASMAdd binariesmissing fromconfig node_modulesvalues |
| 335 | - copyWasmFiles(); | |
| 111 | + addMissingConfigValues(path.join(process.cwd(), './config.yaml')); | |
| 336 | - // 3. Add missing config values | |
| 337 | - addMissingConfigValues(); | |
| 338 | 112 | } catch (error) { |
| 339 | 113 | console.error(error); |
| 340 | 114 | } |
| @@ -1,393 +1,15 @@ | ||
| 1 | 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 | 2 | import { CommandLineParser } from './src/command-line.js'; |
| 26 | 3 | import { loadPluginsserverDirectory } from './src/pluginserver-loaderdirectory.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 | 6 | const cliArgs = new CommandLineParser().parse(process.argv); |
| 91 | 7 | globalThis.DATA_ROOT = cliArgs.dataRoot; |
| 92 | 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 | 11 | try { |
| 334 | - console.log('Launching in a browser...'); | |
| 12 | + await import('./src/server-main.js'); | |
| 335 | - await open(autorunUrl.toString()); | |
| 336 | 13 | } catch (error) { |
| 337 | 14 | console.error('FailedA tocritical launcherror thehas browser.occurred Openwhile thestarting URLthe manually.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 | 2 | import { hideBin } from 'yargs/helpers'; |
| 3 | 3 | import ipRegex from 'ip-regex'; |
| 4 | 4 | import { canResolve, color, getConfigValue, stringToBool } from './util.js'; |
| 5 | +import { initConfig } from './config-init.js'; | |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * @typedef {object} CommandLineArguments Parsed command line arguments |
| 9 | + * @property {string} configPath Path to the config file | |
| 8 | 10 | * @property {string} dataRoot Data root directory |
| 9 | 11 | * @property {number} port Port number |
| 10 | 12 | * @property {boolean} listen If SillyTavern is listening on all network interfaces |
| @@ -40,6 +42,7 @@ export class CommandLineParser { | ||
| 40 | 42 | constructor() { |
| 41 | 43 | /** @type {CommandLineArguments} */ |
| 42 | 44 | this.default = Object.freeze({ |
| 45 | + configPath: './config.yaml', | |
| 43 | 46 | dataRoot: './data', |
| 44 | 47 | port: 8000, |
| 45 | 48 | listen: false, |
| @@ -88,6 +91,11 @@ export class CommandLineParser { | ||
| 88 | 91 | parse(args) { |
| 89 | 92 | const cliArguments = yargs(hideBin(args)) |
| 90 | 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 | 99 | .option('enableIPv6', { |
| 92 | 100 | type: 'string', |
| 93 | 101 | default: null, |
| @@ -177,8 +185,11 @@ export class CommandLineParser { | ||
| 177 | 185 | describe: 'Request proxy bypass list (space separated list of hosts)', |
| 178 | 186 | }).parseSync(); |
| 179 | 187 | |
| 188 | + const configPath = cliArguments.configPath ?? this.default.configPath; | |
| 189 | + initConfig(configPath); | |
| 180 | 190 | /** @type {CommandLineArguments} */ |
| 181 | 191 | const result = { |
| 192 | + configPath: configPath, | |
| 182 | 193 | dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot), |
| 183 | 194 | port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'), |
| 184 | 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 | 1 | import fs from 'node:fs'; |
| 2 | 2 | import path from 'node:path'; |
| 3 | -import process from 'node:process'; | |
| 4 | 3 | import { Buffer } from 'node:buffer'; |
| 5 | 4 | |
| 6 | 5 | import express from 'express'; |
| @@ -8,11 +7,12 @@ import fetch from 'node-fetch'; | ||
| 8 | 7 | import sanitize from 'sanitize-filename'; |
| 9 | 8 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 10 | 9 | |
| 11 | 10 | import { getConfigValue, color, setPermissionsSync } from '../util.js'; |
| 12 | 11 | import { write } from '../character-card-parser.js'; |
| 12 | +import { serverDirectory } from '../server-directory.js'; | |
| 13 | 13 | |
| 14 | 14 | const contentDirectory = path.join(process.cwd()serverDirectory, 'default/content'); |
| 15 | 15 | const scaffoldDirectory = path.join(process.cwd()serverDirectory, 'default/scaffold'); |
| 16 | 16 | const contentIndexPath = path.join(contentDirectory, 'index.json'); |
| 17 | 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 | 151 | fs.cpSync(contentPath, targetPath, { recursive: true, force: false }); |
| 152 | + setPermissionsSync(targetPath); | |
| 152 | 153 | console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`); |
| 153 | 154 | anyContentAdded = true; |
| 154 | 155 | } |
| @@ -2,6 +2,7 @@ import fs from 'node:fs'; | ||
| 2 | 2 | import path from 'node:path'; |
| 3 | 3 | import { fileURLToPath } from 'node:url'; |
| 4 | 4 | import mime from 'mime-types'; |
| 5 | +import { serverDirectory } from './server-directory.js'; | |
| 5 | 6 | |
| 6 | 7 | const originalFetch = globalThis.fetch; |
| 7 | 8 | |
| @@ -67,10 +68,9 @@ globalThis.fetch = async (/** @type {string | URL | Request} */ request, /** @ty | ||
| 67 | 68 | } |
| 68 | 69 | const url = getRequestURL(request); |
| 69 | 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 | 75 | const parsedPath = path.parse(filePath); |
| 76 | 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 | 6 | import { pipeline, env, RawImage } from 'sillytavern-transformers'; |
| 7 | 7 | import { getConfigValue } from './util.js'; |
| 8 | +import { serverDirectory } from './server-directory.js'; | |
| 8 | 9 | |
| 9 | 10 | configureTransformers(); |
| 10 | 11 | |
| @@ -12,7 +13,7 @@ function configureTransformers() { | ||
| 12 | 13 | // Limit the number of threads to 1 to avoid issues on Android |
| 13 | 14 | env.backends.onnx.wasm.numThreads = 1; |
| 14 | 15 | // Use WASM from a local folder to avoid CDN connections |
| 15 | 16 | env.backends.onnx.wasm.wasmPaths = path.join(process.cwd()serverDirectory, 'node_modules', 'sillytavern-transformers', 'dist') + path.sep; |
| 16 | 17 | } |
| 17 | 18 | |
| 18 | 19 | const tasks = { |
| @@ -18,6 +18,7 @@ import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FIL | ||
| 18 | 18 | import { getConfigValue, color, delay, generateTimestamp } from './util.js'; |
| 19 | 19 | import { readSecret, writeSecret } from './endpoints/secrets.js'; |
| 20 | 20 | import { getContentOfType } from './endpoints/content-manager.js'; |
| 21 | +import { serverDirectory } from './server-directory.js'; | |
| 21 | 22 | |
| 22 | 23 | export const KEY_PREFIX = 'user:'; |
| 23 | 24 | const AVATAR_PREFIX = 'avatar:'; |
| @@ -905,7 +906,7 @@ export async function loginPageMiddleware(request, response) { | ||
| 905 | 906 | console.error('Error during auto-login:', error); |
| 906 | 907 | } |
| 907 | 908 | |
| 908 | 909 | return response.sendFile('login.html', { root: path.join(process.cwd()serverDirectory, 'public') }); |
| 909 | 910 | } |
| 910 | 911 | |
| 911 | 912 | /** |
| @@ -15,13 +15,15 @@ import yauzl from 'yauzl'; | ||
| 15 | 15 | import mime from 'mime-types'; |
| 16 | 16 | import { default as simpleGit } from 'simple-git'; |
| 17 | 17 | import chalk from 'chalk'; |
| 18 | -import { LOG_LEVELS } from './constants.js'; | |
| 19 | 18 | import bytes from 'bytes'; |
| 19 | +import { LOG_LEVELS } from './constants.js'; | |
| 20 | +import { serverDirectory } from './server-directory.js'; | |
| 20 | 21 | |
| 21 | 22 | /** |
| 22 | 23 | * Parsed config object. |
| 23 | 24 | */ |
| 24 | 25 | let CACHED_CONFIG = null; |
| 26 | +let CONFIG_PATH = null; | |
| 25 | 27 | |
| 26 | 28 | /** |
| 27 | 29 | * Converts a configuration key to an environment variable key. |
| @@ -32,22 +34,37 @@ let CACHED_CONFIG = null; | ||
| 32 | 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 | 48 | * Returns the config object from the config.yaml file. |
| 36 | 49 | * @returns {object} Config object |
| 37 | 50 | */ |
| 38 | 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 | 57 | if (CACHED_CONFIG) { |
| 40 | 58 | return CACHED_CONFIG; |
| 41 | 59 | } |
| 42 | - | |
| 60 | + if (!fs.existsSync(CONFIG_PATH)) { | |
| 43 | - if (!fs.existsSync('./config.yaml')) { | |
| 44 | 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 | 62 | console.error(color.red('The program will now exit.')); |
| 46 | 63 | process.exit(1); |
| 47 | 64 | } |
| 48 | 65 | |
| 49 | 66 | try { |
| 50 | 67 | const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml')CONFIG_PATH, 'utf8')); |
| 51 | 68 | CACHED_CONFIG = config; |
| 52 | 69 | return config; |
| 53 | 70 | } catch (error) { |
| @@ -121,20 +138,19 @@ export async function getVersion() { | ||
| 121 | 138 | |
| 122 | 139 | try { |
| 123 | 140 | const require = createRequire(import.meta.url); |
| 124 | 141 | const pkgJson = require(path.join(process.cwd()serverDirectory, './package.json')); |
| 125 | 142 | pkgVersion = pkgJson.version; |
| 126 | 143 | if (commandExistsSync('git')) { |
| 127 | 144 | const git = simpleGit({ baseDir: serverDirectory }); |
| 128 | - const cwd = process.cwd(); | |
| 145 | + gitRevision = await git.revparse(['--short', 'HEAD']); | |
| 129 | 146 | gitRevisiongitBranch = await git.cwd(cwd).revparse(['--shortabbrev-ref', 'HEAD']); |
| 130 | 147 | gitBranchcommitDate = await git.cwd(cwd).revparseshow(['--abbrev-refs', 'HEAD--format=%ci', gitRevision]); |
| 131 | - commitDate = await git.cwd(cwd).show(['-s', '--format=%ci', gitRevision]); | |
| 132 | 148 | |
| 133 | 149 | const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']); |
| 134 | 150 | |
| 135 | 151 | // Might fail, but exception is caught. Just don't run anything relevant after in this block... |
| 136 | 152 | const localLatest = await git.cwd(cwd).revparse(['HEAD']); |
| 137 | 153 | const remoteLatest = await git.cwd(cwd).revparse([trackingBranch]); |
| 138 | 154 | isLatest = localLatest === remoteLatest; |
| 139 | 155 | } |
| 140 | 156 | } |
| @@ -1071,3 +1087,39 @@ export function mutateJsonString(jsonString, mutation) { | ||
| 1071 | 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 | 1 | import process from 'node:process'; |
| 2 | 2 | import path from 'node:path'; |
| 3 | 3 | import isDocker from 'is-docker'; |
| 4 | +import { serverDirectory } from './src/server-directory.js'; | |
| 4 | 5 | |
| 5 | 6 | /** |
| 6 | 7 | * Get the Webpack configuration for the public/lib.js file. |
| @@ -40,7 +41,7 @@ export default function getPublicLibConfig(forceDist = false) { | ||
| 40 | 41 | |
| 41 | 42 | return { |
| 42 | 43 | mode: 'production', |
| 43 | 44 | entry: 'path./join(serverDirectory, 'public/lib.js'), |
| 44 | 45 | cache: { |
| 45 | 46 | type: 'filesystem', |
| 46 | 47 | cacheDirectory: cacheDirectory, |