/* * ***************************************************************************** * Copyright (C) 2019-2021 Chrystian Huot * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see * **************************************************************************** */ 'use strict'; import { spawnSync } from 'child_process'; import compression from 'compression'; import cors from 'cors'; import EventEmitter from 'events'; import dotenv from 'dotenv'; import express from 'express'; import fs from 'fs'; import helmet from 'helmet'; import http from 'http'; import https from 'https'; import path from 'path'; import Sequelize from 'sequelize'; import url from 'url'; import { RdioScanner } from './lib/rdio-scanner/main.js'; export class App extends EventEmitter { constructor() { super(); const dirname = path.dirname(url.fileURLToPath(import.meta.url)); const configFile = path.resolve(process.env.APP_DATA || dirname, 'config.json'); const staticFile = 'index.html'; const staticDir = fs.existsSync(path.resolve(dirname, `../client/${staticFile}`)) ? path.resolve(dirname, '../client') : path.resolve(dirname, '../client/dist/rdio-scanner'); const openssl = !spawnSync('openssl', ['version']).error; dotenv.config(); if (fs.existsSync(configFile)) { this.config = JSON.parse(fs.readFileSync(configFile)); } if (this.config === null || typeof this.config !== 'object') { this.config = {}; } const nodejs = this.config.nodejs || {}; this.config.nodejs = { env: typeof nodejs.env === 'string' && nodejs.env.length ? nodejs.env : 'production', host: typeof nodejs.host === 'string' && nodejs.host.length ? nodejs.host : '0.0.0.0', port: typeof nodejs.port === 'number' ? nodejs.port : 3000, ssl: typeof nodejs.ssl === 'boolean' ? nodejs.ssl : false, sslCa: typeof nodejs.sslCa === 'string' && nodejs.sslCa.length ? nodejs.sslCa : 'ca.crt', sslCert: typeof nodejs.sslCert === 'string' && nodejs.sslCert.length ? nodejs.sslCert : 'server.crt', sslKey: typeof nodejs.sslKey === 'string' && nodejs.sslKey.length ? nodejs.sslKey : 'server.key', }; const sequelize = this.config.sequelize || {}; this.config.sequelize = typeof sequelize.dialect === 'string' && sequelize.dialect.length && sequelize.dialect !== 'sqlite' ? { dialect: sequelize.dialect, database: typeof sequelize.database === 'string' && sequelize.database.length ? sequelize.database : 'db', host: typeof sequelize.host === 'string' && sequelize.host.length ? sequelize.host : '', port: typeof sequelize.port === 'number' ? sequelize.port : 3306, username: typeof sequelize.username === 'string' && sequelize.username.length ? sequelize.username : 'user', password: typeof sequelize.password === 'string' && sequelize.password.length ? sequelize.password : 'pass', } : { dialect: 'sqlite', storage: typeof sequelize.storage === 'string' && sequelize.storage.length ? sequelize.storage : 'database.sqlite', }; const sslCaCert = path.resolve(process.env.APP_DATA || dirname, this.config.nodejs.sslCa); const sslServerCert = path.resolve(process.env.APP_DATA || dirname, this.config.nodejs.sslCert); const sslServerKey = path.resolve(process.env.APP_DATA || dirname, this.config.nodejs.sslKey); if (openssl && !(fs.existsSync(sslCaCert) && fs.existsSync(sslServerCert) && fs.existsSync(sslServerKey))) { const sslCaKey = sslCaCert.replace(/\..*$/, '.key'); const sslCaSerial = sslCaCert.replace(/\..*$/, '.srl'); const sslServerCsr = sslServerCert.replace(/\..*$/, '.csr'); spawnSync('openssl', [ 'req', '-new', '-newkey', 'rsa:4096', '-batch', '-nodes', '-x509', '-days', '7305', '-subj', '/CN=Rdio Scanner CA', '-keyout', sslCaKey, '-out', sslCaCert ]); spawnSync('openssl', [ 'req', '-new', '-newkey', 'rsa:4096', '-batch', '-nodes', '-subj', '/CN=Rdio Scanner', '-keyout', sslServerKey, '-out', sslServerCsr, ]); spawnSync('openssl', [ 'x509', '-in', sslServerCsr, '-days', '7305', '-req', '-CA', sslCaCert, '-CAkey', sslCaKey, '-CAcreateserial', '-CAserial', sslCaSerial, '-out', sslServerCert, ]); fs.rmSync(sslCaSerial); fs.rmSync(sslServerCsr); } if (this.config.sequelize === null || typeof this.config.sequelize !== 'object') { this.config.sequelize = {}; } if (typeof this.config.sequelize.dialect !== 'string' || !this.config.sequelize.dialect.length) { this.config.sequelize.dialect = 'sqlite'; } if (this.config.sequelize.dialect === 'sqlite' && (typeof this.config.sequelize.storage !== 'string' || !this.config.sequelize.storage.length)) { this.config.sequelize.storage = 'database.sqlite'; } this.router = express(); this.router.disable('x-powered-by'); this.router.use(compression()); this.router.use(cors()); this.router.use(express.json({ limit: '10mb' })); this.router.use(express.urlencoded({ extended: false, limit: '10mb' })); this.router.use(helmet({ contentSecurityPolicy: false, frameguard: false })); this.router.use(express.static(staticDir)); this.router.use((req, res, next) => { if ( req.path.startsWith('/api') || req.accepts('html', 'json', 'xml') !== 'html' || path.extname(req.path) !== '' ) { return next(); } if (fs.existsSync(path.join(staticDir, staticFile))) { return res.sendFile(staticFile, { root: staticDir }); } else { return res.send('A new build is being prepared. Please check back in a few minutes.'); } }); this.router.set(this.config.nodejs.port); this.sequelize = new Sequelize(Object.assign({}, this.config.sequelize, { logging: false, storage: this.config.sequelize.storage ? path.resolve(process.env.APP_DATA || process.env.DB_STORAGE || dirname, this.config.sequelize.storage) : '', })); if ( this.config.nodejs.env !== 'development' && this.config.nodejs.ssl === true && fs.existsSync(sslServerCert) && fs.existsSync(sslServerKey) ) { const options = { cert: fs.readFileSync(sslServerCert), key: fs.readFileSync(sslServerKey), }; if (fs.existsSync(this.config.nodejs.sslCA)) { options.ca = fs.readFileSync(sslCaCert); } this.httpServer = https.createServer(options, this.router); } else { this.httpServer = http.createServer(this.router); } this.once('ready', () => { let config = Object.keys(this.config) .sort((a, b) => a.localeCompare(b)) .reduce((conf, key) => { conf[key] = this.config[key]; return conf; }, {}); try { fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); } catch (error) { console.error(error.message); } }); RdioScanner.migrate(this.sequelize).then(() => { this.rdioScanner = new RdioScanner(this); this.rdioScanner.once('ready', () => { // Starting from v5.1, config is now in the database. // Delete obsolete config from the config.json file. if ('rdioScanner' in this.config) { delete this.config.rdioScanner; } this.httpServer.listen(this.config.nodejs.port, this.config.nodejs.host, () => { const scheme = this.httpServer instanceof https.Server ? 'https' : 'http'; this.url = `${scheme}://${this.config.nodejs.host}:${this.config.nodejs.port}`; console.log(`Server is running at ${this.url}`); }); this.emit('ready'); }); }); } } export const app = new App();