Spaces:
Running
Running
// native node modules | |
import path from 'node:path'; | |
import util from 'node:util'; | |
import net from 'node:net'; | |
import dns from 'node:dns'; | |
import process from 'node:process'; | |
import { fileURLToPath } from 'node:url'; | |
import cors from 'cors'; | |
import { csrfSync } from 'csrf-sync'; | |
import express from 'express'; | |
import compression from 'compression'; | |
import cookieSession from 'cookie-session'; | |
import multer from 'multer'; | |
import responseTime from 'response-time'; | |
import helmet from 'helmet'; | |
import bodyParser from 'body-parser'; | |
import open from 'open'; | |
// local library imports | |
import { serverEvents, EVENT_NAMES } from './src/server-events.js'; | |
import { CommandLineParser } from './src/command-line.js'; | |
import { loadPlugins } from './src/plugin-loader.js'; | |
import { | |
initUserStorage, | |
getCookieSecret, | |
getCookieSessionName, | |
ensurePublicDirectoriesExist, | |
getUserDirectoriesList, | |
migrateSystemPrompts, | |
migrateUserData, | |
requireLoginMiddleware, | |
setUserDataMiddleware, | |
shouldRedirectToLogin, | |
cleanUploads, | |
getSessionCookieAge, | |
verifySecuritySettings, | |
loginPageMiddleware, | |
} from './src/users.js'; | |
import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; | |
import basicAuthMiddleware from './src/middleware/basicAuth.js'; | |
import getWhitelistMiddleware from './src/middleware/whitelist.js'; | |
import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js'; | |
import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; | |
import initRequestProxy from './src/request-proxy.js'; | |
import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; | |
import corsProxyMiddleware from './src/middleware/corsProxy.js'; | |
import { | |
getVersion, | |
color, | |
removeColorFormatting, | |
getSeparator, | |
safeReadFileSync, | |
setupLogLevel, | |
setWindowTitle, | |
} from './src/util.js'; | |
import { UPLOADS_DIRECTORY } from './src/constants.js'; | |
import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; | |
// Routers | |
import { router as usersPublicRouter } from './src/endpoints/users-public.js'; | |
import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js'; | |
import { checkForNewContent } from './src/endpoints/content-manager.js'; | |
import { init as settingsInit } from './src/endpoints/settings.js'; | |
import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js'; | |
// Unrestrict console logs display limit | |
util.inspect.defaultOptions.maxArrayLength = null; | |
util.inspect.defaultOptions.maxStringLength = null; | |
util.inspect.defaultOptions.depth = 4; | |
// Set a working directory for the server | |
const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); | |
console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); | |
process.chdir(serverDirectory); | |
// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. | |
// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 | |
// Safe to remove once support for Node v20 is dropped. | |
if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) { | |
// @ts-ignore | |
if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); | |
} | |
const cliArgs = new CommandLineParser().parse(process.argv); | |
globalThis.DATA_ROOT = cliArgs.dataRoot; | |
globalThis.COMMAND_LINE_ARGS = cliArgs; | |
if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { | |
console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); | |
process.exit(1); | |
} | |
try { | |
if (cliArgs.dnsPreferIPv6) { | |
dns.setDefaultResultOrder('ipv6first'); | |
console.log('Preferring IPv6 for DNS resolution'); | |
} else { | |
dns.setDefaultResultOrder('ipv4first'); | |
console.log('Preferring IPv4 for DNS resolution'); | |
} | |
} catch (error) { | |
console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.'); | |
} | |
const app = express(); | |
app.use(helmet({ | |
contentSecurityPolicy: false, | |
})); | |
app.use(compression()); | |
app.use(responseTime()); | |
app.use(bodyParser.json({ limit: '200mb' })); | |
app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' })); | |
// CORS Settings // | |
const CORS = cors({ | |
origin: 'null', | |
methods: ['OPTIONS'], | |
}); | |
app.use(CORS); | |
if (cliArgs.listen && cliArgs.basicAuthMode) { | |
app.use(basicAuthMiddleware); | |
} | |
if (cliArgs.whitelistMode) { | |
const whitelistMiddleware = await getWhitelistMiddleware(); | |
app.use(whitelistMiddleware); | |
} | |
if (cliArgs.listen) { | |
app.use(accessLoggerMiddleware()); | |
} | |
if (cliArgs.enableCorsProxy) { | |
app.use('/proxy/:url(*)', corsProxyMiddleware); | |
} else { | |
app.use('/proxy/:url(*)', async (_, res) => { | |
const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; | |
console.log(message); | |
res.status(404).send(message); | |
}); | |
} | |
app.use(cookieSession({ | |
name: getCookieSessionName(), | |
sameSite: 'strict', | |
httpOnly: true, | |
maxAge: getSessionCookieAge(), | |
secret: getCookieSecret(globalThis.DATA_ROOT), | |
})); | |
app.use(setUserDataMiddleware); | |
// CSRF Protection // | |
if (!cliArgs.disableCsrf) { | |
const csrfSyncProtection = csrfSync({ | |
getTokenFromState: (req) => { | |
if (!req.session) { | |
console.error('(CSRF error) getTokenFromState: Session object not initialized'); | |
return; | |
} | |
return req.session.csrfToken; | |
}, | |
getTokenFromRequest: (req) => { | |
return req.headers['x-csrf-token']?.toString(); | |
}, | |
storeTokenInState: (req, token) => { | |
if (!req.session) { | |
console.error('(CSRF error) storeTokenInState: Session object not initialized'); | |
return; | |
} | |
req.session.csrfToken = token; | |
}, | |
size: 32, | |
}); | |
app.get('/csrf-token', (req, res) => { | |
res.json({ | |
'token': csrfSyncProtection.generateToken(req), | |
}); | |
}); | |
// Customize the error message | |
csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); | |
csrfSyncProtection.invalidCsrfTokenError.stack = undefined; | |
app.use(csrfSyncProtection.csrfSynchronisedProtection); | |
} else { | |
console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); | |
app.get('/csrf-token', (req, res) => { | |
res.json({ | |
'token': 'disabled', | |
}); | |
}); | |
} | |
// Static files | |
// Host index page | |
app.get('/', getCacheBusterMiddleware(), (request, response) => { | |
if (shouldRedirectToLogin(request)) { | |
const query = request.url.split('?')[1]; | |
const redirectUrl = query ? `/login?${query}` : '/login'; | |
return response.redirect(redirectUrl); | |
} | |
return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') }); | |
}); | |
// Host login page | |
app.get('/login', loginPageMiddleware); | |
// Host frontend assets | |
const webpackMiddleware = getWebpackServeMiddleware(); | |
app.use(webpackMiddleware); | |
app.use(express.static(process.cwd() + '/public', {})); | |
// Public API | |
app.use('/api/users', usersPublicRouter); | |
// Everything below this line requires authentication | |
app.use(requireLoginMiddleware); | |
app.get('/api/ping', (request, response) => { | |
if (request.query.extend && request.session) { | |
request.session.touch = Date.now(); | |
} | |
response.sendStatus(204); | |
}); | |
// File uploads | |
const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY); | |
app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); | |
app.use(multerMonkeyPatch); | |
app.get('/version', async function (_, response) { | |
const data = await getVersion(); | |
response.send(data); | |
}); | |
redirectDeprecatedEndpoints(app); | |
setupPrivateEndpoints(app); | |
/** | |
* Tasks that need to be run before the server starts listening. | |
* @returns {Promise<void>} | |
*/ | |
async function preSetupTasks() { | |
const version = await getVersion(); | |
// Print formatted header | |
console.log(); | |
console.log(`SillyTavern ${version.pkgVersion}`); | |
if (version.gitBranch) { | |
console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}`); | |
if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) { | |
console.log('INFO: Currently not on the latest commit.'); | |
console.log(' Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.'); | |
} | |
} | |
console.log(); | |
const directories = await getUserDirectoriesList(); | |
await checkForNewContent(directories); | |
await ensureThumbnailCache(); | |
cleanUploads(); | |
migrateAccessLog(); | |
await settingsInit(); | |
await statsInit(); | |
const pluginsDirectory = path.join(serverDirectory, 'plugins'); | |
const cleanupPlugins = await loadPlugins(app, pluginsDirectory); | |
const consoleTitle = process.title; | |
let isExiting = false; | |
const exitProcess = async () => { | |
if (isExiting) return; | |
isExiting = true; | |
await statsOnExit(); | |
if (typeof cleanupPlugins === 'function') { | |
await cleanupPlugins(); | |
} | |
setWindowTitle(consoleTitle); | |
process.exit(); | |
}; | |
// Set up event listeners for a graceful shutdown | |
process.on('SIGINT', exitProcess); | |
process.on('SIGTERM', exitProcess); | |
process.on('uncaughtException', (err) => { | |
console.error('Uncaught exception:', err); | |
exitProcess(); | |
}); | |
// Add request proxy. | |
initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass }); | |
// Wait for frontend libs to compile | |
await webpackMiddleware.runWebpackCompiler(); | |
} | |
/** | |
* Tasks that need to be run after the server starts listening. | |
* @param {import('./src/server-startup.js').ServerStartupResult} result The result of the server startup | |
* @returns {Promise<void>} | |
*/ | |
async function postSetupTasks(result) { | |
const autorunHostname = await cliArgs.getAutorunHostname(result); | |
const autorunUrl = cliArgs.getAutorunUrl(autorunHostname); | |
if (cliArgs.autorun) { | |
console.log('Launching in a browser...'); | |
await open(autorunUrl.toString()); | |
} | |
setWindowTitle('SillyTavern WebServer'); | |
let logListen = 'SillyTavern is listening on'; | |
if (result.useIPv6 && !result.v6Failed) { | |
logListen += color.green( | |
' IPv6: ' + cliArgs.getIPv6ListenUrl().host, | |
); | |
} | |
if (result.useIPv4 && !result.v4Failed) { | |
logListen += color.green( | |
' IPv4: ' + cliArgs.getIPv4ListenUrl().host, | |
); | |
} | |
const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern'; | |
const plainGoToLog = removeColorFormatting(goToLog); | |
console.log(logListen); | |
if (cliArgs.listen) { | |
console.log(); | |
console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); | |
console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); | |
} | |
console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | |
console.log(goToLog); | |
console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | |
setupLogLevel(); | |
serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: autorunUrl }); | |
} | |
/** | |
* Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. | |
*/ | |
function apply404Middleware() { | |
const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? ''; | |
app.use((req, res) => { | |
res.status(404).send(notFoundWebpage); | |
}); | |
} | |
// User storage module needs to be initialized before starting the server | |
initUserStorage(globalThis.DATA_ROOT) | |
.then(ensurePublicDirectoriesExist) | |
.then(migrateUserData) | |
.then(migrateSystemPrompts) | |
.then(verifySecuritySettings) | |
.then(preSetupTasks) | |
.then(apply404Middleware) | |
.then(() => new ServerStartup(app, cliArgs).start()) | |
.then(postSetupTasks); | |