Last active
November 30, 2024 20:45
-
-
Save wommy/fe97bd7706f2fdd33bd13457c4ad29e6 to your computer and use it in GitHub Desktop.
ABS server refactor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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