updated packages, email subscriptions
This commit is contained in:
parent
fb3d54205e
commit
723b2e4aa5
192
app.js
192
app.js
@ -1,6 +1,9 @@
|
||||
const connectSession = require('connect-redis')
|
||||
const rateLimit = require("express-rate-limit")
|
||||
const validemail = require('email-validator')
|
||||
const session = require('express-session')
|
||||
const bodyParser = require('body-parser')
|
||||
const nodemailer = require('nodemailer')
|
||||
const nunjucks = require('nunjucks')
|
||||
const passport = require('passport')
|
||||
const express = require('express')
|
||||
@ -8,6 +11,7 @@ const request = require('request')
|
||||
const sqlite3 = require('sqlite3')
|
||||
const sqlite = require('sqlite')
|
||||
const xml2js = require('xml2js')
|
||||
const crypto = require('crypto')
|
||||
const WebSocket = require('ws')
|
||||
const redis = require('redis')
|
||||
const path = require('path')
|
||||
@ -52,6 +56,21 @@ config = Object.assign({
|
||||
callbackURL: 'http://localhost:5000/auth/_callback/',
|
||||
clientID: '1',
|
||||
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)
|
||||
|
||||
@ -72,6 +91,13 @@ const app = express()
|
||||
const server = http.createServer(app)
|
||||
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
|
||||
const Strategy = require(config.Auth.strategy)
|
||||
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) {
|
||||
done(null, user)
|
||||
})
|
||||
@ -119,6 +240,13 @@ app.use(sessionParser)
|
||||
app.use(passport.initialize())
|
||||
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
|
||||
async function pullMetrics (uuid) {
|
||||
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)
|
||||
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
|
||||
res.set('Location', publishAddress)
|
||||
res.status(302)
|
||||
@ -388,7 +521,12 @@ app.post('/dashboard/link/delete', authed, async (req, res) => {
|
||||
|
||||
// Player
|
||||
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) => {
|
||||
@ -399,7 +537,7 @@ app.get('/player/:name', (req, res) => {
|
||||
app.get('/api/channel/:name', async (req, res) => {
|
||||
const name = req.params.name
|
||||
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!' })
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
app.use((error, req, res, next) => {
|
||||
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
|
||||
const host = dev ? '0.0.0.0' : '127.0.0.1'
|
||||
server.listen(port, host, () => {
|
||||
@ -506,4 +692,6 @@ server.listen(port, host, () => {
|
||||
|
||||
console.log('Listening on %s:%d', host, port)
|
||||
console.log('Authentication module: %s (%s)', strategyConfig.strategy, strategyConfig.provider)
|
||||
|
||||
notify()
|
||||
})
|
||||
|
@ -14,3 +14,16 @@
|
||||
tokenURL = "http://localhost/oauth2/token"
|
||||
clientID = 1
|
||||
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
4
migrations/003_chat.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- Up
|
||||
ALTER TABLE channels ADD COLUMN chat_channel TEXT;
|
||||
|
||||
-- Down
|
14
migrations/004_email_subscribe.sql
Normal file
14
migrations/004_email_subscribe.sql
Normal 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
5579
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@ -7,36 +7,39 @@
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"watch": "webpack -w --mode=development --log-level=debug",
|
||||
"build": "webpack -p",
|
||||
"build": "webpack --mode=production",
|
||||
"start": "node app.js",
|
||||
"serve": "NODE_ENV=\"development\" node app.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bootstrap": "^4.5.0",
|
||||
"copy-webpack-plugin": "^6.0.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"hls.js": "^0.13.2",
|
||||
"bootstrap": "^4.6.0",
|
||||
"copy-webpack-plugin": "^7.0.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"hls.js": "^0.14.17",
|
||||
"jquery": "^3.5.1",
|
||||
"popper.js": "^1.16.1",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-command": "^0.5.0"
|
||||
"webpack": "^5.21.2",
|
||||
"webpack-cli": "^4.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.19.0",
|
||||
"connect-redis": "^4.0.4",
|
||||
"ejs": "^3.1.3",
|
||||
"connect-redis": "^5.1.0",
|
||||
"ejs": "^3.1.6",
|
||||
"email-validator": "^2.0.4",
|
||||
"express": "^4.17.1",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^5.2.5",
|
||||
"express-session": "^1.17.1",
|
||||
"nunjucks": "^3.2.1",
|
||||
"nodemailer": "^6.4.18",
|
||||
"nunjucks": "^3.2.2",
|
||||
"passport": "^0.4.1",
|
||||
"redis": "^3.0.2",
|
||||
"request": "^2.88.2",
|
||||
"sqlite": "^4.0.9",
|
||||
"sqlite3": "^4.2.0",
|
||||
"sqlite": "^4.0.19",
|
||||
"sqlite3": "^5.0.1",
|
||||
"toml": "^3.0.0",
|
||||
"uuid": "^8.1.0",
|
||||
"ws": "^7.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
"ws": "^7.4.3",
|
||||
"xml2js": "^0.4.23"
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ const overlay = player.querySelector('.overlay')
|
||||
const btn = overlay.querySelector('#playbtn')
|
||||
const time = overlay.querySelector('#duration')
|
||||
const fullscreenbtn = overlay.querySelector('#fullscrbtn')
|
||||
const subbtn = overlay.querySelector('#subbtn')
|
||||
const playbtn = overlay.querySelector('#playbtn')
|
||||
const mutebtn = overlay.querySelector('#mutebtn')
|
||||
const lstat = overlay.querySelector('.live')
|
||||
@ -239,6 +240,24 @@ function handlePause () {
|
||||
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', () => {
|
||||
shouldHide = true
|
||||
}, false)
|
||||
@ -436,6 +455,11 @@ playbtn.addEventListener('click', toggleStream)
|
||||
mutebtn.addEventListener('click', toggleSound)
|
||||
fullscreenbtn.addEventListener('click', toggleFullscreen)
|
||||
|
||||
subbtn.addEventListener('click', askSubscription)
|
||||
if (EMAIL_ENABLED === 'false') {
|
||||
subbtn.style.display = 'none'
|
||||
}
|
||||
|
||||
document.addEventListener('webkitfullscreenchange', exitHandler, false)
|
||||
document.addEventListener('mozfullscreenchange', exitHandler, false)
|
||||
document.addEventListener('fullscreenchange', exitHandler, false)
|
||||
|
@ -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="/dist/css/player.css">
|
||||
<script type="text/javascript">
|
||||
const STREAM_SERVER = '{{ server }}'
|
||||
const STREAM_NAME = '{{ name }}'
|
||||
const STREAM_SERVER = '{{ server }}';
|
||||
const STREAM_NAME = '{{ name }}';
|
||||
const CSRF_TOKEN = '{{ csrf }}';
|
||||
const EMAIL_ENABLED = '{{ email }}';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
@ -29,7 +31,8 @@
|
||||
</span>
|
||||
<div id="duration">0:00</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>
|
||||
|
@ -17,13 +17,11 @@ module.exports = {
|
||||
new CopyPlugin({
|
||||
patterns: [{
|
||||
from: 'src/css/*.css',
|
||||
to: 'css',
|
||||
flatten: true
|
||||
to: 'css'
|
||||
},
|
||||
{
|
||||
from: 'node_modules/bootstrap/dist/css/bootstrap.min.css',
|
||||
to: 'css',
|
||||
flatten: true
|
||||
to: 'css'
|
||||
}]
|
||||
})
|
||||
]
|
||||
|
Reference in New Issue
Block a user