Maintenance commit.
This commit is contained in:
parent
dbffa4262a
commit
fb3d54205e
168
app.js
168
app.js
@ -5,15 +5,15 @@ const nunjucks = require('nunjucks')
|
|||||||
const passport = require('passport')
|
const passport = require('passport')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const request = require('request')
|
const request = require('request')
|
||||||
|
const sqlite3 = require('sqlite3')
|
||||||
const sqlite = require('sqlite')
|
const sqlite = require('sqlite')
|
||||||
const xml2js = require('xml2js')
|
const xml2js = require('xml2js')
|
||||||
const WebSocket = require('ws')
|
const WebSocket = require('ws')
|
||||||
const uuid = require('uuid/v4')
|
|
||||||
const redis = require('redis')
|
const redis = require('redis')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const toml = require('toml')
|
const toml = require('toml')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
const URL = require('url')
|
const URL = require('url').URL
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
|
||||||
require('express-async-errors')
|
require('express-async-errors')
|
||||||
@ -22,7 +22,6 @@ const SessionStore = connectSession(session)
|
|||||||
|
|
||||||
const util = require('util')
|
const util = require('util')
|
||||||
const get = util.promisify(request.get)
|
const get = util.promisify(request.get)
|
||||||
const post = util.promisify(request.post)
|
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV === 'development'
|
const dev = process.env.NODE_ENV === 'development'
|
||||||
|
|
||||||
@ -30,7 +29,7 @@ const dev = process.env.NODE_ENV === 'development'
|
|||||||
const filename = path.join(__dirname, 'config.toml')
|
const filename = path.join(__dirname, 'config.toml')
|
||||||
|
|
||||||
let config
|
let config
|
||||||
let cache = { _updated: 0, streamers: {}, viewers: {}, live: [] }
|
const cache = { _updated: 0, streamers: {}, viewers: {}, live: [] }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
config = toml.parse(fs.readFileSync(filename))
|
config = toml.parse(fs.readFileSync(filename))
|
||||||
@ -40,32 +39,33 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config = Object.assign({
|
config = Object.assign({
|
||||||
'Streaming': {
|
Streaming: {
|
||||||
'port': '9322',
|
port: '9322',
|
||||||
'database': 'streaming.db',
|
database: 'streaming.db',
|
||||||
'streamServer': 'https://tv.icynet.eu/live/',
|
streamServer: 'https://tv.icynet.eu/live/',
|
||||||
'serverHost': 'icynet.eu',
|
serverHost: 'icynet.eu',
|
||||||
'publishAddress': 'rtmp://{host}:1935/hls-live/{streamer}',
|
publishAddress: 'rtmp://{host}:1935/hls-live/{streamer}',
|
||||||
'secret': 'changeme'
|
secret: 'changeme'
|
||||||
},
|
},
|
||||||
'Auth': {
|
Auth: {
|
||||||
'strategy': 'passport-oauth2',
|
strategy: 'passport-oauth2',
|
||||||
'callbackURL': 'http://localhost:5000/auth/_callback/',
|
callbackURL: 'http://localhost:5000/auth/_callback/',
|
||||||
'clientID': '1',
|
clientID: '1',
|
||||||
'clientSecret': 'changeme'
|
clientSecret: 'changeme'
|
||||||
}
|
}
|
||||||
}, config)
|
}, config)
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const port = parseInt(config['Streaming']['port'])
|
const port = parseInt(config.Streaming.port)
|
||||||
const streamServer = config['Streaming']['streamServer']
|
const streamServer = config.Streaming.streamServer
|
||||||
const streamServerHost = config['Streaming']['serverHost']
|
const streamServerHost = config.Streaming.serverHost
|
||||||
const streamAppName = streamServer.match(/\/([\w-_]+)\/$/)[1]
|
const streamAppName = streamServer.match(/\/([\w-_]+)\/$/)[1]
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
const dbPromise = Promise.resolve()
|
const dbPromise = sqlite.open({
|
||||||
.then(() => sqlite.open(path.join(process.cwd(), config['Streaming']['database']), { Promise, cache: true }))
|
filename: path.join(process.cwd(), config.Streaming.database),
|
||||||
.then(db => db.migrate())
|
driver: sqlite3.cached.Database
|
||||||
|
})
|
||||||
|
|
||||||
// Setup server
|
// Setup server
|
||||||
const app = express()
|
const app = express()
|
||||||
@ -73,11 +73,11 @@ const server = http.createServer(app)
|
|||||||
const wss = new WebSocket.Server({ clientTracking: false, noServer: true })
|
const wss = new WebSocket.Server({ clientTracking: false, noServer: true })
|
||||||
|
|
||||||
// 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)
|
||||||
if (!strategyConfig.provider) strategyConfig.provider = strategyConfig.strategy.replace('passport-', '')
|
if (!strategyConfig.provider) strategyConfig.provider = strategyConfig.strategy.replace('passport-', '')
|
||||||
passport.use(new Strategy(strategyConfig, function (accessToken, refreshToken, profile, done) {
|
passport.use(new Strategy(strategyConfig, function (accessToken, refreshToken, profile, done) {
|
||||||
process.nextTick(function() {
|
process.nextTick(function () {
|
||||||
return done(null, profile)
|
return done(null, profile)
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
@ -104,7 +104,7 @@ nunjucks.configure('templates', {
|
|||||||
|
|
||||||
const sessionParser = session({
|
const sessionParser = session({
|
||||||
key: 'Streamserver Session',
|
key: 'Streamserver Session',
|
||||||
secret: config['Streaming']['secret'],
|
secret: config.Streaming.secret,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: true,
|
saveUninitialized: true,
|
||||||
store: new SessionStore({ client: redis.createClient() }),
|
store: new SessionStore({ client: redis.createClient() }),
|
||||||
@ -121,16 +121,16 @@ app.use(passport.session())
|
|||||||
|
|
||||||
// Parse stream metrics from the stat.xml file
|
// Parse stream metrics from the stat.xml file
|
||||||
async function pullMetrics (uuid) {
|
async function pullMetrics (uuid) {
|
||||||
let statPath = streamServer + 'stat'
|
const statPath = streamServer + 'stat'
|
||||||
if (!cache.stats || cache._updated < Date.now() - 5000) {
|
if (!cache.stats || cache._updated < Date.now() - 5000) {
|
||||||
let { body } = await get(statPath)
|
const { body } = await get(statPath)
|
||||||
let rip = await xml2js.parseStringPromise(body)
|
const rip = await xml2js.parseStringPromise(body)
|
||||||
if (!rip.rtmp.server) throw new Error('Invalid response from server.')
|
if (!rip.rtmp.server) throw new Error('Invalid response from server.')
|
||||||
|
|
||||||
// Autofind the correct server
|
// Autofind the correct server
|
||||||
let rtmpserver = rip.rtmp.server[0].application
|
const rtmpserver = rip.rtmp.server[0].application
|
||||||
let rtmpapp
|
let rtmpapp
|
||||||
for (let i in rtmpserver) {
|
for (const i in rtmpserver) {
|
||||||
if (rtmpserver[i].name[0] !== streamAppName) continue
|
if (rtmpserver[i].name[0] !== streamAppName) continue
|
||||||
rtmpapp = rtmpserver[i]
|
rtmpapp = rtmpserver[i]
|
||||||
}
|
}
|
||||||
@ -143,7 +143,7 @@ async function pullMetrics (uuid) {
|
|||||||
|
|
||||||
// Extract applicable stream data
|
// Extract applicable stream data
|
||||||
let forUser
|
let forUser
|
||||||
for (let i in cache.stats) {
|
for (const i in cache.stats) {
|
||||||
if (!cache.stats[i].stream) continue
|
if (!cache.stats[i].stream) continue
|
||||||
if (cache.stats[i].stream[0].name[0] !== uuid) continue
|
if (cache.stats[i].stream[0].name[0] !== uuid) continue
|
||||||
forUser = cache.stats[i].stream[0]
|
forUser = cache.stats[i].stream[0]
|
||||||
@ -152,7 +152,7 @@ async function pullMetrics (uuid) {
|
|||||||
if (!forUser) return null
|
if (!forUser) return null
|
||||||
|
|
||||||
// Generic data object
|
// Generic data object
|
||||||
let data = {
|
const data = {
|
||||||
time: forUser.time[0],
|
time: forUser.time[0],
|
||||||
bytes: forUser.bytes_in[0],
|
bytes: forUser.bytes_in[0],
|
||||||
video: null,
|
video: null,
|
||||||
@ -161,7 +161,7 @@ async function pullMetrics (uuid) {
|
|||||||
|
|
||||||
// Add video metadata, if applicable
|
// Add video metadata, if applicable
|
||||||
if (forUser.meta[0].video[0] !== '') {
|
if (forUser.meta[0].video[0] !== '') {
|
||||||
data['video'] = {
|
data.video = {
|
||||||
width: forUser.meta[0].video[0].width[0],
|
width: forUser.meta[0].video[0].width[0],
|
||||||
height: forUser.meta[0].video[0].height[0],
|
height: forUser.meta[0].video[0].height[0],
|
||||||
frame_rate: forUser.meta[0].video[0].frame_rate[0],
|
frame_rate: forUser.meta[0].video[0].frame_rate[0],
|
||||||
@ -171,7 +171,7 @@ async function pullMetrics (uuid) {
|
|||||||
|
|
||||||
// Add audio metadata, if applicable
|
// Add audio metadata, if applicable
|
||||||
if (forUser.meta[0].audio[0] !== '') {
|
if (forUser.meta[0].audio[0] !== '') {
|
||||||
data['audio'] = {
|
data.audio = {
|
||||||
sample_rate: forUser.meta[0].audio[0].sample_rate[0],
|
sample_rate: forUser.meta[0].audio[0].sample_rate[0],
|
||||||
channels: forUser.meta[0].audio[0].channels[0],
|
channels: forUser.meta[0].audio[0].channels[0],
|
||||||
codec: forUser.meta[0].audio[0].codec[0]
|
codec: forUser.meta[0].audio[0].codec[0]
|
||||||
@ -185,16 +185,16 @@ async function pullMetrics (uuid) {
|
|||||||
|
|
||||||
app.post('/publish', async (req, res) => {
|
app.post('/publish', async (req, res) => {
|
||||||
if (!req.body.name) throw new Error('Invalid request.')
|
if (!req.body.name) throw new Error('Invalid request.')
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
|
|
||||||
// Validate stream key
|
// Validate stream key
|
||||||
let streamer = await db.get('SELECT * FROM channels WHERE key=?', req.body.name)
|
const streamer = await db.get('SELECT * FROM channels WHERE key=?', req.body.name)
|
||||||
|
|
||||||
if (!streamer) throw new Error('Invalid stream key.')
|
if (!streamer) throw new Error('Invalid stream key.')
|
||||||
console.log('=> Streamer %s has started streaming!', streamer.name)
|
console.log('=> Streamer %s has started streaming!', streamer.name)
|
||||||
|
|
||||||
// Generate real publish address for the server
|
// Generate real publish address for the server
|
||||||
let publishAddress = config['Streaming']['publishAddress']
|
const publishAddress = config.Streaming.publishAddress
|
||||||
.replace('{streamer}', streamer.name)
|
.replace('{streamer}', streamer.name)
|
||||||
.replace('{host}', '127.0.0.1')
|
.replace('{host}', '127.0.0.1')
|
||||||
|
|
||||||
@ -211,8 +211,8 @@ app.post('/publish', async (req, res) => {
|
|||||||
app.post('/publish_done', async (req, res) => {
|
app.post('/publish_done', async (req, res) => {
|
||||||
if (!req.body.name) throw new Error('Invalid request.')
|
if (!req.body.name) throw new Error('Invalid request.')
|
||||||
|
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
let chan = await db.get('SELECT * FROM channels WHERE key = ?', req.body.name)
|
const chan = await db.get('SELECT * FROM channels WHERE key = ?', req.body.name)
|
||||||
console.log('<= Streamer %s has stopped streaming!', chan.name)
|
console.log('<= Streamer %s has stopped streaming!', chan.name)
|
||||||
|
|
||||||
try { delete cache.viewers[chan.name] } catch (e) {}
|
try { delete cache.viewers[chan.name] } catch (e) {}
|
||||||
@ -230,14 +230,14 @@ app.get('/login', passport.authenticate(strategyConfig.provider, Object.assign({
|
|||||||
app.get('/auth/_callback', passport.authenticate(strategyConfig.provider, { failureRedirect: '/' }), async (req, res) => {
|
app.get('/auth/_callback', passport.authenticate(strategyConfig.provider, { failureRedirect: '/' }), async (req, res) => {
|
||||||
dev && console.log(req.user.username, 'logged in')
|
dev && console.log(req.user.username, 'logged in')
|
||||||
// Get user from database
|
// Get user from database
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
let user = await db.get('SELECT * FROM signed_users WHERE uuid=?', req.user.uuid)
|
const user = await db.get('SELECT * FROM signed_users WHERE uuid=?', req.user.uuid)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await db.run('INSERT INTO signed_users (uuid,name) VALUES (?,?)', req.user.uuid, req.user.username)
|
await db.run('INSERT INTO signed_users (uuid,name) VALUES (?,?)', req.user.uuid, req.user.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lets see if this user is a streamer
|
// Lets see if this user is a streamer
|
||||||
let streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', req.user.uuid)
|
const streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', req.user.uuid)
|
||||||
if (streamer) cache.streamers[req.user.uuid] = streamer
|
if (streamer) cache.streamers[req.user.uuid] = streamer
|
||||||
|
|
||||||
res.redirect('/')
|
res.redirect('/')
|
||||||
@ -263,8 +263,8 @@ app.use(async function (req, res, next) {
|
|||||||
res.locals.user = req.user
|
res.locals.user = req.user
|
||||||
|
|
||||||
if (!cache.streamers[req.user.uuid]) {
|
if (!cache.streamers[req.user.uuid]) {
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
let streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', req.user.uuid)
|
const streamer = await db.get('SELECT * FROM channels WHERE user_uuid = ?', req.user.uuid)
|
||||||
if (streamer) cache.streamers[req.user.uuid] = streamer
|
if (streamer) cache.streamers[req.user.uuid] = streamer
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,13 +283,13 @@ app.get('/', (req, res) => {
|
|||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
app.get('/dashboard', authed, (req, res) => {
|
app.get('/dashboard', authed, (req, res) => {
|
||||||
let stream = cache.streamers[req.user.uuid]
|
const stream = cache.streamers[req.user.uuid]
|
||||||
res.render('dashboard.html', { stream: stream.key, server: 'rtmp://' + streamServerHost + '/live/' })
|
res.render('dashboard.html', { stream: stream.key, server: 'rtmp://' + streamServerHost + '/live/' })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
app.get('/dashboard/stats', authed, async (req, res) => {
|
app.get('/dashboard/stats', authed, async (req, res) => {
|
||||||
let stream = cache.streamers[req.user.uuid]
|
const stream = cache.streamers[req.user.uuid]
|
||||||
let data
|
let data
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -304,10 +304,10 @@ app.get('/dashboard/stats', authed, async (req, res) => {
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
app.get('/dashboard/data', authed, async (req, res) => {
|
app.get('/dashboard/data', authed, async (req, res) => {
|
||||||
let stream = cache.streamers[req.user.uuid]
|
const stream = cache.streamers[req.user.uuid]
|
||||||
let data
|
let data
|
||||||
|
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data = await db.get('SELECT * FROM channels WHERE key=?', stream.key)
|
data = await db.get('SELECT * FROM channels WHERE key=?', stream.key)
|
||||||
@ -318,30 +318,30 @@ app.get('/dashboard/data', authed, async (req, res) => {
|
|||||||
if (!data) return res.jsonp({ error: 'Unauthorized' })
|
if (!data) return res.jsonp({ error: 'Unauthorized' })
|
||||||
|
|
||||||
res.jsonp({
|
res.jsonp({
|
||||||
'name': data.name,
|
name: data.name,
|
||||||
'key': stream.key,
|
key: stream.key,
|
||||||
'uuid': req.user.uuid,
|
uuid: req.user.uuid,
|
||||||
'live': data.live_at != null,
|
live: data.live_at != null,
|
||||||
'live_at': new Date(parseInt(data.live_at)),
|
live_at: new Date(parseInt(data.live_at)),
|
||||||
'last_stream': new Date(parseInt(data.last_stream))
|
last_stream: new Date(parseInt(data.last_stream))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get links
|
// Get links
|
||||||
app.get('/dashboard/link', authed, async (req, res) => {
|
app.get('/dashboard/link', authed, async (req, res) => {
|
||||||
let user = req.user.uuid
|
const user = req.user.uuid
|
||||||
|
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
let links = await db.all('SELECT * FROM link WHERE uuid = ?', user)
|
const links = await db.all('SELECT * FROM link WHERE uuid = ?', user)
|
||||||
|
|
||||||
res.jsonp(links)
|
res.jsonp(links)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add link URL
|
// Add link URL
|
||||||
app.post('/dashboard/link', authed, async (req, res) => {
|
app.post('/dashboard/link', authed, async (req, res) => {
|
||||||
let user = req.user.uuid
|
const user = req.user.uuid
|
||||||
let name = req.body.name
|
const name = req.body.name
|
||||||
let url = req.body.url
|
const url = req.body.url
|
||||||
|
|
||||||
if (name == null || url == null) return res.jsonp({ error: 'Missing parameters!' })
|
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.' })
|
if (name.length > 120) return res.jsonp({ error: 'Only 120 characters are allowed in the name.' })
|
||||||
@ -350,15 +350,15 @@ app.post('/dashboard/link', authed, async (req, res) => {
|
|||||||
url.indexOf('<') !== -1 || url.indexOf('>') !== -1) return res.jsonp({ error: 'HTML tags are forbidden!' })
|
url.indexOf('<') !== -1 || url.indexOf('>') !== -1) return res.jsonp({ error: 'HTML tags are forbidden!' })
|
||||||
|
|
||||||
// Validate URL
|
// Validate URL
|
||||||
let a = URL.parse(url)
|
const a = new URL(url)
|
||||||
if (a.protocol === null || a.host === null || a.slashes !== true) return res.jsonp({ error: 'Invalid URL!' })
|
if (a.protocol === '' || a.host === '') return res.jsonp({ error: 'Invalid URL!' })
|
||||||
|
|
||||||
// Checks
|
// Checks
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
let links = await db.all('SELECT * FROM link WHERE uuid = ?', user)
|
const 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!' })
|
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)
|
const link = await db.get('SELECT * FROM link WHERE url = ? AND uuid = ?', url, user)
|
||||||
if (link) return res.jsonp({ error: 'This URL already exists!' })
|
if (link) return res.jsonp({ error: 'This URL already exists!' })
|
||||||
|
|
||||||
// Add
|
// Add
|
||||||
@ -368,12 +368,12 @@ app.post('/dashboard/link', authed, async (req, res) => {
|
|||||||
|
|
||||||
// Remove link URL
|
// Remove link URL
|
||||||
app.post('/dashboard/link/delete', authed, async (req, res) => {
|
app.post('/dashboard/link/delete', authed, async (req, res) => {
|
||||||
let user = req.user.uuid
|
const user = req.user.uuid
|
||||||
|
|
||||||
if (req.body.name == null && req.body.url == null) return res.jsonp({ error: 'Missing parameters!' })
|
if (req.body.name == null && req.body.url == null) return res.jsonp({ error: 'Missing parameters!' })
|
||||||
|
|
||||||
// Check
|
// Check
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
let link = await db.get('SELECT * FROM link WHERE url = ? AND uuid = ?', req.body.url, user)
|
let link = await db.get('SELECT * FROM link WHERE url = ? AND uuid = ?', req.body.url, user)
|
||||||
if (!link) {
|
if (!link) {
|
||||||
link = await db.get('SELECT * FROM link WHERE name = ? AND uuid = ?', req.body.name, user)
|
link = await db.get('SELECT * FROM link WHERE name = ? AND uuid = ?', req.body.name, user)
|
||||||
@ -397,11 +397,11 @@ app.get('/player/:name', (req, res) => {
|
|||||||
|
|
||||||
// Public data
|
// Public data
|
||||||
app.get('/api/channel/:name', async (req, res) => {
|
app.get('/api/channel/:name', async (req, res) => {
|
||||||
let name = req.params.name
|
const name = req.params.name
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
let 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 FROM channels WHERE name=?', name)
|
||||||
if (!data) return res.jsonp({ error: 'No such channel!' })
|
if (!data) return res.jsonp({ error: 'No such channel!' })
|
||||||
let 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)
|
||||||
|
|
||||||
delete data.user_uuid
|
delete data.user_uuid
|
||||||
data.live = data.live_at != null
|
data.live = data.live_at != null
|
||||||
@ -424,7 +424,7 @@ app.use((error, req, res, next) => {
|
|||||||
wss.on('connection', (ws, request, client) => {
|
wss.on('connection', (ws, request, client) => {
|
||||||
let userId = request.session.id
|
let userId = request.session.id
|
||||||
let username = 'A Friendly Guest'
|
let username = 'A Friendly Guest'
|
||||||
let myChannels = []
|
const myChannels = []
|
||||||
|
|
||||||
if (request.user) {
|
if (request.user) {
|
||||||
userId = request.user.uuid
|
userId = request.user.uuid
|
||||||
@ -434,8 +434,8 @@ wss.on('connection', (ws, request, client) => {
|
|||||||
dev && console.log(userId, 'connected')
|
dev && console.log(userId, 'connected')
|
||||||
ws.on('message', (msg) => {
|
ws.on('message', (msg) => {
|
||||||
dev && console.log(userId, 'said', msg)
|
dev && console.log(userId, 'said', msg)
|
||||||
let is = msg.toString().trim().split(' ')
|
const is = msg.toString().trim().split(' ')
|
||||||
let chan = is[1]
|
const chan = is[1]
|
||||||
if (!chan) return
|
if (!chan) return
|
||||||
switch (is[0]) {
|
switch (is[0]) {
|
||||||
case 'watch':
|
case 'watch':
|
||||||
@ -461,9 +461,9 @@ wss.on('connection', (ws, request, client) => {
|
|||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
dev && console.log(userId, 'disconnected')
|
dev && console.log(userId, 'disconnected')
|
||||||
for (let i in myChannels) {
|
for (const i in myChannels) {
|
||||||
let chan = myChannels[i]
|
const chan = myChannels[i]
|
||||||
let viewers = cache.viewers[chan]
|
const viewers = cache.viewers[chan]
|
||||||
if (viewers && viewers[userId]) delete cache.viewers[chan][userId]
|
if (viewers && viewers[userId]) delete cache.viewers[chan][userId]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -481,7 +481,7 @@ server.on('upgrade', (request, socket, head) => {
|
|||||||
request.user = request.session.passport.user
|
request.user = request.session.passport.user
|
||||||
}
|
}
|
||||||
|
|
||||||
wss.handleUpgrade(request, socket, head, function(ws) {
|
wss.handleUpgrade(request, socket, head, function (ws) {
|
||||||
wss.emit('connection', ws, request)
|
wss.emit('connection', ws, request)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -492,11 +492,15 @@ const host = dev ? '0.0.0.0' : '127.0.0.1'
|
|||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
// Get currently live channels, for example, when server restarted while someone was live
|
// Get currently live channels, for example, when server restarted while someone was live
|
||||||
(async function () {
|
(async function () {
|
||||||
let db = await dbPromise
|
const db = await dbPromise
|
||||||
let allLive = await db.all('SELECT name FROM channels WHERE live_at IS NOT NULL')
|
await db.migrate()
|
||||||
for (let i in allLive) {
|
|
||||||
|
const allLive = await db.all('SELECT name FROM channels WHERE live_at IS NOT NULL')
|
||||||
|
|
||||||
|
for (const i in allLive) {
|
||||||
cache.live.push(allLive[i].name)
|
cache.live.push(allLive[i].name)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`=> Found ${cache.live.length} channels still live`)
|
console.log(`=> Found ${cache.live.length} channels still live`)
|
||||||
})().catch(e => console.error(e.stack))
|
})().catch(e => console.error(e.stack))
|
||||||
|
|
||||||
|
5537
package-lock.json
generated
5537
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
43
package.json
43
package.json
@ -12,30 +12,31 @@
|
|||||||
"serve": "NODE_ENV=\"development\" node app.js"
|
"serve": "NODE_ENV=\"development\" node app.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bootstrap": "^4.3.1",
|
"bootstrap": "^4.5.0",
|
||||||
"copy-webpack-plugin": "^5.0.2",
|
"copy-webpack-plugin": "^6.0.1",
|
||||||
"file-loader": "^1.1.11",
|
"file-loader": "^6.0.0",
|
||||||
"hls.js": "^0.10.1",
|
"hls.js": "^0.13.2",
|
||||||
"jquery": "^3.4.1",
|
"jquery": "^3.5.1",
|
||||||
"popper.js": "^1.14.4",
|
"popper.js": "^1.16.1",
|
||||||
"webpack": "^4.16.4",
|
"webpack": "^4.43.0",
|
||||||
"webpack-command": "^0.4.1"
|
"webpack-command": "^0.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^1.18.3",
|
"body-parser": "^1.19.0",
|
||||||
"connect-redis": "^4.0.3",
|
"connect-redis": "^4.0.4",
|
||||||
"ejs": "^2.6.1",
|
"ejs": "^3.1.3",
|
||||||
"express": "^4.16.4",
|
"express": "^4.17.1",
|
||||||
"express-async-errors": "^3.1.1",
|
"express-async-errors": "^3.1.1",
|
||||||
"express-session": "^1.16.1",
|
"express-session": "^1.17.1",
|
||||||
"nunjucks": "^3.2.0",
|
"nunjucks": "^3.2.1",
|
||||||
"passport": "^0.4.0",
|
"passport": "^0.4.1",
|
||||||
"redis": "^2.8.0",
|
"redis": "^3.0.2",
|
||||||
"request": "^2.88.0",
|
"request": "^2.88.2",
|
||||||
"sqlite": "^3.0.3",
|
"sqlite": "^4.0.9",
|
||||||
|
"sqlite3": "^4.2.0",
|
||||||
"toml": "^3.0.0",
|
"toml": "^3.0.0",
|
||||||
"uuid": "^3.3.2",
|
"uuid": "^8.1.0",
|
||||||
"ws": "^7.2.0",
|
"ws": "^7.3.0",
|
||||||
"xml2js": "^0.4.22"
|
"xml2js": "^0.4.23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,21 +3,21 @@ import $ from 'jquery'
|
|||||||
// https://stackoverflow.com/a/18650828
|
// https://stackoverflow.com/a/18650828
|
||||||
function formatBytes (a, b) {
|
function formatBytes (a, b) {
|
||||||
if (a === 0) return '0 Bytes'
|
if (a === 0) return '0 Bytes'
|
||||||
let c = 1024
|
const c = 1024
|
||||||
let d = b || 2
|
const d = b || 2
|
||||||
let e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
const e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
let f = Math.floor(Math.log(a) / Math.log(c))
|
const f = Math.floor(Math.log(a) / Math.log(c))
|
||||||
return parseFloat((a / Math.pow(c, f)).toFixed(d)) + ' ' + e[f]
|
return parseFloat((a / Math.pow(c, f)).toFixed(d)) + ' ' + e[f]
|
||||||
}
|
}
|
||||||
|
|
||||||
function recursiveStats (table, subtable) {
|
function recursiveStats (table, subtable) {
|
||||||
for (let key in table) {
|
for (const key in table) {
|
||||||
let val = table[key]
|
let val = table[key]
|
||||||
if (typeof val === 'object') {
|
if (typeof val === 'object') {
|
||||||
recursiveStats(val, key)
|
recursiveStats(val, key)
|
||||||
} else {
|
} else {
|
||||||
if (key === 'time') {
|
if (key === 'time') {
|
||||||
let date = new Date(null)
|
const date = new Date(null)
|
||||||
date.setSeconds(Math.floor(parseInt(val) / 1000)) // specify value for SECONDS here
|
date.setSeconds(Math.floor(parseInt(val) / 1000)) // specify value for SECONDS here
|
||||||
val = date.toISOString().substr(11, 8)
|
val = date.toISOString().substr(11, 8)
|
||||||
} else if (key.indexOf('bytes') !== -1) {
|
} else if (key.indexOf('bytes') !== -1) {
|
||||||
@ -35,17 +35,17 @@ function updateLinkList (k) {
|
|||||||
$('#link-list').html('<tbody></tbody>')
|
$('#link-list').html('<tbody></tbody>')
|
||||||
$('#link-list tbody').append('<tr><th>Name</th><th>URL</th><th>Action</th></tr>')
|
$('#link-list tbody').append('<tr><th>Name</th><th>URL</th><th>Action</th></tr>')
|
||||||
|
|
||||||
for (let i in res) {
|
for (const i in res) {
|
||||||
let p = res[i]
|
const p = res[i]
|
||||||
p.name = p.name.replace(/\</g, '<').replace(/\>/g, '>')
|
p.name = p.name.replace(/\</g, '<').replace(/\>/g, '>')
|
||||||
$('#link-list tbody').append('<tr data-url="' + p.url + '"><td>' + p.name +'</td><td>' +
|
$('#link-list tbody').append('<tr data-url="' + p.url + '"><td>' + p.name + '</td><td>' +
|
||||||
'<a href="' + p.url +'" target="_blank" rel="nofollow">' +
|
'<a href="' + p.url + '" target="_blank" rel="nofollow">' +
|
||||||
p.url + '</a></td><td><a href="#" class="delete-link">Remove</a></td></tr>')
|
p.url + '</a></td><td><a href="#" class="delete-link">Remove</a></td></tr>')
|
||||||
}
|
}
|
||||||
|
|
||||||
$('.delete-link').click(function (e) {
|
$('.delete-link').click(function (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
let pr = $(this).parent().parent().attr('data-url')
|
const pr = $(this).parent().parent().attr('data-url')
|
||||||
$.post('/dashboard/link/delete', { url: pr }, updateLinkList)
|
$.post('/dashboard/link/delete', { url: pr }, updateLinkList)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -58,9 +58,11 @@ function dashboard (k) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let fullURL = window.location.origin + '/watch/' + res.name
|
const fullURL = window.location.origin + '/watch/' + res.name
|
||||||
|
const sourceURL = window.location.origin + '/live/' + res.name + '.m3u8'
|
||||||
$('#myStream').attr('src', fullURL)
|
$('#myStream').attr('src', fullURL)
|
||||||
$('#stream_url').text(fullURL).attr('href', fullURL)
|
$('#stream_url').text(fullURL).attr('href', fullURL)
|
||||||
|
$('#source_url').text(sourceURL).attr('href', sourceURL)
|
||||||
$('#stream_live').text(res.live ? 'Yes' : 'No')
|
$('#stream_live').text(res.live ? 'Yes' : 'No')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -70,7 +72,7 @@ function dashboard (k) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
$('.go-page').click(function (e) {
|
$('.go-page').click(function (e) {
|
||||||
let el = $(this)
|
const el = $(this)
|
||||||
$('.go-page').removeClass('active')
|
$('.go-page').removeClass('active')
|
||||||
el.addClass('active')
|
el.addClass('active')
|
||||||
$('.page:visible').fadeOut(function () {
|
$('.page:visible').fadeOut(function () {
|
||||||
@ -80,8 +82,8 @@ function dashboard (k) {
|
|||||||
|
|
||||||
$('#add-link').submit(function (e) {
|
$('#add-link').submit(function (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
let name = $('input[name="name"]').val()
|
const name = $('input[name="name"]').val()
|
||||||
let url = $('input[name="url"]').val()
|
const url = $('input[name="url"]').val()
|
||||||
|
|
||||||
if (name.length > 120) return alert('Only 120 characters are allowed in the name.')
|
if (name.length > 120) return alert('Only 120 characters are allowed in the name.')
|
||||||
|
|
||||||
|
@ -2,21 +2,21 @@
|
|||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
|
|
||||||
// Elements
|
// Elements
|
||||||
let player = document.querySelector('.livecnt')
|
const player = document.querySelector('.livecnt')
|
||||||
let vid = player.querySelector('#stream')
|
const vid = player.querySelector('#stream')
|
||||||
let overlay = player.querySelector('.overlay')
|
const overlay = player.querySelector('.overlay')
|
||||||
let btn = overlay.querySelector('#playbtn')
|
const btn = overlay.querySelector('#playbtn')
|
||||||
let time = overlay.querySelector('#duration')
|
const time = overlay.querySelector('#duration')
|
||||||
let fullscreenbtn = overlay.querySelector('#fullscrbtn')
|
const fullscreenbtn = overlay.querySelector('#fullscrbtn')
|
||||||
let playbtn = overlay.querySelector('#playbtn')
|
const playbtn = overlay.querySelector('#playbtn')
|
||||||
let mutebtn = overlay.querySelector('#mutebtn')
|
const mutebtn = overlay.querySelector('#mutebtn')
|
||||||
let lstat = overlay.querySelector('.live')
|
const lstat = overlay.querySelector('.live')
|
||||||
let opts = overlay.querySelector('.controls')
|
const opts = overlay.querySelector('.controls')
|
||||||
let bigbtn = overlay.querySelector('.bigplaybtn')
|
const bigbtn = overlay.querySelector('.bigplaybtn')
|
||||||
let volumebar = overlay.querySelector('#volume_seek')
|
const volumebar = overlay.querySelector('#volume_seek')
|
||||||
let volumeseek = volumebar.querySelector('.seeker')
|
const volumeseek = volumebar.querySelector('.seeker')
|
||||||
let volumeseekInner = volumeseek.querySelector('.seekbar')
|
const volumeseekInner = volumeseek.querySelector('.seekbar')
|
||||||
let viewers = overlay.querySelector('.viewers')
|
const viewers = overlay.querySelector('.viewers')
|
||||||
let links
|
let links
|
||||||
let linksList
|
let linksList
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ function GET (url, istext) {
|
|||||||
if (xmlHttp.readyState === 4 && xmlHttp.status === 200) {
|
if (xmlHttp.readyState === 4 && xmlHttp.status === 200) {
|
||||||
resolve(xmlHttp.responseText)
|
resolve(xmlHttp.responseText)
|
||||||
} else if (xmlHttp.readyState === 4 && xmlHttp.status >= 400) {
|
} else if (xmlHttp.readyState === 4 && xmlHttp.status >= 400) {
|
||||||
let err = new Error(xmlHttp.status)
|
const err = new Error(xmlHttp.status)
|
||||||
err.request = xmlHttp
|
err.request = xmlHttp
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
@ -93,21 +93,21 @@ function handleWebSocket () {
|
|||||||
if (!live || ws) return
|
if (!live || ws) return
|
||||||
|
|
||||||
ws = new WebSocket(`ws${location.protocol.indexOf('s') !== -1 ? 's' : ''}://${location.host}`)
|
ws = new WebSocket(`ws${location.protocol.indexOf('s') !== -1 ? 's' : ''}://${location.host}`)
|
||||||
ws.onerror = function(e) {
|
ws.onerror = function (e) {
|
||||||
console.error('Socket errored, retrying..', e)
|
console.error('Socket errored, retrying..', e)
|
||||||
ws.close()
|
ws.close()
|
||||||
ws = null
|
ws = null
|
||||||
setTimeout(() => handleWebSocket(), 5000)
|
setTimeout(() => handleWebSocket(), 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onopen = function() {
|
ws.onopen = function () {
|
||||||
console.log('Upstream socket connection established')
|
console.log('Upstream socket connection established')
|
||||||
if (!vid.paused) ws.send('watch ' + STREAM_NAME)
|
if (!vid.paused) ws.send('watch ' + STREAM_NAME)
|
||||||
ws.onmessage = function (event) {
|
ws.onmessage = function (event) {
|
||||||
if (!event) return
|
if (!event) return
|
||||||
let message = event.data
|
const message = event.data
|
||||||
if (message.indexOf('viewlist ') === 0) {
|
if (message.indexOf('viewlist ') === 0) {
|
||||||
let str = message.substring(9)
|
const str = message.substring(9)
|
||||||
let list = str.split(',')
|
let list = str.split(',')
|
||||||
if (str === '') list = []
|
if (str === '') list = []
|
||||||
viewersCount(list)
|
viewersCount(list)
|
||||||
@ -115,7 +115,7 @@ function handleWebSocket () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = function() {
|
ws.onclose = function () {
|
||||||
console.error('Socket died, retrying..')
|
console.error('Socket died, retrying..')
|
||||||
ws = null
|
ws = null
|
||||||
setTimeout(() => handleWebSocket(), 5000)
|
setTimeout(() => handleWebSocket(), 5000)
|
||||||
@ -160,8 +160,8 @@ function resetHide () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateTime () {
|
function updateTime () {
|
||||||
let minutes = Math.floor(vid.currentTime / 60)
|
const minutes = Math.floor(vid.currentTime / 60)
|
||||||
let seconds = Math.floor(vid.currentTime - minutes * 60)
|
const seconds = Math.floor(vid.currentTime - minutes * 60)
|
||||||
time.innerHTML = minutes + ':' + (seconds < 10 ? '0' + seconds : seconds)
|
time.innerHTML = minutes + ':' + (seconds < 10 ? '0' + seconds : seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,7 +178,7 @@ function toggleStream () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleSound () {
|
function toggleSound () {
|
||||||
let muteicon = mutebtn.querySelector('.fa')
|
const muteicon = mutebtn.querySelector('.fa')
|
||||||
if (vid.muted) {
|
if (vid.muted) {
|
||||||
vid.muted = false
|
vid.muted = false
|
||||||
muteicon.className = 'fa fa-volume-up fa-fw'
|
muteicon.className = 'fa fa-volume-up fa-fw'
|
||||||
@ -270,11 +270,11 @@ volumeseek.addEventListener('click', (e) => {
|
|||||||
updateVolume()
|
updateVolume()
|
||||||
})
|
})
|
||||||
|
|
||||||
let mousewheelevt = (/Firefox/i.test(navigator.userAgent)) ? 'DOMMouseScroll' : 'mousewheel'
|
const mousewheelevt = (/Firefox/i.test(navigator.userAgent)) ? 'DOMMouseScroll' : 'mousewheel'
|
||||||
|
|
||||||
volumebar.addEventListener(mousewheelevt, (e) => {
|
volumebar.addEventListener(mousewheelevt, (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
let scrollAmnt = (e.wheelDelta == null ? e.detail * -40 : e.wheelDelta)
|
const scrollAmnt = (e.wheelDelta == null ? e.detail * -40 : e.wheelDelta)
|
||||||
|
|
||||||
if (scrollAmnt < 0) {
|
if (scrollAmnt < 0) {
|
||||||
vid.volume = clampAddition(-0.1)
|
vid.volume = clampAddition(-0.1)
|
||||||
@ -299,7 +299,7 @@ if (Hls.isSupported()) {
|
|||||||
hls.stopLoad()
|
hls.stopLoad()
|
||||||
clearTimeout(retryTimeout)
|
clearTimeout(retryTimeout)
|
||||||
})
|
})
|
||||||
hls.on(Hls.Events.ERROR, (e,d) => {
|
hls.on(Hls.Events.ERROR, (e, d) => {
|
||||||
if (!d.fatal) return // Don't attempt to recover the stream when a non-fatal error occurs
|
if (!d.fatal) return // Don't attempt to recover the stream when a non-fatal error occurs
|
||||||
vidReady = false
|
vidReady = false
|
||||||
|
|
||||||
@ -318,15 +318,15 @@ if (Hls.isSupported()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// helper function to get an element's exact position
|
// helper function to get an element's exact position
|
||||||
function getPosition(el) {
|
function getPosition (el) {
|
||||||
let xPosition = 0
|
let xPosition = 0
|
||||||
let yPosition = 0
|
let yPosition = 0
|
||||||
|
|
||||||
while (el) {
|
while (el) {
|
||||||
if (el.tagName == 'BODY') {
|
if (el.tagName == 'BODY') {
|
||||||
// deal with browser quirks with body/window/document and page scroll
|
// deal with browser quirks with body/window/document and page scroll
|
||||||
let xScrollPos = el.scrollLeft || document.documentElement.scrollLeft
|
const xScrollPos = el.scrollLeft || document.documentElement.scrollLeft
|
||||||
let yScrollPos = el.scrollTop || document.documentElement.scrollTop
|
const yScrollPos = el.scrollTop || document.documentElement.scrollTop
|
||||||
|
|
||||||
xPosition += (el.offsetLeft - xScrollPos + el.clientLeft)
|
xPosition += (el.offsetLeft - xScrollPos + el.clientLeft)
|
||||||
yPosition += (el.offsetTop - yScrollPos + el.clientTop)
|
yPosition += (el.offsetTop - yScrollPos + el.clientTop)
|
||||||
@ -349,8 +349,8 @@ function hideOnClickOutside (element) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const outsideClickListener = event => {
|
const outsideClickListener = event => {
|
||||||
if ((!element.contains(event.target) && isVisible(element))
|
if ((!element.contains(event.target) && isVisible(element)) &&
|
||||||
&& (event.target !== links && !links.contains(event.target))) {
|
(event.target !== links && !links.contains(event.target))) {
|
||||||
element.style.display = 'none'
|
element.style.display = 'none'
|
||||||
removeClickListener()
|
removeClickListener()
|
||||||
}
|
}
|
||||||
@ -390,7 +390,7 @@ function updateLinks (srcs) {
|
|||||||
|
|
||||||
hideOnClickOutside(linksList)
|
hideOnClickOutside(linksList)
|
||||||
|
|
||||||
let pos = getPosition(links)
|
const pos = getPosition(links)
|
||||||
linksList.style = 'display: block;'
|
linksList.style = 'display: block;'
|
||||||
pos.x -= linksList.offsetWidth - links.offsetWidth / 2
|
pos.x -= linksList.offsetWidth - links.offsetWidth / 2
|
||||||
pos.y -= linksList.offsetHeight
|
pos.y -= linksList.offsetHeight
|
||||||
@ -401,9 +401,9 @@ function updateLinks (srcs) {
|
|||||||
links.style.display = 'block'
|
links.style.display = 'block'
|
||||||
linksList.innerHTML = ''
|
linksList.innerHTML = ''
|
||||||
|
|
||||||
for (let i in srcs) {
|
for (const i in srcs) {
|
||||||
let link = srcs[i]
|
const link = srcs[i]
|
||||||
let el = document.createElement('a')
|
const el = document.createElement('a')
|
||||||
el.href = link.url
|
el.href = link.url
|
||||||
el.innerText = link.name
|
el.innerText = link.name
|
||||||
el.target = '_blank'
|
el.target = '_blank'
|
||||||
@ -413,7 +413,7 @@ function updateLinks (srcs) {
|
|||||||
|
|
||||||
function getStreamStatus () {
|
function getStreamStatus () {
|
||||||
GET('/api/channel/' + STREAM_NAME).then((data) => {
|
GET('/api/channel/' + STREAM_NAME).then((data) => {
|
||||||
let jd = JSON.parse(data)
|
const jd = JSON.parse(data)
|
||||||
if (jd.error) {
|
if (jd.error) {
|
||||||
errored = true
|
errored = true
|
||||||
return alert(jd.error)
|
return alert(jd.error)
|
||||||
|
@ -78,12 +78,21 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<label>Stream Link</label>
|
<label>Stream URL</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<a href="" id="stream_url"></a>
|
<a href="" id="stream_url"></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-2">
|
||||||
|
<label>Source URL</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<a href="" id="source_url"></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 class="h2">Metrics</h1>
|
<h1 class="h2">Metrics</h1>
|
||||||
|
@ -14,8 +14,8 @@ module.exports = {
|
|||||||
rules: []
|
rules: []
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new CopyPlugin([
|
new CopyPlugin({
|
||||||
{
|
patterns: [{
|
||||||
from: 'src/css/*.css',
|
from: 'src/css/*.css',
|
||||||
to: 'css',
|
to: 'css',
|
||||||
flatten: true
|
flatten: true
|
||||||
@ -24,7 +24,7 @@ module.exports = {
|
|||||||
from: 'node_modules/bootstrap/dist/css/bootstrap.min.css',
|
from: 'node_modules/bootstrap/dist/css/bootstrap.min.css',
|
||||||
to: 'css',
|
to: 'css',
|
||||||
flatten: true
|
flatten: true
|
||||||
}
|
}]
|
||||||
]),
|
})
|
||||||
],
|
]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user