This repository has been archived on 2024-05-09. You can view files and clone it, but cannot push or open issues or pull requests.
icytv/app.js

506 lines
15 KiB
JavaScript
Raw Normal View History

2019-10-23 13:43:57 +00:00
const connectSession = require('connect-redis')
const session = require('express-session')
const bodyParser = require('body-parser')
const nunjucks = require('nunjucks')
const passport = require('passport')
2019-10-23 13:43:57 +00:00
const express = require('express')
const request = require('request')
const sqlite = require('sqlite')
const xml2js = require('xml2js')
2019-10-23 13:43:57 +00:00
const WebSocket = require('ws')
const uuid = require('uuid/v4')
2019-10-23 08:41:12 +00:00
const redis = require('redis')
const path = require('path')
const toml = require('toml')
const http = require('http')
2019-10-23 10:43:31 +00:00
const URL = require('url')
const fs = require('fs')
require('express-async-errors')
2019-10-23 08:38:14 +00:00
const SessionStore = connectSession(session)
const util = require('util')
const get = util.promisify(request.get)
const post = util.promisify(request.post)
const dev = process.env.NODE_ENV === 'development'
// Load Configuration
const filename = path.join(__dirname, 'config.toml')
let config
2019-10-23 13:43:57 +00:00
let cache = { _updated: 0, streamers: {}, viewers: {}, live: [] }
try {
config = toml.parse(fs.readFileSync(filename))
} catch (e) {
console.error(e)
process.exit(1)
}
config = Object.assign({
'Streaming': {
'port': '9322',
'database': 'streaming.db',
'streamServer': 'https://tv.icynet.eu/live/',
'serverHost': 'icynet.eu',
'publishAddress': 'rtmp://{host}:1935/hls-live/{streamer}',
'secret': 'changeme'
},
'Auth': {
'strategy': 'passport-oauth2',
'callbackURL': 'http://localhost:5000/auth/_callback/',
'clientID': '1',
'clientSecret': 'changeme'
}
}, config)
// Constants
const oauthAuth = '{server}/oauth2/authorize?response_type=code&state={state}&redirect_uri={redirect}&client_id={client}&scope=image'
const port = parseInt(config['Streaming']['port'])
const streamServer = config['Streaming']['streamServer']
const streamServerHost = config['Streaming']['serverHost']
const streamAppName = streamServer.match(/\/([\w-_]+)\/$/)[1]
function teval (str, obj) {
let res = str + ''
for (let key in obj) {
if (res.indexOf('{' + key + '}') === -1) continue
res = res.replace('{' + key + '}', obj[key])
}
return res
}
// Database
const dbPromise = Promise.resolve()
.then(() => sqlite.open(path.join(process.cwd(), config['Streaming']['database']), { Promise, cache: true }))
.then(db => db.migrate())
// Setup server
2019-10-23 13:43:57 +00:00
const app = express()
const server = http.createServer(app)
const wss = new WebSocket.Server({ clientTracking: false, noServer: true })
// Authentication
const Strategy = require(config['Auth']['strategy'])
const strategyConfig = Object.assign({}, config['Auth'])
if (!strategyConfig.provider) strategyConfig.provider = strategyConfig.strategy.replace('passport-', '')
passport.use(new Strategy(strategyConfig, function (accessToken, refreshToken, profile, done) {
process.nextTick(function() {
return done(null, profile)
})
}))
passport.serializeUser(function (user, done) {
done(null, user)
})
passport.deserializeUser(function (user, done) {
done(null, user)
})
app.enable('trust proxy', 1)
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())
app.disable('x-powered-by')
nunjucks.configure('templates', {
autoescape: true,
express: app
})
2019-10-23 13:43:57 +00:00
const sessionParser = session({
2019-10-23 08:38:14 +00:00
key: 'Streamserver Session',
secret: config['Streaming']['secret'],
resave: false,
saveUninitialized: true,
2019-10-23 08:41:12 +00:00
store: new SessionStore({ client: redis.createClient() }),
cookie: {
secure: !dev,
maxAge: 2678400000 // 1 month
}
2019-10-23 13:43:57 +00:00
})
app.use(sessionParser)
app.use(passport.initialize())
app.use(passport.session())
// Parse stream metrics from the stat.xml file
async function pullMetrics (uuid) {
let statPath = streamServer + 'stat'
if (!cache.stats || cache._updated < Date.now() - 5000) {
let { body } = await get(statPath)
let rip = await xml2js.parseStringPromise(body)
if (!rip.rtmp.server) throw new Error('Invalid response from server.')
// Autofind the correct server
let rtmpserver = rip.rtmp.server[0].application
let rtmpapp
for (let i in rtmpserver) {
if (rtmpserver[i].name[0] !== streamAppName) continue
rtmpapp = rtmpserver[i]
}
if (!rtmpapp) throw new Error('Invalid response from server.')
cache.stats = rtmpapp.live
cache._updated = Date.now()
}
// Extract applicable stream data
let forUser
for (let i in cache.stats) {
if (!cache.stats[i].stream) continue
if (cache.stats[i].stream[0].name[0] !== uuid) continue
forUser = cache.stats[i].stream[0]
}
if (!forUser) return null
// Generic data object
let data = {
time: forUser.time[0],
bytes: forUser.bytes_in[0],
video: null,
audio: null
}
// Add video metadata, if applicable
if (forUser.meta[0].video[0] !== '') {
data['video'] = {
width: forUser.meta[0].video[0].width[0],
height: forUser.meta[0].video[0].height[0],
frame_rate: forUser.meta[0].video[0].frame_rate[0],
codec: forUser.meta[0].video[0].codec[0]
}
}
// Add audio metadata, if applicable
if (forUser.meta[0].audio[0] !== '') {
data['audio'] = {
sample_rate: forUser.meta[0].audio[0].sample_rate[0],
channels: forUser.meta[0].audio[0].channels[0],
codec: forUser.meta[0].audio[0].codec[0]
}
}
return data
}
// Handle requests from nginx-rtmp-module
app.post('/publish', async (req, res) => {
if (!req.body.name) throw new Error('Invalid request.')
let db = await dbPromise
// Validate stream key
let streamer = await db.get('SELECT * FROM channels WHERE key=?', req.body.name)
if (!streamer) throw new Error('Invalid stream key.')
2019-10-23 13:43:57 +00:00
console.log('=> Streamer %s has started streaming!', streamer.name)
// Generate real publish address for the server
let publishAddress = config['Streaming']['publishAddress']
.replace('{streamer}', streamer.name)
.replace('{host}', '127.0.0.1')
// Set channel streaming status
db.run('UPDATE channels SET live_at=? WHERE id=?', Date.now(), streamer.id)
2019-10-23 13:43:57 +00:00
cache.live.push(streamer.name)
// Redirect the streaming server to the target
res.set('Location', publishAddress)
res.status(302)
res.end()
})
app.post('/publish_done', async (req, res) => {
if (!req.body.name) throw new Error('Invalid request.')
let db = await dbPromise
2019-10-23 13:43:57 +00:00
let chan = await db.get('SELECT * FROM channels WHERE key = ?', req.body.name)
console.log('<= Streamer %s has stopped streaming!', chan.name)
try { delete cache.viewers[chan.name] } catch (e) {}
if (cache.live.indexOf(chan.name) !== -1) cache.live.splice(cache.live.indexOf(chan.name), 1)
db.run('UPDATE channels SET live_at=NULL, last_stream=? WHERE key=?', Date.now(), chan.key)
res.send('OK')
})
// Front-end server
// OAuth2 authenticator
app.get('/login', passport.authenticate(strategyConfig.provider, Object.assign({}, strategyConfig.authOptions || {})))
app.get('/auth/_callback', passport.authenticate(strategyConfig.provider, { failureRedirect: '/' }), async (req, res) => {
dev && console.log(req.user.username, 'logged in')
// Get user from database
let db = await dbPromise
let user = await db.get('SELECT * FROM signed_users WHERE uuid=?', req.user.uuid)
if (!user) {
await db.run('INSERT INTO signed_users (uuid,name) VALUES (?,?)', req.user.uuid, req.user.username)
}
// Lets see if this user is a streamer
let streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', req.user.uuid)
if (streamer) cache.streamers[req.user.uuid] = streamer
res.redirect('/')
})
app.get('/logout', (req, res) => {
req.logout()
res.redirect('/')
})
function authed (req, res, next) {
if (req.isAuthenticated() && req.isStreamer) return next()
res.jsonp({ error: 'Unauthorized' })
}
// Views
app.use('/dist', express.static(path.join(__dirname, 'dist'), { maxAge: dev ? 0 : 2678400000 }))
app.use(async function (req, res, next) {
req.isStreamer = false
if (!req.isAuthenticated()) return next()
res.locals.user = req.user
if (!cache.streamers[req.user.uuid]) {
2019-10-23 10:43:31 +00:00
let db = await dbPromise
let streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', req.user.uuid)
if (streamer) cache.streamers[req.user.uuid] = streamer
2019-10-23 10:43:31 +00:00
}
if (cache.streamers[req.user.uuid]) {
req.isStreamer = true
return next()
}
2019-10-23 10:43:31 +00:00
next()
})
// Index
app.get('/', (req, res) => {
res.render('index.html', { streamer: req.isStreamer })
})
// Dashboard
app.get('/dashboard', authed, (req, res) => {
let stream = cache.streamers[req.user.uuid]
res.render('dashboard.html', { stream: stream.key, server: 'rtmp://' + streamServerHost + '/live/' })
})
// Stats
app.get('/dashboard/stats', authed, async (req, res) => {
let stream = cache.streamers[req.user.uuid]
let data
try {
data = await pullMetrics(stream.key)
} catch (e) {
return res.jsonp({ error: e.message })
}
if (!data) return res.jsonp({ error: 'No data was returned.' })
res.jsonp(data)
})
// Data
app.get('/dashboard/data', authed, async (req, res) => {
let stream = cache.streamers[req.user.uuid]
let data
let db = await dbPromise
try {
data = await db.get('SELECT * FROM channels WHERE key=?', stream.key)
} catch (e) {
return res.jsonp({ error: 'Unauthorized' })
}
if (!data) return res.jsonp({ error: 'Unauthorized' })
res.jsonp({
'name': data.name,
'key': stream.key,
'uuid': req.user.uuid,
'live': data.live_at != null,
'live_at': new Date(parseInt(data.live_at)),
'last_stream': new Date(parseInt(data.last_stream))
})
})
2019-10-23 10:43:31 +00:00
// Get links
app.get('/dashboard/link', authed, async (req, res) => {
let user = req.user.uuid
2019-10-23 10:43:31 +00:00
let db = await dbPromise
let links = await db.all('SELECT * FROM link WHERE uuid = ?', user)
res.jsonp(links)
})
// Add link URL
app.post('/dashboard/link', authed, async (req, res) => {
let user = req.user.uuid
2019-10-23 10:43:31 +00:00
let name = req.body.name
let url = req.body.url
if (name == null || url == null) return res.jsonp({ error: 'Missing parameters!' })
if (name.length > 120) return res.jsonp({ error: 'Only 120 characters are allowed in the name.' })
2019-10-24 08:27:02 +00:00
if (name.length < 3) return res.jsonp({ error: 'Minimum name length is 3 characters.' })
if (name.indexOf('<') !== -1 || name.indexOf('>') !== -1 ||
url.indexOf('<') !== -1 || url.indexOf('>') !== -1) return res.jsonp({ error: 'HTML tags are forbidden!' })
2019-10-23 10:43:31 +00:00
// Validate URL
2019-10-24 08:27:02 +00:00
let a = URL.parse(url)
if (a.protocol === null || a.host === null || a.slashes !== true) return res.jsonp({ error: 'Invalid URL!' })
2019-10-23 10:43:31 +00:00
// Checks
let db = await dbPromise
let links = await db.all('SELECT * FROM link WHERE uuid = ?', user)
if (links.length > 10) return res.jsonp({ error: 'You can currently only add up to 10 links!' })
let link = await db.get('SELECT * FROM link WHERE url = ? AND uuid = ?', url, user)
if (link) return res.jsonp({ error: 'This URL already exists!' })
// Add
await db.run('INSERT INTO link (name,url,uuid) VALUES (?,?,?)', name, url, user)
res.jsonp({ success: true })
})
// Remove link URL
app.post('/dashboard/link/delete', authed, async (req, res) => {
let user = req.user.uuid
2019-10-23 10:43:31 +00:00
if (req.body.name == null && req.body.url == null) return res.jsonp({ error: 'Missing parameters!' })
// Check
let db = await dbPromise
let link = await db.get('SELECT * FROM link WHERE url = ? AND uuid = ?', req.body.url, user)
if (!link) {
link = await db.get('SELECT * FROM link WHERE name = ? AND uuid = ?', req.body.name, user)
}
if (!link) return res.jsonp({ error: 'Invalid link parameter!' })
// Delete
await db.run('DELETE FROM link WHERE id = ?', link.id)
res.jsonp({ success: true })
})
// Player
app.get('/watch/:name', (req, res) => {
res.render('player.html', { name: req.params.name, server: streamServer })
})
app.get('/player/:name', (req, res) => {
res.redirect('/watch/' + req.params.name)
})
// Public data
app.get('/api/channel/:name', async (req, res) => {
let name = req.params.name
let db = await dbPromise
2019-10-23 10:43:31 +00:00
let data = await db.get('SELECT user_uuid,name,live_at,last_stream FROM channels WHERE name=?', name)
if (!data) return res.jsonp({ error: 'No such channel!' })
2019-10-24 07:34:29 +00:00
let links = await db.all('SELECT name,url FROM link WHERE uuid = ?', data.user_uuid)
2019-10-23 10:43:31 +00:00
delete data.user_uuid
data.live = data.live_at != null
data.live_at = new Date(parseInt(data.live_at))
data.last_stream = new Date(parseInt(data.last_stream))
2019-10-23 10:43:31 +00:00
data.links = links || []
2019-10-23 13:43:57 +00:00
data.viewers = Object.keys(cache.viewers[name] || {}).length
2019-10-24 07:34:29 +00:00
data.source = streamServer + name + '.m3u8'
2019-10-23 10:43:31 +00:00
res.jsonp(data)
})
// Error handler
app.use((error, req, res, next) => {
if (dev) console.error(error.stack)
res.send(error.message)
})
2019-10-23 13:43:57 +00:00
// Socket Server
wss.on('connection', (ws, request, client) => {
const userId = request.user.uuid || request.session.id
const username = request.user.username
2019-10-24 07:34:29 +00:00
let myChannels = []
2019-10-23 13:43:57 +00:00
2019-10-24 07:34:29 +00:00
dev && console.log(userId, 'connected')
2019-10-23 13:43:57 +00:00
ws.on('message', (msg) => {
2019-10-24 07:34:29 +00:00
dev && console.log(userId, 'said', msg)
let is = msg.toString().trim().split(' ')
let chan = is[1]
if (!chan) return
switch (is[0]) {
case 'watch':
dev && console.log('adding watcher', userId, 'to channel', chan)
if (cache.live.indexOf(chan) !== -1) {
if (!cache.viewers[chan]) cache.viewers[chan] = {}
cache.viewers[chan][userId] = username || 'A Friendly Guest'
if (myChannels.indexOf(chan) === -1) myChannels.push(chan)
}
break
case 'stop':
dev && console.log('removing watcher', userId, 'from channel', chan)
if (cache.live.indexOf(chan) !== -1) {
if (cache.viewers[chan] && cache.viewers[chan][userId]) delete cache.viewers[chan][userId]
if (myChannels.indexOf(chan) !== -1) myChannels.splice(myChannels.indexOf(chan), 1)
}
break
case 'viewers':
if (cache.viewers[chan] != null) ws.send('viewlist ' + Object.values(cache.viewers[chan]).join(','))
break
2019-10-23 13:43:57 +00:00
}
})
ws.on('close', () => {
2019-10-24 07:34:29 +00:00
dev && console.log(userId, 'disconnected')
for (let i in myChannels) {
let chan = myChannels[i]
2019-10-23 13:43:57 +00:00
let viewers = cache.viewers[chan]
2019-10-24 07:34:29 +00:00
if (viewers && viewers[userId]) delete cache.viewers[chan][userId]
2019-10-23 13:43:57 +00:00
}
})
ws.on('error', (e) => {
dev && console.error('Socket error:', e)
})
})
// Handle upgrade, parse included session
server.on('upgrade', (request, socket, head) => {
sessionParser(request, {}, () => {
wss.handleUpgrade(request, socket, head, function(ws) {
wss.emit('connection', ws, request)
})
})
})
// Start server
const host = dev ? '0.0.0.0' : '127.0.0.1'
2019-10-23 13:43:57 +00:00
server.listen(port, host, () => {
// Get currently live channels, for example, when server restarted while someone was live
(async function () {
let db = await dbPromise
let allLive = await db.all('SELECT name FROM channels WHERE live_at IS NOT NULL')
for (let i in allLive) {
cache.live.push(allLive[i].name)
}
console.log(`=> Found ${cache.live.length} channels still live`)
})().catch(e => console.error(e.stack))
console.log('Listening on %s:%d', host, port)
console.log('Authentication module: %s (%s)', strategyConfig.strategy, strategyConfig.provider)
2019-10-23 13:43:57 +00:00
})