updated packages, email subscriptions

This commit is contained in:
Evert Prants 2021-02-13 20:31:32 +02:00
parent fb3d54205e
commit 723b2e4aa5
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
9 changed files with 1073 additions and 4799 deletions

192
app.js
View File

@ -1,6 +1,9 @@
const connectSession = require('connect-redis') const connectSession = require('connect-redis')
const rateLimit = require("express-rate-limit")
const validemail = require('email-validator')
const session = require('express-session') const session = require('express-session')
const bodyParser = require('body-parser') const bodyParser = require('body-parser')
const nodemailer = require('nodemailer')
const nunjucks = require('nunjucks') const nunjucks = require('nunjucks')
const passport = require('passport') const passport = require('passport')
const express = require('express') const express = require('express')
@ -8,6 +11,7 @@ const request = require('request')
const sqlite3 = require('sqlite3') const sqlite3 = require('sqlite3')
const sqlite = require('sqlite') const sqlite = require('sqlite')
const xml2js = require('xml2js') const xml2js = require('xml2js')
const crypto = require('crypto')
const WebSocket = require('ws') const WebSocket = require('ws')
const redis = require('redis') const redis = require('redis')
const path = require('path') const path = require('path')
@ -52,6 +56,21 @@ config = Object.assign({
callbackURL: 'http://localhost:5000/auth/_callback/', callbackURL: 'http://localhost:5000/auth/_callback/',
clientID: '1', clientID: '1',
clientSecret: 'changeme' clientSecret: 'changeme'
},
Email: {
enabled: false,
from: 'no-reply@icynet.eu',
host: '',
port: 587,
secure: false,
baseURL: 'http://localhost:9321/',
auth: {
user: '',
pass: '',
},
tls: {
rejectUnauthorized: false,
},
} }
}, config) }, config)
@ -72,6 +91,13 @@ const app = express()
const server = http.createServer(app) const server = http.createServer(app)
const wss = new WebSocket.Server({ clientTracking: false, noServer: true }) const wss = new WebSocket.Server({ clientTracking: false, noServer: true })
// Rate limits
const emlLimiter = rateLimit({
windowMs: 1000 * 60 * 60 * 24,
max: 5,
message: 'Too many subscription attempts from this IP address. Try again in 24 hours.'
})
// Authentication // Authentication
const Strategy = require(config.Auth.strategy) const Strategy = require(config.Auth.strategy)
const strategyConfig = Object.assign({}, config.Auth) const strategyConfig = Object.assign({}, config.Auth)
@ -82,6 +108,101 @@ passport.use(new Strategy(strategyConfig, function (accessToken, refreshToken, p
}) })
})) }))
// Email
let emailTransport;
if (config.Email.enabled) {
emailTransport = nodemailer.createTransport({
...config.Email,
pool: true,
})
}
const notifQueue = []
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
}
const db = await dbPromise
const { name } = await db.get('SELECT name FROM channels WHERE user_uuid = ?', channel)
if (!name) {
return;
}
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
const watchURL = config.Email.baseURL + 'watch/' + name
emailTransport.sendMail({
from: config.Email.from,
to: sub.email,
subject: `🔴 ${name} has gone LIVE on IcyTV!`,
text: `${name} has gone LIVE on IcyTV!\nWatch now: ${watchURL}`
+ `\n\nUnsubscribe from ${name}: ${unsubURL}`,
html: `<h1>${name} has gone LIVE on IcyTV!</h1><p>Watch now: `
+ `<a href="${watchURL}" target="_blank" rel="nofollow">${watchURL}</a>`
+ `</p><br/><p>Unsubscribe from ${name}: `
+ `<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
const { user_uuid } = await db.get('SELECT user_uuid FROM channels WHERE name = ?', channel)
if (!user_uuid) {
throw new Error('Invalid channel!')
}
const exists = await db.get('SELECT * FROM emailsub WHERE email = ? AND uuid = ?', [email, user_uuid])
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 '
+ '(?, ?, ?, ?, 0, ?)', [unsubKey, activateKey, email, user_uuid, now()])
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)
}
passport.serializeUser(function (user, done) { passport.serializeUser(function (user, done) {
done(null, user) done(null, user)
}) })
@ -119,6 +240,13 @@ app.use(sessionParser)
app.use(passport.initialize()) app.use(passport.initialize())
app.use(passport.session()) app.use(passport.session())
app.use((req, res, next) => {
if (!req.session.csrf) {
req.session.csrf = key()
}
next()
})
// Parse stream metrics from the stat.xml file // Parse stream metrics from the stat.xml file
async function pullMetrics (uuid) { async function pullMetrics (uuid) {
const statPath = streamServer + 'stat' const statPath = streamServer + 'stat'
@ -202,6 +330,11 @@ app.post('/publish', async (req, res) => {
db.run('UPDATE channels SET live_at=? WHERE id=?', Date.now(), streamer.id) db.run('UPDATE channels SET live_at=? WHERE id=?', Date.now(), streamer.id)
cache.live.push(streamer.name) cache.live.push(streamer.name)
// Send notifications
if (!notifQueue.includes(streamer.user_uuid)) {
notifQueue.push(streamer.user_uuid)
}
// Redirect the streaming server to the target // Redirect the streaming server to the target
res.set('Location', publishAddress) res.set('Location', publishAddress)
res.status(302) res.status(302)
@ -388,7 +521,12 @@ app.post('/dashboard/link/delete', authed, async (req, res) => {
// Player // Player
app.get('/watch/:name', (req, res) => { app.get('/watch/:name', (req, res) => {
res.render('player.html', { name: req.params.name, server: streamServer }) res.render('player.html', {
name: req.params.name,
server: streamServer,
csrf: req.session.csrf,
email: emailTransport != null,
})
}) })
app.get('/player/:name', (req, res) => { app.get('/player/:name', (req, res) => {
@ -399,7 +537,7 @@ app.get('/player/:name', (req, res) => {
app.get('/api/channel/:name', async (req, res) => { app.get('/api/channel/:name', async (req, res) => {
const name = req.params.name const name = req.params.name
const db = await dbPromise const db = await dbPromise
const data = await db.get('SELECT user_uuid,name,live_at,last_stream FROM channels WHERE name=?', name) const data = await db.get('SELECT user_uuid,name,live_at,last_stream,chat_channel FROM channels WHERE name=?', name)
if (!data) return res.jsonp({ error: 'No such channel!' }) if (!data) return res.jsonp({ error: 'No such channel!' })
const links = await db.all('SELECT name,url FROM link WHERE uuid = ?', data.user_uuid) const links = await db.all('SELECT name,url FROM link WHERE uuid = ?', data.user_uuid)
@ -414,6 +552,45 @@ app.get('/api/channel/:name', async (req, res) => {
res.jsonp(data) res.jsonp(data)
}) })
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')
})
// Error handler // Error handler
app.use((error, req, res, next) => { app.use((error, req, res, next) => {
if (dev) console.error(error.stack) if (dev) console.error(error.stack)
@ -487,6 +664,15 @@ server.on('upgrade', (request, socket, head) => {
}) })
}) })
// 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)
}
// Start server // Start server
const host = dev ? '0.0.0.0' : '127.0.0.1' const host = dev ? '0.0.0.0' : '127.0.0.1'
server.listen(port, host, () => { server.listen(port, host, () => {
@ -506,4 +692,6 @@ server.listen(port, host, () => {
console.log('Listening on %s:%d', host, port) console.log('Listening on %s:%d', host, port)
console.log('Authentication module: %s (%s)', strategyConfig.strategy, strategyConfig.provider) console.log('Authentication module: %s (%s)', strategyConfig.strategy, strategyConfig.provider)
notify()
}) })

View File

@ -14,3 +14,16 @@
tokenURL = "http://localhost/oauth2/token" tokenURL = "http://localhost/oauth2/token"
clientID = 1 clientID = 1
clientSecret = "hackme" clientSecret = "hackme"
[Email]
enabled = false
from = 'no-reply@icynet.eu'
host = 'smtp.google.com'
port = 587
secure = false
baseURL = 'http://localhost:9321/'
[Email.auth]
user = 'my-address@gmail.com'
pass = 'hack me lol'
[Email.tls]
rejectUnauthorized = false

4
migrations/003_chat.sql Normal file
View File

@ -0,0 +1,4 @@
-- Up
ALTER TABLE channels ADD COLUMN chat_channel TEXT;
-- Down

View File

@ -0,0 +1,14 @@
-- Up
CREATE TABLE emailsub (
id INTEGER PRIMARY KEY,
unsubkey TEXT NOT NULL,
activatekey TEXT NOT NULL,
email TEXT NOT NULL,
uuid TEXT NOT NULL,
active INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
-- Down
DROP TABLE emailsub;

5579
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,36 +7,39 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"watch": "webpack -w --mode=development --log-level=debug", "watch": "webpack -w --mode=development --log-level=debug",
"build": "webpack -p", "build": "webpack --mode=production",
"start": "node app.js", "start": "node app.js",
"serve": "NODE_ENV=\"development\" node app.js" "serve": "NODE_ENV=\"development\" node app.js"
}, },
"devDependencies": { "devDependencies": {
"bootstrap": "^4.5.0", "bootstrap": "^4.6.0",
"copy-webpack-plugin": "^6.0.1", "copy-webpack-plugin": "^7.0.0",
"file-loader": "^6.0.0", "file-loader": "^6.2.0",
"hls.js": "^0.13.2", "hls.js": "^0.14.17",
"jquery": "^3.5.1", "jquery": "^3.5.1",
"popper.js": "^1.16.1", "popper.js": "^1.16.1",
"webpack": "^4.43.0", "webpack": "^5.21.2",
"webpack-command": "^0.5.0" "webpack-cli": "^4.5.0"
}, },
"dependencies": { "dependencies": {
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"connect-redis": "^4.0.4", "connect-redis": "^5.1.0",
"ejs": "^3.1.3", "ejs": "^3.1.6",
"email-validator": "^2.0.4",
"express": "^4.17.1", "express": "^4.17.1",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"express-rate-limit": "^5.2.5",
"express-session": "^1.17.1", "express-session": "^1.17.1",
"nunjucks": "^3.2.1", "nodemailer": "^6.4.18",
"nunjucks": "^3.2.2",
"passport": "^0.4.1", "passport": "^0.4.1",
"redis": "^3.0.2", "redis": "^3.0.2",
"request": "^2.88.2", "request": "^2.88.2",
"sqlite": "^4.0.9", "sqlite": "^4.0.19",
"sqlite3": "^4.2.0", "sqlite3": "^5.0.1",
"toml": "^3.0.0", "toml": "^3.0.0",
"uuid": "^8.1.0", "uuid": "^8.3.2",
"ws": "^7.3.0", "ws": "^7.4.3",
"xml2js": "^0.4.23" "xml2js": "^0.4.23"
} }
} }

View File

@ -8,6 +8,7 @@ const overlay = player.querySelector('.overlay')
const btn = overlay.querySelector('#playbtn') const btn = overlay.querySelector('#playbtn')
const time = overlay.querySelector('#duration') const time = overlay.querySelector('#duration')
const fullscreenbtn = overlay.querySelector('#fullscrbtn') const fullscreenbtn = overlay.querySelector('#fullscrbtn')
const subbtn = overlay.querySelector('#subbtn')
const playbtn = overlay.querySelector('#playbtn') const playbtn = overlay.querySelector('#playbtn')
const mutebtn = overlay.querySelector('#mutebtn') const mutebtn = overlay.querySelector('#mutebtn')
const lstat = overlay.querySelector('.live') const lstat = overlay.querySelector('.live')
@ -239,6 +240,24 @@ function handlePause () {
showBigBtn(true) showBigBtn(true)
} }
function askSubscription () {
const email = prompt('Enter your email address to get notifications for when this channel goes live')
if (!email) {
return
}
fetch('/api/email/' + STREAM_NAME, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email, csrf: CSRF_TOKEN
})
})
.then(e => e.json())
.then(b => alert(b.message || 'Something went wrong!'))
}
window.addEventListener('onblur', () => { window.addEventListener('onblur', () => {
shouldHide = true shouldHide = true
}, false) }, false)
@ -436,6 +455,11 @@ playbtn.addEventListener('click', toggleStream)
mutebtn.addEventListener('click', toggleSound) mutebtn.addEventListener('click', toggleSound)
fullscreenbtn.addEventListener('click', toggleFullscreen) fullscreenbtn.addEventListener('click', toggleFullscreen)
subbtn.addEventListener('click', askSubscription)
if (EMAIL_ENABLED === 'false') {
subbtn.style.display = 'none'
}
document.addEventListener('webkitfullscreenchange', exitHandler, false) document.addEventListener('webkitfullscreenchange', exitHandler, false)
document.addEventListener('mozfullscreenchange', exitHandler, false) document.addEventListener('mozfullscreenchange', exitHandler, false)
document.addEventListener('fullscreenchange', exitHandler, false) document.addEventListener('fullscreenchange', exitHandler, false)

View File

@ -6,8 +6,10 @@
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link rel="stylesheet" type="text/css" href="/dist/css/player.css"> <link rel="stylesheet" type="text/css" href="/dist/css/player.css">
<script type="text/javascript"> <script type="text/javascript">
const STREAM_SERVER = '{{ server }}' const STREAM_SERVER = '{{ server }}';
const STREAM_NAME = '{{ name }}' const STREAM_NAME = '{{ name }}';
const CSRF_TOKEN = '{{ csrf }}';
const EMAIL_ENABLED = '{{ email }}';
</script> </script>
</head> </head>
<body> <body>
@ -29,7 +31,8 @@
</span> </span>
<div id="duration">0:00</div> <div id="duration">0:00</div>
<div class="flex-divider"></div> <div class="flex-divider"></div>
<div id="fullscrbtn" class="right button"><i class="fa fa-expand fa-fw"></i></div> <div id="subbtn" class="right button" title="Subscribe"><i class="fa fa-envelope-o fa-fw"></i></div>
<div id="fullscrbtn" class="right button" title="Fullscreen"><i class="fa fa-expand fa-fw"></i></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,13 +17,11 @@ module.exports = {
new CopyPlugin({ new CopyPlugin({
patterns: [{ patterns: [{
from: 'src/css/*.css', from: 'src/css/*.css',
to: 'css', to: 'css'
flatten: true
}, },
{ {
from: 'node_modules/bootstrap/dist/css/bootstrap.min.css', from: 'node_modules/bootstrap/dist/css/bootstrap.min.css',
to: 'css', to: 'css'
flatten: true
}] }]
}) })
] ]