2019-10-23 13:43:57 +00:00
|
|
|
const connectSession = require('connect-redis')
|
2021-02-13 18:31:32 +00:00
|
|
|
const rateLimit = require("express-rate-limit")
|
|
|
|
const validemail = require('email-validator')
|
2019-10-23 08:24:20 +00:00
|
|
|
const session = require('express-session')
|
|
|
|
const bodyParser = require('body-parser')
|
2021-02-13 18:31:32 +00:00
|
|
|
const nodemailer = require('nodemailer')
|
2019-10-25 12:23:11 +00:00
|
|
|
const nunjucks = require('nunjucks')
|
|
|
|
const passport = require('passport')
|
2019-10-23 13:43:57 +00:00
|
|
|
const express = require('express')
|
2019-10-23 08:24:20 +00:00
|
|
|
const request = require('request')
|
2020-05-28 19:06:09 +00:00
|
|
|
const sqlite3 = require('sqlite3')
|
2019-10-23 08:24:20 +00:00
|
|
|
const sqlite = require('sqlite')
|
|
|
|
const xml2js = require('xml2js')
|
2021-02-13 18:31:32 +00:00
|
|
|
const crypto = require('crypto')
|
2019-10-23 13:43:57 +00:00
|
|
|
const WebSocket = require('ws')
|
2019-10-23 08:41:12 +00:00
|
|
|
const redis = require('redis')
|
2019-10-25 12:23:11 +00:00
|
|
|
const path = require('path')
|
|
|
|
const toml = require('toml')
|
|
|
|
const http = require('http')
|
2020-05-28 19:06:09 +00:00
|
|
|
const URL = require('url').URL
|
2019-10-25 12:23:11 +00:00
|
|
|
const fs = require('fs')
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
require('express-async-errors')
|
|
|
|
|
2019-10-23 08:38:14 +00:00
|
|
|
const SessionStore = connectSession(session)
|
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
const util = require('util')
|
|
|
|
const get = util.promisify(request.get)
|
|
|
|
|
|
|
|
const dev = process.env.NODE_ENV === 'development'
|
|
|
|
|
|
|
|
// Load Configuration
|
|
|
|
const filename = path.join(__dirname, 'config.toml')
|
|
|
|
|
|
|
|
let config
|
2020-05-28 19:06:09 +00:00
|
|
|
const cache = { _updated: 0, streamers: {}, viewers: {}, live: [] }
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
try {
|
|
|
|
config = toml.parse(fs.readFileSync(filename))
|
|
|
|
} catch (e) {
|
|
|
|
console.error(e)
|
|
|
|
process.exit(1)
|
|
|
|
}
|
|
|
|
|
|
|
|
config = Object.assign({
|
2020-05-28 19:06:09 +00:00
|
|
|
Streaming: {
|
|
|
|
port: '9322',
|
|
|
|
database: 'streaming.db',
|
|
|
|
streamServer: 'https://tv.icynet.eu/live/',
|
|
|
|
serverHost: 'icynet.eu',
|
|
|
|
publishAddress: 'rtmp://{host}:1935/hls-live/{streamer}',
|
|
|
|
secret: 'changeme'
|
2019-10-23 08:24:20 +00:00
|
|
|
},
|
2020-05-28 19:06:09 +00:00
|
|
|
Auth: {
|
|
|
|
strategy: 'passport-oauth2',
|
|
|
|
callbackURL: 'http://localhost:5000/auth/_callback/',
|
|
|
|
clientID: '1',
|
|
|
|
clientSecret: 'changeme'
|
2021-02-13 18:31:32 +00:00
|
|
|
},
|
|
|
|
Email: {
|
|
|
|
enabled: false,
|
|
|
|
from: 'no-reply@icynet.eu',
|
|
|
|
host: '',
|
|
|
|
port: 587,
|
|
|
|
secure: false,
|
|
|
|
baseURL: 'http://localhost:9321/',
|
|
|
|
auth: {
|
|
|
|
user: '',
|
|
|
|
pass: '',
|
|
|
|
},
|
|
|
|
tls: {
|
|
|
|
rejectUnauthorized: false,
|
|
|
|
},
|
2019-10-23 08:24:20 +00:00
|
|
|
}
|
|
|
|
}, config)
|
|
|
|
|
|
|
|
// Constants
|
2021-02-14 07:54:09 +00:00
|
|
|
const port = parseInt(config.Streaming.port, 10)
|
2020-05-28 19:06:09 +00:00
|
|
|
const streamServer = config.Streaming.streamServer
|
|
|
|
const streamServerHost = config.Streaming.serverHost
|
2019-10-23 08:24:20 +00:00
|
|
|
const streamAppName = streamServer.match(/\/([\w-_]+)\/$/)[1]
|
|
|
|
|
|
|
|
// Database
|
2020-05-28 19:06:09 +00:00
|
|
|
const dbPromise = sqlite.open({
|
|
|
|
filename: path.join(process.cwd(), config.Streaming.database),
|
|
|
|
driver: sqlite3.cached.Database
|
|
|
|
})
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
// 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 })
|
2019-10-23 08:24:20 +00:00
|
|
|
|
2021-02-13 18:31:32 +00:00
|
|
|
// Rate limits
|
|
|
|
const emlLimiter = rateLimit({
|
2021-02-13 18:58:50 +00:00
|
|
|
windowMs: 1000 * 60 * 60,
|
|
|
|
max: 16,
|
|
|
|
message: 'Too many subscription attempts from this IP address. Try again in an hour.'
|
2021-02-13 18:31:32 +00:00
|
|
|
})
|
|
|
|
|
2019-10-25 12:23:11 +00:00
|
|
|
// Authentication
|
2020-05-28 19:06:09 +00:00
|
|
|
const Strategy = require(config.Auth.strategy)
|
|
|
|
const strategyConfig = Object.assign({}, config.Auth)
|
2019-10-25 12:23:11 +00:00
|
|
|
if (!strategyConfig.provider) strategyConfig.provider = strategyConfig.strategy.replace('passport-', '')
|
|
|
|
passport.use(new Strategy(strategyConfig, function (accessToken, refreshToken, profile, done) {
|
2020-05-28 19:06:09 +00:00
|
|
|
process.nextTick(function () {
|
2019-10-25 12:23:11 +00:00
|
|
|
return done(null, profile)
|
|
|
|
})
|
|
|
|
}))
|
|
|
|
|
2021-02-13 18:31:32 +00:00
|
|
|
// Email
|
|
|
|
let emailTransport;
|
|
|
|
if (config.Email.enabled) {
|
|
|
|
emailTransport = nodemailer.createTransport({
|
|
|
|
...config.Email,
|
|
|
|
pool: true,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
const notifQueue = []
|
2021-02-13 19:06:48 +00:00
|
|
|
const notifHistory = {}
|
2021-02-13 18:31:32 +00:00
|
|
|
|
|
|
|
function now() {
|
|
|
|
return Math.floor(Date.now() / 1000)
|
|
|
|
}
|
|
|
|
|
|
|
|
function key() {
|
|
|
|
return crypto.randomBytes(32).toString('hex').slice(0, 32)
|
|
|
|
}
|
|
|
|
|
|
|
|
async function sendEmailPush(channel) {
|
|
|
|
if (!emailTransport) {
|
|
|
|
return
|
|
|
|
}
|
2021-02-13 19:06:48 +00:00
|
|
|
|
|
|
|
// Don't re-send notifications within an hour if a channel happens to go live again
|
|
|
|
if (notifHistory[channel] && notifHistory[channel] > now() - 3600) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
notifHistory[channel] = now()
|
|
|
|
|
2021-02-13 18:31:32 +00:00
|
|
|
const db = await dbPromise
|
2021-02-13 19:06:48 +00:00
|
|
|
const data = await db.get('SELECT name FROM channels WHERE id = ?', channel)
|
2021-02-13 18:58:50 +00:00
|
|
|
if (!data) {
|
2021-02-13 18:31:32 +00:00
|
|
|
return;
|
|
|
|
}
|
2021-02-13 19:06:48 +00:00
|
|
|
|
2021-02-13 18:31:32 +00:00
|
|
|
const subs = await db.all('SELECT email,unsubkey FROM emailsub WHERE uuid = ? AND active = 1', channel);
|
|
|
|
for (const sub of subs) {
|
|
|
|
const unsubURL = config.Email.baseURL + 'unsubscribe/' + sub.unsubkey
|
2021-02-13 18:58:50 +00:00
|
|
|
const watchURL = config.Email.baseURL + 'watch/' + data.name
|
2021-02-13 18:31:32 +00:00
|
|
|
emailTransport.sendMail({
|
|
|
|
from: config.Email.from,
|
|
|
|
to: sub.email,
|
2021-02-13 18:58:50 +00:00
|
|
|
subject: `🔴 ${data.name} has gone LIVE on IcyTV!`,
|
|
|
|
text: `${data.name} has gone LIVE on IcyTV!\nWatch now: ${watchURL}`
|
|
|
|
+ `\n\nUnsubscribe from ${data.name}: ${unsubURL}`,
|
|
|
|
html: `<h1>${data.name} has gone LIVE on IcyTV!</h1><p>Watch now: `
|
2021-02-13 18:31:32 +00:00
|
|
|
+ `<a href="${watchURL}" target="_blank" rel="nofollow">${watchURL}</a>`
|
2021-02-13 18:58:50 +00:00
|
|
|
+ `</p><br/><p>Unsubscribe from ${data.name}: `
|
2021-02-13 18:31:32 +00:00
|
|
|
+ `<a href="${unsubURL}" target="_blank" rel="nofollow">${unsubURL}</a></p>`,
|
|
|
|
}).catch(e => console.error(e))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function subscribeToChannel(channel, email) {
|
|
|
|
if (!emailTransport) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const db = await dbPromise
|
2021-02-13 19:06:48 +00:00
|
|
|
const data = await db.get('SELECT id FROM channels WHERE name = ?', channel)
|
2021-02-13 18:58:50 +00:00
|
|
|
if (!data) {
|
2021-02-13 18:31:32 +00:00
|
|
|
throw new Error('Invalid channel!')
|
|
|
|
}
|
|
|
|
|
2021-02-13 19:06:48 +00:00
|
|
|
const exists = await db.get('SELECT * FROM emailsub WHERE email = ? AND uuid = ?', [email, data.id])
|
2021-02-13 18:31:32 +00:00
|
|
|
if (exists) {
|
|
|
|
throw new Error('A subscription already exists for this email address.')
|
|
|
|
}
|
|
|
|
|
|
|
|
// New verification email
|
|
|
|
const activateKey = key()
|
|
|
|
const unsubKey = key()
|
|
|
|
const activateURL = config.Email.baseURL + 'email/' + activateKey
|
|
|
|
await db.run('INSERT INTO emailsub (unsubkey, activatekey, email, uuid, active, created_at) VALUES '
|
2021-02-13 19:06:48 +00:00
|
|
|
+ '(?, ?, ?, ?, 0, ?)', [unsubKey, activateKey, email, data.id, now()])
|
2021-02-13 18:31:32 +00:00
|
|
|
|
|
|
|
await emailTransport.sendMail({
|
|
|
|
from: config.Email.from,
|
|
|
|
to: email,
|
|
|
|
subject: `Confirm IcyTV subscription to channel ${channel}`,
|
|
|
|
text: `Confirm your subscription\n\nClick here to subscribe to ${channel}: ${activateURL} `
|
|
|
|
+ `\n\nIf you did not subscribe to ${channel} on IcyTV, please ignore this email `
|
|
|
|
+ `\nand no further action is required on your part. If these emails persist, please `
|
|
|
|
+ `\ncontact us via abuse@icynet.eu and we'll be sure to help you.`,
|
|
|
|
html: `<h1>Confirm your subscription</h1><p>Click here to subscribe to ${channel}: `
|
|
|
|
+ `<a href="${activateURL}" target="_blank" rel="nofollow">${activateURL}</a>`
|
|
|
|
+ `</p><br/><p>If you did not subscribe to ${channel} on IcyTV, please ignore this email `
|
|
|
|
+ `and no further action is required on your part. If these emails persist, please contact us via `
|
|
|
|
+ `<a href="mailto:abuse@icynet.eu">abuse@icynet.eu</a> and we'll be sure to help you.</p>`,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
async function unsubscribe(key) {
|
|
|
|
const db = await dbPromise
|
|
|
|
await db.run('DELETE FROM emailsub WHERE unsubkey = ?', key)
|
|
|
|
}
|
|
|
|
|
|
|
|
async function activateSubscription(key) {
|
|
|
|
const db = await dbPromise
|
|
|
|
await db.run('UPDATE emailsub SET active = 1 WHERE activatekey = ?', key)
|
|
|
|
}
|
|
|
|
|
2019-10-25 12:23:11 +00:00
|
|
|
passport.serializeUser(function (user, done) {
|
|
|
|
done(null, user)
|
|
|
|
})
|
|
|
|
|
|
|
|
passport.deserializeUser(function (user, done) {
|
|
|
|
done(null, user)
|
|
|
|
})
|
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
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',
|
2020-05-28 19:06:09 +00:00
|
|
|
secret: config.Streaming.secret,
|
2019-10-23 08:24:20 +00:00
|
|
|
resave: false,
|
|
|
|
saveUninitialized: true,
|
2019-10-23 08:41:12 +00:00
|
|
|
store: new SessionStore({ client: redis.createClient() }),
|
2019-10-23 08:24:20 +00:00
|
|
|
cookie: {
|
|
|
|
secure: !dev,
|
|
|
|
maxAge: 2678400000 // 1 month
|
|
|
|
}
|
2019-10-23 13:43:57 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
app.use(sessionParser)
|
2019-10-23 08:24:20 +00:00
|
|
|
|
2019-10-25 12:23:11 +00:00
|
|
|
app.use(passport.initialize())
|
|
|
|
app.use(passport.session())
|
|
|
|
|
2021-02-13 18:31:32 +00:00
|
|
|
app.use((req, res, next) => {
|
|
|
|
if (!req.session.csrf) {
|
|
|
|
req.session.csrf = key()
|
|
|
|
}
|
|
|
|
next()
|
|
|
|
})
|
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
// Parse stream metrics from the stat.xml file
|
|
|
|
async function pullMetrics (uuid) {
|
2020-05-28 19:06:09 +00:00
|
|
|
const statPath = streamServer + 'stat'
|
2019-10-23 08:24:20 +00:00
|
|
|
if (!cache.stats || cache._updated < Date.now() - 5000) {
|
2020-05-28 19:06:09 +00:00
|
|
|
const { body } = await get(statPath)
|
|
|
|
const rip = await xml2js.parseStringPromise(body)
|
2019-10-23 08:24:20 +00:00
|
|
|
if (!rip.rtmp.server) throw new Error('Invalid response from server.')
|
|
|
|
|
|
|
|
// Autofind the correct server
|
2020-05-28 19:06:09 +00:00
|
|
|
const rtmpserver = rip.rtmp.server[0].application
|
2019-10-23 08:24:20 +00:00
|
|
|
let rtmpapp
|
2020-05-28 19:06:09 +00:00
|
|
|
for (const i in rtmpserver) {
|
2019-10-23 08:24:20 +00:00
|
|
|
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
|
2020-05-28 19:06:09 +00:00
|
|
|
for (const i in cache.stats) {
|
2019-10-23 08:24:20 +00:00
|
|
|
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
|
2020-05-28 19:06:09 +00:00
|
|
|
const data = {
|
2019-10-23 08:24:20 +00:00
|
|
|
time: forUser.time[0],
|
|
|
|
bytes: forUser.bytes_in[0],
|
|
|
|
video: null,
|
|
|
|
audio: null
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add video metadata, if applicable
|
|
|
|
if (forUser.meta[0].video[0] !== '') {
|
2020-05-28 19:06:09 +00:00
|
|
|
data.video = {
|
2019-10-23 08:24:20 +00:00
|
|
|
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] !== '') {
|
2020-05-28 19:06:09 +00:00
|
|
|
data.audio = {
|
2019-10-23 08:24:20 +00:00
|
|
|
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.')
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
// Validate stream key
|
2020-05-28 19:06:09 +00:00
|
|
|
const streamer = await db.get('SELECT * FROM channels WHERE key=?', req.body.name)
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
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)
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
// Generate real publish address for the server
|
2020-05-28 19:06:09 +00:00
|
|
|
const publishAddress = config.Streaming.publishAddress
|
2019-10-23 08:24:20 +00:00
|
|
|
.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)
|
2021-02-13 19:13:48 +00:00
|
|
|
cache.live.push(streamer.name)
|
2019-10-23 08:24:20 +00:00
|
|
|
|
2021-02-13 18:31:32 +00:00
|
|
|
// Send notifications
|
2021-02-13 19:13:48 +00:00
|
|
|
if (!notifQueue.includes(streamer.id)) {
|
|
|
|
notifQueue.push(streamer.id)
|
2021-02-13 18:31:32 +00:00
|
|
|
}
|
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
// 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.')
|
|
|
|
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
|
|
|
const chan = await db.get('SELECT * FROM channels WHERE key = ?', req.body.name)
|
2019-10-23 13:43:57 +00:00
|
|
|
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)
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
res.send('OK')
|
|
|
|
})
|
|
|
|
|
|
|
|
// Front-end server
|
|
|
|
// OAuth2 authenticator
|
2019-10-25 12:23:11 +00:00
|
|
|
app.get('/login', passport.authenticate(strategyConfig.provider, Object.assign({}, strategyConfig.authOptions || {})))
|
2019-10-23 08:24:20 +00:00
|
|
|
|
2019-10-25 12:23:11 +00:00
|
|
|
app.get('/auth/_callback', passport.authenticate(strategyConfig.provider, { failureRedirect: '/' }), async (req, res) => {
|
|
|
|
dev && console.log(req.user.username, 'logged in')
|
2019-10-23 08:24:20 +00:00
|
|
|
// Get user from database
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
|
|
|
const user = await db.get('SELECT * FROM signed_users WHERE uuid=?', req.user.uuid)
|
2019-10-23 08:24:20 +00:00
|
|
|
if (!user) {
|
2019-10-25 12:23:11 +00:00
|
|
|
await db.run('INSERT INTO signed_users (uuid,name) VALUES (?,?)', req.user.uuid, req.user.username)
|
2019-10-23 08:24:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Lets see if this user is a streamer
|
2020-05-28 19:06:09 +00:00
|
|
|
const streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', req.user.uuid)
|
2019-10-25 12:23:11 +00:00
|
|
|
if (streamer) cache.streamers[req.user.uuid] = streamer
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
res.redirect('/')
|
|
|
|
})
|
|
|
|
|
|
|
|
app.get('/logout', (req, res) => {
|
2019-10-25 12:23:11 +00:00
|
|
|
req.logout()
|
2019-10-23 08:24:20 +00:00
|
|
|
res.redirect('/')
|
|
|
|
})
|
|
|
|
|
2019-10-25 12:23:11 +00:00
|
|
|
function authed (req, res, next) {
|
|
|
|
if (req.isAuthenticated() && req.isStreamer) return next()
|
|
|
|
res.jsonp({ error: 'Unauthorized' })
|
|
|
|
}
|
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
// Views
|
|
|
|
|
|
|
|
app.use('/dist', express.static(path.join(__dirname, 'dist'), { maxAge: dev ? 0 : 2678400000 }))
|
|
|
|
app.use(async function (req, res, next) {
|
|
|
|
req.isStreamer = false
|
2019-10-25 12:23:11 +00:00
|
|
|
if (!req.isAuthenticated()) return next()
|
2019-10-23 08:24:20 +00:00
|
|
|
|
2019-10-25 12:23:11 +00:00
|
|
|
res.locals.user = req.user
|
2019-10-23 08:24:20 +00:00
|
|
|
|
2019-10-25 12:23:11 +00:00
|
|
|
if (!cache.streamers[req.user.uuid]) {
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
|
|
|
const streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', req.user.uuid)
|
2019-10-25 12:23:11 +00:00
|
|
|
if (streamer) cache.streamers[req.user.uuid] = streamer
|
2019-10-23 10:43:31 +00:00
|
|
|
}
|
|
|
|
|
2019-10-25 12:23:11 +00:00
|
|
|
if (cache.streamers[req.user.uuid]) {
|
2019-10-23 08:24:20 +00:00
|
|
|
req.isStreamer = true
|
|
|
|
return next()
|
|
|
|
}
|
2019-10-23 10:43:31 +00:00
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
next()
|
|
|
|
})
|
|
|
|
|
|
|
|
// Index
|
|
|
|
app.get('/', (req, res) => {
|
|
|
|
res.render('index.html', { streamer: req.isStreamer })
|
|
|
|
})
|
|
|
|
|
|
|
|
// Dashboard
|
2019-10-25 12:23:11 +00:00
|
|
|
app.get('/dashboard', authed, (req, res) => {
|
2020-05-28 19:06:09 +00:00
|
|
|
const stream = cache.streamers[req.user.uuid]
|
2021-02-14 07:54:09 +00:00
|
|
|
res.render('dashboard.html', { server: 'rtmp://' + streamServerHost + '/live/' })
|
2019-10-23 08:24:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
// Stats
|
2019-10-25 12:23:11 +00:00
|
|
|
app.get('/dashboard/stats', authed, async (req, res) => {
|
2020-05-28 19:06:09 +00:00
|
|
|
const stream = cache.streamers[req.user.uuid]
|
2019-10-23 08:24:20 +00:00
|
|
|
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
|
2019-10-25 12:23:11 +00:00
|
|
|
app.get('/dashboard/data', authed, async (req, res) => {
|
2020-05-28 19:06:09 +00:00
|
|
|
const stream = cache.streamers[req.user.uuid]
|
2019-10-23 08:24:20 +00:00
|
|
|
let data
|
|
|
|
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
2019-10-23 08:24:20 +00:00
|
|
|
|
|
|
|
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({
|
2020-05-28 19:06:09 +00:00
|
|
|
name: data.name,
|
|
|
|
key: stream.key,
|
|
|
|
uuid: req.user.uuid,
|
|
|
|
live: data.live_at != null,
|
2021-02-14 07:54:09 +00:00
|
|
|
live_at: new Date(parseInt(data.live_at, 10)),
|
|
|
|
last_stream: new Date(parseInt(data.last_stream, 10))
|
2019-10-23 08:24:20 +00:00
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2019-10-23 10:43:31 +00:00
|
|
|
// Get links
|
2019-10-25 12:23:11 +00:00
|
|
|
app.get('/dashboard/link', authed, async (req, res) => {
|
2020-05-28 19:06:09 +00:00
|
|
|
const user = req.user.uuid
|
2019-10-23 10:43:31 +00:00
|
|
|
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
|
|
|
const links = await db.all('SELECT * FROM link WHERE uuid = ?', user)
|
2019-10-23 10:43:31 +00:00
|
|
|
|
|
|
|
res.jsonp(links)
|
|
|
|
})
|
|
|
|
|
|
|
|
// Add link URL
|
2019-10-25 12:23:11 +00:00
|
|
|
app.post('/dashboard/link', authed, async (req, res) => {
|
2020-05-28 19:06:09 +00:00
|
|
|
const user = req.user.uuid
|
|
|
|
const name = req.body.name
|
|
|
|
const url = req.body.url
|
2019-10-23 10:43:31 +00:00
|
|
|
|
|
|
|
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
|
2020-05-28 19:06:09 +00:00
|
|
|
const a = new URL(url)
|
|
|
|
if (a.protocol === '' || a.host === '') return res.jsonp({ error: 'Invalid URL!' })
|
2019-10-23 10:43:31 +00:00
|
|
|
|
|
|
|
// Checks
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
|
|
|
const links = await db.all('SELECT * FROM link WHERE uuid = ?', user)
|
2019-10-23 10:43:31 +00:00
|
|
|
if (links.length > 10) return res.jsonp({ error: 'You can currently only add up to 10 links!' })
|
|
|
|
|
2020-05-28 19:06:09 +00:00
|
|
|
const link = await db.get('SELECT * FROM link WHERE url = ? AND uuid = ?', url, user)
|
2019-10-23 10:43:31 +00:00
|
|
|
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
|
2019-10-25 12:23:11 +00:00
|
|
|
app.post('/dashboard/link/delete', authed, async (req, res) => {
|
2020-05-28 19:06:09 +00:00
|
|
|
const 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
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
2019-10-23 10:43:31 +00:00
|
|
|
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 })
|
|
|
|
})
|
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
// Player
|
|
|
|
app.get('/watch/:name', (req, res) => {
|
2021-02-13 18:31:32 +00:00
|
|
|
res.render('player.html', {
|
|
|
|
name: req.params.name,
|
|
|
|
server: streamServer,
|
|
|
|
csrf: req.session.csrf,
|
|
|
|
email: emailTransport != null,
|
|
|
|
})
|
2019-10-23 08:24:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
app.get('/player/:name', (req, res) => {
|
|
|
|
res.redirect('/watch/' + req.params.name)
|
|
|
|
})
|
|
|
|
|
|
|
|
// Public data
|
|
|
|
app.get('/api/channel/:name', async (req, res) => {
|
2020-05-28 19:06:09 +00:00
|
|
|
const name = req.params.name
|
|
|
|
const db = await dbPromise
|
2021-02-13 18:31:32 +00:00
|
|
|
const data = await db.get('SELECT user_uuid,name,live_at,last_stream,chat_channel FROM channels WHERE name=?', name)
|
2019-10-23 08:24:20 +00:00
|
|
|
if (!data) return res.jsonp({ error: 'No such channel!' })
|
2020-05-28 19:06:09 +00:00
|
|
|
const links = await db.all('SELECT name,url FROM link WHERE uuid = ?', data.user_uuid)
|
2019-10-23 10:43:31 +00:00
|
|
|
|
2019-10-23 14:00:06 +00:00
|
|
|
delete data.user_uuid
|
2019-10-23 08:24:20 +00:00
|
|
|
data.live = data.live_at != null
|
2021-02-14 07:54:09 +00:00
|
|
|
data.live_at = new Date(parseInt(data.live_at, 10))
|
|
|
|
data.last_stream = new Date(parseInt(data.last_stream, 10))
|
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
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
res.jsonp(data)
|
|
|
|
})
|
|
|
|
|
2021-02-13 18:31:32 +00:00
|
|
|
app.post('/api/email/:channel', emlLimiter, async (req, res) => {
|
|
|
|
if (!emailTransport) {
|
|
|
|
return res.json({ message: 'Email transport is disabled.' })
|
|
|
|
}
|
|
|
|
|
|
|
|
const csrf = req.body.csrf
|
|
|
|
if (!csrf || !req.session.csrf || req.session.csrf !== csrf) {
|
|
|
|
return res.status(400).json({error: true, message: 'Illegal request!'})
|
|
|
|
}
|
|
|
|
|
|
|
|
const email = req.body.email
|
|
|
|
if (!email || !validemail.validate(email)) {
|
|
|
|
return res.status(400).json({error: true, message: 'Invalid email address!'})
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
await subscribeToChannel(req.params.channel, email)
|
|
|
|
} catch (e) {
|
|
|
|
return res.status(400).json({error: true, message: e.message})
|
|
|
|
}
|
|
|
|
res.json({ message: 'Confirmation email has been sent!' })
|
|
|
|
})
|
|
|
|
|
|
|
|
app.get('/email/:key', async (req, res) => {
|
|
|
|
if (!emailTransport) {
|
|
|
|
return res.redirect('/?activated=false')
|
|
|
|
}
|
|
|
|
await activateSubscription(req.params.key)
|
|
|
|
res.redirect('/?activated=true')
|
|
|
|
})
|
|
|
|
|
|
|
|
app.get('/unsubscribe/:key', async (req, res) => {
|
|
|
|
if (!emailTransport) {
|
|
|
|
return res.redirect('/?unsubscribe=false')
|
|
|
|
}
|
|
|
|
await unsubscribe(req.params.key)
|
|
|
|
res.redirect('/?unsubscribe=true')
|
|
|
|
})
|
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
// 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) => {
|
2019-10-25 12:32:24 +00:00
|
|
|
let userId = request.session.id
|
|
|
|
let username = 'A Friendly Guest'
|
2020-05-28 19:06:09 +00:00
|
|
|
const myChannels = []
|
2019-10-23 13:43:57 +00:00
|
|
|
|
2019-10-25 12:32:24 +00:00
|
|
|
if (request.user) {
|
|
|
|
userId = request.user.uuid
|
|
|
|
username = request.user.username
|
|
|
|
}
|
|
|
|
|
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)
|
2020-05-28 19:06:09 +00:00
|
|
|
const is = msg.toString().trim().split(' ')
|
|
|
|
const chan = is[1]
|
2019-10-24 07:34:29 +00:00
|
|
|
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] = {}
|
2019-10-25 12:32:24 +00:00
|
|
|
cache.viewers[chan][userId] = username
|
2019-10-24 07:34:29 +00:00
|
|
|
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')
|
2020-05-28 19:06:09 +00:00
|
|
|
for (const i in myChannels) {
|
|
|
|
const chan = myChannels[i]
|
|
|
|
const 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, {}, () => {
|
2019-10-25 12:41:54 +00:00
|
|
|
if (!request.session || !request.session.id) return socket.destroy()
|
2019-10-25 12:32:24 +00:00
|
|
|
if (request.session && request.session.passport) {
|
|
|
|
request.user = request.session.passport.user
|
|
|
|
}
|
|
|
|
|
2020-05-28 19:06:09 +00:00
|
|
|
wss.handleUpgrade(request, socket, head, function (ws) {
|
2019-10-23 13:43:57 +00:00
|
|
|
wss.emit('connection', ws, request)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2021-02-13 18:31:32 +00:00
|
|
|
// Stream start notifications pump
|
|
|
|
function notify() {
|
|
|
|
const channel = notifQueue.pop()
|
|
|
|
if (channel) {
|
|
|
|
sendEmailPush(channel).catch(e => console.error(e))
|
|
|
|
}
|
|
|
|
setTimeout(notify, notifQueue.length ? 1000 : 5000)
|
|
|
|
}
|
|
|
|
|
2019-10-23 08:24:20 +00:00
|
|
|
// 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 () {
|
2020-05-28 19:06:09 +00:00
|
|
|
const db = await dbPromise
|
|
|
|
await db.migrate()
|
|
|
|
|
|
|
|
const allLive = await db.all('SELECT name FROM channels WHERE live_at IS NOT NULL')
|
|
|
|
|
|
|
|
for (const i in allLive) {
|
2019-10-23 13:43:57 +00:00
|
|
|
cache.live.push(allLive[i].name)
|
|
|
|
}
|
2020-05-28 19:06:09 +00:00
|
|
|
|
2019-10-23 13:43:57 +00:00
|
|
|
console.log(`=> Found ${cache.live.length} channels still live`)
|
|
|
|
})().catch(e => console.error(e.stack))
|
|
|
|
|
|
|
|
console.log('Listening on %s:%d', host, port)
|
2019-10-25 12:23:11 +00:00
|
|
|
console.log('Authentication module: %s (%s)', strategyConfig.strategy, strategyConfig.provider)
|
2021-02-13 18:31:32 +00:00
|
|
|
|
|
|
|
notify()
|
2019-10-23 13:43:57 +00:00
|
|
|
})
|