Skip to content

Instantly share code, notes, and snippets.

@wommy
Last active November 30, 2024 20:45
Show Gist options
  • Save wommy/fe97bd7706f2fdd33bd13457c4ad29e6 to your computer and use it in GitHub Desktop.
Save wommy/fe97bd7706f2fdd33bd13457c4ad29e6 to your computer and use it in GitHub Desktop.
ABS server refactor
import * as Path from 'path'
import * as Sequelize from 'sequelize'
import * as express from 'express'
import * as http from 'http'
import * as util from 'util'
import * as fs from './libs/fsExtra'
import * as fileUpload from './libs/expressFileupload'
import * as cookieParser from 'cookie-parser'
import { version } from '../package.json'
import * as dotenv from 'dotenv'
import { Request, Response, NextFunction } from 'express'
// Load environment variables
dotenv.config()
// Define constants for configuration
const DEFAULT_PORT = 80
const DEFAULT_HOST = ''
const DEFAULT_CONFIG_PATH = '/config'
const DEFAULT_METADATA_PATH = '/metadata'
const DEFAULT_SOURCE = 'docker'
const DEFAULT_ROUTER_BASE_PATH = ''
// Define a utility function for environment variable retrieval
const getEnvVar = (varName: string, defaultValue: any) => {
return process.env[varName] || defaultValue
}
// Utils
import * as fileUtils from './utils/fileUtils'
import Logger from './Logger'
import Auth from './Auth'
import Watcher from './Watcher'
import Database from './Database'
import SocketAuthority from './SocketAuthority'
import ApiRouter from './routers/ApiRouter'
import HlsRouter from './routers/HlsRouter'
import PublicRouter from './routers/PublicRouter'
import { ManagerClasses } from './managers/'
//Import the main Passport and Express-Session library
import passport from 'passport'
import expressSession from 'express-session'
import MemoryStore from './libs/memorystore'
import LibraryScanner from './scanner/LibraryScanner'
import ShareManager from './managers/ShareManager'
import LogManager from './managers/LogManager'
import CacheManager from './managers/CacheManager'
interface StatusPayload {
app: string
serverVersion: string
isInit: boolean
language: any
authMethods: any
authFormData: any
ConfigPath?: string
MetadataPath?: string
}
export default class Server {
// Properties
private Port: number
private Host: string
private server: http.Server | null = null
private io: any | null = null
private distPath: string = Path.join(global.appRoot, '/dist')
// Managers
private managers: { [key: string]: any } = {}
// Routers
private routers: { [key: string]: any } = {}
constructor(
SOURCE: string = getEnvVar('SOURCE', DEFAULT_SOURCE),
PORT: number = parseInt(getEnvVar('PORT', DEFAULT_PORT.toString())),
HOST: string = getEnvVar('HOST', DEFAULT_HOST),
CONFIG_PATH: string = getEnvVar('CONFIG_PATH', DEFAULT_CONFIG_PATH),
METADATA_PATH: string = getEnvVar('METADATA_PATH', DEFAULT_METADATA_PATH),
ROUTER_BASE_PATH: string = getEnvVar('ROUTER_BASE_PATH', DEFAULT_ROUTER_BASE_PATH),
managers: any[] = ManagerClasses // Pass managers as an array
) {
this.Port = PORT
this.Host = HOST
global.Source = SOURCE
global.isWin = process.platform === 'win32'
global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
global.RouterBasePath = ROUTER_BASE_PATH
global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1'
global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1'
this.initializePaths()
this.initializeManagers(managers)
this.initializeRouters()
Logger.logManager = new LogManager()
}
// Initialization Methods
async init() {
try {
this.logServerInitialization()
await this.managers.playbackSessionManager.removeOrphanStreams()
if (global.Source !== 'docker') {
await this.managers.binaryManager.init()
}
await this.initializeAllServices()
this.setupWatcher(await Database.libraryModel.getAllWithFolders())
} catch (error) {
Logger.error('[Server] Initialization error:', error)
throw error // Rethrow to handle it in the calling function
}
}
private initializePaths() {
this.ensurePathsExist([global.ConfigPath, global.MetadataPath])
}
private initializeManagers(managerClasses: any[]) {
managerClasses.forEach((ManagerClass) => {
const managerName = ManagerClass.name.charAt(0).toLowerCase() + ManagerClass.name.slice(1)
this.managers[managerName] = new ManagerClass()
})
}
private initializeRouters() {
this.routers.apiRouter = new ApiRouter(this)
this.routers.hlsRouter = new HlsRouter(this.managers.auth, this.managers.playbackSessionManager)
this.routers.publicRouter = new PublicRouter(this.managers.playbackSessionManager)
}
// Middleware Methods
private async setupMiddleware(app: express.Express) {
const middlewares = [
cookieParser(),
this.createSessionMiddleware(),
passport.initialize(),
this.managers.auth.ifAuthNeeded(passport.session()),
express.urlencoded({ extended: true, limit: '5mb' }),
express.json({ limit: '5mb' }),
this.fileUploadMiddleware()
]
middlewares.forEach((middleware) => app.use(middleware))
await this.managers.auth.initPassportJs()
}
private createSessionMiddleware() {
return expressSession({
secret: global.ServerSettings.tokenSecret,
resave: false,
saveUninitialized: false,
cookie: { secure: false },
store: new MemoryStore(86400000, 86400000, 1000)
})
}
// Routing Methods
private async setupRouter(router: express.Router) {
this.setupRoutes(router)
router.use('/', this.fileUploadMiddleware())
}
private async setupRoutes(router: express.Router) {
this.setupStaticRoutes(router)
this.setupApiRoutes(router)
this.setupFeedRoutes(router)
this.setupDynamicRoutes(router)
this.setupAuthRoutes(router)
this.setupHealthCheck(router)
}
private setupStaticRoutes(router: express.Router) {
const distPath = Path.join(global.appRoot, '/dist')
router.use(express.static(distPath))
router.use(express.static(Path.join(global.appRoot, 'static')))
}
private setupApiRoutes(router: express.Router) {
router.use('/api', this.managers.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.routers.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.routers.hlsRouter.router)
router.use('/public', this.routers.publicRouter.router)
}
private setupFeedRoutes(router: express.Router) {
router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
this.managers.rssFeedManager.getFeed(req, res)
})
router.get('/feed/:slug/cover*', (req, res) => {
this.managers.rssFeedManager.getFeedCover(req, res)
})
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
this.managers.rssFeedManager.getFeedItem(req, res)
})
}
private setupDynamicRoutes(router: express.Router) {
const dynamicRoutes = [
'/item/:id',
'/author/:id',
'/audiobook/:id/chapters',
'/audiobook/:id/edit',
'/audiobook/:id/manage',
'/library/:library',
'/library/:library/search',
'/library/:library/bookshelf/:id?',
'/library/:library/authors',
'/library/:library/narrators',
'/library/:library/stats',
'/library/:library/series/:id?',
'/library/:library/podcast/search',
'/library/:library/podcast/latest',
'/library/:library/podcast/download-queue',
'/config/users/:id',
'/config/users/:id/sessions',
'/config/item-metadata-utils/:id',
'/collection/:id',
'/playlist/:id',
'/share/:slug'
]
dynamicRoutes.forEach(
(route) => router.get(
route, (req, res) => res.sendFile(Path.join(this.distPath, 'index.html'))
)
)
this.setupInitRoute(router)
this.setupStatusRoute(router)
this.setupHealthCheck(router)
}
private setupInitRoute(router: express.Router) {
router.post('/init', ((req: express.Request, res: express.Response) => {
if (Database.hasRootUser) {
Logger.error(`[Server] attempt to init server when server already has a root user`)
return res.sendStatus(500)
}
return this.initializeServer(req, res)
}) as express.RequestHandler)
}
private setupStatusRoute(router: express.Router) {
router.get('/status', this.handleStatusRequest.bind(this))
router.get('/ping', this.handlePingRequest.bind(this))
router.get('/healthcheck', this.handleHealthCheckRequest.bind(this))
}
// New method to handle status requests
private handleStatusRequest(req: express.Request, res: express.Response) {
const payload: StatusPayload = this.createStatusPayload()
Logger.info('[Server] Status requested:', payload)
res.json(payload)
}
// New method to create the status payload
private createStatusPayload(): StatusPayload {
const payload: StatusPayload = {
app: 'audiobookshelf',
serverVersion: version,
isInit: Database.hasRootUser,
language: Database.serverSettings?.language,
authMethods: Database.serverSettings?.authActiveAuthMethods,
authFormData: Database.serverSettings?.authFormData
}
if (!payload.isInit) {
payload.ConfigPath = global.ConfigPath
payload.MetadataPath = global.MetadataPath
}
return payload
}
// New method to handle ping requests
private handlePingRequest(req: express.Request, res: express.Response) {
Logger.info('Received ping')
res.json({ success: true })
}
// New method to handle health check requests
private handleHealthCheckRequest(req: express.Request, res: express.Response) {
res.sendStatus(200)
}
private async setupAuthRoutes(router: express.Router) {
await this.managers.auth.initAuthRoutes(router)
}
// Service Methods
private async initializeAllServices() {
await Promise.all([
this.initializeCoreServices(),
this.initializeManagersAndServices(),
this.initializeCronManager(await Database.libraryModel.getAllWithFolders())
])
}
private async initializeCoreServices() {
await Promise.all([
Database.init(false),
Logger.logManager!.init(),
this.initializeTokenSecret(),
this.cleanUserData(),
CacheManager.ensureCachePaths()
])
}
private async initializeManagersAndServices() {
await Promise.all([
ShareManager.init(),
this.managers.backupManager.init(),
this.managers.rssFeedManager.init()
])
}
private async initializeCronManager(libraries: any[]) {
await this.managers.cronManager.init(libraries)
this.managers.apiCacheManager.init()
}
private async initializeTokenSecret() {
if (!Database.serverSettings?.tokenSecret) {
await this.managers.auth.initTokenSecret()
}
}
// Event Listeners
initProcessEventListeners() {
process.on('SIGINT', this.handleSigint.bind(this))
process.on('uncaughtExceptionMonitor', this.handleUncaughtException.bind(this))
process.on('unhandledRejection', this.handleUnhandledRejection.bind(this))
}
private async handleSigint() {
let sigintAlreadyReceived = false
if (!sigintAlreadyReceived) {
sigintAlreadyReceived = true
Logger.info('SIGINT (Ctrl+C) received. Shutting down...')
await this.stop()
Logger.info('Server stopped. Exiting.')
} else {
Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.')
}
process.exit(0)
}
private async handleUncaughtException(error: any, origin: string) {
await Logger.fatal(`[Server] Uncaught exception origin: ${origin}, error:`, util.format('%O', error))
}
private async handleUnhandledRejection(reason: any, promise: Promise<any>) {
await Logger.fatal('[Server] Unhandled rejection:', reason, '\npromise:', util.format('%O', promise))
process.exit(1)
}
// Server Control Methods
async start() {
Logger.info('=== Starting Server ===')
this.initProcessEventListeners()
await this.init()
const app = express()
this.setupSecurityHeaders(app)
// Middleware setup
await this.setupMiddleware(app)
const router = express.Router()
this.setupRouter(router)
this.setupHealthCheck(router)
app.use(global.RouterBasePath, router)
app.disable('x-powered-by')
this.server = http.createServer(app)
if (!this.server) {
throw new Error('Server not initialized')
}
this.server!.listen(this.Port, this.Host, () => {
Logger.info(`Listening on ${this.Host ? `http://${this.Host}:${this.Port}` : `port :${this.Port}`}`)
})
// Start listening for socket connections
SocketAuthority.initialize(this)
app.use(this.errorHandler)
}
async stop() {
Logger.info('=== Stopping Server ===')
Watcher.close()
Logger.info('Watcher Closed')
return new Promise<void>((resolve) => {
SocketAuthority.close((err) => {
if (err) {
Logger.error('Failed to close server', err)
} else {
Logger.info('Server successfully closed')
}
resolve()
})
})
}
// Utility Methods
private ensurePathsExist(paths: string[]) {
paths.forEach(this.createPathIfNotExists)
}
private createPathIfNotExists = (path: string) => {
if (!fs.pathExistsSync(path)) {
fs.mkdirsSync(path)
}
}
private logServerInitialization() {
Logger.info(`[Server] Init v${version}`)
Logger.info(`[Server] Node.js Version: ${process.version}`)
Logger.info(`[Server] Platform: ${process.platform}`)
Logger.info(`[Server] Arch: ${process.arch}`)
}
// Error Handling
private errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
Logger.error('[Server] Error:', err)
res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' })
}
// Health Check
private setupHealthCheck(router: express.Router) {
router.get('/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'UP' })
})
router.get('/ping', this.handlePingRequest.bind(this))
router.get('/status', this.handleStatusRequest.bind(this))
}
// Status Method
private getStatus(req: Request, res: Response): void {
const payload: StatusPayload = {
app: 'audiobookshelf',
serverVersion: version,
isInit: Database.hasRootUser,
language: Database.serverSettings?.language || 'en',
authMethods: Database.serverSettings?.authActiveAuthMethods || [],
authFormData: Database.serverSettings?.authFormData || {},
ConfigPath: global.ConfigPath,
MetadataPath: global.MetadataPath
}
Logger.info('[Server] Status requested:', payload)
res.json(payload)
}
// Clean User Data
async cleanUserData() {
// Get all media progress without an associated media item
const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
where: {
'$podcastEpisode.id$': null,
'$book.id$': null
},
attributes: ['id'],
include: [
{ model: Database.bookModel, attributes: ['id'] },
{ model: Database.podcastEpisodeModel, attributes: ['id'] }
]
})
if (mediaProgressToRemove.length) {
// Remove media progress
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
id: mediaProgressToRemove.map((mp) => mp.id)
}
})
Logger.info(`[Server] Removed ${mediaProgressRemoved} media progress for media items that no longer exist in db`)
}
// Remove series from hide from continue listening that no longer exist
try {
const users = (await Database.sequelize?.query(
`
SELECT u.id, u.username, u.extraData, json_group_array(value) AS seriesIdsToRemove
FROM users u
JOIN json_each(u.extraData->"seriesHideFromContinueListening")
LEFT JOIN series se ON se.id = value
WHERE se.id IS NULL
GROUP BY u.id;
`,
{
type: Sequelize.QueryTypes.SELECT,
raw: true,
mapToModel: true
}
)) as any[]
if (users) {
await Promise.all(
users.map(async (user) => {
const extraData = JSON.parse(user.extraData)
const existingSeriesIds = extraData.seriesHideFromContinueListening || []
const seriesIdsToRemove = JSON.parse(user.dataValues.seriesIdsToRemove || '[]')
if (seriesIdsToRemove.length) {
Logger.info(`[Server] Found ${seriesIdsToRemove.length} non-existent series in seriesHideFromContinueListening for user "${user.username}" - Removing (${seriesIdsToRemove.join(',')})`)
const newExtraData = {
...extraData,
seriesHideFromContinueListening: existingSeriesIds.filter((s) => !seriesIdsToRemove.includes(s))
}
await user.update({ extraData: newExtraData })
}
})
)
}
} catch (error) {
Logger.error(`[Server] Failed to cleanup users seriesHideFromContinueListening`, error)
}
}
// Middleware for CORS and security headers
private setupSecurityHeaders(app: express.Express) {
app.use((req: Request, res: Response, next: NextFunction) => {
this.setSecurityHeaders(res)
this.handleCors(req, res)
next()
})
}
// Set security headers
private setSecurityHeaders(res: express.Response) {
// Prevent clickjacking by disallowing iframes
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
}
// Handle CORS
private handleCors(req: express.Request, res: express.Response) {
const isEbookOrCoverRequest = this.isEbookOrCoverRequest(req.path)
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
const origin = req.get('origin') || ''
if (!(Logger.isDev || isEbookOrCoverRequest)) return
if (global.AllowCors || Logger.isDev || allowedOrigins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin)
res.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', '*')
res.header('Access-Control-Allow-Credentials', 'true')
if (req.method === 'OPTIONS') {
return res.sendStatus(200)
}
}
}
// Check if the request path matches the ebook or cover pattern
private isEbookOrCoverRequest(path: string): boolean {
return /\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/.test(path)
}
// New method to setup the watcher
private setupWatcher(libraries: any[]) {
if (Database.serverSettings?.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`)
Watcher.setWatcherDisabled(true)
} else {
Watcher.initWatcher(libraries)
Watcher.on('scanFilesChanged', (pendingFileUpdates, pendingTask) => {
LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask)
})
}
}
async initializeServer(req, res) {
Logger.info(`[Server] Initializing new server`)
const newRoot = req.body.newRoot
const rootUsername = newRoot.username || 'root'
const rootPash = newRoot.password ? await this.managers.auth.hashPass(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
await Database.createRootUser(rootUsername, rootPash, Auth)
res.sendStatus(200)
}
private authMiddleware(req: express.Request, res: express.Response, next: express.NextFunction): void {
this.managers.auth.isAuthenticated(req, res, next)
}
// New method for file upload middleware
private fileUploadMiddleware(): express.RequestHandler {
return fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
useTempFiles: true,
tempFileDir: Path.join(global.MetadataPath, 'tmp')
}) as express.RequestHandler
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment